diff --git a/apps/cli-e2e/src/tests/database-core.e2e.test.ts b/apps/cli-e2e/src/tests/database-core.e2e.test.ts index 5f936e3bad..4ba33c1f79 100644 --- a/apps/cli-e2e/src/tests/database-core.e2e.test.ts +++ b/apps/cli-e2e/src/tests/database-core.e2e.test.ts @@ -279,7 +279,24 @@ describe("db pull", () => { expect(result.stderr).toContain("connect"); }); - testParity(["db", "pull", "--local"]); + // No testParity for `db pull --local`: like `db lint --local` and `test db --local`, + // pull connects via the shared utils.ConnectByConfig → pgxv5.Connect path on Go and + // the same LegacyDbConnection sql-pg layer on TS. With no local Postgres listening in + // the harness, the only reachable path is the connection-failure path, and its stderr + // diverges by driver in ways that aren't cosmetic and can't be normalized away. + // Both emit Go's leading diagnostic to stderr: + // Connecting to local database... + // but the connect-error body and trailing hint still differ by driver. Go (pgx): + // failed to connect to postgres: failed to connect to `host=… user=… database=…`: dial error (dial tcp …: connect: connection refused) + // Make sure your local IP is allowed in Network Restrictions and Network Bans. + // http://…/project/_/database/settings + // The TS port (@effect/sql-pg) prints the effect SqlError and the --debug hint: + // failed to connect to postgres: effect/sql/SqlError: PgClient: Failed to connect + // Try rerunning the command with --debug to troubleshoot the error. + // The meaningful contract (non-zero exit + a connect error on stderr) is covered by + // the behaviour test above. A real connect-path parity test would need a live local + // database in the harness. (db dump --local keeps its testParity because it connects + // through the pg_dump Docker container, so its stderr matches on both runtimes.) }); // --------------------------------------------------------------------------- diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index da22dee063..3f8d3d82a8 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -197,6 +197,76 @@ 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", @@ -475,6 +545,14 @@ 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/db_schema_declarative.go b/apps/cli-go/cmd/db_schema_declarative.go index 3b9ab95e6a..672455ca8c 100644 --- a/apps/cli-go/cmd/db_schema_declarative.go +++ b/apps/cli-go/cmd/db_schema_declarative.go @@ -47,6 +47,14 @@ var ( Use: "declarative", Short: "Manage declarative database schemas", PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // The hidden __catalog seam forwards the resolved linked ref via + // --project-ref so the catalog is built from the remote-merged config. + // Seed flags.ProjectRef before LoadConfig (which keys the [remotes.] + // merge off Config.ProjectId = flags.ProjectRef); this command never runs + // LoadProjectRef, so SUPABASE_PROJECT_ID env alone would not merge. + if len(pgdeltaCatalogProjectRef) > 0 { + flags.ProjectRef = pgdeltaCatalogProjectRef + } if err := flags.LoadConfig(afero.NewOsFs()); err != nil { return err } diff --git a/apps/cli-go/cmd/pgdelta_catalog.go b/apps/cli-go/cmd/pgdelta_catalog.go index 0e94234da6..9e1f49b957 100644 --- a/apps/cli-go/cmd/pgdelta_catalog.go +++ b/apps/cli-go/cmd/pgdelta_catalog.go @@ -11,6 +11,13 @@ import ( // pgdeltaCatalogMode selects which catalog the hidden seam command produces. var pgdeltaCatalogMode string +// pgdeltaCatalogProjectRef is the resolved linked project ref, forwarded by the +// native-TypeScript seam so the catalog is built from the remote-merged config. +// The declarative group's PersistentPreRunE seeds flags.ProjectRef from it before +// LoadConfig (this command never runs LoadProjectRef, so SUPABASE_PROJECT_ID env +// alone would not trigger the [remotes.] merge). +var pgdeltaCatalogProjectRef string + // dbDeclarativeCatalogCmd is a hidden seam used by the native-TypeScript // declarative commands to provision a shadow-database platform baseline (and, // for migrations/declarative modes, apply migrations / declarative files) and @@ -34,5 +41,6 @@ var dbDeclarativeCatalogCmd = &cobra.Command{ func init() { dbDeclarativeCatalogCmd.Flags().StringVar(&pgdeltaCatalogMode, "mode", "", "Catalog mode: baseline, migrations, or declarative.") + dbDeclarativeCatalogCmd.Flags().StringVar(&pgdeltaCatalogProjectRef, "project-ref", "", "Linked project ref, so the catalog merges the matching [remotes.] config override.") dbDeclarativeCmd.AddCommand(dbDeclarativeCatalogCmd) } diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index c3bd6485a2..aa286eea5b 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -22,7 +22,6 @@ import ( "github.com/jackc/pgx/v4" "github.com/spf13/afero" "github.com/supabase/cli/internal/db/start" - "github.com/supabase/cli/internal/pgdelta" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/migration" "github.com/supabase/cli/pkg/parser" @@ -188,47 +187,14 @@ func MigrateShadowDatabase(ctx context.Context, container string, fsys afero.Fs, func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w io.Writer, fsys afero.Fs, differ DiffFunc, usePgDelta bool, options ...func(*pgx.ConnConfig)) (DatabaseDiff, error) { fmt.Fprintln(w, "Creating shadow database...") - shadow, err := CreateShadowDatabase(ctx, utils.Config.Db.ShadowPort) + shadowSource, err := PrepareShadowSource(ctx, schema, utils.IsLocalDatabase(config), usePgDelta, fsys, options...) if err != nil { return DatabaseDiff{}, err } - defer utils.DockerRemove(shadow) - if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, shadow); err != nil { - return DatabaseDiff{}, err - } - if err := MigrateShadowDatabase(ctx, shadow, fsys, options...); err != nil { - return DatabaseDiff{}, err - } - shadowConfig := pgconn.Config{ - Host: utils.Config.Hostname, - Port: utils.Config.Db.ShadowPort, - User: "postgres", - Password: utils.Config.Db.Password, - Database: "postgres", - } - if utils.IsLocalDatabase(config) { - if declared, err := loadDeclaredSchemas(fsys); len(declared) > 0 { - config = shadowConfig - config.Database = "contrib_regression" - if usePgDelta { - declDir := utils.GetDeclarativeDir() - if exists, _ := afero.DirExists(fsys, declDir); exists { - if err := pgdelta.ApplyDeclarative(ctx, config, fsys); err != nil { - return DatabaseDiff{}, err - } - } else { - if err := migrateBaseDatabase(ctx, config, declared, fsys, options...); err != nil { - return DatabaseDiff{}, err - } - } - } else { - if err := migrateBaseDatabase(ctx, config, declared, fsys, options...); err != nil { - return DatabaseDiff{}, err - } - } - } else if err != nil { - return DatabaseDiff{}, err - } + defer utils.DockerRemove(shadowSource.Container) + shadowConfig := shadowSource.Source + if shadowSource.TargetOverride != nil { + config = *shadowSource.TargetOverride } // Load all user defined schemas if len(schema) > 0 { diff --git a/apps/cli-go/internal/db/diff/shadow.go b/apps/cli-go/internal/db/diff/shadow.go new file mode 100644 index 0000000000..8012c565e8 --- /dev/null +++ b/apps/cli-go/internal/db/diff/shadow.go @@ -0,0 +1,116 @@ +package diff + +import ( + "context" + + "github.com/jackc/pgconn" + "github.com/jackc/pgx/v4" + "github.com/spf13/afero" + "github.com/supabase/cli/internal/db/start" + "github.com/supabase/cli/internal/pgdelta" + "github.com/supabase/cli/internal/utils" +) + +// ShadowSource is a provisioned shadow database, left running for an external +// caller (the native-TypeScript db diff/pull commands) to diff against and then +// remove. It mirrors the shadow that DiffDatabase prepares as the diff "source". +type ShadowSource struct { + // Container is the shadow database container id; the caller MUST remove it + // (e.g. `docker rm -f `) when the diff completes. + Container string + // Source is the connection config for the diff source (the shadow with the + // platform baseline + local migrations applied). + Source pgconn.Config + // TargetOverride, when non-nil, replaces the diff target with a second shadow + // database (contrib_regression with declarative schemas applied). Mirrors + // DiffDatabase's local-target declarative branch, where the user's local + // database is not diffed at all. + TargetOverride *pgconn.Config +} + +// PrepareShadowSource provisions the shadow database that DiffDatabase diffs +// against, but returns it running instead of diffing + removing, so a native +// caller can run the differ itself. targetLocal mirrors +// utils.IsLocalDatabase(config) — the only target-derived input the shadow prep +// needs. usePgDelta selects the declarative-apply engine for the local-declared +// branch, matching DiffDatabase. On error the shadow container is removed. +func PrepareShadowSource(ctx context.Context, schema []string, targetLocal bool, usePgDelta bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (ShadowSource, error) { + shadow, err := CreateShadowDatabase(ctx, utils.Config.Db.ShadowPort) + if err != nil { + return ShadowSource{}, err + } + ok := false + defer func() { + if !ok { + utils.DockerRemove(shadow) + } + }() + if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, shadow); err != nil { + return ShadowSource{}, err + } + if err := MigrateShadowDatabase(ctx, shadow, fsys, options...); err != nil { + return ShadowSource{}, err + } + shadowConfig := pgconn.Config{ + Host: utils.Config.Hostname, + Port: utils.Config.Db.ShadowPort, + User: "postgres", + Password: utils.Config.Db.Password, + Database: "postgres", + } + var targetOverride *pgconn.Config + if targetLocal { + declared, err := loadDeclaredSchemas(fsys) + if err != nil { + return ShadowSource{}, err + } + if len(declared) > 0 { + override := shadowConfig + override.Database = "contrib_regression" + if usePgDelta { + declDir := utils.GetDeclarativeDir() + if exists, _ := afero.DirExists(fsys, declDir); exists { + if err := pgdelta.ApplyDeclarative(ctx, override, fsys); err != nil { + return ShadowSource{}, err + } + } else { + if err := migrateBaseDatabase(ctx, override, declared, fsys, options...); err != nil { + return ShadowSource{}, err + } + } + } else { + if err := migrateBaseDatabase(ctx, override, declared, fsys, options...); err != nil { + return ShadowSource{}, err + } + } + targetOverride = &override + } + } + ok = true + return ShadowSource{Container: shadow, Source: shadowConfig, TargetOverride: targetOverride}, nil +} + +// PrepareRawShadow provisions a bare shadow database (created + healthy, with no +// platform baseline or migrations applied), left running for an external caller. +// Mirrors the shadow that pull.pullDeclarativePgDelta uses as the empty +// declarative-export source. On error the shadow container is removed. +func PrepareRawShadow(ctx context.Context) (ShadowSource, error) { + shadow, err := CreateShadowDatabase(ctx, utils.Config.Db.ShadowPort) + if err != nil { + return ShadowSource{}, err + } + if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, shadow); err != nil { + utils.DockerRemove(shadow) + return ShadowSource{}, err + } + return ShadowSource{ + Container: shadow, + Source: pgconn.Config{ + Host: utils.Config.Hostname, + Port: utils.Config.Db.ShadowPort, + User: "postgres", + Password: utils.Config.Db.Password, + Database: "postgres", + }, + }, nil +} diff --git a/apps/cli-go/internal/db/pull/pull.go b/apps/cli-go/internal/db/pull/pull.go index 3905c7ff9c..07503687a7 100644 --- a/apps/cli-go/internal/db/pull/pull.go +++ b/apps/cli-go/internal/db/pull/pull.go @@ -20,7 +20,6 @@ import ( "github.com/supabase/cli/internal/db/declarative" "github.com/supabase/cli/internal/db/diff" "github.com/supabase/cli/internal/db/dump" - "github.com/supabase/cli/internal/db/start" "github.com/supabase/cli/internal/migration/format" "github.com/supabase/cli/internal/migration/list" "github.com/supabase/cli/internal/migration/new" @@ -86,21 +85,12 @@ func Run(ctx context.Context, schema []string, config pgconn.Config, name string // timestamped migration files. func pullDeclarativePgDelta(ctx context.Context, schema []string, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { fmt.Fprintln(os.Stderr, "Preparing declarative schema export using pg-delta...") - shadow, err := diff.CreateShadowDatabase(ctx, utils.Config.Db.ShadowPort) + shadowSource, err := diff.PrepareRawShadow(ctx) if err != nil { return err } - defer utils.DockerRemove(shadow) - if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, shadow); err != nil { - return err - } - shadowConfig := pgconn.Config{ - Host: utils.Config.Hostname, - Port: utils.Config.Db.ShadowPort, - User: "postgres", - Password: utils.Config.Db.Password, - Database: "postgres", - } + defer utils.DockerRemove(shadowSource.Container) + shadowConfig := shadowSource.Source formatOptions := "" if utils.Config.Experimental.PgDelta != nil { formatOptions = strings.TrimSpace(utils.Config.Experimental.PgDelta.FormatOptions) diff --git a/apps/cli-go/internal/utils/connect.go b/apps/cli-go/internal/utils/connect.go index 0653672342..e19aaaf523 100644 --- a/apps/cli-go/internal/utils/connect.go +++ b/apps/cli-go/internal/utils/connect.go @@ -23,6 +23,21 @@ import ( ) 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 { timeoutSecond = 10 @@ -38,7 +53,7 @@ func ToPostgresURL(config pgconn.Config) string { } return fmt.Sprintf( "postgresql://%s@%s:%d/%s?%s", - url.UserPassword(config.User, config.Password), + userinfo, host, config.Port, url.PathEscape(config.Database), diff --git a/apps/cli-go/internal/utils/connect_test.go b/apps/cli-go/internal/utils/connect_test.go index bd1eee66d3..a7b772d70f 100644 --- a/apps/cli-go/internal/utils/connect_test.go +++ b/apps/cli-go/internal/utils/connect_test.go @@ -394,3 +394,20 @@ 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") +} diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 34e67cc4b2..594e26d2c8 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -19,7 +19,7 @@ Percentages and counts below are based on final leaf commands only. Command grou | Metric | Count | Percent | | ------------------------- | ------: | ------: | -| Fully ported commands | 8 / 94 | 8.5% | +| Fully ported commands | 10 / 94 | 10.6% | | Partially ported commands | 55 / 94 | 58.5% | ## Family Summary @@ -28,7 +28,7 @@ Percentages and counts below are based on final leaf commands only. Command grou | ------------------------- | -------------: | --------: | --------: | ---------: | ----------------: | | 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 | 2 (10.5%) | 0 (0%) | 17 (89.5%) | 2 (10.5%) | +| Database | 19 | 4 (21.1%) | 0 (0%) | 15 (78.9%) | 4 (21.1%) | | Code Generation | 3 | 0 (0%) | 0 (0%) | 3 (100%) | 0 (0%) | | Functions | 6 | 0 (0%) | 0 (0%) | 6 (100%) | 0 (0%) | | Storage | 4 | 0 (0%) | 0 (0%) | 4 (100%) | 0 (0%) | @@ -82,10 +82,10 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | | --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `db diff` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `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. | | `db dump` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | | `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` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `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`. `--experimental` dump + initial-pull `pg_dump` (migra) delegate to the Go binary. | | `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | | `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | | `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | @@ -211,108 +211,108 @@ Legend: - `wrapped`: Phase 0 proxy wrapper exists in the legacy shell - `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) (native; `db push` step delegated to the Go binary — interim) | -| `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` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | -| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | -| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | -| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | -| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | -| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | -| `migration list` | `wrapped` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) | -| `migration new` | `wrapped` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) | -| `migration repair` | `wrapped` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) | -| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | -| `migration up` | `wrapped` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) | -| `migration down` | `wrapped` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) | -| `migration fetch` | `wrapped` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) | -| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | -| `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | -| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | -| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | -| `functions list` | `wrapped` | [`../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) | -| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | -| `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | -| `functions serve` | `wrapped` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | -| `storage ls` | `wrapped` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | -| `storage cp` | `wrapped` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | -| `storage mv` | `wrapped` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | -| `storage rm` | `wrapped` | [`../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` | `wrapped` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `wrapped` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) | -| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | -| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | -| `db pull` | `wrapped` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — includes `--declarative` (deprecated alias `--use-pg-delta`) and `--diff-engine` (migra\|pg-delta, mutually exclusive with `--declarative`) | -| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) | -| `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | -| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | -| `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | -| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | -| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | -| `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) (native; `db push` step delegated to the Go binary — interim) | +| `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` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | +| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | +| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | +| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | +| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | +| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | +| `migration list` | `wrapped` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) | +| `migration new` | `wrapped` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) | +| `migration repair` | `wrapped` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) | +| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | +| `migration up` | `wrapped` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) | +| `migration down` | `wrapped` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) | +| `migration fetch` | `wrapped` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) | +| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | +| `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | +| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | +| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | +| `functions list` | `wrapped` | [`../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) | +| `functions deploy` | `ported` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | +| `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | +| `functions serve` | `wrapped` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | +| `storage ls` | `wrapped` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | +| `storage cp` | `wrapped` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | +| `storage mv` | `wrapped` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | +| `storage rm` | `wrapped` | [`../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` | `wrapped` | [`../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 | +| `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | +| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | +| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); `--experimental` / initial `pg_dump` delegate to Go | +| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) | +| `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | +| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | +| `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | +| `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | +| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | +| `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) | diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts index b7e31fdafd..323d2600f8 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts @@ -144,6 +144,7 @@ function setup(opts: SetupOpts = {}) { Effect.sync(() => { proxyCalls.push({ args, env: execOpts?.env }); }), + execCapture: () => Effect.succeed(""), }); const loginApi = mockLegacyLoginApi({ gotrueId: "gotrue-user" }); diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index 2905edb629..479d87619a 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -100,7 +100,10 @@ describe("legacy bootstrap linked-project cache location", () => { }; const api = mockLegacyPlatformApi({ handler }); - const proxyLayer = Layer.succeed(LegacyGoProxy, { exec: () => Effect.void }); + const proxyLayer = Layer.succeed(LegacyGoProxy, { + exec: () => Effect.void, + execCapture: () => Effect.succeed(""), + }); const templateLayer = Layer.succeed(LegacyTemplateService, { listSamples: Effect.succeed([]), download: () => Effect.void, diff --git a/apps/cli/src/legacy/commands/branches/branches.prompt.ts b/apps/cli/src/legacy/commands/branches/branches.prompt.ts index 07653be4af..a5c35eb079 100644 --- a/apps/cli/src/legacy/commands/branches/branches.prompt.ts +++ b/apps/cli/src/legacy/commands/branches/branches.prompt.ts @@ -48,7 +48,7 @@ export const legacyPromptBranchId = Effect.fnUntraced(function* ( if (!tty.stdinIsTty) { // Non-TTY path: read once from stdin, optionally with a git-branch default. - const gitBranch = yield* detectGitBranch; + const gitBranch = yield* detectGitBranch(); const defaultBranch = Option.getOrElse(gitBranch, () => ""); // Go applies `utils.Aqua(branchId)` to the default in the prompt label // (`apps/cli-go/cmd/branches.go:235`). lipgloss color "14" maps to ANSI 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 bb2d79f8f4..9cf9352b1f 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.handler.ts @@ -57,7 +57,7 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function let gitBranchForBody = Option.getOrUndefined(flags.gitBranch); if (branchName.length === 0) { - const gitBranch = yield* detectGitBranch; + const gitBranch = yield* detectGitBranch(); if (Option.isSome(gitBranch) && gitBranch.value.length > 0) { // Go's `create.go:20-25` calls `utils.NewConsole().PromptYesNo(...)` // unconditionally — on a TTY it blocks for input, off-TTY it reads stdin diff --git a/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts b/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts index 884c75abd5..3506609072 100644 --- a/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts @@ -10,6 +10,7 @@ function setupLegacyCompletionBash() { Effect.sync(() => { calls.push(args); }), + execCapture: () => Effect.succeed(""), }); return { layer, calls }; } diff --git a/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts b/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts index 163b2483ca..2f441b4fbb 100644 --- a/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts @@ -10,6 +10,7 @@ function setupLegacyCompletionFish() { Effect.sync(() => { calls.push(args); }), + execCapture: () => Effect.succeed(""), }); return { layer, calls }; } diff --git a/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts b/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts index be1a0ee0c8..056c856dcb 100644 --- a/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts @@ -10,6 +10,7 @@ function setupLegacyCompletionPowershell() { Effect.sync(() => { calls.push(args); }), + execCapture: () => Effect.succeed(""), }); return { layer, calls }; } diff --git a/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts b/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts index e1cf6f4192..f15c1d9a41 100644 --- a/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts @@ -10,6 +10,7 @@ function setupLegacyCompletionZsh() { Effect.sync(() => { calls.push(args); }), + execCapture: () => Effect.succeed(""), }); return { layer, calls }; } 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 6f600f741f..a019c95594 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -1,56 +1,84 @@ # `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. + ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | --------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--db-url` | -| `/supabase/config.toml` | TOML | always, to resolve local DB config | +| 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) | ## Files Written -| Path | Format | When | -| ------------------------------- | ------ | ------------------------- | -| `` (from `--file` / `-f`) | SQL | when `--file` flag is set | +| 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) | + +## Docker + +- Edge-runtime container (pg-delta / migra diff scripts). +- Shadow Postgres container (provisioned + torn down via the Go `db __shadow` seam). +- `supabase/migra` container — the migra OOM bash fallback only. -## API Routes +## API Routes (linked path, via the db-config resolver) -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Purpose | +| ---------- | ---------------------------------- | ------ | -------------------------------- | +| POST | `/v1/projects/{ref}/roles` | Bearer | Temp login role when no password | +| GET | `/v1/projects/{ref}/pooler/config` | Bearer | IPv4 pooler fallback | +| GET/DELETE | `/v1/projects/{ref}/network-bans` | Bearer | Unban during pooler login retry | +| GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | ## 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 | +| 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 | ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | schema diff 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 | ## Output ### `--output-format text` (Go CLI compatible) -Prints the schema diff in SQL migration format to stdout, or writes it to the file specified by `--file`. - -### `--output-format json` - -Not applicable. +Progress to stderr (`Creating shadow database...`, `Diffing schemas[: ]`, +`Finished supabase db diff on branch .`, drop-statement warning, and the +`--file` write warning). The SQL diff prints to stdout when neither `--file` nor +explicit `--output` is set. -### `--output-format stream-json` +### `--output-format json` / `stream-json` -Not applicable. +Progress strings still go to stderr; stdout carries a single structured envelope +`{ diff, file, schemas, engine, dropStatements }` instead of the raw SQL. -## Notes +## Notes / Delegation -- `--use-migra` (default true), `--use-pgadmin`, `--use-pg-schema`, `--use-pg-delta` are mutually exclusive differ strategies. -- `--from` and `--to` enable explicit diff mode; both must be set together. -- `--db-url`, `--linked`, and `--local` are mutually exclusive. -- `--schema` / `-s` restricts diff to specific schemas. +- `--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. +- Explicit `--from`/`--to` mode always uses pg-delta and writes to `--output` (or stdout). 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 596173cf3c..a6f20725c2 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.command.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.command.ts @@ -1,19 +1,33 @@ -import { Argument, Command, Flag } from "effect/unstable/cli"; +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 { legacyParseSchemaFlags } from "../../../shared/legacy-schema-flags.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbDiff } from "./diff.handler.ts"; +import { legacyDbDiffRuntimeLayer } from "./diff.layers.ts"; const config = { + // The four engine flags form a cobra mutually-exclusive group + // (`apps/cli-go/cmd/db.go:416`) and `--use-migra` defaults to true, so they are + // modelled as `Option` to track pflag `Changed`: the mutex check and + // `resolveDiffEngine`'s `useMigraChanged` key off whether the flag was passed, + // not its value. useMigra: Flag.boolean("use-migra").pipe( Flag.withDescription("Use migra to generate schema diff."), + Flag.optional, ), usePgAdmin: Flag.boolean("use-pgadmin").pipe( Flag.withDescription("Use pgAdmin to generate schema diff."), + Flag.optional, ), usePgSchema: Flag.boolean("use-pg-schema").pipe( Flag.withDescription("Use pg-schema-diff to generate schema diff."), + Flag.optional, ), usePgDelta: Flag.boolean("use-pg-delta").pipe( Flag.withDescription("Use pg-delta to generate schema diff."), + Flag.optional, ), from: Flag.string("from").pipe( Flag.withDescription("Diff from local, linked, migrations, or a Postgres URL."), @@ -34,11 +48,16 @@ const config = { ), Flag.optional, ), + // The target flags form the cobra group `[db-url linked local]` + // (`apps/cli-go/cmd/db.go:423`); modelled as `Option` so the mutex check tracks + // `Changed`. `--local` defaults to true via the target resolver's fall-through. linked: Flag.boolean("linked").pipe( Flag.withDescription("Diffs local migration files against the linked project."), + Flag.optional, ), local: Flag.boolean("local").pipe( Flag.withDescription("Diffs local migration files against the local database."), + Flag.optional, ), file: Flag.string("file").pipe( Flag.withAlias("f"), @@ -49,10 +68,13 @@ const config = { Flag.withAlias("s"), Flag.withDescription("Comma separated list of schema to include."), Flag.atLeast(0), - ), - paths: Argument.string("path").pipe( - Argument.withDescription("Additional paths."), - Argument.variadic(), + // Go registers --schema/-s as a cobra StringSliceVarP (`apps/cli-go/cmd/db.go:425`), + // CSV-parsing each value; use the shared pflag-faithful helper so quoted commas + // survive and malformed CSV fails at parse time. + Flag.mapTryCatch( + (rawValues) => legacyParseSchemaFlags(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), ), } as const; @@ -61,5 +83,27 @@ export type LegacyDbDiffFlags = CliCommand.Command.Config.Infer; export const legacyDbDiffCommand = Command.make("diff", config).pipe( Command.withDescription("Diffs the local database for schema changes."), Command.withShortDescription("Diffs the local database for schema changes"), - Command.withHandler((flags) => legacyDbDiff(flags)), + Command.withHandler((flags) => + legacyDbDiff(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "use-migra": flags.useMigra, + "use-pgadmin": flags.usePgAdmin, + "use-pg-schema": flags.usePgSchema, + "use-pg-delta": flags.usePgDelta, + from: flags.from, + to: flags.to, + output: flags.output, + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + file: flags.file, + schema: flags.schema, + }, + aliases: { o: "output", f: "file", s: "schema" }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbDiffRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.e2e.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.e2e.test.ts new file mode 100644 index 0000000000..81209b107d --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/diff.e2e.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +describe("supabase db diff (legacy)", () => { + // Docker-free golden-path: the explicit-mode flag validation runs before any + // shadow/Docker work, so `--from` without `--to` exits non-zero with Go's exact + // message through a real subprocess. + test( + "--from without --to exits non-zero with the explicit-mode error", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase(["db", "diff", "--from", "local"], { + entrypoint: "legacy", + }); + expect(exitCode).not.toBe(0); + expect(`${stdout}${stderr}`).toContain( + "must set both --from and --to when using explicit diff mode", + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.errors.ts b/apps/cli/src/legacy/commands/db/diff/diff.errors.ts new file mode 100644 index 0000000000..b7c6dff954 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/diff.errors.ts @@ -0,0 +1,52 @@ +import { Data } from "effect"; + +/** + * Conflicting database-target flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` `ValidateFlagGroups` + * error byte-for-byte (`apps/cli-go/cmd/db.go:423`). + */ +export class LegacyDbDiffTargetFlagsError extends Data.TaggedError("LegacyDbDiffTargetFlagsError")<{ + readonly message: string; +}> {} + +/** + * Conflicting diff-engine flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("use-migra", "use-pgadmin", "use-pg-schema", "use-pg-delta")` + * error byte-for-byte (`apps/cli-go/cmd/db.go:416`). + */ +export class LegacyDbDiffEngineConflictError extends Data.TaggedError( + "LegacyDbDiffEngineConflictError", +)<{ + readonly message: string; +}> {} + +/** + * Only one of `--from` / `--to` was set in explicit diff mode. Byte-matches Go's + * `"must set both --from and --to when using explicit diff mode"` + * (`apps/cli-go/cmd/db.go:105`). + */ +export class LegacyDbDiffExplicitFlagsError extends Data.TaggedError( + "LegacyDbDiffExplicitFlagsError", +)<{ + readonly message: string; +}> {} + +/** + * An explicit `--from`/`--to` ref was neither `local`/`linked`/`migrations` nor a + * postgres URL. Byte-matches Go's `resolveExplicitDatabaseRef` + * `"unknown target %q: must be one of 'local', 'linked', 'migrations', or a postgres:// URL"` + * (`apps/cli-go/internal/db/diff/explicit.go:44`). + */ +export class LegacyDbDiffUnknownTargetError extends Data.TaggedError( + "LegacyDbDiffUnknownTargetError", +)<{ + readonly message: string; +}> {} + +/** + * Writing the diff output failed — a `--file` migration, or an explicit-mode + * `--output` file. Wraps Go's `utils.WriteFile` failure (`internal/utils/misc.go`). + */ +export class LegacyDbDiffWriteError extends Data.TaggedError("LegacyDbDiffWriteError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/diff/diff.explicit.ts b/apps/cli/src/legacy/commands/db/diff/diff.explicit.ts new file mode 100644 index 0000000000..cadec9bc35 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/diff.explicit.ts @@ -0,0 +1,23 @@ +import { legacyIsPostgresURL } from "../shared/legacy-pgdelta.ts"; + +/** The kinds an explicit `--from`/`--to` ref resolves to. */ +export type LegacyExplicitRefKind = "local" | "linked" | "migrations" | "url" | "unknown"; + +const VALID_TARGETS = new Set(["local", "linked", "migrations"]); + +/** + * Classifies an explicit `--from`/`--to` ref. Mirrors Go's + * `resolveExplicitDatabaseRef` validation (`internal/db/diff/explicit.go:40-71`): + * `local`/`linked`/`migrations` are the named targets; anything else must be a + * `postgres://` / `postgresql://` URL, otherwise it is unknown. + */ +export function legacyClassifyExplicitRef(ref: string): LegacyExplicitRefKind { + if (VALID_TARGETS.has(ref)) return ref as "local" | "linked" | "migrations"; + if (legacyIsPostgresURL(ref)) return "url"; + return "unknown"; +} + +/** Go's unknown-target error message (`internal/db/diff/explicit.go:44`). */ +export function legacyUnknownTargetMessage(ref: string): string { + return `unknown target ${JSON.stringify(ref)}: must be one of 'local', 'linked', 'migrations', or a postgres:// URL`; +} diff --git a/apps/cli/src/legacy/commands/db/diff/diff.explicit.unit.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.explicit.unit.test.ts new file mode 100644 index 0000000000..2e09fcaf07 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/diff.explicit.unit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { legacyClassifyExplicitRef, legacyUnknownTargetMessage } from "./diff.explicit.ts"; + +describe("legacyClassifyExplicitRef", () => { + it("recognises the named targets", () => { + expect(legacyClassifyExplicitRef("local")).toBe("local"); + expect(legacyClassifyExplicitRef("linked")).toBe("linked"); + expect(legacyClassifyExplicitRef("migrations")).toBe("migrations"); + }); + + it("recognises postgres URLs", () => { + expect(legacyClassifyExplicitRef("postgres://u:p@h:5432/db")).toBe("url"); + expect(legacyClassifyExplicitRef("postgresql://u@h/db")).toBe("url"); + }); + + it("rejects anything else as unknown", () => { + expect(legacyClassifyExplicitRef("remote")).toBe("unknown"); + expect(legacyClassifyExplicitRef("https://h/db")).toBe("unknown"); + expect(legacyClassifyExplicitRef("")).toBe("unknown"); + }); +}); + +describe("legacyUnknownTargetMessage", () => { + it("byte-matches Go's quoted error", () => { + expect(legacyUnknownTargetMessage("remote")).toBe( + "unknown target \"remote\": must be one of 'local', 'linked', 'migrations', or a postgres:// URL", + ); + }); +}); 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 d3a903698d..577ff3f6c1 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -1,26 +1,495 @@ -import { Effect, Option } from "effect"; +import { Clock, Effect, FileSystem, Option, Path } from "effect"; + +import { LegacyDnsResolverFlag } 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 { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts"; +import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; +import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; +import { legacySchemaToCsvField } from "../../../shared/legacy-schema-flags.ts"; +import { legacyFindDropStatements } from "../../../shared/legacy-sql-split.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + legacyParseBoolEnv, + legacyResolveDiffEngine, + legacyShouldUsePgDelta, +} from "../shared/legacy-diff-engine.ts"; +import { + legacyFormatMigrationTimestamp, + legacyGetMigrationPath, +} from "../shared/legacy-migration-file.ts"; +import { legacyDiffMigra } from "../shared/legacy-migra.ts"; +import { type LegacyPgDeltaContext, legacyDiffPgDelta } from "../shared/legacy-pgdelta.ts"; +import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbDiffFlags } from "./diff.command.ts"; +import { legacyClassifyExplicitRef, legacyUnknownTargetMessage } from "./diff.explicit.ts"; +import { + LegacyDbDiffEngineConflictError, + LegacyDbDiffExplicitFlagsError, + LegacyDbDiffTargetFlagsError, + LegacyDbDiffUnknownTargetError, + LegacyDbDiffWriteError, +} from "./diff.errors.ts"; -export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: LegacyDbDiffFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "diff"]; - if (flags.useMigra) args.push("--use-migra"); - if (flags.usePgAdmin) args.push("--use-pgadmin"); - if (flags.usePgSchema) args.push("--use-pg-schema"); - if (flags.usePgDelta) args.push("--use-pg-delta"); - if (Option.isSome(flags.from)) args.push("--from", flags.from.value); - if (Option.isSome(flags.to)) args.push("--to", flags.to.value); - if (Option.isSome(flags.output)) args.push("--output", flags.output.value); +// Go's `warnDiff` (`apps/cli-go/internal/db/diff/pgadmin.go:17`), shown after a +// `--file` migration is written. +const warnDiff = `WARNING: The diff tool is not foolproof, so you may need to manually rearrange and modify the generated migration. +Run ${legacyAqua("supabase db reset")} to verify that the new migration does not generate errors.`; + +/** Builds a plain Postgres URL from a resolved connection (Go's `ToPostgresURL`). */ +const connToUrl = (conn: LegacyPgConnInput): string => + legacyToPostgresURL({ + host: conn.host, + port: conn.port, + user: conn.user, + password: conn.password, + database: conn.database, + ...(conn.options !== undefined ? { options: conn.options } : {}), + ...(conn.runtimeParams !== undefined ? { runtimeParams: conn.runtimeParams } : {}), + // Preserve a `--db-url` connect_timeout; Go's ToPostgresURL serializes the + // parsed ConnectTimeout (`connect.go`), defaulting to 10 only when unset. + ...(conn.connectTimeoutSeconds !== undefined + ? { connectTimeoutSeconds: conn.connectTimeoutSeconds } + : {}), + }); + +/** + * 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. + */ +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 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 + // Changed-but-false flag still selects that target. Forward whenever `Some` + // (emitting `--flag=false` for `Some(false)`) so the child's `flag.Changed` + // matches the parent's `Option.isSome`; otherwise the child falls through to a + // 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); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); + pushTarget("linked", flags.linked); + pushTarget("local", flags.local); if (Option.isSome(flags.file)) args.push("--file", flags.file.value); - for (const s of flags.schema) { - args.push("--schema", s); - } - for (const p of flags.paths) { - args.push(String(p)); - } - yield* proxy.exec(args); + if (Option.isSome(flags.output)) args.push("--output", flags.output.value); + // Re-encode each parsed schema as a CSV field so the Go child's pflag StringSlice + // CSV parse doesn't re-split a comma-containing schema (e.g. `"tenant,one"`). + for (const s of flags.schema) args.push("--schema", legacySchemaToCsvField(s)); + return args; +}; + +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; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dnsResolver = yield* LegacyDnsResolverFlag; + + // 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* () { + // cobra `MarkFlagsMutuallyExclusive` runs before RunE. The engine group + // (`use-migra use-pgadmin use-pg-schema use-pg-delta`) and the target group + // (`db-url linked local`); "set" follows pflag `Changed` (Option `Some`). + const engineSet: Array = []; + if (Option.isSome(flags.useMigra)) engineSet.push("use-migra"); + if (Option.isSome(flags.usePgAdmin)) engineSet.push("use-pgadmin"); + if (Option.isSome(flags.usePgSchema)) engineSet.push("use-pg-schema"); + if (Option.isSome(flags.usePgDelta)) engineSet.push("use-pg-delta"); + if (engineSet.length > 1) { + return yield* Effect.fail( + new LegacyDbDiffEngineConflictError({ + message: `if any flags in the group [use-migra use-pgadmin use-pg-schema use-pg-delta] are set none of the others can be; [${[...engineSet].sort().join(" ")}] were all set`, + }), + ); + } + const targetSet: Array = []; + if (Option.isSome(flags.dbUrl)) targetSet.push("db-url"); + if (Option.isSome(flags.linked)) targetSet.push("linked"); + if (Option.isSome(flags.local)) targetSet.push("local"); + if (targetSet.length > 1) { + return yield* Effect.fail( + new LegacyDbDiffTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${[...targetSet].sort().join(" ")}] were all set`, + }), + ); + } + + // Config is read lazily per path, NOT unconditionally up front: Go loads config + // exactly once in PreRun and, on the linked path, only AFTER resolving the ref — + // so it validates the remote-merged config (`config.go` merges `[remotes.]` + // before `Validate`). Reading the base config here would validate fields a + // `[remotes.]` block overrides (db.major_version, deno_version, …) before + // the ref is known, failing a linked diff that Go accepts. The delegate paths + // forward to the Go child (which loads config itself), so they read nothing. + + // Explicit `--from`/`--to` mode (Go's `db.go:102-109`): both required, always + // pg-delta. Go gates on `len(diffFrom) > 0 || len(diffTo) > 0`, so an empty + // value (a shell var expanding to `""`) counts as unset — `--from "" --to ""` + // falls through to the normal diff, while `--from x --to ""` still errors. + const from = Option.getOrElse(flags.from, () => ""); + const to = Option.getOrElse(flags.to, () => ""); + const fromSet = from.length > 0; + const toSet = to.length > 0; + if (fromSet || toSet) { + if (!fromSet || !toSet) { + return yield* Effect.fail( + new LegacyDbDiffExplicitFlagsError({ + message: "must set both --from and --to when using explicit diff 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 + // resolves, so a `migrations` ref resolved before any linked ref uses base. + let mergedLinkedRef: string | undefined; + // Go runs `ParseDatabaseConfig` in the root PersistentPreRunE for every + // `db diff` (`cmd/root.go:118`), before RunE dispatches to RunExplicit + // (`cmd/db.go:107`). It validates a changed target flag (`--db-url bad` fails + // parsing) AND is STATEFUL: a changed `--linked` runs `LoadProjectRef` + + // `LoadConfig`, leaving `utils.Config` remote-merged, so the explicit + // `local`/`migrations` refs and `pgDeltaFormatOptions()` see the linked + // project's `[remotes.]` overrides (`db_url.go:87-93` → + // `config_path.go:11-12`). `--local`/`--db-url` load base config (no merge). + if (Option.isSome(flags.dbUrl) || Option.isSome(flags.linked) || Option.isSome(flags.local)) { + const preflightConnType: LegacyDbConnType = Option.isSome(flags.dbUrl) + ? "db-url" + : Option.isSome(flags.linked) + ? "linked" + : "local"; + const preflight = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType: preflightConnType, + dnsResolver, + password: Option.none(), + }); + if (preflightConnType === "linked") { + const preflightRef = Option.getOrUndefined(preflight.ref ?? Option.none()); + if (preflightRef !== undefined) { + linkedRefForCache = preflightRef; + mergedLinkedRef = preflightRef; + } + } + } + // Read config once, AFTER the preflight: the `[remotes.]`-merged config + // when a changed `--linked` resolved a ref (so base config isn't validated + // before the merge, matching Go's stateful pre-run), else the base config. + let cfg = + mergedLinkedRef !== undefined + ? yield* legacyReadDbToml(fs, path, cliConfig.workdir, mergedLinkedRef) + : yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // Go resolves each ref in order (`explicit.go:21-25`); the `linked` branch + // runs `LoadConfig(ref)` (`explicit.go:78-86`), re-merging the matching + // `[remotes.]` block so a later `local` ref read and the trailing + // `pgDeltaFormatOptions()` see the override. Thread the merged config through. + const resolveRef = (ref: string) => + Effect.gen(function* () { + switch (legacyClassifyExplicitRef(ref)) { + case "local": + return legacyToPostgresURL({ + host: legacyGetHostname(), + port: cfg.port, + user: "postgres", + password: cfg.password, + database: "postgres", + }); + case "linked": { + const resolved = yield* resolver.resolve({ + dbUrl: Option.none(), + connType: "linked", + dnsResolver, + password: Option.none(), + }); + const ref2 = Option.getOrUndefined(resolved.ref ?? Option.none()); + if (ref2 !== undefined) { + linkedRefForCache = ref2; + mergedLinkedRef = ref2; + cfg = yield* legacyReadDbToml(fs, path, cliConfig.workdir, ref2); + } + return connToUrl(resolved.conn); + } + case "migrations": + return yield* seam.exportCatalog({ + mode: "migrations", + noCache: false, + // Pass the linked ref only if one resolved earlier in the cascade, + // so the `__catalog` child merges the same remote override Go's + // in-process migrations catalog sees (`explicit.go:88-126`). Absent + // otherwise → base config, matching Go's resolution order. + ...(mergedLinkedRef !== undefined ? { projectRef: mergedLinkedRef } : {}), + }); + case "url": + return ref; + default: + return yield* Effect.fail( + new LegacyDbDiffUnknownTargetError({ message: legacyUnknownTargetMessage(ref) }), + ); + } + }); + const sourceRef = yield* resolveRef(from); + const targetRef = yield* resolveRef(to); + const explicitCtx: LegacyPgDeltaContext = { + projectId: Option.getOrElse(cliConfig.projectId, () => ""), + cwd: cliConfig.workdir, + npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), + denoVersion: cfg.denoVersion, + }; + const result = yield* legacyDiffPgDelta(explicitCtx, { + sourceRef, + targetRef, + schema: flags.schema, + formatOptions: Option.getOrElse(cfg.pgDelta.formatOptions, () => ""), + }); + // Explicit-mode output: `--output` file (Go's `writeOutput`) or stdout + // (Go's `fmt.Print`, no trailing newline — pg-delta ends each statement `;\n`). + // Go gates the file write on `len(outputPath) > 0` (`explicit.go`), so an + // empty value (`--output="$OUT"` with OUT unset) falls through to stdout + // rather than writing SQL into the project directory. + if (Option.isSome(flags.output) && flags.output.value.length > 0) { + const target = path.resolve(cliConfig.workdir, flags.output.value); + // Create parent dirs first, matching Go's `writeOutput` → `utils.WriteFile` + // (`internal/db/diff/explicit.go`, `internal/utils/misc.go`), so a nested + // `--output tmp/diff.sql` doesn't fail when `tmp/` doesn't exist yet. + yield* fs + .makeDirectory(path.dirname(target), { recursive: true }) + .pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); + yield* fs + .writeFileString(target, result.sql) + .pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); + if (output.format !== "text") { + yield* output.success("Diff written.", { + diff: result.sql, + file: target, + schemas: flags.schema, + engine: "pg-delta", + }); + } + return; + } + if (output.format !== "text") { + yield* output.success("Diff generated.", { + diff: result.sql, + file: null, + schemas: flags.schema, + engine: "pg-delta", + }); + return; + } + yield* output.raw(result.sql); + 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. + 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; + } + if (usePgSchema) { + // The delegated Go `db diff --use-pg-schema` prints the experimental + // warning itself in its RunE (`cmd/db.go`), so don't pre-print it here — + // doing so would double the warning. Mirror the --use-pgadmin branch above. + yield* delegateDiff("pg-schema"); + return; + } + + // Native path: resolve the target, provision a live shadow source, then diff. + const connType: LegacyDbConnType = Option.isSome(flags.dbUrl) + ? "db-url" + : Option.isSome(flags.linked) + ? "linked" + : "local"; + const resolved = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + password: Option.none(), + }); + const linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); + if (linkedRef !== undefined) linkedRefForCache = linkedRef; + const targetUrl = connToUrl(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, () => ""), + cwd: cliConfig.workdir, + npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), + denoVersion: cfg.denoVersion, + }; + const formatOptions = Option.getOrElse(cfg.pgDelta.formatOptions, () => ""); + + // Engine resolution (Go's `db.go:110`): the pg-delta env/config/flag gate, + // read from the (possibly remote-merged) config. + const pgDeltaDefault = legacyShouldUsePgDelta({ + configEnabled: cfg.pgDelta.enabled, + usePgDeltaFlag: Option.getOrElse(flags.usePgDelta, () => false), + envEnabled: legacyParseBoolEnv(cfg.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), + }); + const useDelta = legacyResolveDiffEngine({ + useMigraChanged: Option.isSome(flags.useMigra), + usePgAdmin, + usePgSchema, + 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, + }); + + const out = 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", + ); + if (useDelta) { + const result = yield* legacyDiffPgDelta(ctx, { + sourceRef: shadow.sourceUrl, + targetRef: target, + schema: flags.schema, + formatOptions, + }); + return result.sql; + } + return yield* legacyDiffMigra(ctx, { + source: shadow.sourceUrl, + target, + schema: flags.schema, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }); + }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + + // 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); + let writtenFile: string | null = null; + if (out.length < 2) { + yield* output.raw("No schema changes found\n", "stderr"); + // Go's `SaveDiff` gates the file write on `len(file) > 0` (`pgadmin.go`), so + // an empty `--file=""` (e.g. an unset shell var) falls through to stdout + // rather than writing a `_.sql` migration with no name. + } else if (Option.isSome(flags.file) && flags.file.value.length > 0) { + const timestamp = legacyFormatMigrationTimestamp(yield* Clock.currentTimeMillis); + const migrationPath = legacyGetMigrationPath( + path, + cliConfig.workdir, + timestamp, + flags.file.value, + ); + yield* fs + .makeDirectory(path.dirname(migrationPath), { recursive: true }) + .pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); + yield* fs + .writeFileString(migrationPath, out) + .pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); + writtenFile = migrationPath; + yield* output.raw(`${warnDiff}\n`, "stderr"); + } else if (output.format === "text") { + yield* output.raw(`${out}\n`); + } + if (drops.length > 0) { + yield* output.raw( + "Found drop statements in schema diff. Please double check if these are expected:\n", + "stderr", + ); + yield* output.raw(`${legacyYellow(drops.join("\n"))}\n`, "stderr"); + } + if (output.format !== "text") { + yield* output.success("Diff complete.", { + diff: out, + file: writtenFile, + schemas: flags.schema, + engine, + dropStatements: drops, + }); + } + }).pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); 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 new file mode 100644 index 0000000000..bb52810856 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -0,0 +1,735 @@ +import { existsSync, mkdirSync, readdirSync, 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 { + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { + LegacyDnsResolverFlag, + 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 { 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 { 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"; + +interface SetupOpts { + readonly format?: OutputFormat; + readonly isLocal?: boolean; + readonly linkedRef?: string; + readonly diffSql?: 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 + readonly networkId?: string; // --network-id value forwarded to docker runs +} + +function setup(workdir: string, opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + 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"); + }, + execInherit: () => Effect.succeed(0), + ensureLocalDatabaseStarted: () => 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); + }), + }); + + const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; + const edge = Layer.succeed(LegacyEdgeRuntimeScript, { + run: (runOpts: LegacyEdgeRuntimeRunOpts) => { + edgeCalls.push(runOpts); + if (opts.oom) { + return Effect.fail( + new LegacyEdgeRuntimeScriptError({ message: "Fatal JavaScript out of memory" }), + ); + } + return Effect.succeed({ stdout: opts.diffSql ?? "", stderr: "" }); + }, + }); + + // Exercised only by the migra OOM bash fallback. + const dockerCalls: unknown[] = []; + const docker = Layer.succeed(LegacyDockerRun, { + run: () => Effect.die("run unused"), + runCapture: (dockerOpts) => { + dockerCalls.push(dockerOpts); + return Effect.succeed({ + exitCode: 0, + stdout: new TextEncoder().encode(opts.diffSql ?? ""), + stderr: "", + }); + }, + runStream: () => Effect.die("runStream unused"), + }); + + const dbConnection = Layer.succeed(LegacyDbConnection, { + connect: () => Effect.die("connect unused"), + }); + + const resolverCalls: unknown[] = []; + const resolver = Layer.succeed(LegacyDbConfigResolver, { + resolve: (resolveFlags) => { + resolverCalls.push(resolveFlags); + return Effect.succeed({ + conn: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", + }, + isLocal: opts.isLocal ?? true, + ref: opts.linkedRef !== undefined ? Option.some(opts.linkedRef) : Option.none(), + }); + }, + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); + + const proxyCalls: Array<{ args: ReadonlyArray; env?: Record }> = []; + const proxyCaptureCalls: Array<{ args: ReadonlyArray; env?: Record }> = + []; + const proxy = Layer.succeed(LegacyGoProxy, { + exec: (args, execOpts) => Effect.sync(() => void proxyCalls.push({ args, env: execOpts?.env })), + execCapture: (args, execOpts) => + Effect.sync(() => { + proxyCaptureCalls.push({ args, env: execOpts?.env }); + return opts.delegateStdout ?? ""; + }), + }); + + const layer = Layer.mergeAll( + out.layer, + telemetry.layer, + cache.layer, + seam, + edge, + docker, + dbConnection, + resolver, + proxy, + mockLegacyCliConfig({ workdir, projectId: Option.some("test") }), + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed( + LegacyNetworkIdFlag, + opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), + ), + Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false) }), + mockRuntimeInfo(), + BunServices.layer, + ); + + return { + layer, + out, + cache, + telemetry, + provisionCalls, + removedContainers, + exportCalls, + exportCatalogCalls, + edgeCalls, + resolverCalls, + proxyCalls, + proxyCaptureCalls, + dockerCalls, + }; +} + +const flags = (over: Partial = {}): LegacyDbDiffFlags => ({ + useMigra: over.useMigra ?? Option.none(), + usePgAdmin: over.usePgAdmin ?? Option.none(), + usePgSchema: over.usePgSchema ?? Option.none(), + usePgDelta: over.usePgDelta ?? Option.none(), + from: over.from ?? Option.none(), + to: over.to ?? Option.none(), + output: over.output ?? Option.none(), + dbUrl: over.dbUrl ?? Option.none(), + linked: over.linked ?? Option.none(), + local: over.local ?? Option.none(), + file: over.file ?? Option.none(), + schema: over.schema ?? [], +}); + +// Strip ANSI so assertions are colour-independent: `legacyAqua`/`legacyYellow` +// emit colour only when the test runner's stderr is a TTY. +// eslint-disable-next-line no-control-regex +const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); +const stdout = (out: ReturnType) => + stripAnsi( + out.rawChunks + .filter((c) => c.stream === "stdout") + .map((c) => c.text) + .join(""), + ); +const stderr = (out: ReturnType) => + stripAnsi( + out.rawChunks + .filter((c) => c.stream === "stderr") + .map((c) => c.text) + .join(""), + ); + +const tmp = useLegacyTempWorkdir(); + +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 }]); + 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); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("diffs local with pgdelta when --use-pg-delta is set", () => { + 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 }]); + 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("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 + // (flags/db_url.go:87-97). The default db diff target is local (no merge), so + // this only applies with --linked; base config disables pg-delta, the remote + // override enables it, so the diff must pick the pg-delta engine. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[experimental.pgdelta]", + "enabled = false", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.experimental.pgdelta]", + "enabled = true", + "", + ].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) })); + 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"); + }).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. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[experimental.pgdelta]", + "enabled = false", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ); + 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(); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("diffs the linked project and writes the linked-project cache", () => { + 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) })); + 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", () => { + const s = setup(tmp.current, { + targetOverride: "postgres://postgres:postgres@127.0.0.1:54320/contrib_regression", + diffSql: "create table o ();\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags()); + expect(stdout(s.out)).toBe("create table o ();\n\n"); + expect(s.removedContainers).toEqual(["shadow-1"]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("delegates --use-pgadmin to the Go binary (telemetry disabled on the child)", () => { + 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([]); + }).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" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(true) })); + expect(s.proxyCalls).toHaveLength(1); + }).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 + // (db.major_version=16) must still fail — matching Go. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\nmajor_version = 16\n"); + const s = setup(tmp.current, { diffSql: "create table x ();\n" }); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("re-quotes a comma-containing schema when delegating the diff", () => { + // 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. + const s = setup(tmp.current); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: 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("delegates --use-pg-schema to the Go binary without a duplicate warning", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgSchema: Option.some(true) })); + // The delegated Go `db diff --use-pg-schema` prints the experimental + // warning itself; the TS wrapper must not print a second copy. + expect(stderr(s.out)).not.toContain("--use-pg-schema flag is experimental"); + expect(s.proxyCalls[0]?.args).toEqual(["db", "diff", "--use-pg-schema"]); + }).pipe(Effect.provide(s.layer)); + }); + + 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" }); + 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", + }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--use-pg-schema in json mode wraps the captured SQL in a structured envelope", () => { + const s = setup(tmp.current, { format: "json", delegateStdout: "create table e ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgSchema: Option.some(true) })); + expect(stdout(s.out)).toBe(""); + expect(s.proxyCaptureCalls).toHaveLength(1); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ diff: "create table e ();\n", engine: "pg-schema" }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("writes a timestamped migration when --file is set instead of printing", () => { + const s = setup(tmp.current, { diffSql: "create table f ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ file: Option.some("my_diff") })); + expect(stdout(s.out)).toBe(""); + 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}_my_diff\.sql$/); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("explicit --from local --to linked prints the diff to stdout", () => { + const s = setup(tmp.current, { isLocal: false, diffSql: "create table e ();\n" }); + 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(stdout(s.out)).toBe("create table e ();\n"); + }).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* () { + yield* legacyDbDiff( + flags({ + from: Option.some("local"), + to: Option.some("local"), + output: Option.some("out.sql"), + }), + ); + expect(existsSync(join(tmp.current, "out.sql"))).toBe(true); + expect(stdout(s.out)).toBe(""); + }).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( + "an empty --file value prints to stdout instead of writing a nameless migration", + () => { + // Go's SaveDiff gates the file write on len(file) > 0; an empty --file (e.g. + // an unset shell var) falls through to stdout rather than writing + // `_.sql`. + const s = setup(tmp.current, { diffSql: "create table y ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ file: Option.some("") })); + expect(stdout(s.out)).toContain("create table y ();"); + const migrationsDir = join(tmp.current, "supabase", "migrations"); + expect(existsSync(migrationsDir) ? readdirSync(migrationsDir) : []).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "explicit --output with an empty value prints to stdout instead of writing a file", + () => { + // Go gates the file write on len(outputPath) > 0; an empty value falls through + // to stdout rather than writing SQL into the project directory. + const s = setup(tmp.current, { diffSql: "create table z ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ from: Option.some("local"), to: Option.some("local"), output: Option.some("") }), + ); + // Reaching stdout proves it didn't try to write SQL to the resolved workdir. + expect(stdout(s.out)).toBe("create table z ();\n"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("explicit --from migrations resolves a shadow catalog via the seam", () => { + 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(["migrations"]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "explicit --from linked --to migrations exports the catalog 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). + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "create table m ();\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ from: Option.some("linked"), to: Option.some("migrations") })); + const migrations = s.exportCatalogCalls.find((c) => c.mode === "migrations"); + expect(migrations?.projectRef).toBe("abcdefghijklmnopqrst"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("explicit --from migrations --to linked exports the catalog 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. + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "create table m ();\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("linked") })); + const migrations = s.exportCatalogCalls.find((c) => c.mode === "migrations"); + expect(migrations?.projectRef).toBeUndefined(); + }).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`. + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "create table m ();\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ + from: Option.some("local"), + to: Option.some("migrations"), + linked: Option.some(true), + }), + ); + const migrations = s.exportCatalogCalls.find((c) => c.mode === "migrations"); + expect(migrations?.projectRef).toBe("abcdefghijklmnopqrst"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("explicit --from local --to migrations --linked validates the merged config", () => { + // The explicit base config read is deferred until after the linked preflight, so + // a base config that's only valid after the [remotes.] merge (base + // major_version=16, override=15) does not fail before the ref is resolved — + // matching Go's stateful pre-run (LoadConfig after LoadProjectRef on --linked). + 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", + diffSql: "create table m ();\n", + }); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff( + flags({ + from: Option.some("local"), + to: Option.some("migrations"), + linked: Option.some(true), + }), + ).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("empty --from/--to (shell vars) fall through to the normal diff", () => { + // Go gates explicit mode on len(diffFrom)>0 || len(diffTo)>0; `--from "" --to ""` + // is unset and runs the normal local diff, not an unknown-target error. + const s = setup(tmp.current, { diffSql: "create table e ();\n" }); + 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(stdout(s.out)).toBe("create table e ();\n\n"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an explicit --from with an empty --to still errors 'must set both'", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff( + flags({ from: Option.some("local"), to: Option.some("") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("explicit mode still runs the target-flag preflight on a changed --db-url", () => { + // Go runs ParseDatabaseConfig in PreRun before RunExplicit (cmd/root.go:118), + // so a changed target flag is still validated/loaded even when the explicit + // refs drive the diff. The preflight resolves the --db-url target (connType + // db-url); a real bad URL would surface the resolver's parse error. + const s = setup(tmp.current, { diffSql: "create table p ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ + from: Option.some("local"), + to: Option.some("local"), + dbUrl: Option.some("postgresql://x"), + }), + ); + expect(s.resolverCalls).toContainEqual(expect.objectContaining({ connType: "db-url" })); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("fails when --from is set without --to", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff(flags({ from: Option.some("local") })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("fails on engine-flag conflict (--use-migra with --use-pg-delta)", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff( + flags({ useMigra: Option.some(true), usePgDelta: Option.some(true) }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("fails on target mutex (--linked with --local)", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff( + flags({ linked: Option.some(true), local: Option.some(true) }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("warns on drop statements in the diff", () => { + const s = setup(tmp.current, { diffSql: "drop table gone;\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags()); + expect(stderr(s.out)).toContain("Found drop statements in schema diff"); + expect(stderr(s.out)).toContain("drop table gone"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("emits a json envelope with --output-format json (payload-only stdout)", () => { + const s = setup(tmp.current, { format: "json", diffSql: "create table j ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags()); + // No raw SQL on stdout in machine mode; the envelope carries it instead. + expect(stdout(s.out)).toBe(""); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ + diff: "create table j ();\n", + file: null, + engine: "migra", + }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("prints 'No schema changes found' and exits 0 on an empty diff", () => { + const s = setup(tmp.current, { diffSql: "" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags()); + expect(stderr(s.out)).toContain("No schema changes found"); + expect(stdout(s.out)).toBe(""); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("falls back to the migra Docker image when edge-runtime OOMs", () => { + const s = setup(tmp.current, { oom: true, diffSql: "create table fb ();\n", isLocal: true }); + return Effect.gen(function* () { + // Pass --schema so the fallback does not need a live DB to list schemas. + yield* legacyDbDiff(flags({ schema: ["public"] })); + expect(s.dockerCalls).toHaveLength(1); + expect(stdout(s.out)).toBe("create table fb ();\n\n"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("the migra OOM fallback honors --network-id over host networking", () => { + // Go's bash fallback routes through DockerStart, which overrides the requested + // host network with --network-id when set (internal/utils/docker.go:266-271). + const s = setup(tmp.current, { + oom: true, + diffSql: "create table fb ();\n", + isLocal: true, + networkId: "my-net", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ schema: ["public"] })); + expect(s.dockerCalls).toHaveLength(1); + expect((s.dockerCalls[0] as { network: unknown }).network).toEqual({ + _tag: "named", + name: "my-net", + }); + }).pipe(Effect.provide(s.layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.layers.ts b/apps/cli/src/legacy/commands/db/diff/diff.layers.ts new file mode 100644 index 0000000000..8c2ab09380 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/diff.layers.ts @@ -0,0 +1,61 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.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 { legacyEdgeRuntimeScriptLayer } from "../../../shared/legacy-edge-runtime-script.layer.ts"; +import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; +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. + * Per the "provide doesn't share to siblings" rule, `LegacyCliConfig` is provided + * to every layer that needs it. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + // The linked db-config resolver snapshots the single `LegacyIdentityStitch` + // (Go's one `sync.Once`); the command runtime must provide it or the bundled + // binary panics with a missing-service error (legacy CLAUDE.md rule 5). + Layer.provide(legacyIdentityStitchLayer), +); + +const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( + Layer.provide(legacyDockerRunLayer), + Layer.provide(cliConfig), +); + +const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); + +export const legacyDbDiffRuntimeLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + legacyDockerRunLayer, + edgeRuntime, + legacyPgDeltaSslProbeLayer, + seam, + cliConfig, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + // Go's PersistentPostRun writes the linked-project cache for `--linked`; this + // bundle supplies `LegacyLinkedProjectCache` (+ the lazy Management-API runtime + // it needs), mirroring `db schema declarative generate`. + legacyLinkedDbResolverRuntimeLayer(["db", "diff"]).pipe(Layer.provide(legacyIdentityStitchLayer)), + commandRuntimeLayer(["db", "diff"]), +); 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 f027cb07d8..c8ae8ac6e9 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -1,56 +1,88 @@ # `supabase db pull` +Native Effect port. Pulls the remote schema into either a new timestamped +migration (diffing a throwaway shadow against the remote, native pg-delta or +migra) or declarative files (`--declarative`, native pg-delta export). The rare +`--experimental` structured-dump and initial-pull `pg_dump` (migra) sub-branches +delegate to the bundled Go binary. + ## Files Read -| Path | Format | When | -| -------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| 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 | ## Files Written -| Path | Format | When | -| ------------------------------------------------------ | ------ | ------ | -| `/supabase/migrations/_.sql` | SQL | always | +| Path | Format | When | +| ----------------------------------------------------------- | ------ | ------------------------------------- | +| `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff) | +| `/supabase/database/**` | SQL | `--declarative` | +| `~/.supabase//linked-project.json` | JSON | linked (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | + +## Docker + +- Edge-runtime container (pg-delta export / pg-delta or migra diff). +- Shadow Postgres container (provisioned + torn down via the Go `db __shadow` seam). +- `supabase/migra` container — the migra OOM bash fallback only. -## API Routes +## API Routes / DB -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path / SQL | Auth | Purpose | +| ------ | --------------------------------------------------- | ------ | -------------------------------- | +| POST | `/v1/projects/{ref}/roles` | Bearer | Temp login role when no password | +| GET | `/v1/projects/{ref}/pooler/config` | Bearer | IPv4 pooler fallback | +| GET | `/v1/projects/{ref}` | Bearer | Linked-project cache (post-run) | +| SQL | `SELECT version FROM …schema_migrations` | — | history reconciliation (remote) | +| SQL | `INSERT … ON CONFLICT … schema_migrations` (UPSERT) | — | history update (on confirmation) | ## 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 | +| 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` | structured-dump pull branch (delegates to Go) | no | +| `PGDELTA_NPM_REGISTRY` | scoped npm registry for edge-runtime | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | schema pull error | +| Code | Condition | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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 | + +> 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. ## Output ### `--output-format text` (Go CLI compatible) -Prints `Finished supabase db pull.` on success. - -### `--output-format json` - -Not applicable. +Progress to stderr. Migration path: `Creating shadow database...`, +`Diffing schemas[: ]`, `Schema written to `. Declarative path: +`Preparing declarative schema export using pg-delta...`, `Declarative schema +written to `. Plus the `--use-pg-delta` deprecation line and the +history-update prompt. On success the PostRun line `Finished supabase db pull.` +is printed to stdout. -### `--output-format stream-json` +### `--output-format json` / `stream-json` -Not applicable. +Progress strings still go to stderr; stdout carries a single structured envelope +`{ declarative, schemaWritten, remoteHistoryUpdated, engine }` and suppresses the +`Finished supabase db pull.` line. -## Notes +## Notes / Delegation -- Optional positional argument sets the migration name (defaults to `remote_schema`). -- `--schema` / `-s` restricts pull to specific schemas. -- `--db-url`, `--linked` (default true), and `--local` are mutually exclusive. -- `--declarative` activates declarative pull output through pg-delta (writes `./database` files instead of a migration). `--use-pg-delta` is a deprecated alias. -- `--diff-engine migra|pg-delta` selects the diff engine for migration-style pull; mutually exclusive with `--declarative` / `--use-pg-delta`. When the flag is omitted, the engine defaults to pg-delta if `[experimental.pgdelta] enabled = true` in `config.toml` (or `EXPERIMENTAL_PG_DELTA`), otherwise migra. An explicit `--diff-engine migra` always forces migra. Enabling pg-delta in config does not switch `db pull` to declarative output. +- `--declarative` / deprecated `--use-pg-delta` are mutually exclusive with + `--diff-engine`; `--db-url` / `--linked` (default) / `--local` are a target group. +- `--use-pg-delta` is hidden and emits the cobra deprecation line to stderr. +- The `--experimental` structured-dump branch and the initial-pull `pg_dump` (migra, + no local migrations) 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. 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 ba773ecbe8..d53964fab2 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.command.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.command.ts @@ -1,21 +1,34 @@ import { Argument, 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 { legacyParseSchemaFlags } from "../../../shared/legacy-schema-flags.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbPull } from "./pull.handler.ts"; +import { legacyDbPullRuntimeLayer } from "./pull.layers.ts"; const config = { name: Argument.string("migration name").pipe( Argument.withDescription("Optional name for the migration file."), Argument.optional, ), + // `--declarative` and the deprecated `--use-pg-delta` both bind to the same + // declarative-output mode in Go (`cmd/db.go:464-465`); both are mutually + // exclusive with `--diff-engine`. Modelled as `Option` so the mutex tracks + // pflag `Changed`. declarative: Flag.boolean("declarative").pipe( Flag.withDescription( "Pull schema as declarative files using pg-delta instead of creating a migration.", ), + Flag.optional, ), usePgDelta: Flag.boolean("use-pg-delta").pipe( - Flag.withDescription( - "Deprecated alias for --declarative. Use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead.", - ), + Flag.withDescription("Use pg-delta to pull declarative schema."), + // Go marks this deprecated (`cmd/db.go:466`); Effect V4 has no + // `Flag.withDeprecated`, so it is hidden and the handler emits the + // deprecation line to stderr, matching cobra's behaviour. + Flag.withHidden, + Flag.optional, ), diffEngine: Flag.choice("diff-engine", ["migra", "pg-delta"] as const).pipe( Flag.withDescription("Diff engine to use for migration-style db pull."), @@ -25,6 +38,10 @@ const config = { Flag.withAlias("s"), Flag.withDescription("Comma separated list of schema to include."), Flag.atLeast(0), + Flag.mapTryCatch( + (rawValues) => legacyParseSchemaFlags(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), ), dbUrl: Flag.string("db-url").pipe( Flag.withDescription( @@ -32,8 +49,14 @@ const config = { ), Flag.optional, ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Pulls from the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Pulls from the local database.")), + linked: Flag.boolean("linked").pipe( + Flag.withDescription("Pulls from the linked project."), + Flag.optional, + ), + local: Flag.boolean("local").pipe( + Flag.withDescription("Pulls from the local database."), + Flag.optional, + ), password: Flag.string("password").pipe( Flag.withAlias("p"), Flag.withDescription("Password to your remote Postgres database."), @@ -46,5 +69,24 @@ export type LegacyDbPullFlags = CliCommand.Command.Config.Infer; export const legacyDbPullCommand = Command.make("pull", config).pipe( Command.withDescription("Pull schema from the remote database."), Command.withShortDescription("Pull schema from the remote database"), - Command.withHandler((flags) => legacyDbPull(flags)), + Command.withHandler((flags) => + legacyDbPull(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + declarative: flags.declarative, + "use-pg-delta": flags.usePgDelta, + "diff-engine": flags.diffEngine, + schema: flags.schema, + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + // `password` is a credential — always reaches telemetry as ``. + password: flags.password, + }, + aliases: { s: "schema", p: "password" }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbPullRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.debug.ts b/apps/cli/src/legacy/commands/db/pull/pull.debug.ts new file mode 100644 index 0000000000..cf4d10ef3e --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.debug.ts @@ -0,0 +1,212 @@ +import { type FileSystem, Effect, type Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyBold } from "../../../shared/legacy-colors.ts"; +import { + type LegacyDebugBundle, + legacyDebugBundleMessage, + legacySaveDebugBundle, +} from "../shared/legacy-debug-bundle.ts"; +import { legacyPgDeltaTempPath } from "../shared/legacy-pgdelta.cache.ts"; +import { type LegacyPgDeltaContext, legacyExportCatalogPgDelta } from "../shared/legacy-pgdelta.ts"; + +// Go's `errInSync` (`internal/db/pull/pull.go:33`). +const ERR_IN_SYNC = "No schema changes found"; + +const byteLength = (value: string): number => new TextEncoder().encode(value).length; + +/** + * Port of Go's `redactPostgresURL` (`internal/db/pull/pgdelta_pull_debug.go`): + * replace the password (keeping the username) with `xxxxx`; an empty username + * becomes `redacted`; a URL with no userinfo is unchanged; a parse failure + * returns the literal ``. + */ +export function legacyRedactPostgresURL(raw: string): string { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return ""; + } + if (parsed.username !== "" || parsed.password !== "") { + if (parsed.username === "") parsed.username = "redacted"; + parsed.password = "xxxxx"; + } + return parsed.toString(); +} + +/** Port of Go's `formatConnectionInfo`: a single-line, password-redacted summary. */ +export function legacyFormatConnectionInfo( + conn: { + readonly host: string; + readonly port: number; + readonly user: string; + readonly database: string; + }, + url: string, +): string { + return `host=${conn.host} port=${conn.port} user=${conn.user} database=${conn.database} url=${legacyRedactPostgresURL(url)}`; +} + +/** Object counts extracted from a pg-delta catalog JSON blob (Go's `CatalogSummary`). */ +export interface LegacyCatalogSummary { + readonly totalObjects: number; + readonly bySchema: Record; +} + +/** + * Best-effort counts catalog objects grouped by schema name. Port of Go's + * `SummarizeCatalogJSON` / `walkCatalogObjects` (`internal/db/diff/pgdelta_debug.go`): + * a node counts when it has a `schema` string or a `schema.name`, and children are + * always recursed (so nested catalogs can contribute multiple counts, as in Go). + */ +export function legacySummarizeCatalogJson(catalogJson: string): LegacyCatalogSummary { + const bySchema: Record = {}; + let total = 0; + if (catalogJson.trim().length === 0) return { totalObjects: 0, bySchema }; + let root: unknown; + try { + root = JSON.parse(catalogJson); + } catch { + return { totalObjects: 0, bySchema }; + } + const schemaName = (node: Record): string | undefined => { + const schema = node["schema"]; + if (typeof schema === "string" && schema.length > 0) return schema; + if (typeof schema === "object" && schema !== null && !Array.isArray(schema)) { + const name = (schema as Record)["name"]; + if (typeof name === "string" && name.length > 0) return name; + } + return undefined; + }; + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + for (const child of node) walk(child); + return; + } + if (typeof node === "object" && node !== null) { + const record = node as Record; + const schema = schemaName(record); + if (schema !== undefined) { + total += 1; + bySchema[schema] = (bySchema[schema] ?? 0) + 1; + } + for (const child of Object.values(record)) walk(child); + } + }; + walk(root); + return { totalObjects: total, bySchema }; +} + +/** Port of Go's `formatCatalogSummary`. */ +export function legacyFormatCatalogSummary(label: string, summary: LegacyCatalogSummary): string { + if (summary.totalObjects === 0) return `${label} catalog: no objects detected`; + const parts = Object.entries(summary.bySchema).map(([schema, count]) => `${schema}=${count}`); + return `${label} catalog: ${summary.totalObjects} objects (${parts.join(", ")})`; +} + +/** Port of Go's `formatByteSize` (`%.1f MB` / `%.1f KB` / `%d B`). */ +export function legacyFormatByteSize(size: number): string { + if (size >= 1 << 20) return `${(size / (1 << 20)).toFixed(1)} MB`; + if (size >= 1 << 10) return `${(size / (1 << 10)).toFixed(1)} KB`; + return `${size} B`; +} + +/** + * Builds the stderr summary block printed before the issue-report message. Port + * of Go's `printEmptyPgDeltaPullSummary` (`internal/db/pull/pgdelta_pull_debug.go`). + */ +export function legacyFormatEmptyPgDeltaPullSummary( + debugDir: string, + sourceCatalog: string, + targetCatalog: string, +): string { + const lines = [ + "pg-delta returned 0 statements.", + `Debug bundle saved to ${legacyBold(debugDir)}`, + ]; + if (sourceCatalog.trim().length > 0) { + lines.push( + `${legacyFormatCatalogSummary("Shadow", legacySummarizeCatalogJson(sourceCatalog))} (${legacyFormatByteSize(byteLength(sourceCatalog))})`, + ); + } + if (targetCatalog.trim().length > 0) { + lines.push( + `${legacyFormatCatalogSummary("Remote", legacySummarizeCatalogJson(targetCatalog))} (${legacyFormatByteSize(byteLength(targetCatalog))})`, + ); + } else { + lines.push( + "Remote catalog: export failed or empty (inspect connection.txt and pgdelta-stderr.txt)", + ); + } + return `${lines.join("\n")}\n`; +} + +/** + * Saves the pg-delta empty-diff debug bundle and returns its directory. Port of + * Go's `saveEmptyPgDeltaPullDebug` (`internal/db/pull/pgdelta_pull_debug.go`): + * export the remote/target catalog (warn and continue on failure), write the + * bundle (source/target catalog, stderr, connection.txt, error.txt), then print + * the summary + issue-report message. The shadow source catalog and pg-delta + * stderr are captured during the diff run and passed in. + */ +export const legacySaveEmptyPgDeltaPullDebug = Effect.fnUntraced(function* (params: { + readonly ctx: LegacyPgDeltaContext; + readonly conn: { + readonly host: string; + readonly port: number; + readonly user: string; + readonly database: string; + }; + readonly targetUrl: string; + readonly sourceCatalog: string | undefined; + readonly pgDeltaStderr: string | undefined; + readonly id: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly workdir: string; +}) { + const output = yield* Output; + // Export the remote catalog at debug time (Go connects to the remote `config` + // directly here, not the shadow); a failure only warns — the bundle is still + // written with the catalogs/stderr captured during the diff. + const targetCatalog = yield* legacyExportCatalogPgDelta(params.ctx, { + targetRef: params.targetUrl, + role: "postgres", + }).pipe( + Effect.catch((error) => + output + .raw(`Warning: failed to export remote pg-delta catalog: ${error.message}\n`, "stderr") + .pipe(Effect.as("")), + ), + ); + + const bundle: LegacyDebugBundle = { + id: params.id, + connectionInfo: legacyFormatConnectionInfo(params.conn, params.targetUrl), + error: ERR_IN_SYNC, + ...(params.sourceCatalog !== undefined && params.sourceCatalog.length > 0 + ? { sourceCatalog: params.sourceCatalog } + : {}), + ...(targetCatalog.length > 0 ? { targetCatalog } : {}), + ...(params.pgDeltaStderr !== undefined && params.pgDeltaStderr.length > 0 + ? { pgDeltaStderr: params.pgDeltaStderr } + : {}), + }; + const tempDir = legacyPgDeltaTempPath(params.path, params.workdir); + const migrationsDir = params.path.join(params.workdir, "supabase", "migrations"); + const debugDir = yield* legacySaveDebugBundle( + params.fs, + params.path, + params.workdir, + tempDir, + migrationsDir, + bundle, + ); + yield* output.raw( + legacyFormatEmptyPgDeltaPullSummary(debugDir, params.sourceCatalog ?? "", targetCatalog), + "stderr", + ); + yield* output.raw(legacyDebugBundleMessage(debugDir), "stderr"); + return debugDir; +}); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts new file mode 100644 index 0000000000..dc83ad49db --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyFormatByteSize, + legacyFormatCatalogSummary, + legacyFormatConnectionInfo, + legacyFormatEmptyPgDeltaPullSummary, + legacyRedactPostgresURL, + legacySummarizeCatalogJson, +} from "./pull.debug.ts"; + +// ANSI may wrap the bold debugDir; strip for assertions. +// eslint-disable-next-line no-control-regex +const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); + +describe("legacyRedactPostgresURL", () => { + it("replaces the password but keeps the username", () => { + expect(legacyRedactPostgresURL("postgresql://postgres:secret@db.host:5432/postgres")).toBe( + "postgresql://postgres:xxxxx@db.host:5432/postgres", + ); + }); + + it("uses 'redacted' as the username when only a password is present", () => { + expect(legacyRedactPostgresURL("postgresql://:secret@db.host:5432/postgres")).toBe( + "postgresql://redacted:xxxxx@db.host:5432/postgres", + ); + }); + + it("leaves a URL without userinfo unchanged", () => { + expect(legacyRedactPostgresURL("postgresql://db.host:5432/postgres")).toBe( + "postgresql://db.host:5432/postgres", + ); + }); + + it("returns on a parse failure", () => { + expect(legacyRedactPostgresURL("not a url")).toBe(""); + }); +}); + +describe("legacyFormatConnectionInfo", () => { + it("renders a single redacted line and never leaks the password", () => { + const info = legacyFormatConnectionInfo( + { host: "db.host", port: 5432, user: "postgres", database: "postgres" }, + "postgresql://postgres:secret@db.host:5432/postgres", + ); + expect(info).toBe( + "host=db.host port=5432 user=postgres database=postgres url=postgresql://postgres:xxxxx@db.host:5432/postgres", + ); + expect(info).not.toContain("secret"); + }); +}); + +describe("legacySummarizeCatalogJson", () => { + it("counts objects grouped by schema name (string and nested forms)", () => { + const catalog = JSON.stringify({ + tables: [ + { schema: "public", name: "t1" }, + { schema: "public", name: "t2" }, + { schema: { name: "auth" }, name: "users" }, + ], + }); + const summary = legacySummarizeCatalogJson(catalog); + expect(summary.totalObjects).toBe(3); + expect(summary.bySchema).toEqual({ public: 2, auth: 1 }); + }); + + it("returns an empty summary for blank or invalid JSON", () => { + expect(legacySummarizeCatalogJson("")).toEqual({ totalObjects: 0, bySchema: {} }); + expect(legacySummarizeCatalogJson("{not json")).toEqual({ totalObjects: 0, bySchema: {} }); + }); +}); + +describe("legacyFormatCatalogSummary", () => { + it("reports no objects detected for an empty catalog", () => { + expect(legacyFormatCatalogSummary("Shadow", { totalObjects: 0, bySchema: {} })).toBe( + "Shadow catalog: no objects detected", + ); + }); + + it("lists object counts per schema", () => { + expect(legacyFormatCatalogSummary("Remote", { totalObjects: 2, bySchema: { public: 2 } })).toBe( + "Remote catalog: 2 objects (public=2)", + ); + }); +}); + +describe("legacyFormatByteSize", () => { + it("formats B / KB / MB like Go", () => { + expect(legacyFormatByteSize(512)).toBe("512 B"); + expect(legacyFormatByteSize(2048)).toBe("2.0 KB"); + expect(legacyFormatByteSize(3 * 1024 * 1024)).toBe("3.0 MB"); + }); +}); + +describe("legacyFormatEmptyPgDeltaPullSummary", () => { + it("includes both catalog summaries when present", () => { + const out = stripAnsi( + legacyFormatEmptyPgDeltaPullSummary( + "supabase/.temp/pgdelta/debug/20240101-000000", + JSON.stringify({ t: [{ schema: "public", name: "a" }] }), + JSON.stringify({ t: [{ schema: "public", name: "a" }] }), + ), + ); + expect(out).toContain("pg-delta returned 0 statements."); + expect(out).toContain("Debug bundle saved to supabase/.temp/pgdelta/debug/20240101-000000"); + expect(out).toContain("Shadow catalog: 1 objects (public=1)"); + expect(out).toContain("Remote catalog: 1 objects (public=1)"); + }); + + it("notes a failed/empty remote catalog export", () => { + const out = stripAnsi(legacyFormatEmptyPgDeltaPullSummary("d", "", "")); + expect(out).toContain( + "Remote catalog: export failed or empty (inspect connection.txt and pgdelta-stderr.txt)", + ); + expect(out).not.toContain("Shadow catalog:"); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.e2e.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.e2e.test.ts new file mode 100644 index 0000000000..c11ea3b491 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.e2e.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +describe("supabase db pull (legacy)", () => { + // Docker-free golden-path: the `--declarative` / `--diff-engine` mutual-exclusion + // is validated before any connection or shadow work, so this exits non-zero + // through a real subprocess without Docker. + test( + "--declarative with --diff-engine exits non-zero (mutually exclusive)", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode } = await runSupabase( + ["db", "pull", "--declarative", "--diff-engine", "migra"], + { entrypoint: "legacy" }, + ); + expect(exitCode).not.toBe(0); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts new file mode 100644 index 0000000000..eaf81d3da6 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts @@ -0,0 +1,51 @@ +import { Data } from "effect"; + +/** + * Conflicting database-target flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error byte-for-byte + * (`apps/cli-go/cmd/db.go:472`). + */ +export class LegacyDbPullTargetFlagsError extends Data.TaggedError("LegacyDbPullTargetFlagsError")<{ + readonly message: string; +}> {} + +/** + * `--declarative` / `--use-pg-delta` combined with `--diff-engine`. Reproduces + * cobra's `MarkFlagsMutuallyExclusive` for `[declarative diff-engine]` and + * `[use-pg-delta diff-engine]` (`apps/cli-go/cmd/db.go:473-474`). + */ +export class LegacyDbPullEngineConflictError extends Data.TaggedError( + "LegacyDbPullEngineConflictError", +)<{ + readonly message: string; +}> {} + +/** + * The remote migration history does not match local files. Byte-matches Go's + * `errConflict` (`internal/db/pull/pull.go:35`); the actionable + * `supabase migration repair` suggestion is attached separately. + */ +export class LegacyDbPullMigrationConflictError extends Data.TaggedError( + "LegacyDbPullMigrationConflictError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** + * The diff produced no schema changes. Byte-matches Go's `errInSync` + * (`internal/db/pull/pull.go:34`). Like Go, this surfaces as a (non-zero exit) + * error rather than a success — `db pull` returns it from `Run`, unlike `db diff` + * which prints it and exits 0. + */ +export class LegacyDbPullInSyncError extends Data.TaggedError("LegacyDbPullInSyncError")<{ + readonly message: string; +}> {} + +/** + * Writing the migration file / updating the remote migration-history table failed. + * Wraps Go's `failed to write migration file` / `failed to update migration table`. + */ +export class LegacyDbPullWriteError extends Data.TaggedError("LegacyDbPullWriteError")<{ + readonly message: string; +}> {} 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 01a925035d..b07a307a54 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -1,20 +1,582 @@ -import { Effect, Option } from "effect"; +import { Clock, Effect, FileSystem, Option, Path } from "effect"; + +import { + LegacyDnsResolverFlag, + LegacyExperimentalFlag, + LegacyYesFlag, +} from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyAqua, legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { legacyIsIPv6ConnectivityError } from "../../../shared/legacy-connect-errors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { + legacyReadDbToml, + legacyResolveDeclarativeDir, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts"; +import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; +import { legacySchemaToCsvField } from "../../../shared/legacy-schema-flags.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + legacyUpdateDeclarativeSchemaPathsConfig, + legacyWriteDeclarativeSchemas, +} from "../shared/legacy-pgdelta.write.ts"; +import { + legacyParseBoolEnv, + legacyResolvePullDiffEngine, + legacyShouldUsePgDelta, +} from "../shared/legacy-diff-engine.ts"; +import { legacyDiffMigra } from "../shared/legacy-migra.ts"; +import { + legacyFormatMigrationTimestamp, + legacyGetMigrationPath, +} from "../shared/legacy-migration-file.ts"; +import { legacyFormatDebugId } from "../shared/legacy-debug-bundle.ts"; +import { + type LegacyPgDeltaContext, + legacyDeclarativeExportPgDelta, + legacyDiffPgDelta, + legacyExportCatalogPgDelta, + legacyIsPgDeltaDebugEnabled, +} from "../shared/legacy-pgdelta.ts"; +import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; +import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; +import { + LegacyDbPullEngineConflictError, + LegacyDbPullInSyncError, + LegacyDbPullMigrationConflictError, + LegacyDbPullTargetFlagsError, + LegacyDbPullWriteError, +} from "./pull.errors.ts"; +import { + legacyListRemoteMigrations, + legacyLoadLocalVersions, + legacyReconcileMigrations, + legacyUpdateMigrationHistory, +} from "./pull.sync.ts"; -export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: LegacyDbPullFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "pull"]; +// 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 `.`. +const DEPRECATION_LINE = + "Flag --use-pg-delta has been deprecated, use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead."; + +/** Builds a plain Postgres URL from a resolved connection (Go's `ToPostgresURL`). */ +const connToUrl = (conn: LegacyPgConnInput): string => + legacyToPostgresURL({ + host: conn.host, + port: conn.port, + user: conn.user, + password: conn.password, + database: conn.database, + ...(conn.options !== undefined ? { options: conn.options } : {}), + ...(conn.runtimeParams !== undefined ? { runtimeParams: conn.runtimeParams } : {}), + // Preserve a `--db-url` connect_timeout; Go's ToPostgresURL serializes the + // parsed ConnectTimeout (`connect.go`), defaulting to 10 only when unset. + ...(conn.connectTimeoutSeconds !== undefined + ? { connectTimeoutSeconds: conn.connectTimeoutSeconds } + : {}), + }); + +/** Rebuilds the `db pull` argv for the Go-delegated branches (initial-migra / EXPERIMENTAL dump). */ +const rebuildDelegateArgs = (flags: LegacyDbPullFlags): Array => { + const args = ["db", "pull"]; if (Option.isSome(flags.name)) args.push(flags.name.value); - if (flags.declarative) args.push("--declarative"); - if (flags.usePgDelta) args.push("--use-pg-delta"); + const pushBool = (name: string, value: Option.Option) => { + // Engine/output flags act on their value; `Some(false)` equals the default. + if (Option.isSome(value) && value.value) args.push(`--${name}`); + }; + 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 + // Changed-but-false flag still selects that target. Forward whenever `Some` + // so the delegated child resolves the same target the native path did, instead + // of falling through to a different default. + if (Option.isSome(value)) args.push(value.value ? `--${name}` : `--${name}=false`); + }; + pushBool("declarative", flags.declarative); + pushBool("use-pg-delta", flags.usePgDelta); if (Option.isSome(flags.diffEngine)) args.push("--diff-engine", flags.diffEngine.value); - for (const s of flags.schema) { - args.push("--schema", s); - } + // Re-encode each parsed schema as a CSV field so the Go child's pflag StringSlice + // CSV parse doesn't re-split a comma-containing schema (e.g. `"tenant,one"`). + for (const s of flags.schema) args.push("--schema", legacySchemaToCsvField(s)); if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); + pushTarget("linked", flags.linked); + pushTarget("local", flags.local); if (Option.isSome(flags.password)) args.push("--password", flags.password.value); - yield* proxy.exec(args); + return args; +}; + +export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: LegacyDbPullFlags) { + 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; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const yes = yield* LegacyYesFlag; + const experimental = yield* LegacyExperimentalFlag; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dnsResolver = yield* LegacyDnsResolverFlag; + + let linkedRefForCache: string | undefined; + + yield* Effect.gen(function* () { + const name = Option.getOrElse(flags.name, () => "remote_schema"); + // `--declarative` and the deprecated `--use-pg-delta` both select declarative + // output (Go binds both to `useDeclarative`, `cmd/db.go:464-465`). + const useDeclarative = + Option.getOrElse(flags.declarative, () => false) || + Option.getOrElse(flags.usePgDelta, () => false); + if (Option.isSome(flags.usePgDelta)) { + yield* output.raw(`${DEPRECATION_LINE}\n`, "stderr"); + } + + // cobra mutex groups: `[db-url linked local]`, `[declarative diff-engine]`, + // `[use-pg-delta diff-engine]` (`cmd/db.go:472-474`). "set" = pflag `Changed`. + const targetSet: Array = []; + if (Option.isSome(flags.dbUrl)) targetSet.push("db-url"); + if (Option.isSome(flags.linked)) targetSet.push("linked"); + if (Option.isSome(flags.local)) targetSet.push("local"); + if (targetSet.length > 1) { + return yield* Effect.fail( + new LegacyDbPullTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${[...targetSet].sort().join(" ")}] were all set`, + }), + ); + } + for (const [flagName, present] of [ + ["declarative", Option.isSome(flags.declarative)], + ["use-pg-delta", Option.isSome(flags.usePgDelta)], + ] as const) { + if (present && Option.isSome(flags.diffEngine)) { + return yield* Effect.fail( + new LegacyDbPullEngineConflictError({ + message: `if any flags in the group [${flagName} diff-engine] are set none of the others can be; [${[flagName, "diff-engine"].sort().join(" ")}] were all set`, + }), + ); + } + } + + const connType: LegacyDbConnType = Option.isSome(flags.dbUrl) + ? "db-url" + : Option.isSome(flags.local) + ? "local" + : "linked"; + const resolved = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + password: flags.password ?? Option.none(), + }); + const linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); + if (linkedRef !== undefined) linkedRefForCache = linkedRef; + const targetUrl = connToUrl(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, () => ""), + cwd: cliConfig.workdir, + npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), + denoVersion: toml.denoVersion, + }; + const formatOptions = Option.getOrElse(toml.pgDelta.formatOptions, () => ""); + + // Container-level pooler fallback (Go's `PoolerFallbackConfig`, + // `internal/db/dump/pooler_fallback.go`, wired into `diffRemoteSchema` and + // `pullDeclarativePgDelta`, `internal/db/pull/pull.go`). A linked pull can reach + // the direct host from the CLI process (so the resolver returned the direct + // conn) yet fail from inside the edge-runtime container on an IPv6-only Docker + // network. When the differ/export error classifies as an IPv6 connectivity + // failure, retry once through the project's IPv4 transaction pooler, reusing the + // same shadow source. Gated to the `--linked` path with a direct + // `db..` connection (Go's `PoolerFallbackEligible` + + // `ProjectRefFromDirectDbHost`). The error message embeds the container stderr + // (edge-runtime/migra errors wrap it), which is what Go classifies. + const withPoolerFallback = ( + directTarget: string, + attempt: (targetRef: string) => Effect.Effect, + ) => + attempt(directTarget).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if ( + connType === "linked" && + !resolved.isLocal && + resolved.conn.host.startsWith("db.") && + resolved.conn.host.endsWith(`.${cliConfig.projectHost}`) && + legacyIsIPv6ConnectivityError(error.message) + ) { + // Go's `PoolerFallbackConfig` returns `ok=false` on ANY resolution + // error and the caller then surfaces the ORIGINAL diff error, so a + // resolution failure is treated as "no fallback" (re-fail original). + const pooler = yield* resolver + .resolvePoolerFallback({ + dbUrl: flags.dbUrl, + connType: "linked", + dnsResolver, + password: flags.password ?? Option.none(), + }) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isSome(pooler)) { + yield* output.raw( + `${legacyYellow( + `Warning: Direct connection to ${resolved.conn.host} is unavailable because this environment does not support IPv6.\nRetrying via the IPv4 connection pooler.`, + )}\n`, + "stderr", + ); + return yield* attempt(connToUrl(pooler.value)); + } + } + return yield* Effect.fail(error); + }), + ), + ); + + const usePgDeltaDiff = legacyResolvePullDiffEngine({ + engineFlagChanged: Option.isSome(flags.diffEngine), + engine: Option.getOrElse(flags.diffEngine, () => "migra"), + pgDeltaDefault: legacyShouldUsePgDelta({ + configEnabled: toml.pgDelta.enabled, + usePgDeltaFlag: false, + envEnabled: legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), + }), + }); + + // Runs a Go-delegated pull (initial-migra / EXPERIMENTAL structured dump). In + // machine-output mode the child's stdout is captured and a structured envelope + // is emitted instead, so scripted callers get valid JSON rather than the Go + // child's human output on stdout (CLI-1546: stdout is payload-only in machine + // mode). The child is run with a non-TTY stdin (`"ignore"`) so the migration + // path's "Update remote migration history table?" prompt (Go's `PromptYesNo`, + // `internal/db/pull/pull.go:73`) takes its `true` default without blocking the + // JSON caller. `remoteHistoryUpdated` is passed per call site because the two + // delegated Go paths differ: the initial-migra path prompts + calls + // `repair.UpdateMigrationTable` (so `true`), while the EXPERIMENTAL structured + // dump returns before writing a migration or touching `schema_migrations` + // (`pull.go:49-61`, so `false`). `schemaWritten` stays `null` — the child owns + // the write and doesn't surface the path on stdout. + const delegatePull = ( + engine: "migra" | "pg-delta", + opts: { readonly remoteHistoryUpdated: boolean }, + ) => + Effect.gen(function* () { + const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; + if (output.format !== "text") { + yield* proxy.execCapture(rebuildDelegateArgs(flags), { env, stdin: "ignore" }); + yield* output.success("Schema pulled.", { + declarative: false, + schemaWritten: null, + remoteHistoryUpdated: opts.remoteHistoryUpdated, + engine, + }); + return; + } + yield* proxy.exec(rebuildDelegateArgs(flags), { env }); + }); + + // Connectivity check (Go's `ConnectByConfig` at the top of `pull.Run`). + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* connection.connect(resolved.conn, { + isLocal: resolved.isLocal, + dnsResolver, + }); + + // Declarative export path (Go's `pullDeclarativePgDelta`). + if (useDeclarative) { + 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))); + yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, exported).pipe( + Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), + ); + // Go's WriteDeclarativeSchemas also points [db.migrations] schema_paths at + // 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: + // without it, subsequent db reset/db diff keep reading supabase/migrations + // and ignore the files just pulled. + if (!toml.pgDelta.enabled) { + yield* legacyUpdateDeclarativeSchemaPathsConfig( + fs, + path, + cliConfig.workdir, + declarativeDirRel, + ).pipe( + Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), + ); + } + yield* output.raw( + `Declarative schema written to ${legacyBold(declarativeDir)}\n`, + "stderr", + ); + if (output.format !== "text") { + yield* output.success("Declarative schema pulled.", { + declarative: true, + schemaWritten: declarativeDir, + remoteHistoryUpdated: false, + engine: "pg-delta", + }); + } else { + yield* output.raw(`Finished ${legacyAqua("supabase db pull")}.\n`); + } + return; + } + + // Go's `EXPERIMENTAL` structured-dump branch depends on unported `pg_dump` + // — delegate the whole pull to Go. viper resolves `EXPERIMENTAL` from + // *either* the global `--experimental` pflag or `SUPABASE_EXPERIMENTAL` + // (`cmd/root.go:318-320,327,334`), so honor both forms here; the legacy + // root only forwards `--experimental` to Go proxy argv, never into env. + if (experimental || legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL"))) { + // Go's structured-dump path returns before writing a migration or + // touching schema_migrations (`pull.go:49-61`), so no history repair. + yield* delegatePull(usePgDeltaDiff ? "pg-delta" : "migra", { + remoteHistoryUpdated: false, + }); + return; + } + + // Migration-file path (Go's `pull.run`). + const timestamp = legacyFormatMigrationTimestamp(yield* Clock.currentTimeMillis); + const migrationPath = legacyGetMigrationPath(path, cliConfig.workdir, timestamp, name); + + const remote = yield* legacyListRemoteMigrations(session); + const local = yield* legacyLoadLocalVersions( + fs, + path, + path.join(cliConfig.workdir, "supabase", "migrations"), + ); + const sync = legacyReconcileMigrations(remote, local); + if (sync.kind === "conflict") { + return yield* Effect.fail( + new LegacyDbPullMigrationConflictError({ + message: + "The remote database's migration history does not match local files in supabase/migrations directory.", + suggestion: sync.suggestion, + }), + ); + } + if (sync.kind === "missing" && !usePgDeltaDiff) { + // Initial pull with the migra engine needs `pg_dump` — delegate to Go. + // Go's migration path prompts + updates schema_migrations on the non-TTY + // default (`pull.go:73-76`), so the history is repaired. + yield* delegatePull("migra", { remoteHistoryUpdated: true }); + return; + } + + // Native diff: shadow (baseline + local migrations) vs remote → migration SQL. + // 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, + 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, capture: undefined }; + }), + ); + }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + + const out = diffOutcome.sql; + if (out.trim().length === 0) { + // 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 + // bundle-save failure falls through to the plain in-sync error. + if (diffOutcome.capture !== undefined) { + const debugDir = yield* legacySaveEmptyPgDeltaPullDebug({ + ctx, + conn: resolved.conn, + targetUrl, + sourceCatalog: diffOutcome.capture.sourceCatalog, + pgDeltaStderr: diffOutcome.capture.stderr, + id: legacyFormatDebugId(yield* Clock.currentTimeMillis), + fs, + path, + workdir: cliConfig.workdir, + }).pipe( + Effect.catch((error) => + output + .raw( + `Warning: failed to save pg-delta debug bundle: ${error.message}\n`, + "stderr", + ) + .pipe(Effect.as(undefined)), + ), + ); + if (debugDir !== undefined) { + return yield* Effect.fail( + new LegacyDbPullInSyncError({ + message: `No schema changes found (debug bundle: ${debugDir})`, + }), + ); + } + } + return yield* Effect.fail( + new LegacyDbPullInSyncError({ message: "No schema changes found" }), + ); + } + yield* fs + .makeDirectory(path.dirname(migrationPath), { recursive: true }) + .pipe(Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message }))); + yield* fs.writeFileString(migrationPath, out).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to write migration file: ${cause.message}`, + }), + ), + ); + yield* output.raw(`Schema written to ${legacyBold(migrationPath)}\n`, "stderr"); + + // 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 + // `--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?"; + const shouldUpdate = yield* Effect.gen(function* () { + // Machine output (json/stream-json) never prompts — the non-text layers + // report non-interactive and fail every prompt — so take Go's default. + if (output.format !== "text") return true; + if (yes) { + yield* output.raw(`${updateHistoryTitle} [Y/n] y\n`, "stderr"); + return true; + } + // A non-interactive stdin or any prompt error falls back to the default, + // matching Go's `PromptYesNo` returning `def` on error/timeout. + return yield* output + .promptConfirm(updateHistoryTitle, { defaultValue: true }) + .pipe(Effect.orElseSucceed(() => true)); + }); + if (shouldUpdate) { + yield* legacyUpdateMigrationHistory(session, fs, path, migrationPath, timestamp); + remoteHistoryUpdated = true; + } + + if (output.format !== "text") { + yield* output.success("Schema pulled.", { + declarative: false, + schemaWritten: migrationPath, + remoteHistoryUpdated, + engine: usePgDeltaDiff ? "pg-delta" : "migra", + }); + } else { + yield* output.raw(`Finished ${legacyAqua("supabase db pull")}.\n`); + } + }), + ); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); 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 new file mode 100644 index 0000000000..940af81d15 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -0,0 +1,792 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { + LegacyDnsResolverFlag, + LegacyExperimentalFlag, + LegacyNetworkIdFlag, + LegacyYesFlag, +} from "../../../../shared/legacy/global-flags.ts"; +import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import type { OutputFormat } from "../../../../shared/output/types.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 { 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 EXPORT_JSON = JSON.stringify({ + version: 1, + mode: "declarative", + files: [{ path: "schemas/public/t.sql", order: 0, statements: 1, sql: "create table t ();" }], +}); + +interface SetupOpts { + readonly format?: OutputFormat; + readonly remoteVersions?: ReadonlyArray; + readonly edgeStdout?: string; // diff SQL or declarative export JSON + readonly stdinIsTty?: boolean; + 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 + // `edgeStdout`), to exercise the pooler-fallback retry. + readonly edgeFailFirstWith?: string; + // resolvePoolerFallback returns Some(pooler conn) when true, None otherwise. + readonly poolerAvailable?: boolean; + readonly delegateStdout?: string; // stdout returned by a captured Go-delegate run + readonly catalogStdout?: string; // stdout returned by pg-delta catalog-export runs +} + +function setup(workdir: string, opts: SetupOpts = {}) { + const out = mockOutput({ + format: opts.format ?? "text", + promptConfirmResponses: opts.promptConfirmResponses, + }); + 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"), + execInherit: () => Effect.succeed(0), + ensureLocalDatabaseStarted: () => 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); + }), + }); + + let edgeRunCount = 0; + const edge = Layer.succeed(LegacyEdgeRuntimeScript, { + run: (runOpts: LegacyEdgeRuntimeRunOpts) => { + edgeRunCount += 1; + if (opts.edgeFailFirstWith !== undefined && edgeRunCount === 1) { + return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: opts.edgeFailFirstWith })); + } + // pg-delta catalog exports (debug capture) use a distinct errPrefix; serve + // them their own stdout so an empty diff can still capture non-empty catalogs. + if (runOpts.errPrefix.includes("catalog")) { + return Effect.succeed({ stdout: opts.catalogStdout ?? "", stderr: "" }); + } + return Effect.succeed({ stdout: opts.edgeStdout ?? "", stderr: "" }); + }, + }); + + const docker = Layer.succeed(LegacyDockerRun, { + run: () => Effect.die("run unused"), + runCapture: () => Effect.die("runCapture unused"), + runStream: () => Effect.die("runStream unused"), + }); + + const execLog: string[] = []; + const historyUpserts: ReadonlyArray[] = []; + const session = { + 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); + return Effect.succeed([] as ReadonlyArray>); + }, + extensionExists: () => Effect.die("extensionExists unused"), + copyToCsv: () => Effect.die("copyToCsv unused"), + queryRaw: () => Effect.die("queryRaw unused"), + }; + const dbConnection = Layer.succeed(LegacyDbConnection, { + connect: () => Effect.succeed(session), + }); + + const poolerFallbackCalls: unknown[] = []; + const resolver = Layer.succeed(LegacyDbConfigResolver, { + resolve: ({ connType }) => + Effect.succeed({ + conn: { + // A direct `db..` host so the pooler-fallback gate + // (Go's ProjectRefFromDirectDbHost) matches on the linked path. + host: connType === "local" ? "127.0.0.1" : "db.abcdefghijklmnopqrst.supabase.co", + port: 5432, + user: "postgres", + password: "x", + database: "postgres", + }, + isLocal: connType === "local", + ref: opts.resolvedRef !== undefined ? Option.some(opts.resolvedRef) : Option.none(), + }), + resolvePoolerFallback: (resolveFlags) => { + poolerFallbackCalls.push(resolveFlags); + return Effect.succeed( + opts.poolerAvailable === true + ? Option.some({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 6543, + user: "postgres", + password: "x", + database: "postgres", + }) + : Option.none(), + ); + }, + }); + + const proxyCalls: Array<{ args: ReadonlyArray; env?: Record }> = []; + const proxyCaptureCalls: Array<{ + args: ReadonlyArray; + env?: Record; + stdin?: "inherit" | "ignore"; + }> = []; + const proxy = Layer.succeed(LegacyGoProxy, { + exec: (args, execOpts) => Effect.sync(() => void proxyCalls.push({ args, env: execOpts?.env })), + execCapture: (args, execOpts) => + Effect.sync(() => { + proxyCaptureCalls.push({ args, env: execOpts?.env, stdin: execOpts?.stdin }); + return opts.delegateStdout ?? ""; + }), + }); + + const layer = Layer.mergeAll( + out.layer, + telemetry.layer, + cache.layer, + seam, + edge, + docker, + dbConnection, + resolver, + proxy, + mockLegacyCliConfig({ workdir, projectId: Option.some("test") }), + mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyNetworkIdFlag, Option.none()), + Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false) }), + mockRuntimeInfo(), + BunServices.layer, + ); + + return { + layer, + out, + provisionCalls, + removedContainers, + proxyCalls, + proxyCaptureCalls, + historyUpserts, + execLog, + poolerFallbackCalls, + get edgeRunCount() { + return edgeRunCount; + }, + }; +} + +const flags = (over: Partial = {}): LegacyDbPullFlags => ({ + name: over.name ?? Option.none(), + declarative: over.declarative ?? Option.none(), + usePgDelta: over.usePgDelta ?? Option.none(), + diffEngine: over.diffEngine ?? Option.none(), + schema: over.schema ?? [], + dbUrl: over.dbUrl ?? Option.none(), + linked: over.linked ?? Option.none(), + local: over.local ?? Option.none(), + password: over.password ?? Option.none(), +}); + +// eslint-disable-next-line no-control-regex +const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); +const streamText = (out: ReturnType, stream: "stdout" | "stderr") => + stripAnsi( + out.rawChunks + .filter((c) => c.stream === stream) + .map((c) => c.text) + .join(""), + ); + +const seedMigration = (workdir: string, version: string) => { + const dir = join(workdir, "supabase", "migrations"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${version}_local.sql`), "create table local ();\n"); +}; + +const tmp = useLegacyTempWorkdir(); + +describe("legacy db pull", () => { + it.effect("pulls a migration (pgdelta engine) and updates remote history under --yes", () => { + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })); + const dir = join(tmp.current, "supabase", "migrations"); + expect(existsSync(join(dir, `${"20240101000000"}_local.sql`))).toBe(true); + // A new timestamped remote_schema migration was written. + expect(streamText(s.out, "stderr")).toContain("Schema written to"); + expect(s.historyUpserts.length).toBe(1); + expect(streamText(s.out, "stdout")).toContain("Finished supabase db pull."); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("pulls with the default migra engine", () => { + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.provisionCalls[0]?.usePgDelta).toBe(false); + expect(streamText(s.out, "stderr")).toContain("Schema written to"); + }).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* () { + yield* legacyDbPull(flags({ declarative: Option.some(true) })); + expect(streamText(s.out, "stderr")).toContain("Preparing declarative schema export"); + expect(streamText(s.out, "stderr")).toContain("Declarative schema written to"); + expect( + existsSync(join(tmp.current, "supabase", "database", "schemas", "public", "t.sql")), + ).toBe(true); + expect(s.provisionCalls[0]?.mode).toBe("declarative"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "pull --declarative writes [db.migrations] schema_paths when pg-delta is disabled", + () => { + // Go's WriteDeclarativeSchemas points schema_paths at the declarative dir when + // pg-delta is disabled in config (db pull does not force-enable it), so later + // db reset/db diff read the pulled files (declarative.go:260-268). + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\n"); + const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ declarative: Option.some(true) })); + const config = readFileSync(join(tmp.current, "supabase", "config.toml"), "utf8"); + expect(config).toContain("[db.migrations]"); + expect(config).toContain('schema_paths = [\n "database",\n]'); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("pull --declarative leaves schema_paths untouched when pg-delta is enabled", () => { + // For an enabled config the declarative dir is already the source of truth, so + // Go skips the schema_paths rewrite (the gate reads the config value). + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + const original = "[experimental.pgdelta]\nenabled = true\n"; + writeFileSync(join(tmp.current, "supabase", "config.toml"), original); + const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ declarative: Option.some(true) })); + const config = readFileSync(join(tmp.current, "supabase", "config.toml"), "utf8"); + expect(config).toBe(original); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("pull --declarative replaces an existing schema_paths block in place", () => { + // Go's regex replace-or-append rewrites a present schema_paths block rather + // than appending a duplicate (declarative.go:285-303). + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + '[db.migrations]\nschema_paths = [\n "schemas/*.sql",\n]\n', + ); + const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ declarative: Option.some(true) })); + const config = readFileSync(join(tmp.current, "supabase", "config.toml"), "utf8"); + expect(config).toContain('schema_paths = [\n "database",\n]'); + expect(config).not.toContain("schemas/*.sql"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "deprecated --use-pg-delta prints the deprecation line and behaves like --declarative", + () => { + const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ usePgDelta: Option.some(true) })); + expect(streamText(s.out, "stderr")).toContain("Flag --use-pg-delta has been deprecated"); + expect(streamText(s.out, "stderr")).toContain("Declarative schema written to"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("a migration-history conflict fails with the repair suggestion", () => { + seedMigration(tmp.current, "20240102000000"); + const s = setup(tmp.current, { remoteVersions: ["20240101000000"] }); + return Effect.gen(function* () { + const exit = yield* legacyDbPull(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an initial pull with no local migrations delegates the dump to Go (migra)", () => { + const s = setup(tmp.current, { remoteVersions: [] }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.proxyCalls).toHaveLength(1); + expect(s.proxyCalls[0]?.args[0]).toBe("db"); + expect(s.proxyCalls[0]?.args[1]).toBe("pull"); + expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an initial pull in json mode emits a structured envelope (delegated output)", () => { + // Regression: the initial-migra delegate inherited stdout and returned without + // output.success, so machine-mode callers got the Go child's human output + // instead of a JSON envelope (CLI-1546). Now the child's stdout is captured and + // a structured payload is emitted instead. + const s = setup(tmp.current, { + format: "json", + remoteVersions: [], + delegateStdout: "Schema written to supabase/migrations/x.sql\n", + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.proxyCalls).toHaveLength(0); + expect(s.proxyCaptureCalls).toHaveLength(1); + // The delegated child runs with a non-TTY stdin so its history-update prompt + // takes Go's default (true) without blocking the JSON caller; the child then + // updates the history, so the envelope reports remoteHistoryUpdated: true. + expect(s.proxyCaptureCalls[0]?.stdin).toBe("ignore"); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ + declarative: false, + schemaWritten: null, + remoteHistoryUpdated: true, + engine: "migra", + }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an in-sync pull (empty diff) fails with 'No schema changes found'", () => { + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "" }); + return Effect.gen(function* () { + const exit = yield* legacyDbPull(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "an empty pg-delta diff under PGDELTA_DEBUG saves a debug bundle and reports it", + () => { + // Go saves a debug bundle and embeds its path in the in-sync error when + // PGDELTA_DEBUG is set on an empty pg-delta diff (internal/db/pull/pull.go:176-185). + seedMigration(tmp.current, "20240101000000"); + const catalog = JSON.stringify({ tables: [{ schema: "public", name: "t" }] }); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "", // empty diff + catalogStdout: catalog, // shadow + remote catalog exports succeed + yes: true, + }); + return Effect.gen(function* () { + const prev = process.env["PGDELTA_DEBUG"]; + process.env["PGDELTA_DEBUG"] = "1"; + try { + const error = yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( + Effect.flip, + ); + expect(error.message).toContain("No schema changes found (debug bundle:"); + } finally { + if (prev === undefined) delete process.env["PGDELTA_DEBUG"]; + else process.env["PGDELTA_DEBUG"] = prev; + } + const debugRoot = join(tmp.current, "supabase", ".temp", "pgdelta", "debug"); + const ids = existsSync(debugRoot) ? readdirSync(debugRoot) : []; + expect(ids).toHaveLength(1); + const bundleDir = join(debugRoot, ids[0] ?? ""); + const files = readdirSync(bundleDir); + expect(files).toContain("source-catalog.json"); + expect(files).toContain("target-catalog.json"); + expect(files).toContain("connection.txt"); + expect(files).toContain("error.txt"); + expect(readFileSync(join(bundleDir, "error.txt"), "utf8")).toBe("No schema changes found"); + // connection.txt is password-redacted (Go's redactPostgresURL → xxxxx). + expect(readFileSync(join(bundleDir, "connection.txt"), "utf8")).toContain( + "url=postgresql://postgres:xxxxx@", + ); + expect(streamText(s.out, "stderr")).toContain("pg-delta returned 0 statements."); + expect(streamText(s.out, "stderr")).toContain("Debug bundle saved to"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("an empty pg-delta diff without PGDELTA_DEBUG writes no debug bundle", () => { + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "", yes: true }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( + Effect.flip, + ); + expect(error.message).toBe("No schema changes found"); + const debugRoot = join(tmp.current, "supabase", ".temp", "pgdelta", "debug"); + expect(existsSync(debugRoot) ? readdirSync(debugRoot) : []).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("prompts to update history and inserts on yes (tty)", () => { + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + stdinIsTty: true, + promptConfirmResponses: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("declining the history prompt does not insert (tty)", () => { + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + stdinIsTty: true, + promptConfirmResponses: [false], + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(0); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("emits a json envelope and suppresses 'Finished' in machine mode", () => { + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + format: "json", + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(streamText(s.out, "stdout")).not.toContain("Finished supabase db pull."); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ declarative: false, remoteHistoryUpdated: true }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("auto-accepts the history update in non-tty mode without --yes", () => { + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + stdinIsTty: false, + // no --yes: a non-interactive prompt falls back to the default (true), + // matching Go's PromptYesNo returning `def` on error/timeout. + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("SUPABASE_EXPERIMENTAL delegates the structured-dump pull to Go", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const prev = process.env["SUPABASE_EXPERIMENTAL"]; + process.env["SUPABASE_EXPERIMENTAL"] = "true"; + try { + yield* legacyDbPull(flags()); + } finally { + if (prev === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; + else process.env["SUPABASE_EXPERIMENTAL"] = prev; + } + expect(s.proxyCalls).toHaveLength(1); + expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("forwards an explicit --local=false target flag to the delegated pull", () => { + // Target flags are selectors keyed on flag.Changed in Go; dropping Some(false) + // would make the delegated child default to linked instead of the local target + // the native path selected. + const s = setup(tmp.current, { experimental: true }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ local: Option.some(false) })); + expect(s.proxyCalls[0]?.args).toContain("--local=false"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("the global --experimental flag delegates the structured-dump pull to Go", () => { + // viper resolves EXPERIMENTAL from the pflag OR the env var; the flag form + // (`supabase --experimental db pull`) must delegate just like the env form. + const s = setup(tmp.current, { experimental: true }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.proxyCalls).toHaveLength(1); + expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an experimental pull in json mode reports no remote-history repair", () => { + // Go's structured-dump path returns before writing a migration or touching + // schema_migrations (pull.go:49-61), so the envelope must not claim a repair. + const s = setup(tmp.current, { experimental: true, format: "json" }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.proxyCaptureCalls).toHaveLength(1); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ remoteHistoryUpdated: false }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("re-quotes a comma-containing schema when delegating the pull", () => { + // 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. + const s = setup(tmp.current, { experimental: true }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ 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("a project supabase/.env enabling pg-delta selects the pg-delta engine", () => { + // Go loads supabase/.env via godotenv before reading EXPERIMENTAL_PG_DELTA + // (config.go), so a project .env must select pg-delta even when the shell + // env doesn't set it. The handler reads it via toml.envLookup, not process.env. + seedMigration(tmp.current, "20240101000000"); + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL_PG_DELTA=true\n"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.provisionCalls[0]?.usePgDelta).toBe(true); + }).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. + seedMigration(tmp.current, "20240101000000"); + 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); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "a migration name with a path separator fails instead of an empty-version repair", + () => { + // Go globs `_*.sql` for the repair and fails with ErrNotExist when + // the name has a path separator (the file is nested), so the native path must + // not silently upsert an empty-version migration-history row. + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPull(flags({ name: Option.some("foo/bar") })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.historyUpserts.length).toBe(0); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("machine output in a TTY without --yes skips the prompt and emits the payload", () => { + // Regression: json/stream-json layers fail every prompt as non-interactive, + // so the history-update prompt must be skipped (Go default = yes) instead of + // failing the command before the structured success payload is emitted. + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + format: "json", + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + stdinIsTty: true, + // no --yes + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.historyUpserts.length).toBe(1); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ remoteHistoryUpdated: true }); + }).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 + // (flags/db_url.go:87-97). Base config disables pg-delta; the remote override + // enables it, so the migration-style pull must pick the pg-delta engine. + seedMigration(tmp.current, "20240101000000"); + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[experimental.pgdelta]", + "enabled = false", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + resolvedRef: "abcdefghijklmnopqrst", + }); + 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"); + }).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. + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeFailFirstWith: "error diffing schema:\nfailed to connect: network is unreachable", + edgeStdout: "create table remote ();\n", + yes: true, + poolerAvailable: true, + }); + return Effect.gen(function* () { + 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"); + expect(s.edgeRunCount).toBe(2); + expect(streamText(s.out, "stderr")).toContain("Schema written to"); + }).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). + const s = setup(tmp.current, { + edgeFailFirstWith: "error exporting declarative schema:\nnetwork is unreachable", + edgeStdout: EXPORT_JSON, + poolerAvailable: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ linked: Option.some(true), declarative: Option.some(true) })); + expect(streamText(s.out, "stderr")).toContain("Retrying via the IPv4 connection pooler"); + expect(s.edgeRunCount).toBe(2); + expect(streamText(s.out, "stderr")).toContain("Declarative schema written to"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("an IPv6 diff error with no pooler available surfaces the original error", () => { + // Go's PoolerFallbackConfig returns ok=false when the pooler can't be resolved, + // and the caller surfaces the ORIGINAL diff error rather than a retry error. + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeFailFirstWith: "error diffing schema:\nnetwork is unreachable", + yes: true, + poolerAvailable: false, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPull( + flags({ linked: Option.some(true), diffEngine: Option.some("pg-delta") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(streamText(s.out, "stderr")).not.toContain("Retrying via the IPv4 connection pooler"); + expect(s.edgeRunCount).toBe(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("a non-IPv6 diff error is not retried through the pooler", () => { + // Only IPv6 connectivity errors are eligible; any other failure surfaces as-is + // without consulting the pooler (Go's IsIPv6ConnectivityError gate). + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeFailFirstWith: 'error diffing schema:\nsyntax error at or near "foo"', + yes: true, + poolerAvailable: true, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPull( + flags({ linked: Option.some(true), diffEngine: Option.some("pg-delta") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.poolerFallbackCalls).toHaveLength(0); + expect(s.edgeRunCount).toBe(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("fails on --declarative with --diff-engine (mutual exclusion)", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyDbPull( + flags({ declarative: Option.some(true), diffEngine: Option.some("migra") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).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 new file mode 100644 index 0000000000..0e298b8bc0 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts @@ -0,0 +1,50 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.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 { legacyEdgeRuntimeScriptLayer } from "../../../shared/legacy-edge-runtime-script.layer.ts"; +import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; +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 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. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( + Layer.provide(legacyDockerRunLayer), + Layer.provide(cliConfig), +); + +const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); + +export const legacyDbPullRuntimeLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + legacyDockerRunLayer, + edgeRuntime, + legacyPgDeltaSslProbeLayer, + seam, + cliConfig, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + legacyLinkedDbResolverRuntimeLayer(["db", "pull"]).pipe(Layer.provide(legacyIdentityStitchLayer)), + commandRuntimeLayer(["db", "pull"]), +); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts new file mode 100644 index 0000000000..2d5016ea25 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts @@ -0,0 +1,228 @@ +import { Effect, type FileSystem, type Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyBold } from "../../../shared/legacy-colors.ts"; +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { legacySplitAndTrim } from "../../../shared/legacy-sql-split.ts"; +import { LegacyMigrationsReadError } from "../shared/legacy-pgdelta.errors.ts"; +import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; +import { LegacyDbPullWriteError } from "./pull.errors.ts"; + +/** `SELECT version FROM supabase_migrations.schema_migrations ORDER BY version`. */ +const LIST_MIGRATION_VERSION = + "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; + +// Migration-history DDL/DML, verbatim from Go's `pkg/migration/history.go`. +const SET_LOCK_TIMEOUT = "SET lock_timeout = '4s'"; +const CREATE_VERSION_SCHEMA = "CREATE SCHEMA IF NOT EXISTS supabase_migrations"; +const CREATE_VERSION_TABLE = + "CREATE TABLE IF NOT EXISTS supabase_migrations.schema_migrations (version text NOT NULL PRIMARY KEY)"; +const ADD_STATEMENTS_COLUMN = + "ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS statements text[]"; +const ADD_NAME_COLUMN = + "ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS name text"; +const UPSERT_MIGRATION_VERSION = + "INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES($1, $2, $3) ON CONFLICT (version) DO UPDATE SET name = EXCLUDED.name, statements = EXCLUDED.statements"; + +// `pkg/migration/file.go` — `_.sql`. +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; + +/** The outcome of comparing remote vs local migration histories. */ +export type LegacyMigrationSync = + | { readonly kind: "in-sync" } + | { readonly kind: "missing" } + | { readonly kind: "conflict"; readonly suggestion: string }; + +/** + * Reconciles the remote and local migration version lists. Pure port of Go's + * `assertRemoteInSync` two-pointer comparison (`internal/db/pull/pull.go:212-258`): + * versions that fail to parse as integers are skipped (Go's `Atoi` error → + * `continue`); any extra remote/local version is a conflict; an empty local set + * is `missing`; otherwise in-sync. + */ +export function legacyReconcileMigrations( + remote: ReadonlyArray, + local: ReadonlyArray, +): LegacyMigrationSync { + // Go's `math.MaxInt` on a 64-bit build == math.MaxInt64; the exhausted side pins + // here. Use BigInt so the full int64 range compares EXACTLY — `Number` loses + // precision above `Number.MAX_SAFE_INTEGER` (e.g. `Number("9999999999999999")` + // rounds to 1e16), which would mis-order versions Go accepts. + const MAX = 9223372036854775807n; + const extraRemote: Array = []; + const extraLocal: Array = []; + let i = 0; + let j = 0; + // Matches Go's `strconv.Atoi`: digits only, no empty/whitespace/sign/float. A + // non-parseable version is skipped (Go's `Atoi` error → `continue`). On 64-bit + // builds `Atoi` parses the full int64 range and returns a range error ONLY for + // values above int64 max; reject only those (so e.g. `9999999999999999`, which Go + // accepts and surfaces as a conflict, is NOT skipped) while still rejecting + // 19+-digit values above the sentinel so they can never exceed the exhausted-side + // pin and stall the two-pointer scan. + const parseVersion = (v: string): bigint | undefined => { + if (!/^\d+$/u.test(v)) return undefined; + const parsed = BigInt(v); + return parsed > MAX ? undefined : parsed; + }; + while (i < remote.length || j < local.length) { + let remoteTs = MAX; + if (i < remote.length) { + const parsed = parseVersion(remote[i]!); + if (parsed === undefined) { + i++; + continue; + } + remoteTs = parsed; + } + let localTs = MAX; + if (j < local.length) { + const parsed = parseVersion(local[j]!); + if (parsed === undefined) { + j++; + continue; + } + localTs = parsed; + } + if (localTs < remoteTs) { + extraLocal.push(local[j]!); + j++; + } else if (remoteTs < localTs) { + extraRemote.push(remote[i]!); + i++; + } else { + i++; + j++; + } + } + if (extraRemote.length + extraLocal.length > 0) { + return { kind: "conflict", suggestion: legacySuggestMigrationRepair(extraRemote, extraLocal) }; + } + if (local.length === 0) { + return { kind: "missing" }; + } + return { kind: "in-sync" }; +} + +/** Go's `suggestMigrationRepair` (`internal/db/pull/pull.go:280-289`). */ +export function legacySuggestMigrationRepair( + extraRemote: ReadonlyArray, + extraLocal: ReadonlyArray, +): string { + let result = + "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n"; + for (const version of extraRemote) { + result += `${legacyBold(`supabase migration repair --status reverted ${version}`)}\n`; + } + for (const version of extraLocal) { + result += `${legacyBold(`supabase migration repair --status applied ${version}`)}\n`; + } + return result; +} + +/** + * Lists the remote project's applied migration versions. Mirrors Go's + * `migration.ListRemoteMigrations` (`pkg/migration/list.go:18-31`): ONLY a missing + * history table (`pgerrcode.UndefinedTable` = `42P01`) means the remote has no + * migrations and returns `[]`; any other error (e.g. a malformed table missing the + * `version` column, `42703`) propagates rather than being silently treated as an + * initial pull. We match the SQLSTATE like Go; if the driver didn't surface a code, + * fall back to a message check that matches a missing relation but NOT a missing + * column. + */ +export const legacyListRemoteMigrations = (session: LegacyDbSession) => + session.query(LIST_MIGRATION_VERSION).pipe( + Effect.map((rows) => rows.map((row) => String(row["version"]))), + Effect.catch((error) => + legacyIsUndefinedTableError(error) + ? Effect.succeed>([]) + : Effect.fail(new LegacyMigrationsReadError({ message: error.message })), + ), + ); + +/** Whether a query error is Postgres `undefined_table` (42P01), matching Go's `pgerrcode.UndefinedTable`. */ +const legacyIsUndefinedTableError = (error: LegacyDbExecError): boolean => { + if (error.code !== undefined) return error.code === "42P01"; + // No SQLSTATE surfaced: a relation-not-exist message counts, a column-not-exist + // one does not (Postgres phrases an undefined column as `column "x" does not exist`). + return ( + /relation .* does not exist/iu.test(error.message) && + !/column .* does not exist/iu.test(error.message) + ); +}; + +/** + * Loads the local migration versions (the `` prefixes). Mirrors Go's + * `LoadLocalVersions` (`internal/migration/list/list.go:72`) → `ListLocalMigrations` + * with a version-collecting filter. + */ +export const legacyLoadLocalVersions = ( + fs: FileSystem.FileSystem, + path: Path.Path, + migrationsDir: string, +) => + legacyListLocalMigrations(fs, path, migrationsDir).pipe( + Effect.map((paths) => + paths.flatMap((p) => { + const match = MIGRATE_FILE_PATTERN.exec(path.basename(p)); + return match?.[1] !== undefined ? [match[1]] : []; + }), + ), + ); + +/** + * Records the pulled migration as applied in `supabase_migrations.schema_migrations` + * WITHOUT re-executing it (the schema already exists on the remote). Mirrors Go's + * `repair.UpdateMigrationTable(conn, [version], Applied, false, fsys)` + * (`internal/migration/repair/repair.go:58`): create the history table, then UPSERT + * the version row with the migration's name + statements. + */ +export const legacyUpdateMigrationHistory = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + migrationPath: string, + timestamp: string, +) => + Effect.gen(function* () { + const output = yield* Output; + const match = MIGRATE_FILE_PATTERN.exec(path.basename(migrationPath)); + if (match === null) { + // Go resolves the repair file by globbing `_*.sql` and fails + // with `os.ErrNotExist` when nothing matches (`repair.GetMigrationFile`, + // `internal/migration/repair/repair.go`). A migration name with a path + // separator (`supabase db pull foo/bar`) writes a nested file whose + // basename doesn't match, so the glob misses — fail rather than upserting + // an empty-version migration-history row. + return yield* Effect.fail( + new LegacyDbPullWriteError({ + message: `glob supabase/migrations/${timestamp}_*.sql: file does not exist`, + }), + ); + } + const version = match[1] ?? ""; + const name = match[2] ?? ""; + yield* Effect.gen(function* () { + const content = yield* fs.readFileString(migrationPath); + const statements = legacySplitAndTrim(content); + yield* session.exec(SET_LOCK_TIMEOUT); + yield* session.exec(CREATE_VERSION_SCHEMA); + yield* session.exec(CREATE_VERSION_TABLE); + yield* session.exec(ADD_STATEMENTS_COLUMN); + yield* session.exec(ADD_NAME_COLUMN); + yield* session.query(UPSERT_MIGRATION_VERSION, [version, name, statements]); + }).pipe( + Effect.mapError( + (cause) => + new LegacyDbPullWriteError({ + message: `failed to update migration table: ${cause.message}`, + }), + ), + ); + // Match Go's `repair.UpdateMigrationTable(..., repairAll=false, ...)`, which + // prints `Repaired migration history: [] => applied` to stderr + // (`internal/migration/repair/repair.go`). Plain text on stderr, so it does + // not interfere with machine-output payloads on stdout. + yield* output.raw(`Repaired migration history: [${version}] => applied\n`, "stderr"); + }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.unit.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.unit.test.ts new file mode 100644 index 0000000000..aa5d67c805 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.unit.test.ts @@ -0,0 +1,132 @@ +import { Effect, Exit } from "effect"; +import { describe, expect, it } from "vitest"; + +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { + legacyListRemoteMigrations, + legacyReconcileMigrations, + legacySuggestMigrationRepair, +} from "./pull.sync.ts"; + +/** Minimal session whose `query` fails with the given error. */ +const failingSession = (error: LegacyDbExecError): LegacyDbSession => ({ + exec: () => Effect.die("unused"), + query: () => Effect.fail(error), + extensionExists: () => Effect.die("unused"), + copyToCsv: () => Effect.die("unused"), + queryRaw: () => Effect.die("unused"), +}); + +// Strip ANSI so the bold repair suggestions compare regardless of TTY colour. +// eslint-disable-next-line no-control-regex +const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); + +describe("legacyReconcileMigrations", () => { + it("reports in-sync when remote and local match", () => { + expect(legacyReconcileMigrations(["20240101000000"], ["20240101000000"])).toEqual({ + kind: "in-sync", + }); + }); + + it("reports missing only when both histories are empty", () => { + // Go checks for conflicts (extra remote/local) before the empty-local guard, + // so a remote-only migration is a conflict, not missing. + expect(legacyReconcileMigrations([], [])).toEqual({ kind: "missing" }); + expect(legacyReconcileMigrations(["20240101000000"], []).kind).toBe("conflict"); + }); + + it("reports a conflict with an extra remote migration", () => { + const result = legacyReconcileMigrations(["20240101000000"], ["20240102000000"]); + expect(result.kind).toBe("conflict"); + if (result.kind === "conflict") { + expect(stripAnsi(result.suggestion)).toContain( + "supabase migration repair --status reverted 20240101000000", + ); + expect(stripAnsi(result.suggestion)).toContain( + "supabase migration repair --status applied 20240102000000", + ); + } + }); + + it("reports a conflict with an extra local migration", () => { + const result = legacyReconcileMigrations([], ["20240102000000"]); + expect(result.kind).toBe("conflict"); + }); + + it("skips versions that do not parse as integers", () => { + // A non-numeric remote version is skipped (Go's Atoi-error continue), leaving + // the numeric ones in sync. + expect(legacyReconcileMigrations(["bogus", "20240101000000"], ["20240101000000"])).toEqual({ + kind: "in-sync", + }); + }); + + it("skips empty / whitespace versions (matches strconv.Atoi, not Number())", () => { + // `Number("")`/`Number(" ")` are 0; Go's Atoi errors on both → skip. The + // numeric entries still reconcile in-sync rather than spuriously conflicting. + expect(legacyReconcileMigrations(["", "20240101000000"], [" ", "20240101000000"])).toEqual({ + kind: "in-sync", + }); + }); + + it("treats a version within Go's int64 range as a real conflict (BigInt parity)", () => { + // 9999999999999999 (~1e16) is above Number.MAX_SAFE_INTEGER but within int64, + // so Go's strconv.Atoi accepts it and surfaces it as an extra-remote conflict. + // A Number-based parser would skip it (initial pull); BigInt compares exactly. + expect(legacyReconcileMigrations(["9999999999999999"], []).kind).toBe("conflict"); + }); + + it("skips a version beyond Go's int64 range instead of hanging the scan", () => { + // A 19-digit value exceeds int64 max (9223372036854775807); Go's Atoi returns a + // range error and skips it, so the scan can't stall on the exhausted-side pin. + expect( + legacyReconcileMigrations(["20240101000000", "9999999999999999999"], ["20240101000000"]), + ).toEqual({ kind: "in-sync" }); + }); +}); + +describe("legacyListRemoteMigrations (suppress only undefined_table, like Go)", () => { + const run = (error: LegacyDbExecError) => + Effect.runPromiseExit(legacyListRemoteMigrations(failingSession(error))); + + it("treats a missing history table (42P01) as an empty history", async () => { + const exit = await run( + new LegacyDbExecError({ + message: 'relation "supabase_migrations.schema_migrations" does not exist', + code: "42P01", + }), + ); + expect(exit).toStrictEqual(Exit.succeed([])); + }); + + it("propagates a malformed table (undefined column 42703) instead of swallowing it", async () => { + const exit = await run( + new LegacyDbExecError({ message: 'column "version" does not exist', code: "42703" }), + ); + expect(Exit.isFailure(exit)).toBe(true); + }); + + it("falls back to a relation-not-exist message when no SQLSTATE is surfaced", async () => { + const exit = await run( + new LegacyDbExecError({ + message: 'relation "supabase_migrations.schema_migrations" does not exist', + }), + ); + expect(exit).toStrictEqual(Exit.succeed([])); + }); + + it("does not swallow a column-not-exist message when no SQLSTATE is surfaced", async () => { + const exit = await run(new LegacyDbExecError({ message: 'column "version" does not exist' })); + expect(Exit.isFailure(exit)).toBe(true); + }); +}); + +describe("legacySuggestMigrationRepair", () => { + it("lists reverted (remote) then applied (local) repair commands", () => { + const out = stripAnsi(legacySuggestMigrationRepair(["111"], ["222"])); + expect(out).toContain("try repairing the migration history table:"); + expect(out).toContain("supabase migration repair --status reverted 111"); + expect(out).toContain("supabase migration repair --status applied 222"); + }); +}); 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 d149507f10..a97de5224d 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 @@ -61,31 +61,6 @@ export class LegacyDeclarativeNoFilesGeneratedError extends Data.TaggedError( readonly message: string; }> {} -/** - * The pg-delta edge-runtime script failed. Byte-matches Go's - * `": :\n"` wrapping in `RunEdgeRuntimeScript` - * (`apps/cli-go/internal/utils/edgeruntime.go`), where `errPrefix` is e.g. - * `"error diffing schema"` / `"error exporting declarative schema"` / - * `"error exporting pg-delta catalog"`. - */ -export class LegacyDeclarativeEdgeRuntimeError extends Data.TaggedError( - "LegacyDeclarativeEdgeRuntimeError", -)<{ - readonly message: string; -}> {} - -/** - * Setting up / connecting to / migrating the throwaway shadow database failed. - * Wraps the errors from `CreateShadowDatabase` / `ConnectShadowDatabase` / - * `SetupShadowDatabase` / `MigrateShadowDatabase` - * (`apps/cli-go/internal/db/diff/diff.go`). - */ -export class LegacyDeclarativeShadowDbError extends Data.TaggedError( - "LegacyDeclarativeShadowDbError", -)<{ - readonly message: string; -}> {} - /** * Diffing declarative schema to migrations failed. Wraps * `declarative.DiffDeclarativeToMigrations` errors @@ -96,29 +71,6 @@ export class LegacyDeclarativeDiffError extends Data.TaggedError("LegacyDeclarat readonly message: string; }> {} -/** - * Exporting declarative schema produced no output. Byte-matches Go's - * `"error exporting declarative schema: edge-runtime script produced no output:\n"` - * and the catalog variant `"error exporting pg-delta catalog: edge-runtime script - * produced no output:\n"` (`apps/cli-go/internal/db/diff/pgdelta.go:188,222`). - */ -export class LegacyDeclarativeEmptyOutputError extends Data.TaggedError( - "LegacyDeclarativeEmptyOutputError", -)<{ - readonly message: string; -}> {} - -/** - * Parsing the declarative export envelope failed. Byte-matches Go's - * `"failed to parse declarative export output: " + err` - * (`apps/cli-go/internal/db/diff/pgdelta.go:192`). - */ -export class LegacyDeclarativeParseOutputError extends Data.TaggedError( - "LegacyDeclarativeParseOutputError", -)<{ - readonly message: string; -}> {} - /** * Applying the generated migration to the local database failed. Wraps Go's * `applyMigrationToLocal` error; in interactive mode the handler offers a @@ -135,17 +87,3 @@ export class LegacyDeclarativeApplyError extends Data.TaggedError("LegacyDeclara * `"failed to clean declarative schema directory: " + err` and * `"unsafe declarative export path: " + path`. */ -export class LegacyDeclarativeWriteError extends Data.TaggedError("LegacyDeclarativeWriteError")<{ - readonly message: string; -}> {} - -/** - * Listing local migrations failed for a reason other than the directory being - * absent. Byte-matches Go's `migration.ListLocalMigrations` - * (`apps/cli-go/pkg/migration/list.go:34-37`), which returns - * `"failed to read directory: " + err` for anything but `os.ErrNotExist` rather - * than treating an unreadable `supabase/migrations` as "no migrations". - */ -export class LegacyMigrationsReadError extends Data.TaggedError("LegacyMigrationsReadError")<{ - readonly message: string; -}> {} 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 50d797a877..ff49d1be91 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 @@ -10,7 +10,10 @@ import { LegacyEdgeRuntimeScript, } from "../../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../../shared/legacy-pgdelta-ssl-probe.service.ts"; -import { type LegacyCatalogMode, LegacyDeclarativeSeam } from "./declarative.seam.service.ts"; +import { + type LegacyCatalogMode, + LegacyDeclarativeSeam, +} from "../../shared/legacy-pgdelta.seam.service.ts"; import { type LegacyDeclarativeRunContext, legacyDiffDeclarativeToMigrations, @@ -26,6 +29,8 @@ function mockSeam(paths: Record) { }, execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, + provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), + removeShadowContainer: () => Effect.void, }); return { layer, calls }; } 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 4cee1e4abe..954dc76ec6 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 @@ -4,10 +4,10 @@ import { type LegacyPgDeltaContext, legacyDeclarativeExportPgDelta, legacyDiffPgDelta, -} from "./declarative.pgdelta.ts"; +} from "../../shared/legacy-pgdelta.ts"; import { LegacyDeclarativeDiffError } from "./declarative.errors.ts"; -import { LegacyDeclarativeSeam } from "./declarative.seam.service.ts"; -import { legacyFindDropStatements } from "./declarative.write.ts"; +import { LegacyDeclarativeSeam } from "../../shared/legacy-pgdelta.seam.service.ts"; +import { legacyFindDropStatements } from "../../../../shared/legacy-sql-split.ts"; /** Ambient inputs shared by the orchestration steps. */ export interface LegacyDeclarativeRunContext { 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 3cedb03489..167520a3d4 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,7 +19,7 @@ import { LegacyDeclarativeApplyError, LegacyDeclarativeInvalidDbUrlError, } from "./declarative.errors.ts"; -import { LegacyDeclarativeSeam } from "./declarative.seam.service.ts"; +import { LegacyDeclarativeSeam } from "../../shared/legacy-pgdelta.seam.service.ts"; /** * The local connection bits the smart-target resolver needs (Go reads these from diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.write.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.write.ts deleted file mode 100644 index 6fddb19f9d..0000000000 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.write.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Effect, type FileSystem, type Path } from "effect"; - -import { legacySplitAndTrim } from "../../../../shared/legacy-sql-split.ts"; -import { LegacyDeclarativeWriteError } from "./declarative.errors.ts"; -import type { LegacyDeclarativeOutput } from "./declarative.pgdelta.ts"; - -// `(?i)drop\s+` — Go's `dropStatementRegexp` (`declarative.go:62`). -const DROP_STATEMENT_PATTERN = /drop\s+/i; - -/** - * Extracts DROP statements from a migration diff for the safety warning shown - * during sync. Mirrors Go's `findDropStatements` (`declarative.go:812`): split - * the SQL into statements, then keep those matching `(?i)drop\s+`. - */ -export function legacyFindDropStatements(sql: string): ReadonlyArray { - return legacySplitAndTrim(sql).filter((statement) => DROP_STATEMENT_PATTERN.test(statement)); -} - -/** - * Materializes pg-delta declarative export output under the declarative dir. - * Mirrors Go's `WriteDeclarativeSchemas` (`declarative.go:239`): wipe the dir, - * recreate it, and write each file at its (path-safe) relative path. - * - * Go also updates `[db.migrations] schema_paths` afterwards, but only when - * pg-delta is *disabled* (`if utils.IsPgDeltaEnabled() { return nil }`). - * Declarative commands require pg-delta enabled (the gate), so that branch is - * unreachable here and is intentionally not ported. - */ -export const legacyWriteDeclarativeSchemas = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - declarativeDir: string, - output: LegacyDeclarativeOutput, -) { - yield* fs.remove(declarativeDir, { recursive: true }).pipe( - Effect.catchTag("PlatformError", (error) => - // Go wraps any failure; a missing dir is fine (we recreate it next). - error.reason._tag === "NotFound" - ? Effect.void - : Effect.fail( - new LegacyDeclarativeWriteError({ - message: `failed to clean declarative schema directory: ${error.message}`, - }), - ), - ), - ); - yield* fs.makeDirectory(declarativeDir, { recursive: true }); - - for (const file of output.files) { - const rel = path.normalize(file.path); - if (rel.startsWith("..") || path.isAbsolute(rel)) { - return yield* Effect.fail( - new LegacyDeclarativeWriteError({ - message: `unsafe declarative export path: ${file.path}`, - }), - ); - } - const targetPath = path.join(declarativeDir, rel); - yield* fs.makeDirectory(path.dirname(targetPath), { recursive: true }); - yield* fs.writeFileString(targetPath, file.sql); - } -}); 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 c57ad1d65e..5e090a8633 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 @@ -15,18 +15,18 @@ import { } from "../../../../../shared/legacy-db-config.toml-read.ts"; import { LegacyLinkedProjectCache } from "../../../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyListLocalMigrations } from "../declarative.cache.ts"; +import { legacyListLocalMigrations } from "../../../shared/legacy-pgdelta.cache.ts"; import { LegacyDeclarativeMutuallyExclusiveFlagsError, LegacyDeclarativeNonInteractiveError, } from "../declarative.errors.ts"; -import { LegacyDeclarativeSeam } from "../declarative.seam.service.ts"; +import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.service.ts"; import { legacyRequirePgDelta } from "../declarative.gate.ts"; import { type LegacyDeclarativeRunContext, legacyGenerateDeclarativeOutput, } from "../declarative.orchestrate.ts"; -import { legacyWriteDeclarativeSchemas } from "../declarative.write.ts"; +import { legacyWriteDeclarativeSchemas } from "../../../shared/legacy-pgdelta.write.ts"; import type { LegacyDbSchemaDeclarativeGenerateFlags } from "./generate.command.ts"; import { type LegacyLocalConn, 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 d0371c4e7c..b49cf92592 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 @@ -25,8 +25,11 @@ import { LegacyEdgeRuntimeScript, } from "../../../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../../../shared/legacy-pgdelta-ssl-probe.service.ts"; -import { LegacyDeclarativeShadowDbError } from "../declarative.errors.ts"; -import { type LegacyCatalogMode, LegacyDeclarativeSeam } from "../declarative.seam.service.ts"; +import { LegacyDeclarativeShadowDbError } from "../../../shared/legacy-pgdelta.errors.ts"; +import { + type LegacyCatalogMode, + LegacyDeclarativeSeam, +} from "../../../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbSchemaDeclarativeGenerateFlags } from "./generate.command.ts"; import { legacyDbSchemaDeclarativeGenerate } from "./generate.handler.ts"; @@ -85,6 +88,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { Effect.sync(() => { ensureStartedCalls += 1; }), + provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), + removeShadowContainer: () => Effect.void, }); const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { @@ -113,6 +118,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const proxyCalls: ReadonlyArray[] = []; const proxy = Layer.succeed(LegacyGoProxy, { exec: (args) => Effect.sync(() => void proxyCalls.push(args)), + execCapture: () => Effect.succeed(""), }); const layer = Layer.mergeAll( out.layer, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts index cddcdbb38b..f21b4ccae0 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts @@ -11,7 +11,7 @@ import { legacyIdentityStitchLayer } from "../../../../../shared/legacy-identity 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 "../declarative.seam.layer.ts"; +import { legacyDeclarativeSeamLayer } from "../../../shared/legacy-pgdelta.seam.layer.ts"; /** * Runtime layer for `supabase db schema declarative generate`. 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 be2b8227ac..8213bd9df9 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 @@ -20,14 +20,18 @@ import { legacyApplyMigrationFile } from "../../../../../shared/legacy-migration import { legacyReadProjectRefFile } from "../../../../../shared/legacy-temp-paths.ts"; import { LegacyLinkedProjectCache } from "../../../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyListLocalMigrations, legacyPgDeltaTempPath } from "../declarative.cache.ts"; +import { + legacyListLocalMigrations, + legacyPgDeltaTempPath, +} from "../../../shared/legacy-pgdelta.cache.ts"; import { legacyResolveSmartTargetUrl } from "../declarative.smart-target.ts"; import { - type LegacyDeclarativeDebugBundle, + type LegacyDebugBundle, legacyCollectMigrationsList, legacyDebugBundleMessage, + legacyFormatDebugId, legacySaveDebugBundle, -} from "../declarative.debug-bundle.ts"; +} from "../../../shared/legacy-debug-bundle.ts"; import { LegacyDeclarativeApplyError, LegacyDeclarativeMutuallyExclusiveFlagsError, @@ -45,8 +49,8 @@ import { legacyDiffDeclarativeToMigrations, legacyGenerateDeclarativeOutput, } from "../declarative.orchestrate.ts"; -import { LegacyDeclarativeSeam } from "../declarative.seam.service.ts"; -import { legacyWriteDeclarativeSchemas } from "../declarative.write.ts"; +import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.service.ts"; +import { legacyWriteDeclarativeSchemas } from "../../../shared/legacy-pgdelta.write.ts"; import type { LegacyDbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; const DEFAULT_SYNC_NAME = "declarative_sync"; @@ -55,11 +59,9 @@ const DEFAULT_SYNC_NAME = "declarative_sync"; const formatTimestamp = (millis: number): string => new Date(millis).toISOString().replace(/\D/g, "").slice(0, 14); -/** Go's debug-bundle id layout `20060102-150405` (UTC). */ -const formatDebugId = (millis: number): string => { - const digits = new Date(millis).toISOString().replace(/\D/g, "").slice(0, 14); - return `${digits.slice(0, 8)}-${digits.slice(8)}`; -}; +// Go's debug-bundle id layout `20060102-150405` (UTC) — hoisted to +// `legacy-debug-bundle.ts` and reused by the `db pull` empty-diff bundle. +const formatDebugId = legacyFormatDebugId; export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declarative.sync")( function* (flags: LegacyDbSchemaDeclarativeSyncFlags) { @@ -134,7 +136,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // treat the bundle path as empty when the debug directory cannot be created, so // an apply failure still surfaces without claiming a bundle was saved // (`apps/cli-go/cmd/db_schema_declarative.go:447-461`). - const saveApplyDebugBundle = (bundle: LegacyDeclarativeDebugBundle) => + const saveApplyDebugBundle = (bundle: LegacyDebugBundle) => legacySaveDebugBundle(fs, path, cliConfig.workdir, tempDir, migrationsDir, bundle).pipe( Effect.matchEffect({ onFailure: (error) => 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 b067e2cd8c..0420d274f0 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -24,7 +24,7 @@ import { LegacyEdgeRuntimeScript, } from "../../../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../../../shared/legacy-pgdelta-ssl-probe.service.ts"; -import { LegacyDeclarativeSeam } from "../declarative.seam.service.ts"; +import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; import { legacyDbSchemaDeclarativeSync } from "./sync.handler.ts"; @@ -59,6 +59,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { return opts.resetExitCode ?? 0; }), ensureLocalDatabaseStarted: () => Effect.void, + provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), + removeShadowContainer: () => Effect.void, }); const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: (_opts: LegacyEdgeRuntimeRunOpts) => diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts index c505aec1e6..4d107a3ab7 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts @@ -11,7 +11,7 @@ import { legacyIdentityStitchLayer } from "../../../../../shared/legacy-identity 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 "../declarative.seam.layer.ts"; +import { legacyDeclarativeSeamLayer } from "../../../shared/legacy-pgdelta.seam.layer.ts"; /** * Runtime layer for `supabase db schema declarative sync`. Sync diffs against the diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.debug-bundle.ts b/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.ts similarity index 71% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.debug-bundle.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.ts index c1ffc646dd..cfaa32e102 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.debug-bundle.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.ts @@ -1,24 +1,39 @@ import { Effect, type FileSystem, type Path } from "effect"; -import { legacyBold, legacyYellow } from "../../../../shared/legacy-colors.ts"; -import { legacyListLocalMigrations } from "./declarative.cache.ts"; +import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; +import { legacyListLocalMigrations } from "./legacy-pgdelta.cache.ts"; /** - * Diagnostic artifacts collected when a declarative operation fails. Mirrors - * Go's `DebugBundle` (`apps/cli-go/internal/db/declarative/debug.go`). + * Diagnostic artifacts collected when a pg-delta operation fails (or an empty + * diff under `PGDELTA_DEBUG`). Mirrors Go's `DebugBundle` + * (`apps/cli-go/internal/db/declarative/debug.go`). Shared by the declarative + * commands (ref-based catalogs) and the migration-style `db pull` empty-diff + * debug bundle (inline catalog strings + connection metadata). */ -export interface LegacyDeclarativeDebugBundle { +export interface LegacyDebugBundle { /** Timestamp-based id (e.g. `20240414-044403`); names the debug subdirectory. */ readonly id: string; readonly sourceRef?: string; readonly targetRef?: string; + /** Inline source catalog JSON; preferred over `sourceRef` when present (Go's debug.go:45-52). */ + readonly sourceCatalog?: string; + /** Inline target catalog JSON; preferred over `targetRef` when present (Go's debug.go:54-61). */ + readonly targetCatalog?: string; readonly migrationSql?: string; readonly pgDeltaStderr?: string; + /** Redacted connection metadata, written to `connection.txt` (Go's debug.go:76-77). */ + readonly connectionInfo?: string; readonly error?: string; /** Local migration filenames to copy into the bundle. */ readonly migrations?: ReadonlyArray; } +/** Go's debug-bundle id layout `20060102-150405` (UTC). */ +export function legacyFormatDebugId(millis: number): string { + const digits = new Date(millis).toISOString().replace(/\D/gu, "").slice(0, 14); + return `${digits.slice(0, 8)}-${digits.slice(8)}`; +} + const writeBestEffort = ( fs: FileSystem.FileSystem, filePath: string, @@ -44,7 +59,7 @@ export const legacySaveDebugBundle = Effect.fnUntraced(function* ( workdir: string, tempDir: string, migrationsDir: string, - bundle: LegacyDeclarativeDebugBundle, + bundle: LegacyDebugBundle, ) { const debugDir = path.join(tempDir, "debug", bundle.id); // Go's `SaveDebugBundle` returns an error when the top-level debug directory @@ -58,15 +73,21 @@ export const legacySaveDebugBundle = Effect.fnUntraced(function* ( // The catalog refs come back from the Go seam as workdir-relative paths // (`supabase/.temp/pgdelta/...`); Go chdir's into the workdir before reading them, // so resolve against `workdir` rather than the process cwd (`path.resolve` leaves - // absolute refs unchanged). - if (bundle.sourceRef !== undefined && bundle.sourceRef.length > 0) { + // absolute refs unchanged). An inline catalog string takes precedence over the + // ref (Go's debug.go:45-61), matching the `db pull` empty-diff path which holds + // the catalogs in memory rather than as files. + if (bundle.sourceCatalog !== undefined && bundle.sourceCatalog.length > 0) { + yield* writeBestEffort(fs, path.join(debugDir, "source-catalog.json"), bundle.sourceCatalog); + } else if (bundle.sourceRef !== undefined && bundle.sourceRef.length > 0) { yield* copyBestEffort( fs, path.resolve(workdir, bundle.sourceRef), path.join(debugDir, "source-catalog.json"), ); } - if (bundle.targetRef !== undefined && bundle.targetRef.length > 0) { + if (bundle.targetCatalog !== undefined && bundle.targetCatalog.length > 0) { + yield* writeBestEffort(fs, path.join(debugDir, "target-catalog.json"), bundle.targetCatalog); + } else if (bundle.targetRef !== undefined && bundle.targetRef.length > 0) { yield* copyBestEffort( fs, path.resolve(workdir, bundle.targetRef), @@ -82,6 +103,9 @@ export const legacySaveDebugBundle = Effect.fnUntraced(function* ( if (bundle.pgDeltaStderr !== undefined && bundle.pgDeltaStderr.length > 0) { yield* writeBestEffort(fs, path.join(debugDir, "pgdelta-stderr.txt"), bundle.pgDeltaStderr); } + if (bundle.connectionInfo !== undefined && bundle.connectionInfo.length > 0) { + yield* writeBestEffort(fs, path.join(debugDir, "connection.txt"), bundle.connectionInfo); + } if (bundle.migrations !== undefined && bundle.migrations.length > 0) { const migrationsOut = path.join(debugDir, "migrations"); yield* fs.makeDirectory(migrationsOut, { recursive: true }).pipe(Effect.ignore); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.debug-bundle.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.unit.test.ts similarity index 99% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.debug-bundle.unit.test.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.unit.test.ts index cf975d73d0..af023ed36a 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.debug-bundle.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.unit.test.ts @@ -5,7 +5,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Path } from "effect"; -import { legacyCollectMigrationsList, legacySaveDebugBundle } from "./declarative.debug-bundle.ts"; +import { legacyCollectMigrationsList, legacySaveDebugBundle } from "./legacy-debug-bundle.ts"; const save = (workdir: string, tempDir: string, migrationsDir: string, id: string) => Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-diff-engine.ts b/apps/cli/src/legacy/commands/db/shared/legacy-diff-engine.ts new file mode 100644 index 0000000000..dc91e364ae --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-diff-engine.ts @@ -0,0 +1,75 @@ +// Pure diff-engine resolution shared by `db diff` and `db pull`. Mirrors the +// three Go helpers in `apps/cli-go/cmd/db.go:375-401` so engine selection stays +// byte-identical to the Go CLI. No Effect / service dependencies — unit-tested +// directly. + +/** + * Whether pg-delta is the active default engine. Mirrors Go's `shouldUsePgDelta` + * (`db.go:375-376`): `utils.IsPgDeltaEnabled() || usePgDelta || viper.GetBool("EXPERIMENTAL_PG_DELTA")`. + * The three inputs are the resolved config flag (`[experimental.pgdelta].enabled`), + * the command's `--use-pg-delta` flag, and the `SUPABASE_EXPERIMENTAL_PG_DELTA` + * env var. + */ +export function legacyShouldUsePgDelta(inputs: { + readonly configEnabled: boolean; + readonly usePgDeltaFlag: boolean; + readonly envEnabled: boolean; +}): boolean { + return inputs.configEnabled || inputs.usePgDeltaFlag || inputs.envEnabled; +} + +/** + * Reports whether `db diff` should run in pg-delta mode. Mirrors Go's + * `resolveDiffEngine` (`db.go:385-390`): an explicit `--use-migra`, + * `--use-pgadmin`, or `--use-pg-schema` is an authoritative rollback that clears + * pg-delta mode; `--use-migra` defaults to true so only an explicit pass + * (`useMigraChanged`) counts as opting out. + */ +export function legacyResolveDiffEngine(inputs: { + readonly useMigraChanged: boolean; + readonly usePgAdmin: boolean; + readonly usePgSchema: boolean; + readonly pgDeltaDefault: boolean; +}): boolean { + if (inputs.useMigraChanged || inputs.usePgAdmin || inputs.usePgSchema) { + return false; + } + return inputs.pgDeltaDefault; +} + +/** + * Selects whether migration-style `db pull` uses pg-delta for the shadow diff + * step. Mirrors Go's `resolvePullDiffEngine` (`db.go:396-401`): an explicit + * `--diff-engine` always wins (so `--diff-engine migra` is an authoritative + * rollback even when pg-delta is enabled in config); otherwise the default + * follows the active engine. + */ +export function legacyResolvePullDiffEngine(inputs: { + readonly engineFlagChanged: boolean; + readonly engine: string; + readonly pgDeltaDefault: boolean; +}): boolean { + if (inputs.engineFlagChanged) { + return inputs.engine === "pg-delta"; + } + return inputs.pgDeltaDefault; +} + +/** + * Parses a `viper.GetBool`-style boolean env var. Go's viper delegates to + * `strconv.ParseBool`, which accepts exactly `1 t T TRUE true True` as true and + * treats every other value (including unparseable strings and unset) as false. + */ +export function legacyParseBoolEnv(raw: string | undefined): boolean { + switch (raw) { + case "1": + case "t": + case "T": + case "TRUE": + case "true": + case "True": + return true; + default: + return false; + } +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-diff-engine.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-diff-engine.unit.test.ts new file mode 100644 index 0000000000..935a60060f --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-diff-engine.unit.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyParseBoolEnv, + legacyResolveDiffEngine, + legacyResolvePullDiffEngine, + legacyShouldUsePgDelta, +} from "./legacy-diff-engine.ts"; + +describe("legacyShouldUsePgDelta", () => { + it("is the OR of config, flag, and env", () => { + expect( + legacyShouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: false, envEnabled: false }), + ).toBe(false); + expect( + legacyShouldUsePgDelta({ configEnabled: true, usePgDeltaFlag: false, envEnabled: false }), + ).toBe(true); + expect( + legacyShouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: true, envEnabled: false }), + ).toBe(true); + expect( + legacyShouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: false, envEnabled: true }), + ).toBe(true); + }); +}); + +describe("legacyResolveDiffEngine", () => { + const base = { + useMigraChanged: false, + usePgAdmin: false, + usePgSchema: false, + pgDeltaDefault: true, + }; + + it("returns the pg-delta default when no explicit non-delta engine is selected", () => { + expect(legacyResolveDiffEngine(base)).toBe(true); + expect(legacyResolveDiffEngine({ ...base, pgDeltaDefault: false })).toBe(false); + }); + + it("an explicit --use-migra clears pg-delta mode", () => { + expect(legacyResolveDiffEngine({ ...base, useMigraChanged: true })).toBe(false); + }); + + it("--use-pgadmin clears pg-delta mode", () => { + expect(legacyResolveDiffEngine({ ...base, usePgAdmin: true })).toBe(false); + }); + + it("--use-pg-schema clears pg-delta mode", () => { + expect(legacyResolveDiffEngine({ ...base, usePgSchema: true })).toBe(false); + }); +}); + +describe("legacyResolvePullDiffEngine", () => { + it("an explicit --diff-engine always wins", () => { + expect( + legacyResolvePullDiffEngine({ + engineFlagChanged: true, + engine: "pg-delta", + pgDeltaDefault: false, + }), + ).toBe(true); + expect( + legacyResolvePullDiffEngine({ + engineFlagChanged: true, + engine: "migra", + pgDeltaDefault: true, + }), + ).toBe(false); + }); + + it("falls back to the pg-delta default when the flag is unset", () => { + expect( + legacyResolvePullDiffEngine({ + engineFlagChanged: false, + engine: "migra", + pgDeltaDefault: true, + }), + ).toBe(true); + expect( + legacyResolvePullDiffEngine({ + engineFlagChanged: false, + engine: "migra", + pgDeltaDefault: false, + }), + ).toBe(false); + }); +}); + +describe("legacyParseBoolEnv", () => { + it("accepts only strconv.ParseBool truthy strings", () => { + for (const v of ["1", "t", "T", "TRUE", "true", "True"]) { + expect(legacyParseBoolEnv(v)).toBe(true); + } + }); + + it("treats every other value (including unset) as false", () => { + for (const v of ["0", "f", "FALSE", "false", "yes", "on", "2", "", "TrUe"]) { + expect(legacyParseBoolEnv(v)).toBe(false); + } + expect(legacyParseBoolEnv(undefined)).toBe(false); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.ts new file mode 100644 index 0000000000..5062c9c183 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.ts @@ -0,0 +1,17 @@ +// Verbatim copies of the Go migra Deno + bash templates. These embed the +// scripts byte-for-byte; `legacy-migra.deno-templates.unit.test.ts` asserts +// equality against the Go sources in `apps/cli-go/internal/db/diff/templates/`. +// Do not hand-edit — regenerate from Go. +// +// migra is `db diff`'s default engine and the non-pg-delta `db pull` diff +// engine. The `.ts` template runs inside Edge Runtime (`@pgkit/migra` + +// `@pgkit/client`); the `.sh` template is the OOM bash fallback executed in the +// `supabase/migra` Docker image. + +/** `templates/migra.ts` — diffs SOURCE→TARGET via @pgkit/migra inside Edge Runtime. */ +export const legacyMigraDiffScript = + 'import { createClient, sql } from "npm:@pgkit/client";\nimport { Migration } from "npm:@pgkit/migra";\n\n// Avoids error on self-signed certificate\nconst ca = Deno.env.get("SSL_CA");\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\nconst sslDebug = Deno.env.get("SUPABASE_SSL_DEBUG")?.toLowerCase() === "true";\n\nfunction redactPostgresUrl(raw: string | undefined): string {\n if (!raw) return "";\n try {\n const u = new URL(raw);\n if (u.password) u.password = "xxxxx";\n return u.toString();\n } catch {\n return "";\n }\n}\n\nif (sslDebug) {\n console.error(\n `[ssl-debug] migra.ts deno=${Deno.version.deno} v8=${Deno.version.v8} os=${Deno.build.os}`,\n );\n console.error(\n `[ssl-debug] migra.ts source=${redactPostgresUrl(source)} target=${redactPostgresUrl(target)}`,\n );\n console.error(\n `[ssl-debug] migra.ts ssl_ca_set=${ca != null} ssl_ca_len=${ca?.length ?? 0}`,\n );\n}\n\nconst clientBase = createClient(source);\nconst clientHead = createClient(target, {\n pgpOptions: { connect: { ssl: ca && { ca } } },\n});\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS")?.split(",") ?? [];\nconst excludedSchemas = Deno.env.get("EXCLUDED_SCHEMAS")?.split(",") ?? [];\n\nconst managedSchemas = ["auth", "realtime", "storage"];\nconst extensionSchemas = [\n "pg_catalog",\n "extensions",\n "pgmq",\n "tiger",\n "topology",\n];\n\ntry {\n // Step down from login role to postgres\n await clientHead.query(sql`set role postgres`);\n // Force schema qualified references for pg_get_expr\n await clientHead.query(sql`set search_path = \'\'`);\n await clientBase.query(sql`set search_path = \'\'`);\n const result: string[] = [];\n for (const schema of includedSchemas) {\n const m = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n m.set_safety(false);\n if (managedSchemas.includes(schema)) {\n m.add(m.changes.triggers({ drops_only: true }));\n m.add(m.changes.rlspolicies({ drops_only: true }));\n m.add(m.changes.rlspolicies({ creations_only: true }));\n m.add(m.changes.triggers({ creations_only: true }));\n } else {\n m.add_all_changes(true);\n }\n result.push(m.sql);\n }\n if (includedSchemas.length === 0) {\n // Migra does not ignore custom types and triggers created by extensions, so we diff\n // them separately. This workaround only applies to a known list of managed schemas.\n for (const schema of extensionSchemas) {\n const e = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n e.set_safety(false);\n e.add(e.changes.schemas({ creations_only: true }));\n e.add_extension_changes();\n result.push(e.sql);\n }\n // Diff user defined entities in non-managed schemas, including extensions.\n const m = await Migration.create(clientBase, clientHead, {\n exclude_schema: [\n ...managedSchemas,\n ...extensionSchemas,\n ...excludedSchemas,\n ],\n ignore_extension_versions: true,\n });\n m.set_safety(false);\n m.add_all_changes(true);\n result.push(m.sql);\n // For managed schemas, we want to include triggers and RLS policies only.\n for (const schema of managedSchemas) {\n const s = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n s.set_safety(false);\n s.add(s.changes.triggers({ drops_only: true }));\n s.add(s.changes.rlspolicies({ drops_only: true }));\n s.add(s.changes.rlspolicies({ creations_only: true }));\n s.add(s.changes.triggers({ creations_only: true }));\n result.push(s.sql);\n }\n }\n console.log(result.join(""));\n} catch (e) {\n if (sslDebug) {\n if (e instanceof Error) {\n console.error(\n `[ssl-debug] migra.ts error_name=${e.name} message=${e.message} stack=${e.stack ?? ""}`,\n );\n } else {\n console.error(`[ssl-debug] migra.ts error=${String(e)}`);\n }\n }\n console.error(e);\n} finally {\n await Promise.all([clientHead.end(), clientBase.end()]);\n}\n'; + +/** `templates/migra.sh` — OOM bash fallback executed in the `supabase/migra` image. */ +export const legacyMigraDiffShellScript = + '#!/bin/sh\nset -eu\n\nif [ "${SUPABASE_SSL_DEBUG:-}" = "true" ]; then\n [ -n "${SOURCE:-}" ] && source_set=true || source_set=false\n [ -n "${TARGET:-}" ] && target_set=true || target_set=false\n echo "[ssl-debug] migra.sh uname=$(uname -a)" >&2\n echo "[ssl-debug] migra.sh source_set=$source_set target_set=$target_set schemas=$*" >&2\nfi\n\n# migra doesn\'t shutdown gracefully, so kill it ourselves\ntrap \'kill -9 %1\' TERM\n\nrun_migra() {\n # additional flags for diffing extensions\n [ "$schema" = "extensions" ] && set -- --create-extensions-only --ignore-extension-versions "$@"\n migra --with-privileges --unsafe --schema="$schema" "$@"\n}\n\n# accepts command line args as a list of schema to generate\nfor schema in "$@"; do\n # migra exits 2 when differences are found\n run_migra "$SOURCE" "$TARGET" || status=$?\n if [ "${SUPABASE_SSL_DEBUG:-}" = "true" ]; then\n echo "[ssl-debug] migra.sh schema=$schema exit_status=${status:-0}" >&2\n fi\n if [ ${status:-2} -ne 2 ]; then\n exit $status\n fi\ndone\n'; diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.unit.test.ts new file mode 100644 index 0000000000..6db3f777eb --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.unit.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { + legacyMigraDiffScript, + legacyMigraDiffShellScript, +} from "./legacy-migra.deno-templates.ts"; + +// Resolve the Go template sources relative to this file so the byte-equality +// assertion fails loudly if the embedded copies drift from upstream. +const goDiffTemplatesDir = fileURLToPath( + new URL("../../../../../../cli-go/internal/db/diff/templates/", import.meta.url), +); +const readGoTemplate = (name: string) => readFileSync(`${goDiffTemplatesDir}${name}`, "utf8"); + +describe("embedded migra templates", () => { + it("match the Go sources byte-for-byte", () => { + expect(legacyMigraDiffScript).toBe(readGoTemplate("migra.ts")); + expect(legacyMigraDiffShellScript).toBe(readGoTemplate("migra.sh")); + }); +}); 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 new file mode 100644 index 0000000000..642909c665 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts @@ -0,0 +1,21 @@ +import { Data } from "effect"; + +/** + * The migra diff failed (edge-runtime run, or the OOM bash fallback in the + * `supabase/migra` Docker image). Byte-matches Go's + * `"error diffing schema: %w:\n%s"` wrapping in `DiffSchemaMigra` / + * `DiffSchemaMigraBash` (`apps/cli-go/internal/db/diff/migra.go`). + */ +export class LegacyMigraDiffError extends Data.TaggedError("LegacyMigraDiffError")<{ + readonly message: string; +}> {} + +/** + * Loading the target's user-defined schemas for the migra bash fallback failed. + * Byte-matches Go's `migration.ListUserSchemas` → `"failed to list schemas: %w"` + * (`apps/cli-go/pkg/migration/drop.go:46`); reached only on the OOM fallback path + * when no `--schema` is given. + */ +export class LegacyMigraSchemaLoadError extends Data.TaggedError("LegacyMigraSchemaLoadError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts new file mode 100644 index 0000000000..be5af7dd2d --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts @@ -0,0 +1,277 @@ +import { Effect, Option } from "effect"; + +import { LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { + LegacyDbConnection, + type LegacyDbConnectOptions, +} from "../../../shared/legacy-db-connection.service.ts"; +import { parseLegacyConnectionString } from "../../../shared/legacy-db-config.parse.ts"; +import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; +import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LEGACY_PG_DELTA_CA_BUNDLE } from "../../../shared/legacy-pgdelta-ssl.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { + legacyMigraDiffScript, + legacyMigraDiffShellScript, +} from "./legacy-migra.deno-templates.ts"; +import { LegacyMigraDiffError, LegacyMigraSchemaLoadError } from "./legacy-migra.errors.ts"; +import { legacyEdgeRuntimeId, type LegacyPgDeltaContext } from "./legacy-pgdelta.ts"; + +/** + * The migra Docker image, parsed by Go from its embedded Dockerfile + * (`apps/cli-go/pkg/config/templates/Dockerfile:19` → `config.Images.Migra`). + * Used only by the OOM bash fallback (`DiffSchemaMigraBash`); the common + * edge-runtime path runs `@pgkit/migra` instead. + */ +const LEGACY_MIGRA_IMAGE = "supabase/migra:3.0.1663481299"; + +/** + * Schemas excluded from a no-`--schema` migra diff. Verbatim from Go's + * `managedSchemas` (`apps/cli-go/internal/db/diff/migra.go:26-56`): local-dev, + * extension-owned, deprecated-extension, and Supabase-managed schemas. Passed as + * `EXCLUDED_SCHEMAS` to the edge-runtime template. + */ +const LEGACY_MIGRA_MANAGED_SCHEMAS: ReadonlyArray = [ + // Local development + "_analytics", + "_realtime", + "_supavisor", + // Owned by extensions + "cron", + "graphql", + "graphql_public", + "net", + "pgroonga", + "pgtle", + "repack", + "tiger_data", + "vault", + // Deprecated extensions + "pgsodium", + "pgsodium_masks", + "timescaledb_experimental", + "timescaledb_information", + "_timescaledb_cache", + "_timescaledb_catalog", + "_timescaledb_config", + "_timescaledb_debug", + "_timescaledb_functions", + "_timescaledb_internal", + // Managed by Supabase + "pgbouncer", + "supabase_functions", + "supabase_migrations", +]; + +/** + * LIKE patterns excluded by `ListUserSchemas` when resolving the migra bash + * fallback's schema list. Verbatim from Go's `migration.ManagedSchemas` + * (`apps/cli-go/pkg/migration/drop.go:19-31`). + */ +const LEGACY_LIST_SCHEMAS_EXCLUDE: ReadonlyArray = [ + "information\\_schema", + "pg\\_%", + "\\_analytics", + "\\_realtime", + "\\_supavisor", + "pgbouncer", + "pgmq", + "pgsodium", + "pgtle", + "supabase\\_migrations", + "vault", +]; + +/** Verbatim from Go's `migration.ListSchemas` (`pkg/migration/queries/list.sql`). */ +const LEGACY_LIST_SCHEMAS_SQL = `-- List user defined schemas, excluding +-- Extension created schemas +-- Supabase managed schemas +select pn.nspname +from pg_namespace pn +left join pg_depend pd on pd.objid = pn.oid +where pd.deptype is null + and not pn.nspname like any($1) + and pn.nspowner::regrole::text != 'supabase_admin' +order by pn.nspname`; + +/** Mirrors Go's `types.IsSSLDebugEnabled` (`internal/gen/types/types.go:201`). */ +function legacyIsSslDebugEnabled(): boolean { + return (process.env["SUPABASE_SSL_DEBUG"] ?? "").toLowerCase() === "true"; +} + +/** Mirrors Go's `shouldFallbackToLegacyMigra` (`internal/db/diff/migra.go:155`). */ +function legacyShouldFallbackToBashMigra(message: string): boolean { + return ( + message.includes("Fatal JavaScript out of memory") || + message.includes("Ineffective mark-compacts near heap limit") + ); +} + +/** Builds the shared SOURCE/TARGET/SSL/schema env for both migra paths. */ +const buildMigraEnv = Effect.fnUntraced(function* (params: { + readonly source: string; + readonly target: string; + readonly schema: ReadonlyArray; +}) { + const probe = yield* LegacyPgDeltaSslProbe; + const env: Record = { + SOURCE: params.source, + TARGET: params.target, + }; + if (legacyIsSslDebugEnabled()) env["SUPABASE_SSL_DEBUG"] = "true"; + // Go's GetRootCA: probe the target for TLS; if it speaks TLS, inject the + // embedded CA bundle as SSL_CA (`internal/gen/types/types.go:124-148`). + const requireSsl = yield* probe.requireSsl(params.target); + if (requireSsl) env["SSL_CA"] = LEGACY_PG_DELTA_CA_BUNDLE; + if (params.schema.length > 0) { + env["INCLUDED_SCHEMAS"] = params.schema.join(","); + } else { + env["EXCLUDED_SCHEMAS"] = LEGACY_MIGRA_MANAGED_SCHEMAS.join(","); + } + return env; +}); + +/** + * Loads the target's user-defined schemas for the bash fallback (the bash + * migra.sh iterates over an explicit schema list and cannot diff in exclude + * mode). Mirrors Go's `loadSchema` → `migration.ListUserSchemas` + * (`internal/db/diff/migra.go:99` / `pkg/migration/drop.go:40`). + */ +const loadTargetUserSchemas = Effect.fnUntraced(function* ( + target: string, + connectOptions: LegacyDbConnectOptions, +) { + const connection = yield* LegacyDbConnection; + const input = parseLegacyConnectionString(target); + if (input === undefined) { + return yield* Effect.fail( + new LegacyMigraSchemaLoadError({ + message: "failed to list schemas: invalid target connection string", + }), + ); + } + return yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* connection.connect(input, connectOptions).pipe( + Effect.mapError( + (cause) => + new LegacyMigraSchemaLoadError({ + message: `failed to list schemas: ${cause.message}`, + }), + ), + ); + const rows = yield* session + .query(LEGACY_LIST_SCHEMAS_SQL, [LEGACY_LIST_SCHEMAS_EXCLUDE]) + .pipe( + Effect.mapError( + (cause) => + new LegacyMigraSchemaLoadError({ + message: `failed to list schemas: ${cause.message}`, + }), + ), + ); + return rows.map((row) => String(row["nspname"])); + }), + ); +}); + +/** + * The OOM bash fallback: run migra in the `supabase/migra` Docker image over the + * host network. Mirrors Go's `DiffSchemaMigraBash` + * (`internal/db/diff/migra.go:60`): when no `--schema` is given the included + * schemas are loaded from the target, then passed as positional args to migra.sh. + */ +const diffMigraBash = Effect.fnUntraced(function* (params: { + readonly source: string; + readonly target: string; + readonly schema: ReadonlyArray; + readonly connectOptions: LegacyDbConnectOptions; +}) { + const docker = yield* LegacyDockerRun; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + const schema = + params.schema.length > 0 + ? params.schema + : yield* loadTargetUserSchemas(params.target, params.connectOptions); + const env: Record = { SOURCE: params.source, TARGET: params.target }; + if (legacyIsSslDebugEnabled()) env["SUPABASE_SSL_DEBUG"] = "true"; + // Passing the script as a string means command-line args must be set manually + // via `set --` so migra.sh's `"$@"` loop sees the schema list (Go's `args`). + const args = `set -- ${schema.join(" ")};`; + // Go's bash fallback (`DiffSchemaMigraBash`) routes through `DockerStart` + // (`internal/utils/docker.go:266-271`), which appends the Linux + // `host.docker.internal:host-gateway` mapping and overrides host networking with + // `--network-id` when set. Mirror that here so the fallback reaches the database + // on custom-network / `host.docker.internal` setups, matching the primary path. + 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"] : []; + const result = yield* docker + .runCapture({ + image: legacyGetRegistryImageUrl(LEGACY_MIGRA_IMAGE), + cmd: ["/bin/sh", "-c", args + legacyMigraDiffShellScript], + env, + binds: [], + workingDir: Option.none(), + securityOpt: [], + extraHosts, + network, + }) + .pipe( + Effect.mapError( + (cause) => new LegacyMigraDiffError({ message: `error diffing schema: ${cause.message}` }), + ), + ); + if (result.exitCode !== 0) { + return yield* Effect.fail( + new LegacyMigraDiffError({ + message: `error diffing schema:\n${result.stderr}`, + }), + ); + } + return new TextDecoder().decode(result.stdout); +}); + +/** + * Diffs SOURCE → TARGET with migra via the edge-runtime template + * (`@pgkit/migra` + `@pgkit/client`), falling back to the `supabase/migra` + * Docker image when the edge-runtime worker runs out of memory. Mirrors Go's + * `DiffSchemaMigra` (`internal/db/diff/migra.go:109`). `source`/`target` are + * live Postgres URLs (the shadow source and the diff target). Symmetric with + * `legacyDiffPgDelta`: a free function over a `LegacyPgDeltaContext`, not a + * service. + */ +export const legacyDiffMigra = Effect.fnUntraced(function* ( + ctx: LegacyPgDeltaContext, + params: { + readonly source: string; + readonly target: string; + readonly schema: ReadonlyArray; + readonly connectOptions: LegacyDbConnectOptions; + }, +) { + const edgeRuntime = yield* LegacyEdgeRuntimeScript; + const env = yield* buildMigraEnv(params); + const result = yield* edgeRuntime + .run({ + script: legacyMigraDiffScript, + env, + binds: [`${legacyEdgeRuntimeId(ctx.projectId)}:/root/.cache/deno:rw`], + errPrefix: "error diffing schema", + denoVersion: ctx.denoVersion, + }) + .pipe( + Effect.catch((cause) => + legacyShouldFallbackToBashMigra(cause.message) + ? diffMigraBash(params) + : Effect.fail(new LegacyMigraDiffError({ message: cause.message })), + ), + ); + return typeof result === "string" ? result : result.stdout; +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migration-file.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migration-file.ts new file mode 100644 index 0000000000..f8a7b7cd6e --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migration-file.ts @@ -0,0 +1,26 @@ +import type { Path } from "effect"; + +/** + * Go's `GetCurrentTimestamp` (`apps/cli-go/internal/utils/misc.go:130`): the + * current time formatted UTC as `YYYYMMDDHHMMSS` (Go's `layoutVersion` + * `20060102150405`). Takes the epoch millis (from `Clock.currentTimeMillis`) so + * it stays deterministic under test. + */ +export function legacyFormatMigrationTimestamp(millis: number): string { + return new Date(millis).toISOString().replace(/\D/gu, "").slice(0, 14); +} + +/** + * Go's `new.GetMigrationPath` (`apps/cli-go/internal/migration/new/new.go:31`): + * `/supabase/migrations/_.sql`. Returned absolute so + * callers can write it regardless of the process CWD (Go chdir's into the workdir + * in its persistent pre-run; the native shell resolves against it explicitly). + */ +export function legacyGetMigrationPath( + path: Path.Path, + workdir: string, + timestamp: string, + name: string, +): string { + return path.join(workdir, "supabase", "migrations", `${timestamp}_${name}.sql`); +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migration-file.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migration-file.unit.test.ts new file mode 100644 index 0000000000..e35fc6e311 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migration-file.unit.test.ts @@ -0,0 +1,29 @@ +import type { Path } from "effect"; +import { describe, expect, it } from "vitest"; + +import { legacyFormatMigrationTimestamp, legacyGetMigrationPath } from "./legacy-migration-file.ts"; + +describe("legacyFormatMigrationTimestamp", () => { + it("formats epoch millis as UTC YYYYMMDDHHMMSS", () => { + // 2026-06-18T09:08:07.123Z + const millis = Date.UTC(2026, 5, 18, 9, 8, 7, 123); + expect(legacyFormatMigrationTimestamp(millis)).toBe("20260618090807"); + }); + + it("zero-pads single-digit components", () => { + const millis = Date.UTC(2001, 0, 2, 3, 4, 5); + expect(legacyFormatMigrationTimestamp(millis)).toBe("20010102030405"); + }); +}); + +describe("legacyGetMigrationPath", () => { + it("builds /supabase/migrations/_.sql", () => { + // A tiny posix Path stand-in keeps this a pure unit test (no Effect runtime). + const posix = { + join: (...segments: string[]) => segments.join("/"), + } as unknown as Path.Path; + expect(legacyGetMigrationPath(posix, "/repo", "20260618090807", "remote_schema")).toBe( + "/repo/supabase/migrations/20260618090807_remote_schema.sql", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.cache.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts similarity index 99% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.cache.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts index 1127023ff4..280bd67510 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.cache.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { Effect, type FileSystem, Option, type Path } from "effect"; -import { LegacyMigrationsReadError } from "./declarative.errors.ts"; +import { LegacyMigrationsReadError } from "./legacy-pgdelta.errors.ts"; /** * Declarative catalog-cache key builders + on-disk catalog resolution, ported diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.cache.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts similarity index 99% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.cache.unit.test.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts index d445dd3dda..6262d5970d 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.cache.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts @@ -20,7 +20,7 @@ import { legacyResolveDeclarativeCatalogPath, legacySanitizedCatalogPrefix, legacySetupInputsToken, -} from "./declarative.cache.ts"; +} from "./legacy-pgdelta.cache.ts"; const BASE: LegacySetupInputs = { image: "supabase/postgres:17.6.1.135", diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.deno-templates.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts similarity index 99% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.deno-templates.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts index 5c2fd590f9..625967555d 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.deno-templates.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts @@ -1,5 +1,5 @@ // Verbatim copies of the Go pg-delta Deno templates. These embed the scripts -// byte-for-byte; `declarative.deno-templates.unit.test.ts` asserts equality +// byte-for-byte; `legacy-pgdelta.deno-templates.unit.test.ts` asserts equality // against the Go `.ts` sources. Do not hand-edit — regenerate from Go. // // Four templates back the in-scope flows: diff / declarative-export / catalog- diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.deno-templates.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts similarity index 93% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.deno-templates.unit.test.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts index 8e083f0918..c26287ee4b 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.deno-templates.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts @@ -11,15 +11,15 @@ import { legacyPgDeltaDeclarativeApplyScript, legacyPgDeltaDeclarativeExportScript, legacyPgDeltaDiffScript, -} from "./declarative.deno-templates.ts"; +} from "./legacy-pgdelta.deno-templates.ts"; // Resolve the Go template sources relative to this file so the byte-equality // assertion fails loudly if the embedded copies drift from upstream. const goDiffTemplatesDir = fileURLToPath( - new URL("../../../../../../../cli-go/internal/db/diff/templates/", import.meta.url), + new URL("../../../../../../cli-go/internal/db/diff/templates/", import.meta.url), ); const goPgDeltaTemplatesDir = fileURLToPath( - new URL("../../../../../../../cli-go/internal/pgdelta/templates/", import.meta.url), + new URL("../../../../../../cli-go/internal/pgdelta/templates/", import.meta.url), ); const readGoTemplate = (name: string) => readFileSync(`${goDiffTemplatesDir}${name}`, "utf8"); 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 new file mode 100644 index 0000000000..40c7f75ed4 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts @@ -0,0 +1,71 @@ +import { Data } from "effect"; + +/** + * The pg-delta edge-runtime script failed. Byte-matches Go's + * `": :\n"` wrapping in `RunEdgeRuntimeScript` + * (`apps/cli-go/internal/utils/edgeruntime.go`), where `errPrefix` is e.g. + * `"error diffing schema"` / `"error exporting declarative schema"` / + * `"error exporting pg-delta catalog"`. + */ +export class LegacyDeclarativeEdgeRuntimeError extends Data.TaggedError( + "LegacyDeclarativeEdgeRuntimeError", +)<{ + readonly message: string; +}> {} + +/** + * Setting up / connecting to / migrating the throwaway shadow database failed. + * Wraps the errors from `CreateShadowDatabase` / `ConnectShadowDatabase` / + * `SetupShadowDatabase` / `MigrateShadowDatabase` + * (`apps/cli-go/internal/db/diff/diff.go`). + */ +export class LegacyDeclarativeShadowDbError extends Data.TaggedError( + "LegacyDeclarativeShadowDbError", +)<{ + readonly message: string; +}> {} + +/** + * Exporting declarative schema produced no output. Byte-matches Go's + * `"error exporting declarative schema: edge-runtime script produced no output:\n"` + * and the catalog variant `"error exporting pg-delta catalog: edge-runtime script + * produced no output:\n"` (`apps/cli-go/internal/db/diff/pgdelta.go:188,222`). + */ +export class LegacyDeclarativeEmptyOutputError extends Data.TaggedError( + "LegacyDeclarativeEmptyOutputError", +)<{ + readonly message: string; +}> {} + +/** + * Parsing the declarative export envelope failed. Byte-matches Go's + * `"failed to parse declarative export output: " + err` + * (`apps/cli-go/internal/db/diff/pgdelta.go:192`). + */ +export class LegacyDeclarativeParseOutputError extends Data.TaggedError( + "LegacyDeclarativeParseOutputError", +)<{ + readonly message: string; +}> {} + +/** + * Listing local migrations failed for a reason other than the directory being + * absent. Byte-matches Go's `migration.ListLocalMigrations` + * (`apps/cli-go/pkg/migration/list.go:34-37`), which returns + * `"failed to read directory: " + err` for anything but `os.ErrNotExist` rather + * than treating an unreadable `supabase/migrations` as "no migrations". + */ +export class LegacyMigrationsReadError extends Data.TaggedError("LegacyMigrationsReadError")<{ + readonly message: string; +}> {} + +/** + * Materializing the declarative export on disk failed. Byte-matches Go's + * `WriteDeclarativeSchemas` errors (`declarative.go:239`): + * `"failed to clean declarative schema directory: " + err` and + * `"unsafe declarative export path: " + path`. Shared by `db schema declarative + * generate`/`sync` and `db pull --declarative`. + */ +export class LegacyDeclarativeWriteError extends Data.TaggedError("LegacyDeclarativeWriteError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.pgdelta.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts similarity index 96% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.pgdelta.integration.test.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts index cc7b311cbe..a90d2476e4 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.pgdelta.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts @@ -6,19 +6,19 @@ 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 { LegacyPgDeltaSslProbe } from "../../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +} from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LEGACY_DEFAULT_PG_DELTA_NPM_VERSION, LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER, -} from "./declarative.deno-templates.ts"; +} from "./legacy-pgdelta.deno-templates.ts"; import { legacyDeclarativeExportPgDelta, legacyDiffPgDelta, legacyExportCatalogPgDelta, type LegacyPgDeltaContext, -} from "./declarative.pgdelta.ts"; +} from "./legacy-pgdelta.ts"; const CTX: LegacyPgDeltaContext = { projectId: "ref", diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts similarity index 54% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.seam.layer.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 5252fe3173..7c688577a9 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -2,16 +2,17 @@ import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import { LegacyNetworkIdFlag } 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 { legacyReadDbToml } from "../../../../shared/legacy-db-config.toml-read.ts"; +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 { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; import { legacyResolveLocalProjectId, localDbContainerId, -} from "../../../../shared/legacy-docker-ids.ts"; -import { LegacyDeclarativeShadowDbError } from "./declarative.errors.ts"; -import { LegacyDeclarativeSeam } from "./declarative.seam.service.ts"; +} 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"; /** * Real `LegacyDeclarativeSeam`: runs the bundled `supabase-go`'s hidden @@ -24,6 +25,14 @@ export const legacyDeclarativeSeamLayer = Layer.effect( Effect.gen(function* () { const cliConfig = yield* LegacyCliConfig; const networkId = yield* LegacyNetworkIdFlag; + const profile = yield* LegacyProfileFlag; + // Forward a flag-selected `--profile` into the hidden seam subprocesses. Go's + // root loads the profile before config (`cmd/root.go`) and applies + // profile-specific overrides, but a flag-only `--profile snap` isn't in the + // child's env (only `SUPABASE_PROFILE` is, via `extendEnv`). Pass the raw flag + // token (built-in name or YAML path) so the child re-runs Go's identical + // resolution; skip the default so unselected runs are unchanged. + const profileArgs = profile !== "supabase" ? ["--profile", profile] : []; const spawner = yield* ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -56,6 +65,16 @@ export const legacyDeclarativeSeamLayer = Layer.effect( // same custom network as the pg-delta containers (LegacyGoProxy forwards // it the same way). ...(Option.isSome(networkId) ? ["--network-id", networkId.value] : []), + // Linked path (e.g. `generate --linked`, `db diff --from linked --to + // migrations`): pass the resolved ref as a flag so the catalog merges + // the matching `[remotes.]` override. It MUST be a flag, not + // SUPABASE_PROJECT_ID env: the `__catalog` command's group pre-run + // 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`). + ...(projectRef !== undefined ? ["--project-ref", projectRef] : []), + ...profileArgs, ]; const command = ChildProcess.make(resolved.found, args, { cwd: cliConfig.workdir, @@ -63,12 +82,11 @@ export const legacyDeclarativeSeamLayer = Layer.effect( stdout: "pipe", stderr: "inherit", extendEnv: true, - // For `generate --linked`, pass the resolved ref as SUPABASE_PROJECT_ID - // so the Go config load merges the `[remotes.]` override into the - // platform baseline (viper AutomaticEnv binds it to `project_id`; - // `config.go:492-516`), matching the monolith. `extendEnv` keeps the - // rest of the environment. - ...(projectRef !== undefined ? { env: { SUPABASE_PROJECT_ID: projectRef } } : {}), + // Disable the child's telemetry so the hidden `__catalog` seam + // doesn't emit its own `cli_command_executed` on top of the user's + // TS command (matching the explicit LegacyGoProxy delegates). + // `extendEnv` keeps the rest of the environment. + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, detached: false, }); const handle = yield* spawner.spawn(command).pipe( @@ -223,6 +241,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( "db", "start", ...(Option.isSome(networkId) ? ["--network-id", networkId.value] : []), + ...profileArgs, ]; const startCmd = ChildProcess.make(resolved.found, startArgs, { cwd: cliConfig.workdir, @@ -249,6 +268,129 @@ 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. + const command = ChildProcess.make("docker", ["rm", "-f", "-v", container], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + extendEnv: true, + }); + yield* spawner.exitCode(command).pipe(Effect.ignore); + }), }); }), ); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts similarity index 52% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.seam.service.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts index f394d8670a..de657d0af7 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.seam.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts @@ -1,10 +1,33 @@ import { Context, type Effect } from "effect"; -import type { LegacyDeclarativeShadowDbError } from "./declarative.errors.ts"; +import type { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; /** Which shadow-database catalog the Go seam should produce. */ export type LegacyCatalogMode = "baseline" | "migrations" | "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 @@ -50,6 +73,33 @@ interface LegacyDeclarativeSeamShape { * of failing to connect, matching Go. */ readonly ensureLocalDatabaseStarted: () => Effect.Effect; + /** + * 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 new file mode 100644 index 0000000000..644586df5d --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.ts @@ -0,0 +1,22 @@ +/** + * 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 new file mode 100644 index 0000000000..f8298aa30d --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.unit.test.ts @@ -0,0 +1,46 @@ +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/schema/declarative/declarative.pgdelta.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts similarity index 98% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.pgdelta.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts index eeb8d31dc0..a7b49c230d 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.pgdelta.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts @@ -3,23 +3,23 @@ import { Effect, FileSystem, Path } from "effect"; import { type LegacyEdgeRuntimeFile, LegacyEdgeRuntimeScript, -} from "../../../../shared/legacy-edge-runtime-script.service.ts"; +} from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LEGACY_PG_DELTA_SOURCE_SSL_ENV, LEGACY_PG_DELTA_TARGET_SSL_ENV, legacyPreparePgDeltaRef, -} from "../../../../shared/legacy-pgdelta-ssl.ts"; +} from "../../../shared/legacy-pgdelta-ssl.ts"; import { legacyInterpolatePgDeltaScript, legacyPgDeltaCatalogExportScript, legacyPgDeltaDeclarativeExportScript, legacyPgDeltaDiffScript, -} from "./declarative.deno-templates.ts"; +} from "./legacy-pgdelta.deno-templates.ts"; import { LegacyDeclarativeEdgeRuntimeError, LegacyDeclarativeEmptyOutputError, LegacyDeclarativeParseOutputError, -} from "./declarative.errors.ts"; +} from "./legacy-pgdelta.errors.ts"; const PG_DELTA_NPM_REGISTRY_ENV = "PGDELTA_NPM_REGISTRY"; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.pgdelta.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.unit.test.ts similarity index 98% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.pgdelta.unit.test.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.unit.test.ts index 784c72ffda..0ea876c5ad 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.pgdelta.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.unit.test.ts @@ -6,7 +6,7 @@ import { legacyIsPostgresURL, legacyPgDeltaBinds, legacyPgDeltaContainerRef, -} from "./declarative.pgdelta.ts"; +} from "./legacy-pgdelta.ts"; describe("legacyIsPostgresURL", () => { it("recognizes postgres:// and postgresql:// schemes", () => { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts new file mode 100644 index 0000000000..ffce5573f9 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts @@ -0,0 +1,109 @@ +import { Effect, type FileSystem, type Path } from "effect"; + +import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; +import type { LegacyDeclarativeOutput } from "./legacy-pgdelta.ts"; + +/** + * Materializes pg-delta declarative export output under the declarative dir. + * Mirrors Go's `WriteDeclarativeSchemas` (`declarative.go:239`): wipe the dir, + * recreate it, and write each file at its (path-safe) relative path. + * + * Go also updates `[db.migrations] schema_paths` afterwards, but only when + * pg-delta is *disabled* in config (`if utils.IsPgDeltaEnabled() { return nil }`). + * `db schema declarative generate/sync` force-enable pg-delta, so that branch is + * unreachable for them; `db pull --declarative` does NOT force-enable it, so the + * pull caller invokes `legacyUpdateDeclarativeSchemaPathsConfig` (below) when + * config pg-delta is disabled. Keeping the config edit at the caller leaves this + * writer a pure file-materializer shared unchanged by generate/sync. + */ +export const legacyWriteDeclarativeSchemas = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + declarativeDir: string, + output: LegacyDeclarativeOutput, +) { + yield* fs.remove(declarativeDir, { recursive: true }).pipe( + Effect.catchTag("PlatformError", (error) => + // Go wraps any failure; a missing dir is fine (we recreate it next). + error.reason._tag === "NotFound" + ? Effect.void + : Effect.fail( + new LegacyDeclarativeWriteError({ + message: `failed to clean declarative schema directory: ${error.message}`, + }), + ), + ), + ); + yield* fs.makeDirectory(declarativeDir, { recursive: true }); + + for (const file of output.files) { + const rel = path.normalize(file.path); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + return yield* Effect.fail( + new LegacyDeclarativeWriteError({ + message: `unsafe declarative export path: ${file.path}`, + }), + ); + } + const targetPath = path.join(declarativeDir, rel); + yield* fs.makeDirectory(path.dirname(targetPath), { recursive: true }); + yield* fs.writeFileString(targetPath, file.sql); + } +}); + +// Go's `schemaPathsPattern` (`internal/db/declarative/declarative.go:59`): +// `(?s)\nschema_paths = \[(.*?)\]\n`. The `(?s)` (dotall) maps to `[\s\S]`, and +// the capture group is unused (Go uses `ReplaceAllLiteral`). +const LEGACY_SCHEMA_PATHS_PATTERN = /\nschema_paths = \[[\s\S]*?\]\n/g; + +/** + * Ports Go's `updateDeclarativeSchemaPathsConfig` (`declarative.go:276-304`): a + * raw-text replace-or-append of `[db.migrations] schema_paths` in + * `supabase/config.toml`, pointing it at the `supabase/`-relative declarative dir. + * This is a literal byte-edit (NOT a TOML re-serialize), so it preserves comments + * and formatting exactly like Go — reproduce the regex and the literal block + * rather than "doing the right TOML thing". + * + * `resolvedDeclarativeDir` is the resolved declarative dir (Go's + * `GetDeclarativeDir()`, e.g. `supabase/database`); the leading `supabase/` is + * trimmed for the written value (Go's `strings.TrimPrefix`). + */ +export const legacyUpdateDeclarativeSchemaPathsConfig = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + resolvedDeclarativeDir: string, +) { + const normalized = resolvedDeclarativeDir.split("\\").join("/"); + const relative = normalized.startsWith("supabase/") + ? normalized.slice("supabase/".length) + : normalized; + // Go's literal replacement block (`declarative.go:278-284`): leading newline, + // two-space indent, trailing comma inside the array, trailing newline. + const block = `\nschema_paths = [\n "${relative}",\n]\n`; + const configPath = path.join(workdir, "supabase", "config.toml"); + const existing = yield* fs.readFileString(configPath).pipe( + Effect.catchTag("PlatformError", (error) => + // Go tolerates a missing config (`os.ErrNotExist`); other read errors abort. + error.reason._tag === "NotFound" + ? Effect.succeed("") + : Effect.fail( + new LegacyDeclarativeWriteError({ + message: `failed to read config: ${error.message}`, + }), + ), + ), + ); + // Use a replacer function so `$` in the path/value is never interpreted as a + // replacement pattern (Go's `ReplaceAllLiteral` semantics). + const replaced = existing.replace(LEGACY_SCHEMA_PATHS_PATTERN, () => block); + const next = replaced.includes(block) ? replaced : `${existing}\n[db.migrations]${block}`; + yield* fs + .writeFileString(configPath, next) + .pipe( + Effect.mapError( + (error) => + new LegacyDeclarativeWriteError({ message: `failed to save config: ${error.message}` }), + ), + ); +}); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.write.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts similarity index 77% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.write.unit.test.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts index 2f5cd0e824..be2a0e13de 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.write.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts @@ -6,24 +6,9 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Path } from "effect"; -import { LegacyDeclarativeWriteError } from "./declarative.errors.ts"; -import type { LegacyDeclarativeOutput } from "./declarative.pgdelta.ts"; -import { legacyFindDropStatements, legacyWriteDeclarativeSchemas } from "./declarative.write.ts"; - -describe("legacyFindDropStatements", () => { - it("flags DROP statements (case-insensitive) and ignores others", () => { - const sql = "DROP TABLE a;\nCREATE TABLE b();\ndrop function f();"; - expect(legacyFindDropStatements(sql)).toEqual(["DROP TABLE a", "drop function f()"]); - }); - - it("does not split a function body on its inner ; (no spurious statements)", () => { - // The dollar-quoted `;` must not create extra statements; this benign - // function (no DROP) stays whole and is therefore not flagged. - const sql = - "CREATE FUNCTION f() AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;\nDROP TABLE real;"; - expect(legacyFindDropStatements(sql)).toEqual(["DROP TABLE real"]); - }); -}); +import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; +import type { LegacyDeclarativeOutput } from "./legacy-pgdelta.ts"; +import { legacyWriteDeclarativeSchemas } from "./legacy-pgdelta.write.ts"; const write = (declarativeDir: string, output: LegacyDeclarativeOutput) => Effect.gen(function* () { 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 dcc6077f5e..a6384d4e06 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 @@ -60,6 +60,7 @@ function mockProxy() { Effect.sync(() => { calls.push([...args]); }), + execCapture: () => Effect.succeed(""), }), }; } 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 a568cf3968..e777838119 100644 --- a/apps/cli/src/legacy/commands/migration/migration.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/migration.integration.test.ts @@ -13,6 +13,7 @@ function mockLegacyGoProxy() { Effect.sync(() => { calls.push([...args]); }), + execCapture: () => Effect.succeed(""), }); return { layer, calls }; diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index 83bf74a5fe..c9c9d83fea 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -11,6 +11,7 @@ function setupLegacyStop() { Effect.sync(() => { calls.push(args); }), + execCapture: () => Effect.succeed(""), }); return { layer, calls }; } 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 80e37455bb..d6d9e355cf 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts @@ -332,7 +332,10 @@ export const legacyDbConfigLayer = Layer.effect( LegacyPlatformApiFactory > => Effect.gen(function* () { - const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // Linked-path read: merge the `[remotes.]` override (Go's pooler + // resolution runs after LoadConfig(ref) already merged), so this matches the + // ref-aware read on the main linked branch rather than validating base config. + const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir, ref); let connectionString = Option.getOrUndefined(tomlValues.poolerConnectionString); if (connectionString === undefined) { if (!fetchFromApi) return Option.none(); @@ -406,7 +409,12 @@ export const legacyDbConfigLayer = Layer.effect( const resolve = (flags: LegacyDbConfigFlags) => Effect.gen(function* () { - const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // 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, + // …) before the ref is known, failing a linked run Go accepts (Go validates + // the merged config after LoadProjectRef). Only `--db-url`/`--local` read base + // config — Go's direct/local `LoadConfig`, which never merges a remote block. // Go's `utils.Config.Hostname` (`GetHostname()`): honors // `SUPABASE_SERVICES_HOSTNAME` / a tcp `DOCKER_HOST` in dev-container or // remote-Docker setups, defaulting to 127.0.0.1. @@ -414,6 +422,7 @@ 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); // 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 @@ -502,6 +511,7 @@ export const legacyDbConfigLayer = Layer.effect( } // --local (default). + const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir); return { conn: { host: localHost, 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 4854543d75..ed0e1fe7f7 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 @@ -17,6 +17,14 @@ type EnvLookup = (name: string) => string | undefined; * and aborts the command rather than running against the default local database). */ interface LegacyDbTomlValues { + /** + * Resolves a `SUPABASE_*` env var with Go's precedence: shell env (non-empty) + * wins, then the loaded project `.env*` files (non-empty), else undefined. + * Go writes project `.env` into the process env before viper's `AutomaticEnv` + * reads these (`config.go:624,1055-1096`), so handlers must consult both + * rather than `process.env` alone (e.g. `SUPABASE_EXPERIMENTAL_PG_DELTA`). + */ + readonly envLookup: (name: string) => string | undefined; /** `[db] port`, default 54322 (`packages/config/src/db.ts`). */ readonly port: number; /** `[db] shadow_port`, default 54320. */ @@ -868,6 +876,7 @@ export const legacyReadDbToml = Effect.fnUntraced(function* ( ); const values: LegacyDbTomlValues = { + envLookup: envOverride, port, shadowPort, password: passwordRaw !== undefined ? legacyExpandEnv(passwordRaw, lookup) : DEFAULT_PASSWORD, 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 c5a4e6e33c..49b68bbbce 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts @@ -17,6 +17,13 @@ export class LegacyDbConnectError extends Data.TaggedError("LegacyDbConnectError */ export class LegacyDbExecError extends Data.TaggedError("LegacyDbExecError")<{ readonly message: string; + /** + * Postgres SQLSTATE (e.g. `42P01` undefined_table), extracted from the driver + * error's `cause` chain when present. Lets callers match Go's error-code checks + * (`pgerrcode.*`) instead of fuzzy message matching — e.g. suppressing only a + * missing migration-history table, not an undefined column. + */ + readonly code?: string; }> {} /** 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 26814742f1..8dfdb5b53b 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 @@ -95,16 +95,25 @@ const LEGACY_TLS_GATED_SQLSTATE = "28000"; * `@effect/sql`'s `SqlError.cause`), so we walk the `cause` chain looking for one. */ export function legacyIsTerminalConnectError(error: unknown, usedTls: boolean): boolean { + const code = legacyExtractSqlState(error); + if (code === undefined) return false; + if (LEGACY_TERMINAL_SQLSTATES.has(code)) return true; + return code === LEGACY_TLS_GATED_SQLSTATE && usedTls; +} + +/** + * Extracts the Postgres SQLSTATE from a driver error. The `pg` driver attaches the + * code as a `code` property on the server error, carried through `@effect/sql`'s + * `SqlError.cause`, so walk the `cause` chain and return the first string `code`. + */ +function legacyExtractSqlState(error: unknown): string | undefined { let current: unknown = error; for (let depth = 0; depth < 6 && typeof current === "object" && current !== null; depth++) { const code = Reflect.get(current, "code"); - if (typeof code === "string") { - if (LEGACY_TERMINAL_SQLSTATES.has(code)) return true; - if (code === LEGACY_TLS_GATED_SQLSTATE && usedTls) return true; - } + if (typeof code === "string") return code; current = Reflect.get(current, "cause"); } - return false; + return undefined; } /** @@ -556,16 +565,28 @@ const connect = ( exec: (sql) => client.unsafe(sql).pipe( Effect.asVoid, - Effect.mapError((error) => new LegacyDbExecError({ message: String(error) })), + Effect.mapError( + (error) => + new LegacyDbExecError({ message: String(error), code: legacyExtractSqlState(error) }), + ), ), query: (sql, params) => - client - .unsafe>(sql, params) - .pipe(Effect.mapError((error) => new LegacyDbExecError({ message: String(error) }))), + client.unsafe>(sql, params).pipe( + Effect.mapError( + (error) => + new LegacyDbExecError({ + message: String(error), + code: legacyExtractSqlState(error), + }), + ), + ), extensionExists: (name) => client`select 1 from pg_extension where extname = ${name}`.pipe( Effect.map((rows) => rows.length > 0), - Effect.mapError((error) => new LegacyDbExecError({ message: String(error) })), + Effect.mapError( + (error) => + new LegacyDbExecError({ message: String(error), code: legacyExtractSqlState(error) }), + ), ), queryRaw: (sql) => Effect.gen(function* () { 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 5d6f1ccdd6..5b7d273c78 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 @@ -33,8 +33,10 @@ const allocateFreeHostPort = Effect.callback>((resume) => /** * Real `LegacyEdgeRuntimeScript`: runs the Deno program in the edge-runtime * container via `LegacyDockerRun.runCapture`, overriding the image entrypoint - * with `sh -c ` (Go's `RunEdgeRuntimeScript`). The image is resolved - * once at construction; a fresh free port is allocated per run. + * with `sh -c ` (Go's `RunEdgeRuntimeScript`). The image (from the + * caller's effective `deno_version`) and a fresh free port are resolved per run, + * so layer construction reads no config (it would validate base config before a + * linked command resolves its ref). * * NOTE: the non-zero-exit message string is approximated from the docker exit * code and should be golden-verified against the Go binary. @@ -57,15 +59,6 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( // SUPABASE_SERVICES_HOSTNAME) resolves inside the container on Linux/dev-container. const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - // Read `[edge_runtime] deno_version` so a `deno_version = 1` project runs the - // `deno1` image, matching Go's config-driven image switch (the resolver applies - // the version pin first, then the deno1 override). This is the *base*-config - // value; a caller with a remote-merged config (e.g. `--linked` declarative - // generate) overrides it per-run via `opts.denoVersion` below. - const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); - const baseImage = legacyGetRegistryImageUrl( - yield* legacyResolveEdgeRuntimeImage(fs, path, cliConfig.workdir, toml.denoVersion), - ); // Go requests host networking for the edge-runtime container, but `DockerStart` // overrides any network mode (host included) with `--network-id` when set @@ -81,21 +74,24 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( return LegacyEdgeRuntimeScript.of({ run: (opts) => Effect.gen(function* () { - // Resolve the image per-run only when the caller supplies an effective - // `deno_version` that differs from the base config (the remote-merged - // value on `--linked` declarative generate); otherwise reuse the base - // image resolved once at layer construction. - const registryImage = - opts.denoVersion !== undefined && opts.denoVersion !== toml.denoVersion - ? legacyGetRegistryImageUrl( - yield* legacyResolveEdgeRuntimeImage( - fs, - path, - cliConfig.workdir, - opts.denoVersion, - ), - ) - : baseImage; + // Resolve the image per-run from the caller's effective `deno_version` — + // the remote-merged value the handler resolved AFTER the linked ref. The + // 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. + const denoVersion = + opts.denoVersion ?? + (yield* legacyReadDbToml(fs, path, cliConfig.workdir).pipe( + Effect.mapError( + (error) => new LegacyEdgeRuntimeScriptError({ message: error.message }), + ), + )).denoVersion; + const registryImage = legacyGetRegistryImageUrl( + yield* legacyResolveEdgeRuntimeImage(fs, path, cliConfig.workdir, denoVersion), + ); const port = yield* allocateFreeHostPort; const startCmd = legacyBuildEdgeRuntimeStartCmd({ port, debug }).join(" "); const files = [{ name: "index.ts", content: opts.script }, ...(opts.extraFiles ?? [])]; diff --git a/apps/cli/src/legacy/shared/legacy-schema-flags.ts b/apps/cli/src/legacy/shared/legacy-schema-flags.ts index d4f560ddb3..6c6a56b758 100644 --- a/apps/cli/src/legacy/shared/legacy-schema-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-schema-flags.ts @@ -116,3 +116,30 @@ export function legacyParseSchemaFlags(rawValues: ReadonlyArray): Readon } return schemas; } + +/** + * Whether a CSV field must be quoted. Mirrors Go's `encoding/csv` + * `Writer.fieldNeedsQuotes`: never quote the empty string; always quote `\.`; + * quote when the field contains `,`, `"`, `\r`, or `\n`; otherwise quote when the + * first rune is whitespace. + */ +function fieldNeedsQuotes(field: string): boolean { + if (field === "") return false; + if (field === "\\.") return true; + if (/[\n\r",]/u.test(field)) return true; + return /^\s/u.test(field); +} + +/** + * Serializes a SINGLE parsed schema value back into one CSV field — the inverse of + * `readAsCSVStrict` for one element. A schema parsed from `--schema '"tenant,one"'` + * is the single value `tenant,one`; forwarding it raw to the Go binary would let + * pflag's `StringSlice` CSV-parse it a SECOND time and split it into two schemas. + * Re-encoding (mirroring Go's `csv.Writer`) keeps it one field so the delegated + * child sees exactly the schema set the native path would. Used when rebuilding + * `--schema` argv for the Go-delegated `db diff` / `db pull` paths. + */ +export function legacySchemaToCsvField(value: string): string { + if (!fieldNeedsQuotes(value)) return value; + return `"${value.split('"').join('""')}"`; +} diff --git a/apps/cli/src/legacy/shared/legacy-schema-flags.unit.test.ts b/apps/cli/src/legacy/shared/legacy-schema-flags.unit.test.ts index 7edd1ca342..ca501f76b9 100644 --- a/apps/cli/src/legacy/shared/legacy-schema-flags.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-schema-flags.unit.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { legacyParseSchemaFlags, LegacySchemaFlagParseError } from "./legacy-schema-flags.ts"; +import { + legacyParseSchemaFlags, + LegacySchemaFlagParseError, + legacySchemaToCsvField, +} from "./legacy-schema-flags.ts"; describe("legacyParseSchemaFlags (pflag StringSlice CSV parity)", () => { it("splits unquoted comma-separated values", () => { @@ -70,3 +74,34 @@ describe("legacyParseSchemaFlags (pflag StringSlice CSV parity)", () => { expect(() => legacyParseSchemaFlags(["public", '"broken'])).toThrow(LegacySchemaFlagParseError); }); }); + +describe("legacySchemaToCsvField (inverse — re-encode one value as a CSV field)", () => { + it("leaves a plain value unquoted", () => { + expect(legacySchemaToCsvField("public")).toBe("public"); + }); + + it("leaves the empty string unquoted (Go csv.Writer)", () => { + expect(legacySchemaToCsvField("")).toBe(""); + }); + + it("quotes a value containing a comma", () => { + expect(legacySchemaToCsvField("tenant,one")).toBe('"tenant,one"'); + }); + + it("quotes and doubles an embedded quote", () => { + expect(legacySchemaToCsvField('a"b')).toBe('"a""b"'); + }); + + it("quotes a value with a leading space", () => { + expect(legacySchemaToCsvField(" leading")).toBe('" leading"'); + }); + + it("round-trips through the parser for awkward values", () => { + // parse(encode(x)) === [x] for the cases a delegated child would otherwise split. + for (const value of ["public", "tenant,one", 'a"b', " leading", "a,b,c", ""]) { + expect(legacyParseSchemaFlags([legacySchemaToCsvField(value)])).toEqual( + value === "" ? [] : [value], + ); + } + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-sql-split.ts b/apps/cli/src/legacy/shared/legacy-sql-split.ts index 16e8c30263..4eec9072a3 100644 --- a/apps/cli/src/legacy/shared/legacy-sql-split.ts +++ b/apps/cli/src/legacy/shared/legacy-sql-split.ts @@ -184,3 +184,16 @@ export function legacySplitAndTrim(sql: string): string[] { (token) => token.trim(), ); } + +// `(?i)drop\s+` — Go's `dropStatementPattern` (`internal/db/diff/diff.go:100`, +// also `internal/db/declarative/declarative.go:62`). +const DROP_STATEMENT_PATTERN = /drop\s+/i; + +/** + * Extracts DROP statements from a schema diff for the safety warning shown by + * `db diff` / `db pull` / declarative `sync`. Mirrors Go's `findDropStatements`: + * split the SQL into statements, then keep those matching `(?i)drop\s+`. + */ +export function legacyFindDropStatements(sql: string): ReadonlyArray { + return legacySplitAndTrim(sql).filter((statement) => DROP_STATEMENT_PATTERN.test(statement)); +} 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 a5fbf00d76..b04a4793a0 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 @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { legacySplitAndTrim, legacySplitSql } from "./legacy-sql-split.ts"; +import { + legacyFindDropStatements, + legacySplitAndTrim, + legacySplitSql, +} from "./legacy-sql-split.ts"; describe("legacySplitAndTrim", () => { it("splits simple statements and trims trailing ; + whitespace", () => { @@ -72,3 +76,18 @@ describe("legacySplitSql", () => { expect(legacySplitSql("SELECT 1; SELECT 2")).toEqual(["SELECT 1;", " SELECT 2"]); }); }); + +describe("legacyFindDropStatements", () => { + it("flags DROP statements (case-insensitive) and ignores others", () => { + const sql = "DROP TABLE a;\nCREATE TABLE b();\ndrop function f();"; + expect(legacyFindDropStatements(sql)).toEqual(["DROP TABLE a", "drop function f()"]); + }); + + it("does not split a function body on its inner ; (no spurious statements)", () => { + // The dollar-quoted `;` must not create extra statements; this benign + // function (no DROP) stays whole and is therefore not flagged. + const sql = + "CREATE FUNCTION f() AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;\nDROP TABLE real;"; + expect(legacyFindDropStatements(sql)).toEqual(["DROP TABLE real"]); + }); +}); 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 2757101ed6..2f42731fd7 100644 --- a/apps/cli/src/next/commands/branches/create/create.handler.ts +++ b/apps/cli/src/next/commands/branches/create/create.handler.ts @@ -17,7 +17,7 @@ const resolveBranchName = Effect.fnUntraced(function* (nameOpt: Option.Option { calls.push([...args]); }), + execCapture: () => Effect.succeed(""), }), get calls() { return calls; 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 edd6d5f455..4867d77101 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -33,6 +33,7 @@ function mockLegacyGoProxy() { Effect.sync(() => { calls.push([...args]); }), + execCapture: () => Effect.succeed(""), }); return { layer, calls }; diff --git a/apps/cli/src/shared/git/git-branch.ts b/apps/cli/src/shared/git/git-branch.ts index 6091e1be31..190917f514 100644 --- a/apps/cli/src/shared/git/git-branch.ts +++ b/apps/cli/src/shared/git/git-branch.ts @@ -12,34 +12,38 @@ import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; * Returns `Option.none()` when no git repository is detected. Callers may * substitute their own default (e.g. Go's `GetGitBranch` defaults to "main"; * `branches create` defaults to the empty string so the prompt is skipped). + * + * `startDir` is the directory to begin the walk from; it defaults to the + * runtime CWD. Commands that resolve a `--workdir` should pass it, because Go + * chdirs into the workdir in `PersistentPreRunE` before calling `GetGitBranch` + * (`cmd/root.go`), so the branch must reflect the project dir, not the caller's. */ -export const detectGitBranch: Effect.Effect< - Option.Option, - never, - RuntimeInfo | FileSystem.FileSystem | Path.Path -> = Effect.gen(function* () { - const githubHeadRef = process.env["GITHUB_HEAD_REF"]; - if (githubHeadRef !== undefined && githubHeadRef.length > 0) { - return Option.some(githubHeadRef); - } +export const detectGitBranch = ( + startDir?: string, +): Effect.Effect, never, RuntimeInfo | FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + const githubHeadRef = process.env["GITHUB_HEAD_REF"]; + if (githubHeadRef !== undefined && githubHeadRef.length > 0) { + return Option.some(githubHeadRef); + } - const runtimeInfo = yield* RuntimeInfo; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + const runtimeInfo = yield* RuntimeInfo; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - let dir = path.resolve(runtimeInfo.cwd); - const root = path.parse(dir).root; + let dir = path.resolve(startDir ?? runtimeInfo.cwd); + const root = path.parse(dir).root; - while (true) { - const headPath = path.join(dir, ".git", "HEAD"); - const content = yield* fs.readFileString(headPath).pipe(Effect.option); - if (Option.isSome(content)) { - const match = content.value.trim().match(/^ref: refs\/heads\/(.+)$/); - return match?.[1] !== undefined ? Option.some(match[1]) : Option.none(); - } - if (dir === root) { - return Option.none(); + while (true) { + const headPath = path.join(dir, ".git", "HEAD"); + const content = yield* fs.readFileString(headPath).pipe(Effect.option); + if (Option.isSome(content)) { + const match = content.value.trim().match(/^ref: refs\/heads\/(.+)$/); + return match?.[1] !== undefined ? Option.some(match[1]) : Option.none(); + } + if (dir === root) { + return Option.none(); + } + dir = path.dirname(dir); } - dir = path.dirname(dir); - } -}); + }); diff --git a/apps/cli/src/shared/git/git-branch.unit.test.ts b/apps/cli/src/shared/git/git-branch.unit.test.ts index 3b32a35660..1c8640f8ca 100644 --- a/apps/cli/src/shared/git/git-branch.unit.test.ts +++ b/apps/cli/src/shared/git/git-branch.unit.test.ts @@ -30,7 +30,7 @@ describe("detectGitBranch", () => { original = process.env["GITHUB_HEAD_REF"]; process.env["GITHUB_HEAD_REF"] = "ci-branch"; return Effect.gen(function* () { - const got = yield* detectGitBranch; + const got = yield* detectGitBranch(); try { expect(Option.isSome(got)).toBe(true); if (Option.isSome(got)) expect(got.value).toBe("ci-branch"); @@ -48,7 +48,7 @@ describe("detectGitBranch", () => { mkdirSync(join(root, ".git")); writeFileSync(join(root, ".git", "HEAD"), "ref: refs/heads/feature-x\n"); return Effect.gen(function* () { - const got = yield* detectGitBranch; + const got = yield* detectGitBranch(); try { expect(Option.isSome(got)).toBe(true); if (Option.isSome(got)) expect(got.value).toBe("feature-x"); @@ -68,7 +68,7 @@ describe("detectGitBranch", () => { mkdirSync(join(root, ".git")); writeFileSync(join(root, ".git", "HEAD"), "ref: refs/heads/main\n"); return Effect.gen(function* () { - const got = yield* detectGitBranch; + const got = yield* detectGitBranch(); try { expect(Option.isSome(got)).toBe(true); if (Option.isSome(got)) expect(got.value).toBe("main"); @@ -84,7 +84,7 @@ describe("detectGitBranch", () => { delete process.env["GITHUB_HEAD_REF"]; const root = mkdtempSync(join(tmpdir(), "git-branch-empty-")); return Effect.gen(function* () { - const got = yield* detectGitBranch; + const got = yield* detectGitBranch(); try { expect(Option.isNone(got)).toBe(true); } finally { @@ -101,7 +101,7 @@ describe("detectGitBranch", () => { mkdirSync(join(root, ".git")); writeFileSync(join(root, ".git", "HEAD"), "deadbeef\n"); return Effect.gen(function* () { - const got = yield* detectGitBranch; + const got = yield* detectGitBranch(); try { expect(Option.isNone(got)).toBe(true); } finally { @@ -110,4 +110,27 @@ describe("detectGitBranch", () => { } }).pipe(Effect.provide(withCwd(root))); }); + + it.live("walks from an explicit startDir instead of the runtime CWD", () => { + const original6 = process.env["GITHUB_HEAD_REF"]; + delete process.env["GITHUB_HEAD_REF"]; + // The project repo (with .git/HEAD) is the startDir; the runtime CWD is an + // unrelated dir with no repo, mirroring `supabase --workdir ` run + // from elsewhere. + const project = mkdtempSync(join(tmpdir(), "git-branch-workdir-")); + mkdirSync(join(project, ".git")); + writeFileSync(join(project, ".git", "HEAD"), "ref: refs/heads/project-branch\n"); + const elsewhere = mkdtempSync(join(tmpdir(), "git-branch-cwd-")); + return Effect.gen(function* () { + const got = yield* detectGitBranch(project); + try { + expect(Option.isSome(got)).toBe(true); + if (Option.isSome(got)) expect(got.value).toBe("project-branch"); + } finally { + rmSync(project, { recursive: true, force: true }); + rmSync(elsewhere, { recursive: true, force: true }); + if (original6 !== undefined) process.env["GITHUB_HEAD_REF"] = original6; + } + }).pipe(Effect.provide(withCwd(elsewhere))); + }); }); diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.ts b/apps/cli/src/shared/legacy/go-proxy.layer.ts index 2d4a9b37bf..b190d9f109 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.ts @@ -3,7 +3,7 @@ import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import process from "node:process"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import { CLI_VERSION } from "../cli/version.ts"; @@ -216,6 +216,47 @@ export function makeGoProxyLayer(opts?: { } }), ), + execCapture: (args, execOpts) => + Effect.scoped( + Effect.gen(function* () { + if (!("found" in resolved)) { + yield* Effect.sync(() => { + process.stderr.write(`${formatGoBinaryNotFoundError(resolved.notFound)}\n`); + }); + return yield* processControl.exit(1); + } + const binary = resolved.found; + yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); + const env = + opts?.env || execOpts?.env ? { ...opts?.env, ...execOpts?.env } : undefined; + // 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 + // inherited (interactive); callers pass `"ignore"` to give the child a + // non-TTY stdin so it can't block on a prompt before the wrapper emits + // its machine-output envelope. + const command = ChildProcess.make(binary, [...globalArgs, ...args], { + cwd: execOpts?.cwd ?? opts?.cwd, + env, + extendEnv: true, + stdin: execOpts?.stdin ?? "inherit", + stdout: "pipe", + stderr: "inherit", + detached: false, + }); + const handle = yield* spawner.spawn(command).pipe(Effect.orDie); + // Drain stdout fully before awaiting exit so a full pipe buffer can't + // deadlock the child. + const captured = yield* Stream.mkString(Stream.decodeText(handle.stdout)).pipe( + Effect.orDie, + ); + const exitCode = yield* handle.exitCode.pipe(Effect.orDie); + if (exitCode !== 0) { + return yield* processControl.exit(exitCode); + } + return captured; + }), + ), }); }), ); diff --git a/apps/cli/src/shared/legacy/go-proxy.service.ts b/apps/cli/src/shared/legacy/go-proxy.service.ts index 671bf009b2..e9539e75a4 100644 --- a/apps/cli/src/shared/legacy/go-proxy.service.ts +++ b/apps/cli/src/shared/legacy/go-proxy.service.ts @@ -18,6 +18,32 @@ interface LegacyGoProxyShape { args: ReadonlyArray, opts?: { readonly cwd?: string; readonly env?: Record }, ) => Effect.Effect; + + /** + * Like `exec`, but captures the child's stdout and returns it as a string + * instead of inheriting stdout. stderr is still inherited (so progress / + * diagnostics pass straight through), and a non-zero exit still terminates the + * process with the same code. + * + * `opts.stdin` controls the child's stdin: `"inherit"` (default) keeps the + * child interactive (its prompts reach the terminal); `"ignore"` gives it a + * non-TTY stdin so prompts (Go's `PromptYesNo`) take their default instead of + * blocking — required when a machine-output caller delegates a command that + * would otherwise prompt before the JSON envelope is emitted. + * + * Used in machine-output mode (`--output-format json|stream-json`) to wrap a + * delegated engine's stdout in a structured payload, instead of letting the + * child's raw bytes land on stdout and corrupt the JSON envelope (the CLI-1546 + * "stdout is payload-only in machine mode" invariant). + */ + readonly execCapture: ( + args: ReadonlyArray, + opts?: { + readonly cwd?: string; + readonly env?: Record; + readonly stdin?: "inherit" | "ignore"; + }, + ) => Effect.Effect; } export class LegacyGoProxy extends Context.Service()(