From d029667ce538516c86ea637cdf657e0fde81a718 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 18 Jun 2026 14:24:55 +0100 Subject: [PATCH 01/24] feat(cli): port db diff and db pull to native TypeScript (CLI-1313) Replace the Phase-0 Go proxy stubs for `db diff` and `db pull` with native Effect handlers in the legacy shell. - Promote the pg-delta engine (adapter, seam, cache, deno-templates, write, errors) from db/schema/declarative/ to db/shared/legacy-pgdelta.*; update the generate/sync call sites. Hoist findDropStatements to legacy-sql-split. - Add a live-shadow Go seam: hidden `db __shadow` command (+ PrepareShadowSource / PrepareRawShadow, refactored from DiffDatabase / pullDeclarativePgDelta) provisions a shadow Postgres, prints its URL + container, and leaves it running so the native handler runs migra/pg-delta against it then tears it down. Both engines now mirror Go's differ(shadowConfig, config). - db diff: native pg-delta / migra (edge-runtime) + explicit --from/--to mode; --use-pgadmin / --use-pg-schema delegate to the Go binary. - db pull: native pg-delta / migra migration + --declarative pg-delta export; reconciles schema_migrations and updates remote history. --experimental dump and initial-pull pg_dump (migra) delegate to the Go binary. - Shared: legacy-diff-engine (pure resolvers), legacy-migra (free fn over context, OOM->bash fallback), byte-exact migra deno-templates, migration-file helpers, pull.sync reconciliation. - Add --output-format json/stream-json envelopes; unit + integration + e2e tests; SIDE_EFFECTS.md; flip both commands to `ported` in the porting status. --- apps/cli-go/cmd/db.go | 50 +++ apps/cli-go/internal/db/diff/diff.go | 44 +- apps/cli-go/internal/db/diff/shadow.go | 116 ++++++ apps/cli-go/internal/db/pull/pull.go | 16 +- apps/cli/docs/go-cli-porting-status.md | 218 +++++----- .../legacy/commands/db/diff/SIDE_EFFECTS.md | 92 +++-- .../legacy/commands/db/diff/diff.command.ts | 56 ++- .../legacy/commands/db/diff/diff.e2e.test.ts | 24 ++ .../legacy/commands/db/diff/diff.errors.ts | 52 +++ .../legacy/commands/db/diff/diff.explicit.ts | 23 ++ .../db/diff/diff.explicit.unit.test.ts | 30 ++ .../legacy/commands/db/diff/diff.handler.ts | 382 +++++++++++++++++- .../commands/db/diff/diff.integration.test.ts | 378 +++++++++++++++++ .../legacy/commands/db/diff/diff.layers.ts | 61 +++ .../legacy/commands/db/pull/SIDE_EFFECTS.md | 96 +++-- .../legacy/commands/db/pull/pull.command.ts | 54 ++- .../legacy/commands/db/pull/pull.e2e.test.ts | 22 + .../legacy/commands/db/pull/pull.errors.ts | 51 +++ .../legacy/commands/db/pull/pull.handler.ts | 360 ++++++++++++++++- .../commands/db/pull/pull.integration.test.ts | 345 ++++++++++++++++ .../legacy/commands/db/pull/pull.layers.ts | 50 +++ .../src/legacy/commands/db/pull/pull.sync.ts | 177 ++++++++ .../commands/db/pull/pull.sync.unit.test.ts | 65 +++ .../declarative/declarative.debug-bundle.ts | 2 +- .../schema/declarative/declarative.errors.ts | 62 --- ...eclarative.orchestrate.integration.test.ts | 7 +- .../declarative/declarative.orchestrate.ts | 6 +- .../declarative/declarative.smart-target.ts | 2 +- .../declarative/generate/generate.handler.ts | 6 +- .../generate/generate.integration.test.ts | 9 +- .../declarative/generate/generate.layers.ts | 2 +- .../schema/declarative/sync/sync.handler.ts | 9 +- .../declarative/sync/sync.integration.test.ts | 4 +- .../db/schema/declarative/sync/sync.layers.ts | 2 +- .../commands/db/shared/legacy-diff-engine.ts | 75 ++++ .../db/shared/legacy-diff-engine.unit.test.ts | 102 +++++ .../db/shared/legacy-migra.deno-templates.ts | 17 + .../legacy-migra.deno-templates.unit.test.ts | 22 + .../commands/db/shared/legacy-migra.errors.ts | 21 + .../legacy/commands/db/shared/legacy-migra.ts | 262 ++++++++++++ .../db/shared/legacy-migration-file.ts | 26 ++ .../shared/legacy-migration-file.unit.test.ts | 29 ++ .../legacy-pgdelta.cache.ts} | 2 +- .../legacy-pgdelta.cache.unit.test.ts} | 2 +- .../legacy-pgdelta.deno-templates.ts} | 2 +- ...egacy-pgdelta.deno-templates.unit.test.ts} | 6 +- .../db/shared/legacy-pgdelta.errors.ts | 71 ++++ .../legacy-pgdelta.integration.test.ts} | 10 +- .../legacy-pgdelta.seam.layer.ts} | 98 ++++- .../legacy-pgdelta.seam.service.ts} | 45 ++- .../legacy-pgdelta.ts} | 8 +- .../legacy-pgdelta.unit.test.ts} | 2 +- .../legacy-pgdelta.write.ts} | 17 +- .../legacy-pgdelta.write.unit.test.ts} | 21 +- .../cli/src/legacy/shared/legacy-sql-split.ts | 13 + .../shared/legacy-sql-split.unit.test.ts | 21 +- 56 files changed, 3339 insertions(+), 406 deletions(-) create mode 100644 apps/cli-go/internal/db/diff/shadow.go create mode 100644 apps/cli/src/legacy/commands/db/diff/diff.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/db/diff/diff.errors.ts create mode 100644 apps/cli/src/legacy/commands/db/diff/diff.explicit.ts create mode 100644 apps/cli/src/legacy/commands/db/diff/diff.explicit.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/db/diff/diff.layers.ts create mode 100644 apps/cli/src/legacy/commands/db/pull/pull.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/db/pull/pull.errors.ts create mode 100644 apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/db/pull/pull.layers.ts create mode 100644 apps/cli/src/legacy/commands/db/pull/pull.sync.ts create mode 100644 apps/cli/src/legacy/commands/db/pull/pull.sync.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-diff-engine.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-diff-engine.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-migra.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-migration-file.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-migration-file.unit.test.ts rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.cache.ts => shared/legacy-pgdelta.cache.ts} (99%) rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.cache.unit.test.ts => shared/legacy-pgdelta.cache.unit.test.ts} (99%) rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.deno-templates.ts => shared/legacy-pgdelta.deno-templates.ts} (99%) rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.deno-templates.unit.test.ts => shared/legacy-pgdelta.deno-templates.unit.test.ts} (93%) create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.pgdelta.integration.test.ts => shared/legacy-pgdelta.integration.test.ts} (96%) rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.seam.layer.ts => shared/legacy-pgdelta.seam.layer.ts} (72%) rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.seam.service.ts => shared/legacy-pgdelta.seam.service.ts} (56%) rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.pgdelta.ts => shared/legacy-pgdelta.ts} (98%) rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.pgdelta.unit.test.ts => shared/legacy-pgdelta.unit.test.ts} (98%) rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.write.ts => shared/legacy-pgdelta.write.ts} (70%) rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.write.unit.test.ts => shared/legacy-pgdelta.write.unit.test.ts} (77%) diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index da22dee063..0077310f49 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -197,6 +197,49 @@ var ( }, } + shadowMode string + shadowTargetLocal bool + shadowUsePgDelta bool + shadowSchema []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). 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 { + 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, afero.NewOsFs()) + default: + return fmt.Errorf("unknown shadow mode: %s", shadowMode) + } + if err != nil { + return err + } + fmt.Println(src.Container) + fmt.Println(utils.ToPostgresURL(src.Source)) + if src.TargetOverride != nil { + fmt.Println(utils.ToPostgresURL(*src.TargetOverride)) + } else { + fmt.Println("") + } + return nil + }, + } + dbRemoteCmd = &cobra.Command{ Hidden: true, Use: "remote", @@ -475,6 +518,13 @@ 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.") + 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/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/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/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..4786af50fa 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,368 @@ -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 { 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 } : {}), + }); + +/** + * 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) => { + // Only forward an explicitly-true boolean — a `Some(false)` equals the cobra + // default, so emitting `--flag=false` would just add noise. + if (Option.isSome(value) && value.value) args.push(`--${name}`); + }; + 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"); + pushBool("linked", flags.linked); + pushBool("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); + for (const s of flags.schema) args.push("--schema", 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; + + /** Resolves an explicit `--from`/`--to` ref to a pg-delta SOURCE/TARGET ref. */ + const resolveExplicitRef = ( + ref: string, + toml: { readonly port: number; readonly password: string }, + ) => + Effect.gen(function* () { + switch (legacyClassifyExplicitRef(ref)) { + case "local": + return legacyToPostgresURL({ + host: legacyGetHostname(), + port: toml.port, + user: "postgres", + password: toml.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; + return connToUrl(resolved.conn); + } + case "migrations": + return yield* seam.exportCatalog({ mode: "migrations", noCache: false }); + case "url": + return ref; + default: + return yield* Effect.fail( + new LegacyDbDiffUnknownTargetError({ message: legacyUnknownTargetMessage(ref) }), + ); + } + }); + + 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`, + }), + ); + } + + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + 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, () => ""); + + // Explicit `--from`/`--to` mode (Go's `db.go:102-109`): both required, always + // pg-delta. Runs before engine resolution and the shadow path. + const fromSet = Option.isSome(flags.from); + const toSet = Option.isSome(flags.to); + if (fromSet || toSet) { + if (!fromSet || !toSet) { + return yield* Effect.fail( + new LegacyDbDiffExplicitFlagsError({ + message: "must set both --from and --to when using explicit diff mode", + }), + ); + } + const sourceRef = yield* resolveExplicitRef( + Option.getOrElse(flags.from, () => ""), + toml, + ); + const targetRef = yield* resolveExplicitRef( + Option.getOrElse(flags.to, () => ""), + toml, + ); + const result = yield* legacyDiffPgDelta(ctx, { + sourceRef, + targetRef, + schema: flags.schema, + formatOptions, + }); + // Explicit-mode output: `--output` file (Go's `writeOutput`) or stdout + // (Go's `fmt.Print`, no trailing newline — pg-delta ends each statement `;\n`). + if (Option.isSome(flags.output)) { + const target = path.resolve(cliConfig.workdir, flags.output.value); + 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; + } + + // Engine resolution (Go's `db.go:110`): the pg-delta env/config/flag gate. + const usePgAdmin = Option.getOrElse(flags.usePgAdmin, () => false); + const usePgSchema = Option.getOrElse(flags.usePgSchema, () => false); + const pgDeltaDefault = legacyShouldUsePgDelta({ + configEnabled: toml.pgDelta.enabled, + usePgDeltaFlag: Option.getOrElse(flags.usePgDelta, () => false), + envEnabled: legacyParseBoolEnv(process.env["SUPABASE_EXPERIMENTAL_PG_DELTA"]), + }); + const useDelta = legacyResolveDiffEngine({ + useMigraChanged: Option.isSome(flags.useMigra), + usePgAdmin, + usePgSchema, + pgDeltaDefault, + }); + + // pgAdmin / pg-schema delegate to the bundled Go binary (Go's `RunPgAdmin` / + // `DiffPgSchema` are not ported). Disable the child's telemetry so the single + // `cli_command_executed` event comes from this TS command's instrumentation. + if (usePgAdmin) { + yield* proxy.exec(rebuildDelegateArgs(flags), { + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + }); + return; + } + if (usePgSchema) { + yield* output.raw( + `${legacyYellow("WARNING:")} --use-pg-schema flag is experimental and may not include all entities, such as views and grants.\n`, + "stderr", + ); + yield* proxy.exec(rebuildDelegateArgs(flags), { + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + }); + 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); + + yield* output.raw("Creating shadow database...\n", "stderr"); + const shadow = yield* seam.provisionShadow({ + mode: "diff", + targetLocal: resolved.isLocal, + usePgDelta: useDelta, + schema: flags.schema, + }); + + 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))); + + const branch = Option.getOrElse(yield* detectGitBranch, () => "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"); + } else if (Option.isSome(flags.file)) { + 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..0551985710 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -0,0 +1,378 @@ +import { existsSync, readdirSync } 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 } 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` +} + +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 }> = []; + const removedContainers: string[] = []; + const exportCalls: string[] = []; + const seam = Layer.succeed(LegacyDeclarativeSeam, { + exportCatalog: ({ mode }) => { + exportCalls.push(mode); + return Effect.succeed("supabase/.temp/pgdelta/migrations.json"); + }, + execInherit: () => Effect.succeed(0), + ensureLocalDatabaseStarted: () => Effect.void, + provisionShadow: ({ mode, targetLocal, usePgDelta }) => { + provisionCalls.push({ mode, targetLocal, usePgDelta }); + 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 proxy = Layer.succeed(LegacyGoProxy, { + exec: (args, execOpts) => Effect.sync(() => void proxyCalls.push({ args, env: execOpts?.env })), + }); + + 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(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false) }), + mockRuntimeInfo(), + BunServices.layer, + ); + + return { + layer, + out, + cache, + telemetry, + provisionCalls, + removedContainers, + exportCalls, + edgeCalls, + resolverCalls, + proxyCalls, + 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("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("warns then delegates --use-pg-schema to the Go binary", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgSchema: Option.some(true) })); + expect(stderr(s.out)).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("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("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("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)); + }); +}); 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.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..7e3d10f088 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,356 @@ -import { Effect, Option } from "effect"; +import { Clock, Effect, FileSystem, Option, Path } from "effect"; + +import { LegacyDnsResolverFlag, 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 { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.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 { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { 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 { + type LegacyPgDeltaContext, + legacyDeclarativeExportPgDelta, + legacyDiffPgDelta, +} from "../shared/legacy-pgdelta.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 } : {}), + }); + +/** 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) => { + // Only forward an explicitly-true boolean (a `Some(false)` equals the default). + if (Option.isSome(value) && value.value) args.push(`--${name}`); + }; + 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); - } + for (const s of flags.schema) args.push("--schema", 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"); + pushBool("linked", flags.linked); + pushBool("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 tty = yield* Tty; + 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 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); + + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + 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, () => ""); + + const usePgDeltaDiff = legacyResolvePullDiffEngine({ + engineFlagChanged: Option.isSome(flags.diffEngine), + engine: Option.getOrElse(flags.diffEngine, () => "migra"), + pgDeltaDefault: legacyShouldUsePgDelta({ + configEnabled: toml.pgDelta.enabled, + usePgDeltaFlag: false, + envEnabled: legacyParseBoolEnv(process.env["SUPABASE_EXPERIMENTAL_PG_DELTA"]), + }), + }); + + // 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 declarativeDir = path.resolve( + cliConfig.workdir, + legacyResolveDeclarativeDir(path, toml.pgDelta), + ); + const shadow = yield* seam.provisionShadow({ + mode: "declarative", + targetLocal: false, + usePgDelta: true, + schema: flags.schema, + }); + const exported = yield* legacyDeclarativeExportPgDelta(ctx, { + sourceRef: shadow.sourceUrl, + targetRef: targetUrl, + 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 })), + ); + 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. + if (legacyParseBoolEnv(process.env["SUPABASE_EXPERIMENTAL"])) { + yield* proxy.exec(rebuildDelegateArgs(flags), { + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + }); + 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. + yield* proxy.exec(rebuildDelegateArgs(flags), { + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + }); + 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", + targetLocal: false, + usePgDelta: usePgDeltaDiff, + schema: diffSchema, + }); + const out = yield* Effect.gen(function* () { + yield* output.raw( + diffSchema.length > 0 + ? `Diffing schemas: ${diffSchema.join(",")}\n` + : "Diffing schemas...\n", + "stderr", + ); + if (usePgDeltaDiff) { + const result = yield* legacyDiffPgDelta(ctx, { + sourceRef: shadow.sourceUrl, + targetRef: targetUrl, + schema: diffSchema, + formatOptions, + }); + return result.sql; + } + return yield* legacyDiffMigra(ctx, { + source: shadow.sourceUrl, + target: targetUrl, + schema: diffSchema, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }); + }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + + if (out.trim().length === 0) { + 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 default yes; + // honored verbatim under `--yes`; non-interactive falls through to the default). + let remoteHistoryUpdated = false; + const shouldUpdate = yes + ? true + : !tty.stdinIsTty + ? true + : yield* output.promptConfirm("Update remote migration history table?", { + defaultValue: true, + }); + if (yes) { + yield* output.raw("Update remote migration history table? [Y/n] y\n", "stderr"); + } + if (shouldUpdate) { + yield* legacyUpdateMigrationHistory(session, fs, path, migrationPath); + 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..99b57a0529 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -0,0 +1,345 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyDnsResolverFlag, 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 { + 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 promptConfirmResponses?: ReadonlyArray; +} + +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 }> = []; + 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 }) => { + provisionCalls.push({ mode, usePgDelta }); + return Effect.succeed({ + container: "shadow-1", + sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", + targetUrlOverride: undefined, + }); + }, + removeShadowContainer: (container) => + Effect.sync(() => { + removedContainers.push(container); + }), + }); + + const edge = Layer.succeed(LegacyEdgeRuntimeScript, { + run: (_runOpts: LegacyEdgeRuntimeRunOpts) => + 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 resolver = Layer.succeed(LegacyDbConfigResolver, { + resolve: () => + Effect.succeed({ + conn: { + host: "db.remote", + port: 5432, + user: "postgres", + password: "x", + database: "postgres", + }, + isLocal: false, + ref: Option.none(), + }), + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); + + const proxyCalls: Array<{ args: ReadonlyArray; env?: Record }> = []; + const proxy = Layer.succeed(LegacyGoProxy, { + exec: (args, execOpts) => Effect.sync(() => void proxyCalls.push({ args, env: execOpts?.env })), + }); + + 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(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false) }), + mockRuntimeInfo(), + BunServices.layer, + ); + + return { layer, out, provisionCalls, removedContainers, proxyCalls, historyUpserts, execLog }; +} + +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( + "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 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("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: the !tty branch falls through to the default (true). + }); + 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("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..005843bab0 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts @@ -0,0 +1,177 @@ +import { Effect, type FileSystem, type Path } from "effect"; + +import { legacyBold } from "../../../shared/legacy-colors.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 { + const MAX = Number.MAX_SAFE_INTEGER; + 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`). + const parseVersion = (v: string): number | undefined => + /^\d+$/u.test(v) ? Number(v) : undefined; + 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`): an undefined + * history table means the remote has no migrations, so it returns `[]` rather + * than failing. + */ +export const legacyListRemoteMigrations = (session: LegacyDbSession) => + session.query(LIST_MIGRATION_VERSION).pipe( + Effect.map((rows) => rows.map((row) => String(row["version"]))), + Effect.catch((error) => + /does not exist/iu.test(error.message) + ? Effect.succeed>([]) + : Effect.fail(new LegacyMigrationsReadError({ message: 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, +) => + Effect.gen(function* () { + const content = yield* fs.readFileString(migrationPath); + const statements = legacySplitAndTrim(content); + const match = MIGRATE_FILE_PATTERN.exec(path.basename(migrationPath)); + const version = match?.[1] ?? ""; + const name = match?.[2] ?? ""; + 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}`, + }), + ), + ); 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..245dd3de69 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.unit.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { legacyReconcileMigrations, legacySuggestMigrationRepair } from "./pull.sync.ts"; + +// 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", + }); + }); +}); + +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.debug-bundle.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.debug-bundle.ts index c1ffc646dd..4a0323d492 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.debug-bundle.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.debug-bundle.ts @@ -1,7 +1,7 @@ import { Effect, type FileSystem, type Path } from "effect"; import { legacyBold, legacyYellow } from "../../../../shared/legacy-colors.ts"; -import { legacyListLocalMigrations } from "./declarative.cache.ts"; +import { legacyListLocalMigrations } from "../../shared/legacy-pgdelta.cache.ts"; /** * Diagnostic artifacts collected when a declarative operation fails. Mirrors 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/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..2285957098 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, { 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..9687f67eba 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,7 +20,10 @@ 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, @@ -45,8 +48,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"; 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/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..2de13096f2 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts @@ -0,0 +1,262 @@ +import { Effect, Option } from "effect"; + +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 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(" ")};`; + const result = yield* docker + .runCapture({ + image: legacyGetRegistryImageUrl(LEGACY_MIGRA_IMAGE), + cmd: ["/bin/sh", "-c", args + legacyMigraDiffShellScript], + env, + binds: [], + workingDir: Option.none(), + securityOpt: [], + extraHosts: [], + network: { _tag: "host" }, + }) + .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 72% 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..a24923cc27 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,16 @@ 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 } 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"; /** * Real `LegacyDeclarativeSeam`: runs the bundled `supabase-go`'s hidden @@ -249,6 +249,90 @@ export const legacyDeclarativeSeamLayer = Layer.effect( } }), ), + provisionShadow: ({ mode, targetLocal, usePgDelta, schema }) => + 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] : []), + ]; + const command = ChildProcess.make(resolved.found, args, { + cwd: cliConfig.workdir, + stdin: "inherit", + stdout: "pipe", + stderr: "inherit", + extendEnv: true, + 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). + 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()); + } + return { + container, + sourceUrl, + targetUrlOverride: targetOverride.length > 0 ? targetOverride : 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. + const command = ChildProcess.make("docker", ["rm", "-f", 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 56% 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..fd0efeffb1 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,26 @@ 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; + }) => 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/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/schema/declarative/declarative.write.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts similarity index 70% rename from apps/cli/src/legacy/commands/db/schema/declarative/declarative.write.ts rename to apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts index 6fddb19f9d..dea9e5e801 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.write.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts @@ -1,20 +1,7 @@ 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)); -} +import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; +import type { LegacyDeclarativeOutput } from "./legacy-pgdelta.ts"; /** * Materializes pg-delta declarative export output under the declarative dir. 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/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"]); + }); +}); From c9d420621e596b4772674ab93af41255383bd46f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 18 Jun 2026 14:38:08 +0100 Subject: [PATCH 02/24] test(cli): drop non-parity db pull --local e2e assertion to match Go CLI driver behaviour (ci: e2e shard 2/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db pull --local connects via the native LegacyDbConnection sql-pg layer, so its connection-failure stderr diverges from Go's pgx output by driver — the same un-normalizable divergence already documented for db lint --local and test db --local, which deliberately omit testParity. The newly-added testParity(["db","pull","--local"]) was inconsistent with that established pattern; the user-observable contract (non-zero exit + connect error on stderr) remains covered by the behaviour test. --- .../src/tests/database-core.e2e.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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.) }); // --------------------------------------------------------------------------- From 48666597caa032dd39cb746b701ccf53bb098a1b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 18 Jun 2026 15:31:52 +0100 Subject: [PATCH 03/24] fix(db): stop logging shadow DB password to stdout in db __shadow seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged clear-text logging (CWE-312) on the hidden `db __shadow` seam: it printed the shadow Postgres connection URLs — password included — to stdout for the native-TS db diff/pull caller to capture. Emit the URLs without the password via a new ToPostgresURLWithoutPassword (ToPostgresURL is unchanged; its many env-var callers still need the password). The shadow always uses the local Postgres password, which the TS seam already resolves from config.toml, so it re-injects the password into the captured URLs before handing them to the differ / sql-pg connection. No credential is ever written to stdout. Fixes CodeQL alerts #27/#28. --- apps/cli-go/cmd/db.go | 12 +++-- apps/cli-go/internal/utils/connect.go | 17 ++++++- apps/cli-go/internal/utils/connect_test.go | 17 +++++++ .../db/shared/legacy-pgdelta.seam.layer.ts | 23 +++++++++- .../db/shared/legacy-pgdelta.seam.url.ts | 22 +++++++++ .../legacy-pgdelta.seam.url.unit.test.ts | 46 +++++++++++++++++++ 6 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.unit.test.ts diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index 0077310f49..9785280c30 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -209,8 +209,12 @@ var ( // 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). Shadow provisioning (start.SetupDatabase) is not yet - // ported, which is why this stays in Go. + // 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, @@ -230,9 +234,9 @@ var ( return err } fmt.Println(src.Container) - fmt.Println(utils.ToPostgresURL(src.Source)) + fmt.Println(utils.ToPostgresURLWithoutPassword(src.Source)) if src.TargetOverride != nil { - fmt.Println(utils.ToPostgresURL(*src.TargetOverride)) + fmt.Println(utils.ToPostgresURLWithoutPassword(*src.TargetOverride)) } else { fmt.Println("") } 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/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index a24923cc27..0338a67d3f 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -12,6 +12,7 @@ import { } 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 @@ -306,6 +307,11 @@ export const legacyDeclarativeSeamLayer = Layer.effect( // 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 always uses the local Postgres password, so we + // re-inject the password resolved from config.toml (the same value Go + // used) before handing the URLs to the differ / sql-pg connection. const lines = new TextDecoder().decode(bytes).split(/\r?\n/u); const container = (lines[0] ?? "").trim(); const sourceUrl = (lines[1] ?? "").trim(); @@ -313,10 +319,23 @@ export const legacyDeclarativeSeamLayer = Layer.effect( if (container.length === 0 || sourceUrl.length === 0) { return yield* Effect.fail(failure()); } + const password = yield* legacyReadDbToml(fs, path, cliConfig.workdir).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, - targetUrlOverride: targetOverride.length > 0 ? targetOverride : undefined, + sourceUrl: legacyInjectPostgresPassword(sourceUrl, password), + targetUrlOverride: + targetOverride.length > 0 + ? legacyInjectPostgresPassword(targetOverride, password) + : undefined, } satisfies LegacyShadowSource; }), ), 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"); + }); +}); From 7433b7188a4465281f76053a6a095cc6355330cc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 10:40:20 +0100 Subject: [PATCH 04/24] fix(db): honor --experimental flag and skip history prompt in machine output for db pull Two Go-parity fixes in the native db pull handler, both flagged in review: - --experimental: Go gates the structured-dump branch on viper's EXPERIMENTAL, which resolves from the --experimental pflag OR SUPABASE_EXPERIMENTAL env (cmd/root.go:318-320,327,334). The handler only checked the env var, so `supabase --experimental db pull` slipped past and ran the native diff path. Now reads LegacyExperimentalFlag and ORs it with the env check. - Machine-output prompt: json/stream-json layers fail every prompt as non-interactive, so the history-update confirm failed the command before the structured payload was emitted. Go's PromptYesNo returns the default (yes) on --yes / non-interactive / error (console.go:74-82); now machine mode and any prompt error fall through to the default, matching Go. Adds regression tests for the --experimental flag form and machine-mode-in-TTY. --- .../legacy/commands/db/pull/pull.handler.ts | 48 ++++++++++++------- .../commands/db/pull/pull.integration.test.ts | 42 +++++++++++++++- 2 files changed, 71 insertions(+), 19 deletions(-) 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 7e3d10f088..9991b87389 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -1,9 +1,12 @@ import { Clock, Effect, FileSystem, Option, Path } from "effect"; -import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +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 { Tty } from "../../../../shared/runtime/tty.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; @@ -89,7 +92,6 @@ const rebuildDelegateArgs = (flags: LegacyDbPullFlags): Array => { export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: LegacyDbPullFlags) { const output = yield* Output; - const tty = yield* Tty; const resolver = yield* LegacyDbConfigResolver; const connection = yield* LegacyDbConnection; const seam = yield* LegacyDeclarativeSeam; @@ -98,6 +100,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy 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; @@ -223,8 +226,11 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy } // Go's `EXPERIMENTAL` structured-dump branch depends on unported `pg_dump` - // — delegate the whole pull to Go. - if (legacyParseBoolEnv(process.env["SUPABASE_EXPERIMENTAL"])) { + // — 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(process.env["SUPABASE_EXPERIMENTAL"])) { yield* proxy.exec(rebuildDelegateArgs(flags), { env: { SUPABASE_TELEMETRY_DISABLED: "1" }, }); @@ -315,19 +321,27 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ); yield* output.raw(`Schema written to ${legacyBold(migrationPath)}\n`, "stderr"); - // Prompt to update the remote migration history table (Go default yes; - // honored verbatim under `--yes`; non-interactive falls through to the default). + // 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 shouldUpdate = yes - ? true - : !tty.stdinIsTty - ? true - : yield* output.promptConfirm("Update remote migration history table?", { - defaultValue: true, - }); - if (yes) { - yield* output.raw("Update remote migration history table? [Y/n] y\n", "stderr"); - } + 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); remoteHistoryUpdated = true; diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 99b57a0529..80e33b044f 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -11,7 +11,11 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../../tests/helpers/mocks.ts"; -import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + LegacyDnsResolverFlag, + LegacyExperimentalFlag, + 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"; @@ -38,6 +42,7 @@ interface SetupOpts { readonly edgeStdout?: string; // diff SQL or declarative export JSON readonly stdinIsTty?: boolean; readonly yes?: boolean; + readonly experimental?: boolean; readonly promptConfirmResponses?: ReadonlyArray; } @@ -133,6 +138,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { 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(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false) }), mockRuntimeInfo(), @@ -309,7 +315,8 @@ describe("legacy db pull", () => { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", stdinIsTty: false, - // no --yes: the !tty branch falls through to the default (true). + // 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()); @@ -333,6 +340,37 @@ describe("legacy db pull", () => { }).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("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("fails on --declarative with --diff-engine (mutual exclusion)", () => { const s = setup(tmp.current); return Effect.gen(function* () { From 84aa1fa9001ccfd4acad2612df3901b7c3e73a36 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 10:40:32 +0100 Subject: [PATCH 05/24] fix(db): remove shadow database anonymous volume on cleanup to match Go Go's shadow cleanup routes through DockerRemove with RemoveOptions{RemoveVolumes: true, Force: true} (internal/utils/docker.go:330). The native seam ran `docker rm -f` without -v, leaving the Postgres anonymous data volume dangling after every db diff / db pull. Add -v to match. Flagged in review. --- .../commands/db/shared/legacy-pgdelta.seam.layer.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 0338a67d3f..f73417da50 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -343,8 +343,12 @@ export const legacyDeclarativeSeamLayer = Layer.effect( 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. - const command = ChildProcess.make("docker", ["rm", "-f", container], { + // 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", From a6f886107f05370497bd4af0cf80adc456560470 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 10:50:09 +0100 Subject: [PATCH 06/24] fix(db): close four Go-parity gaps in native db diff/pull (review) Automated-review findings, each grounded in Go behaviour: - Preserve connect_timeout from --db-url: the connToUrl helpers (diff + pull) dropped conn.connectTimeoutSeconds before serializing, so it defaulted to 10s; Go's ToPostgresURL serializes the parsed ConnectTimeout (connect.go). - Don't double-print the --use-pg-schema experimental warning: the delegated Go child prints it in its RunE (cmd/db.go), so the TS pre-print was a duplicate. - Print the migration repair confirmation: Go's repair.UpdateMigrationTable emits 'Repaired migration history: [] => applied' to stderr (repair.go); the native pull updated the table silently. - Detect the git branch from the resolved --workdir, not the caller's CWD: Go chdirs into --workdir before GetGitBranch (cmd/root.go). detectGitBranch now takes an optional startDir (default = runtime CWD, so branches callers are unchanged) and db diff passes cliConfig.workdir. Updates the pg-schema delegate test and adds a detectGitBranch startDir test. --- .../commands/branches/branches.prompt.ts | 2 +- .../branches/create/create.handler.ts | 2 +- .../legacy/commands/db/diff/diff.handler.ts | 18 ++++-- .../commands/db/diff/diff.integration.test.ts | 6 +- .../legacy/commands/db/pull/pull.handler.ts | 5 ++ .../src/legacy/commands/db/pull/pull.sync.ts | 7 +++ .../branches/create/create.handler.ts | 2 +- apps/cli/src/shared/git/git-branch.ts | 56 ++++++++++--------- .../src/shared/git/git-branch.unit.test.ts | 33 +++++++++-- 9 files changed, 90 insertions(+), 41 deletions(-) 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/db/diff/diff.handler.ts b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts index 4786af50fa..60c593440a 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -52,6 +52,11 @@ const connToUrl = (conn: LegacyPgConnInput): string => 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 } + : {}), }); /** @@ -251,10 +256,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy return; } if (usePgSchema) { - yield* output.raw( - `${legacyYellow("WARNING:")} --use-pg-schema flag is experimental and may not include all entities, such as views and grants.\n`, - "stderr", - ); + // 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* proxy.exec(rebuildDelegateArgs(flags), { env: { SUPABASE_TELEMETRY_DISABLED: "1" }, }); @@ -310,7 +314,11 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }); }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); - const branch = Option.getOrElse(yield* detectGitBranch, () => "main"); + // 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", diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index 0551985710..2b42ba84e7 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -250,11 +250,13 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("warns then delegates --use-pg-schema to the Go binary", () => { + 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) })); - expect(stderr(s.out)).toContain("--use-pg-schema flag is experimental"); + // 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)); }); 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 9991b87389..bb63bfb520 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -69,6 +69,11 @@ const connToUrl = (conn: LegacyPgConnInput): string => 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). */ diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts index 005843bab0..28af91bfb5 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts @@ -1,5 +1,6 @@ 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 { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; import { legacySplitAndTrim } from "../../../shared/legacy-sql-split.ts"; @@ -156,6 +157,7 @@ export const legacyUpdateMigrationHistory = ( migrationPath: string, ) => Effect.gen(function* () { + const output = yield* Output; const content = yield* fs.readFileString(migrationPath); const statements = legacySplitAndTrim(content); const match = MIGRATE_FILE_PATTERN.exec(path.basename(migrationPath)); @@ -167,6 +169,11 @@ export const legacyUpdateMigrationHistory = ( yield* session.exec(ADD_STATEMENTS_COLUMN); yield* session.exec(ADD_NAME_COLUMN); yield* session.query(UPSERT_MIGRATION_VERSION, [version, name, statements]); + // 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"); }).pipe( Effect.mapError( (cause) => 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, - 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))); + }); }); From 3d72468b6be1448dd6c17e677c9ccf46568dc570 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 11:15:31 +0100 Subject: [PATCH 07/24] fix(db): load config in db __shadow seam and create parent dirs for explicit diff output Two Go-parity fixes from review: - db __shadow never loaded config.toml: the hidden seam carries no db-url/local/linked target flag, so the root ParseDatabaseConfig skips LoadConfig (db_url.go:46-90) and the shadow was provisioned with default [db] settings. With a custom [db].password the shadow was created with the default password while the native-TS caller injects the config password into the seam URLs (the CWE-312 fix), so the diff/export connection failed. Load config explicitly in the __shadow RunE so the shadow uses the project's password, shadow_port, version, and baseline. - Explicit db diff --output to a nested path failed: the native handler called writeFileString without creating parent dirs; Go's writeOutput -> utils.WriteFile creates them first (explicit.go, misc.go). Make the parent dir first. --- apps/cli-go/cmd/db.go | 14 +++++++++++++- .../src/legacy/commands/db/diff/diff.handler.ts | 6 ++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index 9785280c30..65aeac8be2 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -220,13 +220,25 @@ var ( 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() + 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, afero.NewOsFs()) + src, err = diff.PrepareShadowSource(cmd.Context(), shadowSchema, shadowTargetLocal, shadowUsePgDelta, fsys) default: return fmt.Errorf("unknown shadow mode: %s", shadowMode) } 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 60c593440a..c5a208c69a 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -205,6 +205,12 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // (Go's `fmt.Print`, no trailing newline — pg-delta ends each statement `;\n`). if (Option.isSome(flags.output)) { 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 }))); From e2a4eb90288c343d3f08a5805e252995701cdb6b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 11:29:23 +0100 Subject: [PATCH 08/24] fix(db): honor project .env for pg-delta flag and local-target shadow in db pull Two Go-parity fixes from review: - Project .env for the pg-delta engine flag: Go's LoadConfig loads supabase/.env via godotenv before viper reads EXPERIMENTAL_PG_DELTA (config.go:624,1055-1096), so a project enabling pg-delta in supabase/.env takes the pg-delta path. The handlers read SUPABASE_EXPERIMENTAL_PG_DELTA (and SUPABASE_EXPERIMENTAL) only from process.env. Route them through the project-env-aware resolver already loaded by legacyReadDbToml (exposed as toml.envLookup), matching Go's shell-then-.env precedence. - Local-target shadow for db pull: Go's pull runs through DiffDatabase, which derives targetLocal from utils.IsLocalDatabase and substitutes the declarative contrib_regression target override (diff.go:190,196-197). The native pull hard-coded targetLocal:false and ignored shadow.targetUrlOverride, so db pull --local with declarative schemas diffed the live local DB. Now passes targetLocal: resolved.isLocal and uses shadow.targetUrlOverride ?? targetUrl, mirroring the native db diff handler. Adds pull integration tests for the .env-pg-delta and local-target paths. --- .../legacy/commands/db/diff/diff.handler.ts | 2 +- .../legacy/commands/db/pull/pull.handler.ts | 18 +++++-- .../commands/db/pull/pull.integration.test.ts | 50 ++++++++++++++++--- .../shared/legacy-db-config.toml-read.ts | 9 ++++ 4 files changed, 66 insertions(+), 13 deletions(-) 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 c5a208c69a..130949d945 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -243,7 +243,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy const pgDeltaDefault = legacyShouldUsePgDelta({ configEnabled: toml.pgDelta.enabled, usePgDeltaFlag: Option.getOrElse(flags.usePgDelta, () => false), - envEnabled: legacyParseBoolEnv(process.env["SUPABASE_EXPERIMENTAL_PG_DELTA"]), + envEnabled: legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), }); const useDelta = legacyResolveDiffEngine({ useMigraChanged: Option.isSome(flags.useMigra), 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 bb63bfb520..7a7ee6e5f2 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -179,7 +179,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy pgDeltaDefault: legacyShouldUsePgDelta({ configEnabled: toml.pgDelta.enabled, usePgDeltaFlag: false, - envEnabled: legacyParseBoolEnv(process.env["SUPABASE_EXPERIMENTAL_PG_DELTA"]), + envEnabled: legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), }), }); @@ -235,7 +235,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // *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(process.env["SUPABASE_EXPERIMENTAL"])) { + if (experimental || legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL"))) { yield* proxy.exec(rebuildDelegateArgs(flags), { env: { SUPABASE_TELEMETRY_DISABLED: "1" }, }); @@ -280,11 +280,19 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy yield* output.raw("Creating shadow database...\n", "stderr"); const shadow = yield* seam.provisionShadow({ mode: "diff", - targetLocal: false, + // 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, }); const out = 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` @@ -294,7 +302,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy if (usePgDeltaDiff) { const result = yield* legacyDiffPgDelta(ctx, { sourceRef: shadow.sourceUrl, - targetRef: targetUrl, + targetRef: target, schema: diffSchema, formatOptions, }); @@ -302,7 +310,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy } return yield* legacyDiffMigra(ctx, { source: shadow.sourceUrl, - target: targetUrl, + target, schema: diffSchema, connectOptions: { isLocal: resolved.isLocal, dnsResolver }, }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 80e33b044f..0a0b2bdf98 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -43,6 +43,7 @@ interface SetupOpts { readonly stdinIsTty?: boolean; readonly yes?: boolean; readonly experimental?: boolean; + readonly shadowTargetOverride?: string; readonly promptConfirmResponses?: ReadonlyArray; } @@ -54,18 +55,18 @@ function setup(workdir: string, opts: SetupOpts = {}) { const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); - const provisionCalls: Array<{ mode: string; usePgDelta: boolean }> = []; + const provisionCalls: Array<{ mode: string; usePgDelta: boolean; targetLocal: boolean }> = []; 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 }) => { - provisionCalls.push({ mode, usePgDelta }); + provisionShadow: ({ mode, usePgDelta, targetLocal }) => { + provisionCalls.push({ mode, usePgDelta, targetLocal }); return Effect.succeed({ container: "shadow-1", sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", - targetUrlOverride: undefined, + targetUrlOverride: opts.shadowTargetOverride, }); }, removeShadowContainer: (container) => @@ -105,16 +106,16 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const resolver = Layer.succeed(LegacyDbConfigResolver, { - resolve: () => + resolve: ({ connType }) => Effect.succeed({ conn: { - host: "db.remote", + host: connType === "local" ? "127.0.0.1" : "db.remote", port: 5432, user: "postgres", password: "x", database: "postgres", }, - isLocal: false, + isLocal: connType === "local", ref: Option.none(), }), resolvePoolerFallback: () => Effect.succeed(Option.none()), @@ -351,6 +352,41 @@ describe("legacy db pull", () => { }).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("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 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, From 923d6a437d327ee4eb7d77b1d2482687b273707e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 12:17:23 +0100 Subject: [PATCH 09/24] fix(db): fail db pull on a migration name with a path separator instead of an empty repair Go drives the migration-history repair from the generated timestamp and globs supabase/migrations/_*.sql, failing with os.ErrNotExist when nothing matches (repair.GetMigrationFile, internal/migration/repair/repair.go). A name with a path separator (supabase db pull foo/bar) writes a nested file _foo/bar.sql whose basename doesn't match, so Go fails the repair. The native legacyUpdateMigrationHistory re-parsed the basename and fell back to version="", silently upserting an empty-version migration-history row. Now it fails with Go's glob/file-does-not-exist message before any upsert. Adds a pull integration test. --- .../legacy/commands/db/pull/pull.handler.ts | 2 +- .../commands/db/pull/pull.integration.test.ts | 20 +++++++ .../src/legacy/commands/db/pull/pull.sync.ts | 52 ++++++++++++------- 3 files changed, 55 insertions(+), 19 deletions(-) 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 7a7ee6e5f2..de408a60d0 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -356,7 +356,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy .pipe(Effect.orElseSucceed(() => true)); }); if (shouldUpdate) { - yield* legacyUpdateMigrationHistory(session, fs, path, migrationPath); + yield* legacyUpdateMigrationHistory(session, fs, path, migrationPath, timestamp); remoteHistoryUpdated = true; } diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 0a0b2bdf98..967112ced6 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -387,6 +387,26 @@ describe("legacy db pull", () => { }).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 diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts index 28af91bfb5..e3a0d75f64 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts @@ -155,30 +155,46 @@ export const legacyUpdateMigrationHistory = ( fs: FileSystem.FileSystem, path: Path.Path, migrationPath: string, + timestamp: string, ) => Effect.gen(function* () { const output = yield* Output; - const content = yield* fs.readFileString(migrationPath); - const statements = legacySplitAndTrim(content); const match = MIGRATE_FILE_PATTERN.exec(path.basename(migrationPath)); - const version = match?.[1] ?? ""; - const name = match?.[2] ?? ""; - 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]); + 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"); - }).pipe( - Effect.mapError( - (cause) => - new LegacyDbPullWriteError({ - message: `failed to update migration table: ${cause.message}`, - }), - ), - ); + }); From d3f278753d332153e9fa0281a54c48609c380f4f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 12:58:08 +0100 Subject: [PATCH 10/24] fix(db): forward false target flags, disable shadow-seam telemetry, treat empty --output as stdout Four Go-parity fixes from review: - Forward explicit false target flags (--linked=false/--local=false) to delegated db diff/pull children. Go's ParseDatabaseConfig selects the target by flag.Changed before the value (internal/utils/flags/db_url.go), so a Changed-but-false flag still selects that target. Dropping Some(false) made the child fall through to a different default (diff: local; pull: linked) than the native path resolved. New pushTarget forwards linked/local whenever Some. - Disable telemetry for the hidden db __shadow and __catalog seam subprocesses (SUPABASE_TELEMETRY_DISABLED=1), matching the explicit LegacyGoProxy delegates, so native db diff/pull doesn't record an extra internal cli_command_executed. - Treat an empty --output value as stdout in explicit diff mode: Go gates the file write on len(outputPath) > 0 (explicit.go), so --output="" prints to stdout instead of failing to write SQL into the project directory. Adds diff/pull integration tests for the delegate target flag and empty --output. --- .../legacy/commands/db/diff/diff.handler.ts | 22 +++++++++++---- .../commands/db/diff/diff.integration.test.ts | 27 +++++++++++++++++++ .../legacy/commands/db/pull/pull.handler.ts | 14 +++++++--- .../commands/db/pull/pull.integration.test.ts | 11 ++++++++ .../db/shared/legacy-pgdelta.seam.layer.ts | 23 +++++++++++----- 5 files changed, 83 insertions(+), 14 deletions(-) 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 130949d945..92eda056c6 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -68,17 +68,26 @@ const connToUrl = (conn: LegacyPgConnInput): string => const rebuildDelegateArgs = (flags: LegacyDbDiffFlags): Array => { const args = ["db", "diff"]; const pushBool = (name: string, value: Option.Option) => { - // Only forward an explicitly-true boolean — a `Some(false)` equals the cobra - // default, so emitting `--flag=false` would just add noise. + // 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); - pushBool("linked", flags.linked); - pushBool("local", flags.local); + pushTarget("linked", flags.linked); + pushTarget("local", flags.local); if (Option.isSome(flags.file)) args.push("--file", flags.file.value); if (Option.isSome(flags.output)) args.push("--output", flags.output.value); for (const s of flags.schema) args.push("--schema", s); @@ -203,7 +212,10 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }); // Explicit-mode output: `--output` file (Go's `writeOutput`) or stdout // (Go's `fmt.Print`, no trailing newline — pg-delta ends each statement `;\n`). - if (Option.isSome(flags.output)) { + // 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 diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index 2b42ba84e7..d8b9009475 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -299,6 +299,33 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("forwards an explicit --linked=false target flag to the delegated child", () => { + // Target flags are selectors keyed on flag.Changed in Go; dropping Some(false) + // would make the child default to local instead of the linked target the + // native path selected. + const s = setup(tmp.current); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(false) })); + expect(s.proxyCalls[0]?.args).toEqual(["db", "diff", "--use-pgadmin", "--linked=false"]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "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* () { 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 de408a60d0..19024592fd 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -81,16 +81,24 @@ const rebuildDelegateArgs = (flags: LegacyDbPullFlags): Array => { const args = ["db", "pull"]; if (Option.isSome(flags.name)) args.push(flags.name.value); const pushBool = (name: string, value: Option.Option) => { - // Only forward an explicitly-true boolean (a `Some(false)` equals the default). + // 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); if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - pushBool("linked", flags.linked); - pushBool("local", flags.local); + pushTarget("linked", flags.linked); + pushTarget("local", flags.local); if (Option.isSome(flags.password)) args.push("--password", flags.password.value); return args; }; diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 967112ced6..f7536cb6b4 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -341,6 +341,17 @@ describe("legacy db pull", () => { }).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. diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index f73417da50..51aab1e44e 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -64,12 +64,18 @@ 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). For + // `generate --linked`, also 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`). + // `extendEnv` keeps the rest of the environment. + env: { + SUPABASE_TELEMETRY_DISABLED: "1", + ...(projectRef !== undefined ? { SUPABASE_PROJECT_ID: projectRef } : {}), + }, detached: false, }); const handle = yield* spawner.spawn(command).pipe( @@ -277,6 +283,11 @@ export const legacyDeclarativeSeamLayer = Layer.effect( 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( From 0f99c790fc8d3ae421ce01bdaff198f4a7752cf1 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 13:26:54 +0100 Subject: [PATCH 11/24] fix(db): treat empty --file as stdout and forward --profile into the shadow seam Two Go-parity fixes from review: - Empty --file -> stdout: Go's SaveDiff gates the file write on len(file) > 0 (internal/db/diff/pgadmin.go), so an empty --file="" (unset shell var) prints the diff to stdout instead of writing a nameless _.sql migration. The explicit-mode file branch now requires a non-empty value. - Forward --profile into the hidden seam subprocesses: the Go root loads the profile before config and applies profile-specific overrides (cmd/root.go, config_path.go), but a flag-only --profile snap was never forwarded to the __shadow / __catalog / db start children (only SUPABASE_PROFILE env was inherited). Forward the raw --profile flag token when it isn't the default 'supabase', mirroring the existing --network-id forwarding, so the shadow baseline is built under the selected profile. Adds a diff test for the empty --file case. --- .../src/legacy/commands/db/diff/diff.handler.ts | 5 ++++- .../commands/db/diff/diff.integration.test.ts | 16 ++++++++++++++++ .../db/shared/legacy-pgdelta.seam.layer.ts | 13 ++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) 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 92eda056c6..f57d3bbab2 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -348,7 +348,10 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy let writtenFile: string | null = null; if (out.length < 2) { yield* output.raw("No schema changes found\n", "stderr"); - } else if (Option.isSome(flags.file)) { + // 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, diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index d8b9009475..f182e8bd25 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -310,6 +310,22 @@ describe("legacy db diff", () => { }).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", () => { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 51aab1e44e..c47c2833fc 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -2,7 +2,7 @@ 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 { 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"; @@ -25,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; @@ -57,6 +65,7 @@ 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] : []), + ...profileArgs, ]; const command = ChildProcess.make(resolved.found, args, { cwd: cliConfig.workdir, @@ -230,6 +239,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, @@ -276,6 +286,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( ...(usePgDelta ? ["--use-pg-delta"] : []), ...(schema.length > 0 ? ["--schema", schema.join(",")] : []), ...(Option.isSome(networkId) ? ["--network-id", networkId.value] : []), + ...profileArgs, ]; const command = ChildProcess.make(resolved.found, args, { cwd: cliConfig.workdir, From 18ee03cffc15103f9f0f5aae735a694b261d0908 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 13:50:33 +0100 Subject: [PATCH 12/24] fix(db): merge linked [remotes.] config overrides for db diff and db pull Go loads the project ref before LoadConfig on the linked path (internal/utils/flags/db_url.go:87-97), so a matching [remotes.] block merges before experimental.pgdelta.enabled, format_options, deno_version, and declarative paths are read. The native handlers read config once without the ref, ignoring remote overrides. Pass the resolved linked ref into legacyReadDbToml; for db diff, reorder so the linked ref resolves before the merged read and engine decision, and thread the merged config through the explicit --from/--to cascade (Go's resolveExplicitDatabaseRef LoadConfig). --- .../legacy/commands/db/diff/diff.handler.ts | 157 ++++++++++-------- .../commands/db/diff/diff.integration.test.ts | 59 ++++++- .../legacy/commands/db/pull/pull.handler.ts | 12 +- .../commands/db/pull/pull.integration.test.ts | 36 +++- 4 files changed, 191 insertions(+), 73 deletions(-) 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 f57d3bbab2..4fc9fd725b 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -110,43 +110,6 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // (GET /v1/projects/{ref}) — Go's `ensureProjectGroupsCached` (cmd/root.go:214). let linkedRefForCache: string | undefined; - /** Resolves an explicit `--from`/`--to` ref to a pg-delta SOURCE/TARGET ref. */ - const resolveExplicitRef = ( - ref: string, - toml: { readonly port: number; readonly password: string }, - ) => - Effect.gen(function* () { - switch (legacyClassifyExplicitRef(ref)) { - case "local": - return legacyToPostgresURL({ - host: legacyGetHostname(), - port: toml.port, - user: "postgres", - password: toml.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; - return connToUrl(resolved.conn); - } - case "migrations": - return yield* seam.exportCatalog({ mode: "migrations", noCache: false }); - case "url": - return ref; - default: - return yield* Effect.fail( - new LegacyDbDiffUnknownTargetError({ message: legacyUnknownTargetMessage(ref) }), - ); - } - }); - 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 @@ -176,13 +139,6 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy } const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); - 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, () => ""); // Explicit `--from`/`--to` mode (Go's `db.go:102-109`): both required, always // pg-delta. Runs before engine resolution and the shadow path. @@ -196,19 +152,60 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }), ); } - const sourceRef = yield* resolveExplicitRef( - Option.getOrElse(flags.from, () => ""), - toml, - ); - const targetRef = yield* resolveExplicitRef( - Option.getOrElse(flags.to, () => ""), - toml, - ); - const result = yield* legacyDiffPgDelta(ctx, { + // Go resolves each ref in order (`explicit.go:21-25`); the `linked` branch + // runs `LoadConfig(ref)` (`explicit.go:78-86`), merging the matching + // `[remotes.]` block into the global config so a later `local` ref read + // and the trailing `pgDeltaFormatOptions()` see the override. Thread the + // merged config through the two resolutions to reproduce that cascade. + let cfg = toml; + 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; + cfg = yield* legacyReadDbToml(fs, path, cliConfig.workdir, ref2); + } + return connToUrl(resolved.conn); + } + case "migrations": + return yield* seam.exportCatalog({ mode: "migrations", noCache: false }); + case "url": + return ref; + default: + return yield* Effect.fail( + new LegacyDbDiffUnknownTargetError({ message: legacyUnknownTargetMessage(ref) }), + ); + } + }); + const sourceRef = yield* resolveRef(Option.getOrElse(flags.from, () => "")); + const targetRef = yield* resolveRef(Option.getOrElse(flags.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, + 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`). @@ -249,24 +246,13 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy return; } - // Engine resolution (Go's `db.go:110`): the pg-delta env/config/flag gate. + // 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); - const pgDeltaDefault = legacyShouldUsePgDelta({ - configEnabled: toml.pgDelta.enabled, - usePgDeltaFlag: Option.getOrElse(flags.usePgDelta, () => false), - envEnabled: legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), - }); - const useDelta = legacyResolveDiffEngine({ - useMigraChanged: Option.isSome(flags.useMigra), - usePgAdmin, - usePgSchema, - pgDeltaDefault, - }); - - // pgAdmin / pg-schema delegate to the bundled Go binary (Go's `RunPgAdmin` / - // `DiffPgSchema` are not ported). Disable the child's telemetry so the single - // `cli_command_executed` event comes from this TS command's instrumentation. if (usePgAdmin) { yield* proxy.exec(rebuildDelegateArgs(flags), { env: { SUPABASE_TELEMETRY_DISABLED: "1" }, @@ -299,6 +285,37 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy 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 are read — Go loads config + // after `LoadProjectRef` on the linked path (`flags/db_url.go:87-97`). The + // default `db diff` target is local, which never merges a remote block, so + // only the explicitly-linked path passes the ref. + const cfg = + connType === "linked" && linkedRef !== undefined + ? yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef) + : toml; + 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", diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index f182e8bd25..3415881c24 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync } from "node:fs"; +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"; @@ -214,6 +214,63 @@ describe("legacy db diff", () => { }).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); + }).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); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("diffs the linked project and writes the linked-project cache", () => { const s = setup(tmp.current, { isLocal: false, 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 19024592fd..5a478d1cf6 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -172,7 +172,17 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy if (linkedRef !== undefined) linkedRefForCache = linkedRef; const targetUrl = connToUrl(resolved.conn); - const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // 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, diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index f7536cb6b4..d0512e5e68 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -45,6 +45,7 @@ interface SetupOpts { readonly experimental?: boolean; readonly shadowTargetOverride?: string; readonly promptConfirmResponses?: ReadonlyArray; + readonly resolvedRef?: string; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -116,7 +117,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { database: "postgres", }, isLocal: connType === "local", - ref: Option.none(), + ref: opts.resolvedRef !== undefined ? Option.some(opts.resolvedRef) : Option.none(), }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); @@ -438,6 +439,39 @@ describe("legacy db pull", () => { }).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); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("fails on --declarative with --diff-engine (mutual exclusion)", () => { const s = setup(tmp.current); return Effect.gen(function* () { From bd5b101f6545cf7083d76bf6280a0acdb1ea61fd Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 13:57:21 +0100 Subject: [PATCH 13/24] fix(db): retry linked db pull diffs and declarative exports through the IPv4 pooler Go wraps both the migration-style diff (diffRemoteSchema) and the declarative export (pullDeclarativePgDelta) with dump.PoolerFallbackConfig and retries against the IPv4 transaction pooler when the direct host is unreachable over IPv6 from inside the edge-runtime container (internal/db/pull/pull.go). The native handler ran each once against the direct targetUrl with no fallback. Add a withPoolerFallback helper mirroring the ported db dump (step 7b): classify the differ/export error message via legacyIsIPv6ConnectivityError (the edge-runtime/migra errors embed the container stderr Go classifies), gate to the linked path with a direct db.. connection, resolve the pooler with orElseSucceed(None) so a resolution failure preserves the ORIGINAL error (Go's ok=false), emit the byte-exact IPv6 warning, and retry once against the pooler reusing the same shadow source. --- .../legacy/commands/db/pull/pull.handler.ts | 104 +++++++++++--- .../commands/db/pull/pull.integration.test.ts | 130 +++++++++++++++++- 2 files changed, 207 insertions(+), 27 deletions(-) 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 5a478d1cf6..d6f35bd14a 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -8,7 +8,8 @@ import { 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 } from "../../../shared/legacy-colors.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, @@ -191,6 +192,57 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }; 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"), @@ -222,12 +274,14 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy usePgDelta: true, schema: flags.schema, }); - const exported = yield* legacyDeclarativeExportPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef: targetUrl, - schema: flags.schema, - formatOptions, - }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + 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 })), ); @@ -317,21 +371,27 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy : "Diffing schemas...\n", "stderr", ); - if (usePgDeltaDiff) { - const result = yield* legacyDiffPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef: target, - schema: diffSchema, - formatOptions, - }); - return result.sql; - } - return yield* legacyDiffMigra(ctx, { - source: shadow.sourceUrl, - target, - schema: diffSchema, - connectOptions: { isLocal: resolved.isLocal, dnsResolver }, - }); + 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) { + const result = yield* legacyDiffPgDelta(ctx, { + sourceRef: shadow.sourceUrl, + targetRef, + schema: diffSchema, + formatOptions, + }); + return result.sql; + } + return yield* legacyDiffMigra(ctx, { + source: shadow.sourceUrl, + target: targetRef, + schema: diffSchema, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }); + }), + ); }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); if (out.trim().length === 0) { diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index d0512e5e68..0d0e8dfc3d 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -21,6 +21,7 @@ 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, @@ -46,6 +47,11 @@ interface SetupOpts { 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; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -76,9 +82,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); + let edgeRunCount = 0; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (_runOpts: LegacyEdgeRuntimeRunOpts) => - Effect.succeed({ stdout: opts.edgeStdout ?? "", stderr: "" }), + run: (_runOpts: LegacyEdgeRuntimeRunOpts) => { + edgeRunCount += 1; + if (opts.edgeFailFirstWith !== undefined && edgeRunCount === 1) { + return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: opts.edgeFailFirstWith })); + } + return Effect.succeed({ stdout: opts.edgeStdout ?? "", stderr: "" }); + }, }); const docker = Layer.succeed(LegacyDockerRun, { @@ -106,11 +118,14 @@ function setup(workdir: string, opts: SetupOpts = {}) { connect: () => Effect.succeed(session), }); + const poolerFallbackCalls: unknown[] = []; const resolver = Layer.succeed(LegacyDbConfigResolver, { resolve: ({ connType }) => Effect.succeed({ conn: { - host: connType === "local" ? "127.0.0.1" : "db.remote", + // 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", @@ -119,7 +134,20 @@ function setup(workdir: string, opts: SetupOpts = {}) { isLocal: connType === "local", ref: opts.resolvedRef !== undefined ? Option.some(opts.resolvedRef) : Option.none(), }), - resolvePoolerFallback: () => Effect.succeed(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 }> = []; @@ -147,7 +175,19 @@ function setup(workdir: string, opts: SetupOpts = {}) { BunServices.layer, ); - return { layer, out, provisionCalls, removedContainers, proxyCalls, historyUpserts, execLog }; + return { + layer, + out, + provisionCalls, + removedContainers, + proxyCalls, + historyUpserts, + execLog, + poolerFallbackCalls, + get edgeRunCount() { + return edgeRunCount; + }, + }; } const flags = (over: Partial = {}): LegacyDbPullFlags => ({ @@ -472,6 +512,86 @@ describe("legacy db pull", () => { }).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* () { From 23585a7580c4d0bed194ba0ac474035a4af15dcc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 14:01:46 +0100 Subject: [PATCH 14/24] fix(db): update [db.migrations] schema_paths after db pull --declarative Go's WriteDeclarativeSchemas points [db.migrations] schema_paths at the declarative dir after a declarative pull, but only when pg-delta is disabled in config (declarative.go:260-268, gated on IsPgDeltaEnabled). generate/sync force-enable pg-delta so the branch is unreachable for them, but db pull --declarative does not (cmd/db.go:180-182), so without this a pull with pg-delta disabled leaves db reset/db diff reading supabase/migrations and ignoring the pulled files. Port updateDeclarativeSchemaPathsConfig as a raw-text replace-or-append (byte-matching Go's regex and literal block, not a TOML re-serialize) and invoke it from the pull declarative path when config pg-delta is disabled, keeping the shared declarative writer a pure file-materializer for generate/sync. --- .../legacy/commands/db/pull/pull.handler.ts | 28 ++++++-- .../commands/db/pull/pull.integration.test.ts | 51 +++++++++++++- .../db/shared/legacy-pgdelta.write.ts | 66 ++++++++++++++++++- 3 files changed, 136 insertions(+), 9 deletions(-) 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 d6f35bd14a..31ce5bef0f 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -23,7 +23,10 @@ import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyWriteDeclarativeSchemas } from "../shared/legacy-pgdelta.write.ts"; +import { + legacyUpdateDeclarativeSchemaPathsConfig, + legacyWriteDeclarativeSchemas, +} from "../shared/legacy-pgdelta.write.ts"; import { legacyParseBoolEnv, legacyResolvePullDiffEngine, @@ -264,10 +267,8 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // Declarative export path (Go's `pullDeclarativePgDelta`). if (useDeclarative) { yield* output.raw("Preparing declarative schema export using pg-delta...\n", "stderr"); - const declarativeDir = path.resolve( - cliConfig.workdir, - legacyResolveDeclarativeDir(path, toml.pgDelta), - ); + const declarativeDirRel = legacyResolveDeclarativeDir(path, toml.pgDelta); + const declarativeDir = path.resolve(cliConfig.workdir, declarativeDirRel); const shadow = yield* seam.provisionShadow({ mode: "declarative", targetLocal: false, @@ -285,6 +286,23 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy 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", diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 0d0e8dfc3d..ca2ecd0de6 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; @@ -266,6 +266,55 @@ describe("legacy db pull", () => { }).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", () => { 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 index dea9e5e801..ffce5573f9 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts @@ -9,9 +9,12 @@ import type { LegacyDeclarativeOutput } from "./legacy-pgdelta.ts"; * 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. + * 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, @@ -47,3 +50,60 @@ export const legacyWriteDeclarativeSchemas = Effect.fnUntraced(function* ( 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}` }), + ), + ); +}); From 7ac6b72345f736ab1f00672a27cce17e5118f95b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 14:10:03 +0100 Subject: [PATCH 15/24] fix(db): emit structured output after Go-delegated db diff and db pull paths The pgAdmin/pg-schema delegate (db diff) and the initial-migra / EXPERIMENTAL delegate (db pull) forwarded to the Go binary with inherited stdout and returned without output.success, so in --output-format json|stream-json mode scripted callers got the child's raw human/SQL output on stdout instead of a structured envelope (broken JSON.parse, the CLI-1546 'stdout is payload-only in machine mode' invariant). Add LegacyGoProxy.execCapture, which pipes the child's stdout (capturing it as a string) while keeping stdin/stderr inherited and still propagating the exit code. In machine mode the delegate paths now capture the child stdout and wrap it in output.success with the native envelope shape; text mode keeps the inheriting exec so the child's output streams straight through. Go has no --output-format concept, so the envelope is a TS-layer concern with no Go template; the delegated child owns any file write, so those fields are reported as null/best-effort. --- .../bootstrap/bootstrap.integration.test.ts | 1 + ...ootstrap.workdir-cache.integration.test.ts | 5 ++- .../completion/bash/bash.integration.test.ts | 1 + .../completion/fish/fish.integration.test.ts | 1 + .../powershell/powershell.integration.test.ts | 1 + .../completion/zsh/zsh.integration.test.ts | 1 + .../legacy/commands/db/diff/diff.handler.ts | 29 ++++++++++--- .../commands/db/diff/diff.integration.test.ts | 41 +++++++++++++++++++ .../legacy/commands/db/pull/pull.handler.ts | 30 +++++++++++--- .../commands/db/pull/pull.integration.test.ts | 33 +++++++++++++++ .../generate/generate.integration.test.ts | 1 + .../download/download.integration.test.ts | 1 + .../migration/migration.integration.test.ts | 1 + .../commands/stop/stop.integration.test.ts | 1 + .../download/download.integration.test.ts | 1 + .../src/shared/cli/hidden-flag.unit.test.ts | 1 + apps/cli/src/shared/legacy/go-proxy.layer.ts | 40 +++++++++++++++++- .../cli/src/shared/legacy/go-proxy.service.ts | 16 ++++++++ 18 files changed, 191 insertions(+), 14 deletions(-) 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/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/diff.handler.ts b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts index 4fc9fd725b..a5f6249027 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -253,19 +253,36 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // comes from this TS command's instrumentation. const usePgAdmin = Option.getOrElse(flags.usePgAdmin, () => false); const usePgSchema = Option.getOrElse(flags.usePgSchema, () => false); - if (usePgAdmin) { - yield* proxy.exec(rebuildDelegateArgs(flags), { - env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + // 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* proxy.exec(rebuildDelegateArgs(flags), { - env: { SUPABASE_TELEMETRY_DISABLED: "1" }, - }); + yield* delegateDiff("pg-schema"); return; } diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index 3415881c24..91dff73230 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -34,6 +34,7 @@ interface SetupOpts { 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 } function setup(workdir: string, opts: SetupOpts = {}) { @@ -117,8 +118,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); 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( @@ -149,6 +157,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { edgeCalls, resolverCalls, proxyCalls, + proxyCaptureCalls, dockerCalls, }; } @@ -318,6 +327,38 @@ describe("legacy db diff", () => { }).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* () { 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 31ce5bef0f..ba7bc7a7fb 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -256,6 +256,28 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }), }); + // 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 delegated child owns the migration write and history prompt, so + // schemaWritten/remoteHistoryUpdated aren't introspectable here. + const delegatePull = (engine: "migra" | "pg-delta") => + Effect.gen(function* () { + const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; + if (output.format !== "text") { + yield* proxy.execCapture(rebuildDelegateArgs(flags), { env }); + yield* output.success("Schema pulled.", { + declarative: false, + schemaWritten: null, + remoteHistoryUpdated: false, + 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* () { @@ -326,9 +348,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // (`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"))) { - yield* proxy.exec(rebuildDelegateArgs(flags), { - env: { SUPABASE_TELEMETRY_DISABLED: "1" }, - }); + yield* delegatePull(usePgDeltaDiff ? "pg-delta" : "migra"); return; } @@ -354,9 +374,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy } if (sync.kind === "missing" && !usePgDeltaDiff) { // Initial pull with the migra engine needs `pg_dump` — delegate to Go. - yield* proxy.exec(rebuildDelegateArgs(flags), { - env: { SUPABASE_TELEMETRY_DISABLED: "1" }, - }); + yield* delegatePull("migra"); return; } diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index ca2ecd0de6..73e886c3e7 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -52,6 +52,7 @@ interface SetupOpts { 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 } function setup(workdir: string, opts: SetupOpts = {}) { @@ -151,8 +152,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); 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( @@ -181,6 +189,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { provisionCalls, removedContainers, proxyCalls, + proxyCaptureCalls, historyUpserts, execLog, poolerFallbackCalls, @@ -347,6 +356,30 @@ describe("legacy db pull", () => { }).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); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ + declarative: false, + schemaWritten: null, + remoteHistoryUpdated: false, + 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: "" }); 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 2285957098..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 @@ -118,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/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/next/commands/functions/download/download.integration.test.ts b/apps/cli/src/next/commands/functions/download/download.integration.test.ts index 40b69d0e81..b8095be511 100644 --- a/apps/cli/src/next/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/next/commands/functions/download/download.integration.test.ts @@ -302,6 +302,7 @@ function mockLegacyGoProxy() { Effect.sync(() => { 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/legacy/go-proxy.layer.ts b/apps/cli/src/shared/legacy/go-proxy.layer.ts index 2d4a9b37bf..b7f564d4a9 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,44 @@ 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 stdin/stderr inherited, so the + // child's prompts and progress still reach the user but its stdout is + // collected for wrapping rather than written to our stdout. + const command = ChildProcess.make(binary, [...globalArgs, ...args], { + cwd: execOpts?.cwd ?? opts?.cwd, + env, + extendEnv: true, + 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..74469d3326 100644 --- a/apps/cli/src/shared/legacy/go-proxy.service.ts +++ b/apps/cli/src/shared/legacy/go-proxy.service.ts @@ -18,6 +18,22 @@ 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. stdin and stderr are still inherited (so the + * child's prompts and progress/diagnostics pass straight through), and a + * non-zero exit still terminates the process with the same code. + * + * 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 }, + ) => Effect.Effect; } export class LegacyGoProxy extends Context.Service()( From dfebd99a6caee685d0113dc720ffa164c16a9512 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 14:22:21 +0100 Subject: [PATCH 16/24] fix(db): save a pg-delta debug bundle for empty db pull diffs under PGDELTA_DEBUG When migration-style db pull uses pg-delta and PGDELTA_DEBUG is set, Go saves a debug bundle on an empty diff and embeds its path in the in-sync error (internal/db/pull/pull.go:176-185, pgdelta_pull_debug.go). The native path discarded the capture and returned the plain error. Port saveEmptyPgDeltaPullDebug: capture the shadow source catalog + pg-delta stderr during the diff run (Go's DiffDatabase), export the remote catalog at save time (warn-and-continue), write the bundle (source/target catalog, stderr, redacted connection.txt, error.txt), print the catalog summary + issue-report message, and fail with 'No schema changes found (debug bundle: )'; a save failure falls through to the plain in-sync error. Hoist the declarative debug-bundle helper to db/shared/legacy-debug-bundle.ts (now used by declarative + pull), extend it with inline catalog strings and connection.txt (Go's full DebugBundle), and hoist the debug-id formatter. Add pure helpers (URL redaction, catalog summary, byte-size) with unit coverage and an integration test for the empty-diff bundle. --- .../src/legacy/commands/db/pull/pull.debug.ts | 212 ++++++++++++++++++ .../commands/db/pull/pull.debug.unit.test.ts | 117 ++++++++++ .../legacy/commands/db/pull/pull.handler.ts | 66 +++++- .../commands/db/pull/pull.integration.test.ts | 68 +++++- .../schema/declarative/sync/sync.handler.ts | 15 +- .../legacy-debug-bundle.ts} | 42 +++- .../legacy-debug-bundle.unit.test.ts} | 2 +- 7 files changed, 499 insertions(+), 23 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/pull/pull.debug.ts create mode 100644 apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.debug-bundle.ts => shared/legacy-debug-bundle.ts} (71%) rename apps/cli/src/legacy/commands/db/{schema/declarative/declarative.debug-bundle.unit.test.ts => shared/legacy-debug-bundle.unit.test.ts} (99%) 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.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index ba7bc7a7fb..d5d60a507a 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -37,11 +37,15 @@ 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 { @@ -396,7 +400,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy usePgDelta: usePgDeltaDiff, schema: diffSchema, }); - const out = yield* Effect.gen(function* () { + 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. @@ -412,25 +416,81 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // 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 result.sql; + return { + sql: result.sql, + capture: debug ? { sourceCatalog, stderr: result.stderr } : undefined, + }; } - return yield* legacyDiffMigra(ctx, { + 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" }), ); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 73e886c3e7..020f566990 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +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"; @@ -53,6 +53,7 @@ interface SetupOpts { // 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 = {}) { @@ -85,11 +86,16 @@ function setup(workdir: string, opts: SetupOpts = {}) { let edgeRunCount = 0; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (_runOpts: LegacyEdgeRuntimeRunOpts) => { + 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: "" }); }, }); @@ -389,6 +395,64 @@ describe("legacy db pull", () => { }).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, { 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 9687f67eba..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 @@ -26,11 +26,12 @@ import { } 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, @@ -58,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) { @@ -137,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/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 4a0323d492..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 "../../shared/legacy-pgdelta.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* () { From 534cc9e1f5d5ee58b4b4dd9f3bc08365ee10ba2a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 14:31:32 +0100 Subject: [PATCH 17/24] fix(db): harden db pull/diff parity (version range, migra fallback net, explicit preflight) Three Go-parity fixes from review of the db diff/pull port: - pull.sync.ts: reject migration versions above Number.MAX_SAFE_INTEGER in parseVersion, mirroring Go's strconv.Atoi range error (skip). A crafted 16+ digit version could exceed the exhausted-side sentinel and stall the two-pointer scan in legacyReconcileMigrations indefinitely. - legacy-migra.ts: the migra OOM bash fallback now derives extraHosts (Linux host.docker.internal mapping) and the network from --network-id, matching Go's DockerStart (internal/utils/docker.go:266-271). Previously it always ran host networking with no extra hosts, so the fallback failed on custom-network / host.docker.internal setups the primary path handles. - diff.handler.ts: explicit --from/--to mode now runs the target-flag preflight (resolver.resolve) when a target flag is changed, matching Go running ParseDatabaseConfig in PersistentPreRunE before RunExplicit (cmd/root.go:118). A bad --db-url / unreachable --linked is surfaced instead of silently ignored. --- .../legacy/commands/db/diff/diff.handler.ts | 20 ++++++++ .../commands/db/diff/diff.integration.test.ts | 47 ++++++++++++++++++- .../commands/db/pull/pull.integration.test.ts | 2 + .../src/legacy/commands/db/pull/pull.sync.ts | 15 ++++-- .../commands/db/pull/pull.sync.unit.test.ts | 10 ++++ .../legacy/commands/db/shared/legacy-migra.ts | 19 +++++++- 6 files changed, 107 insertions(+), 6 deletions(-) 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 a5f6249027..bcdc8f73fc 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -152,6 +152,26 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }), ); } + // Go runs `ParseDatabaseConfig` in the root PersistentPreRunE for every + // `db diff` (`cmd/root.go:118`), before RunE dispatches to RunExplicit + // (`cmd/db.go:107`). So an explicit-mode invocation still validates/loads a + // changed target flag: `--db-url bad` fails parsing, `--linked` resolves the + // linked db config (DNS/pooler), `--local` loads config. The explicit refs + // drive the diff, so the resolved config is discarded — this runs purely for + // the parity preflight (surfacing a bad/unreachable target the user passed). + 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"; + yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType: preflightConnType, + dnsResolver, + password: Option.none(), + }); + } // Go resolves each ref in order (`explicit.go:21-25`); the `linked` branch // runs `LoadConfig(ref)` (`explicit.go:78-86`), merging the matching // `[remotes.]` block into the global config so a later `local` ref read diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index 91dff73230..0f26dfda0c 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -11,7 +11,10 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; -import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.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"; @@ -35,6 +38,7 @@ interface SetupOpts { 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 = {}) { @@ -141,6 +145,10 @@ function setup(workdir: string, opts: SetupOpts = {}) { 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, @@ -448,6 +456,24 @@ describe("legacy db diff", () => { }).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* () { @@ -518,4 +544,23 @@ describe("legacy db diff", () => { 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/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 020f566990..e40da43922 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -14,6 +14,7 @@ import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../../tests/helpe import { LegacyDnsResolverFlag, LegacyExperimentalFlag, + LegacyNetworkIdFlag, LegacyYesFlag, } from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; @@ -184,6 +185,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { 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, diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts index e3a0d75f64..ac323b8199 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts @@ -50,9 +50,18 @@ export function legacyReconcileMigrations( 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`). - const parseVersion = (v: string): number | undefined => - /^\d+$/u.test(v) ? Number(v) : undefined; + // non-parseable version is skipped (Go's `Atoi` error → `continue`). Go's `Atoi` + // also returns a range error for values above int64 max, which the scan skips + // the same way; reject anything above the `MAX` sentinel here so a crafted + // 16+-digit version can never exceed it and stall the two-pointer scan (an + // exhausted side is pinned at `MAX`, so a parsed value `> MAX` would never + // advance). Real 14-digit timestamps are far below `MAX`, so this is unreachable + // in normal use — it just keeps a malformed remote-history row from hanging. + const parseVersion = (v: string): number | undefined => { + if (!/^\d+$/u.test(v)) return undefined; + const parsed = Number(v); + return parsed > MAX ? undefined : parsed; + }; while (i < remote.length || j < local.length) { let remoteTs = MAX; if (i < remote.length) { 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 index 245dd3de69..e29040ef89 100644 --- 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 @@ -53,6 +53,16 @@ describe("legacyReconcileMigrations", () => { kind: "in-sync", }); }); + + it("skips an out-of-range version instead of hanging the two-pointer scan", () => { + // A 17-digit version exceeds Number.MAX_SAFE_INTEGER (the exhausted-side + // sentinel); before the range guard the scan stalled forever. Go's Atoi + // returns a range error and skips it the same way, so the surviving entries + // reconcile normally rather than looping. + expect( + legacyReconcileMigrations(["20240101000000", "99999999999999999"], ["20240101000000"]), + ).toEqual({ kind: "in-sync" }); + }); }); describe("legacySuggestMigrationRepair", () => { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts index 2de13096f2..be5af7dd2d 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts @@ -1,5 +1,7 @@ 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, @@ -188,6 +190,8 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { readonly connectOptions: LegacyDbConnectOptions; }) { const docker = yield* LegacyDockerRun; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; const schema = params.schema.length > 0 ? params.schema @@ -197,6 +201,17 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { // 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), @@ -205,8 +220,8 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { binds: [], workingDir: Option.none(), securityOpt: [], - extraHosts: [], - network: { _tag: "host" }, + extraHosts, + network, }) .pipe( Effect.mapError( From 457e7e61825294f720a006554c0743fb83622cab Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 15:00:52 +0100 Subject: [PATCH 18/24] fix(db): merge linked [remotes.] config into the shadow baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native db diff/pull handlers merge a linked [remotes.] override for the target/engine/format, but the shadow database is provisioned by the hidden Go 'db __shadow' command, which ran flags.LoadConfig with no project ref — so the shadow baseline (db.major_version, service enables, vault) was built from the base config, not the remote-merged config the Go monolith uses. That can produce spurious or missing migration SQL on the linked path. cli-go: db __shadow gains a --project-ref flag; when set it seeds flags.ProjectRef before LoadConfig so the matching [remotes.] block merges (pkg/config/config.go). Omitted on local/db-url shadows, which the monolith never remote-merges, so base config is used exactly as before. TS: provisionShadow gains an optional projectRef forwarded as --project-ref (flag, not env — channel parity, and avoids over-merging on local shadows via inherited env). The diff native path and both pull shadow paths pass the resolved ref only on the linked path. --- apps/cli-go/cmd/db.go | 12 ++++++++++++ .../src/legacy/commands/db/diff/diff.handler.ts | 4 ++++ .../commands/db/diff/diff.integration.test.ts | 16 +++++++++++++--- .../src/legacy/commands/db/pull/pull.handler.ts | 6 ++++++ .../commands/db/pull/pull.integration.test.ts | 14 +++++++++++--- .../db/shared/legacy-pgdelta.seam.layer.ts | 9 ++++++++- .../db/shared/legacy-pgdelta.seam.service.ts | 7 +++++++ 7 files changed, 61 insertions(+), 7 deletions(-) diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index 65aeac8be2..3f8d3d82a8 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -201,6 +201,7 @@ var ( 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" @@ -229,6 +230,16 @@ var ( // 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 } @@ -540,6 +551,7 @@ func init() { 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() 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 bcdc8f73fc..c2e7aeb64f 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -359,6 +359,10 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy 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* () { diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index 0f26dfda0c..0549337ea0 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -46,7 +46,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); - const provisionCalls: Array<{ mode: string; targetLocal: boolean; usePgDelta: boolean }> = []; + const provisionCalls: Array<{ + mode: string; + targetLocal: boolean; + usePgDelta: boolean; + projectRef?: string; + }> = []; const removedContainers: string[] = []; const exportCalls: string[] = []; const seam = Layer.succeed(LegacyDeclarativeSeam, { @@ -56,8 +61,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, - provisionShadow: ({ mode, targetLocal, usePgDelta }) => { - provisionCalls.push({ mode, targetLocal, usePgDelta }); + 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", @@ -260,6 +265,9 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ linked: Option.some(true) })); expect(s.provisionCalls[0]?.usePgDelta).toBe(true); + // The shadow is provisioned with the resolved ref so the `db __shadow` child + // merges the same `[remotes.]` override into the shadow baseline. + expect(s.provisionCalls[0]?.projectRef).toBe("abcdefghijklmnopqrst"); }).pipe(Effect.provide(s.layer)); }); @@ -285,6 +293,8 @@ describe("legacy db diff", () => { 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)); }); 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 d5d60a507a..a3eccd2ec0 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -300,6 +300,9 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy 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, { @@ -399,6 +402,9 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy 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 diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index e40da43922..7b013643c0 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -65,14 +65,19 @@ function setup(workdir: string, opts: SetupOpts = {}) { const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); - const provisionCalls: Array<{ mode: string; usePgDelta: boolean; targetLocal: boolean }> = []; + 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 }) => { - provisionCalls.push({ mode, usePgDelta, targetLocal }); + 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", @@ -657,6 +662,9 @@ describe("legacy db pull", () => { return Effect.gen(function* () { yield* legacyDbPull(flags({ linked: Option.some(true) })); expect(s.provisionCalls[0]?.usePgDelta).toBe(true); + // The resolved ref is forwarded to the shadow so the `db __shadow` child + // merges the same `[remotes.]` override into the shadow baseline. + expect(s.provisionCalls[0]?.projectRef).toBe("abcdefghijklmnopqrst"); }).pipe(Effect.provide(s.layer)); }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index c47c2833fc..0e2350052d 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -266,7 +266,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( } }), ), - provisionShadow: ({ mode, targetLocal, usePgDelta, schema }) => + provisionShadow: ({ mode, targetLocal, usePgDelta, schema, projectRef }) => Effect.scoped( Effect.gen(function* () { if (!("found" in resolved)) { @@ -286,6 +286,13 @@ export const legacyDeclarativeSeamLayer = Layer.effect( ...(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, { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts index fd0efeffb1..de657d0af7 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts @@ -86,6 +86,13 @@ interface LegacyDeclarativeSeamShape { 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` From 6a5fa730e5d00ace865fc3b2bf004c48f4ddb06c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 15:38:09 +0100 Subject: [PATCH 19/24] fix(db): correct delegated-pull repair reporting and explicit-diff migrations catalog ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Go-parity follow-ups from review: - pull: the machine-mode delegated pull (initial-migra/EXPERIMENTAL) ran the Go child with inherited stdin and reported remoteHistoryUpdated:false. Go's PromptYesNo(...,true) updates schema_migrations on the default (non-TTY/accept), so the report was wrong, and in a TTY the child's prompt could block a --output-format json caller before the envelope. Run execCapture with a non-TTY stdin ('ignore') so the prompt takes Go's default without blocking, and report remoteHistoryUpdated:true to match the child and the native machine-mode path (internal/db/pull/pull.go:73, internal/utils/console.go). Adds an optional stdin to LegacyGoProxy.execCapture (default inherit). - diff: explicit --from linked --to migrations exported the migrations catalog without the resolved ref, so the __catalog child reloaded base config. Go resolves refs in order and the linked LoadConfig(ref) merges [remotes.] before the migrations catalog is built (explicit.go), so pass the ref to exportCatalog — but only when a linked ref resolved earlier in the cascade, so --from migrations --to linked still uses base config, matching Go's order. --- .../legacy/commands/db/diff/diff.handler.ts | 19 +++++++++- .../commands/db/diff/diff.integration.test.ts | 38 ++++++++++++++++++- .../legacy/commands/db/pull/pull.handler.ts | 14 +++++-- .../commands/db/pull/pull.integration.test.ts | 15 ++++++-- apps/cli/src/shared/legacy/go-proxy.layer.ts | 11 ++++-- .../cli/src/shared/legacy/go-proxy.service.ts | 18 +++++++-- 6 files changed, 97 insertions(+), 18 deletions(-) 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 c2e7aeb64f..bb9308cbcf 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -178,6 +178,14 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // and the trailing `pgDeltaFormatOptions()` see the override. Thread the // merged config through the two resolutions to reproduce that cascade. let cfg = toml; + // Tracks a linked ref resolved earlier in the from→to cascade so a later + // `migrations` catalog export merges the same `[remotes.]` override Go's + // sequential `LoadConfig` leaves in the global config (`explicit.go:78-86` → + // `config_path.go:10-12`; the `__catalog` child re-runs that load from + // SUPABASE_PROJECT_ID). Stays undefined until a linked ref resolves, so a + // `migrations` ref resolved BEFORE any linked ref (e.g. `--from migrations + // --to linked`) still uses base config — matching Go's resolution order. + let mergedLinkedRef: string | undefined; const resolveRef = (ref: string) => Effect.gen(function* () { switch (legacyClassifyExplicitRef(ref)) { @@ -199,12 +207,21 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy 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 }); + 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: diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index 0549337ea0..f3b58261e6 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -54,9 +54,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { }> = []; const removedContainers: string[] = []; const exportCalls: string[] = []; + const exportCatalogCalls: Array<{ mode: string; projectRef?: string }> = []; const seam = Layer.succeed(LegacyDeclarativeSeam, { - exportCatalog: ({ mode }) => { + exportCatalog: ({ mode, projectRef }) => { exportCalls.push(mode); + exportCatalogCalls.push({ mode, projectRef }); return Effect.succeed("supabase/.temp/pgdelta/migrations.json"); }, execInherit: () => Effect.succeed(0), @@ -167,6 +169,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { provisionCalls, removedContainers, exportCalls, + exportCatalogCalls, edgeCalls, resolverCalls, proxyCalls, @@ -466,6 +469,39 @@ describe("legacy db diff", () => { }).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 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 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 a3eccd2ec0..dd197a1bc2 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -264,17 +264,23 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // 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 delegated child owns the migration write and history prompt, so - // schemaWritten/remoteHistoryUpdated aren't introspectable here. + // mode). The child is run with a non-TTY stdin (`"ignore"`) so its + // "Update remote migration history table?" prompt (Go's `PromptYesNo`, + // `internal/db/pull/pull.go:73`) takes its `true` default without blocking the + // JSON caller on interactive input — matching the native machine-mode path, + // which also takes the default and updates the history. The child therefore + // updates `schema_migrations`, so `remoteHistoryUpdated` is `true`; + // `schemaWritten` stays `null` because the child owns the timestamped path and + // doesn't surface it on stdout. const delegatePull = (engine: "migra" | "pg-delta") => Effect.gen(function* () { const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; if (output.format !== "text") { - yield* proxy.execCapture(rebuildDelegateArgs(flags), { env }); + yield* proxy.execCapture(rebuildDelegateArgs(flags), { env, stdin: "ignore" }); yield* output.success("Schema pulled.", { declarative: false, schemaWritten: null, - remoteHistoryUpdated: false, + remoteHistoryUpdated: true, engine, }); return; diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 7b013643c0..c6c906e7d4 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -164,13 +164,16 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const proxyCalls: Array<{ args: ReadonlyArray; env?: Record }> = []; - const proxyCaptureCalls: 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 }); + proxyCaptureCalls.push({ args, env: execOpts?.env, stdin: execOpts?.stdin }); return opts.delegateStdout ?? ""; }), }); @@ -383,11 +386,15 @@ describe("legacy db pull", () => { 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: false, + remoteHistoryUpdated: true, engine: "migra", }); }).pipe(Effect.provide(s.layer)); diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.ts b/apps/cli/src/shared/legacy/go-proxy.layer.ts index b7f564d4a9..b190d9f109 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.ts @@ -229,14 +229,17 @@ export function makeGoProxyLayer(opts?: { yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); const env = opts?.env || execOpts?.env ? { ...opts?.env, ...execOpts?.env } : undefined; - // Capture stdout (pipe) while keeping stdin/stderr inherited, so the - // child's prompts and progress still reach the user but its stdout is - // collected for wrapping rather than written to our stdout. + // 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: "inherit", + stdin: execOpts?.stdin ?? "inherit", stdout: "pipe", stderr: "inherit", detached: false, diff --git a/apps/cli/src/shared/legacy/go-proxy.service.ts b/apps/cli/src/shared/legacy/go-proxy.service.ts index 74469d3326..e9539e75a4 100644 --- a/apps/cli/src/shared/legacy/go-proxy.service.ts +++ b/apps/cli/src/shared/legacy/go-proxy.service.ts @@ -21,9 +21,15 @@ interface LegacyGoProxyShape { /** * Like `exec`, but captures the child's stdout and returns it as a string - * instead of inheriting stdout. stdin and stderr are still inherited (so the - * child's prompts and progress/diagnostics pass straight through), and a - * non-zero exit still terminates the process with the same code. + * 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 @@ -32,7 +38,11 @@ interface LegacyGoProxyShape { */ readonly execCapture: ( args: ReadonlyArray, - opts?: { readonly cwd?: string; readonly env?: Record }, + opts?: { + readonly cwd?: string; + readonly env?: Record; + readonly stdin?: "inherit" | "ignore"; + }, ) => Effect.Effect; } From fc6dd28774960428e4de9b2d797e6314543da2a0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 16:16:26 +0100 Subject: [PATCH 20/24] fix(db): faithful schema CSV round-trip, undefined-table-only suppression, experimental-pull repair flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four Go-parity follow-ups from review of the db diff/pull port: - diff/pull delegated argv: re-encode each parsed --schema value as a CSV field before forwarding to the Go child, so pflag's StringSlice doesn't CSV-split a comma-containing schema (e.g. "tenant,one") a second time. New legacySchemaToCsvField mirrors Go's encoding/csv Writer. - pull.sync ListRemoteMigrations: suppress only undefined_table (42P01) like Go's pgerrcode.UndefinedTable, not every "does not exist" — a malformed history table (missing version column, 42703) now propagates instead of being treated as an initial pull. Surface the SQLSTATE on LegacyDbExecError (extracted from the driver cause chain) and match the code, with a tightened message fallback. - delegatePull: parameterize remoteHistoryUpdated. Go's experimental structured dump returns without touching schema_migrations (pull.go:49-61) so it reports false; the initial-migra path repairs history so it reports true. --- .../legacy/commands/db/diff/diff.handler.ts | 5 +- .../commands/db/diff/diff.integration.test.ts | 13 +++++ .../legacy/commands/db/pull/pull.handler.ts | 37 ++++++++----- .../commands/db/pull/pull.integration.test.ts | 25 +++++++++ .../src/legacy/commands/db/pull/pull.sync.ts | 24 +++++++-- .../commands/db/pull/pull.sync.unit.test.ts | 54 ++++++++++++++++++- .../shared/legacy-db-connection.errors.ts | 7 +++ .../legacy-db-connection.sql-pg.layer.ts | 41 ++++++++++---- .../src/legacy/shared/legacy-schema-flags.ts | 27 ++++++++++ .../shared/legacy-schema-flags.unit.test.ts | 37 ++++++++++++- 10 files changed, 241 insertions(+), 29 deletions(-) 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 bb9308cbcf..7fa18fc66b 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -12,6 +12,7 @@ import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.ser 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"; @@ -90,7 +91,9 @@ const rebuildDelegateArgs = (flags: LegacyDbDiffFlags): Array => { pushTarget("local", flags.local); if (Option.isSome(flags.file)) args.push("--file", flags.file.value); if (Option.isSome(flags.output)) args.push("--output", flags.output.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)); return args; }; diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index f3b58261e6..fa3d7b3f25 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -337,6 +337,19 @@ describe("legacy db diff", () => { }).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* () { 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 dd197a1bc2..b07a307a54 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -21,6 +21,7 @@ import { } 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 { @@ -103,7 +104,9 @@ const rebuildDelegateArgs = (flags: LegacyDbPullFlags): Array => { 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); pushTarget("linked", flags.linked); pushTarget("local", flags.local); @@ -264,15 +267,19 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // 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 its - // "Update remote migration history table?" prompt (Go's `PromptYesNo`, + // 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 on interactive input — matching the native machine-mode path, - // which also takes the default and updates the history. The child therefore - // updates `schema_migrations`, so `remoteHistoryUpdated` is `true`; - // `schemaWritten` stays `null` because the child owns the timestamped path and - // doesn't surface it on stdout. - const delegatePull = (engine: "migra" | "pg-delta") => + // 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") { @@ -280,7 +287,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy yield* output.success("Schema pulled.", { declarative: false, schemaWritten: null, - remoteHistoryUpdated: true, + remoteHistoryUpdated: opts.remoteHistoryUpdated, engine, }); return; @@ -361,7 +368,11 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // (`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"))) { - yield* delegatePull(usePgDeltaDiff ? "pg-delta" : "migra"); + // 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; } @@ -387,7 +398,9 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy } if (sync.kind === "missing" && !usePgDeltaDiff) { // Initial pull with the migra engine needs `pg_dump` — delegate to Go. - yield* delegatePull("migra"); + // 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; } diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index c6c906e7d4..940af81d15 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -564,6 +564,31 @@ describe("legacy db pull", () => { }).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 diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts index ac323b8199..ea5a5bb6b7 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts @@ -2,6 +2,7 @@ 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"; @@ -119,20 +120,35 @@ export function legacySuggestMigrationRepair( /** * Lists the remote project's applied migration versions. Mirrors Go's - * `migration.ListRemoteMigrations` (`pkg/migration/list.go:18`): an undefined - * history table means the remote has no migrations, so it returns `[]` rather - * than failing. + * `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) => - /does not exist/iu.test(error.message) + 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` 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 index e29040ef89..0bcf91355e 100644 --- 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 @@ -1,6 +1,22 @@ +import { Effect, Exit } from "effect"; import { describe, expect, it } from "vitest"; -import { legacyReconcileMigrations, legacySuggestMigrationRepair } from "./pull.sync.ts"; +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 @@ -65,6 +81,42 @@ describe("legacyReconcileMigrations", () => { }); }); +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"])); 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-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], + ); + } + }); +}); From dfccaf84112c5ada45803b640e34df8003d440d7 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 16:58:38 +0100 Subject: [PATCH 21/24] fix(db): explicit-diff linked preflight + empty refs, int64 version parity, shadow password merge Four Go-parity follow-ups from review of the db diff/pull port: - diff explicit mode: seed the merged config from a changed --linked preflight. Go's root ParseDatabaseConfig runs LoadProjectRef+LoadConfig before RunExplicit (cmd/root.go:118), leaving utils.Config remote-merged, so explicit local/ migrations refs + format options use the linked [remotes.] override even when neither explicit ref is itself linked. Reuse the preflight's resolved ref to seed cfg/mergedLinkedRef. - diff explicit mode: gate on non-empty --from/--to like Go's len()>0, so (shell vars) falls through to the normal diff instead of erroring on an unknown empty target; still errors. - pull.sync: compare migration versions as BigInt over Go's full int64 range. strconv.Atoi accepts up to math.MaxInt64, so 9999999999999999 (> JS safe int, < int64 max) is a real conflict, not an initial pull; only values above int64 max are skipped (preserving the prior infinite-loop guard). Number lost precision in that range. - shadow seam: re-read the injected shadow password with the linked ref so a [remotes.].db.password override matches the password the __shadow child built the shadow with (it received --project-ref); otherwise the shadow connection fails auth. --- .../legacy/commands/db/diff/diff.handler.ts | 64 ++++++++++++------- .../commands/db/diff/diff.integration.test.ts | 45 +++++++++++++ .../src/legacy/commands/db/pull/pull.sync.ts | 23 ++++--- .../commands/db/pull/pull.sync.unit.test.ts | 17 +++-- .../db/shared/legacy-pgdelta.seam.layer.ts | 12 ++-- 5 files changed, 117 insertions(+), 44 deletions(-) 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 7fa18fc66b..2bc9b04987 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -144,9 +144,13 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); // Explicit `--from`/`--to` mode (Go's `db.go:102-109`): both required, always - // pg-delta. Runs before engine resolution and the shadow path. - const fromSet = Option.isSome(flags.from); - const toSet = Option.isSome(flags.to); + // 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( @@ -155,40 +159,52 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }), ); } + // `cfg` is the config the explicit refs + format options read from. It starts + // at the base TOML and is re-merged whenever a linked ref is resolved — first + // in the preflight below (a changed top-level `--linked`), then in the from→to + // cascade (an explicit `linked` ref). `mergedLinkedRef` tracks the linked ref + // resolved so far so a later `migrations` catalog export merges the same + // `[remotes.]` override (`explicit.go:88-126`; the `__catalog` child + // re-loads from SUPABASE_PROJECT_ID). Undefined until a linked ref resolves, + // so a `migrations` ref resolved before any linked ref uses base config. + let cfg = toml; + 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`). So an explicit-mode invocation still validates/loads a - // changed target flag: `--db-url bad` fails parsing, `--linked` resolves the - // linked db config (DNS/pooler), `--local` loads config. The explicit refs - // drive the diff, so the resolved config is discarded — this runs purely for - // the parity preflight (surfacing a bad/unreachable target the user passed). + // (`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"; - yield* resolver.resolve({ + const preflight = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType: preflightConnType, dnsResolver, password: Option.none(), }); + // Seed the merged config from a changed `--linked` preflight (stateful in + // Go), so explicit `local`/`migrations` refs use the linked overrides even + // when neither explicit ref is itself `linked`. + if (preflightConnType === "linked") { + const preflightRef = Option.getOrUndefined(preflight.ref ?? Option.none()); + if (preflightRef !== undefined) { + linkedRefForCache = preflightRef; + mergedLinkedRef = preflightRef; + cfg = yield* legacyReadDbToml(fs, path, cliConfig.workdir, preflightRef); + } + } } // Go resolves each ref in order (`explicit.go:21-25`); the `linked` branch - // runs `LoadConfig(ref)` (`explicit.go:78-86`), merging the matching - // `[remotes.]` block into the global config so a later `local` ref read - // and the trailing `pgDeltaFormatOptions()` see the override. Thread the - // merged config through the two resolutions to reproduce that cascade. - let cfg = toml; - // Tracks a linked ref resolved earlier in the from→to cascade so a later - // `migrations` catalog export merges the same `[remotes.]` override Go's - // sequential `LoadConfig` leaves in the global config (`explicit.go:78-86` → - // `config_path.go:10-12`; the `__catalog` child re-runs that load from - // SUPABASE_PROJECT_ID). Stays undefined until a linked ref resolves, so a - // `migrations` ref resolved BEFORE any linked ref (e.g. `--from migrations - // --to linked`) still uses base config — matching Go's resolution order. - let mergedLinkedRef: string | undefined; + // 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)) { @@ -233,8 +249,8 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy ); } }); - const sourceRef = yield* resolveRef(Option.getOrElse(flags.from, () => "")); - const targetRef = yield* resolveRef(Option.getOrElse(flags.to, () => "")); + const sourceRef = yield* resolveRef(from); + const targetRef = yield* resolveRef(to); const explicitCtx: LegacyPgDeltaContext = { projectId: Option.getOrElse(cliConfig.projectId, () => ""), cwd: cliConfig.workdir, diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index fa3d7b3f25..b63504c94c 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -515,6 +515,51 @@ describe("legacy db diff", () => { }).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("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 diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts index ea5a5bb6b7..2d5016ea25 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts @@ -45,22 +45,25 @@ export function legacyReconcileMigrations( remote: ReadonlyArray, local: ReadonlyArray, ): LegacyMigrationSync { - const MAX = Number.MAX_SAFE_INTEGER; + // 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`). Go's `Atoi` - // also returns a range error for values above int64 max, which the scan skips - // the same way; reject anything above the `MAX` sentinel here so a crafted - // 16+-digit version can never exceed it and stall the two-pointer scan (an - // exhausted side is pinned at `MAX`, so a parsed value `> MAX` would never - // advance). Real 14-digit timestamps are far below `MAX`, so this is unreachable - // in normal use — it just keeps a malformed remote-history row from hanging. - const parseVersion = (v: string): number | undefined => { + // 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 = Number(v); + const parsed = BigInt(v); return parsed > MAX ? undefined : parsed; }; while (i < remote.length || j < local.length) { 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 index 0bcf91355e..aa5d67c805 100644 --- 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 @@ -70,13 +70,18 @@ describe("legacyReconcileMigrations", () => { }); }); - it("skips an out-of-range version instead of hanging the two-pointer scan", () => { - // A 17-digit version exceeds Number.MAX_SAFE_INTEGER (the exhausted-side - // sentinel); before the range guard the scan stalled forever. Go's Atoi - // returns a range error and skips it the same way, so the surviving entries - // reconcile normally rather than looping. + 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", "99999999999999999"], ["20240101000000"]), + legacyReconcileMigrations(["20240101000000", "9999999999999999999"], ["20240101000000"]), ).toEqual({ kind: "in-sync" }); }); }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 0e2350052d..e2b5fc131f 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -338,9 +338,13 @@ export const legacyDeclarativeSeamLayer = Layer.effect( // 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 always uses the local Postgres password, so we - // re-inject the password resolved from config.toml (the same value Go - // used) before handing the URLs to the differ / sql-pg connection. + // (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(); @@ -348,7 +352,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( if (container.length === 0 || sourceUrl.length === 0) { return yield* Effect.fail(failure()); } - const password = yield* legacyReadDbToml(fs, path, cliConfig.workdir).pipe( + const password = yield* legacyReadDbToml(fs, path, cliConfig.workdir, projectRef).pipe( Effect.map((toml) => toml.password), Effect.mapError( () => From 2a4d9dfab64c49f45febe9b4230ca3cadc43da38 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 17:38:21 +0100 Subject: [PATCH 22/24] fix(db): defer base config read in db diff until the target/ref is known MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go loads db diff config once in PreRun and, on the linked path, only AFTER resolving the ref (LoadProjectRef then LoadConfig), so it validates the remote-merged config: config.go merges [remotes.] before Validate. The TS handler read+validated the BASE config unconditionally up front, failing on fields a remote block overrides (db.major_version, edge_runtime.deno_version, format_options, bucket/function names, ...) before the ref was known — so db diff --linked / --use-pgadmin --linked could fail on a project Go accepts. Defer the read: the pgAdmin/pg-schema delegate paths now read no config (the forwarded Go child loads it with the ref), the native local/db-url path reads the base config (Go local/direct LoadConfig, no merge), and the linked/explicit paths read with the resolved ref (merged). Tests: a delegated --use-pgadmin --linked with an invalid base major_version succeeds (no base validation), while a native local diff with the same config still fails. --- .../legacy/commands/db/diff/diff.handler.ts | 18 ++++++++---- .../commands/db/diff/diff.integration.test.ts | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) 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 2bc9b04987..381c56e6e8 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -141,7 +141,13 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy ); } - const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // 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 @@ -167,7 +173,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // `[remotes.]` override (`explicit.go:88-126`; the `__catalog` child // re-loads from SUPABASE_PROJECT_ID). Undefined until a linked ref resolves, // so a `migrations` ref resolved before any linked ref uses base config. - let cfg = toml; + let cfg = yield* legacyReadDbToml(fs, path, cliConfig.workdir); let mergedLinkedRef: string | undefined; // Go runs `ParseDatabaseConfig` in the root PersistentPreRunE for every // `db diff` (`cmd/root.go:118`), before RunE dispatches to RunExplicit @@ -358,15 +364,15 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy if (linkedRef !== undefined) linkedRefForCache = linkedRef; const targetUrl = connToUrl(resolved.conn); - // Reload config with the resolved linked ref so a matching `[remotes.]` + // 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, which never merges a remote block, so - // only the explicitly-linked path passes the ref. + // 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) - : toml; + : yield* legacyReadDbToml(fs, path, cliConfig.workdir); const ctx: LegacyPgDeltaContext = { projectId: Option.getOrElse(cliConfig.projectId, () => ""), cwd: cliConfig.workdir, diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index b63504c94c..d0ea5f6fa7 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -337,6 +337,34 @@ describe("legacy db diff", () => { }).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 From cbc32bdfb7b94167c01d0efae66eb233e9b68261 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 18:07:50 +0100 Subject: [PATCH 23/24] fix(db): pass linked ref to __catalog via a flag so it merges the remote config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exportCatalog seam forwarded the resolved linked ref only as the SUPABASE_PROJECT_ID env var, but the hidden db schema declarative __catalog command never runs LoadProjectRef: its group PersistentPreRunE calls flags.LoadConfig directly, and LoadConfig keys the [remotes.] merge off flags.ProjectRef (populated from the env only by LoadProjectRef). So the env was silently ignored and linked catalog exports (db diff --from linked --to migrations, db schema declarative generate --linked) built from the base config. Mirror the db __shadow fix: add a --project-ref flag to __catalog, seed flags.ProjectRef from it before the group LoadConfig, and have the TS seam pass --project-ref (a flag, not env — Go-proxy channel parity) instead of SUPABASE_PROJECT_ID. --- apps/cli-go/cmd/db_schema_declarative.go | 8 ++++++++ apps/cli-go/cmd/pgdelta_catalog.go | 8 ++++++++ .../db/shared/legacy-pgdelta.seam.layer.ts | 20 ++++++++++--------- 3 files changed, 27 insertions(+), 9 deletions(-) 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/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index e2b5fc131f..7c688577a9 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -65,6 +65,15 @@ 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, { @@ -75,16 +84,9 @@ export const legacyDeclarativeSeamLayer = Layer.effect( extendEnv: true, // 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). For - // `generate --linked`, also 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`). + // TS command (matching the explicit LegacyGoProxy delegates). // `extendEnv` keeps the rest of the environment. - env: { - SUPABASE_TELEMETRY_DISABLED: "1", - ...(projectRef !== undefined ? { SUPABASE_PROJECT_ID: projectRef } : {}), - }, + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, detached: false, }); const handle = yield* spawner.spawn(command).pipe( From a016ddc5d770ea74da380798beb4b1e4cac1dee3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 19 Jun 2026 18:43:25 +0100 Subject: [PATCH 24/24] fix(db): defer base config validation until after the linked ref resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go loads db diff/pull config once, after resolving the linked ref, and validates the [remotes.]-merged result. The TS port validated the BASE config at three points before the ref was known, so a project valid only after a remote override (e.g. base db.major_version=16 overridden to 15) failed in TS where Go succeeds: - shared resolver (legacy-db-config.layer.ts): the linked branch no longer does the unconditional top-of-resolve base read; only --db-url/--local read base config (Go local/direct LoadConfig, no merge), and the linked pooler fallback reads with the ref. - diff explicit mode: the cfg base read is deferred until after the linked preflight, reading the merged config when --linked resolved a ref. - edge-runtime layer: the Deno-image config read moved from layer acquisition into run (resolved from the callers effective deno_version), so composing the diff/pull runtime no longer validates base config — even db diff --use-pgadmin --linked no longer fails at layer build. All shared-infra changes keep --local/--db-url base validation unchanged and only affect the linked path (matching Go). Full legacy suite (2416 tests) green. --- .../legacy/commands/db/diff/diff.handler.ts | 24 +++++----- .../commands/db/diff/diff.integration.test.ts | 37 ++++++++++++++ .../legacy/shared/legacy-db-config.layer.ts | 14 +++++- .../legacy-edge-runtime-script.layer.ts | 48 +++++++++---------- 4 files changed, 82 insertions(+), 41 deletions(-) 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 381c56e6e8..577ff3f6c1 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -165,15 +165,10 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }), ); } - // `cfg` is the config the explicit refs + format options read from. It starts - // at the base TOML and is re-merged whenever a linked ref is resolved — first - // in the preflight below (a changed top-level `--linked`), then in the from→to - // cascade (an explicit `linked` ref). `mergedLinkedRef` tracks the linked ref - // resolved so far so a later `migrations` catalog export merges the same - // `[remotes.]` override (`explicit.go:88-126`; the `__catalog` child - // re-loads from SUPABASE_PROJECT_ID). Undefined until a linked ref resolves, - // so a `migrations` ref resolved before any linked ref uses base config. - let cfg = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // `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 @@ -195,18 +190,21 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy dnsResolver, password: Option.none(), }); - // Seed the merged config from a changed `--linked` preflight (stateful in - // Go), so explicit `local`/`migrations` refs use the linked overrides even - // when neither explicit ref is itself `linked`. if (preflightConnType === "linked") { const preflightRef = Option.getOrUndefined(preflight.ref ?? Option.none()); if (preflightRef !== undefined) { linkedRefForCache = preflightRef; mergedLinkedRef = preflightRef; - cfg = yield* legacyReadDbToml(fs, path, cliConfig.workdir, 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 diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index d0ea5f6fa7..bb52810856 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -566,6 +566,43 @@ describe("legacy db diff", () => { }).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. 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-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 ?? [])];