diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c37e341d0d..1656972dfd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -185,7 +185,7 @@ e2e package with `pnpm run test:e2e --shard=1/3`. ## E2E Compatibility Test Suite -`apps/cli-e2e` implements the replay-and-record compatibility harness for the TypeScript Legacy CLI (`ts-legacy`, the only shipped CLI shell). Live tests are owned by `apps/cli` and run from the command they cover. The CLI still shells out to the bundled Go binary for the handful of commands the TS port proxies (`db diff`, `db pull`, `db branch *`, `db remote *`, `gen keys`, `functions download`), so `apps/cli-go/` is built alongside the TS CLI for these suites, but there is no Go-vs-TypeScript parity runner. +`apps/cli-e2e` implements the replay-and-record compatibility harness for the TypeScript Legacy CLI (`ts-legacy`, the only shipped CLI shell). Live tests are owned by `apps/cli` and run from the command they cover. The CLI still shells out to the bundled Go binary for the handful of commands the TS port proxies (`db diff --use-pg-schema`, `db branch *`, `db remote changes`, `gen keys`, `functions download`), so `apps/cli-go/` is built alongside the TS CLI for these suites, but there is no Go-vs-TypeScript parity runner. ### Architecture diff --git a/apps/cli-e2e/.env.example b/apps/cli-e2e/.env.example index 77de786a27..2dff930a1a 100644 --- a/apps/cli-e2e/.env.example +++ b/apps/cli-e2e/.env.example @@ -12,7 +12,7 @@ CLI_HARNESS_TARGET=ts-legacy SUPABASE_ACCESS_TOKEN=sbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # ts-legacy shells out to the bundled Go binary for the proxied commands -# (db diff --use-pg-schema, db pull --experimental, db branch/remote, gen keys, +# (db diff --use-pg-schema, db branch *, db remote changes, gen keys, # functions download --legacy-bundle). Point at a freshly built binary: # cd apps/cli-go && go build -o /tmp/supabase-test-binary . SUPABASE_GO_BINARY=/tmp/supabase-test-binary diff --git a/apps/cli-e2e/AGENTS.md b/apps/cli-e2e/AGENTS.md index 0066ef9e38..da57c77e81 100644 --- a/apps/cli-e2e/AGENTS.md +++ b/apps/cli-e2e/AGENTS.md @@ -205,8 +205,7 @@ SUPABASE_GO_BINARY=/tmp/supabase-test-binary \ Commands currently requiring this — the full proxied surface, nothing else needs a Go binary at all: - `db diff` (for `--use-pg-schema`) -- `db pull` (for `--experimental`) - `db branch create`, `db branch delete`, `db branch list`, `db branch switch` -- `db remote changes`, `db remote commit` +- `db remote changes` - `gen keys` - `functions download` (for the hidden `--legacy-bundle` flag) diff --git a/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts b/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts index 9e03edf7f5..bc9b3383e2 100644 --- a/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts +++ b/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts @@ -85,20 +85,17 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( // The complete spawn surface, mirrored from `LegacyGoProxy` call sites: // - db diff (diff.handler.ts, `--use-pg-schema` delegate path) - // - db pull (pull.handler.ts, `--experimental` delegate path) // - db branch create|delete|list|switch (thin proxies) - // - db remote changes|commit (thin proxies) + // - db remote changes (thin proxy) // - gen keys (keys.handler.ts) // - functions download (shared/functions/download.ts, `--legacy-bundle`) const RETAINED_COMMAND_PATHS: ReadonlyArray> = [ ["db", "diff"], - ["db", "pull"], ["db", "branch", "create"], ["db", "branch", "delete"], ["db", "branch", "list"], ["db", "branch", "switch"], ["db", "remote", "changes"], - ["db", "remote", "commit"], ["gen", "keys"], ["functions", "download"], ]; @@ -145,12 +142,11 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( expect(stderr).not.toMatch(/unknown flag|invalid argument/i); }, 5_000); - // `db pull --experimental` (pull.handler.ts's `rebuildDelegateArgs`), with - // the complete global-flag set root.ts can prepend (globalArgs) — the - // only invocation in this suite exercising all ten at once. `db pull` - // connects directly to --db-url before touching Docker, so this fails at - // connect regardless of the (unused here) --network-id/--profile values. - test("db pull --experimental (full global flag set)", () => { + // `db remote changes` (changes.handler.ts), with the complete global-flag + // set root.ts can prepend (globalArgs) — the only invocation in this + // suite exercising all ten at once. Also provisions a Docker shadow first + // (same as `db diff`), so the bogus DOCKER_HOST is what trips this one. + test("db remote changes (full global flag set)", () => { const { exitCode, stderr } = runGo([ "--output", "json", @@ -168,23 +164,6 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( "--create-ticket", "--agent", "no", - "db", - "pull", - "--experimental", - "--db-url", - "postgresql://u:p@127.0.0.1:1/x", - "--schema", - "public", - ]); - expect(exitCode).toBe(1); - expect(stderr).not.toMatch(/unknown flag|invalid argument/i); - }, 5_000); - - // `db remote changes` (changes.handler.ts). Also provisions a Docker - // shadow first (same as `db diff`), so the bogus DOCKER_HOST is what - // trips this one too. - test("db remote changes", () => { - const { exitCode, stderr } = runGo([ "db", "remote", "changes", @@ -197,22 +176,6 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( expect(stderr).not.toMatch(/unknown flag|invalid argument/i); }, 5_000); - // `db remote commit` (commit.handler.ts). Connects directly to --db-url - // before touching Docker (same as `db pull`). - test("db remote commit", () => { - const { exitCode, stderr } = runGo([ - "db", - "remote", - "commit", - "--db-url", - "postgresql://u:p@127.0.0.1:1/x", - "--schema", - "public", - ]); - expect(exitCode).toBe(1); - expect(stderr).not.toMatch(/unknown flag|invalid argument/i); - }, 5_000); - // `gen keys` (keys.handler.ts). Gated behind Go's Management-API login // check before any network call, so an isolated SUPABASE_HOME (no stored // credentials) plus the bogus --profile fails fast without ever reaching diff --git a/apps/cli-go/CONTRIBUTING.md b/apps/cli-go/CONTRIBUTING.md index 39d0d33d85..c53f8d6c91 100644 --- a/apps/cli-go/CONTRIBUTING.md +++ b/apps/cli-go/CONTRIBUTING.md @@ -44,49 +44,7 @@ The Supabase API client is generated from OpenAPI spec. See [our guide](api/READ ## Testing local pg-delta builds -To exercise unpublished `@supabase/pg-delta` changes inside CLI edge-runtime scripts (`db pull`, `db diff`, `db push`, etc.), publish a local build via Verdaccio in [pg-toolbelt](https://github.com/supabase/pg-toolbelt) and point the CLI at that registry. - -### 1. Start Verdaccio (pg-toolbelt) - -```sh -cd pg-toolbelt -bun run verdaccio:start -``` - -Verdaccio listens on `http://localhost:4873`. `@supabase/*` packages you publish locally are served from local storage; other `@supabase/*` dependencies (for example `@supabase/pg-topo`) are proxied to npmjs. - -### 2. Publish a local pg-delta build - -After changing `packages/pg-delta`: - -```sh -bun run pg-delta:publish-local \ - --write-version-to=/path/to/test-project/supabase/.temp/pgdelta-version -``` - -This publishes a fresh `0.0.0-local.` version and restores `package.json` afterward. The version file tells the CLI which npm version to request (`EffectivePgDeltaNpmVersion`). - -Re-run whenever you change pg-delta source. - -### 3. Run the CLI against the local registry - -Set `PGDELTA_NPM_REGISTRY` to a URL reachable **from inside the edge-runtime Docker container**: - -```sh -# Docker Desktop (macOS / Windows) -export PGDELTA_NPM_REGISTRY=http://host.docker.internal:4873 - -# Linux (Docker 20.10+) -export PGDELTA_NPM_REGISTRY=http://host.docker.internal:4873 -# or: export PGDELTA_NPM_REGISTRY=http://172.17.0.1:4873 -``` - -Then run any pg-delta-backed command, for example: - -```sh -supabase db pull --db-url "$DATABASE_URL" --diff-engine pg-delta -``` - -When set, the CLI injects a scoped `.npmrc` and forwards `NPM_CONFIG_REGISTRY` into the edge-runtime container (`PgDeltaNpmRegistryOption` in `internal/utils/pgdelta_local.go`). - -Unset `PGDELTA_NPM_REGISTRY` to return to the npmjs version pinned in config / `supabase/.temp/pgdelta-version`. +The Go binary no longer runs pg-delta. The TypeScript CLI bundles +`@supabase/pg-delta` in-process. To test a local pg-delta build, update the +`@supabase/pg-delta` dependency pin in `apps/cli/package.json` / +`pnpm-workspace.yaml`. diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index a3b39d83d8..044a639783 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -8,7 +8,6 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/supabase/cli/internal/db/diff" - "github.com/supabase/cli/internal/db/pull" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/legacy/branch/create" @@ -70,74 +69,21 @@ var ( }, } - useMigra bool - usePgAdmin bool - usePgSchema bool - usePgDelta bool - useDeclarative bool - pullDiffEngine = utils.EnumFlag{ - Allowed: []string{"migra", "pg-delta"}, - Value: "migra", - } - diffFrom string - diffTo string - outputPath string - schema []string - file string - dbPassword string + // Bound so the TS `--use-pg-schema` proxy can forward these without unknown-flag errors. + usePgSchema bool + outputPath string + schema []string + file string + dbPassword string dbDiffCmd = &cobra.Command{ Use: "diff", Short: "Diffs the local database for schema changes", RunE: func(cmd *cobra.Command, args []string) error { - if len(diffFrom) > 0 || len(diffTo) > 0 { - switch { - case len(diffFrom) == 0 || len(diffTo) == 0: - return fmt.Errorf("must set both --from and --to when using explicit diff mode") - default: - return diff.RunExplicit(cmd.Context(), diffFrom, diffTo, schema, outputPath, afero.NewOsFs()) - } - } - useDelta := resolveDiffEngine(cmd.Flags().Changed("use-migra"), usePgAdmin, usePgSchema, shouldUsePgDelta()) - if usePgAdmin { - return diff.RunPgAdmin(cmd.Context(), schema, file, flags.DbConfig, afero.NewOsFs()) - } - differ := diff.DiffSchemaMigra - if usePgSchema { - differ = diff.DiffPgSchema - fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "--use-pg-schema flag is experimental and may not include all entities, such as views and grants.") - } else if useDelta { - differ = diff.DiffPgDelta - } - return diff.Run(cmd.Context(), schema, file, flags.DbConfig, differ, useDelta, afero.NewOsFs()) - }, - } - - dbPullCmd = &cobra.Command{ - Use: "pull [migration name]", - Short: "Pull schema from the remote database", - RunE: func(cmd *cobra.Command, args []string) error { - name := "remote_schema" - if len(args) > 0 { - name = args[0] - } - // Declarative export is opt-in via --declarative. Enabling pg-delta in config - // does not switch db pull to declarative output; it keeps the migration-file - // workflow and only defaults the shadow diff engine below. - useDeclarativePgDelta := useDeclarative - usePgDeltaDiff := resolvePullDiffEngine( - cmd.Flags().Changed("diff-engine"), - pullDiffEngine.Value, - shouldUsePgDelta(), - ) - pullDiffer := diff.DiffSchemaMigra - if usePgDeltaDiff { - pullDiffer = diff.DiffPgDelta - } - return pull.Run(cmd.Context(), schema, flags.DbConfig, name, useDeclarativePgDelta, usePgDeltaDiff, pullDiffer, afero.NewOsFs()) - }, - PostRun: func(cmd *cobra.Command, args []string) { - fmt.Println("Finished " + utils.Aqua("supabase db pull") + ".") + // TypeScript only proxies `--use-pg-schema` (stripe/pg-schema-diff). + // Other engines run in-process in the TS CLI. + fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), "--use-pg-schema flag is experimental and may not include all entities, such as views and grants.") + return diff.Run(cmd.Context(), schema, file, flags.DbConfig, diff.DiffPgSchema, afero.NewOsFs()) }, } @@ -153,56 +99,11 @@ var ( Short: "Show changes on the remote database", Long: "Show changes on the remote database since last migration.", RunE: func(cmd *cobra.Command, args []string) error { - return diff.Run(cmd.Context(), schema, file, flags.DbConfig, diff.DiffSchemaMigra, false, afero.NewOsFs()) - }, - } - - dbRemoteCommitCmd = &cobra.Command{ - Deprecated: "use \"db pull\" instead.\n", - Use: "commit", - Short: "Commit remote changes as a new migration", - RunE: func(cmd *cobra.Command, args []string) error { - // remote commit always writes a timestamped migration file. When pg-delta is - // enabled it only swaps the shadow diff engine; it never switches to the - // declarative export path. - usePgDeltaDiff := shouldUsePgDelta() - pullDiffer := diff.DiffSchemaMigra - if usePgDeltaDiff { - pullDiffer = diff.DiffPgDelta - } - return pull.Run(cmd.Context(), schema, flags.DbConfig, "remote_commit", false, usePgDeltaDiff, pullDiffer, afero.NewOsFs()) + return diff.Run(cmd.Context(), schema, file, flags.DbConfig, diff.DiffSchemaMigra, afero.NewOsFs()) }, } ) -func shouldUsePgDelta() bool { - return utils.IsPgDeltaEnabled() || usePgDelta || viper.GetBool("EXPERIMENTAL_PG_DELTA") -} - -// resolveDiffEngine reports whether `db diff` should run in pg-delta mode. The config / -// env default (pgDeltaDefault) applies unless an explicit non-pg-delta engine is selected: -// --use-migra, --use-pgadmin, or --use-pg-schema is an authoritative rollback that clears -// pg-delta mode so diff.Run skips pg-delta-specific declarative shadow setup and the -// PGDELTA_DEBUG capture path. --use-migra defaults to true, so only an explicit pass -// (useMigraChanged) counts as opting out. -func resolveDiffEngine(useMigraChanged, usePgAdmin, usePgSchema, pgDeltaDefault bool) bool { - if useMigraChanged || usePgAdmin || usePgSchema { - return false - } - return pgDeltaDefault -} - -// resolvePullDiffEngine selects whether migration-style db pull uses pg-delta for the -// shadow diff step. An explicit --diff-engine flag always wins, so --diff-engine migra is -// an authoritative rollback even when pg-delta is enabled in config; otherwise the default -// follows whether pg-delta is the active engine (config / env). -func resolvePullDiffEngine(engineFlagChanged bool, engine string, pgDeltaDefault bool) bool { - if engineFlagChanged { - return engine == "pg-delta" - } - return pgDeltaDefault -} - func init() { // Build branch command dbBranchCmd.AddCommand(dbBranchCreateCmd) @@ -212,13 +113,7 @@ func init() { dbCmd.AddCommand(dbBranchCmd) // Build diff command diffFlags := dbDiffCmd.Flags() - diffFlags.BoolVar(&useMigra, "use-migra", true, "Use migra to generate schema diff.") - diffFlags.BoolVar(&usePgAdmin, "use-pgadmin", false, "Use pgAdmin to generate schema diff.") diffFlags.BoolVar(&usePgSchema, "use-pg-schema", false, "Use pg-schema-diff to generate schema diff.") - diffFlags.BoolVar(&usePgDelta, "use-pg-delta", false, "Use pg-delta to generate schema diff.") - dbDiffCmd.MarkFlagsMutuallyExclusive("use-migra", "use-pgadmin", "use-pg-schema", "use-pg-delta") - diffFlags.StringVar(&diffFrom, "from", "", "Diff from local, linked, migrations, or a Postgres URL.") - diffFlags.StringVar(&diffTo, "to", "", "Diff to local, linked, migrations, or a Postgres URL.") diffFlags.StringVarP(&outputPath, "output", "o", "", "Write explicit diff output to a file path.") diffFlags.String("db-url", "", "Diffs against the database specified by the connection string (must be percent-encoded).") diffFlags.Bool("linked", false, "Diffs local migration files against the linked project.") @@ -227,24 +122,6 @@ func init() { diffFlags.StringVarP(&file, "file", "f", "", "Saves schema diff to a new migration file.") diffFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") dbCmd.AddCommand(dbDiffCmd) - // Build pull command - pullFlags := dbPullCmd.Flags() - // --declarative switches pull output from a timestamped migration to declarative - // schema files exported through pg-delta. --use-pg-delta is the deprecated alias. - pullFlags.BoolVar(&useDeclarative, "declarative", false, "Pull schema as declarative files using pg-delta instead of creating a migration.") - pullFlags.BoolVar(&useDeclarative, "use-pg-delta", false, "Use pg-delta to pull declarative schema.") - cobra.CheckErr(pullFlags.MarkDeprecated("use-pg-delta", "use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead.")) - pullFlags.Var(&pullDiffEngine, "diff-engine", "Diff engine to use for migration-style db pull.") - pullFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") - pullFlags.String("db-url", "", "Pulls from the database specified by the connection string (must be percent-encoded).") - pullFlags.Bool("linked", true, "Pulls from the linked project.") - pullFlags.Bool("local", false, "Pulls from the local database.") - dbPullCmd.MarkFlagsMutuallyExclusive("db-url", "linked", "local") - dbPullCmd.MarkFlagsMutuallyExclusive("declarative", "diff-engine") - dbPullCmd.MarkFlagsMutuallyExclusive("use-pg-delta", "diff-engine") - pullFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database.") - cobra.CheckErr(viper.BindPFlag("DB_PASSWORD", pullFlags.Lookup("password"))) - dbCmd.AddCommand(dbPullCmd) // Build remote command remoteFlags := dbRemoteCmd.PersistentFlags() remoteFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") @@ -254,7 +131,6 @@ func init() { remoteFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database.") cobra.CheckErr(viper.BindPFlag("DB_PASSWORD", remoteFlags.Lookup("password"))) dbRemoteCmd.AddCommand(dbRemoteChangesCmd) - dbRemoteCmd.AddCommand(dbRemoteCommitCmd) dbCmd.AddCommand(dbRemoteCmd) rootCmd.AddCommand(dbCmd) } diff --git a/apps/cli-go/cmd/db_test.go b/apps/cli-go/cmd/db_test.go deleted file mode 100644 index 654278d059..0000000000 --- a/apps/cli-go/cmd/db_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package cmd - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestResolvePullDiffEngine(t *testing.T) { - t.Run("defaults to pg-delta when enabled in config", func(t *testing.T) { - assert.True(t, resolvePullDiffEngine(false, "migra", true)) - }) - - t.Run("defaults to migra when pg-delta is not active", func(t *testing.T) { - assert.False(t, resolvePullDiffEngine(false, "migra", false)) - }) - - t.Run("explicit --diff-engine migra overrides config default", func(t *testing.T) { - assert.False(t, resolvePullDiffEngine(true, "migra", true)) - }) - - t.Run("explicit --diff-engine pg-delta wins when config disabled", func(t *testing.T) { - assert.True(t, resolvePullDiffEngine(true, "pg-delta", false)) - }) -} - -func TestResolveDiffEngine(t *testing.T) { - t.Run("uses pg-delta when enabled in config and no engine flag set", func(t *testing.T) { - assert.True(t, resolveDiffEngine(false, false, false, true)) - }) - - t.Run("uses migra when pg-delta is not active", func(t *testing.T) { - assert.False(t, resolveDiffEngine(false, false, false, false)) - }) - - t.Run("explicit --use-migra clears config-driven pg-delta", func(t *testing.T) { - assert.False(t, resolveDiffEngine(true, false, false, true)) - }) - - t.Run("explicit --use-pg-schema clears config-driven pg-delta", func(t *testing.T) { - assert.False(t, resolveDiffEngine(false, false, true, true)) - }) - - t.Run("explicit --use-pgadmin clears config-driven pg-delta", func(t *testing.T) { - assert.False(t, resolveDiffEngine(false, true, false, true)) - }) -} diff --git a/apps/cli-go/go.mod b/apps/cli-go/go.mod index 3d449bb68f..641c729020 100644 --- a/apps/cli-go/go.mod +++ b/apps/cli-go/go.mod @@ -33,7 +33,6 @@ require ( github.com/jackc/pgx/v4 v4.18.3 github.com/joho/godotenv v1.5.1 github.com/muesli/reflow v0.3.0 - github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 github.com/oapi-codegen/nullable v1.2.0 github.com/olekukonko/tablewriter v1.1.4 github.com/posthog/posthog-go v1.24.3 diff --git a/apps/cli-go/go.sum b/apps/cli-go/go.sum index 835a42ce9c..cbaa31edaf 100644 --- a/apps/cli-go/go.sum +++ b/apps/cli-go/go.sum @@ -673,8 +673,6 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4 h1:/yOLCBuysLJeubu2qQjvFU6meWNQ1YR/DP50+wKC1NI= -github.com/multigres/multigres v0.0.0-20260126223308-f5a52171bbc4/go.mod h1:UvLRTBJXqpyyXOtyEYH2NRPyklWWdzM7cNzcrEXiyRM= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -745,8 +743,6 @@ github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls= -github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= diff --git a/apps/cli-go/internal/db/declarative/debug.go b/apps/cli-go/internal/db/declarative/debug.go deleted file mode 100644 index 779514c525..0000000000 --- a/apps/cli-go/internal/db/declarative/debug.go +++ /dev/null @@ -1,128 +0,0 @@ -package declarative - -import ( - "fmt" - "os" - "path/filepath" - "time" - - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" -) - -const ( - debugDirPrefix = "debug" - debugLayout = "20060102-150405" -) - -// DebugBundle collects diagnostic artifacts when a declarative operation fails. -type DebugBundle struct { - ID string // timestamp-based unique ID (e.g. "20240414-044403") - SourceRef string // path to source catalog - TargetRef string // path to target catalog - SourceCatalog string // inline source catalog JSON (optional) - TargetCatalog string // inline target catalog JSON (optional) - MigrationSQL string // generated migration (if available) - PgDeltaStderr string // edge-runtime stderr from pg-delta scripts - ConnectionInfo string // redacted connection metadata - Error error // the error that occurred - Migrations []string // list of local migration files -} - -// SaveDebugBundle writes diagnostic artifacts to .temp/pgdelta/debug// and -// returns the directory path. -func SaveDebugBundle(bundle DebugBundle, fsys afero.Fs) (string, error) { - if len(bundle.ID) == 0 { - bundle.ID = time.Now().UTC().Format(debugLayout) - } - debugDir := filepath.Join(utils.TempDir, pgDeltaTempDir, debugDirPrefix, bundle.ID) - if err := utils.MkdirIfNotExistFS(fsys, debugDir); err != nil { - return "", fmt.Errorf("failed to create debug directory: %w", err) - } - - // Copy source catalog if available - if len(bundle.SourceCatalog) > 0 { - _ = utils.WriteFile(filepath.Join(debugDir, "source-catalog.json"), []byte(bundle.SourceCatalog), fsys) - } else if len(bundle.SourceRef) > 0 { - if data, err := afero.ReadFile(fsys, bundle.SourceRef); err == nil { - _ = utils.WriteFile(filepath.Join(debugDir, "source-catalog.json"), data, fsys) - } - } - - // Copy target catalog if available - if len(bundle.TargetCatalog) > 0 { - _ = utils.WriteFile(filepath.Join(debugDir, "target-catalog.json"), []byte(bundle.TargetCatalog), fsys) - } else if len(bundle.TargetRef) > 0 { - if data, err := afero.ReadFile(fsys, bundle.TargetRef); err == nil { - _ = utils.WriteFile(filepath.Join(debugDir, "target-catalog.json"), data, fsys) - } - } - - // Save generated migration if available - if len(bundle.MigrationSQL) > 0 { - _ = utils.WriteFile(filepath.Join(debugDir, "generated-migration.sql"), []byte(bundle.MigrationSQL), fsys) - } - - // Save error details - if bundle.Error != nil { - _ = utils.WriteFile(filepath.Join(debugDir, "error.txt"), []byte(bundle.Error.Error()), fsys) - } - - if len(bundle.PgDeltaStderr) > 0 { - _ = utils.WriteFile(filepath.Join(debugDir, "pgdelta-stderr.txt"), []byte(bundle.PgDeltaStderr), fsys) - } - - if len(bundle.ConnectionInfo) > 0 { - _ = utils.WriteFile(filepath.Join(debugDir, "connection.txt"), []byte(bundle.ConnectionInfo), fsys) - } - - // Copy migration files - if len(bundle.Migrations) > 0 { - migrationsDir := filepath.Join(debugDir, "migrations") - if err := utils.MkdirIfNotExistFS(fsys, migrationsDir); err == nil { - for _, name := range bundle.Migrations { - src := filepath.Join(utils.MigrationsDir, name) - if data, err := afero.ReadFile(fsys, src); err == nil { - _ = utils.WriteFile(filepath.Join(migrationsDir, name), data, fsys) - } - } - } - } - - return debugDir, nil -} - -// PrintDebugBundleMessage prints instructions for reporting an issue after -// saving a debug bundle. -func PrintDebugBundleMessage(debugDir string) { - fmt.Fprintln(os.Stderr) - if len(debugDir) > 0 { - fmt.Fprintln(os.Stderr, "Debug information saved to "+utils.Bold(debugDir)) - fmt.Fprintln(os.Stderr) - } - fmt.Fprintln(os.Stderr, "To report this issue, you can:") - fmt.Fprintln(os.Stderr, " 1. Open an issue at https://github.com/supabase/pg-toolbelt/issues") - fmt.Fprintln(os.Stderr, " Attach the files from the debug folder above.") - fmt.Fprintln(os.Stderr, " 2. Open a support ticket at https://supabase.com/dashboard/support") - fmt.Fprintln(os.Stderr, " (only visible to Supabase employees)") - fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, utils.Yellow("WARNING: The debug folder may contain sensitive information about your")) - fmt.Fprintln(os.Stderr, utils.Yellow("database schema, including table structures, function definitions, and role")) - fmt.Fprintln(os.Stderr, utils.Yellow("configurations. Review the contents carefully before sharing publicly.")) - fmt.Fprintln(os.Stderr, utils.Yellow("If unsure, prefer opening a support ticket (option 2) instead.")) -} - -// CollectMigrationsList returns a list of local migration filenames for -// inclusion in a debug bundle. -func CollectMigrationsList(fsys afero.Fs) []string { - migrations, err := migration.ListLocalMigrations(utils.MigrationsDir, afero.NewIOFS(fsys)) - if err != nil { - return nil - } - // Strip directory prefix to return just filenames - for i, m := range migrations { - migrations[i] = filepath.Base(m) - } - return migrations -} diff --git a/apps/cli-go/internal/db/declarative/debug_test.go b/apps/cli-go/internal/db/declarative/debug_test.go deleted file mode 100644 index f35a4ff550..0000000000 --- a/apps/cli-go/internal/db/declarative/debug_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package declarative - -import ( - "errors" - "path/filepath" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/utils" -) - -func TestSaveDebugBundleCreatesAllFiles(t *testing.T) { - fsys := afero.NewMemMapFs() - - // Write source and target catalog files - sourceRef := filepath.Join(utils.TempDir, "pgdelta", "source.json") - targetRef := filepath.Join(utils.TempDir, "pgdelta", "target.json") - require.NoError(t, fsys.MkdirAll(filepath.Join(utils.TempDir, "pgdelta"), 0755)) - require.NoError(t, afero.WriteFile(fsys, sourceRef, []byte(`{"source":true}`), 0644)) - require.NoError(t, afero.WriteFile(fsys, targetRef, []byte(`{"target":true}`), 0644)) - - // Write migration files so they can be copied - require.NoError(t, afero.WriteFile(fsys, filepath.Join(utils.MigrationsDir, "20240101000000_init.sql"), []byte("create table a();"), 0644)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(utils.MigrationsDir, "20240102000000_users.sql"), []byte("create table b();"), 0644)) - - bundle := DebugBundle{ - ID: "20240414-044403", - SourceRef: sourceRef, - TargetRef: targetRef, - MigrationSQL: "ALTER TABLE users ADD COLUMN email text;", - Error: errors.New("diff failed: something went wrong"), - Migrations: []string{"20240101000000_init.sql", "20240102000000_users.sql"}, - } - - debugDir, err := SaveDebugBundle(bundle, fsys) - require.NoError(t, err) - assert.Contains(t, debugDir, "20240414-044403") - - // Verify all files were created - source, err := afero.ReadFile(fsys, filepath.Join(debugDir, "source-catalog.json")) - require.NoError(t, err) - assert.JSONEq(t, `{"source":true}`, string(source)) - - target, err := afero.ReadFile(fsys, filepath.Join(debugDir, "target-catalog.json")) - require.NoError(t, err) - assert.JSONEq(t, `{"target":true}`, string(target)) - - migrationSQL, err := afero.ReadFile(fsys, filepath.Join(debugDir, "generated-migration.sql")) - require.NoError(t, err) - assert.Equal(t, "ALTER TABLE users ADD COLUMN email text;", string(migrationSQL)) - - errorTxt, err := afero.ReadFile(fsys, filepath.Join(debugDir, "error.txt")) - require.NoError(t, err) - assert.Equal(t, "diff failed: something went wrong", string(errorTxt)) - - // Verify migration files were copied with full content - initSQL, err := afero.ReadFile(fsys, filepath.Join(debugDir, "migrations", "20240101000000_init.sql")) - require.NoError(t, err) - assert.Equal(t, "create table a();", string(initSQL)) - - usersSQL, err := afero.ReadFile(fsys, filepath.Join(debugDir, "migrations", "20240102000000_users.sql")) - require.NoError(t, err) - assert.Equal(t, "create table b();", string(usersSQL)) -} - -func TestSaveDebugBundlePartialData(t *testing.T) { - fsys := afero.NewMemMapFs() - - bundle := DebugBundle{ - ID: "20240414-050000", - Error: errors.New("connection refused"), - } - - debugDir, err := SaveDebugBundle(bundle, fsys) - require.NoError(t, err) - - // Only error.txt should exist - errorTxt, err := afero.ReadFile(fsys, filepath.Join(debugDir, "error.txt")) - require.NoError(t, err) - assert.Equal(t, "connection refused", string(errorTxt)) - - // Other files should not exist - exists, err := afero.Exists(fsys, filepath.Join(debugDir, "source-catalog.json")) - require.NoError(t, err) - assert.False(t, exists) - - exists, err = afero.Exists(fsys, filepath.Join(debugDir, "target-catalog.json")) - require.NoError(t, err) - assert.False(t, exists) - - exists, err = afero.Exists(fsys, filepath.Join(debugDir, "generated-migration.sql")) - require.NoError(t, err) - assert.False(t, exists) -} - -func TestSaveDebugBundleGeneratesID(t *testing.T) { - fsys := afero.NewMemMapFs() - - bundle := DebugBundle{ - Error: errors.New("test error"), - } - - debugDir, err := SaveDebugBundle(bundle, fsys) - require.NoError(t, err) - assert.NotEmpty(t, debugDir) - - // Should contain a timestamp-like ID - errorTxt, err := afero.ReadFile(fsys, filepath.Join(debugDir, "error.txt")) - require.NoError(t, err) - assert.Equal(t, "test error", string(errorTxt)) -} - -func TestCollectMigrationsList(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, filepath.Join(utils.MigrationsDir, "20240101000000_init.sql"), []byte("create table a();"), 0644)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(utils.MigrationsDir, "20240102000000_users.sql"), []byte("create table b();"), 0644)) - - migrations := CollectMigrationsList(fsys) - assert.Len(t, migrations, 2) -} - -func TestCollectMigrationsListEmpty(t *testing.T) { - fsys := afero.NewMemMapFs() - - migrations := CollectMigrationsList(fsys) - assert.Empty(t, migrations) -} diff --git a/apps/cli-go/internal/db/declarative/declarative.go b/apps/cli-go/internal/db/declarative/declarative.go deleted file mode 100644 index b84087bf9f..0000000000 --- a/apps/cli-go/internal/db/declarative/declarative.go +++ /dev/null @@ -1,842 +0,0 @@ -package declarative - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io/fs" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "time" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/diff" - "github.com/supabase/cli/internal/db/pgcache" - "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" -) - -const ( - // pgDeltaTempDir namespaces pg-delta artifacts under .temp to make ownership - // and cleanup intent explicit. - pgDeltaTempDir = "pgdelta" - // baselineCatalogName caches the catalog of a shadow database with the Supabase - // platform baseline (auth/storage/realtime) provisioned but no user migrations - // applied — equivalent to diff.MigrateShadowDatabase with zero migrations. - // - // It is used as the "source" baseline both when generating declarative files - // from a real database target and when syncing with no local migrations, so it - // must stay in parity with the declarative target's platform baseline. The "%s" - // is a key (see baselineCatalogKey) derived from the image plus every setup - // input that shapes the baseline, so config/roles changes self-invalidate the - // cache rather than reusing a stale snapshot. - baselineCatalogName = "catalog-baseline-%s.json" - // declarativeCatalogName stores catalogs keyed by declarative-content hash. - declarativeCatalogName = "catalog-%s-declarative-%s-%d.json" - // Separate no-cache paths prevent overwrite when both catalogs are - // exported in the same sync invocation (getMigrationsCatalogRef then - // writeDeclarativeCatalogFromConfig). - noCacheBaselineCatalogPath = "catalog-nocache-baseline.json" - noCacheMigrationsCatalogPath = "catalog-nocache-migrations.json" - noCacheDeclarativeCatalogPath = "catalog-nocache-declarative.json" - catalogRetentionCount = 2 -) - -var ( - // schemaPathsPattern locates existing schema_paths in config so declarative - // writes can replace stale values rather than appending duplicates. - schemaPathsPattern = regexp.MustCompile(`(?s)\nschema_paths = \[(.*?)\]\n`) - // dropStatementRegexp flags potentially destructive statements for UX warnings - // when generating migration output from declarative sources. - dropStatementRegexp = regexp.MustCompile(`(?i)drop\s+`) - catalogPrefixRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) - exportCatalog = diff.ExportCatalogPgDelta - applyDeclarative = pgdelta.ApplyDeclarative - declarativeExportRef = diff.DeclarativeExportPgDeltaRef - // diffPgDeltaRef diffs a source catalog against a target catalog. It is a - // package var so tests can exercise the full generate -> sync flow without the - // real pg-delta runtime. - diffPgDeltaRef = diff.DiffPgDeltaRef - // setupShadowDatabase provisions the Supabase platform baseline (auth/storage/ - // realtime) on a shadow database before declarative schemas are applied, so - // Supabase-managed dependencies (auth.sessions, auth.jwt(), ...) resolve. It is - // a package var so tests can inject a no-op without a real shadow database. - setupShadowDatabase = diff.SetupShadowDatabase - // createShadow provisions a healthy shadow database container. It is a package - // var so tests can exercise the baseline/migrations/declarative paths without a - // real Docker daemon. - createShadow = createShadowContainer - // generateBaselineCatalogRefResolver allows Generate to reuse a freshly - // provisioned baseline shadow for declarative cache warmup. - generateBaselineCatalogRefResolver = getGenerateBaselineCatalogRef - // declarativeCatalogRefResolver is used by Generate so tests can verify - // cache warming behavior without provisioning a real shadow database. - declarativeCatalogRefResolver = getDeclarativeCatalogRef -) - -type shadowSession struct { - container string - config pgconn.Config -} - -func (s *shadowSession) cleanup() { - if s == nil || len(s.container) == 0 { - return - } - utils.DockerRemove(s.container) - s.container = "" -} - -type generateBaselineCatalogRef struct { - ref string - shadow *shadowSession -} - -// Generate exports a live database schema into files under supabase/declarative. -// -// The workflow uses pg-delta catalogs so output can be deterministic and filtered -// by schema, then optionally prompts before replacing existing files. -func Generate(ctx context.Context, schema []string, config pgconn.Config, overwrite bool, noCache bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - baseline, err := generateBaselineCatalogRefResolver(ctx, noCache, fsys, options...) - if err != nil { - return err - } - if baseline.shadow != nil { - defer baseline.shadow.cleanup() - } - sourceRef := baseline.ref - output, err := declarativeExportRef(ctx, sourceRef, utils.ToPostgresURL(config), schema, pgDeltaFormatOptions(), options...) - if err != nil { - return err - } - if !overwrite { - ok, err := confirmOverwrite(ctx, fsys) - if err != nil { - return err - } - if !ok { - fmt.Fprintln(os.Stderr, "Skipped writing declarative schema.") - return nil - } - } - if err := WriteDeclarativeSchemas(output, fsys); err != nil { - return err - } - // Warm declarative catalog cache after generate so follow-up sync - // can reuse it without provisioning another shadow database. - if !noCache { - if baseline.shadow != nil { - // The reused baseline shadow already has the platform baseline - // provisioned (getGenerateBaselineCatalogRef), so apply declarative - // schemas directly on top of it without setting it up again. - hash, err := declarativeCatalogCacheKey(fsys) - if err != nil { - return err - } - if _, err := writeDeclarativeCatalogFromConfig(ctx, baseline.shadow.config, hash, "local", false, fsys, options...); err != nil { - return err - } - } else { - if _, err := declarativeCatalogRefResolver(ctx, false, fsys, options...); err != nil { - return err - } - } - } - fmt.Fprintln(os.Stderr, "Declarative schema written to "+utils.Bold(utils.GetDeclarativeDir())) - return nil -} - -// SyncResult holds the output of a declarative-to-migrations diff operation. -type SyncResult struct { - DiffSQL string // The generated migration SQL - SourceRef string // Migrations catalog ref (for debug) - TargetRef string // Declarative catalog ref (for debug) - DropWarnings []string // Any DROP statements found -} - -// DiffDeclarativeToMigrations computes the diff between local migrations state -// and declarative schema files, returning the result without writing anything. -func DiffDeclarativeToMigrations(ctx context.Context, schema []string, noCache bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (*SyncResult, error) { - declarativeDir := utils.GetDeclarativeDir() - if exists, err := afero.DirExists(fsys, declarativeDir); err != nil { - return nil, err - } else if !exists { - return nil, errors.Errorf("No declarative schema directory found. Run %s first.", utils.Aqua("supabase db schema declarative generate")) - } - sourceRef, err := getMigrationsCatalogRef(ctx, noCache, fsys, "local", options...) - if err != nil { - return nil, err - } - targetRef, err := getDeclarativeCatalogRef(ctx, noCache, fsys, options...) - if err != nil { - return nil, err - } - out, err := diffPgDeltaRef(ctx, sourceRef, targetRef, schema, pgDeltaFormatOptions(), options...) - if err != nil { - return nil, err - } - return &SyncResult{ - DiffSQL: out, - SourceRef: sourceRef, - TargetRef: targetRef, - DropWarnings: findDropStatements(out), - }, nil -} - -// SyncToMigrations diffs local declarative files against migration state and -// writes the delta as a new migration file. -func SyncToMigrations(ctx context.Context, schema []string, file string, noCache bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - result, err := DiffDeclarativeToMigrations(ctx, schema, noCache, fsys, options...) - if err != nil { - return err - } - if len(strings.TrimSpace(file)) == 0 { - file = "declarative_sync" - } - if err := diff.SaveDiff(diff.DatabaseDiff{SQL: result.DiffSQL}, file, fsys); err != nil { - return err - } - if len(result.DropWarnings) > 0 { - fmt.Fprintln(os.Stderr, "Found drop statements in schema diff. Please double check if these are expected:") - fmt.Fprintln(os.Stderr, utils.Yellow(strings.Join(result.DropWarnings, "\n"))) - } - return nil -} - -// confirmOverwrite asks before replacing existing declarative files. -// -// This guard exists because declarative export rewrites the entire directory. -func confirmOverwrite(ctx context.Context, fsys afero.Fs) (bool, error) { - declarativeDir := utils.GetDeclarativeDir() - exists, err := afero.DirExists(fsys, declarativeDir) - if err != nil || !exists { - return true, err - } - files, err := afero.ReadDir(fsys, declarativeDir) - if err != nil { - return false, err - } - if len(files) == 0 { - return true, nil - } - msg := "Overwrite declarative schema? Existing files may be deleted." - return utils.NewConsole().PromptYesNo(ctx, msg, false) -} - -// WriteDeclarativeSchemas materializes pg-delta declarative output on disk and -// updates schema_paths so downstream commands read from declarative files. -func WriteDeclarativeSchemas(output diff.DeclarativeOutput, fsys afero.Fs) error { - declarativeDir := utils.GetDeclarativeDir() - if err := fsys.RemoveAll(declarativeDir); err != nil { - return errors.Errorf("failed to clean declarative schema directory: %w", err) - } - if err := utils.MkdirIfNotExistFS(fsys, declarativeDir); err != nil { - return err - } - for _, file := range output.Files { - relPath := filepath.FromSlash(filepath.Clean(file.Path)) - if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { - return errors.Errorf("unsafe declarative export path: %s", file.Path) - } - targetPath := filepath.Join(declarativeDir, relPath) - if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(targetPath)); err != nil { - return err - } - if err := utils.WriteFile(targetPath, []byte(file.SQL), fsys); err != nil { - return err - } - } - // When pg-delta is enabled, the declarative directory (default or configured) - // is the source of truth; do not overwrite [db.migrations] schema_paths. - if utils.IsPgDeltaEnabled() { - return nil - } - utils.Config.Db.Migrations.SchemaPaths = []string{ - declarativeDir, - } - return updateDeclarativeSchemaPathsConfig(fsys) -} - -// updateDeclarativeSchemaPathsConfig ensures config.toml points to declarative -// SQL files after generate/sync operations. -// -// This makes declarative output the active source of truth for commands that -// read schema paths from config. -func updateDeclarativeSchemaPathsConfig(fsys afero.Fs) error { - // Remove the `supabase` prefix from the declarative directory - declarativeDir := strings.TrimPrefix(utils.GetDeclarativeDir(), "supabase/") - lines := []string{ - "\nschema_paths = [", - fmt.Sprintf(` "%s",`, declarativeDir), - "]\n", - } - schemaPaths := strings.Join(lines, "\n") - data, err := afero.ReadFile(fsys, utils.ConfigPath) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return errors.Errorf("failed to read config: %w", err) - } - if newConfig := schemaPathsPattern.ReplaceAllLiteral(data, []byte(schemaPaths)); bytesContain(newConfig, []byte(schemaPaths)) { - return utils.WriteFile(utils.ConfigPath, newConfig, fsys) - } - f, err := fsys.OpenFile(utils.ConfigPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) - if err != nil { - return errors.Errorf("failed to open config: %w", err) - } - defer f.Close() - if _, err := f.WriteString("\n[db.migrations]"); err != nil { - return errors.Errorf("failed to write header: %w", err) - } - if _, err := f.WriteString(schemaPaths); err != nil { - return errors.Errorf("failed to write config: %w", err) - } - return nil -} - -func getGenerateBaselineCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (generateBaselineCatalogRef, error) { - cachePath, err := baselineCatalogPath(fsys) - if err != nil { - return generateBaselineCatalogRef{}, err - } - if !noCache { - if ok, err := afero.Exists(fsys, cachePath); err == nil && ok { - return generateBaselineCatalogRef{ref: cachePath}, nil - } - } - shadowID, config, err := createShadow(ctx) - if err != nil { - return generateBaselineCatalogRef{}, err - } - shadow := &shadowSession{ - container: shadowID, - config: config, - } - // Provision the Supabase platform baseline before exporting so the baseline - // catalog represents "platform baseline, no user migrations" — the same - // semantics as diff.MigrateShadowDatabase with zero migrations. This baseline is - // reused as the diff source by both Generate (against the live database) and - // sync-with-no-migrations (getMigrationsCatalogRef). Its starting point must - // match the declarative target, which also sets up the platform baseline; - // otherwise platform objects (auth/storage/realtime) surface as spurious - // additions in generated migrations. - if err := setupShadowDatabase(ctx, shadow.container, fsys, options...); err != nil { - shadow.cleanup() - return generateBaselineCatalogRef{}, err - } - snapshot, err := exportCatalog(ctx, utils.ToPostgresURL(config), "postgres", options...) - if err != nil { - shadow.cleanup() - return generateBaselineCatalogRef{}, err - } - if noCache { - path, err := writeTempCatalog(fsys, noCacheBaselineCatalogPath, snapshot) - shadow.cleanup() - if err != nil { - return generateBaselineCatalogRef{}, err - } - return generateBaselineCatalogRef{ref: path}, nil - } - if err := ensureTempDir(fsys); err != nil { - shadow.cleanup() - return generateBaselineCatalogRef{}, err - } - if err := utils.WriteFile(cachePath, []byte(snapshot), fsys); err != nil { - shadow.cleanup() - return generateBaselineCatalogRef{}, err - } - return generateBaselineCatalogRef{ - ref: cachePath, - shadow: shadow, - }, nil -} - -// getMigrationsCatalogRef returns a catalog reference representing local -// migrations applied to a shadow database. -// -// A migration-content hash plus setup-input token keys the cache so it is reused -// only when both local migration state and platform baseline inputs are unchanged. -func getMigrationsCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, prefix string, options ...func(*pgx.ConnConfig)) (string, error) { - migrations, err := migration.ListLocalMigrations(utils.MigrationsDir, afero.NewIOFS(fsys)) - if err != nil { - return "", err - } - // With no local migrations, the migrations catalog is exactly the platform - // baseline, so it is cached under the setup-keyed baseline path rather than the - // migrations-hash cache. The migrations-hash cache is not setup-aware, so an - // older empty-migrations snapshot from a different platform setup must not be - // reused as the no-migration sync source. - zeroMigrations := len(migrations) == 0 - var baselinePath string - if zeroMigrations { - baselinePath, err = baselineCatalogPath(fsys) - if err != nil { - return "", err - } - if !noCache { - if ok, err := afero.Exists(fsys, baselinePath); err != nil { - return "", err - } else if ok { - return baselinePath, nil - } - } - } - hash, err := migrationsCatalogCacheKey(fsys) - if err != nil { - return "", err - } - if !noCache && !zeroMigrations { - if cachePath, ok, err := pgcache.ResolveMigrationCatalogPath(fsys, hash, prefix); err != nil { - return "", err - } else if ok { - return cachePath, nil - } - } - shadow, config, err := createShadow(ctx) - if err != nil { - return "", err - } - defer utils.DockerRemove(shadow) - if err := diff.MigrateShadowDatabase(ctx, shadow, fsys, options...); err != nil { - return "", err - } - snapshot, err := exportCatalog(ctx, utils.ToPostgresURL(config), "postgres", options...) - if err != nil { - return "", err - } - if noCache { - return writeTempCatalog(fsys, noCacheMigrationsCatalogPath, snapshot) - } - if zeroMigrations { - // MigrateShadowDatabase with zero migrations == the platform baseline. - if err := ensureTempDir(fsys); err != nil { - return "", err - } - if err := utils.WriteFile(baselinePath, []byte(snapshot), fsys); err != nil { - return "", err - } - return baselinePath, nil - } - return pgcache.WriteMigrationCatalogSnapshot(fsys, prefix, hash, snapshot) -} - -// getDeclarativeCatalogRef applies local declarative files to a shadow database -// and exports the resulting catalog for diffing. -func getDeclarativeCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (string, error) { - hash, err := declarativeCatalogCacheKey(fsys) - if err != nil { - return "", err - } - prefix := "local" - if !noCache { - if path, ok, err := resolveDeclarativeCatalogPath(fsys, hash, prefix); err != nil { - return "", err - } else if ok { - return path, nil - } - } - shadow, config, err := createShadow(ctx) - if err != nil { - return "", err - } - defer utils.DockerRemove(shadow) - // Apply the Supabase platform baseline (auth/storage/realtime) before applying - // declarative schemas so dependencies on Supabase-managed objects (auth.sessions, - // auth.jwt(), ...) resolve. This keeps the declarative shadow in parity with the - // migrations shadow (diff.MigrateShadowDatabase), so platform objects cancel out - // of the diff instead of surfacing as spurious changes or "stuck" applies. - if err := setupShadowDatabase(ctx, shadow, fsys, options...); err != nil { - return "", err - } - return writeDeclarativeCatalogFromConfig(ctx, config, hash, prefix, noCache, fsys, options...) -} - -func writeDeclarativeCatalogFromConfig(ctx context.Context, config pgconn.Config, hash, prefix string, noCache bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (string, error) { - if err := applyDeclarative(ctx, config, fsys); err != nil { - return "", err - } - snapshot, err := exportCatalog(ctx, utils.ToPostgresURL(config), "postgres", options...) - if err != nil { - return "", err - } - if noCache { - return writeTempCatalog(fsys, noCacheDeclarativeCatalogPath, snapshot) - } - if err := ensureTempDir(fsys); err != nil { - return "", err - } - path := declarativeCatalogPath(hash, prefix, time.Now().UTC()) - if err := utils.WriteFile(path, []byte(snapshot), fsys); err != nil { - return "", err - } - if err := cleanupOldDeclarativeCatalogs(fsys, prefix); err != nil { - return "", err - } - return path, nil -} - -// createShadowContainer provisions and health-checks the temporary Postgres -// container used by declarative conversion and diff operations. -func createShadowContainer(ctx context.Context) (string, pgconn.Config, error) { - fmt.Fprintln(os.Stderr, "Creating shadow database...") - shadow, err := diff.CreateShadowDatabase(ctx, utils.Config.Db.ShadowPort) - if err != nil { - return "", pgconn.Config{}, err - } - if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, shadow); err != nil { - utils.DockerRemove(shadow) - return "", pgconn.Config{}, err - } - config := pgconn.Config{ - Host: utils.Config.Hostname, - Port: utils.Config.Db.ShadowPort, - User: "postgres", - Password: utils.Config.Db.Password, - Database: "postgres", - } - return shadow, config, nil -} - -// hashMigrations mirrors pgcache hashing for declarative package tests. -func hashMigrations(fsys afero.Fs) (string, error) { - return pgcache.HashMigrations(fsys) -} - -// hashDeclarativeSchemas computes a stable hash of declarative SQL files. -func hashDeclarativeSchemas(fsys afero.Fs) (string, error) { - declarativeDir := utils.GetDeclarativeDir() - var paths []string - if err := afero.Walk(fsys, declarativeDir, func(path string, info fs.FileInfo, err error) error { - if err != nil { - return err - } - if info.Mode().IsRegular() && filepath.Ext(info.Name()) == ".sql" { - paths = append(paths, path) - } - return nil - }); err != nil { - return "", err - } - sort.Strings(paths) - h := sha256.New() - for _, path := range paths { - contents, err := afero.ReadFile(fsys, path) - if err != nil { - return "", err - } - rel, err := filepath.Rel(declarativeDir, path) - if err != nil { - return "", err - } - normalized := filepath.ToSlash(rel) - if _, err := h.Write([]byte(normalized)); err != nil { - return "", err - } - if _, err := h.Write(contents); err != nil { - return "", err - } - } - return hex.EncodeToString(h.Sum(nil)), nil -} - -// writeTempCatalog writes a catalog snapshot under utils.TempDir and returns -// the file path so callers can pass it to pg-delta as a source/target reference. -func writeTempCatalog(fsys afero.Fs, name, snapshot string) (string, error) { - if err := ensureTempDir(fsys); err != nil { - return "", err - } - path := filepath.Join(pgDeltaTempPath(), name) - if err := utils.WriteFile(path, []byte(snapshot), fsys); err != nil { - return "", err - } - return path, nil -} - -// ensureTempDir creates the shared temp directory used by declarative catalog -// caches and ephemeral snapshots. -func ensureTempDir(fsys afero.Fs) error { - return utils.MkdirIfNotExistFS(fsys, pgDeltaTempPath()) -} - -func pgDeltaTempPath() string { - return filepath.Join(utils.TempDir, pgDeltaTempDir) -} - -func declarativeCatalogPath(hash, prefix string, createdAt time.Time) string { - return filepath.Join(pgDeltaTempPath(), fmt.Sprintf(declarativeCatalogName, sanitizedCatalogPrefix(prefix), hash, createdAt.UnixMilli())) -} - -func resolveDeclarativeCatalogPath(fsys afero.Fs, hash, prefix string) (string, bool, error) { - if err := ensureTempDir(fsys); err != nil { - return "", false, err - } - entries, err := afero.ReadDir(fsys, pgDeltaTempPath()) - if err != nil { - return "", false, err - } - familyPrefix := fmt.Sprintf("catalog-%s-declarative-%s-", sanitizedCatalogPrefix(prefix), hash) - latestPath := "" - latestTimestamp := int64(-1) - for _, entry := range entries { - name := entry.Name() - if !strings.HasPrefix(name, familyPrefix) || !strings.HasSuffix(name, ".json") { - continue - } - stamp := strings.TrimSuffix(strings.TrimPrefix(name, familyPrefix), ".json") - ts, err := strconv.ParseInt(stamp, 10, 64) - if err != nil { - continue - } - if ts > latestTimestamp { - latestTimestamp = ts - latestPath = filepath.Join(pgDeltaTempPath(), name) - } - } - if latestTimestamp >= 0 { - return latestPath, true, nil - } - return "", false, nil -} - -func cleanupOldDeclarativeCatalogs(fsys afero.Fs, prefix string) error { - if err := ensureTempDir(fsys); err != nil { - return err - } - entries, err := afero.ReadDir(fsys, pgDeltaTempPath()) - if err != nil { - return err - } - familyPrefix := fmt.Sprintf("catalog-%s-declarative-", sanitizedCatalogPrefix(prefix)) - type catalogFile struct { - name string - timestamp int64 - } - var files []catalogFile - for _, entry := range entries { - name := entry.Name() - if !strings.HasPrefix(name, familyPrefix) || !strings.HasSuffix(name, ".json") { - continue - } - if ts, ok := catalogTimestamp(name); ok { - files = append(files, catalogFile{name: name, timestamp: ts}) - continue - } - files = append(files, catalogFile{name: name, timestamp: 0}) - } - sort.Slice(files, func(i, j int) bool { - if files[i].timestamp == files[j].timestamp { - return files[i].name > files[j].name - } - return files[i].timestamp > files[j].timestamp - }) - for i := catalogRetentionCount; i < len(files); i++ { - if err := fsys.Remove(filepath.Join(pgDeltaTempPath(), files[i].name)); err != nil { - return err - } - } - return nil -} - -func catalogTimestamp(name string) (int64, bool) { - if !strings.HasSuffix(name, ".json") { - return 0, false - } - raw := strings.TrimSuffix(name, ".json") - idx := strings.LastIndex(raw, "-") - if idx < 0 || idx+1 >= len(raw) { - return 0, false - } - ts, err := strconv.ParseInt(raw[idx+1:], 10, 64) - if err != nil { - return 0, false - } - return ts, true -} - -func baselineVersionToken() string { - image := strings.TrimSpace(utils.Config.Db.Image) - if idx := strings.LastIndex(image, ":"); idx >= 0 && idx+1 < len(image) { - image = image[idx+1:] - } - if len(strings.TrimSpace(image)) == 0 { - image = fmt.Sprintf("pg%d", utils.Config.Db.MajorVersion) - } - return catalogPrefixRegexp.ReplaceAllString(image, "-") -} - -// setupInputsToken hashes every project input that start.SetupDatabase consumes -// and that therefore shapes the platform baseline: -// -// - the Postgres image (initSchema content); -// - the service toggles that gate initSchema — auth/storage/realtime; -// - api.auto_expose_new_tables (ApplyApiPrivileges default ACLs); -// - vault secret names (UpsertVaultSecrets); -// - supabase/roles.sql (SeedGlobals). -// -// Every catalog produced in this flow is "platform baseline + {nothing | migrations -// | declarative}", so each cache folds this token into its key and self-invalidates -// when setup changes instead of reusing a snapshot from a different baseline. -func setupInputsToken(fsys afero.Fs) (string, error) { - h := sha256.New() - fmt.Fprintln(h, baselineVersionToken()) - // initSchema conditionally provisions these service schemas. - fmt.Fprintf(h, "auth=%t storage=%t realtime=%t\n", - utils.Config.Auth.Enabled, utils.Config.Storage.Enabled, utils.Config.Realtime.Enabled) - // api.auto_expose_new_tables drives ApplyApiPrivileges (default ACLs). Key on the - // effective value, not the raw tri-state: as of the 2026-05-30 flip an unset flag - // resolves to the same revoke-by-default baseline as explicit false (see - // start.ApplyApiPrivileges). Folding the effective bool in self-invalidates caches - // built before the flip (when unset meant auto-expose, keyed as "unset"). - autoExpose := utils.Config.Api.AutoExposeNewTables != nil && *utils.Config.Api.AutoExposeNewTables - fmt.Fprintf(h, "auto_expose_new_tables=%t\n", autoExpose) - // Vault secrets are created during setup; key on their names. - names := make([]string, 0, len(utils.Config.Db.Vault)) - for name := range utils.Config.Db.Vault { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - fmt.Fprintf(h, "vault=%s\n", name) - } - // supabase/roles.sql is seeded into the baseline. - roles, err := afero.ReadFile(fsys, utils.CustomRolesPath) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return "", err - } - if _, err := h.Write(roles); err != nil { - return "", err - } - return hex.EncodeToString(h.Sum(nil))[:12], nil -} - -// baselineCatalogKey derives the cache key for the platform baseline catalog. -// -// Keying only by image would let a stale baseline — produced by a pre-platform- -// baseline CLI, a different image, or different service/api/vault/roles config — be -// reused as the no-migration diff source, leaking spurious objects into generated -// migrations until .temp/pgdelta is cleared. The image token stays as a human- -// readable prefix; old bare-baseline files keyed by the token alone no longer -// match, so they are never reused. -func baselineCatalogKey(fsys afero.Fs) (string, error) { - token, err := setupInputsToken(fsys) - if err != nil { - return "", err - } - return baselineVersionToken() + "-" + token, nil -} - -// baselineCatalogPath returns the on-disk path of the platform baseline catalog -// for the current project inputs. Both the generate writer and the no-migration -// sync reader resolve the path through this helper so they always agree. -func baselineCatalogPath(fsys afero.Fs) (string, error) { - key, err := baselineCatalogKey(fsys) - if err != nil { - return "", err - } - return filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, key)), nil -} - -// declarativeCatalogCacheKey keys the warmed declarative target catalog by both the -// declarative SQL files and the setup inputs. The target is the platform baseline -// plus the declarative schema, so a change to either must invalidate it; otherwise -// sync could pair a freshly keyed source baseline with a target warmed under a -// different setup, emitting platform/config-only differences as user migrations. -func declarativeCatalogCacheKey(fsys afero.Fs) (string, error) { - schemaHash, err := hashDeclarativeSchemas(fsys) - if err != nil { - return "", err - } - setup, err := setupInputsToken(fsys) - if err != nil { - return "", err - } - return setup + "-" + schemaHash, nil -} - -func migrationsCatalogCacheKey(fsys afero.Fs) (string, error) { - migrationsHash, err := hashMigrations(fsys) - if err != nil { - return "", err - } - setup, err := setupInputsToken(fsys) - if err != nil { - return "", err - } - return setup + "-" + migrationsHash, nil -} - -func sanitizedCatalogPrefix(prefix string) string { - prefix = strings.TrimSpace(prefix) - if len(prefix) == 0 { - return "local" - } - return catalogPrefixRegexp.ReplaceAllString(prefix, "-") -} - -func pgDeltaFormatOptions() string { - if utils.Config.Experimental.PgDelta == nil { - return "" - } - return strings.TrimSpace(utils.Config.Experimental.PgDelta.FormatOptions) -} - -func TryCacheMigrationsCatalog(ctx context.Context, config pgconn.Config, prefix string, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - if !shouldCacheMigrationsCatalog() || len(version) > 0 { - return nil - } - if len(strings.TrimSpace(prefix)) == 0 { - prefix = catalogPrefixFromConfig(config) - } - hash, err := hashMigrations(fsys) - if err != nil { - return err - } - snapshot, err := exportCatalog(ctx, utils.ToPostgresURL(config), "postgres", options...) - if err != nil { - return err - } - if err := ensureTempDir(fsys); err != nil { - return err - } - _, err = pgcache.WriteMigrationCatalogSnapshot(fsys, prefix, hash, snapshot) - return err -} - -func shouldCacheMigrationsCatalog() bool { - return pgcache.ShouldCacheMigrationsCatalog() -} - -func catalogPrefixFromConfig(config pgconn.Config) string { - return pgcache.CatalogPrefixFromConfig(config) -} - -// findDropStatements extracts DROP statements for safety warnings shown when -// generating migration output from declarative diffs. -func findDropStatements(out string) []string { - lines, err := parser.SplitAndTrim(strings.NewReader(out)) - if err != nil { - return nil - } - var drops []string - for _, line := range lines { - if dropStatementRegexp.MatchString(line) { - drops = append(drops, line) - } - } - return drops -} - -// bytesContain avoids pulling in bytes package for one containment check while -// keeping config replacement logic readable. -func bytesContain(data, needle []byte) bool { - return strings.Contains(string(data), string(needle)) -} diff --git a/apps/cli-go/internal/db/declarative/declarative_flow_test.go b/apps/cli-go/internal/db/declarative/declarative_flow_test.go deleted file mode 100644 index 3aaf09dca6..0000000000 --- a/apps/cli-go/internal/db/declarative/declarative_flow_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package declarative - -import ( - "context" - "encoding/json" - "sort" - "strings" - "testing" - - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/db/diff" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/config" -) - -// catalogObjects models a pg-delta catalog snapshot as the set of object names -// present in a shadow database, so the full generate -> sync flow can be -// exercised without the real pg-delta runtime while still proving that platform -// objects cancel out of the generated diff. -type catalogObjects struct { - Objects []string `json:"objects"` -} - -func marshalCatalog(objects []string) string { - sorted := append([]string(nil), objects...) - sort.Strings(sorted) - out, _ := json.Marshal(catalogObjects{Objects: sorted}) - return string(out) -} - -func readCatalogObjects(t *testing.T, fsys afero.Fs, path string) []string { - t.Helper() - raw, err := afero.ReadFile(fsys, path) - require.NoError(t, err) - var parsed catalogObjects - require.NoError(t, json.Unmarshal(raw, &parsed)) - return parsed.Objects -} - -// TestGenerateThenSyncWithNoMigrationsCancelsPlatformObjects exercises the full -// generate -> sync (no local migrations) flow end to end through the public -// command functions. The bug it guards: generate writes the baseline catalog -// (catalog-baseline-.json) that sync reuses as its diff source when -// there are no local migrations. If that baseline is captured from a bare image -// instead of the platform baseline, platform-managed objects (auth/storage/ -// realtime) leak into the generated migration even though the user only declared -// a single table. The Docker and pg-delta seams are stubbed (the established -// cli-go pattern) so the test runs in the standard `go test ./...` CI job. -func TestGenerateThenSyncWithNoMigrationsCancelsPlatformObjects(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("[db]\n"), 0644)) - - originalPgDelta := utils.Config.Experimental.PgDelta - originalImage := utils.Config.Db.Image - originalCreateShadow := createShadow - originalSetupShadow := setupShadowDatabase - originalExportCatalog := exportCatalog - originalApplyDeclarative := applyDeclarative - originalExportRef := declarativeExportRef - originalDiffRef := diffPgDeltaRef - t.Cleanup(func() { - utils.Config.Experimental.PgDelta = originalPgDelta - utils.Config.Db.Image = originalImage - createShadow = originalCreateShadow - setupShadowDatabase = originalSetupShadow - exportCatalog = originalExportCatalog - applyDeclarative = originalApplyDeclarative - declarativeExportRef = originalExportRef - diffPgDeltaRef = originalDiffRef - }) - - utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} - utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" - - shadowConfig := pgconn.Config{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"} - // Model the evolving shadow state: platform baseline provisioning adds the - // auth/storage/realtime schemas, declarative apply adds the user's table. - platformReady := false - declarativeApplied := false - platformObjects := []string{"auth", "realtime", "storage"} - const userObject = "public.profiles" - - createShadow = func(_ context.Context) (string, pgconn.Config, error) { - return "test-shadow-container", shadowConfig, nil - } - setupShadowDatabase = func(_ context.Context, _ string, _ afero.Fs, _ ...func(*pgx.ConnConfig)) error { - platformReady = true - return nil - } - applyDeclarative = func(_ context.Context, _ pgconn.Config, _ afero.Fs) error { - declarativeApplied = true - return nil - } - exportCatalog = func(_ context.Context, _ string, role string, _ ...func(*pgx.ConnConfig)) (string, error) { - assert.Equal(t, "postgres", role) - var objects []string - if platformReady { - objects = append(objects, platformObjects...) - } - if declarativeApplied { - objects = append(objects, userObject) - } - return marshalCatalog(objects), nil - } - // generate exports declarative files from the live database; emit a single - // table that depends on auth so WriteDeclarativeSchemas + hashing have content. - declarativeExportRef = func(_ context.Context, _, _ string, _ []string, _ string, _ ...func(*pgx.ConnConfig)) (diff.DeclarativeOutput, error) { - return diff.DeclarativeOutput{ - Files: []diff.DeclarativeFile{ - {Path: "schemas/public/tables/profiles.sql", SQL: "create table public.profiles (id uuid primary key references auth.users(id));"}, - }, - }, nil - } - // Stand in for the pg-delta diff: emit DDL for objects present in the target - // catalog but missing from the source catalog. Platform objects that exist in - // both sides must not appear. - diffPgDeltaRef = func(_ context.Context, sourceRef, targetRef string, _ []string, _ string, _ ...func(*pgx.ConnConfig)) (string, error) { - source := readCatalogObjects(t, fsys, sourceRef) - target := readCatalogObjects(t, fsys, targetRef) - inSource := make(map[string]bool, len(source)) - for _, obj := range source { - inSource[obj] = true - } - var added []string - for _, obj := range target { - if !inSource[obj] { - added = append(added, obj) - } - } - sort.Strings(added) - var sb strings.Builder - for _, obj := range added { - sb.WriteString("create " + obj + ";\n") - } - return sb.String(), nil - } - - liveConfig := pgconn.Config{Host: "db.test.supabase.co", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"} - - // 1. generate writes declarative files and warms the baseline + declarative caches. - require.NoError(t, Generate(t.Context(), nil, liveConfig, true, false, fsys)) - - // The baseline catalog reused by sync must represent the platform baseline, - // not a bare image. - baselinePath, err := baselineCatalogPath(fsys) - require.NoError(t, err) - assert.ElementsMatch(t, platformObjects, readCatalogObjects(t, fsys, baselinePath), - "baseline catalog must capture the platform baseline (auth/storage/realtime)") - - // 2. sync with no local migrations diffs the warmed declarative catalog against - // the baseline. Platform objects exist on both sides, so only the user's table - // should surface in the generated migration. - result, err := DiffDeclarativeToMigrations(t.Context(), nil, false, fsys) - require.NoError(t, err) - assert.Equal(t, baselinePath, result.SourceRef, "no-migration sync must source from the platform baseline catalog") - assert.Contains(t, result.DiffSQL, "public.profiles", "the user's declared table should be generated") - for _, platform := range platformObjects { - assert.NotContains(t, result.DiffSQL, "create "+platform+";", - "platform object %q must cancel out instead of leaking into the migration", platform) - } -} diff --git a/apps/cli-go/internal/db/declarative/declarative_test.go b/apps/cli-go/internal/db/declarative/declarative_test.go deleted file mode 100644 index 093fa6197a..0000000000 --- a/apps/cli-go/internal/db/declarative/declarative_test.go +++ /dev/null @@ -1,721 +0,0 @@ -package declarative - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "path/filepath" - "strings" - "testing" - - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/db/diff" - "github.com/supabase/cli/internal/db/pgcache" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/config" -) - -func TestWriteDeclarativeSchemas(t *testing.T) { - // This verifies the main happy path for declarative export materialization: - // files are written to expected locations and config is updated accordingly. - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("[db]\n"), 0644)) - - output := diff.DeclarativeOutput{ - Files: []diff.DeclarativeFile{ - {Path: "cluster/roles.sql", SQL: "create role app;"}, - {Path: "schemas/public/tables/users.sql", SQL: "create table users(id bigint);"}, - }, - } - - err := WriteDeclarativeSchemas(output, fsys) - require.NoError(t, err) - - roles, err := afero.ReadFile(fsys, filepath.Join(utils.DeclarativeDir, "cluster", "roles.sql")) - require.NoError(t, err) - assert.Equal(t, "create role app;", string(roles)) - - users, err := afero.ReadFile(fsys, filepath.Join(utils.DeclarativeDir, "schemas", "public", "tables", "users.sql")) - require.NoError(t, err) - assert.Equal(t, "create table users(id bigint);", string(users)) - - cfg, err := afero.ReadFile(fsys, utils.ConfigPath) - require.NoError(t, err) - assert.Contains(t, string(cfg), `"schemas"`) -} - -func TestWriteDeclarativeSchemasSkipsConfigUpdateWhenPgDeltaEnabled(t *testing.T) { - fsys := afero.NewMemMapFs() - originalConfig := "[db]\n" - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte(originalConfig), 0644)) - original := utils.Config.Experimental.PgDelta - utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} - t.Cleanup(func() { - utils.Config.Experimental.PgDelta = original - }) - - output := diff.DeclarativeOutput{ - Files: []diff.DeclarativeFile{ - {Path: "schemas/public/tables/users.sql", SQL: "create table users(id bigint);"}, - }, - } - - err := WriteDeclarativeSchemas(output, fsys) - require.NoError(t, err) - - users, err := afero.ReadFile(fsys, filepath.Join(utils.DeclarativeDir, "schemas", "public", "tables", "users.sql")) - require.NoError(t, err) - assert.Equal(t, "create table users(id bigint);", string(users)) - - cfg, err := afero.ReadFile(fsys, utils.ConfigPath) - require.NoError(t, err) - assert.Equal(t, originalConfig, string(cfg)) -} - -func TestTryCacheMigrationsCatalogWritesPrefixedCache(t *testing.T) { - fsys := afero.NewMemMapFs() - original := utils.Config.Experimental.PgDelta - utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} - t.Cleanup(func() { - utils.Config.Experimental.PgDelta = original - exportCatalog = diff.ExportCatalogPgDelta - }) - p := filepath.Join(utils.MigrationsDir, "20240101000000_first.sql") - require.NoError(t, afero.WriteFile(fsys, p, []byte("create table a();"), 0644)) - exportCatalog = func(_ context.Context, targetRef, role string, _ ...func(*pgx.ConnConfig)) (string, error) { - assert.Equal(t, "postgres", role) - assert.Contains(t, targetRef, "db.test.supabase.co") - return `{"version":1}`, nil - } - - err := TryCacheMigrationsCatalog(t.Context(), pgconn.Config{ - Host: "db.test.supabase.co", - Port: 5432, - User: "postgres", - Password: "postgres", - Database: "postgres", - }, "remote-ref", "", fsys) - require.NoError(t, err) - - hash, err := hashMigrations(fsys) - require.NoError(t, err) - cachePath, ok, err := pgcache.ResolveMigrationCatalogPath(fsys, hash, "remote-ref") - require.NoError(t, err) - require.True(t, ok) - cached, err := afero.ReadFile(fsys, cachePath) - require.NoError(t, err) - assert.JSONEq(t, `{"version":1}`, string(cached)) -} - -func TestTryCacheMigrationsCatalogSkipsPartialApply(t *testing.T) { - fsys := afero.NewMemMapFs() - original := utils.Config.Experimental.PgDelta - utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} - called := false - t.Cleanup(func() { - utils.Config.Experimental.PgDelta = original - exportCatalog = diff.ExportCatalogPgDelta - }) - exportCatalog = func(_ context.Context, _ string, _ string, _ ...func(*pgx.ConnConfig)) (string, error) { - called = true - return `{"version":1}`, nil - } - - err := TryCacheMigrationsCatalog(t.Context(), pgconn.Config{ - Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres", - }, "", "20240101000000", fsys) - require.NoError(t, err) - assert.False(t, called) -} - -func TestCatalogPrefixFromConfig(t *testing.T) { - local := catalogPrefixFromConfig(pgconn.Config{Host: utils.Config.Hostname, Port: utils.Config.Db.Port}) - assert.Equal(t, "local", local) - - linked := catalogPrefixFromConfig(pgconn.Config{Host: "db.abcdefghijklmnopqrst.supabase.co", Port: 5432}) - assert.Equal(t, "abcdefghijklmnopqrst", linked) - - custom := catalogPrefixFromConfig(pgconn.Config{Host: "db.example.com", Port: 5432, Database: "postgres", User: "postgres"}) - sum := sha256.Sum256([]byte("postgres@db.example.com:5432/postgres")) - assert.Equal(t, "url-"+hex.EncodeToString(sum[:])[:12], custom) -} - -func TestWriteDeclarativeSchemasUsesConfiguredDir(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("[db]\n"), 0644)) - original := utils.Config.Experimental.PgDelta - utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{ - DeclarativeSchemaPath: filepath.Join(utils.SupabaseDirPath, "db", "decl"), - } - t.Cleanup(func() { - utils.Config.Experimental.PgDelta = original - }) - - output := diff.DeclarativeOutput{ - Files: []diff.DeclarativeFile{ - {Path: "cluster/roles.sql", SQL: "create role app;"}, - }, - } - - err := WriteDeclarativeSchemas(output, fsys) - require.NoError(t, err) - - rolesPath := filepath.Join(utils.SupabaseDirPath, "db", "decl", "cluster", "roles.sql") - roles, err := afero.ReadFile(fsys, rolesPath) - require.NoError(t, err) - assert.Equal(t, "create role app;", string(roles)) - - cfg, err := afero.ReadFile(fsys, utils.ConfigPath) - require.NoError(t, err) - assert.Contains(t, string(cfg), `db/decl`) -} - -func TestWriteDeclarativeSchemasSkipsConfigUpdateForPgDeltaCustomDir(t *testing.T) { - fsys := afero.NewMemMapFs() - originalConfig := "[db]\n" - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte(originalConfig), 0644)) - original := utils.Config.Experimental.PgDelta - utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{ - Enabled: true, - DeclarativeSchemaPath: filepath.Join(utils.SupabaseDirPath, "db", "decl"), - } - t.Cleanup(func() { - utils.Config.Experimental.PgDelta = original - }) - - output := diff.DeclarativeOutput{ - Files: []diff.DeclarativeFile{ - {Path: "cluster/roles.sql", SQL: "create role app;"}, - }, - } - - err := WriteDeclarativeSchemas(output, fsys) - require.NoError(t, err) - - rolesPath := filepath.Join(utils.SupabaseDirPath, "db", "decl", "cluster", "roles.sql") - roles, err := afero.ReadFile(fsys, rolesPath) - require.NoError(t, err) - assert.Equal(t, "create role app;", string(roles)) - - cfg, err := afero.ReadFile(fsys, utils.ConfigPath) - require.NoError(t, err) - assert.Equal(t, originalConfig, string(cfg)) -} - -func TestWriteDeclarativeSchemasRejectsUnsafePath(t *testing.T) { - // Export paths must stay within supabase/declarative to prevent traversal. - fsys := afero.NewMemMapFs() - err := WriteDeclarativeSchemas(diff.DeclarativeOutput{ - Files: []diff.DeclarativeFile{ - {Path: "../oops.sql", SQL: "select 1;"}, - }, - }, fsys) - assert.ErrorContains(t, err, "unsafe declarative export path") -} - -func TestHashMigrationsChangesWithContent(t *testing.T) { - // Cache keys must change whenever migration SQL changes. - fsys := afero.NewMemMapFs() - p1 := filepath.Join(utils.MigrationsDir, "20240101000000_first.sql") - p2 := filepath.Join(utils.MigrationsDir, "20240101000001_second.sql") - require.NoError(t, afero.WriteFile(fsys, p1, []byte("create table a();"), 0644)) - require.NoError(t, afero.WriteFile(fsys, p2, []byte("create table b();"), 0644)) - - h1, err := hashMigrations(fsys) - require.NoError(t, err) - require.NotEmpty(t, h1) - - require.NoError(t, afero.WriteFile(fsys, p2, []byte("create table b(id bigint);"), 0644)) - h2, err := hashMigrations(fsys) - require.NoError(t, err) - - assert.NotEqual(t, h1, h2) -} - -func TestGetMigrationsCatalogRefUsesCache(t *testing.T) { - // When a matching hash snapshot exists, catalog generation should be skipped. - fsys := afero.NewMemMapFs() - p := filepath.Join(utils.MigrationsDir, "20240101000000_first.sql") - require.NoError(t, afero.WriteFile(fsys, p, []byte("create table a();"), 0644)) - legacyHash, err := hashMigrations(fsys) - require.NoError(t, err) - stalePath := filepath.Join(utils.TempDir, "pgdelta", "catalog-local-migrations-"+legacyHash+"-1000.json") - require.NoError(t, afero.WriteFile(fsys, stalePath, []byte(`{"version":"stale"}`), 0644)) - - hash, err := migrationsCatalogCacheKey(fsys) - require.NoError(t, err) - cachePath := filepath.Join(utils.TempDir, "pgdelta", "catalog-local-migrations-"+hash+"-1000.json") - require.NoError(t, afero.WriteFile(fsys, cachePath, []byte(`{"version":1}`), 0644)) - - ref, err := getMigrationsCatalogRef(t.Context(), false, fsys, "local") - require.NoError(t, err) - assert.Equal(t, cachePath, ref) - assert.NotEqual(t, stalePath, ref) -} - -func TestGetMigrationsCatalogRefUsesProjectPrefix(t *testing.T) { - fsys := afero.NewMemMapFs() - p := filepath.Join(utils.MigrationsDir, "20240101000000_first.sql") - require.NoError(t, afero.WriteFile(fsys, p, []byte("create table a();"), 0644)) - hash, err := migrationsCatalogCacheKey(fsys) - require.NoError(t, err) - - cachePath := filepath.Join(utils.TempDir, "pgdelta", "catalog-testproject-migrations-"+hash+"-1000.json") - require.NoError(t, afero.WriteFile(fsys, cachePath, []byte(`{"version":1}`), 0644)) - - ref, err := getMigrationsCatalogRef(t.Context(), false, fsys, "testproject") - require.NoError(t, err) - assert.Equal(t, cachePath, ref) -} - -func TestGetMigrationsCatalogRefUsesBaselineWhenNoMigrations(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, fsys.MkdirAll(filepath.Join(utils.TempDir, "pgdelta"), 0755)) - baselinePath, err := baselineCatalogPath(fsys) - require.NoError(t, err) - require.NoError(t, afero.WriteFile(fsys, baselinePath, []byte(`{"version":1}`), 0644)) - - ref, err := getMigrationsCatalogRef(t.Context(), false, fsys, "local") - require.NoError(t, err) - assert.Equal(t, baselinePath, ref) -} - -func TestGetGenerateBaselineCatalogRefSetsUpPlatformBaseline(t *testing.T) { - // The baseline catalog is reused as the diff source for sync-with-no-migrations - // (getMigrationsCatalogRef). Since the declarative target now provisions the - // Supabase platform baseline, the baseline catalog must represent the same - // platform baseline (not the empty image) so platform objects cancel out of the - // diff instead of surfacing as spurious additions. Assert setup runs before the - // catalog is exported. - fsys := afero.NewMemMapFs() - require.NoError(t, fsys.MkdirAll(filepath.Join(utils.TempDir, "pgdelta"), 0755)) - - originalCreateShadow := createShadow - originalSetupShadow := setupShadowDatabase - originalExportCatalog := exportCatalog - t.Cleanup(func() { - createShadow = originalCreateShadow - setupShadowDatabase = originalSetupShadow - exportCatalog = originalExportCatalog - }) - - shadowConfig := pgconn.Config{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"} - createShadow = func(_ context.Context) (string, pgconn.Config, error) { - return "test-shadow-container", shadowConfig, nil - } - var order []string - setupShadowDatabase = func(_ context.Context, container string, _ afero.Fs, _ ...func(*pgx.ConnConfig)) error { - assert.Equal(t, "test-shadow-container", container) - order = append(order, "setup") - return nil - } - exportCatalog = func(_ context.Context, _ string, role string, _ ...func(*pgx.ConnConfig)) (string, error) { - assert.Equal(t, "postgres", role) - order = append(order, "export") - return `{"version":1}`, nil - } - - ref, err := getGenerateBaselineCatalogRef(t.Context(), false, fsys) - require.NoError(t, err) - assert.Equal(t, []string{"setup", "export"}, order, "platform baseline must be provisioned before the baseline catalog is exported") - - cachePath, err := baselineCatalogPath(fsys) - require.NoError(t, err) - assert.Equal(t, cachePath, ref.ref) - cached, err := afero.ReadFile(fsys, cachePath) - require.NoError(t, err) - assert.JSONEq(t, `{"version":1}`, string(cached)) -} - -func TestHashDeclarativeSchemasChangesWithContent(t *testing.T) { - fsys := afero.NewMemMapFs() - p1 := filepath.Join(utils.GetDeclarativeDir(), "schemas", "public", "tables", "a.sql") - p2 := filepath.Join(utils.GetDeclarativeDir(), "schemas", "public", "tables", "b.sql") - require.NoError(t, afero.WriteFile(fsys, p1, []byte("create table a();"), 0644)) - require.NoError(t, afero.WriteFile(fsys, p2, []byte("create table b();"), 0644)) - - h1, err := hashDeclarativeSchemas(fsys) - require.NoError(t, err) - require.NotEmpty(t, h1) - - require.NoError(t, afero.WriteFile(fsys, p2, []byte("create table b(id bigint);"), 0644)) - h2, err := hashDeclarativeSchemas(fsys) - require.NoError(t, err) - assert.NotEqual(t, h1, h2) -} - -func TestResolveDeclarativeCatalogPathUsesLatestTimestamp(t *testing.T) { - fsys := afero.NewMemMapFs() - temp := filepath.Join(utils.TempDir, "pgdelta") - require.NoError(t, fsys.MkdirAll(temp, 0755)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(temp, "catalog-local-declarative-hash-1000.json"), []byte("{}"), 0644)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(temp, "catalog-local-declarative-hash-2000.json"), []byte("{}"), 0644)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(temp, "catalog-local-declarative-hash-3000.json"), []byte("{}"), 0644)) - - path, ok, err := resolveDeclarativeCatalogPath(fsys, "hash", "local") - require.NoError(t, err) - require.True(t, ok) - assert.Equal(t, filepath.Join(temp, "catalog-local-declarative-hash-3000.json"), path) -} - -func TestCleanupOldDeclarativeCatalogsKeepsLatestTwo(t *testing.T) { - fsys := afero.NewMemMapFs() - temp := filepath.Join(utils.TempDir, "pgdelta") - require.NoError(t, fsys.MkdirAll(temp, 0755)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(temp, "catalog-local-declarative-h1-1000.json"), []byte("{}"), 0644)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(temp, "catalog-local-declarative-h2-2000.json"), []byte("{}"), 0644)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(temp, "catalog-local-declarative-h3-3000.json"), []byte("{}"), 0644)) - require.NoError(t, cleanupOldDeclarativeCatalogs(fsys, "local")) - - ok, err := afero.Exists(fsys, filepath.Join(temp, "catalog-local-declarative-h1-1000.json")) - require.NoError(t, err) - assert.False(t, ok) - - ok, err = afero.Exists(fsys, filepath.Join(temp, "catalog-local-declarative-h2-2000.json")) - require.NoError(t, err) - assert.True(t, ok) - - ok, err = afero.Exists(fsys, filepath.Join(temp, "catalog-local-declarative-h3-3000.json")) - require.NoError(t, err) - assert.True(t, ok) -} - -func TestBaselineCatalogKeyVariesWithSetupInputs(t *testing.T) { - // The baseline is produced by SetupDatabase, so its cache key must change when - // any setup input changes; otherwise a stale baseline is reused as the diff - // source and platform/config changes leak into generated migrations. - originalImage := utils.Config.Db.Image - originalExpose := utils.Config.Api.AutoExposeNewTables - originalVault := utils.Config.Db.Vault - t.Cleanup(func() { - utils.Config.Db.Image = originalImage - utils.Config.Api.AutoExposeNewTables = originalExpose - utils.Config.Db.Vault = originalVault - }) - utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" - utils.Config.Api.AutoExposeNewTables = nil - utils.Config.Db.Vault = nil - - fsys := afero.NewMemMapFs() - base, err := baselineCatalogKey(fsys) - require.NoError(t, err) - assert.True(t, strings.HasPrefix(base, baselineVersionToken()+"-"), "image token should remain a readable prefix") - - require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte("create role app;"), 0644)) - withRoles, err := baselineCatalogKey(fsys) - require.NoError(t, err) - assert.NotEqual(t, base, withRoles, "roles.sql content must change the key") - - // withRoles was computed with the flag unset, which resolves to the revoke-by-default - // baseline (same as explicit false). Explicit true is the auto-expose baseline, so it - // must produce a different key. - expose := true - utils.Config.Api.AutoExposeNewTables = &expose - withApi, err := baselineCatalogKey(fsys) - require.NoError(t, err) - assert.NotEqual(t, withRoles, withApi, "auto_expose_new_tables must change the key") - - utils.Config.Db.Vault = map[string]config.Secret{"KEY": {}} - withVault, err := baselineCatalogKey(fsys) - require.NoError(t, err) - assert.NotEqual(t, withApi, withVault, "vault secrets must change the key") -} - -func TestBaselineCatalogKeyTreatsUnsetExposeAsFalse(t *testing.T) { - // As of the 2026-05-30 flip, an unset auto_expose_new_tables resolves to the same - // revoke-by-default baseline as explicit false, so the cache key must match. This also - // busts caches built before the flip, which keyed the unset case as a distinct token. - originalExpose := utils.Config.Api.AutoExposeNewTables - t.Cleanup(func() { - utils.Config.Api.AutoExposeNewTables = originalExpose - }) - fsys := afero.NewMemMapFs() - - utils.Config.Api.AutoExposeNewTables = nil - unset, err := baselineCatalogKey(fsys) - require.NoError(t, err) - - expose := false - utils.Config.Api.AutoExposeNewTables = &expose - explicitFalse, err := baselineCatalogKey(fsys) - require.NoError(t, err) - - assert.Equal(t, unset, explicitFalse, "unset must key identically to explicit false") -} - -func TestBaselineCatalogKeyVariesWithServiceToggles(t *testing.T) { - // initSchema conditionally provisions auth/storage/realtime schemas, so toggling - // a service must invalidate the baseline cache even on the same image. - originalImage := utils.Config.Db.Image - originalStorage := utils.Config.Storage.Enabled - t.Cleanup(func() { - utils.Config.Db.Image = originalImage - utils.Config.Storage.Enabled = originalStorage - }) - utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" - - fsys := afero.NewMemMapFs() - utils.Config.Storage.Enabled = true - on, err := baselineCatalogKey(fsys) - require.NoError(t, err) - utils.Config.Storage.Enabled = false - off, err := baselineCatalogKey(fsys) - require.NoError(t, err) - assert.NotEqual(t, on, off, "toggling a service must change the baseline cache key") -} - -func TestDeclarativeCatalogCacheKeyVariesWithSetupInputs(t *testing.T) { - // The declarative target is built on the platform baseline, so its cache key - // must change when setup inputs change even if the declarative SQL does not. - originalImage := utils.Config.Db.Image - originalStorage := utils.Config.Storage.Enabled - t.Cleanup(func() { - utils.Config.Db.Image = originalImage - utils.Config.Storage.Enabled = originalStorage - }) - utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" - - fsys := afero.NewMemMapFs() - p := filepath.Join(utils.GetDeclarativeDir(), "schemas", "public", "tables", "a.sql") - require.NoError(t, afero.WriteFile(fsys, p, []byte("create table a();"), 0644)) - - utils.Config.Storage.Enabled = true - on, err := declarativeCatalogCacheKey(fsys) - require.NoError(t, err) - utils.Config.Storage.Enabled = false - off, err := declarativeCatalogCacheKey(fsys) - require.NoError(t, err) - assert.NotEqual(t, on, off, "setup input changes must invalidate the warmed declarative catalog") -} - -func TestGetMigrationsCatalogRefZeroMigrationsIgnoresMigrationsHashCache(t *testing.T) { - // With no local migrations, the source must come from the setup-keyed baseline, - // not the migrations-hash cache (which is not setup-aware and could otherwise - // surface an empty-migrations snapshot from a different platform setup). - originalImage := utils.Config.Db.Image - t.Cleanup(func() { utils.Config.Db.Image = originalImage }) - utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" - - fsys := afero.NewMemMapFs() - require.NoError(t, fsys.MkdirAll(pgDeltaTempPath(), 0755)) - - // A stale empty-migrations catalog in the migrations-hash cache. - emptyHash, err := pgcache.HashMigrations(fsys) - require.NoError(t, err) - stale := filepath.Join(pgDeltaTempPath(), "catalog-local-migrations-"+emptyHash+"-1000.json") - require.NoError(t, afero.WriteFile(fsys, stale, []byte(`{"objects":["stale"]}`), 0644)) - - // A baseline catalog for the current setup key. - baselinePath, err := baselineCatalogPath(fsys) - require.NoError(t, err) - require.NoError(t, afero.WriteFile(fsys, baselinePath, []byte(`{"objects":[]}`), 0644)) - - ref, err := getMigrationsCatalogRef(t.Context(), false, fsys, "local") - require.NoError(t, err) - assert.Equal(t, baselinePath, ref, "zero-migration source must be the setup-keyed baseline") - assert.NotEqual(t, stale, ref, "the non-setup-aware migrations-hash cache must not be reused") -} - -func TestBaselineCatalogPathIgnoresLegacyBareBaseline(t *testing.T) { - // A baseline written by a pre-fix CLI is keyed by the image token alone and - // holds a bare-image catalog. The input-hashed key must not collide with it, so - // no-migration sync never reuses the stale snapshot. - originalImage := utils.Config.Db.Image - t.Cleanup(func() { utils.Config.Db.Image = originalImage }) - utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" - - fsys := afero.NewMemMapFs() - require.NoError(t, fsys.MkdirAll(pgDeltaTempPath(), 0755)) - legacy := filepath.Join(pgDeltaTempPath(), "catalog-baseline-"+baselineVersionToken()+".json") - require.NoError(t, afero.WriteFile(fsys, legacy, []byte(`{"objects":[]}`), 0644)) - - current, err := baselineCatalogPath(fsys) - require.NoError(t, err) - assert.NotEqual(t, legacy, current, "input-hashed key must not collide with the legacy bare-baseline filename") - exists, err := afero.Exists(fsys, current) - require.NoError(t, err) - assert.False(t, exists, "stale bare baseline must not satisfy the current cache key") -} - -func TestBaselineVersionToken(t *testing.T) { - originalImage := utils.Config.Db.Image - originalMajor := utils.Config.Db.MajorVersion - t.Cleanup(func() { - utils.Config.Db.Image = originalImage - utils.Config.Db.MajorVersion = originalMajor - }) - - utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" - assert.Equal(t, "15.8.1.049", baselineVersionToken()) - - utils.Config.Db.Image = "" - utils.Config.Db.MajorVersion = 17 - assert.Equal(t, "pg17", baselineVersionToken()) -} - -func TestGenerateWarmsDeclarativeCatalogCache(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("[db]\n"), 0644)) - require.NoError(t, fsys.MkdirAll(filepath.Join(utils.TempDir, "pgdelta"), 0755)) - baselinePath, err := baselineCatalogPath(fsys) - require.NoError(t, err) - require.NoError(t, afero.WriteFile(fsys, baselinePath, []byte(`{"version":1}`), 0644)) - - originalPgDelta := utils.Config.Experimental.PgDelta - utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} - originalExportRef := declarativeExportRef - originalBaselineResolver := generateBaselineCatalogRefResolver - originalResolver := declarativeCatalogRefResolver - t.Cleanup(func() { - utils.Config.Experimental.PgDelta = originalPgDelta - declarativeExportRef = originalExportRef - generateBaselineCatalogRefResolver = originalBaselineResolver - declarativeCatalogRefResolver = originalResolver - }) - generateBaselineCatalogRefResolver = func(_ context.Context, _ bool, _ afero.Fs, _ ...func(*pgx.ConnConfig)) (generateBaselineCatalogRef, error) { - return generateBaselineCatalogRef{ref: baselinePath}, nil - } - - declarativeExportRef = func(_ context.Context, sourceRef, _ string, _ []string, _ string, _ ...func(*pgx.ConnConfig)) (diff.DeclarativeOutput, error) { - assert.Equal(t, baselinePath, sourceRef) - return diff.DeclarativeOutput{ - Files: []diff.DeclarativeFile{ - {Path: "cluster/roles.sql", SQL: "create role app;"}, - }, - }, nil - } - called := false - declarativeCatalogRefResolver = func(_ context.Context, noCache bool, _ afero.Fs, _ ...func(*pgx.ConnConfig)) (string, error) { - assert.False(t, noCache) - called = true - return filepath.Join(utils.TempDir, "pgdelta", "catalog-local-declarative-hash-1000.json"), nil - } - - err = Generate(t.Context(), nil, pgconn.Config{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"}, true, false, fsys) - require.NoError(t, err) - assert.True(t, called) -} - -func TestGenerateNoCacheSkipsDeclarativeCatalogWarmup(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("[db]\n"), 0644)) - require.NoError(t, fsys.MkdirAll(filepath.Join(utils.TempDir, "pgdelta"), 0755)) - - originalPgDelta := utils.Config.Experimental.PgDelta - utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} - originalExportRef := declarativeExportRef - originalBaselineResolver := generateBaselineCatalogRefResolver - originalResolver := declarativeCatalogRefResolver - t.Cleanup(func() { - utils.Config.Experimental.PgDelta = originalPgDelta - declarativeExportRef = originalExportRef - generateBaselineCatalogRefResolver = originalBaselineResolver - declarativeCatalogRefResolver = originalResolver - }) - generateBaselineCatalogRefResolver = func(_ context.Context, _ bool, _ afero.Fs, _ ...func(*pgx.ConnConfig)) (generateBaselineCatalogRef, error) { - return generateBaselineCatalogRef{ref: filepath.Join(utils.TempDir, "pgdelta", "catalog-baseline-test.json")}, nil - } - - declarativeExportRef = func(_ context.Context, _, _ string, _ []string, _ string, _ ...func(*pgx.ConnConfig)) (diff.DeclarativeOutput, error) { - return diff.DeclarativeOutput{ - Files: []diff.DeclarativeFile{ - {Path: "cluster/roles.sql", SQL: "create role app;"}, - }, - }, nil - } - declarativeCatalogRefResolver = func(_ context.Context, _ bool, _ afero.Fs, _ ...func(*pgx.ConnConfig)) (string, error) { - return "", assert.AnError - } - - err := Generate(t.Context(), nil, pgconn.Config{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"}, true, true, fsys) - require.NoError(t, err) -} - -func TestGenerateReusesBaselineShadowForDeclarativeWarmup(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("[db]\n"), 0644)) - require.NoError(t, fsys.MkdirAll(filepath.Join(utils.TempDir, "pgdelta"), 0755)) - - originalPgDelta := utils.Config.Experimental.PgDelta - utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} - originalExportRef := declarativeExportRef - originalBaselineResolver := generateBaselineCatalogRefResolver - originalResolver := declarativeCatalogRefResolver - originalApplyDeclarative := applyDeclarative - originalExportCatalog := exportCatalog - originalSetupShadow := setupShadowDatabase - t.Cleanup(func() { - utils.Config.Experimental.PgDelta = originalPgDelta - declarativeExportRef = originalExportRef - generateBaselineCatalogRefResolver = originalBaselineResolver - declarativeCatalogRefResolver = originalResolver - applyDeclarative = originalApplyDeclarative - exportCatalog = originalExportCatalog - setupShadowDatabase = originalSetupShadow - }) - - const baselinePath = ".temp/pgdelta/catalog-baseline-test.json" - const shadowContainer = "test-shadow-container" - shadowConfig := pgconn.Config{ - Host: "127.0.0.1", - Port: 5432, - User: "postgres", - Password: "postgres", - Database: "postgres", - } - generateBaselineCatalogRefResolver = func(_ context.Context, _ bool, _ afero.Fs, _ ...func(*pgx.ConnConfig)) (generateBaselineCatalogRef, error) { - return generateBaselineCatalogRef{ - ref: baselinePath, - shadow: &shadowSession{ - container: shadowContainer, - config: shadowConfig, - }, - }, nil - } - setupCalled := false - setupShadowDatabase = func(_ context.Context, _ string, _ afero.Fs, _ ...func(*pgx.ConnConfig)) error { - setupCalled = true - return nil - } - declarativeExportRef = func(_ context.Context, sourceRef, _ string, _ []string, _ string, _ ...func(*pgx.ConnConfig)) (diff.DeclarativeOutput, error) { - assert.Equal(t, baselinePath, sourceRef) - return diff.DeclarativeOutput{ - Files: []diff.DeclarativeFile{ - {Path: "cluster/roles.sql", SQL: "create role app;"}, - }, - }, nil - } - fallbackCalled := false - declarativeCatalogRefResolver = func(_ context.Context, _ bool, _ afero.Fs, _ ...func(*pgx.ConnConfig)) (string, error) { - fallbackCalled = true - return "", nil - } - applyCalled := false - applyDeclarative = func(_ context.Context, config pgconn.Config, _ afero.Fs) error { - applyCalled = true - assert.Equal(t, shadowConfig.Host, config.Host) - assert.Equal(t, shadowConfig.Port, config.Port) - return nil - } - exportCatalog = func(_ context.Context, _ string, role string, _ ...func(*pgx.ConnConfig)) (string, error) { - assert.Equal(t, "postgres", role) - return `{"version":1}`, nil - } - - err := Generate(t.Context(), nil, pgconn.Config{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"}, true, false, fsys) - require.NoError(t, err) - assert.False(t, setupCalled, "generate must not re-run platform setup on the reused shadow; the baseline resolver already provisioned it") - assert.True(t, applyCalled, "generate should apply declarative schema using reused baseline shadow") - assert.False(t, fallbackCalled, "fallback declarative resolver should not run when baseline shadow is reusable") - - hash, err := declarativeCatalogCacheKey(fsys) - require.NoError(t, err) - cachePath, ok, err := resolveDeclarativeCatalogPath(fsys, hash, "local") - require.NoError(t, err) - require.True(t, ok) - assert.NotEmpty(t, cachePath) -} diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index 32fabb2c21..f4c05fb801 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -22,6 +22,7 @@ import ( "github.com/jackc/pgx/v4" "github.com/spf13/afero" "github.com/supabase/cli/internal/db/start" + "github.com/supabase/cli/internal/migration/new" "github.com/supabase/cli/internal/utils" configpkg "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/migration" @@ -30,8 +31,13 @@ import ( type DiffFunc func(context.Context, pgconn.Config, pgconn.Config, []string, ...func(*pgx.ConnConfig)) (string, error) -func Run(ctx context.Context, schema []string, file string, config pgconn.Config, differ DiffFunc, usePgDelta bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (err error) { - result, err := DiffDatabase(ctx, schema, config, os.Stderr, fsys, differ, usePgDelta, options...) +// DatabaseDiff is the result of diffing a target database against a shadow baseline. +type DatabaseDiff struct { + SQL string +} + +func Run(ctx context.Context, schema []string, file string, config pgconn.Config, differ DiffFunc, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (err error) { + result, err := DiffDatabase(ctx, schema, config, os.Stderr, fsys, differ, options...) if err != nil { return err } @@ -100,22 +106,23 @@ func loadDeclaredSchemas(fsys afero.Fs) ([]string, error) { return declared, nil } -func shouldApplyDeclarativeWithPgDelta(usePgDelta bool) bool { - if !usePgDelta { - return false - } - schemas := utils.Config.Db.Migrations.SchemaPaths - if len(schemas) == 0 { - return true - } - if len(schemas) != 1 { - return false - } - return cleanSchemaPath(schemas[0]) == cleanSchemaPath(utils.GetDeclarativeDir()) -} +var warnDiff = `WARNING: The diff tool is not foolproof, so you may need to manually rearrange and modify the generated migration. +Run ` + utils.Aqua("supabase db reset") + ` to verify that the new migration does not generate errors.` -func cleanSchemaPath(path string) string { - return filepath.ToSlash(filepath.Clean(path)) +func SaveDiff(result DatabaseDiff, file string, fsys afero.Fs) error { + out := result.SQL + if len(out) < 2 { + fmt.Fprintln(os.Stderr, "No schema changes found") + } else if len(file) > 0 { + path := new.GetMigrationPath(utils.GetCurrentTimestamp(), file) + if err := utils.WriteFile(path, []byte(out), fsys); err != nil { + return err + } + fmt.Fprintln(os.Stderr, warnDiff) + } else { + fmt.Println(out) + } + return nil } // https://github.com/djrobstep/migra/blob/master/migra/statements.py#L6 @@ -208,9 +215,9 @@ func MigrateShadowDatabase(ctx context.Context, container string, fsys afero.Fs, return migration.ApplyMigrations(ctx, migrations, conn, afero.NewIOFS(fsys)) } -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) { +func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w io.Writer, fsys afero.Fs, differ DiffFunc, options ...func(*pgx.ConnConfig)) (DatabaseDiff, error) { fmt.Fprintln(w, "Creating shadow database...") - shadowSource, err := PrepareShadowSource(ctx, schema, utils.IsLocalDatabase(config), usePgDelta, fsys, options...) + shadowSource, err := PrepareShadowSource(ctx, utils.IsLocalDatabase(config), fsys, options...) if err != nil { return DatabaseDiff{}, err } @@ -219,38 +226,11 @@ func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w if shadowSource.TargetOverride != nil { config = *shadowSource.TargetOverride } - // Load all user defined schemas if len(schema) > 0 { fmt.Fprintln(w, "Diffing schemas:", strings.Join(schema, ",")) } else { fmt.Fprintln(w, "Diffing schemas...") } - if usePgDelta { - // pg-delta always goes through the diffPgDeltaRefDetailed seam so callers get - // the execution-aware per-unit files (db pull writes one migration file each); - // db diff/declarative flatten them back via SQL. This mirrors the config-based - // differ (DiffPgDelta) exactly, so it is safe to bypass the injected differ() - // here — differ() remains the migra engine path below. - var debugCapture *PgDeltaDebugCapture - if IsPgDeltaDebugEnabled() { - // Capture the shadow baseline catalog and edge-runtime stderr so an - // empty diff can be inspected later. - debugCapture = &PgDeltaDebugCapture{} - if snapshot, exportErr := exportCatalogPgDelta(ctx, utils.ToPostgresURL(shadowConfig), "postgres", options...); exportErr == nil { - debugCapture.SourceCatalog = snapshot - } else { - fmt.Fprintf(w, "Warning: failed to export shadow pg-delta catalog: %v\n", exportErr) - } - } - result, err := diffPgDeltaRefDetailed(ctx, utils.ToPostgresURL(shadowConfig), utils.ToPostgresURL(config), schema, pgDeltaFormatOptions(), options...) - if err != nil { - return DatabaseDiff{}, err - } - if debugCapture != nil { - debugCapture.Stderr = result.Stderr - } - return DatabaseDiff{SQL: joinPgDeltaFiles(result.Files), Files: result.Files, Debug: debugCapture}, nil - } output, err := differ(ctx, shadowConfig, config, schema, options...) if err != nil { return DatabaseDiff{}, err diff --git a/apps/cli-go/internal/db/diff/diff_test.go b/apps/cli-go/internal/db/diff/diff_test.go index aff3242699..af0cacf610 100644 --- a/apps/cli-go/internal/db/diff/diff_test.go +++ b/apps/cli-go/internal/db/diff/diff_test.go @@ -82,35 +82,6 @@ func TestLoadDeclaredSchemas(t *testing.T) { }) } -func TestShouldApplyDeclarativeWithPgDelta(t *testing.T) { - t.Run("uses pg-delta declarative apply when no schema_paths override is configured", func(t *testing.T) { - originalConfig := utils.Config - t.Cleanup(func() { utils.Config = originalConfig }) - utils.Config.Db.Migrations.SchemaPaths = nil - - assert.True(t, shouldApplyDeclarativeWithPgDelta(true)) - }) - - t.Run("uses pg-delta declarative apply when schema_paths points at the declarative dir", func(t *testing.T) { - originalConfig := utils.Config - t.Cleanup(func() { utils.Config = originalConfig }) - utils.Config.Db.Migrations.SchemaPaths = pkgconfig.Glob{utils.DeclarativeDir + "/"} - - assert.True(t, shouldApplyDeclarativeWithPgDelta(true)) - }) - - t.Run("uses ordered migration apply for explicit schema_paths files", func(t *testing.T) { - originalConfig := utils.Config - t.Cleanup(func() { utils.Config = originalConfig }) - utils.Config.Db.Migrations.SchemaPaths = pkgconfig.Glob{ - "supabase/schemas/z_function.sql", - "supabase/schemas/a_table.sql", - } - - assert.False(t, shouldApplyDeclarativeWithPgDelta(true)) - }) -} - func TestRun(t *testing.T) { t.Run("runs migra diff", func(t *testing.T) { // Setup in-memory fs @@ -149,7 +120,7 @@ func TestRun(t *testing.T) { Reply("CREATE DATABASE") defer conn.Close(t) // Run test - err := Run(context.Background(), []string{"public"}, "file", dbConfig, DiffSchemaMigra, false, fsys, func(cc *pgx.ConnConfig) { + err := Run(context.Background(), []string{"public"}, "file", dbConfig, DiffSchemaMigra, fsys, func(cc *pgx.ConnConfig) { if cc.Host == dbConfig.Host { // Fake a SSL error when connecting to target database cc.LookupFunc = func(ctx context.Context, host string) (addrs []string, err error) { @@ -173,102 +144,6 @@ func TestRun(t *testing.T) { assert.Equal(t, []byte(diff), contents) }) - t.Run("applies schema_paths in order before saving generated diff", func(t *testing.T) { - originalConfig := utils.Config - t.Cleanup(func() { utils.Config = originalConfig }) - utils.Config.Db.MajorVersion = 14 - utils.Config.Db.ShadowPort = 54320 - utils.Config.Db.Migrations.SchemaPaths = pkgconfig.Glob{ - "supabase/schemas/z_function.sql", - "supabase/schemas/a_table.sql", - } - utils.Config.Experimental.PgDelta = &pkgconfig.PgDeltaConfig{ - Enabled: true, - DeclarativeSchemaPath: utils.SchemasDir, - } - utils.GlobalsSql = "create schema public" - utils.InitialSchemaPg14Sql = "create schema private" - functionSQL := "create function public.z_function() returns integer language sql as $$ select 1 $$" - tableSQL := "create table public.a_table (id integer default public.z_function())" - generated := functionSQL + ";\n" + tableSQL + ";\n" - fsys := afero.NewMemMapFs() - require.NoError(t, afero.WriteFile(fsys, "supabase/schemas/a_table.sql", []byte(tableSQL), 0644)) - require.NoError(t, afero.WriteFile(fsys, "supabase/schemas/z_function.sql", []byte(functionSQL), 0644)) - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Db.Image), "test-shadow-db") - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db/json"). - Reply(http.StatusOK). - JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ - State: &container.State{ - Running: true, - Health: &container.Health{Status: types.Healthy}, - }, - }}) - gock.New(utils.Docker.DaemonHost()). - Delete("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db"). - Reply(http.StatusOK) - shadowConn := pgtest.NewConn() - defer shadowConn.Close(t) - shadowConn.Query(utils.GlobalsSql). - Reply("CREATE SCHEMA"). - Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") - helper.MockApiPrivilegesRevoke(shadowConn). - Query(CREATE_TEMPLATE). - Reply("CREATE DATABASE") - declaredConn := pgtest.NewConn() - defer declaredConn.Close(t) - declaredConn.Query(functionSQL). - Reply("CREATE FUNCTION"). - Query(tableSQL). - Reply("CREATE TABLE") - // pg-delta bypasses the injected DiffFunc and runs the real edge-runtime - // pipeline, so stub the seam DiffDatabase uses (mirrors exportCatalogPgDelta). - // The migra differ must never be reached on this path. - originalDiffPgDelta := diffPgDeltaRefDetailed - t.Cleanup(func() { diffPgDeltaRefDetailed = originalDiffPgDelta }) - diffCalled := false - diffPgDeltaRefDetailed = func(_ context.Context, _, targetRef string, schema []string, _ string, _ ...func(*pgx.ConnConfig)) (PgDeltaDiffResult, error) { - diffCalled = true - assert.Contains(t, targetRef, "contrib_regression") - assert.Equal(t, []string{"public"}, schema) - return PgDeltaDiffResult{ - Files: []PgDeltaPlanFile{{Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: generated}}, - }, nil - } - differ := func(context.Context, pgconn.Config, pgconn.Config, []string, ...func(*pgx.ConnConfig)) (string, error) { - t.Fatal("migra differ must not be called on the pg-delta path") - return "", nil - } - localConfig := pgconn.Config{ - Host: utils.Config.Hostname, - Port: utils.Config.Db.Port, - User: "postgres", - Password: utils.Config.Db.Password, - Database: "postgres", - } - - err := Run(context.Background(), []string{"public"}, "ordered_schema", localConfig, differ, true, fsys, func(cc *pgx.ConnConfig) { - if cc.Database == "contrib_regression" { - declaredConn.Intercept(cc) - } else { - shadowConn.Intercept(cc) - } - }) - - require.NoError(t, err) - assert.True(t, diffCalled) - assert.Empty(t, apitest.ListUnmatchedRequests()) - files, err := afero.ReadDir(fsys, utils.MigrationsDir) - require.NoError(t, err) - require.Len(t, files, 1) - contents, err := afero.ReadFile(fsys, filepath.Join(utils.MigrationsDir, files[0].Name())) - require.NoError(t, err) - assert.Equal(t, []byte(generated), contents) - }) - t.Run("throws error on failure to diff target", func(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() @@ -279,7 +154,7 @@ func TestRun(t *testing.T) { Get("/v" + utils.Docker.ClientVersion() + "/images/" + utils.GetRegistryImageUrl(utils.Config.Db.Image) + "/json"). ReplyError(errors.New("network error")) // Run test - err := Run(context.Background(), []string{"public"}, "file", dbConfig, DiffSchemaMigra, false, fsys) + err := Run(context.Background(), []string{"public"}, "file", dbConfig, DiffSchemaMigra, fsys) // Check error assert.ErrorContains(t, err, "network error") assert.Empty(t, apitest.ListUnmatchedRequests()) @@ -421,7 +296,7 @@ func TestDiffDatabase(t *testing.T) { Get("/v" + utils.Docker.ClientVersion() + "/images/" + utils.GetRegistryImageUrl(utils.Config.Db.Image) + "/json"). ReplyError(errNetwork) // Run test - result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false) + result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra) // Check error assert.Empty(t, result) assert.ErrorIs(t, err, errNetwork) @@ -452,7 +327,7 @@ func TestDiffDatabase(t *testing.T) { Delete("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db"). Reply(http.StatusOK) // Run test - result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false) + result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra) // Check error assert.Empty(t, result) assert.ErrorContains(t, err, "test-shadow-db container is not running: exited") @@ -484,7 +359,7 @@ func TestDiffDatabase(t *testing.T) { conn.Query(utils.GlobalsSql). ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`) // Run test - result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false, conn.Intercept) + result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, conn.Intercept) // Check error assert.Empty(t, result) assert.ErrorContains(t, err, `ERROR: schema "public" already exists (SQLSTATE 42P06) @@ -550,7 +425,7 @@ create schema public`) Query(migration.INSERT_MIGRATION_VERSION, "0", "test", []string{sql}). Reply("INSERT 0 1") // Run test - result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false, func(cc *pgx.ConnConfig) { + result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, func(cc *pgx.ConnConfig) { if cc.Host == dbConfig.Host { // Fake a SSL error when connecting to target database cc.LookupFunc = func(ctx context.Context, host string) (addrs []string, err error) { diff --git a/apps/cli-go/internal/db/diff/explicit.go b/apps/cli-go/internal/db/diff/explicit.go deleted file mode 100644 index d4601fcc65..0000000000 --- a/apps/cli-go/internal/db/diff/explicit.go +++ /dev/null @@ -1,126 +0,0 @@ -package diff - -import ( - "context" - "fmt" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/pgcache" - "github.com/supabase/cli/internal/db/start" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" -) - -type linkedConfigResolver func(context.Context, afero.Fs) (pgconn.Config, error) -type migrationsRefResolver func(context.Context, afero.Fs, ...func(*pgx.ConnConfig)) (string, error) - -func RunExplicit(ctx context.Context, fromRef, toRef string, schema []string, outputPath string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - source, err := resolveExplicitDatabaseRef(ctx, fromRef, fsys, resolveLinkedConfig, resolveMigrationsCatalogRef, options...) - if err != nil { - return err - } - target, err := resolveExplicitDatabaseRef(ctx, toRef, fsys, resolveLinkedConfig, resolveMigrationsCatalogRef, options...) - if err != nil { - return err - } - out, err := DiffPgDeltaRef(ctx, source, target, schema, pgDeltaFormatOptions(), options...) - if err != nil { - return err - } - if len(outputPath) > 0 { - return writeOutput(out, outputPath, fsys) - } - fmt.Print(out) - return nil -} - -var validTargets = map[string]bool{"local": true, "linked": true, "migrations": true} - -func resolveExplicitDatabaseRef(ctx context.Context, ref string, fsys afero.Fs, resolveLinked linkedConfigResolver, resolveMigrations migrationsRefResolver, options ...func(*pgx.ConnConfig)) (string, error) { - if !validTargets[ref] && !isPostgresURL(ref) { - return "", errors.Errorf("unknown target %q: must be one of 'local', 'linked', 'migrations', or a postgres:// URL", ref) - } - switch ref { - case "local": - return utils.ToPostgresURL(pgconn.Config{ - Host: utils.Config.Hostname, - Port: utils.Config.Db.Port, - User: "postgres", - Password: utils.Config.Db.Password, - Database: "postgres", - }), nil - case "linked": - if resolveLinked == nil { - resolveLinked = resolveLinkedConfig - } - config, err := resolveLinked(ctx, fsys) - if err != nil { - return "", err - } - return utils.ToPostgresURL(config), nil - case "migrations": - if resolveMigrations == nil { - resolveMigrations = resolveMigrationsCatalogRef - } - return resolveMigrations(ctx, fsys, options...) - default: - return ref, nil - } -} - -func writeOutput(out, outputPath string, fsys afero.Fs) error { - return utils.WriteFile(outputPath, []byte(out), fsys) -} - -func resolveLinkedConfig(ctx context.Context, fsys afero.Fs) (pgconn.Config, error) { - if err := flags.LoadProjectRef(fsys); err != nil { - return pgconn.Config{}, err - } - if err := flags.LoadConfig(fsys); err != nil { - return pgconn.Config{}, err - } - return flags.NewDbConfigWithPassword(ctx, flags.ProjectRef) -} - -func resolveMigrationsCatalogRef(ctx context.Context, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (string, error) { - hash, err := pgcache.HashMigrations(fsys) - if err != nil { - return "", err - } - if cachePath, ok, err := pgcache.ResolveMigrationCatalogPath(fsys, hash, "local"); err != nil { - return "", err - } else if ok { - return cachePath, nil - } - shadow, err := CreateShadowDatabase(ctx, utils.Config.Db.ShadowPort) - if err != nil { - return "", err - } - defer utils.DockerRemove(shadow) - if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, shadow); err != nil { - utils.DockerRemove(shadow) - return "", err - } - if err := MigrateShadowDatabase(ctx, shadow, fsys, options...); 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", - } - snapshot, err := ExportCatalogPgDelta(ctx, utils.ToPostgresURL(shadowConfig), "postgres", options...) - if err != nil { - return "", err - } - cachePath, err := pgcache.WriteMigrationCatalogSnapshot(fsys, "local", hash, snapshot) - if err != nil { - return "", err - } - return cachePath, nil -} diff --git a/apps/cli-go/internal/db/diff/explicit_test.go b/apps/cli-go/internal/db/diff/explicit_test.go deleted file mode 100644 index fb8d02a3b1..0000000000 --- a/apps/cli-go/internal/db/diff/explicit_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package diff - -import ( - "context" - "path/filepath" - "testing" - - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/utils" -) - -func TestResolveExplicitDatabaseRef(t *testing.T) { - fsys := afero.NewMemMapFs() - utils.Config.Hostname = "127.0.0.1" - utils.Config.Db.Port = 54322 - utils.Config.Db.Password = "postgres" - - t.Run("resolves local database", func(t *testing.T) { - ref, err := resolveExplicitDatabaseRef(context.Background(), "local", fsys, nil, nil) - - require.NoError(t, err) - assert.Equal(t, "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10", ref) - }) - - t.Run("passes through database url", func(t *testing.T) { - ref, err := resolveExplicitDatabaseRef(context.Background(), "postgres://user:pass@db.example.com:5432/postgres", fsys, nil, nil) - - require.NoError(t, err) - assert.Equal(t, "postgres://user:pass@db.example.com:5432/postgres", ref) - }) - - t.Run("resolves linked database via provider", func(t *testing.T) { - ref, err := resolveExplicitDatabaseRef(context.Background(), "linked", fsys, func(context.Context, afero.Fs) (pgconn.Config, error) { - return pgconn.Config{ - Host: "db.abcdefghijklmnopqrst.supabase.co", - Port: 5432, - User: "postgres", - Password: "secret", - Database: "postgres", - }, nil - }, nil) - - require.NoError(t, err) - assert.Equal(t, "postgresql://postgres:secret@db.abcdefghijklmnopqrst.supabase.co:5432/postgres?connect_timeout=10", ref) - }) - - t.Run("rejects unknown target", func(t *testing.T) { - _, err := resolveExplicitDatabaseRef(context.Background(), "invalid", fsys, nil, nil) - - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown target") - }) - - t.Run("resolves migrations catalog via provider", func(t *testing.T) { - expected := filepath.Join(utils.TempDir, "pgdelta", "catalog-local.json") - ref, err := resolveExplicitDatabaseRef(context.Background(), "migrations", fsys, nil, func(context.Context, afero.Fs, ...func(*pgx.ConnConfig)) (string, error) { - return expected, nil - }) - - require.NoError(t, err) - assert.Equal(t, expected, ref) - }) -} - -func TestWriteOutput(t *testing.T) { - fsys := afero.NewMemMapFs() - - err := writeOutput("create table test();\n", filepath.Join("tmp", "diff.sql"), fsys) - require.NoError(t, err) - - written, err := afero.ReadFile(fsys, filepath.Join("tmp", "diff.sql")) - require.NoError(t, err) - assert.Equal(t, "create table test();\n", string(written)) -} diff --git a/apps/cli-go/internal/db/diff/pgadmin.go b/apps/cli-go/internal/db/diff/pgadmin.go deleted file mode 100644 index 9f1a692971..0000000000 --- a/apps/cli-go/internal/db/diff/pgadmin.go +++ /dev/null @@ -1,121 +0,0 @@ -package diff - -import ( - "context" - _ "embed" - "fmt" - "os" - "time" - - "github.com/jackc/pgconn" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/start" - "github.com/supabase/cli/internal/migration/new" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/config" -) - -var warnDiff = `WARNING: The diff tool is not foolproof, so you may need to manually rearrange and modify the generated migration. -Run ` + utils.Aqua("supabase db reset") + ` to verify that the new migration does not generate errors.` - -func SaveDiff(result DatabaseDiff, file string, fsys afero.Fs) error { - out := result.SQL - if len(out) < 2 { - fmt.Fprintln(os.Stderr, "No schema changes found") - } else if len(file) > 0 { - // A pg-delta plan that crosses a transaction boundary yields more than one - // ordered unit; writing them into a single migration file would later fail - // when `db push`/`reset` applies it as one transaction. Write one migration - // file per unit in that case (Go's `WritePgDeltaMigrations`). The migra / - // pgadmin engines and single-unit pg-delta plans keep the exact single-file - // path, byte-identical to before. - if len(result.Files) > 1 { - if _, err := WritePgDeltaMigrations(result.Files, time.Now(), file, fsys); err != nil { - return err - } - } else { - path := new.GetMigrationPath(utils.GetCurrentTimestamp(), file) - if err := utils.WriteFile(path, []byte(out), fsys); err != nil { - return err - } - } - fmt.Fprintln(os.Stderr, warnDiff) - } else { - fmt.Println(out) - } - return nil -} - -func RunPgAdmin(ctx context.Context, schema []string, file string, config pgconn.Config, fsys afero.Fs) error { - // Sanity checks. - if err := utils.AssertSupabaseDbIsRunning(); err != nil { - return err - } - - if err := utils.RunProgram(ctx, func(p utils.Program, ctx context.Context) error { - return run(p, ctx, schema, config, fsys) - }); err != nil { - return err - } - - return SaveDiff(DatabaseDiff{SQL: output}, file, fsys) -} - -var output string - -func run(p utils.Program, ctx context.Context, schema []string, config pgconn.Config, fsys afero.Fs) error { - p.Send(utils.StatusMsg("Creating shadow database...")) - - // 1. Create shadow db and run migrations - shadow, err := CreateShadowDatabase(ctx, utils.Config.Db.ShadowPort) - if err != nil { - return err - } - defer utils.DockerRemove(shadow) - if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, shadow); err != nil { - return err - } - if err := MigrateShadowDatabase(ctx, shadow, fsys); err != nil { - return err - } - - p.Send(utils.StatusMsg("Diffing local database with current migrations...")) - - // 2. Diff local db (source) with shadow db (target), print it. - source := utils.ToPostgresURL(config) - target := fmt.Sprintf("postgresql://postgres:postgres@127.0.0.1:%d/postgres", utils.Config.Db.ShadowPort) - output, err = DiffSchemaPgAdmin(ctx, source, target, schema, p) - return err -} - -func DiffSchemaPgAdmin(ctx context.Context, source, target string, schema []string, p utils.Program) (string, error) { - stream := utils.NewDiffStream(p) - args := []string{"--json-diff", source, target} - if len(schema) == 0 { - if err := utils.DockerRunOnceWithStream( - ctx, - config.Images.Differ, - nil, - args, - stream.Stdout(), - stream.Stderr(), - ); err != nil { - return "", err - } - } - for _, s := range schema { - p.Send(utils.StatusMsg("Diffing schema: " + s)) - if err := utils.DockerRunOnceWithStream( - ctx, - config.Images.Differ, - nil, - append([]string{"--schema", s}, args...), - stream.Stdout(), - stream.Stderr(), - ); err != nil { - return "", err - } - } - diffBytes, err := stream.Collect() - return string(diffBytes), err -} diff --git a/apps/cli-go/internal/db/diff/pgadmin_test.go b/apps/cli-go/internal/db/diff/pgadmin_test.go deleted file mode 100644 index 7bf65e7d78..0000000000 --- a/apps/cli-go/internal/db/diff/pgadmin_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package diff - -import ( - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/utils" -) - -func TestSaveDiff(t *testing.T) { - t.Run("reports no changes on empty diff", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, SaveDiff(DatabaseDiff{SQL: ""}, "my_diff", fsys)) - // Nothing written when there are no schema changes. - entries, err := afero.ReadDir(fsys, utils.MigrationsDir) - assert.Error(t, err) - assert.Empty(t, entries) - }) - - t.Run("writes a single migration file for a single-unit plan", func(t *testing.T) { - fsys := afero.NewMemMapFs() - files := []PgDeltaPlanFile{ - {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "create table a ();"}, - } - result := DatabaseDiff{SQL: joinPgDeltaFiles(files), Files: files} - require.NoError(t, SaveDiff(result, "my_diff", fsys)) - entries, err := afero.ReadDir(fsys, utils.MigrationsDir) - require.NoError(t, err) - require.Len(t, entries, 1) - // A single-unit plan keeps the plain `_.sql` name and the exact - // diff SQL, byte-identical to the pre-multi-file behavior (no trailing newline - // added, no unit-name suffix). - assert.Regexp(t, `^\d{14}_my_diff\.sql$`, entries[0].Name()) - contents, err := afero.ReadFile(fsys, utils.MigrationsDir+"/"+entries[0].Name()) - require.NoError(t, err) - assert.Equal(t, "create table a ();", string(contents)) - }) - - t.Run("writes one migration file per unit for a multi-unit plan", func(t *testing.T) { - fsys := afero.NewMemMapFs() - files := []PgDeltaPlanFile{ - {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "alter type mood add value 'ok';"}, - {Order: 2, Name: "after_enum_values", TransactionMode: "transactional", SQL: "insert into t values ('ok');"}, - } - result := DatabaseDiff{SQL: joinPgDeltaFiles(files), Files: files} - require.NoError(t, SaveDiff(result, "my_diff", fsys)) - entries, err := afero.ReadDir(fsys, utils.MigrationsDir) - require.NoError(t, err) - require.Len(t, entries, 2) - // Multi-unit plans split into one ordered file per unit, each suffixed with the - // unit name, so `db push`/`reset` applies each unit as its own transaction. - assert.Regexp(t, `^\d{14}_my_diff_schema_changes\.sql$`, entries[0].Name()) - assert.Regexp(t, `^\d{14}_my_diff_after_enum_values\.sql$`, entries[1].Name()) - }) - - t.Run("prints diff to stdout when no file is given", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, SaveDiff(DatabaseDiff{SQL: "create table a ();"}, "", fsys)) - entries, _ := afero.ReadDir(fsys, utils.MigrationsDir) - assert.Empty(t, entries) - }) - - t.Run("creates nested parent directories for a nested single-unit name", func(t *testing.T) { - fsys := afero.NewMemMapFs() - require.NoError(t, SaveDiff(DatabaseDiff{SQL: "create table a ();"}, "snapshots/remote", fsys)) - matches, err := afero.Glob(fsys, utils.MigrationsDir+"/*_snapshots/remote.sql") - require.NoError(t, err) - require.Len(t, matches, 1) - contents, err := afero.ReadFile(fsys, matches[0]) - require.NoError(t, err) - assert.Equal(t, "create table a ();", string(contents)) - }) - - t.Run("creates nested parent directories for a nested multi-unit name", func(t *testing.T) { - fsys := afero.NewMemMapFs() - files := []PgDeltaPlanFile{ - {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "alter type mood add value 'ok';"}, - {Order: 2, Name: "after_enum_values", TransactionMode: "transactional", SQL: "insert into t values ('ok');"}, - } - result := DatabaseDiff{SQL: joinPgDeltaFiles(files), Files: files} - require.NoError(t, SaveDiff(result, "snapshots/remote", fsys)) - matches, err := afero.Glob(fsys, utils.MigrationsDir+"/*_snapshots/remote_*.sql") - require.NoError(t, err) - require.Len(t, matches, 2) - }) -} diff --git a/apps/cli-go/internal/db/diff/pgdelta.go b/apps/cli-go/internal/db/diff/pgdelta.go deleted file mode 100644 index 5267f0c6dd..0000000000 --- a/apps/cli-go/internal/db/diff/pgdelta.go +++ /dev/null @@ -1,275 +0,0 @@ -package diff - -import ( - "bytes" - "context" - _ "embed" - "encoding/json" - "os" - "path/filepath" - "strings" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/supabase/cli/internal/gen/types" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/config" -) - -//go:embed templates/pgdelta.ts -var pgDeltaScript string - -//go:embed templates/pgdelta_declarative_export.ts -var pgDeltaDeclarativeExportScript string - -//go:embed templates/pgdelta_catalog_export.ts -var pgDeltaCatalogExportScript string - -// DeclarativeFile mirrors the per-file payload returned by pg-delta declarative -// export so the CLI can materialize structured SQL files on disk. -type DeclarativeFile struct { - Path string `json:"path"` - Order int `json:"order"` - Statements int `json:"statements"` - SQL string `json:"sql"` -} - -// DeclarativeOutput is the top-level declarative export envelope emitted by the -// pg-delta script and consumed by db/declarative workflows. -type DeclarativeOutput struct { - Version int `json:"version"` - Mode string `json:"mode"` - Files []DeclarativeFile `json:"files"` -} - -// PgDeltaPlanFile is one execution-aware migration unit rendered by pg-delta's -// renderPlanFiles: a numbered SQL file whose header comments record the unit -// number, transaction mode and boundary reason. -type PgDeltaPlanFile struct { - Order int `json:"order"` - Name string `json:"name"` - TransactionMode string `json:"transactionMode"` - SQL string `json:"sql"` -} - -// PgDeltaDiffOutput is the top-level diff envelope emitted by templates/pgdelta.ts. -type PgDeltaDiffOutput struct { - Version int `json:"version"` - Files []PgDeltaPlanFile `json:"files"` -} - -// joinPgDeltaFiles flattens the per-unit files back into a single SQL string for -// callers (db diff, declarative sync) that consume one blob. The per-unit header -// comments keep the transaction boundaries visible in the reviewed output; empty -// files produce an empty string, preserving "no changes" detection. -func joinPgDeltaFiles(files []PgDeltaPlanFile) string { - blocks := make([]string, len(files)) - for i, file := range files { - blocks[i] = file.SQL - } - return strings.Join(blocks, "\n\n") -} - -func isPostgresURL(ref string) bool { - return strings.HasPrefix(ref, "postgres://") || strings.HasPrefix(ref, "postgresql://") -} - -// containerRef translates a host-relative catalog file path into the absolute -// path where it appears inside the edge runtime container (CWD mounted at -// /workspace). Postgres URLs and empty strings pass through unchanged. Path -// separators are normalised to forward slashes so Windows paths (with `\`) -// resolve correctly inside the Linux container. -func containerRef(ref string) string { - if ref == "" || isPostgresURL(ref) { - return ref - } - return "/workspace/" + filepath.ToSlash(ref) -} - -// pgDeltaFormatOptions returns the experimental.pgdelta.format_options config for -// use when invoking pg-delta scripts that produce SQL output. -func pgDeltaFormatOptions() string { - if utils.Config.Experimental.PgDelta == nil { - return "" - } - return strings.TrimSpace(utils.Config.Experimental.PgDelta.FormatOptions) -} - -func appendPgDeltaPostgresEnv( - ctx context.Context, - env []string, - name string, - ref string, - sslRootCertEnv string, - options ...func(*pgx.ConnConfig), -) ([]string, error) { - preparedRef, sslEnv, err := types.PreparePgDeltaPostgresRef(ctx, ref, sslRootCertEnv, options...) - if err != nil { - return nil, err - } - env = append(env, name+"="+containerRef(preparedRef)) - return append(env, sslEnv...), nil -} - -// DiffPgDelta diffs source and target Postgres configs via pg-delta. -// -// This wrapper preserves the old config-based interface while delegating to -// DiffPgDeltaRef, which also supports catalog-file references. Format options -// are read from config so DiffFunc callers do not need to change. -func DiffPgDelta(ctx context.Context, source, target pgconn.Config, schema []string, options ...func(*pgx.ConnConfig)) (string, error) { - return DiffPgDeltaRef(ctx, utils.ToPostgresURL(source), utils.ToPostgresURL(target), schema, pgDeltaFormatOptions(), options...) -} - -// DiffPgDeltaRef supports pg-delta diffing across both live database URLs and -// on-disk catalog references used by declarative sync commands. formatOptions -// is passed through as FORMAT_OPTIONS to the pg-delta script when non-empty. -func DiffPgDeltaRef(ctx context.Context, sourceRef, targetRef string, schema []string, formatOptions string, options ...func(*pgx.ConnConfig)) (string, error) { - result, err := DiffPgDeltaRefDetailed(ctx, sourceRef, targetRef, schema, formatOptions, options...) - if err != nil { - return "", err - } - return joinPgDeltaFiles(result.Files), nil -} - -// DiffPgDeltaRefDetailed is like DiffPgDeltaRef but also returns edge-runtime stderr. -func DiffPgDeltaRefDetailed(ctx context.Context, sourceRef, targetRef string, schema []string, formatOptions string, options ...func(*pgx.ConnConfig)) (PgDeltaDiffResult, error) { - var env []string - var err error - env, err = appendPgDeltaPostgresEnv(ctx, env, "TARGET", targetRef, types.PgDeltaTargetSSLRootCert, options...) - if err != nil { - return PgDeltaDiffResult{}, err - } - if len(sourceRef) > 0 { - env, err = appendPgDeltaPostgresEnv(ctx, env, "SOURCE", sourceRef, types.PgDeltaSourceSSLRootCert, options...) - if err != nil { - return PgDeltaDiffResult{}, err - } - } - if len(schema) > 0 { - env = append(env, "INCLUDED_SCHEMAS="+strings.Join(schema, ",")) - } - if len(strings.TrimSpace(formatOptions)) > 0 { - env = append(env, "FORMAT_OPTIONS="+formatOptions) - } - if IsPgDeltaDebugEnabled() { - env = append(env, "PGDELTA_DEBUG=1") - } - binds := []string{utils.EdgeRuntimeId + ":/root/.cache/deno:rw"} - if cwd, err := os.Getwd(); err == nil { - binds = append(binds, cwd+":/workspace") - } - var stdout, stderr bytes.Buffer - script := config.InterpolatePgDeltaScript(config.Config(&utils.Config), pgDeltaScript) - if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error diffing schema", &stdout, &stderr, utils.PgDeltaNpmRegistryOption()); err != nil { - return PgDeltaDiffResult{}, err - } - return parsePgDeltaDiffOutput(stdout.String(), stderr.String()) -} - -// parsePgDeltaDiffOutput turns the pg-delta diff script's stdout envelope into a -// result. The template always prints the envelope on the success path, even for -// an empty plan (`{"version":1,"files":[]}`); a truly empty stdout means no -// envelope was produced, which we surface as "no changes" (empty Files) rather -// than an error. Non-empty stdout that is not valid envelope JSON is a parse -// error carrying the edge-runtime stderr for diagnosis. -func parsePgDeltaDiffOutput(stdout, stderr string) (PgDeltaDiffResult, error) { - result := PgDeltaDiffResult{Stderr: stderr} - if len(strings.TrimSpace(stdout)) == 0 { - return result, nil - } - var envelope PgDeltaDiffOutput - if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { - return PgDeltaDiffResult{}, errors.Errorf("failed to parse pg-delta diff output: %w:\n%s", err, stderr) - } - result.Files = envelope.Files - return result, nil -} - -// exportCatalogPgDelta is overridden in tests to mock catalog export. -var exportCatalogPgDelta = ExportCatalogPgDelta - -// diffPgDeltaRefDetailed is the seam DiffDatabase uses for the pg-delta engine. -// Tests override it to stub the real edge-runtime pipeline (which the injected -// DiffFunc differ cannot, since pg-delta bypasses differ), the same pattern as -// exportCatalogPgDelta above. -var diffPgDeltaRefDetailed = DiffPgDeltaRefDetailed - -// DeclarativeExportPgDelta exports target schema as declarative file payloads -// while keeping a config-based API for existing call sites. -func DeclarativeExportPgDelta(ctx context.Context, source, target pgconn.Config, schema []string, formatOptions string, options ...func(*pgx.ConnConfig)) (DeclarativeOutput, error) { - return DeclarativeExportPgDeltaRef(ctx, utils.ToPostgresURL(source), utils.ToPostgresURL(target), schema, formatOptions, options...) -} - -// DeclarativeExportPgDeltaRef exports declarative file payloads using either -// live URLs or catalog references as source/target inputs. -func DeclarativeExportPgDeltaRef(ctx context.Context, sourceRef, targetRef string, schema []string, formatOptions string, options ...func(*pgx.ConnConfig)) (DeclarativeOutput, error) { - var env []string - var err error - env, err = appendPgDeltaPostgresEnv(ctx, env, "TARGET", targetRef, types.PgDeltaTargetSSLRootCert, options...) - if err != nil { - return DeclarativeOutput{}, err - } - if len(sourceRef) > 0 { - env, err = appendPgDeltaPostgresEnv(ctx, env, "SOURCE", sourceRef, types.PgDeltaSourceSSLRootCert, options...) - if err != nil { - return DeclarativeOutput{}, err - } - } - if len(schema) > 0 { - env = append(env, "INCLUDED_SCHEMAS="+strings.Join(schema, ",")) - } - if len(strings.TrimSpace(formatOptions)) > 0 { - env = append(env, "FORMAT_OPTIONS="+formatOptions) - } - if IsPgDeltaDebugEnabled() { - env = append(env, "PGDELTA_DEBUG=1") - } - binds := []string{utils.EdgeRuntimeId + ":/root/.cache/deno:rw"} - if cwd, err := os.Getwd(); err == nil { - binds = append(binds, cwd+":/workspace") - } - var stdout, stderr bytes.Buffer - script := config.InterpolatePgDeltaScript(config.Config(&utils.Config), pgDeltaDeclarativeExportScript) - if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error exporting declarative schema", &stdout, &stderr, utils.PgDeltaNpmRegistryOption()); err != nil { - return DeclarativeOutput{}, err - } - if stdout.Len() == 0 { - return DeclarativeOutput{}, errors.Errorf("error exporting declarative schema: edge-runtime script produced no output:\n%s", stderr.String()) - } - var result DeclarativeOutput - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - return DeclarativeOutput{}, errors.Errorf("failed to parse declarative export output: %w", err) - } - return result, nil -} - -// ExportCatalogPgDelta snapshots a database/catalog into serialized pg-delta -// catalog JSON so later operations can diff without reconnecting. -func ExportCatalogPgDelta(ctx context.Context, targetRef, role string, options ...func(*pgx.ConnConfig)) (string, error) { - var env []string - var err error - env, err = appendPgDeltaPostgresEnv(ctx, env, "TARGET", targetRef, types.PgDeltaTargetSSLRootCert, options...) - if err != nil { - return "", err - } - if len(role) > 0 { - env = append(env, "ROLE="+role) - } - binds := []string{ - utils.EdgeRuntimeId + ":/root/.cache/deno:rw", - } - if cwd, err := os.Getwd(); err == nil { - binds = append(binds, cwd+":/workspace") - } - var stdout, stderr bytes.Buffer - script := config.InterpolatePgDeltaScript(config.Config(&utils.Config), pgDeltaCatalogExportScript) - if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error exporting pg-delta catalog", &stdout, &stderr, utils.PgDeltaNpmRegistryOption()); err != nil { - return "", err - } - snapshot := strings.TrimSpace(stdout.String()) - if len(snapshot) == 0 { - return "", errors.Errorf("error exporting pg-delta catalog: edge-runtime script produced no output:\n%s", stderr.String()) - } - return snapshot, nil -} diff --git a/apps/cli-go/internal/db/diff/pgdelta_debug.go b/apps/cli-go/internal/db/diff/pgdelta_debug.go deleted file mode 100644 index 1439018e80..0000000000 --- a/apps/cli-go/internal/db/diff/pgdelta_debug.go +++ /dev/null @@ -1,89 +0,0 @@ -package diff - -import ( - "encoding/json" - "os" - "strings" -) - -// IsPgDeltaDebugEnabled reports whether pg-delta diagnostic output is requested. -// Unlike --debug, this does not disable SSL for remote Postgres connections. -func IsPgDeltaDebugEnabled() bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv("PGDELTA_DEBUG"))) { - case "1", "true", "yes": - return true - default: - return false - } -} - -// PgDeltaDiffResult holds the parsed pg-delta diff envelope (one file per -// execution-aware plan unit) and the edge-runtime stderr. -type PgDeltaDiffResult struct { - Files []PgDeltaPlanFile - Stderr string -} - -// PgDeltaDebugCapture holds artifacts collected during a pg-delta shadow diff. -type PgDeltaDebugCapture struct { - SourceCatalog string - Stderr string -} - -// DatabaseDiff is the result of diffing a target database against a shadow baseline. -type DatabaseDiff struct { - SQL string - // Files carries the per-unit pg-delta plan files (empty for the migra engine). - // SQL is the flattened join of these, kept for callers that consume one blob. - Files []PgDeltaPlanFile - Debug *PgDeltaDebugCapture -} - -// CatalogSummary summarizes object counts extracted from a pg-delta catalog JSON blob. -type CatalogSummary struct { - TotalObjects int - BySchema map[string]int -} - -// SummarizeCatalogJSON best-effort counts catalog objects grouped by schema name. -func SummarizeCatalogJSON(catalogJSON string) CatalogSummary { - summary := CatalogSummary{BySchema: map[string]int{}} - if len(strings.TrimSpace(catalogJSON)) == 0 { - return summary - } - var root any - if err := json.Unmarshal([]byte(catalogJSON), &root); err != nil { - return summary - } - walkCatalogObjects(root, summary.BySchema, &summary.TotalObjects) - return summary -} - -func walkCatalogObjects(node any, bySchema map[string]int, total *int) { - switch value := node.(type) { - case map[string]any: - if schema, ok := schemaNameFromCatalogNode(value); ok { - *total++ - bySchema[schema]++ - } - for _, child := range value { - walkCatalogObjects(child, bySchema, total) - } - case []any: - for _, child := range value { - walkCatalogObjects(child, bySchema, total) - } - } -} - -func schemaNameFromCatalogNode(node map[string]any) (string, bool) { - if schema, ok := node["schema"].(string); ok && len(schema) > 0 { - return schema, true - } - if schemaObj, ok := node["schema"].(map[string]any); ok { - if name, ok := schemaObj["name"].(string); ok && len(name) > 0 { - return name, true - } - } - return "", false -} diff --git a/apps/cli-go/internal/db/diff/pgdelta_debug_test.go b/apps/cli-go/internal/db/diff/pgdelta_debug_test.go deleted file mode 100644 index a8c0d47763..0000000000 --- a/apps/cli-go/internal/db/diff/pgdelta_debug_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package diff - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestIsPgDeltaDebugEnabled(t *testing.T) { - t.Run("disabled by default", func(t *testing.T) { - t.Setenv("PGDELTA_DEBUG", "") - assert.False(t, IsPgDeltaDebugEnabled()) - }) - - t.Run("enabled for 1", func(t *testing.T) { - t.Setenv("PGDELTA_DEBUG", "1") - assert.True(t, IsPgDeltaDebugEnabled()) - }) - - t.Run("enabled for true", func(t *testing.T) { - t.Setenv("PGDELTA_DEBUG", "true") - assert.True(t, IsPgDeltaDebugEnabled()) - }) - - t.Run("enabled for yes", func(t *testing.T) { - t.Setenv("PGDELTA_DEBUG", "YES") - assert.True(t, IsPgDeltaDebugEnabled()) - }) -} - -func TestSummarizeCatalogJSON(t *testing.T) { - t.Run("counts schema objects", func(t *testing.T) { - catalog := `{ - "schemas": [ - {"schema": "public", "tables": [{"schema": "public", "name": "airports"}]}, - {"schema": "auth", "tables": [{"schema": "auth", "name": "users"}]} - ] - }` - summary := SummarizeCatalogJSON(catalog) - assert.Equal(t, 4, summary.TotalObjects) - assert.Equal(t, 2, summary.BySchema["public"]) - assert.Equal(t, 2, summary.BySchema["auth"]) - }) - - t.Run("returns empty summary for invalid json", func(t *testing.T) { - summary := SummarizeCatalogJSON("{not-json") - assert.Equal(t, 0, summary.TotalObjects) - assert.Empty(t, summary.BySchema) - }) -} diff --git a/apps/cli-go/internal/db/diff/pgdelta_migrations.go b/apps/cli-go/internal/db/diff/pgdelta_migrations.go deleted file mode 100644 index c8f4a8b244..0000000000 --- a/apps/cli-go/internal/db/diff/pgdelta_migrations.go +++ /dev/null @@ -1,108 +0,0 @@ -package diff - -import ( - "os" - "path/filepath" - "time" - - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/migration/new" - "github.com/supabase/cli/internal/utils" -) - -// maxVersionCollisionAttempts bounds the base-timestamp bump retry so a directory -// already full of same-second migrations can't spin forever. -const maxVersionCollisionAttempts = 60 - -// WrittenMigration is a migration file produced by a diff/pull, paired with the -// version to record in the remote migration history. -type WrittenMigration struct { - Path string - Version string -} - -// WritePgDeltaMigrations writes one ordered migration file per plan unit. A -// single-unit plan (the common case) keeps the exact `_.sql` filename; -// multi-unit plans append the unit name and give each file a strictly increasing -// timestamp (real time arithmetic on the base, never string increment) so their -// execution order and migration-history order stay stable. -// -// Before writing anything, the FULL set of generated filenames is collision-checked -// against the filesystem: if any target path already exists the base is advanced by -// one second and every version recomputed, so the set stays strictly ascending AND -// unique against pre-existing migrations. The base only ever moves forward — never -// backdated below the caller's wall clock, since backdating could sort a new file -// before pre-existing migrations. The resulting ≤N−1s future-dating is inherent to -// second-granularity versions and acceptable once uniqueness is enforced. -func WritePgDeltaMigrations(files []PgDeltaPlanFile, base time.Time, name string, fsys afero.Fs) (_ []WrittenMigration, err error) { - single := len(files) == 1 - buildSet := func(b time.Time) []WrittenMigration { - set := make([]WrittenMigration, len(files)) - for i, file := range files { - version := utils.GetVersionTimestamp(b.Add(time.Duration(i) * time.Second)) - fileName := name - if !single { - fileName = name + "_" + file.Name - } - set[i] = WrittenMigration{Path: new.GetMigrationPath(version, fileName), Version: version} - } - return set - } - - set := buildSet(base) - for attempt := 0; ; attempt++ { - collision := false - for _, w := range set { - exists, err := afero.Exists(fsys, w.Path) - if err != nil { - return nil, errors.Errorf("failed to check migration file: %w", err) - } - if exists { - collision = true - break - } - } - if !collision { - break - } - if attempt+1 >= maxVersionCollisionAttempts { - return nil, errors.Errorf("failed to find a unique migration version after %d attempts", maxVersionCollisionAttempts) - } - base = base.Add(time.Second) - set = buildSet(base) - } - - written := make([]WrittenMigration, 0, len(files)) - // Best-effort cleanup: if any open/write fails mid-loop, remove every file this - // invocation already wrote so a partial multi-file migration isn't left behind. - // A removal failure never masks the original error. - defer func() { - if err != nil { - for _, w := range written { - _ = fsys.Remove(w.Path) - } - } - }() - for i, file := range files { - w := set[i] - if err = utils.MkdirIfNotExistFS(fsys, filepath.Dir(w.Path)); err != nil { - return nil, err - } - // O_EXCL (not O_TRUNC): a race that created the file between the collision - // check and here must never silently overwrite an existing migration. - f, openErr := fsys.OpenFile(w.Path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) - if openErr != nil { - err = errors.Errorf("failed to open migration file: %w", openErr) - return nil, err - } - if _, writeErr := f.WriteString(file.SQL + "\n"); writeErr != nil { - f.Close() - err = errors.Errorf("failed to write migration file: %w", writeErr) - return nil, err - } - f.Close() - written = append(written, w) - } - return written, nil -} diff --git a/apps/cli-go/internal/db/diff/pgdelta_migrations_test.go b/apps/cli-go/internal/db/diff/pgdelta_migrations_test.go deleted file mode 100644 index 16ace1b7ff..0000000000 --- a/apps/cli-go/internal/db/diff/pgdelta_migrations_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package diff - -import ( - "os" - "testing" - "time" - - "github.com/go-errors/errors" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/migration/new" -) - -// failOnNthOpenFs fails the Nth create-for-write OpenFile so a mid-loop write -// failure can be exercised deterministically. Stat/mkdir/read calls pass through. -type failOnNthOpenFs struct { - afero.Fs - failOn int - count int -} - -func (f *failOnNthOpenFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) { - if flag&os.O_CREATE != 0 { - f.count++ - if f.count == f.failOn { - return nil, errors.New("simulated open failure") - } - } - return f.Fs.OpenFile(name, flag, perm) -} - -func TestWritePgDeltaMigrations(t *testing.T) { - base := time.Date(2026, 7, 17, 15, 18, 48, 0, time.UTC) - - t.Run("writes a single unit with the unchanged name", func(t *testing.T) { - fsys := afero.NewMemMapFs() - files := []PgDeltaPlanFile{ - {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "-- unit 1\n\ncreate table a ();"}, - } - written, err := WritePgDeltaMigrations(files, base, "remote_schema", fsys) - require.NoError(t, err) - require.Len(t, written, 1) - assert.Equal(t, "20260717151848", written[0].Version) - expectedPath := new.GetMigrationPath("20260717151848", "remote_schema") - assert.Equal(t, expectedPath, written[0].Path) - contents, err := afero.ReadFile(fsys, expectedPath) - require.NoError(t, err) - assert.Equal(t, "-- unit 1\n\ncreate table a ();\n", string(contents)) - }) - - t.Run("writes one ordered file per unit with strictly increasing versions", func(t *testing.T) { - fsys := afero.NewMemMapFs() - files := []PgDeltaPlanFile{ - {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "-- unit 1\n\nalter type mood add value 'ok';"}, - {Order: 2, Name: "after_enum_values", TransactionMode: "transactional", SQL: "-- unit 2\n\ninsert into t values ('ok');"}, - {Order: 3, Name: "non_transactional", TransactionMode: "none", SQL: "-- unit 3\n\ncreate index concurrently i on t (c);"}, - } - written, err := WritePgDeltaMigrations(files, base, "remote_schema", fsys) - require.NoError(t, err) - require.Len(t, written, 3) - - wantVersions := []string{"20260717151848", "20260717151849", "20260717151850"} - wantNames := []string{"remote_schema_schema_changes", "remote_schema_after_enum_values", "remote_schema_non_transactional"} - for i, w := range written { - assert.Equal(t, wantVersions[i], w.Version) - assert.Equal(t, new.GetMigrationPath(wantVersions[i], wantNames[i]), w.Path) - contents, err := afero.ReadFile(fsys, w.Path) - require.NoError(t, err) - assert.Equal(t, files[i].SQL+"\n", string(contents)) - } - // Versions are strictly increasing so history + execution order stay stable. - assert.True(t, written[0].Version < written[1].Version) - assert.True(t, written[1].Version < written[2].Version) - }) - - t.Run("creates nested parent directories for a nested migration name", func(t *testing.T) { - fsys := afero.NewMemMapFs() - files := []PgDeltaPlanFile{ - {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "create table a ();"}, - {Order: 2, Name: "after_enum_values", TransactionMode: "transactional", SQL: "insert into t values ('ok');"}, - } - written, err := WritePgDeltaMigrations(files, base, "snapshots/remote", fsys) - require.NoError(t, err) - require.Len(t, written, 2) - for i, w := range written { - contents, err := afero.ReadFile(fsys, w.Path) - require.NoError(t, err) - assert.Equal(t, files[i].SQL+"\n", string(contents)) - } - }) - - t.Run("bumps the base version when a target file already exists", func(t *testing.T) { - fsys := afero.NewMemMapFs() - files := []PgDeltaPlanFile{ - {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "create table a ();"}, - {Order: 2, Name: "after_enum_values", TransactionMode: "transactional", SQL: "insert into t values ('ok');"}, - } - // Pre-existing migration at the first version the base would otherwise use. - existing := new.GetMigrationPath("20260717151848", "remote_schema_schema_changes") - require.NoError(t, afero.WriteFile(fsys, existing, []byte("-- pre-existing\n"), 0644)) - - written, err := WritePgDeltaMigrations(files, base, "remote_schema", fsys) - require.NoError(t, err) - require.Len(t, written, 2) - // The whole set advances one second so it skips the colliding version and - // stays strictly ascending against the pre-existing file. - assert.Equal(t, "20260717151849", written[0].Version) - assert.Equal(t, "20260717151850", written[1].Version) - assert.True(t, written[0].Version < written[1].Version) - // The pre-existing file is untouched (never overwritten). - contents, err := afero.ReadFile(fsys, existing) - require.NoError(t, err) - assert.Equal(t, "-- pre-existing\n", string(contents)) - }) - - t.Run("removes already-written files when a later write fails", func(t *testing.T) { - fsys := &failOnNthOpenFs{Fs: afero.NewMemMapFs(), failOn: 2} - files := []PgDeltaPlanFile{ - {Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "create table a ();"}, - {Order: 2, Name: "after_enum_values", TransactionMode: "transactional", SQL: "insert into t values ('ok');"}, - } - written, err := WritePgDeltaMigrations(files, base, "remote_schema", fsys) - require.Error(t, err) - assert.Nil(t, written) - // The first unit's file was written then removed on the failure, so nothing - // from this invocation is left behind. - first := new.GetMigrationPath("20260717151848", "remote_schema_schema_changes") - exists, statErr := afero.Exists(fsys, first) - require.NoError(t, statErr) - assert.False(t, exists) - }) -} diff --git a/apps/cli-go/internal/db/diff/pgdelta_template_test.go b/apps/cli-go/internal/db/diff/pgdelta_template_test.go deleted file mode 100644 index 3fbc2bb792..0000000000 --- a/apps/cli-go/internal/db/diff/pgdelta_template_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package diff - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// lastCodeLine returns the final non-blank, non-comment line of a script. -func lastCodeLine(script string) string { - lines := strings.Split(script, "\n") - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line == "" || strings.HasPrefix(line, "//") { - continue - } - return line - } - return "" -} - -// Every pg-delta edge-runtime script must force the worker's event loop closed -// once its output has been written. The pg connection pool can leave keepalive -// handles registered even after close() resolves; if the worker never exits, -// the container never stops and the CLI — which streams the container logs with -// Follow:true — blocks forever following them, hanging declarative sync at 0% -// CPU (supabase/pg-toolbelt#312). The success path must terminate -// unconditionally rather than rely on the event loop draining on its own, so -// guard against the force-close being dropped from any template's success path. -func TestPgDeltaScriptsForceCloseOnSuccess(t *testing.T) { - scripts := map[string]string{ - "pgdelta.ts": pgDeltaScript, - "pgdelta_declarative_export.ts": pgDeltaDeclarativeExportScript, - "pgdelta_catalog_export.ts": pgDeltaCatalogExportScript, - } - for name, script := range scripts { - t.Run(name, func(t *testing.T) { - require.NotEmpty(t, script) - // The terminating statement runs on the success path (the catch - // branch no longer re-throws), so the worker is torn down whether - // or not the body succeeded. - assert.Equal(t, `throw new Error("");`, lastCodeLine(script), - "success path must force the Edge Runtime worker to exit so the container stops") - }) - } -} diff --git a/apps/cli-go/internal/db/diff/pgdelta_test.go b/apps/cli-go/internal/db/diff/pgdelta_test.go deleted file mode 100644 index ad312273c5..0000000000 --- a/apps/cli-go/internal/db/diff/pgdelta_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package diff - -import ( - "runtime" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestContainerRef(t *testing.T) { - t.Run("passes empty string through", func(t *testing.T) { - assert.Equal(t, "", containerRef("")) - }) - - t.Run("passes postgres URLs through", func(t *testing.T) { - assert.Equal(t, "postgresql://user@host:5432/db", containerRef("postgresql://user@host:5432/db")) - assert.Equal(t, "postgres://user@host:5432/db", containerRef("postgres://user@host:5432/db")) - }) - - t.Run("normalises Windows path separators", func(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("path separator behaviour is Windows-only") - } - // On Windows, filepath.Join produces backslashes which the Linux - // container cannot read; containerRef must convert them. - ref := `supabase\.temp\pgdelta\catalog-baseline-17.6.1.106.json` - assert.Equal(t, "/workspace/supabase/.temp/pgdelta/catalog-baseline-17.6.1.106.json", containerRef(ref)) - }) - - t.Run("leaves unix paths untouched", func(t *testing.T) { - ref := "supabase/.temp/pgdelta/catalog-baseline-17.6.1.106.json" - assert.Equal(t, "/workspace/supabase/.temp/pgdelta/catalog-baseline-17.6.1.106.json", containerRef(ref)) - }) -} - -func TestParsePgDeltaDiffOutput(t *testing.T) { - t.Run("parses a multi-file envelope", func(t *testing.T) { - stdout := `{"version":1,"files":[` + - `{"order":1,"name":"schema_changes","transactionMode":"transactional","sql":"-- unit 1\n\nCREATE TABLE a ();"},` + - `{"order":2,"name":"after_enum_values","transactionMode":"transactional","sql":"-- unit 2\n\nINSERT INTO a VALUES (1);"}` + - `]}` - result, err := parsePgDeltaDiffOutput(stdout, "debug stderr") - assert.NoError(t, err) - assert.Equal(t, "debug stderr", result.Stderr) - assert.Len(t, result.Files, 2) - assert.Equal(t, PgDeltaPlanFile{Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: "-- unit 1\n\nCREATE TABLE a ();"}, result.Files[0]) - assert.Equal(t, "after_enum_values", result.Files[1].Name) - // The flattened join keeps unit boundaries visible via header comments. - assert.Equal(t, "-- unit 1\n\nCREATE TABLE a ();\n\n-- unit 2\n\nINSERT INTO a VALUES (1);", joinPgDeltaFiles(result.Files)) - }) - - t.Run("treats an empty envelope as no changes", func(t *testing.T) { - result, err := parsePgDeltaDiffOutput(`{"version":1,"files":[]}`, "") - assert.NoError(t, err) - assert.Empty(t, result.Files) - assert.Equal(t, "", joinPgDeltaFiles(result.Files)) - }) - - t.Run("treats empty stdout as no changes", func(t *testing.T) { - result, err := parsePgDeltaDiffOutput(" \n", "") - assert.NoError(t, err) - assert.Empty(t, result.Files) - }) - - t.Run("fails on malformed json and embeds stderr", func(t *testing.T) { - _, err := parsePgDeltaDiffOutput("not json", "boom on the edge runtime") - assert.Error(t, err) - assert.ErrorContains(t, err, "failed to parse pg-delta diff output") - assert.ErrorContains(t, err, "boom on the edge runtime") - }) -} diff --git a/apps/cli-go/internal/db/diff/save_diff_test.go b/apps/cli-go/internal/db/diff/save_diff_test.go new file mode 100644 index 0000000000..e03df73eec --- /dev/null +++ b/apps/cli-go/internal/db/diff/save_diff_test.go @@ -0,0 +1,50 @@ +package diff + +import ( + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/utils" +) + +func TestSaveDiff(t *testing.T) { + t.Run("reports no changes on empty diff", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, SaveDiff(DatabaseDiff{SQL: ""}, "my_diff", fsys)) + entries, err := afero.ReadDir(fsys, utils.MigrationsDir) + assert.Error(t, err) + assert.Empty(t, entries) + }) + + t.Run("writes a single migration file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, SaveDiff(DatabaseDiff{SQL: "create table a ();"}, "my_diff", fsys)) + entries, err := afero.ReadDir(fsys, utils.MigrationsDir) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Regexp(t, `^\d{14}_my_diff\.sql$`, entries[0].Name()) + contents, err := afero.ReadFile(fsys, utils.MigrationsDir+"/"+entries[0].Name()) + require.NoError(t, err) + assert.Equal(t, "create table a ();", string(contents)) + }) + + t.Run("prints diff to stdout when no file is given", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, SaveDiff(DatabaseDiff{SQL: "create table a ();"}, "", fsys)) + entries, _ := afero.ReadDir(fsys, utils.MigrationsDir) + assert.Empty(t, entries) + }) + + t.Run("creates nested parent directories for a nested name", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, SaveDiff(DatabaseDiff{SQL: "create table a ();"}, "snapshots/remote", fsys)) + matches, err := afero.Glob(fsys, utils.MigrationsDir+"/*_snapshots/remote.sql") + require.NoError(t, err) + require.Len(t, matches, 1) + contents, err := afero.ReadFile(fsys, matches[0]) + require.NoError(t, err) + assert.Equal(t, "create table a ();", string(contents)) + }) +} diff --git a/apps/cli-go/internal/db/diff/shadow.go b/apps/cli-go/internal/db/diff/shadow.go index 2ebd13591f..7cafbeaf1e 100644 --- a/apps/cli-go/internal/db/diff/shadow.go +++ b/apps/cli-go/internal/db/diff/shadow.go @@ -7,13 +7,12 @@ 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" ) // 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". +// caller 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. @@ -21,20 +20,17 @@ type ShadowSource struct { // 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, when non-nil, replaces the diff target with a second + // shadow database (contrib_regression with declarative schemas applied). 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) { +// against, but returns it running instead of diffing + removing. targetLocal +// mirrors utils.IsLocalDatabase(config). On error the shadow container is +// removed. Declared schemas are applied with the migra seed path; pg-delta +// apply lives in the TypeScript CLI. +func PrepareShadowSource(ctx context.Context, targetLocal bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (ShadowSource, error) { shadow, err := CreateShadowDatabase(ctx, utils.Config.Db.ShadowPort) if err != nil { return ShadowSource{}, err @@ -67,21 +63,8 @@ func PrepareShadowSource(ctx context.Context, schema []string, targetLocal bool, if len(declared) > 0 { override := shadowConfig override.Database = "contrib_regression" - if shouldApplyDeclarativeWithPgDelta(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 - } + if err := migrateBaseDatabase(ctx, override, declared, fsys, options...); err != nil { + return ShadowSource{}, err } targetOverride = &override } @@ -89,28 +72,3 @@ func PrepareShadowSource(ctx context.Context, schema []string, targetLocal bool, 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/diff/templates/pgdelta.ts b/apps/cli-go/internal/db/diff/templates/pgdelta.ts deleted file mode 100644 index 7cf0806c22..0000000000 --- a/apps/cli-go/internal/db/diff/templates/pgdelta.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { - createPlan, - deserializeCatalog, - renderPlanFiles, -} from "npm:@supabase/pg-delta@1.0.0-alpha.20"; -import { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase"; - -async function resolveInput(ref: string | undefined) { - if (!ref) { - return null; - } - if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) { - return ref; - } - const json = await Deno.readTextFile(ref); - return deserializeCatalog(JSON.parse(json)); -} - -const source = Deno.env.get("SOURCE"); -const target = Deno.env.get("TARGET"); - -const includedSchemas = Deno.env.get("INCLUDED_SCHEMAS"); -if (includedSchemas) { - const schemas = includedSchemas.split(","); - const schemaFilter = { - or: [{ "*/schema": schemas }, { "schema/name": schemas }], - }; - // CompositionPattern `and` is valid FilterDSL; Deno's structural typing is strict on `or` branches. - supabase.filter = { - and: [supabase.filter!, schemaFilter], - } as typeof supabase.filter; -} - -const formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS"); -const parsedFormatOptions = formatOptionsRaw ? JSON.parse(formatOptionsRaw) : undefined; -// Format the emitted SQL by default with the same sensible settings the -// declarative export uses (`exportDeclarativeSchema` in @supabase/pg-delta: -// `{ ...DEFAULT_OPTIONS, maxWidth: 180, keywordCase: "upper", ...userOptions }`), -// so `db pull` / `db diff` produce readable migrations even when config sets no -// `[experimental.pgdelta] format_options`. The formatter fills DEFAULT_OPTIONS -// for missing keys itself, so only the two overrides are passed here. Setting -// `format_options = "null"` (parsed to `null`) is the explicit opt-out: raw, -// unformatted statements, mirroring declarative export's `formatOptions === null`. -const sqlFormatOptions = - parsedFormatOptions === null - ? undefined - : { maxWidth: 180, keywordCase: "upper", ...parsedFormatOptions }; - -try { - const result = await createPlan( - await resolveInput(source), - await resolveInput(target), - { - ...supabase, - skipDefaultPrivilegeSubtraction: true, - }, - ); - // pg-delta >= 1.0.0-alpha.32 groups plan statements into execution-aware - // `units` with transaction boundaries. `renderPlanFiles` turns those into one - // numbered SQL file per unit (header comments included). `includeTransactions: - // false` because the CLI appliers already wrap each migration file in a single - // transaction (Go and TS implicit extended-protocol batches), so embedded - // BEGIN/COMMIT would override that file-level boundary. Format options are - // applied per unit here instead of a manual `formatSqlStatements` pass. - const files = result - ? renderPlanFiles(result.plan, { - includeTransactions: false, - sqlFormatOptions, - }) - : []; - const envelope = files.map((file, index) => ({ - order: index + 1, - // The unit name is the rendered path minus its numeric prefix and `.sql` - // extension (e.g. `001_after_enum_values.sql` -> `after_enum_values`). - name: file.path.replace(/^\d+_/, "").replace(/\.sql$/, ""), - transactionMode: file.unit.transactionMode, - sql: file.sql, - })); - if (Deno.env.get("PGDELTA_DEBUG")) { - console.error( - JSON.stringify({ - statementCount: files.reduce((total, file) => total + file.unit.statements.length, 0), - fileCount: files.length, - source: source ? "connected" : "null", - target: target ? "connected" : "null", - includedSchemas: includedSchemas ?? null, - skipDefaultPrivilegeSubtraction: true, - }), - ); - } - console.log(JSON.stringify({ version: 1, files: envelope })); -} catch (e) { - console.error(e); - // Emit a sentinel so the CLI runner can distinguish a real script crash from a - // successful empty diff, even though the forced-exit non-zero code below is - // suppressed by the "main worker has been destroyed" handling. - console.error("PGDELTA_SCRIPT_ERROR"); - // Force close event loop - throw new Error(""); -} -// Force close the event loop on the success path too. When SOURCE/TARGET are -// live database URLs the plan opens connections whose keepalive handles can keep -// the Edge Runtime worker alive after the diff has been written, so the container -// never exits and the CLI — which follows this container's logs — hangs -// indefinitely at 0% CPU (supabase/pg-toolbelt#312). -throw new Error(""); diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts b/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts deleted file mode 100644 index dfecc58da1..0000000000 --- a/apps/cli-go/internal/db/diff/templates/pgdelta_catalog_export.ts +++ /dev/null @@ -1,44 +0,0 @@ -// This script serializes a database catalog for caching/reuse in declarative -// sync workflows, so later diff/export operations can run from file references. -import { - createManagedPool, - extractCatalog, - serializeCatalog, - stringifyCatalogSnapshot, -} from "npm:@supabase/pg-delta@1.0.0-alpha.20"; - -const target = Deno.env.get("TARGET"); -const role = Deno.env.get("ROLE") ?? undefined; - -if (!target) { - console.error("TARGET is required"); - // Emit a sentinel so the CLI runner treats this as a real script crash rather - // than a successful empty catalog, even though the forced-exit non-zero code is - // suppressed by the "main worker has been destroyed" handling. - console.error("PGDELTA_SCRIPT_ERROR"); - throw new Error(""); -} -const { pool, close } = await createManagedPool(target, { role }); - -try { - const catalog = await extractCatalog(pool); - console.log(stringifyCatalogSnapshot(serializeCatalog(catalog))); -} catch (e) { - console.error(e); - // Emit a sentinel so the CLI runner can distinguish a real script crash from a - // successful empty catalog, even though the forced-exit non-zero code below is - // suppressed by the "main worker has been destroyed" handling. - console.error("PGDELTA_SCRIPT_ERROR"); - // Force close event loop - throw new Error(""); -} finally { - await close(); -} -// Force close the event loop on the success path too. The connection pool can -// leave keepalive handles registered even after close() resolves, which keeps -// the Edge Runtime worker (and therefore the container) alive after the catalog -// has already been written to stdout. The CLI streams this container's logs with -// Follow:true, so a worker that never exits hangs the parent `__catalog` -// subprocess — and the declarative-sync command that spawned it — indefinitely -// at 0% CPU (supabase/pg-toolbelt#312). -throw new Error(""); diff --git a/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts b/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts deleted file mode 100644 index 18820ff7b9..0000000000 --- a/apps/cli-go/internal/db/diff/templates/pgdelta_declarative_export.ts +++ /dev/null @@ -1,83 +0,0 @@ -// This script is executed inside Edge Runtime by the CLI to export a target -// schema as declarative file payloads. It accepts either live DB URLs or -// catalog-file references for SOURCE/TARGET, which enables cached sync flows. -import { - createPlan, - deserializeCatalog, - exportDeclarativeSchema, -} from "npm:@supabase/pg-delta@1.0.0-alpha.20"; -import { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase"; - -async function resolveInput(ref: string | undefined) { - if (!ref) { - return null; - } - if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) { - return ref; - } - const json = await Deno.readTextFile(ref); - return deserializeCatalog(JSON.parse(json)); -} - -const source = Deno.env.get("SOURCE"); -const target = Deno.env.get("TARGET"); - -const includedSchemas = Deno.env.get("INCLUDED_SCHEMAS"); -if (includedSchemas) { - const schemas = includedSchemas.split(","); - const schemaFilter = { - or: [{ "*/schema": schemas }, { "schema/name": schemas }], - }; - supabase.filter = { - and: [supabase.filter!, schemaFilter], - } as unknown as typeof supabase.filter; -} - -const formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS"); -let formatOptions = undefined; -if (formatOptionsRaw) { - formatOptions = JSON.parse(formatOptionsRaw); -} -try { - const result = await createPlan( - await resolveInput(source), - await resolveInput(target), - { - ...supabase, - skipDefaultPrivilegeSubtraction: true, - }, - ); - if (!result) { - console.log( - JSON.stringify({ - version: 1, - mode: "declarative", - files: [], - }), - ); - } else { - const output = exportDeclarativeSchema(result, { - integration: supabase, - formatOptions, - }); - console.log( - JSON.stringify(output, (_key, value) => - typeof value === "bigint" ? Number(value) : value, - ), - ); - } -} catch (e) { - console.error(e); - // Emit a sentinel so the CLI runner can distinguish a real script crash from a - // successful empty export, even though the forced-exit non-zero code below is - // suppressed by the "main worker has been destroyed" handling. - console.error("PGDELTA_SCRIPT_ERROR"); - // Force close event loop - throw new Error(""); -} -// Force close the event loop on the success path too. When SOURCE/TARGET are -// live database URLs the plan opens connections whose keepalive handles can keep -// the Edge Runtime worker alive after the export has been written, so the -// container never exits and the CLI — which follows this container's logs — -// hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312). -throw new Error(""); diff --git a/apps/cli-go/internal/db/dump/dump.go b/apps/cli-go/internal/db/dump/dump.go deleted file mode 100644 index 982b473f35..0000000000 --- a/apps/cli-go/internal/db/dump/dump.go +++ /dev/null @@ -1,99 +0,0 @@ -package dump - -import ( - "context" - _ "embed" - "fmt" - "io" - "os" - "strings" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" -) - -func Run(ctx context.Context, path string, config pgconn.Config, dataOnly, roleOnly, dryRun bool, fsys afero.Fs, opts ...migration.DumpOptionFunc) error { - // Initialize output stream - outStream := (io.Writer)(os.Stdout) - if dryRun { - fmt.Fprintln(os.Stderr, "DRY RUN: *only* printing the pg_dump script to console.") - } else if len(path) > 0 { - f, err := fsys.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) - if err != nil { - return errors.Errorf("failed to open dump file: %w", err) - } - defer f.Close() - outStream = f - } - db := "remote" - if utils.IsLocalDatabase(config) { - db = "local" - } - return RunWithPoolerFallback(ctx, config, outStream, dryRun, func(ctx context.Context, config pgconn.Config, out io.Writer, exec migration.ExecFunc) error { - if dataOnly { - fmt.Fprintf(os.Stderr, "Dumping data from %s database...\n", db) - return migration.DumpData(ctx, config, out, exec, opts...) - } else if roleOnly { - fmt.Fprintf(os.Stderr, "Dumping roles from %s database...\n", db) - return migration.DumpRole(ctx, config, out, exec, opts...) - } - fmt.Fprintf(os.Stderr, "Dumping schemas from %s database...\n", db) - return migration.DumpSchema(ctx, config, out, exec, opts...) - }) -} - -// captureExec wraps DockerExec so the container's stderr is teed into errBuf -// (in addition to the user's terminal) for post-failure classification. -func captureExec(errBuf *strings.Builder) migration.ExecFunc { - return func(ctx context.Context, script string, env []string, w io.Writer) error { - return dockerExec(ctx, script, env, w, io.MultiWriter(os.Stderr, errBuf)) - } -} - -func noExec(ctx context.Context, script string, env []string, w io.Writer) error { - envMap := make(map[string]string, len(env)) - for _, e := range env { - index := strings.IndexByte(e, '=') - if index < 0 { - continue - } - envMap[e[:index]] = e[index+1:] - } - expanded := os.Expand(script, func(key string) string { - // Bash variable expansion is unsupported: - // https://github.com/golang/go/issues/47187 - parts := strings.Split(key, ":") - value := envMap[parts[0]] - // Escape double quotes in env vars - return strings.ReplaceAll(value, `"`, `\"`) - }) - fmt.Fprintln(w, expanded) - return nil -} - -func DockerExec(ctx context.Context, script string, env []string, w io.Writer) error { - return dockerExec(ctx, script, env, w, os.Stderr) -} - -func dockerExec(ctx context.Context, script string, env []string, w, errW io.Writer) error { - return utils.DockerRunOnceWithConfig( - ctx, - container.Config{ - Image: utils.Config.Db.Image, - Env: env, - Cmd: []string{"bash", "-c", script, "--"}, - }, - container.HostConfig{ - NetworkMode: network.NetworkHost, - }, - network.NetworkingConfig{}, - "", - w, - errW, - ) -} diff --git a/apps/cli-go/internal/db/dump/dump_test.go b/apps/cli-go/internal/db/dump/dump_test.go deleted file mode 100644 index 05d7748ed7..0000000000 --- a/apps/cli-go/internal/db/dump/dump_test.go +++ /dev/null @@ -1,250 +0,0 @@ -package dump - -import ( - "bytes" - "context" - "errors" - "io" - "net/http" - "os" - "testing" - - "github.com/h2non/gock" - "github.com/jackc/pgconn" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" - "github.com/supabase/cli/pkg/migration" -) - -var dbConfig = pgconn.Config{ - Host: "127.0.0.1", - Port: 5432, - User: "admin", - Password: "password", - Database: "postgres", -} - -func TestDumpCommand(t *testing.T) { - imageUrl := utils.GetRegistryImageUrl(utils.Config.Db.Image) - const containerId = "test-container" - - t.Run("pulls from remote", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, imageUrl, containerId) - require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, "hello world")) - // Run test - err := Run(context.Background(), "schema.sql", dbConfig, false, false, false, fsys) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - // Validate migration - contents, err := afero.ReadFile(fsys, "schema.sql") - assert.NoError(t, err) - assert.Equal(t, []byte("hello world"), contents) - }) - - t.Run("writes to stdout", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, imageUrl, containerId) - require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, "hello world\n")) - // Run test - err := Run(context.Background(), "", dbConfig, false, false, false, fsys, migration.WithSchema("public")) - // Check error - assert.NoError(t, err) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("suggests ipv4 pooler on ipv6 dump failure", func(t *testing.T) { - utils.CmdSuggestion = "" - t.Cleanup(func() { utils.CmdSuggestion = "" }) - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, imageUrl, containerId) - require.NoError(t, apitest.MockDockerErrorLogs(utils.Docker, containerId, 1, - `pg_dump: error: could not translate host name "db.test.supabase.co" to address: No address associated with hostname`)) - // Run test - err := Run(context.Background(), "", dbConfig, false, false, false, fsys) - // Check error - assert.ErrorContains(t, err, "error running container: exit 1") - assert.Contains(t, utils.CmdSuggestion, "Your network does not support IPv6") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("suggests ipv4 pooler when pg_dump cannot assign ipv6 address", func(t *testing.T) { - utils.CmdSuggestion = "" - t.Cleanup(func() { utils.CmdSuggestion = "" }) - fsys := afero.NewMemMapFs() - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, imageUrl, containerId) - require.NoError(t, apitest.MockDockerErrorLogs(utils.Docker, containerId, 1, - `pg_dump: error: connection to server at "db.test.supabase.co" (2600:1f1c:c19:4901:963f:d22e:683a:381c), port 5432 failed: Cannot assign requested address`)) - err := Run(context.Background(), "", dbConfig, false, false, false, fsys) - assert.ErrorContains(t, err, "error running container: exit 1") - assert.Contains(t, utils.CmdSuggestion, "Your network does not support IPv6") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("retries via ipv4 pooler on ipv6 dump failure", func(t *testing.T) { - utils.CmdSuggestion = "" - t.Cleanup(func() { utils.CmdSuggestion = "" }) - // Auto-retry only applies to the linked path, not explicit --db-url. - flags.PoolerFallbackEligible = true - t.Cleanup(func() { flags.PoolerFallbackEligible = false }) - // Stub pooler resolution so the retry path does not touch the network. - orig := resolvePoolerFallback - resolvePoolerFallback = func(ctx context.Context, projectRef string) (pgconn.Config, error) { - return pgconn.Config{ - Host: "aws-0-us-east-1.pooler.supabase.com", - Port: 5432, - User: "postgres." + projectRef, - Password: "secret", - Database: "postgres", - }, nil - } - t.Cleanup(func() { resolvePoolerFallback = orig }) - // Capture stderr to assert the user-visible fallback warning. - oldStderr := os.Stderr - r, w, err := os.Pipe() - require.NoError(t, err) - os.Stderr = w - stderr := make(chan string, 1) - go func() { - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - stderr <- buf.String() - }() - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - // First container run fails because the direct host is unreachable over IPv6. - apitest.MockDockerStart(utils.Docker, imageUrl, containerId) - require.NoError(t, apitest.MockDockerErrorLogs(utils.Docker, containerId, 1, - `pg_dump: error: could not translate host name "db.bvkmtbubamprwkclmslb.supabase.co" to address: No address associated with hostname`)) - // Retry through the pooler succeeds. - apitest.MockDockerStart(utils.Docker, imageUrl, containerId) - require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, "hello world")) - // Run test - directConfig := pgconn.Config{ - Host: "db.bvkmtbubamprwkclmslb.supabase.co", - Port: 5432, - User: "postgres", - Password: "password", - Database: "postgres", - } - err = Run(context.Background(), "schema.sql", directConfig, false, false, false, fsys) - require.NoError(t, w.Close()) - os.Stderr = oldStderr - // Check error - require.NoError(t, err) - assert.Empty(t, utils.CmdSuggestion) - assert.Empty(t, apitest.ListUnmatchedRequests()) - // Validate the retry wrote the full dump after truncating the failed attempt. - contents, err := afero.ReadFile(fsys, "schema.sql") - require.NoError(t, err) - assert.Equal(t, []byte("hello world"), contents) - // Validate the user saw the fallback warning. - assert.Contains(t, <-stderr, "Retrying via the IPv4 connection pooler") - }) - - t.Run("throws error on missing docker", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/images"). - Reply(http.StatusServiceUnavailable) - // Run test - err := Run(context.Background(), "", dbConfig, false, false, false, fsys) - // Check error - assert.ErrorContains(t, err, "request returned 503 Service Unavailable for API route and version") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on permission denied", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewReadOnlyFs(afero.NewMemMapFs()) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, imageUrl, containerId) - require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, "hello world\n")) - // Run test - err := Run(context.Background(), "schema.sql", dbConfig, false, false, false, fsys) - // Check error - assert.ErrorContains(t, err, "operation not permitted") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestPoolerFallbackConfig(t *testing.T) { - ipv6Err := errors.New(`could not translate host name "db.bvkmtbubamprwkclmslb.supabase.co" to address: No address associated with hostname`) - directConfig := pgconn.Config{Host: "db.bvkmtbubamprwkclmslb.supabase.co", Port: 5432} - - stubResolver := func(cfg pgconn.Config, err error) func() { - orig := resolvePoolerFallback - resolvePoolerFallback = func(context.Context, string) (pgconn.Config, error) { return cfg, err } - return func() { resolvePoolerFallback = orig } - } - withEligible := func(v bool) func() { - orig := flags.PoolerFallbackEligible - flags.PoolerFallbackEligible = v - return func() { flags.PoolerFallbackEligible = orig } - } - - t.Run("resolves pooler for eligible linked ipv6 failure", func(t *testing.T) { - t.Cleanup(withEligible(true)) - pooler := pgconn.Config{Host: "aws-0-us-east-1.pooler.supabase.com", Port: 5432} - t.Cleanup(stubResolver(pooler, nil)) - got, ok := PoolerFallbackConfig(context.Background(), directConfig, ipv6Err) - assert.True(t, ok) - assert.Equal(t, pooler.Host, got.Host) - }) - - t.Run("never reroutes explicit --db-url targets", func(t *testing.T) { - t.Cleanup(withEligible(false)) - t.Cleanup(stubResolver(pgconn.Config{}, errors.New("resolver must not be called"))) - _, ok := PoolerFallbackConfig(context.Background(), directConfig, ipv6Err) - assert.False(t, ok) - }) - - t.Run("ignores non-ipv6 failures", func(t *testing.T) { - t.Cleanup(withEligible(true)) - t.Cleanup(stubResolver(pgconn.Config{}, errors.New("resolver must not be called"))) - _, ok := PoolerFallbackConfig(context.Background(), directConfig, errors.New("permission denied for table")) - assert.False(t, ok) - }) - - t.Run("ignores non-direct hosts", func(t *testing.T) { - t.Cleanup(withEligible(true)) - t.Cleanup(stubResolver(pgconn.Config{}, errors.New("resolver must not be called"))) - _, ok := PoolerFallbackConfig(context.Background(), pgconn.Config{Host: "aws-0-us-east-1.pooler.supabase.com"}, ipv6Err) - assert.False(t, ok) - }) - - t.Run("returns false when pooler resolution fails", func(t *testing.T) { - t.Cleanup(withEligible(true)) - t.Cleanup(stubResolver(pgconn.Config{}, errors.New("no pooler"))) - _, ok := PoolerFallbackConfig(context.Background(), directConfig, ipv6Err) - assert.False(t, ok) - }) -} diff --git a/apps/cli-go/internal/db/dump/pooler_fallback.go b/apps/cli-go/internal/db/dump/pooler_fallback.go deleted file mode 100644 index 15bd6f67a6..0000000000 --- a/apps/cli-go/internal/db/dump/pooler_fallback.go +++ /dev/null @@ -1,113 +0,0 @@ -package dump - -import ( - "context" - "io" - "strings" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" - "github.com/supabase/cli/pkg/migration" -) - -// resolvePoolerFallback resolves IPv4 transaction pooler credentials for a direct -// host that failed over IPv6. It is indirected through a variable so tests can -// stub the network call. -var resolvePoolerFallback = flags.ResolvePoolerConfigForFallback - -// RunWithPoolerFallback runs a Docker-backed pg_dump style operation and, when it -// fails because the Supabase direct database host is unreachable over IPv6, -// transparently retries once through the project's IPv4 transaction pooler. -// -// This is the common failure on Docker Desktop for macOS: the host can reach the -// IPv6-only direct database, but the pg_dump container cannot, so the operation -// fails even though direct connection config was selected. -// -// The run closure receives the connection config to use and an ExecFunc that tees -// the container's stderr for failure classification. out receives the dump output -// and is reset between attempts when it supports truncation. -func RunWithPoolerFallback( - ctx context.Context, - config pgconn.Config, - out io.Writer, - dryRun bool, - run func(ctx context.Context, config pgconn.Config, out io.Writer, exec migration.ExecFunc) error, -) error { - if dryRun { - return run(ctx, config, out, noExec) - } - var errBuf strings.Builder - err := run(ctx, config, out, captureExec(&errBuf)) - if err == nil { - return nil - } - // The container exit code hides why pg_dump failed; its stderr carries the - // connection detail, so classify that to decide whether to retry via pooler. - connErr := errors.New(errBuf.String()) - if poolerConfig, ok := PoolerFallbackConfig(ctx, config, connErr); ok { - resetOutput(out) - errBuf.Reset() - if retryErr := run(ctx, poolerConfig, out, captureExec(&errBuf)); retryErr != nil { - utils.SetConnectSuggestion(errors.New(errBuf.String())) - return retryErr - } - return nil - } - // Could not auto-recover: classify the failure into an actionable suggestion. - utils.SetConnectSuggestion(connErr) - if utils.IsIPv6ConnectivityError(connErr) { - // Enrich the hint with the project's actual transaction pooler URL so the - // user gets a copy-pasteable --db-url. - utils.SuggestIPv6Pooler(ctx, config.Host) - } - return err -} - -// PoolerFallbackConfig decides whether a failed remote container operation should -// be retried through the project's IPv4 transaction pooler, returning the pooler -// config to retry with. It returns ok=false unless every condition holds: -// - pooler fallback is eligible (the connection came from --linked, never an -// explicit --db-url/--local target), -// - the failure is an IPv6 connectivity error, -// - the host is a direct Supabase database host (db..supabase.co), and -// - the pooler config resolves. -// -// classifyErr must carry the underlying connection failure text — the teed -// container stderr for pg_dump, or the returned error for the diff/declarative -// paths, which already embed their container stderr. It emits the user-facing -// fallback warning when it returns ok, so callers can simply retry with the -// returned config. -func PoolerFallbackConfig(ctx context.Context, config pgconn.Config, classifyErr error) (pgconn.Config, bool) { - if !flags.PoolerFallbackEligible || !utils.IsIPv6ConnectivityError(classifyErr) { - return pgconn.Config{}, false - } - projectRef, ok := utils.ProjectRefFromDirectDbHost(config.Host) - if !ok { - return pgconn.Config{}, false - } - poolerConfig, err := resolvePoolerFallback(ctx, projectRef) - if err != nil { - return pgconn.Config{}, false - } - utils.WarnIPv6PoolerFallback(config.Host) - return poolerConfig, true -} - -// resetOutput rewinds the dump output between retry attempts so a failed first -// attempt does not leave partial content. It handles the in-memory buffer, -// on-disk file, and stdout cases; truncation errors (e.g. on stdout) are ignored. -func resetOutput(out io.Writer) { - switch w := out.(type) { - case interface{ Reset() }: - w.Reset() - case interface { - Truncate(int64) error - Seek(int64, int) (int64, error) - }: - if err := w.Truncate(0); err == nil { - _, _ = w.Seek(0, io.SeekStart) - } - } -} diff --git a/apps/cli-go/internal/db/pgcache/cache.go b/apps/cli-go/internal/db/pgcache/cache.go deleted file mode 100644 index 3cc1ccd4b4..0000000000 --- a/apps/cli-go/internal/db/pgcache/cache.go +++ /dev/null @@ -1,278 +0,0 @@ -package pgcache - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "time" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/spf13/viper" - "github.com/supabase/cli/internal/gen/types" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/config" - "github.com/supabase/cli/pkg/migration" -) - -const ( - pgDeltaTempDir = "pgdelta" - migrationsCatalogName = "catalog-%s-migrations-%s-%d.json" - legacyMigrationsCatalogName = "catalog-%s-migrations-%s.json" - catalogRetentionCount = 2 - pgDeltaCatalogExportTS = `// This script serializes a database catalog for caching/reuse in declarative -// pg-delta workflows. Uses the same API as pgdelta_catalog_export.ts (main package only, no /catalog subpath). -import { - createManagedPool, - extractCatalog, - serializeCatalog, - stringifyCatalogSnapshot, -} from "npm:@supabase/pg-delta@1.0.0-alpha.20"; -const target = Deno.env.get("TARGET"); -const role = Deno.env.get("ROLE") ?? undefined; -if (!target) { - console.error("TARGET is required"); - throw new Error(""); -} -const { pool, close } = await createManagedPool(target, { role }); -try { - const catalog = await extractCatalog(pool); - console.log(stringifyCatalogSnapshot(serializeCatalog(catalog))); -} catch (e) { - console.error(e); - // Force close event loop - throw new Error(""); -} finally { - await close(); -} -// Force close the event loop on the success path too. The connection pool can -// leave keepalive handles registered even after close() resolves, which keeps -// the Edge Runtime worker (and therefore the container) alive after the catalog -// has already been written to stdout. The CLI streams this container's logs with -// Follow:true, so a worker that never exits hangs the migrations-catalog cache -// path (db start / db push with pg-delta caching) indefinitely at 0% CPU -// (supabase/pg-toolbelt#312). -throw new Error(""); -` -) - -var catalogPrefixRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) - -func TryCacheMigrationsCatalog(ctx context.Context, config pgconn.Config, prefix string, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - if !ShouldCacheMigrationsCatalog() || len(version) > 0 { - return nil - } - if len(strings.TrimSpace(prefix)) == 0 { - prefix = CatalogPrefixFromConfig(config) - } - hash, err := HashMigrations(fsys) - if err != nil { - return err - } - snapshot, err := exportCatalog(ctx, utils.ToPostgresURL(config), options...) - if err != nil { - return err - } - if err := ensureTempDir(fsys); err != nil { - return err - } - _, err = WriteMigrationCatalogSnapshot(fsys, prefix, hash, snapshot) - return err -} - -func ShouldCacheMigrationsCatalog() bool { - return utils.IsPgDeltaEnabled() || viper.GetBool("EXPERIMENTAL_PG_DELTA") -} - -func CatalogPrefixFromConfig(config pgconn.Config) string { - if utils.IsLocalDatabase(config) { - return "local" - } - if matches := utils.ProjectHostPattern.FindStringSubmatch(config.Host); len(matches) > 2 { - return matches[2] - } - key := fmt.Sprintf("%s@%s:%d/%s", config.User, config.Host, config.Port, config.Database) - sum := sha256.Sum256([]byte(key)) - return "url-" + hex.EncodeToString(sum[:])[:12] -} - -func MigrationCatalogPath(hash, prefix string, createdAt time.Time) string { - return filepath.Join(pgDeltaTempPath(), fmt.Sprintf(migrationsCatalogName, SanitizedCatalogPrefix(prefix), hash, createdAt.UnixMilli())) -} - -func ResolveMigrationCatalogPath(fsys afero.Fs, hash, prefix string) (string, bool, error) { - if err := ensureTempDir(fsys); err != nil { - return "", false, err - } - entries, err := afero.ReadDir(fsys, pgDeltaTempPath()) - if err != nil { - return "", false, err - } - familyPrefix := fmt.Sprintf("catalog-%s-migrations-%s-", SanitizedCatalogPrefix(prefix), hash) - legacyName := fmt.Sprintf(legacyMigrationsCatalogName, SanitizedCatalogPrefix(prefix), hash) - latestPath := "" - latestTimestamp := int64(-1) - for _, entry := range entries { - name := entry.Name() - if strings.HasPrefix(name, familyPrefix) && strings.HasSuffix(name, ".json") { - stamp := strings.TrimSuffix(strings.TrimPrefix(name, familyPrefix), ".json") - ts, err := strconv.ParseInt(stamp, 10, 64) - if err != nil { - continue - } - if ts > latestTimestamp { - latestTimestamp = ts - latestPath = filepath.Join(pgDeltaTempPath(), name) - } - } - } - if latestTimestamp >= 0 { - return latestPath, true, nil - } - legacyPath := filepath.Join(pgDeltaTempPath(), legacyName) - if ok, err := afero.Exists(fsys, legacyPath); err != nil { - return "", false, err - } else if ok { - return legacyPath, true, nil - } - return "", false, nil -} - -func WriteMigrationCatalogSnapshot(fsys afero.Fs, prefix, hash, snapshot string) (string, error) { - if err := ensureTempDir(fsys); err != nil { - return "", err - } - path := MigrationCatalogPath(hash, prefix, time.Now().UTC()) - if err := utils.WriteFile(path, []byte(snapshot), fsys); err != nil { - return "", err - } - if err := CleanupOldMigrationCatalogs(fsys, prefix); err != nil { - return "", err - } - return path, nil -} - -func CleanupOldMigrationCatalogs(fsys afero.Fs, prefix string) error { - if err := ensureTempDir(fsys); err != nil { - return err - } - entries, err := afero.ReadDir(fsys, pgDeltaTempPath()) - if err != nil { - return err - } - keepPrefix := SanitizedCatalogPrefix(prefix) - familyPrefix := fmt.Sprintf("catalog-%s-migrations-", keepPrefix) - type catalogFile struct { - name string - timestamp int64 - } - var files []catalogFile - for _, entry := range entries { - name := entry.Name() - if !strings.HasPrefix(name, familyPrefix) || !strings.HasSuffix(name, ".json") { - continue - } - if ts, ok := migrationCatalogTimestamp(name); ok { - files = append(files, catalogFile{name: name, timestamp: ts}) - continue - } - files = append(files, catalogFile{name: name, timestamp: 0}) - } - sort.Slice(files, func(i, j int) bool { - if files[i].timestamp == files[j].timestamp { - return files[i].name > files[j].name - } - return files[i].timestamp > files[j].timestamp - }) - for i := catalogRetentionCount; i < len(files); i++ { - if err := fsys.Remove(filepath.Join(pgDeltaTempPath(), files[i].name)); err != nil { - return err - } - } - return nil -} - -func migrationCatalogTimestamp(name string) (int64, bool) { - if !strings.HasSuffix(name, ".json") { - return 0, false - } - raw := strings.TrimSuffix(name, ".json") - idx := strings.LastIndex(raw, "-") - if idx < 0 || idx+1 >= len(raw) { - return 0, false - } - ts, err := strconv.ParseInt(raw[idx+1:], 10, 64) - if err != nil { - return 0, false - } - return ts, true -} - -func HashMigrations(fsys afero.Fs) (string, error) { - migrations, err := migration.ListLocalMigrations(utils.MigrationsDir, afero.NewIOFS(fsys)) - if err != nil { - return "", err - } - h := sha256.New() - for _, fp := range migrations { - contents, err := afero.ReadFile(fsys, fp) - if err != nil { - return "", err - } - if _, err := h.Write([]byte(fp)); err != nil { - return "", err - } - if _, err := h.Write(contents); err != nil { - return "", err - } - } - return hex.EncodeToString(h.Sum(nil)), nil -} - -func SanitizedCatalogPrefix(prefix string) string { - prefix = strings.TrimSpace(prefix) - if len(prefix) == 0 { - return "local" - } - return catalogPrefixRegexp.ReplaceAllString(prefix, "-") -} - -func ensureTempDir(fsys afero.Fs) error { - return utils.MkdirIfNotExistFS(fsys, pgDeltaTempPath()) -} - -func pgDeltaTempPath() string { - return filepath.Join(utils.TempDir, pgDeltaTempDir) -} - -func exportCatalog(ctx context.Context, targetRef string, options ...func(*pgx.ConnConfig)) (string, error) { - preparedRef, sslEnv, err := types.PreparePgDeltaPostgresRef(ctx, targetRef, types.PgDeltaTargetSSLRootCert, options...) - if err != nil { - return "", err - } - env := append([]string{"TARGET=" + preparedRef, "ROLE=postgres"}, sslEnv...) - binds := []string{utils.EdgeRuntimeId + ":/root/.cache/deno:rw"} - if cwd, err := os.Getwd(); err == nil { - binds = append(binds, cwd+":/workspace") - } - var stdout, stderr bytes.Buffer - script := config.InterpolatePgDeltaScript(config.Config(&utils.Config), pgDeltaCatalogExportTS) - if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error exporting pg-delta catalog", &stdout, &stderr, utils.PgDeltaNpmRegistryOption()); err != nil { - return "", err - } - snapshot := strings.TrimSpace(stdout.String()) - if len(snapshot) == 0 { - return "", errors.Errorf("error exporting pg-delta catalog: edge-runtime script produced no output:\n%s", stderr.String()) - } - return snapshot, nil -} diff --git a/apps/cli-go/internal/db/pgcache/cache_template_test.go b/apps/cli-go/internal/db/pgcache/cache_template_test.go deleted file mode 100644 index 1118853597..0000000000 --- a/apps/cli-go/internal/db/pgcache/cache_template_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package pgcache - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// The migrations-catalog cache script (db start / db push with pg-delta caching) -// opens a connection pool and must force the worker's event loop closed once it -// has written its snapshot. If a keepalive handle lingers after close() resolves -// the worker never exits, so the container never stops and the CLI — which -// follows the container logs with Follow:true — hangs indefinitely at 0% CPU -// (supabase/pg-toolbelt#312). Guard against the success-path force-close being -// dropped. -func TestPgDeltaCatalogExportScriptForceClosesOnSuccess(t *testing.T) { - require.NotEmpty(t, pgDeltaCatalogExportTS) - - lines := strings.Split(pgDeltaCatalogExportTS, "\n") - last := "" - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line == "" || strings.HasPrefix(line, "//") { - continue - } - last = line - break - } - assert.Equal(t, `throw new Error("");`, last, - "success path must force the Edge Runtime worker to exit so the container stops") -} diff --git a/apps/cli-go/internal/db/pgcache/cache_test.go b/apps/cli-go/internal/db/pgcache/cache_test.go deleted file mode 100644 index 25ccf28fb4..0000000000 --- a/apps/cli-go/internal/db/pgcache/cache_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package pgcache - -import ( - "path/filepath" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/utils" -) - -func TestResolveMigrationCatalogPathUsesLatestTimestamp(t *testing.T) { - fsys := afero.NewMemMapFs() - temp := filepath.Join(utils.TempDir, "pgdelta") - require.NoError(t, fsys.MkdirAll(temp, 0755)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(temp, "catalog-local-migrations-abc-1000.json"), []byte("{}"), 0644)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(temp, "catalog-local-migrations-abc-2000.json"), []byte("{}"), 0644)) - - path, ok, err := ResolveMigrationCatalogPath(fsys, "abc", "local") - require.NoError(t, err) - require.True(t, ok) - assert.Equal(t, filepath.Join(temp, "catalog-local-migrations-abc-2000.json"), path) -} - -func TestCleanupOldMigrationCatalogsKeepsLatestTwo(t *testing.T) { - fsys := afero.NewMemMapFs() - temp := filepath.Join(utils.TempDir, "pgdelta") - require.NoError(t, fsys.MkdirAll(temp, 0755)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(temp, "catalog-local-migrations-a-1000.json"), []byte("{}"), 0644)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(temp, "catalog-local-migrations-b-2000.json"), []byte("{}"), 0644)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(temp, "catalog-local-migrations-c-3000.json"), []byte("{}"), 0644)) - - require.NoError(t, CleanupOldMigrationCatalogs(fsys, "local")) - - ok, err := afero.Exists(fsys, filepath.Join(temp, "catalog-local-migrations-a-1000.json")) - require.NoError(t, err) - assert.False(t, ok) - - ok, err = afero.Exists(fsys, filepath.Join(temp, "catalog-local-migrations-b-2000.json")) - require.NoError(t, err) - assert.True(t, ok) - - ok, err = afero.Exists(fsys, filepath.Join(temp, "catalog-local-migrations-c-3000.json")) - require.NoError(t, err) - assert.True(t, ok) -} diff --git a/apps/cli-go/internal/db/pull/pgdelta_pull_debug.go b/apps/cli-go/internal/db/pull/pgdelta_pull_debug.go deleted file mode 100644 index 0cb35072a4..0000000000 --- a/apps/cli-go/internal/db/pull/pgdelta_pull_debug.go +++ /dev/null @@ -1,113 +0,0 @@ -package pull - -import ( - "context" - "fmt" - "net/url" - "os" - "strings" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/declarative" - "github.com/supabase/cli/internal/db/diff" - "github.com/supabase/cli/internal/utils" -) - -var exportTargetCatalog = diff.ExportCatalogPgDelta - -func saveEmptyPgDeltaPullDebug( - ctx context.Context, - config pgconn.Config, - capture *diff.PgDeltaDebugCapture, - fsys afero.Fs, - options ...func(*pgx.ConnConfig), -) (string, error) { - if capture == nil { - capture = &diff.PgDeltaDebugCapture{} - } - targetCatalog, err := exportTargetCatalog(ctx, utils.ToPostgresURL(config), "postgres", options...) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to export remote pg-delta catalog: %v\n", err) - } - bundle := declarative.DebugBundle{ - SourceCatalog: capture.SourceCatalog, - TargetCatalog: targetCatalog, - PgDeltaStderr: capture.Stderr, - ConnectionInfo: formatConnectionInfo(config), - Error: errors.New(errInSync), - } - debugDir, err := declarative.SaveDebugBundle(bundle, fsys) - if err != nil { - return "", err - } - printEmptyPgDeltaPullSummary(debugDir, capture.SourceCatalog, targetCatalog) - declarative.PrintDebugBundleMessage(debugDir) - return debugDir, nil -} - -func printEmptyPgDeltaPullSummary(debugDir, sourceCatalog, targetCatalog string) { - fmt.Fprintln(os.Stderr, "pg-delta returned 0 statements.") - fmt.Fprintln(os.Stderr, "Debug bundle saved to "+utils.Bold(debugDir)) - if len(strings.TrimSpace(sourceCatalog)) > 0 { - fmt.Fprintln(os.Stderr, formatCatalogSummary("Shadow", diff.SummarizeCatalogJSON(sourceCatalog))+ - fmt.Sprintf(" (%s)", formatByteSize(len(sourceCatalog)))) - } - if len(strings.TrimSpace(targetCatalog)) > 0 { - fmt.Fprintln(os.Stderr, formatCatalogSummary("Remote", diff.SummarizeCatalogJSON(targetCatalog))+ - fmt.Sprintf(" (%s)", formatByteSize(len(targetCatalog)))) - } else { - fmt.Fprintln(os.Stderr, "Remote catalog: export failed or empty (inspect connection.txt and pgdelta-stderr.txt)") - } -} - -func formatConnectionInfo(config pgconn.Config) string { - return fmt.Sprintf( - "host=%s port=%d user=%s database=%s url=%s", - config.Host, - config.Port, - config.User, - config.Database, - redactPostgresURL(utils.ToPostgresURL(config)), - ) -} - -func redactPostgresURL(raw string) string { - parsed, err := url.Parse(raw) - if err != nil { - return "" - } - if parsed.User != nil { - username := parsed.User.Username() - if username == "" { - parsed.User = url.UserPassword("redacted", "xxxxx") - } else { - parsed.User = url.UserPassword(username, "xxxxx") - } - } - return parsed.String() -} - -func formatCatalogSummary(label string, summary diff.CatalogSummary) string { - if summary.TotalObjects == 0 { - return label + " catalog: no objects detected" - } - parts := make([]string, 0, len(summary.BySchema)) - for schema, count := range summary.BySchema { - parts = append(parts, fmt.Sprintf("%s=%d", schema, count)) - } - return fmt.Sprintf("%s catalog: %d objects (%s)", label, summary.TotalObjects, strings.Join(parts, ", ")) -} - -func formatByteSize(size int) string { - switch { - case size >= 1<<20: - return fmt.Sprintf("%.1f MB", float64(size)/(1<<20)) - case size >= 1<<10: - return fmt.Sprintf("%.1f KB", float64(size)/(1<<10)) - default: - return fmt.Sprintf("%d B", size) - } -} diff --git a/apps/cli-go/internal/db/pull/pgdelta_pull_debug_test.go b/apps/cli-go/internal/db/pull/pgdelta_pull_debug_test.go deleted file mode 100644 index 1eef2cb655..0000000000 --- a/apps/cli-go/internal/db/pull/pgdelta_pull_debug_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package pull - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/db/diff" - "github.com/supabase/cli/internal/utils" -) - -func TestSaveEmptyPgDeltaPullDebug(t *testing.T) { - t.Setenv("PGDELTA_DEBUG", "1") - fsys := afero.NewMemMapFs() - original := exportTargetCatalog - t.Cleanup(func() { - exportTargetCatalog = original - }) - exportTargetCatalog = func(ctx context.Context, targetRef, role string, options ...func(*pgx.ConnConfig)) (string, error) { - return `{"schema":"public","name":"airports"}`, nil - } - config := pgconn.Config{ - Host: "db.example.supabase.co", - Port: 5432, - User: "postgres", - Password: "secret", - Database: "postgres", - } - capture := &diff.PgDeltaDebugCapture{ - SourceCatalog: `{"schema":"public","name":"roles"}`, - Stderr: `{"statementCount":0}`, - } - debugDir, err := saveEmptyPgDeltaPullDebug(context.Background(), config, capture, fsys) - require.NoError(t, err) - require.NotEmpty(t, debugDir) - - sourcePath := filepath.Join(debugDir, "source-catalog.json") - targetPath := filepath.Join(debugDir, "target-catalog.json") - stderrPath := filepath.Join(debugDir, "pgdelta-stderr.txt") - connectionPath := filepath.Join(debugDir, "connection.txt") - errorPath := filepath.Join(debugDir, "error.txt") - - source, err := afero.ReadFile(fsys, sourcePath) - require.NoError(t, err) - assert.Contains(t, string(source), `"roles"`) - - target, err := afero.ReadFile(fsys, targetPath) - require.NoError(t, err) - assert.Contains(t, string(target), `"airports"`) - - stderr, err := afero.ReadFile(fsys, stderrPath) - require.NoError(t, err) - assert.Contains(t, string(stderr), `"statementCount":0`) - - connection, err := afero.ReadFile(fsys, connectionPath) - require.NoError(t, err) - assert.Contains(t, string(connection), "db.example.supabase.co") - assert.NotContains(t, string(connection), "secret") - - errorText, err := afero.ReadFile(fsys, errorPath) - require.NoError(t, err) - assert.Contains(t, string(errorText), "No schema changes found") -} - -func TestSaveEmptyPgDeltaPullDebugUsesTempDir(t *testing.T) { - fsys := afero.NewMemMapFs() - original := exportTargetCatalog - t.Cleanup(func() { - exportTargetCatalog = original - }) - exportTargetCatalog = func(ctx context.Context, targetRef, role string, options ...func(*pgx.ConnConfig)) (string, error) { - return `{}`, nil - } - debugDir, err := saveEmptyPgDeltaPullDebug(context.Background(), pgconn.Config{}, &diff.PgDeltaDebugCapture{}, fsys) - require.NoError(t, err) - assert.Contains(t, debugDir, filepath.Join(utils.TempDir, "pgdelta", "debug")) -} - -func TestDiffRemoteSchemaEmptyWithoutDebug(t *testing.T) { - t.Setenv("PGDELTA_DEBUG", "") - fsys := afero.NewMemMapFs() - existsBefore, err := afero.Exists(fsys, filepath.Join(utils.TempDir, "pgdelta")) - require.NoError(t, err) - assert.False(t, existsBefore) - - // saveEmptyPgDeltaPullDebug should not run when env is unset; verify gate directly. - assert.False(t, diff.IsPgDeltaDebugEnabled()) - _, err = os.Stat(filepath.Join(utils.TempDir, "pgdelta", "debug")) - assert.Error(t, err) -} diff --git a/apps/cli-go/internal/db/pull/pull.go b/apps/cli-go/internal/db/pull/pull.go deleted file mode 100644 index 21e0db87f0..0000000000 --- a/apps/cli-go/internal/db/pull/pull.go +++ /dev/null @@ -1,305 +0,0 @@ -package pull - -import ( - "bytes" - "context" - _ "embed" - "fmt" - "io" - "math" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" - "github.com/spf13/afero" - "github.com/spf13/viper" - "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/migration/format" - "github.com/supabase/cli/internal/migration/list" - "github.com/supabase/cli/internal/migration/new" - "github.com/supabase/cli/internal/migration/repair" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" -) - -var ( - errMissing = errors.New("No migrations found") - errInSync = errors.New("No schema changes found") - errConflict = errors.Errorf("The remote database's migration history does not match local files in %s directory.", utils.MigrationsDir) -) - -func Run(ctx context.Context, schema []string, config pgconn.Config, name string, usePgDelta bool, usePgDeltaDiff bool, differ diff.DiffFunc, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - // 1. Check postgres connection - conn, err := utils.ConnectByConfig(ctx, config, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - // In experimental mode, allow db pull to switch from migration-file output to - // declarative-file output through pg-delta when explicitly requested. - if usePgDelta { - return pullDeclarativePgDelta(ctx, schema, config, fsys, options...) - } - if viper.GetBool("EXPERIMENTAL") { - var buf bytes.Buffer - if err := dump.RunWithPoolerFallback(ctx, config, &buf, false, func(ctx context.Context, config pgconn.Config, out io.Writer, exec migration.ExecFunc) error { - if err := migration.DumpRole(ctx, config, out, exec); err != nil { - return err - } - return migration.DumpSchema(ctx, config, out, exec) - }); err != nil { - return err - } - // TODO: handle managed schemas - return format.WriteStructuredSchemas(ctx, &buf, fsys) - } - // 2. Pull schema. pg-delta plans with transaction boundaries produce more than - // one ordered migration file; migra always produces exactly one. - base := time.Now().UTC() - written, err := run(ctx, schema, base, name, conn, usePgDeltaDiff, differ, fsys, options...) - if err != nil { - return err - } - if len(written) == 0 { - return errors.New(errInSync) - } - // 3. Insert a row to `schema_migrations` for every file written. - versions := make([]string, len(written)) - for i, w := range written { - fmt.Fprintln(os.Stderr, "Schema written to "+utils.Bold(w.Path)) - versions[i] = w.Version - } - if shouldUpdate, err := utils.NewConsole().PromptYesNo(ctx, "Update remote migration history table?", true); err != nil { - return err - } else if shouldUpdate { - return repair.UpdateMigrationTable(ctx, conn, versions, repair.Applied, false, fsys) - } - return nil -} - -// pullDeclarativePgDelta exports remote schema into declarative SQL files by -// diffing against an empty shadow baseline with pg-delta declarative export. -// -// This path is separate from run() because it does not produce or update -// 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...") - shadowSource, err := diff.PrepareRawShadow(ctx) - if err != nil { - return err - } - defer utils.DockerRemove(shadowSource.Container) - shadowConfig := shadowSource.Source - formatOptions := "" - if utils.Config.Experimental.PgDelta != nil { - formatOptions = strings.TrimSpace(utils.Config.Experimental.PgDelta.FormatOptions) - } - exported, err := diff.DeclarativeExportPgDelta(ctx, shadowConfig, config, schema, formatOptions, options...) - if err != nil { - // The pg-delta container connects to the remote (target) host; if that - // fails over IPv6, retry through the IPv4 pooler like the dump path does. - poolerConfig, ok := dump.PoolerFallbackConfig(ctx, config, err) - if !ok { - return err - } - if exported, err = diff.DeclarativeExportPgDelta(ctx, shadowConfig, poolerConfig, schema, formatOptions, options...); err != nil { - return err - } - } - if err := declarative.WriteDeclarativeSchemas(exported, fsys); err != nil { - return err - } - fmt.Fprintln(os.Stderr, "Declarative schema written to "+utils.Bold(utils.GetDeclarativeDir())) - return nil -} - -func run(ctx context.Context, schema []string, base time.Time, name string, conn *pgx.Conn, usePgDeltaDiff bool, differ diff.DiffFunc, fsys afero.Fs, options ...func(*pgx.ConnConfig)) ([]diff.WrittenMigration, error) { - config := conn.Config().Config - timestamp := utils.GetVersionTimestamp(base) - path := new.GetMigrationPath(timestamp, name) - // 1. Assert `supabase/migrations` and `schema_migrations` are in sync. - if err := assertRemoteInSync(ctx, conn, fsys); errors.Is(err, errMissing) { - // pg_dump strips ownership when restored as a non-superuser, so platform - // objects (FDWs, wasm wrappers, system-owned ACLs) leak into the migration - // and later break `supabase db reset`. pg-delta speaks pg_catalog directly - // and the supabase integration filter drops these by owner, so the diff - // against an empty shadow yields a clean initial migration on its own. - if !usePgDeltaDiff { - // Ignore schemas flag when working on the initial pull - if err = dumpRemoteSchema(ctx, path, config, fsys); err != nil { - return nil, err - } - } - // For the legacy path this is a second pass that captures changes - // pg_dump cannot emit (default privileges, managed schemas). For the - // pg-delta path this is the only pass and produces the full schema. - written, err := diffRemoteSchema(ctx, nil, base, name, config, usePgDeltaDiff, differ, fsys, options...) - if err = swallowInitialInSync(err, fsys, path); err != nil { - return nil, err - } - // The migra initial pull seeds `path` with a pg_dump even when the follow-up - // diff is empty and swallowed above, so record that single migration. - if !usePgDeltaDiff && len(written) == 0 { - written = []diff.WrittenMigration{{Path: path, Version: timestamp}} - } - return written, nil - } else if err != nil { - return nil, err - } - // 2. Fetch remote schema changes - return diffRemoteSchema(ctx, schema, base, name, config, usePgDeltaDiff, differ, fsys, options...) -} - -func dumpRemoteSchema(ctx context.Context, path string, config pgconn.Config, fsys afero.Fs) error { - // Special case if this is the first migration - fmt.Fprintln(os.Stderr, "Dumping schema from remote database...") - if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(path)); err != nil { - return err - } - f, err := fsys.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) - if err != nil { - return errors.Errorf("failed to open dump file: %w", err) - } - defer f.Close() - return dump.RunWithPoolerFallback(ctx, config, f, false, func(ctx context.Context, config pgconn.Config, out io.Writer, exec migration.ExecFunc) error { - return migration.DumpSchema(ctx, config, out, exec) - }) -} - -func diffRemoteSchema(ctx context.Context, schema []string, base time.Time, name string, config pgconn.Config, usePgDeltaDiff bool, differ diff.DiffFunc, fsys afero.Fs, options ...func(*pgx.ConnConfig)) ([]diff.WrittenMigration, error) { - // Diff remote db (source) & shadow db (target) and write it as a new migration. - result, err := diff.DiffDatabase(ctx, schema, config, os.Stderr, fsys, differ, usePgDeltaDiff, options...) - if err != nil { - // The diff runs the remote (source) host inside a container; if that - // fails over IPv6, retry through the IPv4 pooler like the dump path does - // so the whole db pull workflow is self-healing, not just the dump pass. - poolerConfig, ok := dump.PoolerFallbackConfig(ctx, config, err) - if !ok { - return nil, err - } - if result, err = diff.DiffDatabase(ctx, schema, poolerConfig, os.Stderr, fsys, differ, usePgDeltaDiff, options...); err != nil { - return nil, err - } - } - // pg-delta path: one migration file per execution-aware plan unit. - if usePgDeltaDiff { - if len(result.Files) == 0 { - if diff.IsPgDeltaDebugEnabled() { - if debugDir, debugErr := saveEmptyPgDeltaPullDebug(ctx, config, result.Debug, fsys, options...); debugErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to save pg-delta debug bundle: %v\n", debugErr) - } else if len(debugDir) > 0 { - return nil, errors.Errorf("%w (debug bundle: %s)", errInSync, debugDir) - } - } - return nil, errors.New(errInSync) - } - return diff.WritePgDeltaMigrations(result.Files, base, name, fsys) - } - // migra path: a single migration file, appended when seeded by dumpRemoteSchema. - output := result.SQL - if trimmed := strings.TrimSpace(output); len(trimmed) == 0 { - return nil, errors.New(errInSync) - } - timestamp := utils.GetVersionTimestamp(base) - path := new.GetMigrationPath(timestamp, name) - if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(path)); err != nil { - return nil, err - } - // Append to existing migration file when we run this after dumpRemoteSchema; - // for a non-initial pull this creates the file fresh. - f, err := fsys.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) - if err != nil { - return nil, errors.Errorf("failed to open migration file: %w", err) - } - defer f.Close() - if _, err := f.WriteString(output); err != nil { - return nil, errors.Errorf("failed to write migration file: %w", err) - } - return []diff.WrittenMigration{{Path: path, Version: timestamp}}, nil -} - -func assertRemoteInSync(ctx context.Context, conn *pgx.Conn, fsys afero.Fs) error { - remoteMigrations, err := migration.ListRemoteMigrations(ctx, conn) - if err != nil { - return err - } - localMigrations, err := list.LoadLocalVersions(fsys) - if err != nil { - return err - } - // Find any mismatch between local and remote migrations - var extraRemote, extraLocal []string - for i, j := 0, 0; i < len(remoteMigrations) || j < len(localMigrations); { - remoteTimestamp := math.MaxInt - if i < len(remoteMigrations) { - if remoteTimestamp, err = strconv.Atoi(remoteMigrations[i]); err != nil { - i++ - continue - } - } - localTimestamp := math.MaxInt - if j < len(localMigrations) { - if localTimestamp, err = strconv.Atoi(localMigrations[j]); err != nil { - j++ - continue - } - } - // Top to bottom chronological order - if localTimestamp < remoteTimestamp { - extraLocal = append(extraLocal, localMigrations[j]) - j++ - } else if remoteTimestamp < localTimestamp { - extraRemote = append(extraRemote, remoteMigrations[i]) - i++ - } else { - i++ - j++ - } - } - // Suggest delete local migrations / reset migration history - if len(extraRemote)+len(extraLocal) > 0 { - utils.CmdSuggestion = suggestMigrationRepair(extraRemote, extraLocal) - return errors.New(errConflict) - } - if len(localMigrations) == 0 { - return errors.New(errMissing) - } - return nil -} - -func hasMigrationContent(fsys afero.Fs, path string) bool { - info, err := fsys.Stat(path) - return err == nil && info.Size() > 0 -} - -func swallowInitialInSync(err error, fsys afero.Fs, path string) error { - if errors.Is(err, errInSync) && hasMigrationContent(fsys, path) { - return nil - } - return err -} - -func ensureMigrationWritten(fsys afero.Fs, path string) error { - if hasMigrationContent(fsys, path) { - return nil - } - return errors.New(errInSync) -} - -func suggestMigrationRepair(extraRemote, extraLocal []string) string { - result := fmt.Sprintln("\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:") - for _, version := range extraRemote { - result += fmt.Sprintln(utils.Bold("supabase migration repair --status reverted " + version)) - } - for _, version := range extraLocal { - result += fmt.Sprintln(utils.Bold("supabase migration repair --status applied " + version)) - } - return result -} diff --git a/apps/cli-go/internal/db/pull/pull_test.go b/apps/cli-go/internal/db/pull/pull_test.go deleted file mode 100644 index ec3e784f83..0000000000 --- a/apps/cli-go/internal/db/pull/pull_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package pull - -import ( - "context" - "errors" - "os" - "path/filepath" - "testing" - "time" - - "github.com/h2non/gock" - "github.com/jackc/pgconn" - "github.com/jackc/pgerrcode" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/db/diff" - "github.com/supabase/cli/internal/migration/new" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/testing/fstest" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/migration" - "github.com/supabase/cli/pkg/pgtest" -) - -var dbConfig = pgconn.Config{ - Host: "db.supabase.co", - Port: 5432, - User: "admin", - Password: "password", - Database: "postgres", -} - -func TestPullCommand(t *testing.T) { - t.Run("throws error on connect failure", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err := Run(context.Background(), nil, pgconn.Config{}, "", false, false, diff.DiffSchemaMigra, fsys) - // Check error - assert.ErrorContains(t, err, "invalid port (outside range)") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on sync failure", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - ReplyError(pgerrcode.InvalidCatalogName, `database "postgres" does not exist`) - // Run test - err := Run(context.Background(), nil, dbConfig, "", false, false, diff.DiffSchemaMigra, fsys, conn.Intercept) - // Check error - assert.ErrorContains(t, err, `ERROR: database "postgres" does not exist (SQLSTATE 3D000)`) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestPullSchema(t *testing.T) { - t.Run("dumps remote schema", func(t *testing.T) { - errNetwork := errors.New("network error") - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - apitest.MockDockerStart(utils.Docker, utils.GetRegistryImageUrl(utils.Config.Db.Image), "test-db") - require.NoError(t, apitest.MockDockerLogs(utils.Docker, "test-db", "test")) - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/images/" + utils.GetRegistryImageUrl(utils.Config.Db.Image) + "/json"). - ReplyError(errNetwork) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 0") - // Run test - base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - path := new.GetMigrationPath(utils.GetVersionTimestamp(base), "test") - _, err := run(context.Background(), nil, base, "test", conn.MockClient(t), false, diff.DiffSchemaMigra, fsys) - // Check error - assert.ErrorIs(t, err, errNetwork) - assert.Empty(t, apitest.ListUnmatchedRequests()) - contents, err := afero.ReadFile(fsys, path) - assert.NoError(t, err) - assert.Equal(t, []byte("test"), contents) - }) - - t.Run("skips pg_dump for pg-delta diff engine on initial pull", func(t *testing.T) { - errNetwork := errors.New("network error") - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock docker. Only mock the image inspect call that - // CreateShadowDatabase makes; do NOT mock the pg_dump container so - // the test fails loudly if pg_dump is still invoked. - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/images/" + utils.GetRegistryImageUrl(utils.Config.Db.Image) + "/json"). - ReplyError(errNetwork) - // Setup mock postgres (no local migrations -> initial pull path) - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 0") - // Run test with usePgDeltaDiff=true - base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - path := new.GetMigrationPath(utils.GetVersionTimestamp(base), "test") - _, err := run(context.Background(), nil, base, "test", conn.MockClient(t), true, diff.DiffPgDelta, fsys) - // Failure must come from shadow-creation image inspect (proving we - // reached the diff step), not from pg_dump. - assert.ErrorIs(t, err, errNetwork) - assert.Empty(t, apitest.ListUnmatchedRequests()) - exists, err := afero.Exists(fsys, path) - assert.NoError(t, err) - assert.False(t, exists, "pg_dump should be skipped for pg-delta diff engine") - }) - - t.Run("throws error on diff failure", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte(""), 0644)) - // Setup mock docker - require.NoError(t, apitest.MockDocker(utils.Docker)) - defer gock.OffAll() - gock.New(utils.Docker.DaemonHost()). - Get("/v" + utils.Docker.ClientVersion() + "/images/" + utils.GetRegistryImageUrl(utils.Config.Db.Image) + "/json"). - ReplyError(errors.New("network error")) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 1", []any{"0"}) - // Run test - base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - _, err := run(context.Background(), []string{"public"}, base, "test", conn.MockClient(t), false, diff.DiffSchemaMigra, fsys) - // Check error - assert.ErrorContains(t, err, "network error") - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} - -func TestInitialPullInSync(t *testing.T) { - fsys := afero.NewMemMapFs() - path := "0_test.sql" - - t.Run("swallows errInSync when pg_dump already wrote migration content", func(t *testing.T) { - require.NoError(t, afero.WriteFile(fsys, path, []byte("create table t(id int);"), 0644)) - err := swallowInitialInSync(errInSync, fsys, path) - assert.NoError(t, err) - }) - - t.Run("returns errInSync for pg-delta initial pull with no migration file", func(t *testing.T) { - err := swallowInitialInSync(errInSync, fsys, "missing.sql") - assert.ErrorIs(t, err, errInSync) - }) - - t.Run("returns errInSync when migration file is empty", func(t *testing.T) { - require.NoError(t, afero.WriteFile(fsys, "empty.sql", []byte{}, 0644)) - err := swallowInitialInSync(errInSync, fsys, "empty.sql") - assert.ErrorIs(t, err, errInSync) - }) -} - -func TestEnsureMigrationWritten(t *testing.T) { - fsys := afero.NewMemMapFs() - - t.Run("passes when migration file has content", func(t *testing.T) { - path := "0_test.sql" - require.NoError(t, afero.WriteFile(fsys, path, []byte("create table t(id int);"), 0644)) - assert.NoError(t, ensureMigrationWritten(fsys, path)) - }) - - t.Run("returns errInSync when migration file is missing", func(t *testing.T) { - err := ensureMigrationWritten(fsys, "missing.sql") - assert.ErrorIs(t, err, errInSync) - }) -} - -func TestSyncRemote(t *testing.T) { - t.Run("throws error on permission denied", func(t *testing.T) { - // Setup in-memory fs - fsys := &fstest.OpenErrorFs{DenyPath: utils.MigrationsDir} - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 0") - // Run test - err := assertRemoteInSync(context.Background(), conn.MockClient(t), fsys) - // Check error - assert.ErrorIs(t, err, os.ErrPermission) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on mismatched length", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte(""), 0644)) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 0") - // Run test - err := assertRemoteInSync(context.Background(), conn.MockClient(t), fsys) - // Check error - assert.ErrorIs(t, err, errConflict) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on mismatched migration", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - path := filepath.Join(utils.MigrationsDir, "0_test.sql") - require.NoError(t, afero.WriteFile(fsys, path, []byte(""), 0644)) - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 1", []any{"20220727064247"}) - // Run test - err := assertRemoteInSync(context.Background(), conn.MockClient(t), fsys) - // Check error - assert.ErrorIs(t, err, errConflict) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) - - t.Run("throws error on missing migration", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Setup mock postgres - conn := pgtest.NewConn() - defer conn.Close(t) - conn.Query(migration.LIST_MIGRATION_VERSION). - Reply("SELECT 0") - // Run test - err := assertRemoteInSync(context.Background(), conn.MockClient(t), fsys) - // Check error - assert.ErrorIs(t, err, errMissing) - assert.Empty(t, apitest.ListUnmatchedRequests()) - }) -} diff --git a/apps/cli-go/internal/db/start/start.go b/apps/cli-go/internal/db/start/start.go index 6cd411791c..dc8327c816 100644 --- a/apps/cli-go/internal/db/start/start.go +++ b/apps/cli-go/internal/db/start/start.go @@ -20,7 +20,6 @@ import ( "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/pgcache" "github.com/supabase/cli/internal/migration/apply" "github.com/supabase/cli/internal/status" "github.com/supabase/cli/internal/utils" @@ -368,15 +367,6 @@ func SetupLocalDatabase(ctx context.Context, version string, fsys afero.Fs, w io if err := apply.MigrateAndSeed(ctx, version, conn, fsys); err != nil { return err } - if err := pgcache.TryCacheMigrationsCatalog(ctx, pgconn.Config{ - Host: utils.Config.Hostname, - Port: utils.Config.Db.Port, - User: "postgres", - Password: utils.Config.Db.Password, - Database: "postgres", - }, "local", version, fsys, options...); err != nil { - fmt.Fprintln(os.Stderr, "Warning: failed to cache migrations catalog:", err) - } return nil } diff --git a/apps/cli-go/internal/gen/types/pgdelta_conn.go b/apps/cli-go/internal/gen/types/pgdelta_conn.go deleted file mode 100644 index 4c3136c84e..0000000000 --- a/apps/cli-go/internal/gen/types/pgdelta_conn.go +++ /dev/null @@ -1,125 +0,0 @@ -package types - -import ( - "context" - "net/url" - "os" - "path/filepath" - "strings" - - "github.com/jackc/pgx/v4" -) - -const ( - PgDeltaSourceSSLRootCert = "PGDELTA_SOURCE_SSLROOTCERT" - PgDeltaTargetSSLRootCert = "PGDELTA_TARGET_SSLROOTCERT" - pgDeltaCABundleDir = "supabase/.temp/pgdelta" -) - -func isPostgresURL(ref string) bool { - return strings.HasPrefix(ref, "postgres://") || strings.HasPrefix(ref, "postgresql://") -} - -func isSupabaseHostedPostgresURL(dbURL string) bool { - parsed, err := url.Parse(dbURL) - if err != nil { - return false - } - host := strings.ToLower(parsed.Hostname()) - return strings.HasSuffix(host, ".supabase.co") || - host == "pooler.supabase.com" || - strings.HasSuffix(host, ".pooler.supabase.com") -} - -// pgDeltaRootCA returns the CA bundle pg-delta should use for a Postgres URL. -// Supabase-hosted databases always receive the embedded bundle even when the -// SSL probe is skipped (for example in --debug mode). -func pgDeltaRootCA(ctx context.Context, dbURL string, options ...func(*pgx.ConnConfig)) (string, error) { - ca, err := GetRootCA(ctx, dbURL, options...) - if err != nil { - return "", err - } - if len(ca) > 0 { - return ca, nil - } - if isSupabaseHostedPostgresURL(dbURL) { - return caStaging + caProd + caSnap, nil - } - return "", nil -} - -// caBundleFilename returns the per-ref filename for the in-container CA -// bundle. SOURCE and TARGET use distinct files so a diff between two -// remotes with different CAs cannot accidentally share a single bundle. -func caBundleFilename(sslRootCertEnv string) string { - switch sslRootCertEnv { - case PgDeltaSourceSSLRootCert: - return "pgdelta-source-ca.crt" - case PgDeltaTargetSSLRootCert: - return "pgdelta-target-ca.crt" - default: - return "pgdelta-ca.crt" - } -} - -// PreparePgDeltaPostgresRef configures a Postgres URL and env vars for pg-delta. -// -// pg-delta disables TLS when sslmode is absent and only reads PGDELTA_*_SSLROOTCERT -// for verify-ca/verify-full. Remote Supabase databases require verify-ca plus a -// CA bundle written into the workspace so edge-runtime can read it from disk. -func PreparePgDeltaPostgresRef( - ctx context.Context, - ref string, - sslRootCertEnv string, - options ...func(*pgx.ConnConfig), -) (string, []string, error) { - if !isPostgresURL(ref) { - return ref, nil, nil - } - ca, err := pgDeltaRootCA(ctx, ref, options...) - if err != nil { - return "", nil, err - } - if len(ca) == 0 { - return ref, nil, nil - } - containerCertPath, err := writePgDeltaCABundleFile(ca, caBundleFilename(sslRootCertEnv)) - if err != nil { - return "", nil, err - } - return ensurePgDeltaSSL(ref, containerCertPath), []string{sslRootCertEnv + "=" + ca}, nil -} - -func writePgDeltaCABundleFile(ca, filename string) (string, error) { - cwd, err := os.Getwd() - if err != nil { - return "", err - } - relPath := filepath.Join(pgDeltaCABundleDir, filename) - abs := filepath.Join(cwd, relPath) - if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { - return "", err - } - if err := os.WriteFile(abs, []byte(ca), 0o600); err != nil { - return "", err - } - return "/workspace/" + filepath.ToSlash(relPath), nil -} - -func ensurePgDeltaSSL(dbURL, sslrootcertPath string) string { - parsed, err := url.Parse(dbURL) - if err != nil { - return dbURL - } - query := parsed.Query() - switch query.Get("sslmode") { - case "verify-ca", "verify-full": - default: - query.Set("sslmode", "verify-ca") - } - if len(sslrootcertPath) > 0 { - query.Set("sslrootcert", sslrootcertPath) - } - parsed.RawQuery = query.Encode() - return parsed.String() -} diff --git a/apps/cli-go/internal/gen/types/pgdelta_conn_test.go b/apps/cli-go/internal/gen/types/pgdelta_conn_test.go deleted file mode 100644 index 60a472279a..0000000000 --- a/apps/cli-go/internal/gen/types/pgdelta_conn_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package types - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestEnsurePgDeltaSSL(t *testing.T) { - t.Run("adds verify-ca when sslmode is absent", func(t *testing.T) { - input := "postgresql://postgres:secret@db.example.supabase.co:5432/postgres?connect_timeout=10" - got := ensurePgDeltaSSL(input, "") - assert.Contains(t, got, "sslmode=verify-ca") - assert.Contains(t, got, "connect_timeout=10") - }) - - t.Run("preserves existing verify-ca", func(t *testing.T) { - input := "postgresql://postgres:secret@db.example.supabase.co:5432/postgres?sslmode=verify-ca" - assert.Equal(t, input, ensurePgDeltaSSL(input, "")) - }) - - t.Run("preserves existing verify-full", func(t *testing.T) { - input := "postgresql://postgres:secret@db.example.supabase.co:5432/postgres?sslmode=verify-full" - assert.Equal(t, input, ensurePgDeltaSSL(input, "")) - }) - - t.Run("replaces require with verify-ca", func(t *testing.T) { - input := "postgresql://postgres:secret@db.example.supabase.co:5432/postgres?sslmode=require" - got := ensurePgDeltaSSL(input, "") - assert.Contains(t, got, "sslmode=verify-ca") - assert.NotContains(t, got, "sslmode=require") - }) - - t.Run("adds the sslrootcert path when provided", func(t *testing.T) { - input := "postgresql://postgres:secret@db.example.supabase.co:5432/postgres?connect_timeout=10" - got := ensurePgDeltaSSL(input, "/workspace/supabase/.temp/pgdelta/pgdelta-target-ca.crt") - assert.Contains(t, got, "sslmode=verify-ca") - assert.Contains(t, got, "sslrootcert=%2Fworkspace%2Fsupabase%2F.temp%2Fpgdelta%2Fpgdelta-target-ca.crt") - }) -} - -func TestIsSupabaseHostedPostgresURL(t *testing.T) { - assert.True(t, isSupabaseHostedPostgresURL("postgresql://postgres@db.ref.supabase.co:5432/postgres")) - assert.True(t, isSupabaseHostedPostgresURL("postgresql://supabase_admin@aws-0-us-east-2.pooler.supabase.com:5432/postgres")) - assert.True(t, isSupabaseHostedPostgresURL("postgresql://supabase_admin@pooler.supabase.com:5432/postgres")) - assert.False(t, isSupabaseHostedPostgresURL("postgresql://postgres@localhost:5432/postgres")) - // Suffix match rejects look-alike hostnames that merely contain the - // pooler domain as a substring (e.g. an attacker-controlled host like - // pooler.supabase.com.example.org). - assert.False(t, isSupabaseHostedPostgresURL("postgresql://postgres@pooler.supabase.com.example.org:5432/postgres")) -} - -func TestCABundleFilename(t *testing.T) { - assert.Equal(t, "pgdelta-source-ca.crt", caBundleFilename(PgDeltaSourceSSLRootCert)) - assert.Equal(t, "pgdelta-target-ca.crt", caBundleFilename(PgDeltaTargetSSLRootCert)) - assert.Equal(t, "pgdelta-ca.crt", caBundleFilename("")) -} - -func TestPreparePgDeltaPostgresRefNonPostgres(t *testing.T) { - ref, env, err := PreparePgDeltaPostgresRef(t.Context(), "supabase/.temp/catalog.json", PgDeltaTargetSSLRootCert) - assert.NoError(t, err) - assert.Equal(t, "supabase/.temp/catalog.json", ref) - assert.Empty(t, env) -} diff --git a/apps/cli-go/internal/migration/down/down.go b/apps/cli-go/internal/migration/down/down.go index 6fba0c1fb8..35a9fa7e88 100644 --- a/apps/cli-go/internal/migration/down/down.go +++ b/apps/cli-go/internal/migration/down/down.go @@ -9,7 +9,6 @@ import ( "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/pgcache" "github.com/supabase/cli/internal/migration/apply" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/migration" @@ -52,13 +51,7 @@ func ResetAll(ctx context.Context, version string, conn *pgx.Conn, fsys afero.Fs if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil { return err } - if err := apply.MigrateAndSeed(ctx, version, conn, fsys); err != nil { - return err - } - if err := pgcache.TryCacheMigrationsCatalog(ctx, conn.Config().Config, "", version, fsys); err != nil { - fmt.Fprintln(os.Stderr, "Warning: failed to cache migrations catalog:", err) - } - return nil + return apply.MigrateAndSeed(ctx, version, conn, fsys) } func confirmResetAll(pending []string) string { diff --git a/apps/cli-go/internal/migration/format/format.go b/apps/cli-go/internal/migration/format/format.go deleted file mode 100644 index cb5045745f..0000000000 --- a/apps/cli-go/internal/migration/format/format.go +++ /dev/null @@ -1,710 +0,0 @@ -package format - -import ( - "bytes" - "context" - _ "embed" - "fmt" - "io" - "os" - "path/filepath" - "regexp" - "strings" - - "github.com/go-errors/errors" - mg "github.com/multigres/multigres/go/parser" - "github.com/multigres/multigres/go/parser/ast" - "github.com/spf13/afero" - "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/parser" -) - -var ( - rolesPath = filepath.Join(utils.ClusterDir, "roles.sql") - extensionsPath = filepath.Join(utils.ClusterDir, "extensions.sql") - foreignDWPath = filepath.Join(utils.ClusterDir, "foreign_data_wrappers.sql") - publicationsPath = filepath.Join(utils.ClusterDir, "publications.sql") - subscriptionsPath = filepath.Join(utils.ClusterDir, "subscriptions.sql") - eventTriggersPath = filepath.Join(utils.ClusterDir, "event_triggers.sql") - tablespacesPath = filepath.Join(utils.ClusterDir, "tablespaces.sql") - variablesPath = filepath.Join(utils.ClusterDir, "variables.sql") - unqualifiedPath = filepath.Join(utils.SchemasDir, "unqualified.sql") -) - -func getSchemaPath(name string) string { - return filepath.Join(utils.SchemasDir, name, "schema.sql") -} - -func getTypesPath(schema string) string { - return filepath.Join(utils.SchemasDir, schema, "types.sql") -} - -func getSequencesPath(schema string) string { - return filepath.Join(utils.SchemasDir, schema, "sequences.sql") -} - -func getTablePath(schema, name string) string { - return filepath.Join(utils.SchemasDir, schema, "tables", name+".sql") -} - -func getForeignTablePath(schema, name string) string { - return filepath.Join(utils.SchemasDir, schema, "foreign_tables", name+".sql") -} - -func getFunctionPath(schema, name string) string { - return filepath.Join(utils.SchemasDir, schema, "functions", name+".sql") -} - -func getProcedurePath(schema, name string) string { - return filepath.Join(utils.SchemasDir, schema, "procedures", name+".sql") -} - -func getMaterializedViewPath(schema, name string) string { - return filepath.Join(utils.SchemasDir, schema, "materialized_views", name+".sql") -} - -func getViewPath(schema, name string) string { - return filepath.Join(utils.SchemasDir, schema, "views", name+".sql") -} - -func getPolicyPath(schema, name string) string { - return filepath.Join(utils.SchemasDir, schema, "policies", name+".sql") -} - -func getDomainPath(schema, name string) string { - return filepath.Join(utils.SchemasDir, schema, "domains", name+".sql") -} - -func getOperatorPath(schema, name string) string { - return filepath.Join(utils.SchemasDir, schema, "operators", name+".sql") -} - -func getSequenceOrTablePath(schema, name string, seen map[string]string) string { - keys := []string{fmt.Sprintf("%s.%s.%s", ast.OBJECT_SEQUENCE, schema, name)} - // Find sequences that were created implicitly with tables - parts := strings.Split(name, "_") - for i := len(parts) - 2; i > 0; i-- { - table := strings.Join(parts[:i], "_") - keys = append(keys, fmt.Sprintf("%s.%s.%s", ast.OBJECT_TABLE, schema, table)) - } - for _, k := range keys { - if fp, found := seen[k]; found { - return fp - } - } - // Tables may be renamed such that its sequence id doesn't contain the table name - return getSequencesPath(schema) -} - -func WriteStructuredSchemas(ctx context.Context, sql io.Reader, fsys afero.Fs) error { - stat, err := parser.Split(sql, strings.TrimSpace) - if err != nil { - return err - } - for _, d := range []string{utils.ClusterDir, utils.SchemasDir} { - if err := fsys.RemoveAll(d); err != nil { - return errors.Errorf("failed to remove directory: %w", err) - } - } - schemaPaths := []string{ - variablesPath, - rolesPath, - extensionsPath, - foreignDWPath, - tablespacesPath, - } - // Holds entities that depend on others but can be referenced directly by id - // Or those with ambiguous keywords like table / view, etc. - nodeToPath := map[string]string{} - for _, line := range stat { - name := unqualifiedPath - parsed, err := mg.ParseSQL(line) - if err != nil { - return errors.Errorf("failed to parse SQL: %w", err) - } else if len(parsed) == 0 { - continue - } - switch v := parsed[0].(type) { - // Cluster level entities - case *ast.CreateRoleStmt, *ast.AlterRoleStmt, *ast.AlterRoleSetStmt, *ast.GrantRoleStmt: - name = rolesPath - case *ast.CreateExtensionStmt, *ast.AlterExtensionStmt, *ast.AlterExtensionContentsStmt: - name = extensionsPath - case *ast.CreateFdwStmt, *ast.AlterFdwStmt, *ast.CreateForeignServerStmt, *ast.AlterForeignServerStmt, *ast.CreateUserMappingStmt, *ast.AlterUserMappingStmt: - name = foreignDWPath - case *ast.CreatePublicationStmt, *ast.AlterPublicationStmt: - name = publicationsPath - case *ast.CreateSubscriptionStmt, *ast.AlterSubscriptionStmt: - name = subscriptionsPath - case *ast.CreateEventTrigStmt, *ast.AlterEventTrigStmt: - name = eventTriggersPath - case *ast.CreateTableSpaceStmt, *ast.AlterTableSpaceStmt: - name = tablespacesPath - case *ast.CreatedbStmt, *ast.AlterDatabaseStmt, *ast.AlterDatabaseSetStmt, *ast.AlterSystemStmt, *ast.VariableSetStmt: - name = variablesPath - // Schema level entities - case *ast.CreateSchemaStmt: - name = getSchemaPath(v.Schemaname) - case *ast.CreateOpFamilyStmt: - if s := toQualifiedName(v.OpFamilyName); len(s) == 2 { - name = getSchemaPath(s[0]) - } - case *ast.AlterOpFamilyStmt: - if s := toQualifiedName(v.OpFamilyName); len(s) == 2 { - name = getSchemaPath(s[0]) - } - case *ast.AlterCollationStmt: - if s := toQualifiedName(v.Collname); len(s) == 2 { - name = getSchemaPath(s[0]) - } - case *ast.AlterTSDictionaryStmt: - if s := toQualifiedName(v.Dictname); len(s) == 2 { - name = getSchemaPath(s[0]) - } - case *ast.AlterTSConfigurationStmt: - if s := toQualifiedName(v.Cfgname); len(s) == 2 { - name = getSchemaPath(s[0]) - } - // Schema level entities - types - case *ast.DefineStmt: - if s := getNodePath(v.Kind, v.DefNames, nodeToPath); len(s) > 0 { - name = s - } - case *ast.AlterTypeStmt: - if s := toQualifiedName(v.TypeName); len(s) == 2 { - name = getTypesPath(s[0]) - } - case *ast.CompositeTypeStmt: - if r := v.Typevar; r != nil && len(r.SchemaName) > 0 { - name = getTypesPath(r.SchemaName) - } - case *ast.AlterCompositeTypeStmt: - if s := toQualifiedName(v.TypeName); len(s) == 2 { - name = getTypesPath(s[0]) - } - case *ast.CreateEnumStmt: - if s := toQualifiedName(v.TypeName); len(s) == 2 { - name = getTypesPath(s[0]) - } - case *ast.AlterEnumStmt: - if s := toQualifiedName(v.TypeName); len(s) == 2 { - name = getTypesPath(s[0]) - } - case *ast.CreateRangeStmt: - if s := toQualifiedName(v.TypeName); len(s) == 2 { - name = getTypesPath(s[0]) - } - case *ast.CreateTransformStmt: - if t := v.FromSql; t != nil { - if s := toQualifiedName(t.Objname); len(s) == 2 { - name = getOperatorPath(s[0], s[1]) - } - } - if t := v.TypeName; t != nil { - if s := toQualifiedName(t.Names); len(s) == 2 { - key := fmt.Sprintf("%s.%s.%s", ast.OBJECT_TRANSFORM, s[0], s[1]) - nodeToPath[key] = name - } - } - case *ast.CreateDomainStmt: - if s := toQualifiedName(v.Domainname); len(s) == 2 { - name = getDomainPath(s[0], s[1]) - } - case *ast.AlterDomainStmt: - if s := toQualifiedName(v.TypeName); len(s) == 2 { - name = getDomainPath(s[0], s[1]) - } - // Schema level entities - relations - case *ast.CreateStmt: - if r := v.Relation; r != nil && len(r.SchemaName) > 0 { - name = getTablePath(r.SchemaName, r.RelName) - key := fmt.Sprintf("%s.%s.%s", ast.OBJECT_TABLE, r.SchemaName, r.RelName) - nodeToPath[key] = name - } - case *ast.AlterTableStmt: - if r := v.Relation; r != nil && len(r.SchemaName) > 0 { - name = getTablePath(r.SchemaName, r.RelName) - // TODO: alter sequence / view owner may be parsed to wrong ast - switch v.Objtype { - case ast.OBJECT_SEQUENCE: - name = getSequenceOrTablePath(r.SchemaName, r.RelName, nodeToPath) - case ast.OBJECT_VIEW: - name = getViewPath(r.SchemaName, r.RelName) - default: - if c := v.Cmds; c != nil { - for _, e := range c.Items { - if t, ok := e.(*ast.AlterTableCmd); ok { - if n, ok := t.Def.(*ast.Constraint); ok { - switch n.Contype { - case ast.CONSTR_FOREIGN: - name = getPolicyPath(r.SchemaName, r.RelName) - } - } - } - } - } - } - } - case *ast.CreateForeignTableStmt: - if t := v.Base; t != nil { - if r := t.Relation; r != nil && len(r.SchemaName) > 0 { - name = getForeignTablePath(r.SchemaName, r.RelName) - key := fmt.Sprintf("%s.%s.%s", ast.OBJECT_FOREIGN_TABLE, r.SchemaName, r.RelName) - nodeToPath[key] = name - } - } - case *ast.CreateTableAsStmt: - if t := v.Into; t != nil { - if r := t.Rel; r != nil && len(r.SchemaName) > 0 { - name = getMaterializedViewPath(r.SchemaName, r.RelName) - key := fmt.Sprintf("%s.%s.%s", ast.OBJECT_MATVIEW, r.SchemaName, r.RelName) - nodeToPath[key] = name - } - } - case *ast.ViewStmt: - if r := v.View; r != nil && len(r.SchemaName) > 0 { - name = getViewPath(r.SchemaName, r.RelName) - key := fmt.Sprintf("%s.%s.%s", ast.OBJECT_VIEW, r.SchemaName, r.RelName) - // Adjust for forward declaration of views - if _, found := nodeToPath[key]; found { - name = name[:len(name)-4] + "-final.sql" - } - nodeToPath[key] = name - } - case *ast.CreateSeqStmt: - if r := v.Sequence; r != nil && len(r.SchemaName) > 0 { - name = getSequencesPath(r.SchemaName) - if o := v.Options; o != nil { - for _, s := range o.Items { - if e, ok := s.(*ast.DefElem); ok && e.Defname == "owned_by" { - if n := getQualifiedName(e.Arg); len(n) == 3 { - name = getTablePath(n[0], n[1]) - key := fmt.Sprintf("%s.%s.%s", ast.OBJECT_SEQUENCE, r.SchemaName, r.RelName) - nodeToPath[key] = name - } - } - } - } - } - case *ast.AlterSeqStmt: - if r := v.Sequence; r != nil && len(r.SchemaName) > 0 { - name = getSequencesPath(r.SchemaName) - if o := v.Options; o != nil { - for _, s := range o.Items { - if e, ok := s.(*ast.DefElem); ok && e.Defname == "owned_by" { - if n := getQualifiedName(e.Arg); len(n) == 3 { - name = getTablePath(n[0], n[1]) - key := fmt.Sprintf("%s.%s.%s", ast.OBJECT_SEQUENCE, r.SchemaName, r.RelName) - nodeToPath[key] = name - } - } - } - } - } - case *ast.IndexStmt: - if r := v.Relation; r != nil && len(r.SchemaName) > 0 { - name = getTablePath(r.SchemaName, r.RelName) - } - key := fmt.Sprintf("%s.%s", ast.OBJECT_INDEX, v.Idxname) - nodeToPath[key] = name - case *ast.CreatePolicyStmt: - if r := v.Table; r != nil && len(r.SchemaName) > 0 { - name = getPolicyPath(r.SchemaName, r.RelName) - } - case *ast.AlterPolicyStmt: - if r := v.Table; r != nil && len(r.SchemaName) > 0 { - name = getPolicyPath(r.SchemaName, r.RelName) - } - case *ast.RuleStmt: - if r := v.Relation; r != nil && len(r.SchemaName) > 0 { - name = getPolicyPath(r.SchemaName, r.RelName) - } - // Schema level entities - functions - case *ast.CreateFunctionStmt: - if s := toQualifiedName(v.FuncName); len(s) == 2 { - if v.IsProcedure { - name = getProcedurePath(s[0], s[1]) - } else { - name = getFunctionPath(s[0], s[1]) - } - } - case *ast.AlterFunctionStmt: - if s := getNodePath(v.ObjType, v.Func, nodeToPath); len(s) > 0 { - name = s - } - case *ast.CreateTriggerStmt: - if r := v.Relation; r != nil && len(r.SchemaName) > 0 { - name = getPolicyPath(r.SchemaName, r.RelName) - } else if s := toQualifiedName(v.Funcname); len(s) == 2 { - name = getFunctionPath(s[0], s[1]) - } - case *ast.CreatePLangStmt: - if s := toQualifiedName(v.PLHandler); len(s) == 2 { - name = getFunctionPath(s[0], s[1]) - } - key := fmt.Sprintf("%s.%s", ast.OBJECT_LANGUAGE, v.PLName) - nodeToPath[key] = name - case *ast.CreateAmStmt: - if s := toQualifiedName(v.HandlerName); len(s) == 2 { - name = getFunctionPath(s[0], s[1]) - } - key := fmt.Sprintf("%s.%s", ast.OBJECT_ACCESS_METHOD, v.AmName) - nodeToPath[key] = name - case *ast.CreateConversionStmt: - if s := toQualifiedName(v.FuncName); len(s) == 2 { - name = getFunctionPath(s[0], s[1]) - } - // Schema level entities - operators - case *ast.CreateOpClassStmt: - if t := v.DataType; t != nil { - if s := toQualifiedName(t.Names); len(s) == 2 { - name = getOperatorPath(s[0], s[1]) - } - } - // case *ast.CreateCastStmt: - case *ast.AlterOperatorStmt: - if t := v.Opername; t != nil { - if s := toQualifiedName(t.Objname); len(s) == 2 { - name = getOperatorPath(s[0], s[1]) - } - } - // Schema level entities - others - case *ast.CommentStmt: - if s := getNodePath(v.Objtype, v.Object, nodeToPath); len(s) > 0 { - name = s - } - case *ast.AlterOwnerStmt: - if s := getNodePath(v.ObjectType, v.Object, nodeToPath); len(s) > 0 { - name = s - } - case *ast.GrantStmt: - if n := v.Objects; n != nil && len(n.Items) == 1 { - if s := getNodePath(v.Objtype, n.Items[0], nodeToPath); len(s) > 0 { - name = s - } - } - case *ast.AlterDefaultPrivilegesStmt: - if o := v.Options; o != nil { - for _, s := range o.Items { - if e, ok := s.(*ast.DefElem); ok && e.Defname == "schemas" { - if n := getQualifiedName(e.Arg); len(n) == 1 { - name = getSchemaPath(n[0]) - } - } - } - } - // TODO: Data level entities, ie. pg_cron, pgmq, etc. - case *ast.InsertStmt, *ast.UpdateStmt, *ast.DeleteStmt, *ast.CopyStmt, *ast.CallStmt, *ast.SelectStmt: - } - if name == unqualifiedPath { - fmt.Fprintf(utils.GetDebugLogger(), "Unqualified (%T): %s\n", parsed[0], line) - } else if strings.HasPrefix(name, utils.SchemasDir) { - schemaPaths = append(schemaPaths, name) - if filepath.Base(name) == "schema.sql" { - schema := filepath.Base(filepath.Dir(name)) - schemaPaths = append(schemaPaths, - getTypesPath(schema), - getSequencesPath(schema), - ) - } - } - if err := appendLine(name, line, fsys); err != nil { - return err - } - } - schemaPaths = append(schemaPaths, - unqualifiedPath, - publicationsPath, - subscriptionsPath, - eventTriggersPath, - ) - utils.Config.Db.Migrations.SchemaPaths = utils.RemoveDuplicates(schemaPaths) - return appendConfig(fsys) -} - -func getNodePath(obj ast.ObjectType, n ast.Node, seen map[string]string) string { - switch obj { - case ast.OBJECT_ACCESS_METHOD: - if s := getQualifiedName(n); len(s) == 1 { - if fp, found := seen[fmt.Sprintf("%s.%s", obj, s[0])]; found { - return fp - } - } - case ast.OBJECT_AGGREGATE: - if s := getQualifiedName(n); len(s) == 2 { - return getOperatorPath(s[0], s[1]) - } - case ast.OBJECT_AMOP: - if s := getQualifiedName(n); len(s) == 1 { - if fp, found := seen[fmt.Sprintf("%s.%s", obj, s[0])]; found { - return fp - } - } - case ast.OBJECT_AMPROC: - if s := getQualifiedName(n); len(s) == 1 { - if fp, found := seen[fmt.Sprintf("%s.%s", obj, s[0])]; found { - return fp - } - } - case ast.OBJECT_ATTRIBUTE: - if s := getQualifiedName(n); len(s) == 2 { - return getTypesPath(s[0]) - } - // case ast.OBJECT_CAST: - case ast.OBJECT_COLUMN: - if s := getQualifiedName(n); len(s) == 3 { - return getTablePath(s[0], s[1]) - } - case ast.OBJECT_COLLATION: - if s := getQualifiedName(n); len(s) == 2 { - return getSchemaPath(s[0]) - } - case ast.OBJECT_CONVERSION: - if s := getQualifiedName(n); len(s) == 2 { - return getFunctionPath(s[0], s[1]) - } - case ast.OBJECT_DATABASE: - return variablesPath - case ast.OBJECT_DEFAULT: - if s := getQualifiedName(n); len(s) == 1 { - return getSchemaPath(s[0]) - } - case ast.OBJECT_DEFACL: - if s := getQualifiedName(n); len(s) == 1 { - return getSchemaPath(s[0]) - } - case ast.OBJECT_DOMAIN: - if s := getQualifiedName(n); len(s) == 2 { - return getDomainPath(s[0], s[1]) - } - case ast.OBJECT_DOMCONSTRAINT: - if s := getQualifiedName(n); len(s) == 3 { - return getDomainPath(s[0], s[1]) - } - case ast.OBJECT_EVENT_TRIGGER: - return eventTriggersPath - case ast.OBJECT_EXTENSION: - return extensionsPath - case ast.OBJECT_FDW: - return foreignDWPath - case ast.OBJECT_FOREIGN_SERVER: - return foreignDWPath - case ast.OBJECT_FOREIGN_TABLE: - if s := getQualifiedName(n); len(s) == 2 { - return getTablePath(s[0], s[1]) - } - case ast.OBJECT_FUNCTION: - if s := getQualifiedName(n); len(s) == 2 { - return getFunctionPath(s[0], s[1]) - } - case ast.OBJECT_INDEX: - if s := getQualifiedName(n); len(s) == 1 { - if fp, found := seen[fmt.Sprintf("%s.%s", obj, s[0])]; found { - return fp - } - } - case ast.OBJECT_LANGUAGE: - if s := getQualifiedName(n); len(s) == 1 { - if fp, found := seen[fmt.Sprintf("%s.%s", obj, s[0])]; found { - return fp - } - } - // case ast.OBJECT_LARGEOBJECT: - case ast.OBJECT_MATVIEW: - if s := getQualifiedName(n); len(s) == 2 { - return getMaterializedViewPath(s[0], s[1]) - } - case ast.OBJECT_OPCLASS: - if s := getQualifiedName(n); len(s) == 3 { - return getOperatorPath(s[1], s[2]) - } - case ast.OBJECT_OPERATOR: - if s := getQualifiedName(n); len(s) == 2 { - return getOperatorPath(s[0], s[1]) - } - case ast.OBJECT_OPFAMILY: - if s := getQualifiedName(n); len(s) == 2 { - return getSchemaPath(s[0]) - } - case ast.OBJECT_PARAMETER_ACL: - return variablesPath - case ast.OBJECT_POLICY: - if s := getQualifiedName(n); len(s) == 3 { - return getPolicyPath(s[0], s[1]) - } - case ast.OBJECT_PROCEDURE: - if s := getQualifiedName(n); len(s) == 2 { - return getProcedurePath(s[0], s[1]) - } - case ast.OBJECT_PUBLICATION: - return publicationsPath - case ast.OBJECT_PUBLICATION_NAMESPACE: - return publicationsPath - case ast.OBJECT_PUBLICATION_REL: - return publicationsPath - case ast.OBJECT_ROLE: - return rolesPath - case ast.OBJECT_ROUTINE: - if s := getQualifiedName(n); len(s) == 2 { - return getFunctionPath(s[0], s[1]) - } - case ast.OBJECT_RULE: - if s := getQualifiedName(n); len(s) == 3 { - return getPolicyPath(s[0], s[1]) - } - case ast.OBJECT_SCHEMA: - if s := getQualifiedName(n); len(s) == 1 { - return getSchemaPath(s[0]) - } - case ast.OBJECT_SEQUENCE: - if s := getQualifiedName(n); len(s) == 2 { - return getSequenceOrTablePath(s[0], s[1], seen) - } - case ast.OBJECT_SUBSCRIPTION: - return subscriptionsPath - // case ast.OBJECT_STATISTIC_EXT: - case ast.OBJECT_TABCONSTRAINT: - if s := getQualifiedName(n); len(s) == 3 { - return getPolicyPath(s[0], s[1]) - } - case ast.OBJECT_TABLE: - if s := getQualifiedName(n); len(s) == 2 { - // View and table grants can share the same keyword - keys := []string{ - fmt.Sprintf("%s.%s.%s", obj, s[0], s[1]), - fmt.Sprintf("%s.%s.%s", ast.OBJECT_VIEW, s[0], s[1]), - fmt.Sprintf("%s.%s.%s", ast.OBJECT_MATVIEW, s[0], s[1]), - fmt.Sprintf("%s.%s.%s", ast.OBJECT_FOREIGN_TABLE, s[0], s[1]), - } - for _, k := range keys { - if fp, found := seen[k]; found { - return fp - } - } - return getTablePath(s[0], s[1]) - } - case ast.OBJECT_TABLESPACE: - return tablespacesPath - case ast.OBJECT_TRANSFORM: - if s := getQualifiedName(n); len(s) == 1 { - if fp, found := seen[fmt.Sprintf("%s.%s", obj, s[0])]; found { - return fp - } - } - case ast.OBJECT_TRIGGER: - if s := getQualifiedName(n); len(s) == 3 { - return getFunctionPath(s[0], s[1]) - } - case ast.OBJECT_TSCONFIGURATION: - if s := getQualifiedName(n); len(s) == 2 { - return getSchemaPath(s[0]) - } - case ast.OBJECT_TSDICTIONARY: - if s := getQualifiedName(n); len(s) == 2 { - return getSchemaPath(s[0]) - } - case ast.OBJECT_TSPARSER: - if s := getQualifiedName(n); len(s) == 2 { - return getSchemaPath(s[0]) - } - case ast.OBJECT_TSTEMPLATE: - if s := getQualifiedName(n); len(s) == 2 { - return getSchemaPath(s[0]) - } - case ast.OBJECT_TYPE: - if s := getQualifiedName(n); len(s) == 2 { - return getTypesPath(s[0]) - } - case ast.OBJECT_USER_MAPPING: - return foreignDWPath - case ast.OBJECT_VIEW: - if s := getQualifiedName(n); len(s) == 2 { - return getViewPath(s[0], s[1]) - } - } - fmt.Fprintf(utils.GetDebugLogger(), "\tObject %s: %T\n", obj, n) - return "" -} - -func getQualifiedName(n ast.Node) []string { - switch v := n.(type) { - case *ast.NodeList: - return toQualifiedName(v) - case *ast.TypeName: - return toQualifiedName(v.Names) - case *ast.ObjectWithArgs: - return toQualifiedName(v.Objname) - case *ast.RangeVar: - if len(v.SchemaName) > 0 { - return []string{v.SchemaName, v.RelName} - } - case *ast.String: - return []string{v.SVal} - } - return nil -} - -func toQualifiedName(n *ast.NodeList) []string { - if n == nil { - return nil - } - var r []string - for _, v := range n.Items { - if s, ok := v.(*ast.String); ok { - r = append(r, s.SVal) - } - } - return r -} - -func appendLine(name, data string, fsys afero.Fs) error { - if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(name)); err != nil { - return err - } - f, err := fsys.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) - if err != nil { - return errors.Errorf("failed to open file: %w", err) - } - defer f.Close() - if _, err := fmt.Fprintln(f, data); err != nil { - return errors.Errorf("failed to write file: %w", err) - } - return nil -} - -// Non-greedy match of any character in [], including new lines -var pattern = regexp.MustCompile(`(?s)\nschema_paths = \[(.*?)\]\n`) - -func appendConfig(fsys afero.Fs) error { - lines := []string{"\nschema_paths = ["} - for _, fp := range utils.Config.Db.Migrations.SchemaPaths { - relPath, err := filepath.Rel(utils.SupabaseDirPath, fp) - if err != nil { - return errors.Errorf("failed to resolve path: %w", err) - } - lines = append(lines, fmt.Sprintf(` "%s",`, relPath)) - } - lines = append(lines, "]\n") - schemaPaths := strings.Join(lines, "\n") - // Attempt in-line config replacement - data, err := afero.ReadFile(fsys, utils.ConfigPath) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return errors.Errorf("failed to read config: %w", err) - } - if newConfig := pattern.ReplaceAllLiteral(data, []byte(schemaPaths)); bytes.Contains(newConfig, []byte(schemaPaths)) { - return utils.WriteFile(utils.ConfigPath, newConfig, fsys) - } - // Fallback to append - f, err := fsys.OpenFile(utils.ConfigPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) - if err != nil { - return errors.Errorf("failed to open config: %w", err) - } - defer f.Close() - if _, err := f.WriteString("\n[db.migrations]"); err != nil { - return errors.Errorf("failed to write header: %w", err) - } - if _, err := f.WriteString(schemaPaths); err != nil { - return errors.Errorf("failed to write config: %w", err) - } - return nil -} diff --git a/apps/cli-go/internal/migration/format/format_test.go b/apps/cli-go/internal/migration/format/format_test.go deleted file mode 100644 index d67726b61b..0000000000 --- a/apps/cli-go/internal/migration/format/format_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package format - -import ( - "context" - "embed" - "fmt" - "io/fs" - "path" - "strings" - "testing" - - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/utils" -) - -//go:embed testdata -var testdata embed.FS - -func TestWriteStructured(t *testing.T) { - testCases, err := testdata.ReadDir("testdata") - require.NoError(t, err) - - for _, tc := range testCases { - testName := fmt.Sprintf("formats %s statements", tc.Name()) - testFs := afero.NewBasePathFs( - afero.FromIOFS{FS: testdata}, - path.Join("testdata", tc.Name()), - ) - const dumpPath = "dump.sql" - - t.Run(testName, func(t *testing.T) { - sql, err := testFs.Open(dumpPath) - require.NoError(t, err) - defer sql.Close() - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - err = WriteStructuredSchemas(context.Background(), sql, fsys) - // Check error - assert.NoError(t, err) - err = afero.Walk(testFs, ".", func(fp string, info fs.FileInfo, err error) error { - if err != nil || info.IsDir() || info.Name() == dumpPath { - return err - } - expected, err := afero.ReadFile(testFs, fp) - assert.NoError(t, err) - actual, _ := afero.ReadFile(fsys, path.Join(utils.SupabaseDirPath, fp)) - assert.Equal(t, string(expected), string(actual), fp) - return nil - }) - assert.NoError(t, err) - }) - } -} - -func TestAppendConfig(t *testing.T) { - t.Run("replaces config inline", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - assert.NoError(t, utils.WriteConfig(fsys, false)) - // Run test - utils.Config.Db.Migrations.SchemaPaths = []string{ - getSchemaPath("public"), - } - err := appendConfig(fsys) - // Check error - assert.NoError(t, err) - data, err := afero.ReadFile(fsys, utils.ConfigPath) - assert.NoError(t, err) - assert.True(t, strings.Contains(string(data), ` -schema_paths = [ - "schemas/public/schema.sql", -] -`)) - assert.True(t, strings.Contains( - strings.TrimSpace(string(data)), - `# format_options =`, - )) - }) - - t.Run("appends config file", func(t *testing.T) { - // Setup in-memory fs - fsys := afero.NewMemMapFs() - // Run test - utils.Config.Db.Migrations.SchemaPaths = []string{ - getSchemaPath("public"), - } - err := appendConfig(fsys) - // Check error - assert.NoError(t, err) - data, err := afero.ReadFile(fsys, utils.ConfigPath) - assert.NoError(t, err) - assert.Equal(t, ` -[db.migrations] -schema_paths = [ - "schemas/public/schema.sql", -] -`, string(data)) - }) -} diff --git a/apps/cli-go/internal/migration/format/testdata/comment/cluster/extensions.sql b/apps/cli-go/internal/migration/format/testdata/comment/cluster/extensions.sql deleted file mode 100644 index 33bf48048f..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/cluster/extensions.sql +++ /dev/null @@ -1 +0,0 @@ -COMMENT ON EXTENSION extension_name IS 'extension comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/comment/cluster/roles.sql b/apps/cli-go/internal/migration/format/testdata/comment/cluster/roles.sql deleted file mode 100644 index d33662b189..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/cluster/roles.sql +++ /dev/null @@ -1 +0,0 @@ -COMMENT ON ROLE role_name IS 'role comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/comment/cluster/variables.sql b/apps/cli-go/internal/migration/format/testdata/comment/cluster/variables.sql deleted file mode 100644 index 09cdeff396..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/cluster/variables.sql +++ /dev/null @@ -1 +0,0 @@ -COMMENT ON DATABASE database_name IS 'database comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/comment/dump.sql b/apps/cli-go/internal/migration/format/testdata/comment/dump.sql deleted file mode 100644 index 5d6abda868..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/dump.sql +++ /dev/null @@ -1,14 +0,0 @@ -COMMENT ON TABLE public.table_name IS 'table comment'; -COMMENT ON COLUMN public.table_name.column_name IS 'column comment'; -COMMENT ON VIEW public.view_name IS 'view comment'; -COMMENT ON MATERIALIZED VIEW public.matview_name IS 'matview comment'; -COMMENT ON SCHEMA public IS 'schema comment'; -COMMENT ON DATABASE database_name IS 'database comment'; -COMMENT ON INDEX public.index_name IS 'index comment'; -COMMENT ON CONSTRAINT constraint_name ON public.table_name IS 'constraint comment'; -COMMENT ON FUNCTION public.function_name(args) IS 'function comment'; -COMMENT ON PROCEDURE public.procedure_name(args) IS 'procedure comment'; -COMMENT ON TRIGGER trigger_name ON public.table_name IS 'trigger comment'; -COMMENT ON TYPE public.type_name IS 'type comment'; -COMMENT ON EXTENSION extension_name IS 'extension comment'; -COMMENT ON ROLE role_name IS 'role comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/functions/function_name.sql b/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/functions/function_name.sql deleted file mode 100644 index bc20425196..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/functions/function_name.sql +++ /dev/null @@ -1 +0,0 @@ -COMMENT ON FUNCTION public.function_name(args) IS 'function comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/materialized_views/matview_name.sql b/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/materialized_views/matview_name.sql deleted file mode 100644 index b41b07215d..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/materialized_views/matview_name.sql +++ /dev/null @@ -1 +0,0 @@ -COMMENT ON MATERIALIZED VIEW public.matview_name IS 'matview comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/policies/table_name.sql b/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/policies/table_name.sql deleted file mode 100644 index c3239db359..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/policies/table_name.sql +++ /dev/null @@ -1 +0,0 @@ -COMMENT ON CONSTRAINT constraint_name ON public.table_name IS 'constraint comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/procedures/procedure_name.sql b/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/procedures/procedure_name.sql deleted file mode 100644 index 0502a792b7..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/procedures/procedure_name.sql +++ /dev/null @@ -1 +0,0 @@ -COMMENT ON PROCEDURE public.procedure_name(args) IS 'procedure comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/schema.sql b/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/schema.sql deleted file mode 100644 index a9f11ea555..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/schema.sql +++ /dev/null @@ -1 +0,0 @@ -COMMENT ON SCHEMA public IS 'schema comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/tables/table_name.sql b/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/tables/table_name.sql deleted file mode 100644 index 6cef0a0d33..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/tables/table_name.sql +++ /dev/null @@ -1,2 +0,0 @@ -COMMENT ON TABLE public.table_name IS 'table comment'; -COMMENT ON COLUMN public.table_name.column_name IS 'column comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/types.sql b/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/types.sql deleted file mode 100644 index 7d7fa9e067..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/types.sql +++ /dev/null @@ -1 +0,0 @@ -COMMENT ON TYPE public.type_name IS 'type comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/views/view_name.sql b/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/views/view_name.sql deleted file mode 100644 index 71ea0a2d5f..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/schemas/public/views/view_name.sql +++ /dev/null @@ -1 +0,0 @@ -COMMENT ON VIEW public.view_name IS 'view comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/comment/schemas/unqualified.sql b/apps/cli-go/internal/migration/format/testdata/comment/schemas/unqualified.sql deleted file mode 100644 index d0abb5fe0b..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/comment/schemas/unqualified.sql +++ /dev/null @@ -1 +0,0 @@ -COMMENT ON INDEX public.index_name IS 'index comment'; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/cluster/event_triggers.sql b/apps/cli-go/internal/migration/format/testdata/owner/cluster/event_triggers.sql deleted file mode 100644 index 895d9e1d7c..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/cluster/event_triggers.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER EVENT TRIGGER trigger_name OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/cluster/foreign_data_wrappers.sql b/apps/cli-go/internal/migration/format/testdata/owner/cluster/foreign_data_wrappers.sql deleted file mode 100644 index 3c3dac6c0e..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/cluster/foreign_data_wrappers.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER FOREIGN DATA WRAPPER fdw_name OWNER TO new_owner; -ALTER SERVER server_name OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/cluster/publications.sql b/apps/cli-go/internal/migration/format/testdata/owner/cluster/publications.sql deleted file mode 100644 index 270b59ac60..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/cluster/publications.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER PUBLICATION publication_name OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/cluster/subscriptions.sql b/apps/cli-go/internal/migration/format/testdata/owner/cluster/subscriptions.sql deleted file mode 100644 index e34a1c33fa..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/cluster/subscriptions.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER SUBSCRIPTION subscription_name OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/cluster/tablespaces.sql b/apps/cli-go/internal/migration/format/testdata/owner/cluster/tablespaces.sql deleted file mode 100644 index 6319a8c62c..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/cluster/tablespaces.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLESPACE tablespace_name OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/cluster/variables.sql b/apps/cli-go/internal/migration/format/testdata/owner/cluster/variables.sql deleted file mode 100644 index fcbb7ff697..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/cluster/variables.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER DATABASE database_name OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/dump.sql b/apps/cli-go/internal/migration/format/testdata/owner/dump.sql deleted file mode 100644 index 1206eeda85..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/dump.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER DATABASE database_name OWNER TO new_owner; -ALTER TABLE public.table_name OWNER TO new_owner; -ALTER VIEW public.view_name OWNER TO new_owner; -ALTER SCHEMA public OWNER TO new_owner; -ALTER SEQUENCE public.sequence_name OWNER TO new_owner; -ALTER FUNCTION public.function_name(argument_types) OWNER TO new_owner; -ALTER TYPE public.type_name OWNER TO new_owner; -ALTER PUBLICATION publication_name OWNER TO new_owner; -ALTER SUBSCRIPTION subscription_name OWNER TO new_owner; -ALTER TABLESPACE tablespace_name OWNER TO new_owner; -ALTER FOREIGN DATA WRAPPER fdw_name OWNER TO new_owner; -ALTER SERVER server_name OWNER TO new_owner; -ALTER EVENT TRIGGER trigger_name OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/functions/function_name.sql b/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/functions/function_name.sql deleted file mode 100644 index a7c1685d35..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/functions/function_name.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER FUNCTION public.function_name(argument_types) OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/schema.sql b/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/schema.sql deleted file mode 100644 index 9a621fb239..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/schema.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER SCHEMA public OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/sequences.sql b/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/sequences.sql deleted file mode 100644 index 7d1d1a8522..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/sequences.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER SEQUENCE public.sequence_name OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/tables/table_name.sql b/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/tables/table_name.sql deleted file mode 100644 index e8a22ebbb6..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/tables/table_name.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE public.table_name OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/types.sql b/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/types.sql deleted file mode 100644 index 1435946c27..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/types.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TYPE public.type_name OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/views/view_name.sql b/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/views/view_name.sql deleted file mode 100644 index c4a005e439..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/owner/schemas/public/views/view_name.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER VIEW public.view_name OWNER TO new_owner; diff --git a/apps/cli-go/internal/migration/format/testdata/owner/schemas/unqualified.sql b/apps/cli-go/internal/migration/format/testdata/owner/schemas/unqualified.sql deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/apps/cli-go/internal/migration/format/testdata/simple/cluster/extensions.sql b/apps/cli-go/internal/migration/format/testdata/simple/cluster/extensions.sql deleted file mode 100644 index c7dfcfe2ac..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/simple/cluster/extensions.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS "pgsodium"; -CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql"; -CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions"; -CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions"; -CREATE EXTENSION IF NOT EXISTS "pgjwt" WITH SCHEMA "extensions"; -CREATE EXTENSION IF NOT EXISTS "postgis" WITH SCHEMA "extensions"; -CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault"; -CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions"; diff --git a/apps/cli-go/internal/migration/format/testdata/simple/cluster/publications.sql b/apps/cli-go/internal/migration/format/testdata/simple/cluster/publications.sql deleted file mode 100644 index 6242dd628a..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/simple/cluster/publications.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER PUBLICATION "supabase_realtime" OWNER TO "postgres"; diff --git a/apps/cli-go/internal/migration/format/testdata/simple/cluster/variables.sql b/apps/cli-go/internal/migration/format/testdata/simple/cluster/variables.sql deleted file mode 100644 index eddaf8e3f1..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/simple/cluster/variables.sql +++ /dev/null @@ -1,11 +0,0 @@ -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; -SET default_tablespace = ''; -SET default_table_access_method = "heap"; diff --git a/apps/cli-go/internal/migration/format/testdata/simple/dump.sql b/apps/cli-go/internal/migration/format/testdata/simple/dump.sql deleted file mode 100644 index 465653e435..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/simple/dump.sql +++ /dev/null @@ -1,73 +0,0 @@ -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; -CREATE EXTENSION IF NOT EXISTS "pgsodium"; -COMMENT ON SCHEMA "public" IS 'standard public schema'; -CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql"; -CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions"; -CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions"; -CREATE EXTENSION IF NOT EXISTS "pgjwt" WITH SCHEMA "extensions"; -CREATE EXTENSION IF NOT EXISTS "postgis" WITH SCHEMA "extensions"; -CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault"; -CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions"; -CREATE TYPE "public"."continents" AS ENUM ( - 'Africa', - 'Antarctica', - 'Asia', - 'Europe', - 'Oceania', - 'North America', - 'South America' -); -ALTER TYPE "public"."continents" OWNER TO "postgres"; -SET default_tablespace = ''; -SET default_table_access_method = "heap"; -CREATE TABLE IF NOT EXISTS "public"."countries" ( - "id" bigint NOT NULL, - "name" "text", - "iso2" "text" NOT NULL, - "iso3" "text", - "local_name" "text", - "continent" "public"."continents" -); -ALTER TABLE "public"."countries" OWNER TO "postgres"; -ALTER TABLE "public"."countries" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME "public"."countries_id_seq" - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1 -); -ALTER TABLE ONLY "public"."countries" - ADD CONSTRAINT "countries_pkey" PRIMARY KEY ("id"); -ALTER PUBLICATION "supabase_realtime" OWNER TO "postgres"; -GRANT USAGE ON SCHEMA "public" TO "postgres"; -GRANT USAGE ON SCHEMA "public" TO "anon"; -GRANT USAGE ON SCHEMA "public" TO "authenticated"; -GRANT USAGE ON SCHEMA "public" TO "service_role"; -GRANT ALL ON TABLE "public"."countries" TO "anon"; -GRANT ALL ON TABLE "public"."countries" TO "authenticated"; -GRANT ALL ON TABLE "public"."countries" TO "service_role"; -GRANT ALL ON SEQUENCE "public"."countries_id_seq" TO "anon"; -GRANT ALL ON SEQUENCE "public"."countries_id_seq" TO "authenticated"; -GRANT ALL ON SEQUENCE "public"."countries_id_seq" TO "service_role"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "postgres"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "anon"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "authenticated"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "service_role"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "postgres"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "anon"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "authenticated"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "service_role"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "postgres"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "anon"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "authenticated"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "service_role"; diff --git a/apps/cli-go/internal/migration/format/testdata/simple/schemas/public/schema.sql b/apps/cli-go/internal/migration/format/testdata/simple/schemas/public/schema.sql deleted file mode 100644 index cfb6c8e5c0..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/simple/schemas/public/schema.sql +++ /dev/null @@ -1,17 +0,0 @@ -COMMENT ON SCHEMA "public" IS 'standard public schema'; -GRANT USAGE ON SCHEMA "public" TO "postgres"; -GRANT USAGE ON SCHEMA "public" TO "anon"; -GRANT USAGE ON SCHEMA "public" TO "authenticated"; -GRANT USAGE ON SCHEMA "public" TO "service_role"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "postgres"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "anon"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "authenticated"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "service_role"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "postgres"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "anon"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "authenticated"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "service_role"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "postgres"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "anon"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "authenticated"; -ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "service_role"; diff --git a/apps/cli-go/internal/migration/format/testdata/simple/schemas/public/sequences.sql b/apps/cli-go/internal/migration/format/testdata/simple/schemas/public/sequences.sql deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/apps/cli-go/internal/migration/format/testdata/simple/schemas/public/tables/countries.sql b/apps/cli-go/internal/migration/format/testdata/simple/schemas/public/tables/countries.sql deleted file mode 100644 index 9eab6e4d33..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/simple/schemas/public/tables/countries.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE TABLE IF NOT EXISTS "public"."countries" ( - "id" bigint NOT NULL, - "name" "text", - "iso2" "text" NOT NULL, - "iso3" "text", - "local_name" "text", - "continent" "public"."continents" -); -ALTER TABLE "public"."countries" OWNER TO "postgres"; -ALTER TABLE "public"."countries" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME "public"."countries_id_seq" - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1 -); -ALTER TABLE ONLY "public"."countries" - ADD CONSTRAINT "countries_pkey" PRIMARY KEY ("id"); -GRANT ALL ON TABLE "public"."countries" TO "anon"; -GRANT ALL ON TABLE "public"."countries" TO "authenticated"; -GRANT ALL ON TABLE "public"."countries" TO "service_role"; -GRANT ALL ON SEQUENCE "public"."countries_id_seq" TO "anon"; -GRANT ALL ON SEQUENCE "public"."countries_id_seq" TO "authenticated"; -GRANT ALL ON SEQUENCE "public"."countries_id_seq" TO "service_role"; diff --git a/apps/cli-go/internal/migration/format/testdata/simple/schemas/public/types.sql b/apps/cli-go/internal/migration/format/testdata/simple/schemas/public/types.sql deleted file mode 100644 index ff1cb9bfe3..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/simple/schemas/public/types.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TYPE "public"."continents" AS ENUM ( - 'Africa', - 'Antarctica', - 'Asia', - 'Europe', - 'Oceania', - 'North America', - 'South America' -); -ALTER TYPE "public"."continents" OWNER TO "postgres"; diff --git a/apps/cli-go/internal/migration/format/testdata/simple/schemas/unqualified.sql b/apps/cli-go/internal/migration/format/testdata/simple/schemas/unqualified.sql deleted file mode 100644 index 1112b1bbbd..0000000000 --- a/apps/cli-go/internal/migration/format/testdata/simple/schemas/unqualified.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT pg_catalog.set_config('search_path', '', false); diff --git a/apps/cli-go/internal/pgdelta/apply.go b/apps/cli-go/internal/pgdelta/apply.go deleted file mode 100644 index 22fca756a6..0000000000 --- a/apps/cli-go/internal/pgdelta/apply.go +++ /dev/null @@ -1,354 +0,0 @@ -package pgdelta - -import ( - "bytes" - "context" - _ "embed" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/spf13/afero" - "github.com/spf13/viper" - "github.com/supabase/cli/internal/utils" - pkgconfig "github.com/supabase/cli/pkg/config" -) - -//go:embed templates/pgdelta_declarative_apply.ts -var pgDeltaDeclarativeApplyScript string - -// ApplyResult models the JSON payload emitted by pgdelta_declarative_apply.ts. -// -// The fields are surfaced to provide concise CLI feedback after apply runs. -type ApplyResult struct { - Status string `json:"status"` - TotalStatements int `json:"totalStatements"` - TotalRounds int `json:"totalRounds"` - TotalApplied int `json:"totalApplied"` - TotalSkipped int `json:"totalSkipped"` - Errors []ApplyIssue `json:"errors"` - StuckStatements []ApplyIssue `json:"stuckStatements"` - // ValidationErrors captures failures from pg-delta's final - // check_function_bodies=on pass. They are reported even when all - // statements applied cleanly, so must be surfaced explicitly. - ValidationErrors []ApplyIssue `json:"validationErrors,omitempty"` - Diagnostics []ApplyDiagnosis `json:"diagnostics,omitempty"` -} - -// ApplyIssue models a pg-delta apply error or stuck statement. -// -// pg-delta may emit either a plain string or a structured object, so unmarshal -// needs to gracefully handle both forms. -type ApplyIssue struct { - Statement *ApplyStatement `json:"statement,omitempty"` - Code string `json:"code,omitempty"` - Message string `json:"message,omitempty"` - IsDependencyError bool `json:"isDependencyError,omitempty"` - Position int `json:"position,omitempty"` - Detail string `json:"detail,omitempty"` - Hint string `json:"hint,omitempty"` -} - -// ApplyDiagnosis mirrors pg-topo's Diagnostic entries: static-analysis -// warnings that are surfaced alongside the apply result but don't cause -// failure on their own. Shape must stay in sync with the pg-topo package. -// -// UnmarshalJSON is implemented defensively so new or changed fields in -// pg-topo's Diagnostic do not break the whole apply result parse. Losing a -// diagnostic here would also swallow validationErrors and stuckStatements, -// leaving the user with a useless "failed to parse pg-delta apply output" -// message instead of the actual SQL error. -type ApplyDiagnosis struct { - Code string `json:"code,omitempty"` - Message string `json:"message,omitempty"` - StatementID *ApplyStatementLocation `json:"statementId,omitempty"` - SuggestedFix string `json:"suggestedFix,omitempty"` -} - -// ApplyStatementLocation matches pg-topo's StatementId shape. -type ApplyStatementLocation struct { - FilePath string `json:"filePath,omitempty"` - StatementIndex int `json:"statementIndex,omitempty"` - SourceOffset int `json:"sourceOffset,omitempty"` -} - -func (d *ApplyDiagnosis) UnmarshalJSON(data []byte) error { - trimmed := bytes.TrimSpace(data) - if bytes.Equal(trimmed, []byte("null")) { - *d = ApplyDiagnosis{} - return nil - } - // Unmarshal into a shadow type first so an unexpected statementId shape - // (string, missing fields, future additions) degrades gracefully instead - // of aborting the whole ApplyResult parse. - var raw struct { - Code string `json:"code"` - Message string `json:"message"` - StatementID json.RawMessage `json:"statementId"` - SuggestedFix string `json:"suggestedFix"` - } - if err := json.Unmarshal(trimmed, &raw); err != nil { - return err - } - d.Code = raw.Code - d.Message = raw.Message - d.SuggestedFix = raw.SuggestedFix - if len(bytes.TrimSpace(raw.StatementID)) == 0 || bytes.Equal(bytes.TrimSpace(raw.StatementID), []byte("null")) { - d.StatementID = nil - return nil - } - var loc ApplyStatementLocation - if err := json.Unmarshal(raw.StatementID, &loc); err == nil { - d.StatementID = &loc - return nil - } - // Fallback: accept a bare string (older pg-topo revisions) so we keep - // something printable instead of dropping the diagnostic entirely. - var asString string - if err := json.Unmarshal(raw.StatementID, &asString); err == nil { - d.StatementID = &ApplyStatementLocation{FilePath: asString} - } - return nil -} - -type ApplyStatement struct { - ID string `json:"id"` - SQL string `json:"sql"` - StatementClass string `json:"statementClass"` -} - -func (i *ApplyIssue) UnmarshalJSON(data []byte) error { - trimmed := bytes.TrimSpace(data) - if bytes.Equal(trimmed, []byte("null")) { - *i = ApplyIssue{} - return nil - } - var message string - if err := json.Unmarshal(trimmed, &message); err == nil { - *i = ApplyIssue{Message: message} - return nil - } - type alias ApplyIssue - var parsed alias - if err := json.Unmarshal(trimmed, &parsed); err != nil { - return err - } - *i = ApplyIssue(parsed) - return nil -} - -// formatApplyFailure renders a human-readable summary of an unsuccessful -// pg-delta apply result. When verbose is false (the default CLI output), -// pg-topo diagnostics are collapsed to a single-line summary because they are -// static-analysis warnings – not fatal errors – and can number in the -// hundreds for large schemas. Passing verbose=true (set by --debug) expands -// them to the full per-diagnostic listing. -func formatApplyFailure(result ApplyResult, verbose bool) string { - totalStatements := result.TotalStatements - if totalStatements == 0 { - totalStatements = result.TotalApplied + result.TotalSkipped + len(result.StuckStatements) - } - lines := []string{ - fmt.Sprintf("pg-delta apply returned status %q.", result.Status), - fmt.Sprintf("%d/%d statements applied in %d round(s); %d skipped.", result.TotalApplied, totalStatements, result.TotalRounds, result.TotalSkipped), - } - if len(result.Errors) > 0 { - lines = append(lines, "Errors:") - for _, issue := range result.Errors { - lines = append(lines, formatApplyIssue(issue)) - } - } - if len(result.StuckStatements) > 0 { - lines = append(lines, "Stuck statements:") - for _, issue := range result.StuckStatements { - lines = append(lines, formatApplyIssue(issue)) - } - } - if len(result.ValidationErrors) > 0 { - lines = append(lines, "Validation errors (from check_function_bodies=on pass):") - for _, issue := range result.ValidationErrors { - lines = append(lines, formatApplyIssue(issue)) - } - } - if len(result.Diagnostics) > 0 { - if verbose { - lines = append(lines, "Diagnostics:") - for _, d := range result.Diagnostics { - lines = append(lines, formatApplyDiagnosis(d)) - } - } else { - lines = append(lines, fmt.Sprintf("%d pg-topo diagnostic(s) omitted (re-run with --debug to view).", len(result.Diagnostics))) - } - } - // pg-delta may report status "error" without populating any issue arrays - // (e.g. an internal assertion in a future pg-delta release). Tell the user - // how to collect more information rather than leaving them with just the - // bare status line. - if len(result.Errors) == 0 && len(result.StuckStatements) == 0 && len(result.ValidationErrors) == 0 { - lines = append(lines, - "No per-statement diagnostics were reported by pg-delta.", - "Re-run with --debug to print the raw pg-delta payload, or open an issue at", - "https://github.com/supabase/pg-toolbelt/issues with the debug bundle attached.", - ) - } - return strings.Join(lines, "\n") -} - -func formatApplyIssue(issue ApplyIssue) string { - if issue.Statement == nil { - return "- " + formatApplyIssueMessage(issue) - } - title := "- " + issue.Statement.ID - if issue.Statement.StatementClass != "" { - title += " [" + issue.Statement.StatementClass + "]" - } - lines := []string{title} - lines = append(lines, " "+formatApplyIssueMessage(issue)) - if detail := strings.TrimSpace(issue.Detail); detail != "" { - lines = append(lines, " Detail: "+detail) - } - if hint := strings.TrimSpace(issue.Hint); hint != "" { - lines = append(lines, " Hint: "+hint) - } - if sql := formatStatementSQL(issue.Statement.SQL); sql != "" { - lines = append(lines, " SQL: "+sql) - } - return strings.Join(lines, "\n") -} - -func formatApplyIssueMessage(issue ApplyIssue) string { - message := strings.TrimSpace(issue.Message) - if message == "" { - message = "unknown pg-delta issue" - } - var metadata []string - if issue.Code != "" { - metadata = append(metadata, "SQLSTATE "+issue.Code) - } - if issue.Position > 0 { - metadata = append(metadata, fmt.Sprintf("position %d", issue.Position)) - } - if issue.IsDependencyError { - metadata = append(metadata, "dependency error") - } - if len(metadata) == 0 { - return message - } - return fmt.Sprintf("%s (%s)", message, strings.Join(metadata, ", ")) -} - -func formatApplyDiagnosis(d ApplyDiagnosis) string { - message := strings.TrimSpace(d.Message) - if message == "" { - message = "unknown pg-delta diagnostic" - } - parts := []string{"- "} - if code := strings.TrimSpace(d.Code); code != "" { - parts = append(parts, "["+code+"] ") - } - parts = append(parts, message) - if loc := formatStatementLocation(d.StatementID); loc != "" { - parts = append(parts, " ("+loc+")") - } - if fix := strings.TrimSpace(d.SuggestedFix); fix != "" { - parts = append(parts, "\n Suggested fix: "+fix) - } - return strings.Join(parts, "") -} - -func formatStatementLocation(loc *ApplyStatementLocation) string { - if loc == nil { - return "" - } - path := strings.TrimSpace(loc.FilePath) - if path == "" { - return "" - } - if loc.StatementIndex > 0 { - return fmt.Sprintf("%s#%d", path, loc.StatementIndex) - } - return path -} - -func formatStatementSQL(sql string) string { - normalized := strings.Join(strings.Fields(sql), " ") - const maxLen = 120 - if len(normalized) <= maxLen { - return normalized - } - return normalized[:maxLen-3] + "..." -} - -func formatDebugJSON(raw []byte) string { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) == 0 { - return "" - } - var indented bytes.Buffer - if err := json.Indent(&indented, trimmed, "", " "); err == nil { - return indented.String() - } - return string(trimmed) -} - -// ApplyDeclarative applies files from supabase/declarative to the target -// database using pg-delta's declarative apply engine. -// -// This is intentionally separate from migration apply so declarative workflows -// can evolve independently from timestamped migration execution. -func ApplyDeclarative(ctx context.Context, config pgconn.Config, fsys afero.Fs) error { - declarativeDir := utils.GetDeclarativeDir() - if _, err := fsys.Stat(declarativeDir); err != nil { - return errors.Errorf("declarative schema directory not found: %s", declarativeDir) - } - absDir, err := filepath.Abs(declarativeDir) - if err != nil { - return errors.Errorf("failed to resolve declarative dir: %w", err) - } - - const containerSchemaPath = "/declarative" - env := []string{ - "SCHEMA_PATH=" + containerSchemaPath, - "TARGET=" + utils.ToPostgresURL(config), - } - binds := []string{ - utils.EdgeRuntimeId + ":/root/.cache/deno:rw", - absDir + ":" + containerSchemaPath + ":ro", - } - - fmt.Fprintln(os.Stderr, "Applying declarative schemas via pg-delta...") - var stdout, stderr bytes.Buffer - script := pkgconfig.InterpolatePgDeltaScript(pkgconfig.Config(&utils.Config), pgDeltaDeclarativeApplyScript) - if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error running pg-delta script", &stdout, &stderr, utils.PgDeltaNpmRegistryOption()); err != nil { - return err - } - - var result ApplyResult - if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { - if viper.GetBool("DEBUG") { - return errors.Errorf("failed to parse pg-delta apply output: %w\nstdout: %s", err, stdout.String()) - } - return errors.Errorf("failed to parse pg-delta apply output: %w", err) - } - if result.Status != "success" { - // Always print the human-readable summary so failures are actionable - // even when --debug is set. In debug mode the summary also expands - // pg-topo diagnostics inline and we additionally dump the raw - // pg-delta payload so users can forward it when reporting bugs. - verbose := viper.GetBool("DEBUG") - fmt.Fprintln(os.Stderr, formatApplyFailure(result, verbose)) - if verbose { - if debugJSON := formatDebugJSON(stdout.Bytes()); len(debugJSON) > 0 { - fmt.Fprintln(os.Stderr, "pg-delta apply result:") - fmt.Fprintln(os.Stderr, debugJSON) - } - } - return errors.Errorf("pg-delta declarative apply failed with status: %s", result.Status) - } - fmt.Fprintf(os.Stderr, "Applied %d statements in %d round(s).\n", result.TotalApplied, result.TotalRounds) - return nil -} diff --git a/apps/cli-go/internal/pgdelta/apply_test.go b/apps/cli-go/internal/pgdelta/apply_test.go deleted file mode 100644 index bef780269e..0000000000 --- a/apps/cli-go/internal/pgdelta/apply_test.go +++ /dev/null @@ -1,324 +0,0 @@ -package pgdelta - -import ( - "encoding/json" - "strings" - "testing" -) - -func TestApplyResultUnmarshalStructuredStuckStatements(t *testing.T) { - raw := []byte(`{ - "status": "stuck", - "totalStatements": 34, - "totalRounds": 2, - "totalApplied": 29, - "totalSkipped": 0, - "errors": [], - "stuckStatements": [ - { - "statement": { - "id": "cluster/extensions/pgmq.sql:0", - "sql": "CREATE EXTENSION pgmq WITH SCHEMA pgmq;", - "statementClass": "CREATE_EXTENSION" - }, - "code": "3F000", - "message": "schema \"pgmq\" does not exist", - "isDependencyError": true - } - ] - }`) - - var result ApplyResult - if err := json.Unmarshal(raw, &result); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - - if got, want := len(result.StuckStatements), 1; got != want { - t.Fatalf("len(StuckStatements) = %d, want %d", got, want) - } - - stuck := result.StuckStatements[0] - if stuck.Statement == nil { - t.Fatal("expected structured statement details") - } - if got, want := stuck.Statement.ID, "cluster/extensions/pgmq.sql:0"; got != want { - t.Fatalf("Statement.ID = %q, want %q", got, want) - } - if got, want := stuck.Statement.StatementClass, "CREATE_EXTENSION"; got != want { - t.Fatalf("Statement.StatementClass = %q, want %q", got, want) - } - if got, want := stuck.Code, "3F000"; got != want { - t.Fatalf("Code = %q, want %q", got, want) - } - if got, want := stuck.Message, `schema "pgmq" does not exist`; got != want { - t.Fatalf("Message = %q, want %q", got, want) - } - if !stuck.IsDependencyError { - t.Fatal("expected dependency error to be preserved") - } -} - -func TestFormatApplyFailure(t *testing.T) { - result := ApplyResult{ - Status: "stuck", - TotalStatements: 34, - TotalRounds: 2, - TotalApplied: 29, - TotalSkipped: 0, - StuckStatements: []ApplyIssue{ - { - Statement: &ApplyStatement{ - ID: "cluster/extensions/pgmq.sql:0", - SQL: "CREATE EXTENSION pgmq WITH SCHEMA pgmq;", - StatementClass: "CREATE_EXTENSION", - }, - Code: "3F000", - Message: `schema "pgmq" does not exist`, - IsDependencyError: true, - }, - }, - } - - formatted := formatApplyFailure(result, false) - assertContains(t, formatted, `pg-delta apply returned status "stuck"`) - assertContains(t, formatted, `29/34 statements applied in 2 round(s)`) - assertContains(t, formatted, `cluster/extensions/pgmq.sql:0 [CREATE_EXTENSION]`) - assertContains(t, formatted, `schema "pgmq" does not exist (SQLSTATE 3F000, dependency error)`) - assertContains(t, formatted, `SQL: CREATE EXTENSION pgmq WITH SCHEMA pgmq;`) -} - -// TestApplyResultUnmarshalValidationErrors reproduces the payload shape pg-delta -// emits when the final check_function_bodies=on pass fails: totalApplied -// matches totalStatements, errors and stuckStatements are empty, but status is -// "error" because validationErrors is non-empty. -func TestApplyResultUnmarshalValidationErrors(t *testing.T) { - raw := []byte(`{ - "status": "error", - "totalStatements": 1633, - "totalRounds": 1, - "totalApplied": 1633, - "totalSkipped": 0, - "errors": [], - "stuckStatements": [], - "validationErrors": [ - { - "statement": { - "id": "public/functions/my_function.sql:0", - "sql": "CREATE FUNCTION public.my_function() RETURNS integer LANGUAGE sql AS $$ SELECT missing_column FROM users $$;", - "statementClass": "CREATE_FUNCTION" - }, - "code": "42703", - "message": "column \"missing_column\" does not exist", - "isDependencyError": false, - "position": 8, - "hint": "Perhaps you meant to reference the column \"users.missing_column_renamed\"." - } - ] - }`) - - var result ApplyResult - if err := json.Unmarshal(raw, &result); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - - if got, want := len(result.ValidationErrors), 1; got != want { - t.Fatalf("len(ValidationErrors) = %d, want %d", got, want) - } - - issue := result.ValidationErrors[0] - if issue.Statement == nil { - t.Fatal("expected structured statement details") - } - if got, want := issue.Statement.ID, "public/functions/my_function.sql:0"; got != want { - t.Fatalf("Statement.ID = %q, want %q", got, want) - } - if got, want := issue.Code, "42703"; got != want { - t.Fatalf("Code = %q, want %q", got, want) - } - if got, want := issue.Position, 8; got != want { - t.Fatalf("Position = %d, want %d", got, want) - } - if issue.Hint == "" { - t.Fatal("expected Hint to be preserved") - } -} - -func TestFormatApplyFailureValidationErrors(t *testing.T) { - result := ApplyResult{ - Status: "error", - TotalStatements: 1633, - TotalRounds: 1, - TotalApplied: 1633, - TotalSkipped: 0, - ValidationErrors: []ApplyIssue{ - { - Statement: &ApplyStatement{ - ID: "public/functions/my_function.sql:0", - SQL: "CREATE FUNCTION public.my_function() RETURNS integer LANGUAGE sql AS $$ SELECT missing_column FROM users $$;", - StatementClass: "CREATE_FUNCTION", - }, - Code: "42703", - Message: `column "missing_column" does not exist`, - Position: 8, - Hint: `Perhaps you meant to reference the column "users.missing_column_renamed".`, - }, - }, - } - - formatted := formatApplyFailure(result, false) - assertContains(t, formatted, `pg-delta apply returned status "error"`) - assertContains(t, formatted, `1633/1633 statements applied in 1 round(s)`) - assertContains(t, formatted, "Validation errors (from check_function_bodies=on pass):") - assertContains(t, formatted, "public/functions/my_function.sql:0 [CREATE_FUNCTION]") - assertContains(t, formatted, `column "missing_column" does not exist (SQLSTATE 42703, position 8)`) - assertContains(t, formatted, "Hint: Perhaps you meant to reference the column") -} - -// TestFormatApplyFailureNoDiagnostics exercises the fallback text we render -// when pg-delta returns status=error without any structured issues. The user -// originally reported seeing a bare error message in this situation. -func TestFormatApplyFailureNoDiagnostics(t *testing.T) { - result := ApplyResult{ - Status: "error", - TotalStatements: 1633, - TotalRounds: 1, - TotalApplied: 1633, - TotalSkipped: 0, - } - - formatted := formatApplyFailure(result, false) - assertContains(t, formatted, `pg-delta apply returned status "error"`) - assertContains(t, formatted, "No per-statement diagnostics were reported by pg-delta") - assertContains(t, formatted, "--debug") -} - -// TestApplyResultUnmarshalRealWorldPayload covers the full shape pg-delta emits -// in practice, including diagnostics whose statementId is an object. Before we -// made ApplyDiagnosis.UnmarshalJSON defensive, this payload caused the entire -// result parse to fail with "cannot unmarshal object into Go struct field -// ApplyDiagnosis.diagnostics.statementId of type string", which in turn hid -// the real validation error from the user. -func TestApplyResultUnmarshalRealWorldPayload(t *testing.T) { - raw := []byte(`{ - "status": "error", - "totalStatements": 1625, - "totalRounds": 1, - "totalApplied": 1625, - "totalSkipped": 0, - "errors": [], - "stuckStatements": [], - "validationErrors": [ - { - "statement": { - "id": "schemas/public/functions/create_device.sql:0", - "sql": "CREATE FUNCTION public.create_device () RETURNS void LANGUAGE plpgsql AS $function$BEGIN Invalid sql statement; END;$function$;", - "statementClass": "CREATE_FUNCTION" - }, - "code": "42601", - "message": "syntax error at or near \"Invalid\"", - "isDependencyError": false, - "position": 541 - } - ], - "diagnostics": [ - { - "code": "UNRESOLVED_DEPENDENCY", - "message": "No producer found for 'function:pgmq:delete:(unknown,unknown)'.", - "statementId": { - "filePath": "schemas/public/functions/pgmq_delete.sql", - "statementIndex": 0, - "sourceOffset": 0 - }, - "objectRefs": [ - {"kind": "function", "name": "delete", "schema": "pgmq", "signature": "(unknown,unknown)"} - ], - "suggestedFix": "Add the missing statement to your SQL set or declare an explicit pg-topo annotation.", - "details": { - "requiredObjectKey": "function:pgmq:delete:(unknown,unknown)", - "candidateObjectKeys": [] - } - } - ] - }`) - - var result ApplyResult - if err := json.Unmarshal(raw, &result); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - - if got, want := len(result.ValidationErrors), 1; got != want { - t.Fatalf("len(ValidationErrors) = %d, want %d", got, want) - } - if got, want := result.ValidationErrors[0].Message, `syntax error at or near "Invalid"`; got != want { - t.Fatalf("ValidationErrors[0].Message = %q, want %q", got, want) - } - - if got, want := len(result.Diagnostics), 1; got != want { - t.Fatalf("len(Diagnostics) = %d, want %d", got, want) - } - diag := result.Diagnostics[0] - if diag.StatementID == nil { - t.Fatal("expected StatementID to be preserved as a structured location") - } - if got, want := diag.StatementID.FilePath, "schemas/public/functions/pgmq_delete.sql"; got != want { - t.Fatalf("StatementID.FilePath = %q, want %q", got, want) - } - if got, want := diag.Code, "UNRESOLVED_DEPENDENCY"; got != want { - t.Fatalf("Code = %q, want %q", got, want) - } - if diag.SuggestedFix == "" { - t.Fatal("expected SuggestedFix to be preserved") - } - - // Default (non-verbose) output collapses the diagnostics to a single line - // so the user isn't flooded with pg-topo warnings on large schemas. - formatted := formatApplyFailure(result, false) - assertContains(t, formatted, "Validation errors (from check_function_bodies=on pass):") - assertContains(t, formatted, "schemas/public/functions/create_device.sql:0 [CREATE_FUNCTION]") - assertContains(t, formatted, `syntax error at or near "Invalid" (SQLSTATE 42601, position 541)`) - assertContains(t, formatted, "1 pg-topo diagnostic(s) omitted (re-run with --debug to view).") - assertNotContains(t, formatted, "[UNRESOLVED_DEPENDENCY]") - - // Verbose mode (triggered by --debug) expands the diagnostics inline. - verbose := formatApplyFailure(result, true) - assertContains(t, verbose, "Diagnostics:") - assertContains(t, verbose, "[UNRESOLVED_DEPENDENCY]") - assertContains(t, verbose, "schemas/public/functions/pgmq_delete.sql") - assertNotContains(t, verbose, "pg-topo diagnostic(s) omitted") -} - -// TestApplyDiagnosisFallbackStatementIdString covers the defensive path where -// pg-topo emits statementId as a string (older revisions) so the diagnostic -// still survives the parse. -func TestApplyDiagnosisFallbackStatementIdString(t *testing.T) { - raw := []byte(`{ - "code": "LEGACY", - "message": "legacy diagnostic shape", - "statementId": "schemas/foo.sql:0" - }`) - - var d ApplyDiagnosis - if err := json.Unmarshal(raw, &d); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if d.StatementID == nil { - t.Fatal("expected StatementID to be populated from legacy string shape") - } - if got, want := d.StatementID.FilePath, "schemas/foo.sql:0"; got != want { - t.Fatalf("StatementID.FilePath = %q, want %q", got, want) - } -} - -func assertContains(t *testing.T, text, want string) { - t.Helper() - if !strings.Contains(text, want) { - t.Fatalf("expected %q to contain %q", text, want) - } -} - -func assertNotContains(t *testing.T, text, unwanted string) { - t.Helper() - if strings.Contains(text, unwanted) { - t.Fatalf("expected %q to NOT contain %q", text, unwanted) - } -} diff --git a/apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go b/apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go deleted file mode 100644 index c0a7e601f3..0000000000 --- a/apps/cli-go/internal/pgdelta/pgdelta_apply_template_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package pgdelta - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// The declarative-apply script connects to TARGET and must force the worker's -// event loop closed once it has written its result JSON. applyDeclarativeSchema -// can leave connection keepalive handles registered, and if the worker never -// exits the container never stops — the CLI, which follows the container logs -// with Follow:true, then hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312). -// The success path must terminate unconditionally, so guard against the -// force-close being dropped. -func TestDeclarativeApplyScriptForceClosesOnSuccess(t *testing.T) { - require.NotEmpty(t, pgDeltaDeclarativeApplyScript) - - lines := strings.Split(pgDeltaDeclarativeApplyScript, "\n") - last := "" - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line == "" || strings.HasPrefix(line, "//") { - continue - } - last = line - break - } - assert.Equal(t, `throw new Error("");`, last, - "success path must force the Edge Runtime worker to exit so the container stops") -} diff --git a/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts b/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts deleted file mode 100644 index 9dfb07cf62..0000000000 --- a/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts +++ /dev/null @@ -1,61 +0,0 @@ -// This script applies declarative schema files to a target database and emits -// structured JSON so the Go caller can report success/failure deterministically. -import { - applyDeclarativeSchema, - loadDeclarativeSchema, -} from "npm:@supabase/pg-delta@1.0.0-alpha.20/declarative"; - -const schemaPath = Deno.env.get("SCHEMA_PATH"); -const target = Deno.env.get("TARGET"); - -if (!schemaPath) { - throw new Error("SCHEMA_PATH is required"); -} -if (!target) { - throw new Error("TARGET is required"); -} - -try { - const content = await loadDeclarativeSchema(schemaPath); - if (content.length === 0) { - console.log(JSON.stringify({ status: "success", totalStatements: 0 })); - } else { - const result = await applyDeclarativeSchema({ - content, - targetUrl: target, - }); - const apply = result?.apply; - if (!apply) { - throw new Error("pg-delta apply returned no result"); - } - const payload = { - status: apply.status, - totalStatements: result.totalStatements ?? 0, - totalRounds: apply.totalRounds ?? 0, - totalApplied: apply.totalApplied ?? 0, - totalSkipped: apply.totalSkipped ?? 0, - errors: apply.errors ?? [], - stuckStatements: apply.stuckStatements ?? [], - // validationErrors is populated when the final - // check_function_bodies=on pass catches issues that didn't surface during - // the initial apply rounds (e.g. a function body that references a - // column whose type changed). Without surfacing this field, callers see - // status=error with empty errors/stuckStatements and no actionable info. - validationErrors: apply.validationErrors ?? [], - diagnostics: result.diagnostics ?? [], - }; - console.log(JSON.stringify(payload)); - if (apply.status !== "success") { - throw new Error("pg-delta apply failed with status: " + apply.status); - } - } -} catch (e) { - throw e instanceof Error ? e : new Error(String(e)); -} -// Force close the event loop on the success path. applyDeclarativeSchema opens a -// connection to TARGET whose keepalive handles can keep the Edge Runtime worker -// alive after the result JSON has been written, so the container never exits and -// the CLI — which follows this container's logs — hangs indefinitely at 0% CPU -// (supabase/pg-toolbelt#312). The catch above re-throws the real error, so this -// only runs once a successful apply has been reported on stdout. -throw new Error(""); diff --git a/apps/cli-go/internal/utils/container_output.go b/apps/cli-go/internal/utils/container_output.go index bd3e19d1f6..912d071a41 100644 --- a/apps/cli-go/internal/utils/container_output.go +++ b/apps/cli-go/internal/utils/container_output.go @@ -1,15 +1,8 @@ package utils import ( - "bufio" - "bytes" "encoding/json" - "fmt" "io" - "os" - "regexp" - "slices" - "strconv" "strings" "github.com/docker/docker/pkg/jsonmessage" @@ -58,144 +51,3 @@ func ProcessPullOutput(out io.ReadCloser, p Program) error { return nil } - -type DiffStream struct { - o bytes.Buffer - r *io.PipeReader - w *io.PipeWriter - p Program -} - -func NewDiffStream(p Program) *DiffStream { - r, w := io.Pipe() - go func() { - if err := ProcessDiffProgress(p, r); err != nil { - fmt.Fprintln(os.Stderr, err) - } - }() - return &DiffStream{r: r, w: w, p: p} -} - -func (c DiffStream) Stdout() io.Writer { - return &c.o -} - -func (c DiffStream) Stderr() io.Writer { - return c.w -} - -func (c DiffStream) Collect() ([]byte, error) { - if err := c.w.Close(); err != nil { - fmt.Fprintln(os.Stderr, "Failed to close stream:", err) - } - return ProcessDiffOutput(c.o.Bytes()) -} - -func ProcessDiffProgress(p Program, out io.Reader) error { - scanner := bufio.NewScanner(out) - re := regexp.MustCompile(`(.*)([[:digit:]]{2,3})%`) - for scanner.Scan() { - line := scanner.Text() - - if line == "Starting schema diff..." { - percentage := 0.0 - p.Send(ProgressMsg(&percentage)) - } - - matches := re.FindStringSubmatch(line) - if len(matches) != 3 { - // TODO: emit actual error statements - continue - } - - p.Send(StatusMsg(matches[1])) - percentage, err := strconv.ParseFloat(matches[2], 64) - if err != nil { - continue - } - percentage = percentage / 100 - p.Send(ProgressMsg(&percentage)) - } - p.Send(ProgressMsg(nil)) - return scanner.Err() -} - -type DiffDependencies struct { - Type string `json:"type"` -} - -type DiffEntry struct { - Type string `json:"type"` - Status string `json:"status"` - DiffDdl string `json:"diff_ddl"` - GroupName string `json:"group_name"` - Dependencies []DiffDependencies `json:"dependencies"` - SourceSchemaName *string `json:"source_schema_name"` -} - -const diffHeader = `-- This script was generated by the Schema Diff utility in pgAdmin 4 --- For the circular dependencies, the order in which Schema Diff writes the objects is not very sophisticated --- and may require manual changes to the script to ensure changes are applied in the correct order. --- Please report an issue for any failure with the reproduction steps.` - -func ProcessDiffOutput(diffBytes []byte) ([]byte, error) { - // TODO: Remove when https://github.com/supabase/pgadmin4/issues/24 is fixed. - diffBytes = bytes.TrimPrefix(diffBytes, []byte("NOTE: Configuring authentication for DESKTOP mode.\n")) - - if len(diffBytes) == 0 { - return diffBytes, nil - } - - var diffJson []DiffEntry - if err := json.Unmarshal(diffBytes, &diffJson); err != nil { - return nil, err - } - - var filteredDiffDdls []string - for _, diffEntry := range diffJson { - if diffEntry.Status == "Identical" || diffEntry.DiffDdl == "" { - continue - } - - switch diffEntry.Type { - case "extension", "function", "mview", "table", "trigger_function", "type", "view": - // skip - default: - continue - } - - { - doContinue := false - for _, dep := range diffEntry.Dependencies { - if dep.Type == "extension" { - doContinue = true - break - } - } - - if doContinue { - continue - } - } - - isSchemaIgnored := func(schema string) bool { - return slices.Contains(InternalSchemas, schema) - } - - if isSchemaIgnored(diffEntry.GroupName) || - // Needed at least for trigger_function - (diffEntry.SourceSchemaName != nil && isSchemaIgnored(*diffEntry.SourceSchemaName)) { - continue - } - - trimmed := strings.TrimSpace(diffEntry.DiffDdl) - if len(trimmed) > 0 { - filteredDiffDdls = append(filteredDiffDdls, trimmed) - } - } - - if len(filteredDiffDdls) == 0 { - return nil, nil - } - return []byte(diffHeader + "\n\n" + strings.Join(filteredDiffDdls, "\n\n") + "\n"), nil -} diff --git a/apps/cli-go/internal/utils/container_output_test.go b/apps/cli-go/internal/utils/container_output_test.go index 3250846183..44376e2d34 100644 --- a/apps/cli-go/internal/utils/container_output_test.go +++ b/apps/cli-go/internal/utils/container_output_test.go @@ -9,60 +9,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/docker/docker/pkg/jsonmessage" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestProcessDiffOutput(t *testing.T) { - t.Run("processes valid diff entries", func(t *testing.T) { - input := []DiffEntry{ - { - Type: "table", - Status: "Different", - DiffDdl: "ALTER TABLE test;", - GroupName: "public", - }, - { - Type: "extension", - Status: "Different", - DiffDdl: "CREATE EXTENSION test;", - GroupName: "public", - }, - } - inputBytes, err := json.Marshal(input) - require.NoError(t, err) - - output, err := ProcessDiffOutput(inputBytes) - - assert.NoError(t, err) - assert.Contains(t, string(output), "ALTER TABLE test;") - assert.Contains(t, string(output), "CREATE EXTENSION test;") - }) - - t.Run("filters out internal schemas", func(t *testing.T) { - input := []DiffEntry{ - { - Type: "table", - Status: "Different", - DiffDdl: "ALTER TABLE test;", - GroupName: "auth", - }, - { - Type: "extension", - Status: "Different", - DiffDdl: "CREATE EXTENSION test;", - GroupName: "auth", - }, - } - inputBytes, err := json.Marshal(input) - require.NoError(t, err) - - output, err := ProcessDiffOutput(inputBytes) - - assert.NoError(t, err) - assert.Nil(t, output) - }) -} - func TestProcessPullOutput(t *testing.T) { t.Run("processes docker pull messages", func(t *testing.T) { messages := []jsonmessage.JSONMessage{ diff --git a/apps/cli-go/internal/utils/misc.go b/apps/cli-go/internal/utils/misc.go index 8805544430..186573146b 100644 --- a/apps/cli-go/internal/utils/misc.go +++ b/apps/cli-go/internal/utils/misc.go @@ -94,7 +94,6 @@ var ( PgmetaVersionPath = filepath.Join(TempDir, "pgmeta-version") PoolerVersionPath = filepath.Join(TempDir, "pooler-version") RealtimeVersionPath = filepath.Join(TempDir, "realtime-version") - PgDeltaVersionPath = filepath.Join(TempDir, "pgdelta-version") CliVersionPath = filepath.Join(TempDir, "cli-latest") CurrBranchPath = filepath.Join(SupabaseDirPath, ".branches", "_current_branch") // DeclarativeDir is the canonical location for pg-delta declarative schema diff --git a/apps/cli-go/internal/utils/pgdelta_local.go b/apps/cli-go/internal/utils/pgdelta_local.go deleted file mode 100644 index 50232b238e..0000000000 --- a/apps/cli-go/internal/utils/pgdelta_local.go +++ /dev/null @@ -1,44 +0,0 @@ -package utils - -import ( - "os" - "strings" - - "github.com/supabase/cli/pkg/config" -) - -// PgDeltaNpmRegistryOption returns an EdgeRuntimeOption that points the -// edge-runtime container at a user-controlled npm registry when -// PGDELTA_NPM_REGISTRY is set. It applies three coordinated overrides: -// -// 1. Writes a project-local `.npmrc` with a `@supabase`-scoped registry -// line. Deno honors `.npmrc` for scoped registries when discovered in -// the cwd or parents (Deno >= 1.39), so this keeps every non-`@supabase` -// npm specifier on npmjs. -// 2. Forwards the canonical `NPM_CONFIG_REGISTRY` env var into the -// container. This is the universal npm/Deno escape hatch — it routes -// every `npm:` specifier through the chosen registry regardless of -// whether the host runtime reads `.npmrc`. Verdaccio's `npmjs` uplink -// proxies any non-`@supabase` packages back to npmjs, so widening the -// scope is safe and protects us against edge-runtime image variants -// that ignore `.npmrc`. -// 3. Forwards `PGDELTA_NPM_REGISTRY` itself into the container. -// -// Returns nil when the env var is unset or whitespace-only, which makes it -// safe to pass unconditionally to RunEdgeRuntimeScript (nil options are -// ignored). -func PgDeltaNpmRegistryOption() EdgeRuntimeOption { - registry := strings.TrimSpace(os.Getenv(config.PgDeltaNpmRegistryEnv)) - if registry == "" { - return nil - } - npmrc := WithExtraFile(".npmrc", "@supabase:registry="+registry+"\n") - envFwd := WithExtraEnv( - config.PgDeltaNpmRegistryEnv+"="+registry, - "NPM_CONFIG_REGISTRY="+registry, - ) - return func(o *edgeRuntimeOptions) { - npmrc(o) - envFwd(o) - } -} diff --git a/apps/cli-go/internal/utils/pgdelta_local_test.go b/apps/cli-go/internal/utils/pgdelta_local_test.go deleted file mode 100644 index 656e17c4b2..0000000000 --- a/apps/cli-go/internal/utils/pgdelta_local_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package utils - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/cli/pkg/config" -) - -func TestPgDeltaNpmRegistryOption(t *testing.T) { - t.Run("returns nil when PGDELTA_NPM_REGISTRY is unset", func(t *testing.T) { - t.Setenv(config.PgDeltaNpmRegistryEnv, "") - assert.Nil(t, PgDeltaNpmRegistryOption()) - }) - - t.Run("writes a scoped .npmrc and forwards both PGDELTA_NPM_REGISTRY and NPM_CONFIG_REGISTRY when set", func(t *testing.T) { - t.Setenv(config.PgDeltaNpmRegistryEnv, "http://host.docker.internal:4873") - opt := PgDeltaNpmRegistryOption() - require.NotNil(t, opt) - - state := &edgeRuntimeOptions{} - opt(state) - require.Len(t, state.extraFiles, 1) - assert.Equal(t, ".npmrc", state.extraFiles[0].name) - assert.Equal(t, - "@supabase:registry=http://host.docker.internal:4873\n", - state.extraFiles[0].content, - ) - // NPM_CONFIG_REGISTRY is the universal escape hatch for runtimes - // that ignore .npmrc (e.g. some supabase/edge-runtime variants); - // PGDELTA_NPM_REGISTRY is forwarded so scripts can read the configured - // registry URL when needed. - assert.Equal(t, - []string{ - "PGDELTA_NPM_REGISTRY=http://host.docker.internal:4873", - "NPM_CONFIG_REGISTRY=http://host.docker.internal:4873", - }, - state.extraEnv, - ) - }) - - t.Run("trims surrounding whitespace from the registry URL", func(t *testing.T) { - t.Setenv(config.PgDeltaNpmRegistryEnv, " http://localhost:4873 ") - opt := PgDeltaNpmRegistryOption() - require.NotNil(t, opt) - - state := &edgeRuntimeOptions{} - opt(state) - require.Len(t, state.extraFiles, 1) - assert.Equal(t, - "@supabase:registry=http://localhost:4873\n", - state.extraFiles[0].content, - ) - assert.Equal(t, - []string{ - "PGDELTA_NPM_REGISTRY=http://localhost:4873", - "NPM_CONFIG_REGISTRY=http://localhost:4873", - }, - state.extraEnv, - ) - }) -} diff --git a/apps/cli-go/pkg/config/config.go b/apps/cli-go/pkg/config/config.go index 7fb7b684ed..04eae99289 100644 --- a/apps/cli-go/pkg/config/config.go +++ b/apps/cli-go/pkg/config/config.go @@ -323,8 +323,6 @@ type ( Enabled bool `toml:"enabled" json:"enabled"` DeclarativeSchemaPath string `toml:"declarative_schema_path" json:"declarative_schema_path"` FormatOptions string `toml:"format_options" json:"format_options"` - // NpmVersion is set from .temp/pgdelta-version during Load (not from TOML). - NpmVersion string `toml:"-" json:"-"` } inspect struct { @@ -868,16 +866,6 @@ func (c *config) Load(path string, fsys fs.FS, overrides ...ConfigEditor) error if version, err := fs.ReadFile(fsys, builder.LogflareVersionPath); err == nil && len(version) > 0 { c.Analytics.Image = replaceImageTag(Images.Logflare, string(version)) } - v := DefaultPgDeltaNpmVersion - if version, err := fs.ReadFile(fsys, builder.PgDeltaVersionPath); err == nil { - if trimmed := strings.TrimSpace(string(version)); len(trimmed) > 0 { - v = trimmed - } - } - if c.Experimental.PgDelta == nil { - c.Experimental.PgDelta = &PgDeltaConfig{} - } - c.Experimental.PgDelta.NpmVersion = v // TODO: replace derived config resolution with viper decode hooks if err := c.resolve(builder, fsys); err != nil { return err diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go index f09803bc4e..4190d71371 100644 --- a/apps/cli-go/pkg/config/config_test.go +++ b/apps/cli-go/pkg/config/config_test.go @@ -311,57 +311,6 @@ instances = 3 }) } -func TestPgDeltaNpmVersionPinning(t *testing.T) { - t.Run("defaults when pgdelta-version file missing", func(t *testing.T) { - c := NewConfig() - require.NoError(t, c.Load("", fs.MapFS{})) - require.NotNil(t, c.Experimental.PgDelta) - assert.Equal(t, DefaultPgDeltaNpmVersion, c.Experimental.PgDelta.NpmVersion) - assert.Equal(t, DefaultPgDeltaNpmVersion, EffectivePgDeltaNpmVersion(Config(&c))) - }) - - t.Run("EffectivePgDeltaNpmVersion nil config uses default", func(t *testing.T) { - assert.Equal(t, DefaultPgDeltaNpmVersion, EffectivePgDeltaNpmVersion(nil)) - }) - - t.Run("reads trimmed version from supabase/.temp/pgdelta-version", func(t *testing.T) { - c := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[experimental.pgdelta] -enabled = true -`)}, - "supabase/.temp/pgdelta-version": &fs.MapFile{Data: []byte(" 9.9.9-test \n")}, - } - require.NoError(t, c.Load("", fsys)) - require.NotNil(t, c.Experimental.PgDelta) - assert.Equal(t, "9.9.9-test", c.Experimental.PgDelta.NpmVersion) - assert.Equal(t, "9.9.9-test", EffectivePgDeltaNpmVersion(Config(&c))) - }) - - t.Run("whitespace-only pgdelta-version keeps default", func(t *testing.T) { - c := NewConfig() - fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: []byte(` -[experimental.pgdelta] -enabled = true -`)}, - "supabase/.temp/pgdelta-version": &fs.MapFile{Data: []byte(" \n")}, - } - require.NoError(t, c.Load("", fsys)) - require.NotNil(t, c.Experimental.PgDelta) - assert.Equal(t, DefaultPgDeltaNpmVersion, c.Experimental.PgDelta.NpmVersion) - }) - - t.Run("InterpolatePgDeltaScript substitutes placeholder", func(t *testing.T) { - c := NewConfig() - require.NoError(t, c.Load("", fs.MapFS{})) - // Embedded TS pins use this semver literal before InterpolatePgDeltaScript runs. - got := InterpolatePgDeltaScript(Config(&c), `from "npm:@supabase/pg-delta@1.0.0-alpha.20";`) - assert.Equal(t, `from "npm:@supabase/pg-delta@`+DefaultPgDeltaNpmVersion+`";`, got) - }) -} - func TestRemoteOverride(t *testing.T) { t.Run("load staging override", func(t *testing.T) { config := NewConfig() diff --git a/apps/cli-go/pkg/config/pgdelta_local.go b/apps/cli-go/pkg/config/pgdelta_local.go deleted file mode 100644 index bbdd443d13..0000000000 --- a/apps/cli-go/pkg/config/pgdelta_local.go +++ /dev/null @@ -1,15 +0,0 @@ -package config - -// PgDeltaNpmRegistryEnv is the env var that, when set to an npm registry URL -// reachable from the edge-runtime container, routes Deno's `npm:` resolution -// for `@supabase/pg-delta` through that registry instead of the public -// npmjs.org. Pair with the pg-toolbelt `bun run pg-delta:publish-local` script -// to iterate on local pg-delta changes without republishing to npmjs. -// -// See apps/cli-go/CONTRIBUTING.md#testing-local-pg-delta-builds for the -// Verdaccio workflow (CLI maintainers only). -// -// Typical value when running pg-toolbelt's Verdaccio on Docker Desktop: -// -// PGDELTA_NPM_REGISTRY=http://host.docker.internal:4873 -const PgDeltaNpmRegistryEnv = "PGDELTA_NPM_REGISTRY" diff --git a/apps/cli-go/pkg/config/pgdelta_version.go b/apps/cli-go/pkg/config/pgdelta_version.go deleted file mode 100644 index a2c016473f..0000000000 --- a/apps/cli-go/pkg/config/pgdelta_version.go +++ /dev/null @@ -1,28 +0,0 @@ -package config - -import "strings" - -// DefaultPgDeltaNpmVersion is the npm dist-tag/version used for @supabase/pg-delta -// when supabase/.temp/pgdelta-version is absent or empty. -const DefaultPgDeltaNpmVersion = "1.0.0-alpha.33" - -const pgDeltaNpmVersionPlaceholder = "1.0.0-alpha.20" - -// EffectivePgDeltaNpmVersion returns the pg-delta npm version from loaded config, -// or DefaultPgDeltaNpmVersion when unset (e.g. before Load or empty field). -func EffectivePgDeltaNpmVersion(c Config) string { - if c == nil { - return DefaultPgDeltaNpmVersion - } - if c.Experimental.PgDelta != nil { - if v := strings.TrimSpace(c.Experimental.PgDelta.NpmVersion); v != "" { - return v - } - } - return DefaultPgDeltaNpmVersion -} - -// InterpolatePgDeltaScript substitutes pg delta npm version placeholders in embedded TS. -func InterpolatePgDeltaScript(c Config, script string) string { - return strings.ReplaceAll(script, pgDeltaNpmVersionPlaceholder, EffectivePgDeltaNpmVersion(c)) -} diff --git a/apps/cli-go/pkg/config/utils.go b/apps/cli-go/pkg/config/utils.go index 4c004d4eeb..2bb6db6f4b 100644 --- a/apps/cli-go/pkg/config/utils.go +++ b/apps/cli-go/pkg/config/utils.go @@ -28,7 +28,6 @@ type pathBuilder struct { RealtimeVersionPath string EdgeRuntimeVersionPath string LogflareVersionPath string - PgDeltaVersionPath string CliVersionPath string CurrBranchPath string SchemasDir string @@ -65,7 +64,6 @@ func NewPathBuilder(configPath string) pathBuilder { PoolerVersionPath: filepath.Join(base, ".temp", "pooler-version"), RealtimeVersionPath: filepath.Join(base, ".temp", "realtime-version"), LogflareVersionPath: filepath.Join(base, ".temp", "logflare-version"), - PgDeltaVersionPath: filepath.Join(base, ".temp", "pgdelta-version"), CliVersionPath: filepath.Join(base, ".temp", "cli-latest"), CurrBranchPath: filepath.Join(base, ".branches", "_current_branch"), SchemasDir: filepath.Join(base, "schemas"), diff --git a/apps/cli/docs/binary-distribution.md b/apps/cli/docs/binary-distribution.md index 798d25ccc6..8415cd3cb0 100644 --- a/apps/cli/docs/binary-distribution.md +++ b/apps/cli/docs/binary-distribution.md @@ -22,7 +22,7 @@ The legacy shell was built as a gradual TypeScript port of the Go CLI, moving ea - **Phase 0** — The command is defined in the TS CLI tree but proxied to the Go binary at runtime via `LegacyGoProxy`. - **Phase 1+** — The command is implemented natively in TypeScript. -That port is complete (CLI-1970). `supabase-go` is the residual proxy target for a fixed, small command surface: `db diff` (for `--use-pg-schema`), `db pull` (for `--experimental`), the Go-deprecated `db branch`/`db remote` command families, `gen keys`, and `functions download` (for the hidden `--legacy-bundle` path). See "Go binary command surface" under Release Workflow below for the full list and why each command stays. Two lifecycles apply here and must not be conflated: the **public commands** in that surface are retained (dropping them was ruled a breaking change — CLI-1964), while their **Go implementations** are slated for eventual native TS replacement, after which `supabase-go` stops shipping. Until the proxied surface is empty, the TS binary (`supabase`) still needs `supabase-go` available on the same system for those invocations. Every other Go command from the original CLI has been deleted outright from `apps/cli-go/`, not merely excluded from the build — see the same section for how. +That port is complete (CLI-1970). `supabase-go` is the residual proxy target for a fixed, small command surface: `db diff` (for `--use-pg-schema`), the Go-deprecated `db branch`/`db remote changes` command families, `gen keys`, and `functions download` (for the hidden `--legacy-bundle` path). See "Go binary command surface" under Release Workflow below for the full list and why each command stays. Two lifecycles apply here and must not be conflated: the **public commands** in that surface are retained (dropping them was ruled a breaking change — CLI-1964), while their **Go implementations** are slated for eventual native TS replacement, after which `supabase-go` stops shipping. Until the proxied surface is empty, the TS binary (`supabase`) still needs `supabase-go` available on the same system for those invocations. Every other Go command from the original CLI has been deleted outright from `apps/cli-go/`, not merely excluded from the build — see the same section for how. ## Package Layout @@ -100,9 +100,8 @@ This: `supabase-go` does not ship the full old Go CLI — only the fixed subset the TypeScript CLI still proxies to via `LegacyGoProxy`: - `db diff` — kept for `--use-pg-schema`, which wraps the in-process `stripe/pg-schema-diff` Go library with no TS/container equivalent (CLI-1960) -- `db pull` — kept for `--experimental`, which needs the multigres Postgres DDL parser for structured dumps (CLI-1957) - `db branch create`, `db branch delete`, `db branch list`, `db branch switch` -- `db remote changes`, `db remote commit` +- `db remote changes` - `gen keys` — the public command is kept (its planned removal, CLI-1964, was cancelled as a breaking change); the Go implementation stays only until a native TS replacement lands - `functions download` — kept for the hidden `--legacy-bundle` path (CLI-1963) @@ -129,7 +128,7 @@ Measured on the CLI-1970 branch with the real release build (`build.ts --shell l Progression across both trims: the original two-binary baseline was 97–103 MB per platform; CLI-1966's `internal/start` deletion cut it to roughly 47–52 MB; CLI-1970 brings it to 39.1–42.8 MB. -The remaining size is dominated by dependencies the retained commands still need: the multigres Postgres parser (`db pull --experimental`), `stripe/pg-schema-diff` (`db diff --use-pg-schema`), the Docker client (shadow-database provisioning for diff/pull), pgx, the Management API client, cobra/viper, and sentry/posthog. +The remaining size is dominated by dependencies the retained commands still need: `stripe/pg-schema-diff` (`db diff --use-pg-schema`), the Docker client (shadow-database provisioning for diff), pgx, the Management API client, cobra/viper, and sentry/posthog. Release archive sizes (TS `supabase` binary + `supabase-go` together): `.tar.gz` 37.9–52.9 MB, `.zip` (Windows) 50.0–53.0 MB, `.deb` 52.7–53.4 MB, `.rpm` 52.3–53.2 MB, `.apk` 52.8–54.1 MB. diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md index b21dc50bac..15fc0d511d 100644 --- a/apps/cli/docs/go-cli-divergences.md +++ b/apps/cli/docs/go-cli-divergences.md @@ -23,13 +23,16 @@ These commands exist in the TS CLI today but have no direct top-level equivalent ## Flag divergences from the Go reference - `db diff`, `db pull`, and `db schema declarative generate`/`sync` have a TS-only - `--strict-coverage` flag (no Go equivalent). It applies only when the bundled - pg-delta next engine is active (the default): coverage gaps that the engine - reports — statements it skipped or objects it could not represent — normally - surface as warnings, and `--strict-coverage` promotes them to hard failures. - Under the `SUPABASE_USE_PG_DELTA_NEXT=false` legacy opt-out the flag is - accepted but has no effect, since the legacy edge-runtime engine does not - emit coverage diagnostics. Default behavior (omitted flag) matches Go. + `--strict-coverage` flag (no Go equivalent). It applies whenever the bundled + pg-delta engine runs (always for the declarative commands; for `db diff` and + migration-style `db pull` when pg-delta is selected via + `[experimental.pgdelta] enabled = true`, `--use-pg-delta`, or + `--diff-engine pg-delta`): coverage gaps that the engine reports — statements + it skipped or objects it could not represent — normally surface as warnings, + and `--strict-coverage` promotes them to hard failures. Selecting migra (the + `db diff` / migration-style `db pull` default, or explicitly via `--use-migra` + / `--diff-engine migra`) accepts the flag but has no effect, since migra does + not emit coverage diagnostics. Default behavior (omitted flag) matches Go. - `db push` has a TS-only `--skip-vault` flag. It applies migrations without resolving or updating `[db.vault]` secrets; default behavior still matches Go. - Every legacy command that resolves a linked project ref for its own database @@ -96,13 +99,11 @@ These commands exist in the TS CLI today but have no direct top-level equivalent files or an export manifest — telling the user to set `declarative_schema_path = "./database"` or move the tree. The warning never changes behavior or exit codes; a non-interactive sync still fails with Go's - "no declarative schema found" message. Inside that directory the bundled (default) pg-delta + "no declarative schema found" message. Inside that directory the bundled pg-delta engine writes one directory per schema at the root — `supabase/schemas/public/tables/x.sql` — - with cluster-level objects under a reserved `supabase/schemas/_cluster/`. The Go reference, - and the opt-out legacy engine (`SUPABASE_USE_PG_DELTA_NEXT=false`, which runs the pinned - `[experimental.pgdelta] npm_version` in Edge Runtime), instead nest everything one level - deeper as `schemas//…` plus `cluster/…`, so a legacy-engine export lands at - `supabase/schemas/schemas/public/tables/x.sql`. + with cluster-level objects under a reserved `supabase/schemas/_cluster/`. + Structured export is TypeScript-only; the Go binary no longer ships a + pg-delta dump path. - Local `pg_net` presence now converges with `[experimental.webhooks]` instead of being installed unconditionally: `db-webhook.sql` no longer creates the extension at container init, `supabase start`/`db start` install it (with grants reapplied via the @@ -165,15 +166,16 @@ These commands exist in the TS CLI today but have no direct top-level equivalent asymmetry caused (validated against `/supabase/...`, mounted from `/...`) is gone. The `init` scaffold ejects the root-relative form, which is incompatible with Go if uncommented (#6159/#6160). -- `db remote changes|commit --password

`: since CLI-1970, an explicit +- `db remote changes --password

`: since CLI-1970, an explicit `--password` beats the `SUPABASE_DB_PASSWORD` env var. Before the trim, Go's package-wide "last `viper.BindPFlag("DB_PASSWORD", …)` wins" behavior bound the key to `projects create --db-password` (lexically last `cmd/*.go` file), so `db remote`'s own `--password` flag was never the bound instance and env silently won over it — a latent bug. With `projects.go` deleted, the bind lands on `db remote`'s persistent `--password` and flag-beats-env applies as - intended. Accepted (not restored) in the CLI-1970 parity audit; `db pull` - keeps the old precedence (env wins over its `--password`) unchanged. + intended. Accepted (not restored) in the CLI-1970 parity audit. `db remote +commit` is now native `db pull`, so it uses pull's flag-then-env-then-dotenv + password order. `db pull` keeps that precedence unchanged. - `branches {list,create,get,update,delete,pause,unpause,disable}` resolve their project ref through a PARENT-scoped chain instead of plain `--project-ref` flag/env/file resolution: an explicit `--project-ref` still wins outright, but the fallback is env `SUPABASE_PROJECT_ID` → diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index a3310165f1..52e0027584 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -19,14 +19,13 @@ records the earlier transition policy). ## The delegation surface -| Command/path | TS proxy site | Go implementation (in-tree) | Why it stays | -| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `db diff --use-pg-schema` | [`src/commands/db/diff/diff.handler.ts`](../src/commands/db/diff/diff.handler.ts) (delegate path) | `internal/db/diff/pgschema.go` | Wraps the Go-only `stripe/pg-schema-diff` library (CLI-1960); deprecated but sanctioned. | -| `db pull --experimental` (structured dump) | [`src/commands/db/pull/pull.handler.ts`](../src/commands/db/pull/pull.handler.ts) | `internal/migration/format.WriteStructuredSchemas` + the multigres DDL parser | No TS DDL parser equivalent (CLI-1957). | -| `db branch create\|delete\|list\|switch` | `src/commands/db/branch/*/` | `legacy/branch/*` | Go-deprecated wrapped commands kept indefinitely (CLI-1964 cancelled: dropping them was ruled a breaking change not worth shipping). | -| `db remote changes\|commit` | `src/commands/db/remote/*/` | inline in `cmd/db.go` over `internal/db/{diff,pull}` | Same CLI-1964 ruling. | -| `gen keys` | `src/commands/gen/keys/` | `legacy/keys` | Same ruling; requires `--experimental`. | -| `functions download --legacy-bundle` (hidden flag; both shells) | `src/shared/functions/download.ts` `makeGoProxyLegacyBundleArgs` | `internal/functions/download` | Legacy Deno bundle extraction (CLI-1963). | +| Command/path | TS proxy site | Go implementation (in-tree) | Why it stays | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `db diff --use-pg-schema` | [`src/commands/db/diff/diff.handler.ts`](../src/commands/db/diff/diff.handler.ts) (delegate path) | `internal/db/diff/pgschema.go` | Wraps the Go-only `stripe/pg-schema-diff` library (CLI-1960); deprecated but sanctioned. | +| `db branch create\|delete\|list\|switch` | `src/commands/db/branch/*/` | `legacy/branch/*` | Go-deprecated wrapped commands kept indefinitely (CLI-1964 cancelled: dropping them was ruled a breaking change not worth shipping). | +| `db remote changes` | `src/commands/db/remote/changes/` | inline in `cmd/db.go` over `internal/db/diff` | Same CLI-1964 ruling. `db remote commit` is native `db pull`. | +| `gen keys` | `src/commands/gen/keys/` | `legacy/keys` | Same ruling; requires `--experimental`. | +| `functions download --legacy-bundle` (hidden flag; both shells) | `src/shared/functions/download.ts` `makeGoProxyLegacyBundleArgs` | `internal/functions/download` | Legacy Deno bundle extraction (CLI-1963). | ## Mechanics diff --git a/apps/cli/docs/supabase/db/pull.md b/apps/cli/docs/supabase/db/pull.md index e10c0679f7..c0b917a4c5 100644 --- a/apps/cli/docs/supabase/db/pull.md +++ b/apps/cli/docs/supabase/db/pull.md @@ -28,14 +28,6 @@ If `db pull --diff-engine pg-delta` reports `No schema changes found` but you ex PGDELTA_DEBUG=1 supabase db pull --db-url "$DATABASE_URL" --diff-engine pg-delta ``` -When pg-delta returns zero statements, the CLI writes a debug bundle under `supabase/.temp/pgdelta/debug//`: - -- `source-catalog.json` — shadow database baseline pg-delta extracted -- `target-catalog.json` — remote database pg-delta extracted -- `pgdelta-stderr.txt` — pg-delta script diagnostics (statement count, schemas) -- `connection.txt` — redacted connection metadata -- `error.txt` — error summary - -Catalog files are not written during normal `db pull` runs. The `.temp/pgdelta` directory is also used by migration catalog caching (`db push`, local `db start`) when `[experimental.pgdelta] enabled = true`. +When pg-delta returns zero statements, the CLI writes a debug bundle under `supabase/.temp/pgdelta/v2/debug/-diff/` containing the source/desired catalog snapshots, the plan, and coverage diagnostics. Catalog files are not written during normal `db pull` runs. For TLS tracing without disabling SSL, use `SUPABASE_SSL_DEBUG=true` alongside `PGDELTA_DEBUG=1`. diff --git a/apps/cli/package.json b/apps/cli/package.json index 05cf5048a9..3c1909d587 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -56,7 +56,7 @@ "@napi-rs/keyring": "^1.3.0", "@supabase/api": "workspace:*", "@supabase/config": "workspace:*", - "@supabase/pg-delta": "1.0.0-alpha.47", + "@supabase/pg-delta": "1.0.0-alpha.49", "@supabase/pg-topo": "1.0.0-alpha.6", "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", diff --git a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts index 7e46812996..1ec80c6659 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts @@ -63,40 +63,6 @@ * `--no-seed`/`--sql-paths` overrides on top of the loaded `[db.seed]` config first * (a no-op for `db start`, which has neither flag) — see * {@link legacyResolveResetSeedConfig}. - * 7. **`pgcache.TryCacheMigrationsCatalog`** (`start.go:371-379`) — a best-effort - * warmup of the `catalog-local-migrations-*` snapshot subsequent pg-delta - * workflows (`db diff`/`db push`) consume, via the already-ported - * `legacyTryCacheMigrationsCatalog` ({@link legacy-pgdelta.cache.ts}, the exact - * same function `db push` already calls after its own migration apply). Gated - * identically to Go's `ShouldCacheMigrationsCatalog()` (`pgcache/cache.go:93-95`): - * `input.version.length === 0` AND (`toml.pgDelta.enabled` OR - * `SUPABASE_EXPERIMENTAL_PG_DELTA`) — reached by BOTH real Go callers of this - * shared function, `db start` (always `version: ""`) and `db reset`'s PG15 - * recreate (its own resolved reset version, usually also `""`). A failure prints - * Go's exact warning (`Warning: failed to cache migrations catalog: `, - * `start.go:378`) to stderr and is otherwise swallowed, reusing the identical - * best-effort catch/warn shape `legacy-db-push-core.ts` already established for - * its own call — this step never fails {@link legacyStartSetupLocalDatabase} or - * the caller's `start`/`db start`/`db reset` run. Requires - * `LegacyEdgeRuntimeScript`/`LegacyPgDeltaSslProbe` in this function's own effect - * environment (widened accordingly below), so `start.command.ts`, - * `db/start/start.layers.ts`, AND `db/reset/reset.layers.ts` all compose - * `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer`, matching `db - * push`'s own layer composition (`push.layers.ts`). The underlying - * `legacyExportCatalogPgDelta` reads `PGDELTA_NPM_REGISTRY` straight off bare - * `process.env` ({@link legacy-pgdelta.ts}'s `legacyPgDeltaNpmRegistryOption`) — - * Go's `Config.Load` already `os.Setenv`'d the project `.env` into the process - * before `start`/`db start`/`db reset` ever reaches this call (`loadNestedEnv`, - * `config.go:788`), so a registry override set only in `supabase/.env` (not the - * shell) must be visible here too. This module never mutates `process.env` - * globally the way `start`/`db start`'s own config resolution does — every other - * Go env override is threaded explicitly via `projectEnvValues` — so this ONE - * call is scoped with `legacyApplyProjectEnv` (the same opt-in helper `db - * push`/`db pull`/`db dump`/`bootstrap` already use around their own pg-delta/ - * image work) for just its own duration, then reverted. `legacySetupDatabase` - * (CLI-1956's extraction of steps 1-4 above, reused by shadow-database - * provisioning) never reaches this step at all — only this function's own - * trailing `MigrateAndSeed` + pgcache tail does. * * Go's `initCurrentBranch` (`start.go:233-241`, writes `supabase/.branches/ * _current_branch` = `"main"` if absent) is NOT part of this pipeline, even though @@ -132,28 +98,13 @@ import { import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; import type { LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; -import { redactLegacyConnectionString } from "../legacy-db-config.parse.ts"; -import { - legacyApplyProjectEnv, - legacyCheckDbToml, - legacyResolveSeedSqlPath, -} from "../legacy-db-config.toml-read.ts"; -import { legacyParseBoolEnv } from "../legacy-diff-engine.ts"; +import { legacyCheckDbToml, legacyResolveSeedSqlPath } from "../legacy-db-config.toml-read.ts"; import { LEGACY_CLI_PROJECT_LABEL, localDbContainerId } from "../legacy-docker-ids.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; -import { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; import { LegacyMigrationApplyError, legacyExecSqlFile } from "../legacy-migration-apply.ts"; import { legacyReadMigrationTable } from "../legacy-migration-history.ts"; import { legacyStatementInstallsPgNet } from "../legacy-pg-net-guidance.ts"; -import { legacyTryCacheMigrationsCatalog } from "../legacy-pgdelta.cache.ts"; -import { - LEGACY_PG_DELTA_NEXT_FLAG_NAME, - legacyPgDeltaImplementationFlag, - legacyResolvePgDeltaImplementation, -} from "../legacy-pgdelta-next-flag.ts"; -import type { LegacyPgDeltaContext } from "../legacy-pgdelta.ts"; -import { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import type { LegacyMigrationSeedError, LegacySeedConfig } from "../legacy-seed.ts"; import { ramInBytes } from "../legacy-size-units.ts"; import { @@ -1148,19 +1099,7 @@ export const legacyStartSetupLocalDatabase = ( // because a batch that cannot check a connection out of the pool fails with the // driver's connect error verbatim, suggestion included. LegacyStartSetupLocalDatabaseError | LegacyDbConnectError, - | Output - | LegacyDockerRun - | RuntimeInfo - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe - // `legacyTryCacheMigrationsCatalog`'s own pg-delta export call resolves - // `FileSystem.FileSystem`/`Path.Path` from the effect context itself (not from - // the `fs`/`path` values this function already threads through as plain data — - // see `legacy-pgdelta.ts`'s `legacyExportCatalogPgDelta`), so both must be - // ambient here too; every real caller already gets them from `BunServices.layer` - // at the CLI root runtime, same as `db push`'s own composition. - | FileSystem.FileSystem - | Path.Path + Output | LegacyDockerRun | RuntimeInfo | FileSystem.FileSystem | Path.Path > => Effect.gen(function* () { const { session, fs, path, workdir } = input; @@ -1211,80 +1150,6 @@ export const legacyStartSetupLocalDatabase = ( localDatabaseWebhooksEnabled: toml.webhooksEnabled, }); - const output = yield* Output; - - // pgcache.TryCacheMigrationsCatalog(ctx, pgconn.Config{Host: Config.Hostname, - // Port: Config.Db.Port, User: "postgres", Password: Config.Db.Password, Database: - // "postgres"}, "local", version, fsys, ...) (start.go:371-379): best-effort, run - // immediately after MigrateAndSeed above, for BOTH real Go callers of this shared - // function — `db start` (always `version: ""`) and `db reset`'s PG15 recreate - // (its own resolved reset `input.version`, usually also `""`). `cacheEnabled` - // reproduces Go's `ShouldCacheMigrationsCatalog()` gate exactly - // (`pgcache/cache.go:93-95`): `len(version) == 0` AND (`toml.pgDelta.enabled` OR - // `SUPABASE_EXPERIMENTAL_PG_DELTA`) — the same formula `legacy-db-push-core.ts` - // already uses for its own call. `input.dbUrl` is already the HOST-facing - // `postgresql://postgres:@:/postgres` address (see its - // own doc comment) — the exact same shape Go's `utils.ToPostgresURL(config)` builds - // from that literal `pgconn.Config` here, so it's reused directly as `targetUrl` - // rather than re-derived. `conn`'s fields are only ever read by - // `legacyCatalogPrefixFromConfig` on a non-local prefix fallback, unreachable here - // since `isLocal` is always `true`. - const cacheEnabled = - input.version.length === 0 && - (toml.pgDelta.enabled || - legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA"))); - const pgDeltaImplementation = legacyResolvePgDeltaImplementation( - legacyPgDeltaImplementationFlag( - process.env[LEGACY_PG_DELTA_NEXT_FLAG_NAME], - toml.projectEnv[LEGACY_PG_DELTA_NEXT_FLAG_NAME], - ), - ); - const pgDeltaCtx: LegacyPgDeltaContext = { - projectId: input.projectId, - cwd: workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), - denoVersion: toml.denoVersion, - projectEnv: toml.projectEnv, - }; - const hostDbUrl = new URL(input.dbUrl); - // Scope the `PGDELTA_NPM_REGISTRY`-from-project-`.env` apply to just this call: - // `legacyExportCatalogPgDelta` reads it off bare `process.env` - // (`legacyPgDeltaNpmRegistryOption`), same as `db push`/`db pull`/`db dump`/ - // `bootstrap`'s own calls into pg-delta — Go's `loadNestedEnv` already made it - // process-wide by this point (`config.go:788`), but this module otherwise threads - // every override through `projectEnvValues` explicitly rather than mutating - // `process.env`, so this one shared-code call needs the same opt-in helper those - // other commands use. `legacyApplyProjectEnv` registers a finalizer that reverts it. - yield* Effect.scoped( - Effect.gen(function* () { - yield* legacyApplyProjectEnv(input.projectEnvValues ?? {}); - yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { - // The catalog is a legacy-engine artifact with no in-process consumer. - enabled: cacheEnabled && pgDeltaImplementation === "legacy", - targetUrl: input.dbUrl, - conn: { - host: hostDbUrl.hostname, - port: Number(hostDbUrl.port), - user: "postgres", - database: "postgres", - }, - isLocal: true, - migrationsDir: path.join(workdir, "supabase", "migrations"), - }).pipe( - // Best-effort: Go's own `TryCacheMigrationsCatalog` failure only ever warns - // (`fmt.Fprintln(os.Stderr, "Warning: failed to cache migrations catalog:", err)`, - // start.go:378) and never fails `legacyStartSetupLocalDatabase` — same shape - // `legacy-db-push-core.ts` already established for this exact call. - Effect.catch((error) => - output.raw( - `Warning: failed to cache migrations catalog: ${redactLegacyConnectionString(error.message)}\n`, - "stderr", - ), - ), - ); - }), - ); - // `initCurrentBranch` (start.go:233-241) is NOT called here — see this // module's header for why it moved to the caller instead. }); @@ -1436,14 +1301,7 @@ export const legacyRunFreshDbSetup = ( ): Effect.Effect< void, LegacyStartSetupLocalDatabaseError | LegacyDbConnectError | LegacyImagePrepullError | E, - | Output - | LegacyDbConnection - | LegacyDockerRun - | RuntimeInfo - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe - | FileSystem.FileSystem - | Path.Path + Output | LegacyDbConnection | LegacyDockerRun | RuntimeInfo | FileSystem.FileSystem | Path.Path > => Effect.scoped( Effect.gen(function* () { diff --git a/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts index 3ce030be94..3e1f6296b7 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.unit.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CliConfig } from "@supabase/config"; @@ -14,12 +14,6 @@ import { LegacyDbExecError } from "../legacy-db-connection.errors.ts"; import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; import { LegacyDockerRunError } from "../legacy-docker-run.errors.ts"; -import { LegacyEdgeRuntimeScriptError } from "../legacy-edge-runtime-script.errors.ts"; -import { - LegacyEdgeRuntimeScript, - type LegacyEdgeRuntimeRunOpts, -} from "../legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import { LegacyDbSetupError, legacyResolveDbSetupPrelude, @@ -165,35 +159,6 @@ function mockDockerRunFails() { return { layer }; } -/** - * `LegacyEdgeRuntimeScript`/`LegacyPgDeltaSslProbe` back - * `legacyTryCacheMigrationsCatalog`'s own pg-delta catalog-export call (`db-setup.ts`'s - * pgcache-warmup step) — required by {@link legacyStartSetupLocalDatabase}'s own widened - * effect environment regardless of whether a given test's config actually enables - * pg-delta (the early `!params.enabled` return means these mocks are never invoked at - * runtime unless a test opts in via `writeConfigToml`'s `[experimental.pgdelta]`). - */ -function mockEdgeRuntime(opts: { readonly stdout?: string; readonly failWith?: string } = {}) { - const calls: Array = []; - const layer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - calls.push(runOpts); - if (opts.failWith !== undefined) { - return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: opts.failWith })); - } - return Effect.succeed({ stdout: opts.stdout ?? '{"version":1}', stderr: "" }); - }, - }); - return { layer, calls }; -} - -function mockPgDeltaSslProbeLayer() { - return Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); -} - function makeWorkdir(): string { return mkdtempSync(join(tmpdir(), "legacy-db-setup-")); } @@ -245,7 +210,6 @@ const run = ( input: Omit, out: ReturnType, docker: ReturnType | ReturnType, - edgeRuntime: ReturnType = mockEdgeRuntime(), ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -262,8 +226,6 @@ const run = ( out.layer, docker.layer, mockRuntimeInfo({ platform: "darwin" }), - edgeRuntime.layer, - mockPgDeltaSslProbeLayer(), ), ), ); @@ -693,206 +655,6 @@ describe("legacyStartSetupLocalDatabase", () => { }, ); }); - - describe("pgcache migrations-catalog warmup (start.go:371-379)", () => { - it.effect("does not attempt to cache the migrations catalog when pg-delta is disabled", () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime(); - return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker, edgeRuntime).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(0); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }); - - it.effect("skips the legacy catalog when the default next engine is enabled", () => { - const workdir = makeWorkdir(); - writeConfigToml(workdir, "[experimental.pgdelta]\nenabled = true\n"); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime({ stdout: '{"snapshot":"ok"}' }); - return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker, edgeRuntime).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(0); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }); - - it.effect("caches the migrations catalog for the legacy engine after MigrateAndSeed", () => { - const workdir = makeWorkdir(); - writeConfigToml(workdir, "[experimental.pgdelta]\nenabled = true\n"); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime({ stdout: '{"snapshot":"ok"}' }); - return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker, edgeRuntime).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(1); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - const tempDir = join(workdir, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => - name.startsWith("catalog-local-migrations-"), - ); - expect(catalogFiles).toHaveLength(1); - expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }); - - it.effect( - "skips the legacy catalog when an empty shell value shadows a project .env false (godotenv parity)", - () => { - // godotenv.Load never replaces a shell value, including an empty one, so - // an empty `SUPABASE_USE_PG_DELTA_NEXT` in the shell must suppress the - // `supabase/.env` fallback below and resolve to the next implementation — - // matching the engine-selector layer's own precedence rather than - // `toml.envLookup`'s (which treats an empty shell value as unset). - const prev = process.env["SUPABASE_USE_PG_DELTA_NEXT"]; - process.env["SUPABASE_USE_PG_DELTA_NEXT"] = ""; - const workdir = makeWorkdir(); - writeConfigToml(workdir, "[experimental.pgdelta]\nenabled = true\n"); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime({ stdout: '{"snapshot":"ok"}' }); - return run( - baseInput(workdir, session, { majorVersion: 14 }), - out, - docker, - edgeRuntime, - ).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(0); - rmSync(workdir, { recursive: true, force: true }); - }), - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_USE_PG_DELTA_NEXT"]; - else process.env["SUPABASE_USE_PG_DELTA_NEXT"] = prev; - }), - ), - ); - }, - ); - - it.effect( - "caches the migrations catalog when SUPABASE_EXPERIMENTAL_PG_DELTA is enabled via project .env", - () => { - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", ".env"), - "SUPABASE_EXPERIMENTAL_PG_DELTA=true\nSUPABASE_USE_PG_DELTA_NEXT=false\n", - ); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime({ stdout: '{"snapshot":"ok"}' }); - return run( - baseInput(workdir, session, { majorVersion: 14 }), - out, - docker, - edgeRuntime, - ).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(1); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }, - ); - - it.effect( - "applies PGDELTA_NPM_REGISTRY from the project .env for the catalog export, then reverts it", - () => { - // Go's `Config.Load` already `os.Setenv`'d the project `.env` into the process - // (`loadNestedEnv`, config.go:788) long before `SetupLocalDatabase` runs, so a - // PGDELTA_NPM_REGISTRY set only in supabase/.env (not the shell) reaches - // `PgDeltaNpmRegistryOption` there. This module threads config overrides via - // `projectEnvValues` rather than mutating `process.env` globally, so the - // cache-warmup step must scope-apply it around just `legacyExportCatalogPgDelta`'s - // call (`legacyPgDeltaNpmRegistryOption` reads bare `process.env`) and revert - // afterwards — mirroring `db push`/`db pull`/`db dump`/`bootstrap`'s own use of - // `legacyApplyProjectEnv` for the same shared pg-delta code. - const previous = process.env["PGDELTA_NPM_REGISTRY"]; - delete process.env["PGDELTA_NPM_REGISTRY"]; - const workdir = makeWorkdir(); - writeConfigToml(workdir, "[experimental.pgdelta]\nenabled = true\n"); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", ".env"), - "PGDELTA_NPM_REGISTRY=https://registry.example.com/supabase\nSUPABASE_USE_PG_DELTA_NEXT=false\n", - ); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime({ stdout: '{"snapshot":"ok"}' }); - return run( - baseInput(workdir, session, { - majorVersion: 14, - projectEnvValues: { PGDELTA_NPM_REGISTRY: "https://registry.example.com/supabase" }, - }), - out, - docker, - edgeRuntime, - ).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(1); - expect(edgeRuntime.calls[0]?.extraEnv?.["PGDELTA_NPM_REGISTRY"]).toBe( - "https://registry.example.com/supabase", - ); - expect(edgeRuntime.calls[0]?.extraEnv?.["NPM_CONFIG_REGISTRY"]).toBe( - "https://registry.example.com/supabase", - ); - // Reverted: the scope closes once the cache-warmup call completes, so it - // never leaks into subsequent steps or other tests. - expect(process.env["PGDELTA_NPM_REGISTRY"]).toBeUndefined(); - if (previous === undefined) delete process.env["PGDELTA_NPM_REGISTRY"]; - else process.env["PGDELTA_NPM_REGISTRY"] = previous; - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }, - ); - - it.effect( - "warns without failing legacyStartSetupLocalDatabase when the catalog export fails", - () => { - const workdir = makeWorkdir(); - writeConfigToml(workdir, "[experimental.pgdelta]\nenabled = true\n"); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime({ - failWith: "edge-runtime script produced no output", - }); - return run( - baseInput(workdir, session, { majorVersion: 14 }), - out, - docker, - edgeRuntime, - ).pipe( - Effect.map(() => { - expect(out.stderrText).toContain( - "Warning: failed to cache migrations catalog: edge-runtime script produced no output", - ); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }, - ); - }); }); describe("legacyResolveDbSetupPrelude", () => { diff --git a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts index e5637e39dd..3bb980dac4 100644 --- a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts @@ -69,13 +69,6 @@ * NEVER called (Go's `resetDatabase`/`resetDatabase14`/`resetDatabase15` never * call it), and no rollback on failure (Go's `cmd/db.go` only wraps `--mode * start` in a `DockerRemoveAll` cleanup — the recreate dispatch has none). - * - * `pgcache.TryCacheMigrationsCatalog`'s best-effort catalog warmup (part of Go's - * `SetupLocalDatabase`, reachable from the PG15 path above via - * `legacyStartSetupLocalDatabase`) IS reached here too — see `db-setup.ts`'s own - * header for the exact gate/citations. `reset.layers.ts` composes - * `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` for it, matching - * `db start`'s own layer composition (`db/start/start.layers.ts`). */ import { Data, Effect, Result, Schedule, type FileSystem, type Path } from "effect"; @@ -95,14 +88,12 @@ import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connectio import { LegacyDbExecError, type LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; import { LEGACY_CLI_PROJECT_LABEL } from "../legacy-docker-ids.ts"; import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; -import type { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; import { legacyFormatExecBatchError, type LegacyMigrationApplyError, } from "../legacy-migration-apply.ts"; import { legacyErrorMessage } from "../legacy-error-message.ts"; -import type { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import type { LegacyMigrationSeedError } from "../legacy-seed.ts"; import { legacyEnsureNetwork, @@ -364,8 +355,6 @@ const legacyRecreateLocalDatabase15 = ( | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path > => @@ -434,8 +423,6 @@ const legacyRecreateLocalDatabase14 = ( | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path > => @@ -540,8 +527,6 @@ export const legacyRecreateLocalDatabase = ( | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path > => diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts index e47b1f4630..d1b944bc41 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -975,8 +975,8 @@ const legacyWarmShadow = ( /** * `Effect.acquireUseRelease`'s `acquire` for every shadow-provisioning call site that runs the - * platform baseline (`db diff`'s migra/pg-delta branch, `db pull`'s migration diff, - * `legacy-pgdelta.cache.ts`'s catalog export, and pg-delta next's scoped shadows) — see + * platform baseline (`db diff`'s migra/pg-delta branch, `db pull`'s migration diff, and + * pg-delta's scoped shadows) — see * {@link legacyWithShadowDatabase} for the acquire/use/release wrapper, and * `legacy-pgdelta-next-shadow.layer.ts` for the scoped `acquireRelease` form next uses so the * container outlives provision (the engine keeps using the URL after this returns). diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts index 5904c4c73c..913d83aaee 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts @@ -1,26 +1,14 @@ /** - * Native TypeScript port of Go's shadow-database provisioning primitives - * (`apps/cli-go/internal/db/diff/diff.go:138-209`) — CLI-1956. These are the low-level - * building blocks; `legacyPrepareRawShadow` below (create -> health-wait, no platform - * baseline) is one of the two composed shapes `db diff`/`db pull` actually call (Go's - * `PrepareRawShadow`, `apps/cli-go/internal/db/diff/shadow.go:93-116`) — it has zero - * pg-delta/declarative dependency, so it lives here rather than in - * `commands/db/shared/legacy-shadow-source.ts`, which owns the OTHER composed shape - * (`legacyPrepareShadowSource`, Go's `PrepareShadowSource`) precisely because that one also - * needs the `--target-local` declarative-schema branch and pg-delta, which this module — - * deliberately kept dependency-light, like every other `shared/db-bootstrap/` module — does - * not. + * The shadow-database provisioning primitives: create, health-wait, platform-baseline setup, + * and migrations replay. These are the low-level, dependency-light building blocks — the + * composed diff/pull shape (`legacyPrepareShadowSource`, with its migra declarative-schema + * branch) lives in `commands/db/shared/legacy-shadow-source.ts` instead, so this module and + * the rest of `shared/db-bootstrap/` never pull in the diff engines. * - * Exposed separately (not fused into one monolithic function) because the composed shapes - * Go itself has are NOT all the same: `migration squash` (a future port, CLI-1969) only ever - * needs create -> health-wait -> connect -> `SetupDatabase` (no `CREATE_TEMPLATE`, no - * migrations at that point — `apps/cli-go/internal/migration/squash/squash.go:83-96`, - * deleted in CLI-1970; last present at commit 7b469f5b3), while - * `db diff --use-pgadmin` (CLI-1968, realized: see `diff.handler.ts`'s pgadmin branch) needs - * create -> health-wait -> `MigrateShadowDatabase` (`apps/cli-go/internal/db/diff/ - * pgadmin.go:70-78`). Exposing every primitive individually lets each future caller compose - * exactly the subset it needs, matching Go's own module shape 1:1 rather than forcing every - * caller through one shape only `db diff`/`db pull` happen to need. + * Exposed as individual primitives rather than one fused function because the callers compose + * different subsets: `migration squash` needs create -> health-wait -> connect -> setup (no + * `CREATE_TEMPLATE`, no migrations at that point), while `db diff --use-pgadmin` (see + * `diff.handler.ts`'s pgadmin branch) needs create -> health-wait -> migrations replay. * * A note on the shadow container's own addressing, since it's the one genuinely surprising * empirical fact this whole module depends on: the shadow container is created with NO name @@ -76,8 +64,6 @@ import { } from "./container-lifecycle.ts"; import type { LegacyStartContainerSpec } from "./docker-create-args.ts"; import type { LegacyImagePrepullError } from "./image-prepull.ts"; -import type { LegacyHealthCheckTimeoutError } from "./health-check.ts"; -import { legacyWaitForShadowReady } from "./health-check.ts"; import type { LegacyLocalDbContainerInputs } from "./local-container-inputs.ts"; import { legacyListLocalMigrationPaths } from "../legacy-migration-history.ts"; import { legacyToPostgresURL } from "../legacy-postgres-url.ts"; @@ -260,7 +246,7 @@ export interface LegacyShadowDatabaseHandle { * Leak window (deliberate Go parity, not a bug — the canonical explanation every call site * below cross-references): every real caller runs this whole function as the `acquire` of an * `Effect.acquireUseRelease` whose `release` is {@link legacyRemoveShadowDatabase} (see - * `diff.handler.ts`/`pull.handler.ts`/`legacy-pgdelta.cache.ts`'s call sites). Effect only + * `diff.handler.ts`/`pull.handler.ts`'s call sites). Effect only * registers `release` once `acquire` itself resolves successfully; an `acquire` that fails * partway through — `docker create` having already succeeded, but the LATER `docker * cp`/`docker start` step inside {@link legacyCreateContainer} then failing @@ -379,15 +365,14 @@ export interface LegacyShadowSourceResult { /** * When set, replaces the diff target with a second database on the SAME shadow container * (`contrib_regression`, cloned from `postgres` by `CREATE_TEMPLATE` during shadow setup — - * see {@link legacySetupShadowConn}) with declarative schemas applied. Mirrors Go's - * local-target declarative branch, where the user's local DB is not diffed. Only ever set - * by `legacy-shadow-source.ts`'s `legacyPrepareShadowSource` — {@link legacyPrepareRawShadow} - * below always leaves this `undefined`. + * see {@link legacySetupShadowConn}) with declarative schemas applied, so the user's local + * DB itself is never diffed directly in that branch. Only ever set by + * `legacy-shadow-source.ts`'s `legacyPrepareShadowSource`. */ readonly targetUrlOverride: string | undefined; } -/** Fields shared by `legacy-shadow-source.ts`'s `LegacyPrepareShadowSourceInput`/{@link LegacyPrepareRawShadowInput}. */ +/** Fields shared by `legacy-shadow-source.ts`'s `LegacyPrepareShadowSourceInput` and the shadow readiness probes. */ export interface LegacyShadowConnectionInput extends LegacyCreateShadowDatabaseInput { readonly fs: FileSystem.FileSystem; readonly path: Path.Path; @@ -397,15 +382,13 @@ export interface LegacyShadowConnectionInput extends LegacyCreateShadowDatabaseI readonly healthTimeoutSeconds: number; } -export type LegacyPrepareRawShadowInput = LegacyShadowConnectionInput; - /** * {@link LegacyShadowConnectionInput} plus the platform-baseline setup fields * {@link legacySetupDatabase}/`legacyMigrateShadowDatabase`/`legacySetupShadowDatabase` need — * the full shape {@link legacyShadowRunInputFromLocalContainerInputs} returns. Named here * (CLI-1969) rather than as an `Omit<...>` of a diff/pull-specific type, so `migration squash` - * — which has none of the diff/pull-specific fields (`targetLocal`/`usePgDelta`/`schemaPaths`/ - * `pgDelta`/`ctx`) — can consume the promoted function's return value directly, with no `as` + * — which has none of the diff/pull-specific fields (`targetLocal`/`schemaPaths`/ + * `migrationMode`/…) — can consume the promoted function's return value directly, with no `as` * cast. `legacy-shadow-source.ts`'s `LegacyPrepareShadowSourceInput` extends this with * those extra fields instead of duplicating the `setup` field itself. */ @@ -439,9 +422,9 @@ export function legacyMemoizeSuccess(effect: Effect.Effect): Effect. * Adapts {@link LegacyLocalDbContainerInputs} (`local-container-inputs.ts`, the SAME * config/image/JWKS resolution prelude `db start`/`db reset` share) plus the caller's own * already-loaded `config.toml` slice into {@link LegacyShadowSetupInput} — every field - * `legacyPrepareShadowSource`/{@link legacyPrepareRawShadow} (`legacy-shadow-source.ts`/this - * module) or `migration squash`'s own shadow composition need EXCEPT the diff/pull-specific - * ones (`targetLocal`/`usePgDelta`/`schemaPaths`/`pgDelta`/`ctx`, left to each call site). + * `legacy-shadow-source.ts`'s `legacyPrepareShadowSource` or `migration squash`'s own shadow + * composition need EXCEPT the diff/pull-specific + * ones (`targetLocal`/`schemaPaths`/`migrationMode`/…, left to each call site). * Promoted here from * `commands/db/shared/legacy-shadow-source.ts` (CLI-1969, hoist-before-duplicate): `migration * squash` needs this same shadow run-input shape, but importing the `db`-family-scoped @@ -559,54 +542,6 @@ export const legacyShadowConnConfig = (input: LegacyShadowConnFields): LegacyPgC database: "postgres", }); -/** - * Port of Go's `PrepareRawShadow` (`apps/cli-go/internal/db/diff/shadow.go:93-116`): readiness - * wait against an already-{@link legacyCreateShadowDatabase}-created shadow (created + accepting - * connections, no platform baseline or migrations applied) — used inline (`db pull - * --declarative`'s empty declarative-export source), not the `ok`-sentinel error-path pattern - * `legacy-shadow-source.ts`'s `legacyPrepareShadowSource` uses, since there is only ONE step - * here that can fail (the readiness wait) rather than several. Lives here (not - * `legacy-shadow-source.ts`) because it has zero pg-delta/declarative dependency — see this - * module's own header. - * - * Gates on {@link legacyWaitForShadowReady}, NOT on the Docker-health - * `legacyWaitForHealthyServices` the long-running `db` container still uses: the shadow's - * own healthcheck cannot report `healthy` before its first 10-second-interval probe, ~6.5s after - * Postgres is already connectable — see that function's own doc comment. - * - * Deliberately does NOT call {@link legacyCreateShadowDatabase} itself — the caller does, as the - * `acquire` of an `Effect.acquireUseRelease` whose `use` phase is this function (see - * `diff.handler.ts`/`pull.handler.ts`'s call sites). Go's `PrepareRawShadow` threads a single - * cancellable `ctx` through both creation and the readiness wait, so a SIGINT can interrupt - * either; an earlier shape here instead passed the WHOLE create-then-wait effect as `acquire`, - * which Effect's `uninterruptibleMask` (`acquireUseRelease(acquire, use, release) => - * uninterruptibleMask(restore => flatMap(acquire, a => onExitPrimitive(restore(use(a)), ...)))`) - * makes entirely uninterruptible — a SIGINT during the readiness wait (which can run for up to - * `healthTimeoutSeconds`) was silently swallowed until the wait finished or timed out on its - * own, unlike Go. Splitting `legacyCreateShadowDatabase` out as the (brief, Docker-API-bound) - * `acquire` and keeping this wait as part of the interruptible `use` restores that parity - * — a SIGINT here now lands immediately, while `legacyRemoveShadowDatabase` still - * runs as the `release` finalizer regardless of how `use` exits. - */ -export const legacyPrepareRawShadow = ( - spawner: Spawner, - handle: LegacyShadowDatabaseHandle, - input: LegacyPrepareRawShadowInput, -): Effect.Effect => - Effect.gen(function* () { - const { containerId } = handle; - const connConfig = legacyShadowConnConfig(input); - yield* legacyWaitForShadowReady(spawner, containerId, connConfig, { - timeoutSeconds: input.healthTimeoutSeconds, - image: input.image, - }); - return { - container: containerId, - sourceUrl: legacyToPostgresURL(connConfig), - targetUrlOverride: undefined, - }; - }); - /** * Port of Go's `setupShadowConn` (`apps/cli-go/internal/db/diff/diff.go:171-179`): * {@link legacySetupDatabase} (Go's `SetupDatabase`) against an already-connected shadow, diff --git a/apps/cli/src/command-internal/db-bootstrap/start-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-database.ts index 9627adabfa..986a394224 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-database.ts @@ -88,8 +88,6 @@ import { legacyWaitForHealthyServices, type LegacyHealthCheckTimeoutError, } from "./health-check.ts"; -import type { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; -import type { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import { LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, LEGACY_START_STARTING_DATABASE_MESSAGE, @@ -184,8 +182,6 @@ export const legacyStartDatabase = ( | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path > => diff --git a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts index 94fe3a37fb..f183673e6d 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-local-database.ts @@ -137,9 +137,7 @@ export const legacyStartLocalDatabase = Effect.fnUntraced(function* (fromBackupF // Realtime/Storage/Auth migrate job tees its own stderr (`db-setup.ts`'s // `legacyRunStartMigrateJob`). Resolved with the `SUPABASE_DEBUG` shell/project-`.env` // fallback, not the bare flag: every Go debug read on this path went through - // `viper.GetBool("DEBUG")` under `AutomaticEnv`, and the sibling shadow-provision path - // (`legacy-pgdelta.cache.ts`'s `legacyBuildShadowCatalogInputs`) already resolves it the - // same way. + // `viper.GetBool("DEBUG")` under `AutomaticEnv`. const debug = yield* legacyResolveDebugWithProjectEnv(dbTomlValues.projectEnv); // The rest of config loading — full config decode/resolution (`legacyLoadLocalProjectContext`) diff --git a/apps/cli/src/command-internal/legacy-db-config.toml-read.ts b/apps/cli/src/command-internal/legacy-db-config.toml-read.ts index 4800447889..e8bd13e24d 100644 --- a/apps/cli/src/command-internal/legacy-db-config.toml-read.ts +++ b/apps/cli/src/command-internal/legacy-db-config.toml-read.ts @@ -165,10 +165,9 @@ interface LegacyDbVaultSecretToml { /** * Cache-key inputs from `[auth]`/`[storage]`/`[realtime]`/`[api]`/`[db.vault]`. - * Exported so callers that build this cache-key subset directly (e.g. - * `legacyResolveSetupInputs` in `legacy-pgdelta.cache.ts`) reference this shape - * instead of re-declaring it inline, making field drift a compile error rather - * than a silent cache-key gap. + * Exported so callers that build this cache-key subset directly reference this + * shape instead of re-declaring it inline, making field drift a compile error + * rather than a silent cache-key gap. */ export interface LegacyBaselineTomlConfig { /** `[auth] enabled`, default true. Gates `initSchema`'s auth service migration. */ @@ -187,10 +186,7 @@ export interface LegacyBaselineTomlConfig { readonly vaultNames: ReadonlyArray; } -/** - * The `[experimental.pgdelta]` subtree. `npmVersion` is sourced from - * `supabase/.temp/pgdelta-version` (not the TOML), matching `config.Load`. - */ +/** The `[experimental.pgdelta]` subtree. */ export interface LegacyPgDeltaTomlConfig { /** `[experimental.pgdelta] enabled`, default false. `IsPgDeltaEnabled`. */ readonly enabled: boolean; @@ -202,8 +198,6 @@ export interface LegacyPgDeltaTomlConfig { readonly declarativeSchemaPath: Option.Option; /** `[experimental.pgdelta] format_options`, a JSON string passed to pg-delta. */ readonly formatOptions: Option.Option; - /** `@supabase/pg-delta` npm version from `.temp/pgdelta-version`. */ - readonly npmVersion: Option.Option; } const DEFAULT_PORT = 54322; @@ -979,25 +973,19 @@ const DEFAULT_SUPABASE_ENV = "development"; * Keys {@link legacyApplyProjectEnv} copies from the project `.env` into * `process.env`. Kept to an allowlist of values that are read *only* via * `process.env` (no project-env map path) and must reflect `supabase/.env`: - * `SUPABASE_INTERNAL_IMAGE_REGISTRY` (`legacyGetRegistryImageUrl`) and - * `PGDELTA_NPM_REGISTRY` (`legacyPgDeltaNpmRegistryOption`, read straight from - * `process.env` for legacy-opt-out pg-delta edge-runtime invocations). The bundled - * next implementation never consults it. Go's + * `SUPABASE_INTERNAL_IMAGE_REGISTRY` (`legacyGetRegistryImageUrl`). Go's * `godotenv.Load` (`loadNestedEnv`) `os.Setenv`s every key from the project - * `.env`, so both readers see a `.env`-only value there; omitting either here - * would leave that one process.env-only reader blind to a project-`.env`-scoped + * `.env`, so the reader sees a `.env`-only value there; omitting it here + * would leave that process.env-only reader blind to a project-`.env`-scoped * override the shell never set. * Everything else is read from {@link legacyLoadProjectEnv}'s returned map - * (`envLookup`, `legacyResolveYesWithProjectEnv`, `resolveDbPassword`) or resolved + * (`envOverride`, `legacyResolveYesWithProjectEnv`, `resolveDbPassword`) or resolved * eagerly from the shell before any `.env` load — Go's root globals (workdir / * profile / `SUPABASE_ENV` / project-ref) are frozen before `loadNestedEnv`, so * writing them here would let our lazily-built resolvers diverge from Go (retarget * the project, switch the env-file set, or leak into the Go `--experimental` proxy). */ -const LEGACY_PROCESS_ENV_APPLY_KEYS = [ - "SUPABASE_INTERNAL_IMAGE_REGISTRY", - "PGDELTA_NPM_REGISTRY", -] as const; +const LEGACY_PROCESS_ENV_APPLY_KEYS = ["SUPABASE_INTERNAL_IMAGE_REGISTRY"] as const; /** * Load the project's nested `.env` files into a lookup map. **Pure**: it reads the @@ -1069,10 +1057,10 @@ export const legacyLoadProjectEnv = Effect.fnUntraced(function* ( * Apply the allowlisted project-`.env` keys (see {@link LEGACY_PROCESS_ENV_APPLY_KEYS}) * to `process.env` **for the duration of the current scope**, then revert. This is * the opt-in counterpart to the pure {@link legacyLoadProjectEnv}: `bootstrap` / - * `db push` / `db pull` / `db dump` run it around their pg_dump / migration / pg-delta - * container work so a `SUPABASE_INTERNAL_IMAGE_REGISTRY` or `PGDELTA_NPM_REGISTRY` set - * only in `supabase/.env` still reaches `legacyGetRegistryImageUrl` / - * `legacyPgDeltaNpmRegistryOption` (both read `process.env` synchronously) — mirroring + * `db push` / `db pull` / `db dump` run it around their pg_dump / migration + * container work so a `SUPABASE_INTERNAL_IMAGE_REGISTRY` set + * only in `supabase/.env` still reaches `legacyGetRegistryImageUrl` (which reads + * `process.env` synchronously) — mirroring * the `os.Setenv` half of `loadNestedEnv`. Kept out of the shared loader so * SUPABASE_YES / db-password reads stay side-effect-free. * @@ -1519,17 +1507,6 @@ const readDbTomlCore = Effect.fnUntraced(function* ( .readFileString(poolerUrlPath) .pipe(Effect.map(nonEmptyString), Effect.orElseSucceed(Option.none)); - // The legacy pg-delta npm version is read from - // `.temp/pgdelta-version` (trimmed, non-empty) during Load, never from the - // TOML. An absent/empty file leaves it `None` (callers fall back to the - // default via `legacyEffectivePgDeltaNpmVersion`). The bundled next engine is - // fixed at CLI build time and ignores this compatibility setting. - const pgDeltaVersionPath = path.join(supabaseDir, ".temp", "pgdelta-version"); - const pgDeltaNpmVersion = yield* fs.readFileString(pgDeltaVersionPath).pipe( - Effect.map((content) => nonEmptyString(content.trim())), - Effect.orElseSucceed(Option.none), - ); - // `SUPABASE_DB_*` env vars override the matching `[db]` field before the TOML // value/default. An empty env value is ignored, and the project `.env` files are // loaded into the environment first, so consult both. @@ -2694,7 +2671,6 @@ const readDbTomlCore = Effect.fnUntraced(function* ( enabled, declarativeSchemaPath, formatOptions, - npmVersion: pgDeltaNpmVersion, }, webhooksEnabled, baseline: { diff --git a/apps/cli/src/command-internal/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/command-internal/legacy-db-config.toml-read.unit.test.ts index d8c886fa6e..df06cb9df3 100644 --- a/apps/cli/src/command-internal/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/command-internal/legacy-db-config.toml-read.unit.test.ts @@ -2635,40 +2635,32 @@ describe("legacyReadDbToml", () => { // Go's loadNestedEnv os.Setenv's the project .env, but its root globals // (project-ref, SUPABASE_ENV, workdir/profile) are resolved from the shell // BEFORE loadNestedEnv. Our resolvers read process.env lazily, so we apply only - // the allowlisted `SUPABASE_INTERNAL_IMAGE_REGISTRY` / `PGDELTA_NPM_REGISTRY` - // (the two process.env-only readers): a .env project-ref must not retarget the + // the allowlisted `SUPABASE_INTERNAL_IMAGE_REGISTRY` (the process.env-only + // reader): a .env project-ref must not retarget the // lazy ref/pooler resolvers, and a .env SUPABASE_ENV must not switch the // env-file set. const saved: Record = {}; - for (const k of [ - "SUPABASE_INTERNAL_IMAGE_REGISTRY", - "PGDELTA_NPM_REGISTRY", - "SUPABASE_PROJECT_ID", - "SUPABASE_ENV", - ]) { + for (const k of ["SUPABASE_INTERNAL_IMAGE_REGISTRY", "SUPABASE_PROJECT_ID", "SUPABASE_ENV"]) { saved[k] = process.env[k]; delete process.env[k]; } const loaded = { SUPABASE_INTERNAL_IMAGE_REGISTRY: "my-mirror.example.com", - PGDELTA_NPM_REGISTRY: "https://npm.example.com", SUPABASE_PROJECT_ID: "envonlyref", SUPABASE_ENV: "staging", }; return Effect.gen(function* () { - // Inside the scope: only the registry keys are applied; the ref/env selector are not. + // Inside the scope: only the registry key is applied; the ref/env selector are not. yield* Effect.scoped( Effect.gen(function* () { yield* legacyApplyProjectEnv(loaded); expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe("my-mirror.example.com"); - expect(process.env["PGDELTA_NPM_REGISTRY"]).toBe("https://npm.example.com"); expect(process.env["SUPABASE_PROJECT_ID"]).toBeUndefined(); expect(process.env["SUPABASE_ENV"]).toBeUndefined(); }), ); // After the scope closes the applied keys are reverted (no test-worker leak). expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); - expect(process.env["PGDELTA_NPM_REGISTRY"]).toBeUndefined(); // An existing process.env value is never overridden, and is NOT deleted on close. process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = "shell-wins.example.com"; @@ -2729,7 +2721,6 @@ describe("legacyReadDbToml [experimental.pgdelta]", () => { expect(v.pgDelta.enabled).toBe(false); expect(Option.isNone(v.pgDelta.declarativeSchemaPath)).toBe(true); expect(Option.isNone(v.pgDelta.formatOptions)).toBe(true); - expect(Option.isNone(v.pgDelta.npmVersion)).toBe(true); rmSync(dir, { recursive: true, force: true }); }), ), @@ -2776,34 +2767,6 @@ describe("legacyReadDbToml [experimental.pgdelta]", () => { ), ); }); - - it.effect("reads the npm version from .temp/pgdelta-version (trimmed)", () => { - const dir = withConfig(["[experimental.pgdelta]", "enabled = true", ""].join("\n")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(dir, "supabase", ".temp", "pgdelta-version"), " 9.9.9-test \n"); - return read(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.pgDelta.npmVersion)).toBe("9.9.9-test"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - it.effect("leaves npm version None for an empty .temp/pgdelta-version", () => { - const dir = withConfig(["[experimental.pgdelta]", "enabled = true", ""].join("\n")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(dir, "supabase", ".temp", "pgdelta-version"), " \n"); - return read(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.isNone(v.pgDelta.npmVersion)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); }); describe("legacyResolveDeclarativeDir", () => { @@ -2815,7 +2778,6 @@ describe("legacyResolveDeclarativeDir", () => { enabled: false, declarativeSchemaPath: Option.none(), formatOptions: Option.none(), - npmVersion: Option.none(), }), ).toBe(join("supabase", "schemas")); }).pipe(Effect.provide(BunServices.layer)), @@ -2829,7 +2791,6 @@ describe("legacyResolveDeclarativeDir", () => { enabled: true, declarativeSchemaPath: Option.some(join("supabase", "db", "decl")), formatOptions: Option.none(), - npmVersion: Option.none(), }), ).toBe(join("supabase", "db", "decl")); }).pipe(Effect.provide(BunServices.layer)), diff --git a/apps/cli/src/command-internal/legacy-db-push-core.ts b/apps/cli/src/command-internal/legacy-db-push-core.ts index a7436c9aa3..8fb6aec7fc 100644 --- a/apps/cli/src/command-internal/legacy-db-push-core.ts +++ b/apps/cli/src/command-internal/legacy-db-push-core.ts @@ -1,19 +1,9 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import { legacyPromptYesNo } from "../shared/legacy/legacy-prompt-yes-no.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../shared/output/errors.ts"; import { Output } from "../shared/output/output.service.ts"; -import { - legacyListLocalMigrations, - legacyTryCacheMigrationsCatalog, -} from "./legacy-pgdelta.cache.ts"; -import { type LegacyPgDeltaContext } from "./legacy-pgdelta.ts"; -import { legacyParseBoolEnv } from "./legacy-diff-engine.ts"; -import { - LEGACY_PG_DELTA_NEXT_FLAG_NAME, - legacyPgDeltaImplementationFlag, - legacyResolvePgDeltaImplementation, -} from "./legacy-pgdelta-next-flag.ts"; +import { legacyListLocalMigrations } from "./legacy-migration-list.ts"; import { LEGACY_ERR_MISSING_LOCAL, LEGACY_ERR_MISSING_REMOTE, @@ -31,15 +21,12 @@ import { } from "../commands/db/push/push.errors.ts"; import { legacyAqua, legacyBold } from "./legacy-colors.ts"; import type { LegacyDbTomlValues } from "./legacy-db-config.toml-read.ts"; -import { redactLegacyConnectionString } from "./legacy-db-config.parse.ts"; import { LegacyDbConnection, type LegacyPgConnInput } from "./legacy-db-connection.service.ts"; -import { legacyResolveLocalProjectId, legacySanitizeProjectId } from "./legacy-docker-ids.ts"; import { legacyApplyMigrations, legacySeedGlobals } from "./legacy-migration-apply.ts"; import { legacyListRemoteMigrations, legacySuggestRevertHistory, } from "./legacy-migration-history.ts"; -import { legacyToPostgresURL } from "./legacy-postgres-url.ts"; import { legacyUpsertVaultSecrets } from "./legacy-vault.ts"; const CUSTOM_ROLES_PATH = "supabase/roles.sql"; @@ -112,33 +99,6 @@ export interface LegacyDbPushCoreInput { readonly includeSeed: boolean; readonly includeVault: boolean; readonly dnsResolver: "native" | "https"; - /** - * `LegacyCliSettings.projectId` (`SUPABASE_PROJECT_ID` env override only) — the - * top precedence tier of the pg-delta Docker-volume id. Combined internally - * with `toml.projectId`, `projectRef`, and a workdir-basename default via - * {@link legacyResolveLocalProjectId}, mirroring `Config.ProjectId` - * resolution: env override → config.toml `project_id` → `flags.ProjectRef` - * (when non-empty) → workdir basename. That third tier comes from - * `flags.LoadConfig` seeding - * `utils.Config.ProjectId = ProjectRef` *before* `Config.Load` runs, so on - * the linked path (default `db push`, and bootstrap — both resolve - * `ProjectRef` before loading config) a config.toml that omits `project_id` - * (e.g. a downloaded bootstrap template's own file) keeps the linked ref - * rather than falling to the workdir basename; only `--local`/`--db-url` - * (where Go never seeds `ProjectRef`) fall straight to the basename. - * Passing this env-only tier straight through as the id (as bootstrap's own - * `config.toml` is scaffolded fresh mid-handler, after `LegacyCliSettings` was - * already built) would bind the pg-delta edge-runtime cache volume to the - * generic `supabase_edge_runtime_` name shared by every unrelated project. - * The resolved id is sanitized ({@link legacySanitizeProjectId}) before it - * reaches {@link LegacyPgDeltaContext.projectId} — `Config.Validate` - * rewrites `Config.ProjectId` to its - * sanitized form once at config-load time, so every later reader (including - * `EdgeRuntimeId`) sees the already-sanitized value; an unsanitized - * `project_id` (e.g. `"my app"` from a downloaded bootstrap template) would - * otherwise reach the Docker volume name unescaped. - */ - readonly projectId: Option.Option; /** Already loaded + validated `config.toml`, e.g. via `legacyCheckDbToml`. */ readonly toml: LegacyDbTomlValues; /** Already resolved confirm-prompt default, e.g. via `legacyResolveYesWithProjectEnv`. */ @@ -169,7 +129,6 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush includeSeed, includeVault, dnsResolver, - projectId, toml, yes, emitStructuredResult, @@ -324,55 +283,6 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush yield* legacyUpsertVaultSecrets(session, vaultSecrets); } yield* legacyApplyMigrations(session, fs, path, pending, applyError); - const cacheEnabled = - toml.pgDelta.enabled || - legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")); - const pgDeltaImplementation = legacyResolvePgDeltaImplementation( - legacyPgDeltaImplementationFlag( - process.env[LEGACY_PG_DELTA_NEXT_FLAG_NAME], - toml.projectEnv[LEGACY_PG_DELTA_NEXT_FLAG_NAME], - ), - ); - const pgDeltaCtx: LegacyPgDeltaContext = { - // `flags.LoadConfig` seeds `Config.ProjectId = ProjectRef` before - // `Config.Load` runs, so an absent config.toml `project_id` retains the - // linked ref, not the workdir basename — that fallback only applies when - // `flags.ProjectRef` is unset (`--local`/`--db-url`, where `projectRef` is - // `""` here too, see `LegacyDbPushCoreInput.projectId`'s doc comment). - // `legacyResolveLocalProjectId` itself only knows the env/toml/basename - // tiers, so splice this third tier in by feeding it as `tomlProjectId`'s - // own fallback rather than widening that helper's signature for its two - // other (local-only, `projectRef`-less) callers. - projectId: legacySanitizeProjectId( - legacyResolveLocalProjectId( - Option.getOrUndefined(projectId), - Option.getOrUndefined(toml.projectId) ?? - (projectRef !== "" ? projectRef : undefined), - workdir, - ), - ), - cwd: workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), - denoVersion: toml.denoVersion, - projectEnv: toml.projectEnv, - }; - yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { - // The catalog is an alpha.33-only artifact with no next-engine - // consumer. Default-next commands deliberately skip this obsolete - // warmup so a successful push/bootstrap cannot start edge-runtime. - enabled: cacheEnabled && pgDeltaImplementation === "legacy", - targetUrl: legacyToPostgresURL(conn), - conn, - isLocal, - migrationsDir: path.join(workdir, "supabase", "migrations"), - }).pipe( - Effect.catch((error) => - output.raw( - `Warning: failed to cache migrations catalog: ${redactLegacyConnectionString(error.message)}\n`, - "stderr", - ), - ), - ); } else { yield* output.raw("Schema migrations are up to date.\n", "stderr"); } diff --git a/apps/cli/src/command-internal/legacy-edge-runtime-script.layer.ts b/apps/cli/src/command-internal/legacy-edge-runtime-script.layer.ts index 74882eabf7..d09f039202 100644 --- a/apps/cli/src/command-internal/legacy-edge-runtime-script.layer.ts +++ b/apps/cli/src/command-internal/legacy-edge-runtime-script.layer.ts @@ -106,9 +106,9 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( ); const port = yield* allocateFreeHostPort; const startCmd = legacyBuildEdgeRuntimeStartCmd({ port, debug }).join(" "); - const files = [{ name: "index.ts", content: opts.script }, ...(opts.extraFiles ?? [])]; + const files = [{ name: "index.ts", content: opts.script }]; const entrypointBody = legacyBuildEdgeRuntimeEntrypoint(files, startCmd); - const env = { ...opts.env, ...opts.extraEnv }; + const env = opts.env; const result = yield* docker .runCapture({ diff --git a/apps/cli/src/command-internal/legacy-edge-runtime-script.service.ts b/apps/cli/src/command-internal/legacy-edge-runtime-script.service.ts index 309ca908da..9f0dc26341 100644 --- a/apps/cli/src/command-internal/legacy-edge-runtime-script.service.ts +++ b/apps/cli/src/command-internal/legacy-edge-runtime-script.service.ts @@ -24,16 +24,12 @@ export interface LegacyEdgeRuntimeFile { export interface LegacyEdgeRuntimeRunOpts { /** The `index.ts` program (already version-interpolated for pg-delta). */ readonly script: string; - /** Container env (`KEY` → value); merged with `extraEnv`. */ + /** Container env (`KEY` → value). */ readonly env: Readonly>; /** Volume binds (e.g. the Deno cache volume + `cwd:/workspace`). */ readonly binds: ReadonlyArray; /** Prefix for the failure message, matching `errPrefix`. */ readonly errPrefix: string; - /** Extra files written next to `index.ts` (e.g. `.npmrc`). */ - readonly extraFiles?: ReadonlyArray; - /** Extra container env appended after `env` (`WithExtraEnv`). */ - readonly extraEnv?: Readonly>; /** * Effective `edge_runtime.deno_version` for this run, used to pick the image tag * (`1` → the `deno1` image). Lets a caller that has the remote-merged config (e.g. diff --git a/apps/cli/src/command-internal/legacy-glob.ts b/apps/cli/src/command-internal/legacy-glob.ts index 20e2b2d51f..7c76f91f5e 100644 --- a/apps/cli/src/command-internal/legacy-glob.ts +++ b/apps/cli/src/command-internal/legacy-glob.ts @@ -115,7 +115,7 @@ export const legacyGlobPattern = ( * supplementary-plane codepoint (`>= U+10000 > U+FFFF`) AFTER it. Verified empirically: * `["a\u{1F600}.sql","a.sql"].sort()` (default) disagrees with `Buffer.compare` on the * same two strings' UTF-8 bytes. Used for every `sort.Strings` this module (and its callers - * across `legacy-shadow-source.ts`/`legacy-pgdelta.cache.ts`) ports, so a directory with such + * across `legacy-shadow-source.ts`/`legacy-migration-list.ts`) ports, so a directory with such * filenames applies/lists in the same order Go would. */ export function legacyCompareUtf8Bytes(a: string, b: string): number { diff --git a/apps/cli/src/command-internal/legacy-migration-history.ts b/apps/cli/src/command-internal/legacy-migration-history.ts index d6500a73b9..adac596152 100644 --- a/apps/cli/src/command-internal/legacy-migration-history.ts +++ b/apps/cli/src/command-internal/legacy-migration-history.ts @@ -1,6 +1,6 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; -import { legacyListLocalMigrations } from "./legacy-pgdelta.cache.ts"; +import { legacyListLocalMigrations } from "./legacy-migration-list.ts"; import { legacyBold } from "./legacy-colors.ts"; import { legacyCompareUtf8Bytes } from "./legacy-glob.ts"; import type { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; diff --git a/apps/cli/src/command-internal/legacy-migration-list.ts b/apps/cli/src/command-internal/legacy-migration-list.ts new file mode 100644 index 0000000000..fd3d7bd680 --- /dev/null +++ b/apps/cli/src/command-internal/legacy-migration-list.ts @@ -0,0 +1,92 @@ +import { Effect, Option, Predicate, type FileSystem, type Path } from "effect"; + +import { Output } from "../shared/output/output.service.ts"; +import { legacyCompareUtf8Bytes } from "./legacy-glob.ts"; +import { LegacyMigrationsReadError } from "./legacy-migration.errors.ts"; + +// A first migration named `<14-digit>_init.sql` with a timestamp before 2021-12-09 is a +// deprecated init schema and is skipped. +const INIT_SCHEMA_PATTERN = /([0-9]{14})_init\.sql/; +const INIT_SCHEMA_CUTOFF = 20211209000000; +// Valid migration filenames: `_.sql`. +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/; + +const NO_MIGRATIONS: ReadonlyArray = []; + +/** + * Lists local migration file paths under `migrationsDir`. Entries are sorted byte-wise over each + * name's UTF-8 encoding, via {@link legacyCompareUtf8Bytes} — not JS's default + * UTF-16-code-unit `Array.prototype.sort()`. Directories are skipped, a deprecated + * `<14-digit>_init.sql` first migration (pre-2021-12-09) is skipped, and names must match + * `_*.sql`. + * + * Each skipped file emits the established stderr warning — same wording for both the + * deprecated-init and misnamed-file cases. Because this is the shared lister, the warning + * fires for the `db diff/pull/schema declarative` paths too, not only the `migration` + * commands. + */ +export const legacyListLocalMigrations = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + migrationsDir: string, +) { + const output = yield* Output; + // Only a not-exist directory means "no migrations"; every other read error (the path + // is a file → `ENOTDIR`, permission denied, …) aborts rather than silently letting + // smart generate/sync believe there are no local migrations. Effect surfaces + // "not found" as a `PlatformError` with a `SystemError` reason tagged `"NotFound"`. + const names = yield* fs.readDirectory(migrationsDir).pipe( + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.succeed(NO_MIGRATIONS) + : Effect.fail( + new LegacyMigrationsReadError({ + message: `failed to read directory: ${error.message}`, + }), + ), + ), + ); + if (names.length === 0) return NO_MIGRATIONS; + // Entries must sort byte-wise over each name's UTF-8 encoding — NOT JS's default + // `Array.prototype.sort()`, which compares UTF-16 code units and disagrees with byte/codepoint + // order for a supplementary-plane filename character alongside a BMP private-use one (see + // {@link legacyCompareUtf8Bytes}'s own doc comment). Left uncorrected, such a migrations + // directory would replay in a different order than previous releases, and a dependent + // migration could fail or produce a different shadow schema. + const sorted = [...names].sort(legacyCompareUtf8Bytes); + const result: Array = []; + for (let index = 0; index < sorted.length; index++) { + const name = sorted[index]!; + const entryPath = path.join(migrationsDir, name); + // Directory entries are classified from their own type without following symlinks: a + // `.sql` symlink whose target is a directory is never skipped as a directory here — it + // only fails later, when the migration is read as a regular file. `fs.stat` below follows + // symlinks, so it would misclassify a symlink-to-directory as a plain directory and + // silently skip it. Check `readLink` (which only succeeds for a symlink) first and skip + // the directory check entirely for symlinks. + const isSymlink = Option.isSome(yield* fs.readLink(entryPath).pipe(Effect.option)); + if (!isSymlink) { + const stat = yield* fs.stat(entryPath).pipe(Effect.option); + if (Option.isSome(stat) && stat.value.type === "Directory") continue; + } + if (index === 0) { + const init = INIT_SCHEMA_PATTERN.exec(name); + if (init !== null && Number(init[1]) < INIT_SCHEMA_CUTOFF) { + yield* output.raw( + `Skipping migration ${name}... (replace "init" with a different file name to apply this migration)\n`, + "stderr", + ); + continue; + } + } + if (!MIGRATE_FILE_PATTERN.test(name)) { + yield* output.raw( + `Skipping migration ${name}... (file name must match pattern "_name.sql")\n`, + "stderr", + ); + continue; + } + result.push(entryPath); + } + return result; +}); diff --git a/apps/cli/src/command-internal/legacy-migration-list.unit.test.ts b/apps/cli/src/command-internal/legacy-migration-list.unit.test.ts new file mode 100644 index 0000000000..c387752734 --- /dev/null +++ b/apps/cli/src/command-internal/legacy-migration-list.unit.test.ts @@ -0,0 +1,166 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Layer, Path } from "effect"; + +import { Output } from "../shared/output/output.service.ts"; +import { mockOutput } from "../../tests/helpers/mocks.ts"; +import { legacyListLocalMigrations } from "./legacy-migration-list.ts"; + +const withTemp = () => mkdtempSync(join(tmpdir(), "legacy-migration-list-")); + +const run = (effect: Effect.Effect) => + effect.pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer)), + ) as Effect.Effect; + +const withServices = ( + body: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, +) => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* body(fs, path); + }), + ); + +describe("legacyListLocalMigrations", () => { + it.effect("returns sorted valid migrations, skipping a deprecated _init.sql first file", () => { + const dir = withTemp(); + const migrationsDir = join(dir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + writeFileSync(join(migrationsDir, "20200101000000_init.sql"), "-- old init"); + writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); + writeFileSync(join(migrationsDir, "notes.txt"), "ignore me"); + return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( + Effect.tap((paths) => + Effect.sync(() => { + expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "warns (byte-exact, on stderr) when skipping a deprecated init and a misnamed file", + () => { + // One stderr line for the deprecated `_init.sql` first file and one for any + // name that does not match `_name.sql`. + const dir = withTemp(); + const migrationsDir = join(dir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + writeFileSync(join(migrationsDir, "20200101000000_init.sql"), "-- old init"); + writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); + writeFileSync(join(migrationsDir, "notes.txt"), "ignore me"); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyListLocalMigrations(fs, path, migrationsDir); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, out.layer)), + Effect.tap((paths) => + Effect.sync(() => { + expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); + const stderr = out.rawChunks.filter((c) => c.stream === "stderr").map((c) => c.text); + expect(stderr).toContain( + 'Skipping migration 20200101000000_init.sql... (replace "init" with a different file name to apply this migration)\n', + ); + expect(stderr).toContain( + 'Skipping migration notes.txt... (file name must match pattern "_name.sql")\n', + ); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ) as Effect.Effect; + }, + ); + + it.effect("includes a validly-named .sql symlink to a directory (no symlink follow)", () => { + // A directory entry is classified from its own type without following symlinks, + // so a `.sql` symlink whose target is a directory is NOT skipped as a directory — + // it is only ever dropped later, if something actually tries to read it as a + // file. A naive stat-based directory check (which follows symlinks) would + // misclassify it and silently skip it. + const dir = withTemp(); + const migrationsDir = join(dir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + const targetDir = join(dir, "outside-target"); + mkdirSync(targetDir, { recursive: true }); + writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); + symlinkSync(targetDir, join(migrationsDir, "20240102000000_link.sql")); + return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( + Effect.tap((paths) => + Effect.sync(() => { + expect(paths.map((p) => p.split("/").pop())).toEqual([ + "20240101120000_create.sql", + "20240102000000_link.sql", + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("sorts by UTF-8 byte order, not JS's default UTF-16 code-unit order", () => { + // Entries sort byte-wise over each name's UTF-8 encoding. A BMP private-use + // character (U+E000, single UTF-16 code unit `0xE000`) and a supplementary-plane + // character (U+1F600, a surrogate pair starting `0xD83D`) reverse order between + // the two schemes: JS's default `Array.prototype.sort()` ranks the surrogate pair + // first (`0xD83D < 0xE000`), while byte order — which preserves codepoint order — + // ranks U+1F600 (`> U+FFFF`) after U+E000. A migrations directory with such + // filenames must replay in byte order, or a dependent migration could apply out + // of order. + const dir = withTemp(); + const migrationsDir = join(dir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + const privateUseFile = "20240101120000_z\uE000.sql"; + const supplementaryFile = "20240101120000_z\u{1F600}.sql"; + writeFileSync(join(migrationsDir, privateUseFile), "create table x();"); + writeFileSync(join(migrationsDir, supplementaryFile), "create table y();"); + return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( + Effect.tap((paths) => + Effect.sync(() => { + expect(paths.map((p) => p.split("/").pop())).toEqual([privateUseFile, supplementaryFile]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("returns [] when the migrations dir is absent", () => { + const dir = withTemp(); + return withServices((fs, path) => legacyListLocalMigrations(fs, path, join(dir, "nope"))).pipe( + Effect.tap((paths) => + Effect.sync(() => { + expect(paths).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("fails (instead of returning []) when the migrations path is unreadable", () => { + // `supabase/migrations` exists but is a file, not a directory — the lister + // aborts with `failed to read directory` rather than treating it as "no + // migrations". + const dir = withTemp(); + const migrationsPath = join(dir, "supabase", "migrations"); + mkdirSync(join(dir, "supabase"), { recursive: true }); + writeFileSync(migrationsPath, "not a directory"); + return withServices((fs, path) => + legacyListLocalMigrations(fs, path, migrationsPath).pipe(Effect.exit), + ).pipe( + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/command-internal/legacy-pgdelta-next-flag.ts b/apps/cli/src/command-internal/legacy-pgdelta-next-flag.ts deleted file mode 100644 index 2d2ce09f5c..0000000000 --- a/apps/cli/src/command-internal/legacy-pgdelta-next-flag.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type LegacyPgDeltaImplementation = "next" | "legacy"; - -/** The env var name for the pg-delta implementation rollout flag. */ -export const LEGACY_PG_DELTA_NEXT_FLAG_NAME = "SUPABASE_USE_PG_DELTA_NEXT"; - -/** - * Combines the shell and project-`.env` values of the pg-delta rollout flag - * into the one raw value `legacyResolvePgDeltaImplementation` consumes. - * - * godotenv.Load never replaces a shell value, including an empty or invalid - * one, so presence in `process.env` must suppress the project-file fallback — - * this is the single source of truth for that precedence; every reader of - * this flag must combine its shell/project values through this function - * rather than reimplementing the rule (e.g. via `envLookup`, which treats an - * empty shell value as unset and does not apply here). - */ -export const legacyPgDeltaImplementationFlag = ( - shellValue: string | undefined, - projectValue: string | undefined, -) => shellValue ?? projectValue; - -/** - * Resolves the pg-delta implementation rollout flag from one raw environment - * value. Defaults to the next implementation when unset or not an explicit - * false; only known false spellings select the legacy implementation. - * - * The caller owns reading `process.env`, allowing the strategy boundary to - * resolve the selection exactly once per command invocation. - */ -export function legacyResolvePgDeltaImplementation( - raw: string | undefined, -): LegacyPgDeltaImplementation { - switch (raw?.toLowerCase()) { - case "0": - case "f": - case "false": - return "legacy"; - default: - return "next"; - } -} diff --git a/apps/cli/src/command-internal/legacy-pgdelta-next-flag.unit.test.ts b/apps/cli/src/command-internal/legacy-pgdelta-next-flag.unit.test.ts deleted file mode 100644 index b638a0c7ad..0000000000 --- a/apps/cli/src/command-internal/legacy-pgdelta-next-flag.unit.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - legacyPgDeltaImplementationFlag, - legacyResolvePgDeltaImplementation, -} from "./legacy-pgdelta-next-flag.ts"; - -describe("legacyPgDeltaImplementationFlag", () => { - it("prefers shell presence and otherwise uses the project value", () => { - expect(legacyPgDeltaImplementationFlag("true", "false")).toBe("true"); - expect(legacyPgDeltaImplementationFlag("", "false")).toBe(""); - expect(legacyPgDeltaImplementationFlag(undefined, "false")).toBe("false"); - }); -}); - -describe("legacyResolvePgDeltaImplementation", () => { - it("defaults to the next implementation when unset", () => { - expect(legacyResolvePgDeltaImplementation(undefined)).toBe("next"); - }); - - it.each(["1", "t", "TRUE", "true", "True", "yes", "on", "", "garbage"])( - "selects the next implementation for %j", - (raw) => { - expect(legacyResolvePgDeltaImplementation(raw)).toBe("next"); - }, - ); - - it.each(["0", "f", "F", "FALSE", "false", "False"])( - "selects the legacy implementation for %s", - (raw) => { - expect(legacyResolvePgDeltaImplementation(raw)).toBe("legacy"); - }, - ); -}); diff --git a/apps/cli/src/command-internal/legacy-pgdelta-ssl.ts b/apps/cli/src/command-internal/legacy-pgdelta-ssl.ts index 0c27703319..b925ce4164 100644 --- a/apps/cli/src/command-internal/legacy-pgdelta-ssl.ts +++ b/apps/cli/src/command-internal/legacy-pgdelta-ssl.ts @@ -1,115 +1,8 @@ -import { Effect, type FileSystem, type Path } from "effect"; - -import { LegacyPgDeltaSslProbe } from "./legacy-pgdelta-ssl-probe.service.ts"; - /** - * pg-delta SSL handling for remote Postgres endpoints. Ported from Go's - * `internal/gen/types/pgdelta_conn.go` + `types.go`. pg-delta (Deno) disables - * TLS when `sslmode` is absent and only reads `PGDELTA_*_SSLROOTCERT` for - * verify-ca/verify-full, so a TLS-requiring endpoint needs a CA bundle written - * into the workspace and the URL rewritten to `sslmode=verify-ca`. - * - * Mirroring Go's `pgDeltaRootCA`, the decision runs for EVERY postgres URL (not - * just Supabase hosts): a live `SSLRequest` probe (`isRequireSSL`) determines - * whether the server speaks TLS; if it does, the bundle is injected. Supabase-hosted - * URLs additionally get the bundle as a fallback even if the probe reports no TLS. - * Only a non-URL ref (a catalog-file path) or a server that refuses TLS (e.g. a - * plain local DB) passes through unchanged. + * The Supabase CA bundle for TLS-requiring remote Postgres endpoints + * (concatenation of Go's embedded `caStaging + caProd + caSnap` bundles, + * verbatim). The migra engine passes it to its edge-runtime script as `SSL_CA` + * when the `LegacyPgDeltaSslProbe` reports the server requires TLS. */ - -const PG_DELTA_CA_BUNDLE_DIR_SEGMENTS = ["supabase", ".temp", "pgdelta"] as const; - -/** Concatenation of Go's embedded `caStaging + caProd + caSnap` bundles (verbatim). */ export const LEGACY_PG_DELTA_CA_BUNDLE = "-----BEGIN CERTIFICATE-----\nMIID1DCCArygAwIBAgIUbYRdq/8/uNq8G9stMCdOFSBgA2MwDQYJKoZIhvcNAQEL\nBQAwczELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEmMCQGA1UEAwwdU3VwYWJh\nc2UgU3RhZ2luZyBSb290IDIwMjEgQ0EwHhcNMjEwNDI4MTAzNjEzWhcNMzEwNDI2\nMTAzNjEzWjBzMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRGVsd2FyZTETMBEGA1UE\nBwwKTmV3IENhc3RsZTEVMBMGA1UECgwMU3VwYWJhc2UgSW5jMSYwJAYDVQQDDB1T\ndXBhYmFzZSBTdGFnaW5nIFJvb3QgMjAyMSBDQTCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAN0AKRE8a56O8LaZxiOAcHFUFnwiKUvPoXPq26Ifw+Nv+7zg\nN2V5WnMZbbw24q61Os60ZUn0XmbVtuIeJ+stPHsO7qxxuL+bmPR+qU5tkDrIOyEe\nYD/2u8/q6ssVv42k4XcXbhM6RVz7CkCDY0TiBm1bMtRZso3xB6E9wAjxDf43XfV5\nPAGs3JI+Zo/vyqCDlN0hHOrB/aBl01JXqQWI84Gia5ooucq4SjA1CyawBcQ2IAvG\nrXuy1BouY+xM3zRuNvtfFP6rb5Mta+jCYEMh1AZ8yP8sYUWAyhxX6k9EbOb009wQ\naZljbUCh/UglGWuBxdzePavx+zPjzWXB1NyVkpkCAwEAAaNgMF4wCwYDVR0PBAQD\nAgEGMB0GA1UdDgQWBBQFx+PHLf27iIo/PMfIfGqXF7Zb+DAfBgNVHSMEGDAWgBQF\nx+PHLf27iIo/PMfIfGqXF7Zb+DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB\nCwUAA4IBAQB/xIiz5dDqzGXjqYqXZYx4iSfSxsVayeOPDMfmaiCfSMJEUG4cUiwG\nOvMPGztaUEYeip5SCvSKuAAjVkXyP7ahKR7t7lZ9mErVXyxSZoVLbOd578CuYiZk\nOgT17UjPv66WMzEKEr8wGpomTYWWfEkuqt8ENdiM1Z4LNFahdKj36+jm6/a+9R8K\n25VIL68DTaQpBxFWG6ixC1HRMHJ12lDhKsshIi099BVpkGibESlxPrQOdKKqBB/J\nvIX+/Hb+mS4H5zYMeK2wX0onp+GBcD6X9L1UJuXMVd+BRan8RFidXL5s3++xXjQq\nNzbc6lnA69urKffvcT07YwMsY/OmHzVa\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW\nQyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q\nDmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2\nGtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi\ncvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4\nO4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt\nNaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX\nuXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt\naUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU\ntVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b\nVW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6\njB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx\nCea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2\nCMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P\no/bKiIz+Fq8=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIDxzCCAq+gAwIBAgIUeX+gpfmsRW9asFkRvjyXjHxbfgcwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTI1MDkwMzA4MDEyNVoXDTM1MDkwMTA4MDEyNVow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5Ve7\ni9UAmc7luUilELPtqzEk8nGHxg7nY0aCStr625M7+K4OPO6RUllTsHh47k1jWyzm\nLXLlyYwCsYCjQp+3vn06H+F/HRUxBt6CK2B7bNng230exTunk0xFvfkX6YgHR7B3\n1B7L25Rq3PhuRFPV4hnGYRam2XBZC4UNPqoAgrhV0HOYzXXAVoTr2yaBTMnB331Z\nRwOmINh7eqTCk/JRZbb6vfZOhZRAVAe9AoRLoG8aKwmeoLGwlu0UuFx6z3E+6bmA\nfSNa8Lx02GEoCdPLw9IRKUFq/SgBpQUKm44H1fDwTjH2CMM0N4p0mL/6wXnNeHvt\nC40MmKZ0RcVmHE5wBwIDAQABo2MwYTAdBgNVHQ4EFgQUjvEE541toZcwtXQlZlcB\nYOBRTnowHwYDVR0jBBgwFoAUjvEE541toZcwtXQlZlcBYOBRTnowDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggEBACD5IcGP\nXKvS9qg0CgEQPFqYavt5c7P+0xxFgiZe+xoG8fUw58yNeK2APtgGPRpxEOGfAlNx\nz9HDt4gcyHEE00B3qAVDm49pqNxioFWzNqU2LGfM/HL1QmN6urR7hCOkVCJddvOc\nFhFX4nZDuRfaBboDvS5HlK3Pzxddp9hvrJi2bemr8HLqYc3HzmVckgPGSLML6t+h\n4LRCXSlQsDgQ1LZ4KHsl4cq7K51N6FOXQBLB5q4lMKhs0VUhCT8Pdsj12+84laCV\nc22q6p2mdT9SaernCSRnWazXWisgpjv3H7Ex4S1DCYjJIwn3PUToGFv1r8YRN2/S\nO19yVSxxCIf64Sg=\n-----END CERTIFICATE-----\n"; - -/** Source/target distinct CA filenames (Go's `caBundleFilename`). */ -export const LEGACY_PG_DELTA_SOURCE_SSL_ENV = "PGDELTA_SOURCE_SSLROOTCERT"; -export const LEGACY_PG_DELTA_TARGET_SSL_ENV = "PGDELTA_TARGET_SSLROOTCERT"; - -const caBundleFilename = (sslRootCertEnv: string): string => - sslRootCertEnv === LEGACY_PG_DELTA_SOURCE_SSL_ENV - ? "pgdelta-source-ca.crt" - : sslRootCertEnv === LEGACY_PG_DELTA_TARGET_SSL_ENV - ? "pgdelta-target-ca.crt" - : "pgdelta-ca.crt"; - -/** Mirrors Go's `isPostgresURL`. */ -const legacyIsPostgresUrl = (ref: string): boolean => - ref.startsWith("postgres://") || ref.startsWith("postgresql://"); - -/** Mirrors Go's `isSupabaseHostedPostgresURL`. */ -export function legacyIsSupabaseHostedPostgresUrl(dbUrl: string): boolean { - let host: string; - try { - host = new URL(dbUrl).hostname.toLowerCase(); - } catch { - return false; - } - return ( - host.endsWith(".supabase.co") || - host === "pooler.supabase.com" || - host.endsWith(".pooler.supabase.com") - ); -} - -/** Mirrors Go's `ensurePgDeltaSSL`: force `sslmode=verify-ca` (unless already verify-*) + `sslrootcert`. */ -export function legacyEnsurePgDeltaSsl(dbUrl: string, sslRootCertPath: string): string { - let parsed: URL; - try { - parsed = new URL(dbUrl); - } catch { - return dbUrl; - } - const sslmode = parsed.searchParams.get("sslmode"); - if (sslmode !== "verify-ca" && sslmode !== "verify-full") { - parsed.searchParams.set("sslmode", "verify-ca"); - } - if (sslRootCertPath.length > 0) parsed.searchParams.set("sslrootcert", sslRootCertPath); - return parsed.toString(); -} - -/** - * Mirrors Go's `pgDeltaRootCA` (`internal/gen/types/pgdelta_conn.go:37`): probe the - * endpoint for TLS (`GetRootCA` → `isRequireSSL`); if it speaks TLS, the embedded - * bundle is needed. A Supabase-hosted URL gets the bundle regardless (fallback for - * when the probe is skipped or reports no TLS). Otherwise no bundle. - */ -const legacyPgDeltaNeedsRootCa = Effect.fnUntraced(function* (ref: string) { - const probe = yield* LegacyPgDeltaSslProbe; - const requireSsl = yield* probe.requireSsl(ref); - return requireSsl || legacyIsSupabaseHostedPostgresUrl(ref); -}); - -/** - * Prepares a SOURCE/TARGET ref + its SSL env for pg-delta. Catalog-file refs pass - * through unchanged; a postgres URL is probed for TLS (Go's `pgDeltaRootCA`) and, - * when TLS is required (or it is a Supabase-hosted host), gets the embedded CA bundle - * written under `supabase/.temp/pgdelta/` and the URL rewritten to `sslmode=verify-ca`. - * Mirrors Go's `PreparePgDeltaPostgresRef`. - */ -export const legacyPreparePgDeltaRef = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - cwd: string, - ref: string, - sslRootCertEnv: string, -) { - // Go only short-circuits on a non-postgres ref (`if !isPostgresURL(ref)`); a - // catalog-file path needs no SSL handling. - if (!legacyIsPostgresUrl(ref)) { - return { ref, sslEnv: {} as Record }; - } - if (!(yield* legacyPgDeltaNeedsRootCa(ref))) { - return { ref, sslEnv: {} as Record }; - } - const relPath = path.join(...PG_DELTA_CA_BUNDLE_DIR_SEGMENTS, caBundleFilename(sslRootCertEnv)); - const absPath = path.join(cwd, relPath); - yield* fs.makeDirectory(path.dirname(absPath), { recursive: true }).pipe(Effect.ignore); - yield* fs.writeFileString(absPath, LEGACY_PG_DELTA_CA_BUNDLE); - const containerCertPath = `/workspace/${relPath.split("\\").join("/")}`; - return { - ref: legacyEnsurePgDeltaSsl(ref, containerCertPath), - sslEnv: { [sslRootCertEnv]: LEGACY_PG_DELTA_CA_BUNDLE } as Record, - }; -}); diff --git a/apps/cli/src/command-internal/legacy-pgdelta-ssl.unit.test.ts b/apps/cli/src/command-internal/legacy-pgdelta-ssl.unit.test.ts index c91cbc1afe..8cae53e9fb 100644 --- a/apps/cli/src/command-internal/legacy-pgdelta-ssl.unit.test.ts +++ b/apps/cli/src/command-internal/legacy-pgdelta-ssl.unit.test.ts @@ -1,157 +1,6 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { describe, expect, it } from "vitest"; -import { - LegacyPgDeltaSslProbe, - LegacyPgDeltaSslProbeError, -} from "./legacy-pgdelta-ssl-probe.service.ts"; -import { - LEGACY_PG_DELTA_CA_BUNDLE, - LEGACY_PG_DELTA_TARGET_SSL_ENV, - legacyEnsurePgDeltaSsl, - legacyIsSupabaseHostedPostgresUrl, - legacyPreparePgDeltaRef, -} from "./legacy-pgdelta-ssl.ts"; - -describe("legacyIsSupabaseHostedPostgresUrl", () => { - it("recognizes Supabase-hosted hosts", () => { - expect( - legacyIsSupabaseHostedPostgresUrl("postgresql://x@db.abc.supabase.co:5432/postgres"), - ).toBe(true); - expect( - legacyIsSupabaseHostedPostgresUrl("postgresql://x@pooler.supabase.com:6543/postgres"), - ).toBe(true); - expect( - legacyIsSupabaseHostedPostgresUrl("postgresql://x@abc.pooler.supabase.com:6543/postgres"), - ).toBe(true); - }); - - it("rejects local + non-Supabase hosts and unparseable URLs", () => { - expect(legacyIsSupabaseHostedPostgresUrl("postgresql://x@127.0.0.1:54322/postgres")).toBe( - false, - ); - expect(legacyIsSupabaseHostedPostgresUrl("postgresql://x@db.example.com:5432/postgres")).toBe( - false, - ); - expect(legacyIsSupabaseHostedPostgresUrl("not a url")).toBe(false); - }); -}); - -describe("legacyEnsurePgDeltaSsl", () => { - it("forces sslmode=verify-ca and sets sslrootcert", () => { - const out = legacyEnsurePgDeltaSsl( - "postgresql://u:p@db.abc.supabase.co:5432/postgres?connect_timeout=10", - "/workspace/supabase/.temp/pgdelta/pgdelta-target-ca.crt", - ); - expect(out).toContain("sslmode=verify-ca"); - expect(out).toContain( - "sslrootcert=%2Fworkspace%2Fsupabase%2F.temp%2Fpgdelta%2Fpgdelta-target-ca.crt", - ); - expect(out).toContain("connect_timeout=10"); - }); - - it("preserves an existing verify-full sslmode", () => { - const out = legacyEnsurePgDeltaSsl("postgresql://h/db?sslmode=verify-full", ""); - expect(out).toContain("sslmode=verify-full"); - }); -}); - -// Stub the live TLS probe so `legacyPreparePgDeltaRef` is testable without a server. -// `requireSsl` is what Go's `isRequireSSL` returns: true → server speaks TLS, -// false → server refused TLS, or a probe error (propagated like Go's `return false, err`). -const probeLayer = (requireSsl: boolean | "error") => - Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => - requireSsl === "error" - ? Effect.fail(new LegacyPgDeltaSslProbeError({ message: "connection refused" })) - : Effect.succeed(requireSsl), - requireSslForHost: () => - requireSsl === "error" - ? Effect.fail(new LegacyPgDeltaSslProbeError({ message: "connection refused" })) - : Effect.succeed(requireSsl), - }); - -const prepare = (cwd: string, ref: string, requireSsl: boolean | "error" = false) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* legacyPreparePgDeltaRef(fs, path, cwd, ref, LEGACY_PG_DELTA_TARGET_SSL_ENV); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, probeLayer(requireSsl)))); - -describe("legacyPreparePgDeltaRef", () => { - it.effect("passes through catalog-file refs without probing", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); - return Effect.gen(function* () { - const file = yield* prepare(dir, "supabase/.temp/pgdelta/catalog.json", "error"); - expect(file).toEqual({ ref: "supabase/.temp/pgdelta/catalog.json", sslEnv: {} }); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); - - it.effect("passes through a URL when the server refuses TLS (probe → not required)", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); - return Effect.gen(function* () { - const local = yield* prepare(dir, "postgresql://u:p@127.0.0.1:54322/postgres", false); - expect(local.ref).toBe("postgresql://u:p@127.0.0.1:54322/postgres"); - expect(local.sslEnv).toEqual({}); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); - - it.effect( - "injects the CA bundle for a non-Supabase remote that requires TLS (probe → required)", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); - return Effect.gen(function* () { - const prepared = yield* prepare(dir, "postgresql://u:p@db.example.com:5432/postgres", true); - expect(prepared.ref).toContain("sslmode=verify-ca"); - expect(prepared.ref).toContain("pgdelta-target-ca.crt"); - expect(prepared.sslEnv[LEGACY_PG_DELTA_TARGET_SSL_ENV]).toBe(LEGACY_PG_DELTA_CA_BUNDLE); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }, - ); - - it.effect("propagates a probe connection error (Go's `return false, err`)", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); - return Effect.gen(function* () { - const exit = yield* prepare( - dir, - "postgresql://u:p@db.example.com:5432/postgres", - "error", - ).pipe(Effect.exit); - expect(exit._tag).toBe("Failure"); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); - - it.effect( - "writes the CA bundle for a Supabase-hosted remote even when the probe reports no TLS", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); - return Effect.gen(function* () { - // probe=false exercises Go's `pgDeltaRootCA` Supabase fallback branch. - const prepared = yield* prepare( - dir, - "postgresql://u:p@db.abc.supabase.co:5432/postgres", - false, - ); - expect(prepared.ref).toContain("sslmode=verify-ca"); - // sslrootcert is percent-encoded in the query string (matches Go's url.Values.Encode). - expect(prepared.ref).toContain("pgdelta-target-ca.crt"); - expect( - decodeURIComponent(new URL(prepared.ref).searchParams.get("sslrootcert") ?? ""), - ).toBe("/workspace/supabase/.temp/pgdelta/pgdelta-target-ca.crt"); - expect(prepared.sslEnv[LEGACY_PG_DELTA_TARGET_SSL_ENV]).toBe(LEGACY_PG_DELTA_CA_BUNDLE); - const written = readFileSync( - join(dir, "supabase", ".temp", "pgdelta", "pgdelta-target-ca.crt"), - "utf8", - ); - expect(written).toBe(LEGACY_PG_DELTA_CA_BUNDLE); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }, - ); -}); +import { LEGACY_PG_DELTA_CA_BUNDLE } from "./legacy-pgdelta-ssl.ts"; describe("LEGACY_PG_DELTA_CA_BUNDLE", () => { it("concatenates the three Supabase CA certificates", () => { diff --git a/apps/cli/src/command-internal/legacy-pgdelta.cache.ts b/apps/cli/src/command-internal/legacy-pgdelta.cache.ts deleted file mode 100644 index 035500475b..0000000000 --- a/apps/cli/src/command-internal/legacy-pgdelta.cache.ts +++ /dev/null @@ -1,1328 +0,0 @@ -import { createHash } from "node:crypto"; -import { Clock, Effect, type FileSystem, Option, type Path } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/unstable/process/ChildProcessSpawner"; - -import { - LegacyNetworkIdFlag, - legacyResolveDebugWithProjectEnv, -} from "../shared/legacy/global-flags.ts"; -import { Output } from "../shared/output/output.service.ts"; -import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; -import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; -import { - type LegacyBaselineTomlConfig, - type LegacyDbTomlValues, - legacyReadDbToml, - legacyResolveDeclarativeDir, -} from "./legacy-db-config.toml-read.ts"; -import { legacyWalkSqlFiles } from "./legacy-glob.ts"; -import { legacyResolveDbImage } from "./legacy-db-image.ts"; -import { - legacyBuildLocalDbContainerInputs, - type LegacyLocalDbContainerInputs, -} from "./db-bootstrap/local-container-inputs.ts"; -import { legacyWaitForShadowReady } from "./db-bootstrap/health-check.ts"; -import { - legacyWithShadowDatabase, - type LegacyShadowAcquiredHandle, - type LegacyShadowCacheOpts, -} from "./db-bootstrap/shadow-cache.ts"; -import { - legacySetupShadowDatabase, - legacyShadowRunInputFromLocalContainerInputs, - type LegacyShadowSetupInput, -} from "./db-bootstrap/shadow-database.ts"; -import { legacyPgDeltaTempPath } from "./legacy-pgdelta.paths.ts"; -import { legacyCompareUtf8Bytes } from "./legacy-glob.ts"; -import { LegacyMigrationsReadError } from "./legacy-migration.errors.ts"; -import { legacyToPostgresURL } from "./legacy-postgres-url.ts"; -import { - type LegacyPgDeltaContext, - legacyExportCatalogPgDelta, - legacyResolvePgDeltaProjectId, -} from "./legacy-pgdelta.ts"; -import { legacyApplyDeclarativePgDelta } from "../commands/db/shared/legacy-pgdelta.apply.ts"; -import { LegacyDbConfigLoadError } from "./legacy-db-config.errors.ts"; -import { legacyPrepareShadowSource } from "../commands/db/shared/legacy-shadow-source.ts"; - -type Spawner = ChildProcessSpawnerType["Service"]; - -/** - * Declarative catalog-cache key builders + on-disk catalog resolution, based on - * Go (`apps/cli-go/internal/db/declarative/declarative.go` + - * `internal/db/pgcache/cache.go`). Byte-stable keys still matter for this CLI's - * own cache reuse under `supabase/.temp/pgdelta/` across runs — a drifting key - * would silently miss (re-provision) or over-hit (reuse a stale snapshot). Keys - * now intentionally diverge from the old Go binary for configs with - * `api.auto_expose_new_tables` unset: the effective value flipped to `true`, the - * baked cluster genuinely differs, and reusing a Go-era snapshot would mean - * reusing one with revoked grants. - * - * Beyond the pure key/path builders, this file also owns the migrations-catalog - * RESOLUTION path for both `db diff --from/--to migrations` and `db schema - * declarative sync` ({@link legacyResolveMigrationsCatalogRef}, - * {@link legacyGetMigrationsCatalogRef}) — including NATIVE shadow-database - * provisioning/removal (CLI-1956, {@link exportViaShadowCatalog}, the same - * `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`/ - * `legacyRemoveShadowDatabase` primitives `db diff`/`db pull` use for their own - * shadow — no seam/subprocess involved) and the "Creating shadow database..." - * stderr side effect the latter prints on a cache miss. It is not a pure module. - */ - -const CATALOG_PREFIX_PATTERN = /[^a-zA-Z0-9._-]+/g; -const CATALOG_RETENTION_COUNT = 2; -// `pkg/migration/list.go` — `<14-digit>_init.sql` first migrations (pre-2021-12-09) are skipped. -const INIT_SCHEMA_PATTERN = /([0-9]{14})_init\.sql/; -const INIT_SCHEMA_CUTOFF = 20211209000000; -// `pkg/migration/file.go` — valid migration filenames. -const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/; -// `internal/utils/misc.go` — `ProjectHostPattern`, matches a direct `db..supabase.{co,red}` host. -const PROJECT_HOST_PATTERN = /^(db\.)([a-z]{20})\.supabase\.(co|red)$/; - -/** Inputs that shape the legacy `WithLegacyPgNetBaseline` shadow setup. */ -export interface LegacySetupInputs { - /** The resolved Postgres image (`Config.Db.Image`); only its tag is used. */ - readonly image: string; - readonly majorVersion: number; - readonly authEnabled: boolean; - readonly storageEnabled: boolean; - readonly realtimeEnabled: boolean; - /** Effective `api.auto_expose_new_tables` (unset and `true` both → `true`). */ - readonly autoExpose: boolean; - /** `[db.vault]` secret names (sorted before hashing). */ - readonly vaultNames: ReadonlyArray; - /** Contents of `supabase/roles.sql` (empty string when absent). */ - readonly rolesSql: string; -} - -/** Mirrors Go's `sanitizedCatalogPrefix` (`declarative.go:765`). */ -export function legacySanitizedCatalogPrefix(prefix: string): string { - const trimmed = prefix.trim(); - if (trimmed.length === 0) return "local"; - return trimmed.replace(CATALOG_PREFIX_PATTERN, "-"); -} - -/** - * Mirrors Go's `pgcache.CatalogPrefixFromConfig` (`pgcache/cache.go`): `"local"` - * for the local dev database, the project ref for a direct `db..supabase.*` - * host, else a stable `url-` derived from the connection. - */ -export function legacyCatalogPrefixFromConfig( - conn: { - readonly host: string; - readonly port: number; - readonly user: string; - readonly database: string; - }, - isLocal: boolean, -): string { - if (isLocal) return "local"; - const match = PROJECT_HOST_PATTERN.exec(conn.host); - if (match?.[2] !== undefined) return match[2]; - const key = `${conn.user}@${conn.host}:${conn.port}/${conn.database}`; - const digest = createHash("sha256").update(key, "utf8").digest("hex"); - return `url-${digest.slice(0, 12)}`; -} - -/** Mirrors Go's `baselineVersionToken` (`declarative.go:665`): the image tag, or `pg`. */ -export function legacyBaselineVersionToken(image: string, majorVersion: number): string { - let tag = image.trim(); - const colon = tag.lastIndexOf(":"); - if (colon >= 0 && colon + 1 < tag.length) tag = tag.slice(colon + 1); - if (tag.trim().length === 0) tag = `pg${majorVersion}`; - return tag.replace(CATALOG_PREFIX_PATTERN, "-"); -} - -const boolToken = (value: boolean) => (value ? "true" : "false"); - -/** - * Mirrors Go's `setupInputsToken` (`declarative.go:688`): a 12-char hex digest of - * the platform-baseline inputs. The hashed byte sequence reproduces Go's - * `fmt.Fprintln`/`fmt.Fprintf` writes exactly so the key matches the Go binary's. - */ -export function legacySetupInputsToken(inputs: LegacySetupInputs): string { - const versionToken = legacyBaselineVersionToken(inputs.image, inputs.majorVersion); - let payload = `${versionToken}\n`; - payload += `auth=${boolToken(inputs.authEnabled)} storage=${boolToken( - inputs.storageEnabled, - )} realtime=${boolToken(inputs.realtimeEnabled)}\n`; - payload += `auto_expose_new_tables=${boolToken(inputs.autoExpose)}\n`; - for (const name of [...inputs.vaultNames].sort()) payload += `vault=${name}\n`; - payload += inputs.rolesSql; - return createHash("sha256").update(payload, "utf8").digest("hex").slice(0, 12); -} - -/** Mirrors Go's `baselineCatalogKey` (`declarative.go:729`): `-`. */ -export function legacyBaselineCatalogKey(inputs: LegacySetupInputs): string { - return `${legacyBaselineVersionToken(inputs.image, inputs.majorVersion)}-${legacySetupInputsToken( - inputs, - )}`; -} - -/** - * Resolves {@link LegacySetupInputs} from the caller's already-loaded db config: - * the resolved Postgres image, and `supabase/roles.sql`'s content (empty when - * absent, mirroring Go's `errors.Is(err, os.ErrNotExist)` tolerance in - * `setupInputsToken`, `apps/cli-go/internal/db/declarative/declarative.go:711-714`). - * Callers pass `toml.baseline` (`legacy-db-config.toml-read.ts`'s - * `LegacyBaselineTomlConfig`, already exactly this cache-key subset) verbatim. - */ -export const legacyResolveSetupInputs = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - majorVersion: number, - orioledbVersion: string | undefined, - baseline: LegacyBaselineTomlConfig, -) { - const { image } = yield* legacyResolveDbImage(fs, path, workdir, majorVersion, orioledbVersion); - const rolesPath = path.join(workdir, "supabase", "roles.sql"); - const rolesSql = yield* fs - .readFileString(rolesPath) - .pipe( - Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), - ), - ); - return { - image, - majorVersion, - authEnabled: baseline.authEnabled, - storageEnabled: baseline.storageEnabled, - realtimeEnabled: baseline.realtimeEnabled, - autoExpose: Option.getOrElse(baseline.apiAutoExposeNewTables, () => true), - vaultNames: baseline.vaultNames, - rolesSql, - } satisfies LegacySetupInputs; -}); - -/** Mirrors Go's `declarativeCatalogCacheKey` (`declarative.go:753`): `-`. */ -export function legacyDeclarativeCatalogCacheKey(setupToken: string, schemaHash: string): string { - return `${setupToken}-${schemaHash}`; -} - -/** - * Mirrors Go's `migrationsCatalogCacheKey` (`declarative.go:765`): `- - * `. Used ONLY by {@link legacyGetMigrationsCatalogRef} (the - * `db schema declarative sync` migrations source) — `db diff`'s explicit - * `--from/--to migrations` uses a bare, setup-token-less hash instead (Go's - * `resolveMigrationsCatalogRef`, `internal/db/diff/explicit.go:88`; see - * {@link legacyResolveMigrationsCatalogRef}). These are deliberately two different - * cache-key schemes over the same `catalog-local-migrations-*.json` filename - * family, matching Go exactly (CLI-1959). - */ -export function legacyMigrationsCatalogCacheKey( - setupToken: string, - migrationsHash: string, -): string { - return `${setupToken}-${migrationsHash}`; -} - -/** `catalog-baseline-.json` (`declarative.go:44`). */ -export function legacyBaselineCatalogFileName(key: string): string { - return `catalog-baseline-${key}.json`; -} - -/** `catalog--declarative--.json` (`declarative.go:46`). */ -export function legacyDeclarativeCatalogFileName( - prefix: string, - hash: string, - timestampMillis: number, -): string { - return `catalog-${legacySanitizedCatalogPrefix(prefix)}-declarative-${hash}-${timestampMillis}.json`; -} - -/** - * Lists local migration file paths under `migrationsDir`. Mirrors Go's - * `migration.ListLocalMigrations` (`pkg/migration/list.go:33`): entries are sorted by name — Go's - * `fs.ReadDir` byte-wise UTF-8 order, via {@link legacyCompareUtf8Bytes}, not JS's default - * UTF-16-code-unit `Array.prototype.sort()` — directories skipped, a deprecated - * `<14-digit>_init.sql` first migration (pre-2021-12-09) is skipped, and names must match - * `_*.sql`. - * - * Each skipped file emits a byte-exact stderr warning matching Go's - * `fmt.Fprintf(os.Stderr, …)` (`list.go:45-53`) — same wording for both the - * deprecated-init and misnamed-file cases. Because this is the shared lister, - * the warning fires for the `db diff/pull/schema declarative` and pgcache paths - * too, not only the `migration` commands, exactly as in Go. - */ -export const legacyListLocalMigrations = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - migrationsDir: string, -) { - const output = yield* Output; - // Mirror Go's single `fs.ReadDir` (`pkg/migration/list.go:34-37`): only a - // not-exist directory is "no migrations"; every other read error (the path is a - // file → `ENOTDIR`, permission denied, …) aborts rather than silently letting - // smart generate/sync believe there are no local migrations. Effect surfaces - // "not found" as a `PlatformError` with a `SystemError` reason tagged `"NotFound"`. - const names = yield* fs.readDirectory(migrationsDir).pipe( - Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" - ? Effect.succeed([] as ReadonlyArray) - : Effect.fail( - new LegacyMigrationsReadError({ - message: `failed to read directory: ${error.message}`, - }), - ), - ), - ); - if (names.length === 0) return [] as ReadonlyArray; - // Go's `fs.ReadDir` (`pkg/migration/list.go:34`) returns entries sorted byte-wise over each - // name's UTF-8 encoding — NOT JS's default `Array.prototype.sort()`, which compares UTF-16 code - // units and disagrees with byte/codepoint order for a supplementary-plane filename character - // alongside a BMP private-use one (see {@link legacyCompareUtf8Bytes}'s own doc comment, - // verified empirically there against both Go's `sort.Strings` and `os.ReadDir`). Left - // uncorrected, such a migrations directory would replay in a different order than Go, and a - // dependent migration could fail or produce a different shadow schema (review: - // PRRT_kwDOErm0O86W3OyD). - const sorted = [...names].sort(legacyCompareUtf8Bytes); - const result: Array = []; - for (let index = 0; index < sorted.length; index++) { - const name = sorted[index]!; - const entryPath = path.join(migrationsDir, name); - // Go's `os.ReadDir`/`DirEntry.IsDir()` (`pkg/migration/list.go:34-43`) classifies a - // directory entry from its own type without following symlinks (verified empirically: - // `DirEntry.IsDir()` reports `false` for a `.sql` symlink whose target is a directory) — - // so a symlinked migration is never skipped as a directory in Go, only later, when - // `ApplyMigrations` fails to read it as a regular file. `fs.stat` below follows - // symlinks, so it would misclassify a symlink-to-directory as a plain directory and - // silently skip it here instead. Check `readLink` (which only succeeds for a symlink) - // first and skip the directory check entirely for symlinks, matching Go's `IsDir()`. - const isSymlink = Option.isSome(yield* fs.readLink(entryPath).pipe(Effect.option)); - if (!isSymlink) { - const stat = yield* fs.stat(entryPath).pipe(Effect.option); - if (Option.isSome(stat) && stat.value.type === "Directory") continue; - } - if (index === 0) { - const init = INIT_SCHEMA_PATTERN.exec(name); - if (init !== null && Number(init[1]) < INIT_SCHEMA_CUTOFF) { - yield* output.raw( - `Skipping migration ${name}... (replace "init" with a different file name to apply this migration)\n`, - "stderr", - ); - continue; - } - } - if (!MIGRATE_FILE_PATTERN.test(name)) { - yield* output.raw( - `Skipping migration ${name}... (file name must match pattern "_name.sql")\n`, - "stderr", - ); - continue; - } - result.push(entryPath); - } - return result as ReadonlyArray; -}); - -/** - * Mirrors Go's `pgcache.HashMigrations` (`pgcache/cache.go`): for each local - * migration (in list order), hash its `workdir`-relative path then its - * contents. Returns full hex. - */ -export const legacyHashMigrations = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - migrationsDir: string, -) { - const migrations = yield* legacyListLocalMigrations(fs, path, migrationsDir); - const hash = createHash("sha256"); - for (const filePath of migrations) { - const contents = yield* fs.readFile(filePath); - hash.update(path.relative(workdir, filePath), "utf8"); - hash.update(contents); - } - return hash.digest("hex"); -}); - -/** - * Walk the declarative dir for regular `.sql` files, byte-sort by relative path, and hash - * each file's forward-slash relative path then its contents. Returns full hex. - * - * Uses {@link legacyWalkSqlFiles} for the traversal, which gives three properties this cache - * key depends on: the walk is strict (a readDirectory/stat failure fails the hash rather than - * shrinking it into a possible stale-cache collision), it never follows symlinks (a directory - * symlink pointing at an ancestor would otherwise loop the walk forever, and symlinked `.sql` - * files are excluded like non-regular files), and each directory level plus the final list is - * byte-sorted so the key is stable across platforms. A missing root is the one tolerated - * case (deterministic empty hash; callers gate on the dir existing before catalog export) — - * `fs.exists` maps only not-found to `false`, so any other root failure (permissions, I/O) - * propagates instead of masquerading as an empty tree. - * - * Deliberate divergence from the old Go walk: a declarative root that is ITSELF a directory - * symlink is followed (entries beneath it still aren't). Go's lstat-rooted walk hashed such a - * root as an empty tree while the apply path followed the link and applied the target's files — - * a hash≠apply mismatch of exactly the stale-catalog class this function guards against. The - * cost is a one-time cache miss for symlinked-root setups. - */ -export const legacyHashDeclarativeSchemas = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - _path: Path.Path, - declarativeDir: string, -) { - const exists = yield* fs.exists(declarativeDir); - const files = exists ? yield* legacyWalkSqlFiles(fs, declarativeDir, "") : []; - const hash = createHash("sha256"); - for (const rel of files) { - const contents = yield* fs.readFile(`${declarativeDir}/${rel}`); - hash.update(rel, "utf8"); - hash.update(contents); - } - return hash.digest("hex"); -}); - -const parseCatalogTimestamp = (name: string): Option.Option => { - if (!name.endsWith(".json")) return Option.none(); - const raw = name.slice(0, -".json".length); - const idx = raw.lastIndexOf("-"); - if (idx < 0 || idx + 1 >= raw.length) return Option.none(); - const ts = Number(raw.slice(idx + 1)); - return Number.isInteger(ts) ? Option.some(ts) : Option.none(); -}; - -/** - * Mirrors Go's `ensureTempDir` + `ReadDir` pairing (`pgcache/cache.go`, - * `declarative.go`): the temp dir's existence is already guaranteed by the - * `MkdirAll` that runs before every write into it, so Go's `ReadDir` only ever - * needs to tolerate a genuinely missing directory (a cache that was never - * written to) — every OTHER read failure (e.g. permission denied) propagates, - * same as {@link legacyListLocalMigrations} above. Swallowing every failure - * (as an earlier version of this did) let a real read error silently look like - * "no cached catalogs", which both bypasses catalog resolution's cache HIT and - * — for cleanup's caller — bypasses the retention limit indefinitely, since - * the caller's own warning path never fires without a propagated failure. - */ -const listJsonEntries = Effect.fnUntraced(function* (fs: FileSystem.FileSystem, tempDir: string) { - return yield* fs.readDirectory(tempDir).pipe( - Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" - ? Effect.succeed([] as ReadonlyArray) - : Effect.fail( - new LegacyMigrationsReadError({ - message: `failed to read directory: ${error.message}`, - }), - ), - ), - ); -}); - -/** - * Shared "highest suffixed timestamp wins" scan behind both - * {@link legacyResolveDeclarativeCatalogPath} and {@link legacyResolveMigrationCatalogPath}: - * of every `.json` entry in `tempDir`, returns the path with the - * highest `ts`. Mirrors both Go's `resolveDeclarativeCatalogPath` - * (`declarative.go:578`) and `pgcache.ResolveMigrationCatalogPath` - * (`internal/db/pgcache/cache.go:112-149`), which share this exact scan over their - * own filename family — only the family prefix differs between callers. - */ -const resolveLatestByFamily = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - familyPrefix: string, -) { - const entries = yield* listJsonEntries(fs, tempDir); - let latestPath = Option.none(); - let latest = -1; - for (const name of entries) { - if (!name.startsWith(familyPrefix) || !name.endsWith(".json")) continue; - const stamp = Number(name.slice(familyPrefix.length, -".json".length)); - if (Number.isInteger(stamp) && stamp > latest) { - latest = stamp; - latestPath = Option.some(path.join(tempDir, name)); - } - } - return latestPath; -}); - -/** - * Resolves the newest cached declarative catalog for `(hash, prefix)`. Mirrors - * Go's `resolveDeclarativeCatalogPath` (`declarative.go:578`): of all - * `catalog--declarative--.json`, returns the highest `ts`. - */ -export const legacyResolveDeclarativeCatalogPath = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - hash: string, - prefix: string, -) { - return yield* resolveLatestByFamily( - fs, - path, - tempDir, - `catalog-${legacySanitizedCatalogPrefix(prefix)}-declarative-${hash}-`, - ); -}); - -const cleanupOldCatalogsByFamily = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - familyPrefix: string, -) { - const entries = yield* listJsonEntries(fs, tempDir); - const files = entries - .filter((name) => name.startsWith(familyPrefix) && name.endsWith(".json")) - .map((name) => ({ name, timestamp: Option.getOrElse(parseCatalogTimestamp(name), () => 0) })) - .sort((a, b) => - b.timestamp === a.timestamp ? (a.name > b.name ? -1 : 1) : b.timestamp - a.timestamp, - ); - // Removal failures propagate: retention silently not being enforced would let - // snapshots accumulate indefinitely while every run reports a successful write. - for (let index = CATALOG_RETENTION_COUNT; index < files.length; index++) { - yield* fs.remove(path.join(tempDir, files[index]!.name)); - } -}); - -/** - * Removes all but the newest `catalogRetentionCount` declarative catalogs for a - * prefix family. Mirrors Go's `cleanupOldDeclarativeCatalogs` (`declarative.go:610`). - */ -export const legacyCleanupOldDeclarativeCatalogs = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - prefix: string, -) { - yield* cleanupOldCatalogsByFamily( - fs, - path, - tempDir, - `catalog-${legacySanitizedCatalogPrefix(prefix)}-declarative-`, - ); -}); - -/** - * Removes all but the newest `catalogRetentionCount` migrations catalogs for a - * prefix family. Mirrors Go's `pgcache.CleanupOldMigrationCatalogs` (`pgcache/cache.go`). - */ -export const legacyCleanupOldMigrationCatalogs = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - prefix: string, -) { - yield* cleanupOldCatalogsByFamily( - fs, - path, - tempDir, - `catalog-${legacySanitizedCatalogPrefix(prefix)}-migrations-`, - ); -}); - -/** `catalog--migrations--.json` (Go's `migrationsCatalogName`, `pgcache/cache.go`). */ -export function legacyMigrationCatalogFileName( - prefix: string, - hash: string, - timestampMillis: number, -): string { - return `catalog-${legacySanitizedCatalogPrefix(prefix)}-migrations-${hash}-${timestampMillis}.json`; -} - -/** - * Resolves the newest cached migrations catalog for `(hash, prefix)`. Mirrors - * Go's `pgcache.ResolveMigrationCatalogPath` (`internal/db/pgcache/cache.go:112-149`). - * Go's fallback to a pre-timestamp legacy filename (`catalog--migrations- - * .json`, no `-` suffix) is intentionally NOT replicated: nothing in the - * Go tree writes that name any more — `pgcache.MigrationCatalogPath` has always - * produced the timestamped form since the fallback was added in the same commit - * (CLI-1959 go-parity-auditor finding) — so it is unreachable dead code on both - * sides. - */ -export const legacyResolveMigrationCatalogPath = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - hash: string, - prefix: string, -) { - return yield* resolveLatestByFamily( - fs, - path, - tempDir, - `catalog-${legacySanitizedCatalogPrefix(prefix)}-migrations-${hash}-`, - ); -}); - -/** - * Writes a migrations-catalog snapshot to `/catalog--migrations--.json` - * and prunes older snapshots for the same `(prefix)` family. Mirrors Go's - * `pgcache.WriteMigrationCatalogSnapshot` (`pgcache/cache.go`). - */ -export const legacyWriteMigrationCatalogSnapshot = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - prefix: string, - hash: string, - snapshot: string, - timestampMillis: number, -) { - yield* fs.makeDirectory(tempDir, { recursive: true }).pipe(Effect.ignore); - const filePath = path.join( - tempDir, - legacyMigrationCatalogFileName(prefix, hash, timestampMillis), - ); - yield* fs.writeFileString(filePath, snapshot); - yield* legacyCleanupOldMigrationCatalogs(fs, path, tempDir, prefix); - return filePath; -}); - -/** - * Best-effort caches the migrations catalog for pg-delta after a successful - * `db push` migration apply. Mirrors Go's `pgcache.TryCacheMigrationsCatalog` - * (`pgcache/cache.go`); `enabled` is resolved by the caller since it depends on - * already-loaded config. Reuses `legacyExportCatalogPgDelta` (Go's correct - * `diff/pgdelta.go` `ExportCatalogPgDelta`) rather than porting a second copy, - * so this can't reintroduce the `/workspace` mount bug `pgcache/cache.go` had - * (supabase/cli#5921). - * - * The snapshot's timestamp is read from `Clock` HERE — after `legacyHashMigrations` - * and `legacyExportCatalogPgDelta` (the network round-trip) have both resolved, - * immediately before the write — never accepted as a caller-supplied parameter. - * This mirrors Go's own call order exactly: `TryCacheMigrationsCatalog` - * (`pgcache/cache.go:71-91`) resolves `hash` and `snapshot` FIRST, and only THEN - * calls `WriteMigrationCatalogSnapshot`, which itself reads `time.Now().UTC()` - * (`pgcache/cache.go:151-163`) — i.e. Go's clock read happens LAST, right before - * the file write, not before the export. A caller capturing the timestamp before - * calling this function (review CLI-1958) would race a concurrent cache write - * from another process: Go would order the two snapshots by real write-time, but - * the early-captured timestamp could sort the wrong one as "latest" during - * catalog resolution/retention (`legacyResolveMigrationCatalogPath`, - * `legacyCleanupOldMigrationCatalogs`). - */ -export const legacyTryCacheMigrationsCatalog = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - ctx: LegacyPgDeltaContext, - params: { - readonly enabled: boolean; - readonly targetUrl: string; - readonly conn: { - readonly host: string; - readonly port: number; - readonly user: string; - readonly database: string; - }; - readonly isLocal: boolean; - readonly migrationsDir: string; - }, -) { - if (!params.enabled) return; - const prefix = legacyCatalogPrefixFromConfig(params.conn, params.isLocal); - const hash = yield* legacyHashMigrations(fs, path, ctx.cwd, params.migrationsDir); - const snapshot = yield* legacyExportCatalogPgDelta(ctx, { - targetRef: params.targetUrl, - role: "postgres", - }); - const nowMillis = yield* Clock.currentTimeMillis; - yield* legacyWriteMigrationCatalogSnapshot( - fs, - path, - legacyPgDeltaTempPath(path, ctx.cwd), - prefix, - hash, - snapshot, - nowMillis, - ); -}); - -/** The spawner + already-built local container inputs {@link exportViaShadowCatalog} needs. */ -interface LegacyShadowCatalogInputs { - readonly spawner: ChildProcessSpawnerType["Service"]; - readonly localInputs: LegacyLocalDbContainerInputs; -} - -/** - * Builds the {@link LegacyShadowCatalogInputs} {@link exportViaShadowCatalog} needs — the SAME - * second `@supabase/config` load (`legacyBuildLocalDbContainerInputs`) `db diff`/`db pull` run - * before their own "Creating shadow database..." banner (`diff.handler.ts`'s `localInputs` - * build, see that call site's doc comment). Split out from `exportViaShadowCatalog` itself so - * {@link legacyGetMigrationsCatalogRef} can run it BEFORE printing its own banner: this load can - * fail on its own (e.g. an enabled API TLS's unreadable cert/key files, which `toml` never - * reads), and Go's config loading — ALL of it, including this validation — runs once in the - * root `PersistentPreRunE`, strictly before `declarative.go`'s `createShadowContainer` ever - * prints "Creating shadow database..." (`declarative.go:490`). Building it as an implicit side - * effect of `exportViaShadowCatalog` (called only after the banner already printed) would - * surface that failure AFTER the banner instead, unlike Go. {@link legacyResolveMigrationsCatalogRef} - * has no such banner, so calling this immediately before `exportViaShadowCatalog` on its own - * cache-miss path is harmless there too — it only ever changes when a pre-existing, - * unconditional build runs relative to a print that never happens on that path. - */ -const legacyBuildShadowCatalogInputs = Effect.fnUntraced(function* ( - ctx: LegacyPgDeltaContext, - toml: LegacyDbTomlValues, - provisionParams: { readonly projectRef?: string }, -) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runtimeInfo = yield* RuntimeInfo; - const networkIdFlag = yield* LegacyNetworkIdFlag; - // Go's equivalent stderr writer for the shadow's one-shot setup jobs is - // `utils.GetDebugLogger()` = `viper.GetBool("DEBUG")` (`internal/utils/logger.go:11`), - // which also honors `SUPABASE_DEBUG` via `AutomaticEnv` — NOT the bare `--debug` pflag - // value. `legacyResolveDebugWithProjectEnv` reproduces that (plus the project `.env` - // Go's `loadNestedEnv` has already `os.Setenv`'d into the process by this point). - const debug = yield* legacyResolveDebugWithProjectEnv(toml.projectEnv); - const localInputs = yield* legacyBuildLocalDbContainerInputs( - spawner, - ctx.cwd, - networkIdFlag, - runtimeInfo.platform, - debug, - provisionParams.projectRef, - toml.remoteOverrideKeys, - ); - return { spawner, localInputs } satisfies LegacyShadowCatalogInputs; -}); - -/** - * Shared shadow-provision → pg-delta export → persist → cleanup mechanics behind - * both {@link legacyResolveMigrationsCatalogRef} and {@link legacyGetMigrationsCatalogRef} - * on a cache miss. Provisions the shadow via the SAME native primitives `db - * diff`/`db pull` use for their own diff-source shadow (CLI-1956, - * `legacyCreateShadowDatabase` + `legacyPrepareShadowSource` + - * `legacyRemoveShadowDatabase`, `commands/db/shared/legacy-shadow-source.ts`) — - * NOT the retired `db __shadow` hidden CLI subcommand, which was only ever a - * TS-facing IPC shim over these same Go functions. This is in fact TRUER Go - * parity than the shim it replaces: Go's own two callers of this mechanics — - * `resolveMigrationsCatalogRef` (`apps/cli-go/internal/db/diff/explicit.go:88-126`) - * and `getMigrationsCatalogRef`'s `createShadow`/`createShadowContainer` - * (`apps/cli-go/internal/db/declarative/declarative.go:368-430,487-506`) — both - * call `diff.CreateShadowDatabase` + `diff.MigrateShadowDatabase` (via - * `start.WaitForHealthyService`) DIRECTLY, in-process, never through a CLI - * subcommand. `legacyPrepareShadowSource` is called with `targetLocal: false` + - * `usePgDelta: false`, which skips its ENTIRE declarative-schema-override branch - * (Go's local-target `PrepareShadowSource`/`shadow.go:37-91` branch) — neither Go - * function above ever takes that branch either, since neither has a "target" at - * all; they only ever provision + migrate + export. - * - * Exports the shadow's catalog via the already-native {@link legacyExportCatalogPgDelta} - * (the same edge-runtime script Go's own `ExportCatalogPgDelta` runs), hands the - * snapshot to `persist` to decide where it lands on disk, then removes the shadow - * (`Effect.acquireUseRelease`'s release phase, once the `use` phase below has run — - * success or failure alike) — matching Go's `defer utils.DockerRemove(shadow)` - * immediately after creation, and `diff.handler.ts`/`pull.handler.ts`'s own - * `acquire`=create/`use`=prepare+diff/`release`=remove shape for the exact same - * interruptibility reason (see `legacyPrepareShadowSource`'s own doc comment: - * creation runs inside `acquireUseRelease`'s uninterruptible `acquire`, while the - * health-wait/migrate sequence stays in the interruptible `use` phase, so a SIGINT - * during either can still land while the shadow is still reliably torn down). This - * is NOT an unconditional guarantee, though — see `legacyCreateShadowDatabase`'s own - * doc comment (`shadow-database.ts`) for the still-present, deliberate-Go-parity - * leak window when `acquire` itself (container creation) fails partway through. - * - * The persisted path is made relative to `ctx.cwd` before returning: every caller - * feeds this ref into pg-delta's edge-runtime scripts as SOURCE/TARGET, which - * prefix a bare (non-postgres://) ref with `/workspace/` — matching the container - * bind `${ctx.cwd}:/workspace` (`legacyPgDeltaContainerRef`, `legacy-pgdelta.ts: - * 100-103`). Go's equivalent (`pgcache.WriteMigrationCatalogSnapshot`) is only - * ever built from `utils.TempDir`, a workdir-RELATIVE constant (Go chdirs into the - * workdir first), so the ref it returns is relative too; return the same shape - * here rather than the absolute host path `persist` builds internally. The two - * public functions differ only in their cache-decision and `persist`'s - * cache-write logic, not in this mechanics. - * - * `toml` is the caller's own already-loaded/remote-merged `config.toml` read - * (`legacyReadDbToml`'s result) — used, together with the caller-supplied - * {@link LegacyShadowCatalogInputs} (built by {@link legacyBuildShadowCatalogInputs}), - * to derive the shadow's own container spec (image, JWT secret, root key, - * `db.settings`, service enabled-for-setup flags) exactly like `db diff`/`db pull` - * do for their own shadow. The build is NOT performed in here — see - * {@link legacyBuildShadowCatalogInputs}'s own doc comment for why a caller that - * prints a "Creating shadow database..." banner first must build it BEFORE that - * print, not have it built implicitly as a side effect of calling this function. - */ -/** - * A provisioned shadow, ready to export a pg-delta catalog from. `sourceUrl` is the only field - * {@link exportViaShadowCatalog} itself reads — {@link legacyPrepareShadowSource}'s richer - * `LegacyShadowSourceResult` (used by the migrations-catalog `provision` below) satisfies this - * structurally, so callers that provision a bare platform-baseline/declarative shadow (no - * migrations-catalog `targetUrlOverride` concept) can return just this shape. - */ -interface LegacyProvisionedShadow { - readonly sourceUrl: string; -} - -/** - * Shared shadow-provision → pg-delta export → persist → cleanup mechanics behind every - * `exportCatalog` composition in this file: {@link legacyResolveMigrationsCatalogRef} and - * {@link legacyGetMigrationsCatalogRef} below (migrations catalogs, via `provision = - * legacyPrepareShadowSource`, which additionally applies local migrations/declarative overrides), - * and {@link legacyExportBaselineCatalogRef}/{@link legacyExportDeclarativeCatalogRef} (the native - * `LegacyDeclarativeSeam.exportCatalog` compositions, via a `provision` that runs ONLY the - * platform baseline — see those functions' own doc comments for why they must NOT reuse - * `legacyPrepareShadowSource`, which applies local migrations). `provision` is the one part of the - * shadow lifecycle that genuinely differs between callers; everything else (create, export, - * persist, remove) is identical, so it is parameterized here rather than duplicated — see this - * function's own git history for the sibling-function shape this replaced. - */ -const exportViaShadowCatalog = ( - fs: FileSystem.FileSystem, - path: Path.Path, - ctx: LegacyPgDeltaContext, - toml: LegacyDbTomlValues, - built: LegacyShadowCatalogInputs, - provision: ( - spawner: Spawner, - handle: LegacyShadowAcquiredHandle, - shadowInput: LegacyShadowSetupInput, - ) => Effect.Effect, - persist: (snapshot: string) => Effect.Effect, - // No default: every caller must declare its provisioner's effective webhooks policy. - shadowCacheOpts: LegacyShadowCacheOpts, -) => - Effect.gen(function* () { - const { spawner, localInputs } = built; - const resolvedImage = yield* localInputs.resolvePostgresImage; - const shadowInput = legacyShadowRunInputFromLocalContainerInputs( - localInputs, - resolvedImage, - toml, - fs, - path, - ); - // `legacyWithShadowDatabase` (`db-bootstrap/shadow-cache.ts`) rather than a bare - // `legacyCreateShadowDatabase`/`legacyRemoveShadowDatabase` pair — see its doc comment: with - // `SUPABASE_SHADOW_CACHE` explicitly disabled it IS that pair (identical Docker argv, - // identical labels), and by default (cache on) a catalog cache miss restores a key-matching - // PGDATA snapshot into the fresh shadow instead of paying the full cold provision — the same - // swap `db diff`/`db pull`'s own call sites make. `shadowCacheOpts` carries `sync - // --no-cache`'s bypass and the caller's effective Webhooks policy — see - // `LegacyShadowCacheOpts`. - const written = yield* legacyWithShadowDatabase( - spawner, - shadowInput, - (handle) => - Effect.gen(function* () { - const shadow = yield* provision(spawner, handle, shadowInput); - const snapshot = yield* legacyExportCatalogPgDelta(ctx, { - targetRef: shadow.sourceUrl, - role: "postgres", - }); - return yield* persist(snapshot); - }), - shadowCacheOpts, - ); - return path.relative(ctx.cwd, written); - }); - -/** - * {@link exportViaShadowCatalog}'s `provision` for the migrations-catalog callers - * ({@link legacyResolveMigrationsCatalogRef}/{@link legacyGetMigrationsCatalogRef}): extends the - * base shadow-setup input with the pg-delta/declarative-override fields - * {@link legacyPrepareShadowSource} needs (`targetLocal: false`/`usePgDelta: false` — neither - * caller has a "target" at all, they only ever provision + migrate + export), then applies local - * migrations via `legacyMigrateShadowDatabase`. - */ -const legacyProvisionMigrationsShadow = ( - ctx: LegacyPgDeltaContext, - toml: LegacyDbTomlValues, - spawner: Spawner, - handle: LegacyShadowAcquiredHandle, - shadowInput: LegacyShadowSetupInput, -) => - legacyPrepareShadowSource(spawner, handle, { - ...shadowInput, - targetLocal: false, - usePgDelta: false, - schemaPaths: toml.schemaPathPatterns, - pgDelta: toml.pgDelta, - ctx, - }); - -/** - * Resolves the pg-delta migrations-catalog ref for `db diff`'s explicit - * `--from migrations` / `--to migrations` target — the native replacement for - * the hidden Go seam `db schema declarative __catalog --mode migrations` this - * call site used to shell out to (CLI-1959). Mirrors Go's - * `resolveMigrationsCatalogRef` (`apps/cli-go/internal/db/diff/explicit.go:88-126`) - * EXACTLY — not {@link legacyGetMigrationsCatalogRef} below, which backs a - * different Go function (`declarative.go`'s `getMigrationsCatalogRef`, used by - * `db schema declarative sync`). The two diverge on purpose: this one uses a - * BARE migrations-content hash (no setup-inputs token — `explicit.go:89`'s - * `pgcache.HashMigrations`), always consults the cache (`db diff` has no - * `--no-cache` flag on this path), has no zero-migrations/baseline special case, - * and prints no "Creating shadow database..." line (Go calls the shadow - * primitives directly, without `DiffDatabase`'s own progress line). - * - * On a cache miss, the shadow-provision/export/persist/cleanup mechanics are - * shared with {@link legacyGetMigrationsCatalogRef} via {@link exportViaShadowCatalog} - * — see its doc comment. The catalog is cached with - * {@link legacyWriteMigrationCatalogSnapshot}. `toml` is the caller's own - * already-loaded/remote-merged `config.toml` read, threaded through to - * {@link exportViaShadowCatalog} for the shadow's own container spec (CLI-1956) — - * see that function's doc comment. - */ -export const legacyResolveMigrationsCatalogRef = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - ctx: LegacyPgDeltaContext, - toml: LegacyDbTomlValues, - params: { readonly projectRef?: string }, -) { - const tempDir = legacyPgDeltaTempPath(path, ctx.cwd); - const migrationsDir = path.join(ctx.cwd, "supabase", "migrations"); - const hash = yield* legacyHashMigrations(fs, path, ctx.cwd, migrationsDir); - const cached = yield* legacyResolveMigrationCatalogPath(fs, path, tempDir, hash, "local"); - if (Option.isSome(cached)) return path.relative(ctx.cwd, cached.value); - - const built = yield* legacyBuildShadowCatalogInputs(ctx, toml, params); - return yield* exportViaShadowCatalog( - fs, - path, - ctx, - toml, - built, - (spawner, handle, shadowInput) => - legacyProvisionMigrationsShadow(ctx, toml, spawner, handle, shadowInput), - (snapshot) => - Effect.gen(function* () { - const timestamp = yield* Clock.currentTimeMillis; - return yield* legacyWriteMigrationCatalogSnapshot( - fs, - path, - tempDir, - "local", - hash, - snapshot, - timestamp, - ); - }), - // `legacyProvisionMigrationsShadow` migrates via `legacyMigrateShadowDatabase`, which forces - // `pg_net` on — the key must record that, not the config-following default (no `bypassCache`: - // `db diff` has no `--no-cache` on this path). - { webhooks: "enabled" }, - ); -}); - -/** `catalog-nocache-migrations.json` — Go's `noCacheMigrationsCatalogPath` (`declarative.go:51`). */ -const NO_CACHE_MIGRATIONS_CATALOG_NAME = "catalog-nocache-migrations.json"; - -/** - * Resolves (and caches under `supabase/.temp/pgdelta/`) the pg-delta migrations - * catalog — platform baseline + local migrations applied — for `db schema - * declarative sync`'s diff SOURCE. The native replacement for the hidden Go seam - * `db schema declarative __catalog --mode migrations` this call site used to - * shell out to (CLI-1959). Mirrors Go's `getMigrationsCatalogRef` - * (`apps/cli-go/internal/db/declarative/declarative.go:368-430`) — see - * {@link legacyResolveMigrationsCatalogRef}'s doc comment for exactly how this - * diverges from `db diff`'s bare-hash version: this one folds the setup-inputs - * token into the cache key, special-cases zero local migrations by reusing/ - * writing the platform-baseline catalog, honors `--no-cache`, and prints - * "Creating shadow database..." to stderr on a cache miss - * (`declarative.go:490`, reached only when `createShadow` actually runs). - * - * On a cache miss, the shadow-provision/export/persist/cleanup mechanics are - * shared with {@link legacyResolveMigrationsCatalogRef} via - * {@link exportViaShadowCatalog} — see its doc comment. `toml` is the caller's - * own already-loaded/remote-merged `config.toml` read, threaded through for the - * shadow's own container spec (CLI-1956) — distinct from `setupInputs`, which is - * only the cache-key/baseline-setup subset. - */ -export const legacyGetMigrationsCatalogRef = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - ctx: LegacyPgDeltaContext, - toml: LegacyDbTomlValues, - setupInputs: LegacySetupInputs, - params: { readonly noCache: boolean; readonly projectRef?: string }, -) { - const output = yield* Output; - const tempDir = legacyPgDeltaTempPath(path, ctx.cwd); - const migrationsDir = path.join(ctx.cwd, "supabase", "migrations"); - const migrations = yield* legacyListLocalMigrations(fs, path, migrationsDir); - const zeroMigrations = migrations.length === 0; - - // Built BEFORE the cache probes below, not just before the banner: this is a SECOND - // `@supabase/config` load (`legacyBuildLocalDbContainerInputs`) whose failure (e.g. an - // enabled API TLS's unreadable cert/key files) must surface regardless of cache state — - // Go's config loading (all of it) ran once in the root `PersistentPreRunE`, strictly - // before any catalog cache lookup and before `declarative.go`'s `createShadowContainer` - // ever prints the banner (`declarative.go:490`). Probing first would make an invalid - // project succeed or fail depending on whether a cache file happens to exist. - const built = yield* legacyBuildShadowCatalogInputs(ctx, toml, params); - - const baselinePath = path.join( - tempDir, - legacyBaselineCatalogFileName(legacyBaselineCatalogKey(setupInputs)), - ); - if (zeroMigrations && !params.noCache) { - // `fs.exists` maps only not-found to `false`, so any other probe failure (permissions, - // I/O under `.temp/pgdelta`) propagates — matching Go's `getMigrationsCatalogRef` - // returning the `afero.Exists` error immediately, before any Docker side effect. - const exists = yield* fs.exists(baselinePath); - if (exists) return path.relative(ctx.cwd, baselinePath); - } - - // Mirrors Go's unconditional `migrationsCatalogCacheKey` call (`declarative.go:393`), - // which always runs — even on the zeroMigrations/noCache paths — since it is pure - // and only unused there, not because it needs to run early for a side effect. - const setupToken = legacySetupInputsToken(setupInputs); - const migrationsHash = yield* legacyHashMigrations(fs, path, ctx.cwd, migrationsDir); - const hash = legacyMigrationsCatalogCacheKey(setupToken, migrationsHash); - - if (!params.noCache && !zeroMigrations) { - const cached = yield* legacyResolveMigrationCatalogPath(fs, path, tempDir, hash, "local"); - if (Option.isSome(cached)) return path.relative(ctx.cwd, cached.value); - } - - yield* output.raw("Creating shadow database...\n", "stderr"); - return yield* exportViaShadowCatalog( - fs, - path, - ctx, - toml, - built, - (spawner, handle, shadowInput) => - legacyProvisionMigrationsShadow(ctx, toml, spawner, handle, shadowInput), - (snapshot) => - Effect.gen(function* () { - if (params.noCache) { - yield* fs.makeDirectory(tempDir, { recursive: true }).pipe(Effect.ignore); - const noCachePath = path.join(tempDir, NO_CACHE_MIGRATIONS_CATALOG_NAME); - yield* fs.writeFileString(noCachePath, snapshot); - return noCachePath; - } - if (zeroMigrations) { - yield* fs.makeDirectory(tempDir, { recursive: true }).pipe(Effect.ignore); - yield* fs.writeFileString(baselinePath, snapshot); - return baselinePath; - } - const timestamp = yield* Clock.currentTimeMillis; - return yield* legacyWriteMigrationCatalogSnapshot( - fs, - path, - tempDir, - "local", - hash, - snapshot, - timestamp, - ); - }), - // `--no-cache` must also bypass the baseline snapshot, not just the catalog. - { bypassCache: params.noCache, webhooks: "enabled" }, - ); -}); - -/** `catalog-nocache-baseline.json` — Go's `noCacheBaselineCatalogPath` (`declarative.go:50`). */ -export const LEGACY_NO_CACHE_BASELINE_CATALOG_NAME = "catalog-nocache-baseline.json"; - -/** `catalog-nocache-declarative.json` — Go's `noCacheDeclarativeCatalogPath` (`declarative.go:52`). */ -export const LEGACY_NO_CACHE_DECLARATIVE_CATALOG_NAME = "catalog-nocache-declarative.json"; - -/** Writes a catalog snapshot to an exact path, creating `tempDir` first. Mirrors Go's `writeTempCatalog`/`ensureTempDir` pairing (`declarative.go:553-568`) for a caller that already knows its target file name (a keyed baseline catalog, or either mode's `--no-cache` file), unlike {@link legacyWriteMigrationCatalogSnapshot}/{@link legacyWriteDeclarativeCatalogSnapshot} below, which also derive the file name and prune older snapshots. */ -const legacyWriteCatalogFile = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - tempDir: string, - filePath: string, - snapshot: string, -) { - yield* fs.makeDirectory(tempDir, { recursive: true }).pipe(Effect.ignore); - yield* fs.writeFileString(filePath, snapshot); - return filePath; -}); - -/** - * Writes a declarative-catalog snapshot to - * `/catalog--declarative--.json` and prunes older snapshots for the - * same `(prefix, hash)` family (retention 2). The declarative sibling of - * {@link legacyWriteMigrationCatalogSnapshot}; mirrors Go's `writeDeclarativeCatalogFromConfig`'s - * own persist step (`declarative.go:463-485`). - */ -export const legacyWriteDeclarativeCatalogSnapshot = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - prefix: string, - hash: string, - snapshot: string, - timestampMillis: number, -) { - const filePath = path.join( - tempDir, - legacyDeclarativeCatalogFileName(prefix, hash, timestampMillis), - ); - yield* legacyWriteCatalogFile(fs, tempDir, filePath, snapshot); - yield* legacyCleanupOldDeclarativeCatalogs(fs, path, tempDir, prefix); - return filePath; -}); - -/** - * {@link exportViaShadowCatalog}'s `provision` for {@link legacyExportBaselineCatalogRef}: the - * platform baseline ONLY — no local migrations, no declarative apply. Mirrors Go's - * `getGenerateBaselineCatalogRef` (`declarative.go:306-361`), which sets up the shadow via - * `setupShadowDatabase` (the Supabase platform baseline, auth/storage/realtime) and nothing - * else. Deliberately does NOT call {@link legacyPrepareShadowSource}/`legacyMigrateShadowDatabase` - * — those apply local migrations, which would contaminate the baseline catalog Go's own - * `baselineCatalogName` doc comment warns against (the baseline is reused as sync's diff - * SOURCE when there are no local migrations, and as generate's diff SOURCE against a live - * database — both need "platform baseline, nothing else"). - */ -const legacyProvisionBaselineShadow = ( - spawner: Spawner, - fs: FileSystem.FileSystem, - path: Path.Path, - ctx: LegacyPgDeltaContext, - handle: LegacyShadowAcquiredHandle, - shadowInput: LegacyShadowSetupInput, -) => - Effect.gen(function* () { - const connConfig: LegacyPgConnInput = { - host: shadowInput.hostname, - port: shadowInput.shadowPort, - user: "postgres", - password: shadowInput.password, - database: "postgres", - }; - yield* legacyWaitForShadowReady(spawner, handle.containerId, connConfig, { - timeoutSeconds: shadowInput.healthTimeoutSeconds, - image: shadowInput.image, - }); - yield* legacySetupShadowDatabase( - spawner, - { - fs, - path, - workdir: ctx.cwd, - projectId: shadowInput.projectId, - container: handle.containerId, - networkId: shadowInput.networkId, - connConfig, - setup: shadowInput.setup, - }, - {}, - handle, - ); - return { sourceUrl: legacyToPostgresURL(connConfig) } satisfies LegacyProvisionedShadow; - }); - -/** - * {@link exportViaShadowCatalog}'s `provision` for {@link legacyExportDeclarativeCatalogRef}: the - * platform baseline, THEN the declarative directory applied to the shadow's own `postgres` - * database (NOT `contrib_regression` — unlike `legacy-shadow-source.ts`'s local-target override - * branch, there is no separate "target" database here; the shadow IS the declarative target). - * Mirrors Go's `getDeclarativeCatalogRef`/`writeDeclarativeCatalogFromConfig` - * (`declarative.go:434-485`). - */ -const legacyProvisionDeclarativeShadow = ( - spawner: Spawner, - fs: FileSystem.FileSystem, - path: Path.Path, - ctx: LegacyPgDeltaContext, - declarativeDirAbs: string, - declarativeDirRel: string, - handle: LegacyShadowAcquiredHandle, - shadowInput: LegacyShadowSetupInput, -) => - Effect.gen(function* () { - const connConfig: LegacyPgConnInput = { - host: shadowInput.hostname, - port: shadowInput.shadowPort, - user: "postgres", - password: shadowInput.password, - database: "postgres", - }; - yield* legacyWaitForShadowReady(spawner, handle.containerId, connConfig, { - timeoutSeconds: shadowInput.healthTimeoutSeconds, - image: shadowInput.image, - }); - yield* legacySetupShadowDatabase( - spawner, - { - fs, - path, - workdir: ctx.cwd, - projectId: shadowInput.projectId, - container: handle.containerId, - networkId: shadowInput.networkId, - connConfig, - setup: shadowInput.setup, - }, - {}, - handle, - ); - const targetUrl = legacyToPostgresURL(connConfig); - yield* legacyApplyDeclarativePgDelta(ctx, { - fs, - declarativeDirAbs, - declarativeDirRel, - target: targetUrl, - }); - return { sourceUrl: targetUrl } satisfies LegacyProvisionedShadow; - }); - -/** - * Resolves (and caches under `supabase/.temp/pgdelta/`) the pg-delta BASELINE catalog — the - * Supabase platform baseline (auth/storage/realtime) with no local migrations and no - * declarative files applied. Backs `LegacyDeclarativeSeam.exportCatalog({ mode: "baseline" })` — - * `db schema declarative generate`'s own diff SOURCE, and (via - * {@link legacyGetMigrationsCatalogRef}'s own zero-migrations special case above) `sync`'s diff - * source when there are no local migrations. Mirrors Go's `getGenerateBaselineCatalogRef` - * (`apps/cli-go/internal/db/declarative/declarative.go:306-361`). - * - * Unlike Go, which can reuse ONE shadow across `Generate`'s own export and its post-write cache - * warm (`generateBaselineCatalogRef.shadow`), this always provisions (and tears down) its own - * shadow per call — see `legacy-pgdelta.seam.service.ts`'s own doc comment for why that - * simplification is deliberate and accepted. - */ -export const legacyExportBaselineCatalogRef = ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - cliProjectId: Option.Option, - params: { readonly noCache: boolean; readonly projectRef?: string }, -) => - Effect.gen(function* () { - const toml = yield* legacyReadDbToml(fs, path, workdir, params.projectRef); - const ctx: LegacyPgDeltaContext = { - projectId: legacyResolvePgDeltaProjectId(cliProjectId, toml, workdir), - cwd: workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), - denoVersion: toml.denoVersion, - projectEnv: toml.projectEnv, - }; - const tempDir = legacyPgDeltaTempPath(path, workdir); - const setupInputs = yield* legacyResolveSetupInputs( - fs, - path, - workdir, - toml.majorVersion, - Option.getOrUndefined(toml.orioledbVersion), - toml.baseline, - ); - // Built BEFORE the cache probe below, not just before the banner: this is the SECOND - // `@supabase/config` load, and the Go `__catalog` child ran its equivalent in the command - // pre-run unconditionally — so an invalid project (e.g. an unreadable `[api.tls]` cert) - // failed consistently whether or not a cached catalog existed. Probing first would make - // that failure appear and disappear with cache state. - const built = yield* legacyBuildShadowCatalogInputs(ctx, toml, params); - const cachePath = path.join( - tempDir, - legacyBaselineCatalogFileName(legacyBaselineCatalogKey(setupInputs)), - ); - if (!params.noCache) { - // Same propagation as the zero-migrations probe in `legacyResolveMigrationsCatalogRef`: - // `fs.exists` maps only not-found to `false`, so a failing probe (permissions, I/O) - // fails the export before any Docker side effect instead of faking a cache miss. - const exists = yield* fs.exists(cachePath); - if (exists) return path.relative(workdir, cachePath); - } - - const output = yield* Output; - yield* output.raw("Creating shadow database...\n", "stderr"); - return yield* exportViaShadowCatalog( - fs, - path, - ctx, - toml, - built, - (spawner, handle, shadowInput) => - legacyProvisionBaselineShadow(spawner, fs, path, ctx, handle, shadowInput), - (snapshot) => - params.noCache - ? legacyWriteCatalogFile( - fs, - tempDir, - path.join(tempDir, LEGACY_NO_CACHE_BASELINE_CATALOG_NAME), - snapshot, - ) - : legacyWriteCatalogFile(fs, tempDir, cachePath, snapshot), - { bypassCache: params.noCache, webhooks: "config" }, - ); - }); - -/** - * Resolves (and caches under `supabase/.temp/pgdelta/`) the pg-delta DECLARATIVE catalog — the - * Supabase platform baseline with the declarative directory applied. Backs - * `LegacyDeclarativeSeam.exportCatalog({ mode: "declarative" })` — `sync`'s diff TARGET, and the - * cache `generate` warms after writing declarative files. Mirrors Go's `getDeclarativeCatalogRef` - * (`apps/cli-go/internal/db/declarative/declarative.go:434-461`). - */ -export const legacyExportDeclarativeCatalogRef = ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - cliProjectId: Option.Option, - params: { readonly noCache: boolean; readonly projectRef?: string }, -) => - Effect.gen(function* () { - const toml = yield* legacyReadDbToml(fs, path, workdir, params.projectRef); - const ctx: LegacyPgDeltaContext = { - projectId: legacyResolvePgDeltaProjectId(cliProjectId, toml, workdir), - cwd: workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), - denoVersion: toml.denoVersion, - projectEnv: toml.projectEnv, - }; - const tempDir = legacyPgDeltaTempPath(path, workdir); - const declarativeDirRel = legacyResolveDeclarativeDir(path, toml.pgDelta); - const declarativeDirAbs = path.resolve(workdir, declarativeDirRel); - - const setupInputs = yield* legacyResolveSetupInputs( - fs, - path, - workdir, - toml.majorVersion, - Option.getOrUndefined(toml.orioledbVersion), - toml.baseline, - ); - const setupToken = legacySetupInputsToken(setupInputs); - const schemaHash = yield* legacyHashDeclarativeSchemas(fs, path, declarativeDirAbs); - const hash = legacyDeclarativeCatalogCacheKey(setupToken, schemaHash); - const prefix = "local"; - - // Built BEFORE the cache probe — see `legacyExportBaselineCatalogRef`'s own comment above: - // config validation must not depend on cache state. - const built = yield* legacyBuildShadowCatalogInputs(ctx, toml, params); - if (!params.noCache) { - const cached = yield* legacyResolveDeclarativeCatalogPath(fs, path, tempDir, hash, prefix); - if (Option.isSome(cached)) return path.relative(workdir, cached.value); - } - - const output = yield* Output; - yield* output.raw("Creating shadow database...\n", "stderr"); - return yield* exportViaShadowCatalog( - fs, - path, - ctx, - toml, - built, - (spawner, handle, shadowInput) => - legacyProvisionDeclarativeShadow( - spawner, - fs, - path, - ctx, - declarativeDirAbs, - declarativeDirRel, - handle, - shadowInput, - ), - (snapshot) => - params.noCache - ? legacyWriteCatalogFile( - fs, - tempDir, - path.join(tempDir, LEGACY_NO_CACHE_DECLARATIVE_CATALOG_NAME), - snapshot, - ) - : Effect.gen(function* () { - const timestamp = yield* Clock.currentTimeMillis; - return yield* legacyWriteDeclarativeCatalogSnapshot( - fs, - path, - tempDir, - prefix, - hash, - snapshot, - timestamp, - ); - }), - { bypassCache: params.noCache, webhooks: "config" }, - ); - }); diff --git a/apps/cli/src/command-internal/legacy-pgdelta.cache.unit.test.ts b/apps/cli/src/command-internal/legacy-pgdelta.cache.unit.test.ts deleted file mode 100644 index 21d068a8ab..0000000000 --- a/apps/cli/src/command-internal/legacy-pgdelta.cache.unit.test.ts +++ /dev/null @@ -1,857 +0,0 @@ -import { createHash } from "node:crypto"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; - -import { Output } from "../shared/output/output.service.ts"; -import { mockOutput } from "../../tests/helpers/mocks.ts"; -import { LegacyEdgeRuntimeScript } from "./legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "./legacy-pgdelta-ssl-probe.service.ts"; -import { type LegacyPgDeltaContext } from "./legacy-pgdelta.ts"; -import { - LEGACY_NO_CACHE_BASELINE_CATALOG_NAME, - LEGACY_NO_CACHE_DECLARATIVE_CATALOG_NAME, - type LegacySetupInputs, - legacyBaselineCatalogFileName, - legacyBaselineCatalogKey, - legacyBaselineVersionToken, - legacyCatalogPrefixFromConfig, - legacyCleanupOldDeclarativeCatalogs, - legacyCleanupOldMigrationCatalogs, - legacyDeclarativeCatalogCacheKey, - legacyDeclarativeCatalogFileName, - legacyHashDeclarativeSchemas, - legacyHashMigrations, - legacyListLocalMigrations, - legacyMigrationCatalogFileName, - legacyMigrationsCatalogCacheKey, - legacyResolveDeclarativeCatalogPath, - legacyResolveMigrationCatalogPath, - legacyResolveSetupInputs, - legacySanitizedCatalogPrefix, - legacySetupInputsToken, - legacyTryCacheMigrationsCatalog, - legacyWriteDeclarativeCatalogSnapshot, - legacyWriteMigrationCatalogSnapshot, -} from "./legacy-pgdelta.cache.ts"; - -const BASE: LegacySetupInputs = { - image: "supabase/postgres:17.6.1.135", - majorVersion: 17, - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - autoExpose: false, - vaultNames: [], - rolesSql: "", -}; - -const sha12 = (payload: string) => - createHash("sha256").update(payload, "utf8").digest("hex").slice(0, 12); - -describe("legacySanitizedCatalogPrefix", () => { - it("defaults blank to 'local' and sanitizes non [a-zA-Z0-9._-]", () => { - expect(legacySanitizedCatalogPrefix(" ")).toBe("local"); - expect(legacySanitizedCatalogPrefix("local")).toBe("local"); - expect(legacySanitizedCatalogPrefix("db prod/2")).toBe("db-prod-2"); - }); -}); - -describe("legacyBaselineVersionToken", () => { - it("uses the image tag", () => { - expect(legacyBaselineVersionToken("supabase/postgres:17.6.1.135", 17)).toBe("17.6.1.135"); - }); - - it("falls back to pg only when the image is empty", () => { - expect(legacyBaselineVersionToken("", 15)).toBe("pg15"); - expect(legacyBaselineVersionToken(" ", 15)).toBe("pg15"); - // Go only slices when idx+1 < len, so a trailing-colon image is sanitized whole. - expect(legacyBaselineVersionToken("supabase/postgres:", 14)).toBe("supabase-postgres-"); - }); -}); - -describe("legacySetupInputsToken", () => { - it("byte-matches the Go hash input sequence", () => { - const expected = sha12( - "17.6.1.135\nauth=true storage=true realtime=true\nauto_expose_new_tables=false\n", - ); - expect(legacySetupInputsToken(BASE)).toBe(expected); - }); - - it("folds in sorted vault names and roles.sql", () => { - const token = legacySetupInputsToken({ - ...BASE, - vaultNames: ["b_secret", "a_secret"], - rolesSql: "create role app;", - }); - const expected = sha12( - "17.6.1.135\nauth=true storage=true realtime=true\nauto_expose_new_tables=false\n" + - "vault=a_secret\nvault=b_secret\ncreate role app;", - ); - expect(token).toBe(expected); - }); - - it("self-invalidates when any baseline input changes", () => { - const baseToken = legacySetupInputsToken(BASE); - expect(legacySetupInputsToken({ ...BASE, authEnabled: false })).not.toBe(baseToken); - expect(legacySetupInputsToken({ ...BASE, autoExpose: true })).not.toBe(baseToken); - expect(legacySetupInputsToken({ ...BASE, vaultNames: ["x"] })).not.toBe(baseToken); - expect(legacySetupInputsToken({ ...BASE, rolesSql: "x" })).not.toBe(baseToken); - expect(legacySetupInputsToken({ ...BASE, image: "supabase/postgres:15.8.1.085" })).not.toBe( - baseToken, - ); - }); -}); - -describe("catalog keys + file names", () => { - it("composes the baseline + declarative cache keys", () => { - expect(legacyBaselineCatalogKey(BASE)).toBe(`17.6.1.135-${legacySetupInputsToken(BASE)}`); - expect(legacyDeclarativeCatalogCacheKey("setup12chars", "schemahash")).toBe( - "setup12chars-schemahash", - ); - }); - - it("composes the migrations cache key used by `db schema declarative sync` (setup-token-folded)", () => { - // Mirrors Go's `migrationsCatalogCacheKey` (`declarative.go:765`) — deliberately - // different from `db diff`'s bare `pgcache.HashMigrations` key (CLI-1959): this - // one folds the setup-inputs token in so a baseline/config change self- - // invalidates the sync migrations catalog too. - expect(legacyMigrationsCatalogCacheKey("setup12chars", "migrationshash")).toBe( - "setup12chars-migrationshash", - ); - }); - - it("formats catalog file names", () => { - expect(legacyBaselineCatalogFileName("17.6.1.135-abc")).toBe( - "catalog-baseline-17.6.1.135-abc.json", - ); - expect(legacyDeclarativeCatalogFileName("local", "h", 1700)).toBe( - "catalog-local-declarative-h-1700.json", - ); - }); -}); - -const withTemp = () => mkdtempSync(join(tmpdir(), "legacy-decl-cache-")); - -const run = (effect: Effect.Effect) => - effect.pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer)), - ) as Effect.Effect; - -const withServices = ( - body: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, -) => - run( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* body(fs, path); - }), - ); - -describe("legacyListLocalMigrations", () => { - it.effect("returns sorted valid migrations, skipping a deprecated _init.sql first file", () => { - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20200101000000_init.sql"), "-- old init"); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - writeFileSync(join(migrationsDir, "notes.txt"), "ignore me"); - return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - it.effect( - "warns (byte-exact, on stderr) when skipping a deprecated init and a misnamed file", - () => { - // Mirrors Go's `ListLocalMigrations` warnings (`pkg/migration/list.go:45-53`): - // a `fmt.Fprintf(os.Stderr, …)` for the deprecated `_init.sql` first file and - // for any name that does not match `_name.sql`. - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20200101000000_init.sql"), "-- old init"); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - writeFileSync(join(migrationsDir, "notes.txt"), "ignore me"); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* legacyListLocalMigrations(fs, path, migrationsDir); - }).pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, out.layer)), - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); - const stderr = out.rawChunks.filter((c) => c.stream === "stderr").map((c) => c.text); - expect(stderr).toContain( - 'Skipping migration 20200101000000_init.sql... (replace "init" with a different file name to apply this migration)\n', - ); - expect(stderr).toContain( - 'Skipping migration notes.txt... (file name must match pattern "_name.sql")\n', - ); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ) as Effect.Effect; - }, - ); - - it.effect( - "includes a validly-named .sql symlink to a directory, matching Go's IsDir() (no follow)", - () => { - // Go's `os.ReadDir`/`DirEntry.IsDir()` (`pkg/migration/list.go:34-43`) classifies a - // directory entry from its own type without following symlinks, so a `.sql` symlink - // whose target is a directory is NOT skipped as a directory — it is only ever dropped - // later, if something actually tries to read it as a file. A naive `fs.stat`-based - // directory check (which follows symlinks) would misclassify it and silently skip it. - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - const targetDir = join(dir, "outside-target"); - mkdirSync(targetDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - symlinkSync(targetDir, join(migrationsDir, "20240102000000_link.sql")); - return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual([ - "20240101120000_create.sql", - "20240102000000_link.sql", - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); - - it.effect( - "sorts by UTF-8 byte order, matching Go's fs.ReadDir, not JS's default UTF-16 code-unit order", - () => { - // Go's `fs.ReadDir` (`pkg/migration/list.go:34`) sorts entries byte-wise over each name's - // UTF-8 encoding. A BMP private-use character (U+E000, single UTF-16 code unit `0xE000`) - // and a supplementary-plane character (U+1F600, a surrogate pair starting `0xD83D`) reverse - // order between the two schemes: JS's default `Array.prototype.sort()` ranks the surrogate - // pair first (`0xD83D < 0xE000`), while Go's byte order — which preserves codepoint order — - // ranks U+1F600 (`> U+FFFF`) after U+E000. A migrations directory with such filenames must - // replay in Go's order, not JS's default, or a dependent migration could apply out of order. - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - const privateUseFile = "20240101120000_z\uE000.sql"; - const supplementaryFile = "20240101120000_z\u{1F600}.sql"; - writeFileSync(join(migrationsDir, privateUseFile), "create table x();"); - writeFileSync(join(migrationsDir, supplementaryFile), "create table y();"); - return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual([ - privateUseFile, - supplementaryFile, - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); - - it.effect("returns [] when the migrations dir is absent", () => { - const dir = withTemp(); - return withServices((fs, path) => legacyListLocalMigrations(fs, path, join(dir, "nope"))).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - it.effect("fails (instead of returning []) when the migrations path is unreadable", () => { - // `supabase/migrations` exists but is a file, not a directory — Go's - // ListLocalMigrations aborts with `failed to read directory` rather than - // treating it as "no migrations". - const dir = withTemp(); - const migrationsPath = join(dir, "supabase", "migrations"); - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(migrationsPath, "not a directory"); - return withServices((fs, path) => - legacyListLocalMigrations(fs, path, migrationsPath).pipe(Effect.exit), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(exit._tag).toBe("Failure"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); -}); - -describe("legacyHashMigrations", () => { - it.effect( - "hashes the workdir-relative path + contents in list order (stable, content-sensitive)", - () => { - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - const file = join(migrationsDir, "20240101120000_create.sql"); - writeFileSync(file, "create table x();"); - const relPath = join("supabase", "migrations", "20240101120000_create.sql"); - const expected = createHash("sha256") - .update(relPath, "utf8") - .update(Buffer.from("create table x();")) - .digest("hex"); - return withServices((fs, path) => legacyHashMigrations(fs, path, dir, migrationsDir)).pipe( - Effect.tap((hash) => - Effect.sync(() => { - expect(hash).toBe(expected); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); - - it.effect( - "is unaffected by the absolute location of workdir (Go-parity, not machine-specific)", - () => { - const dirA = withTemp(); - const dirB = withTemp(); - const migrationsA = join(dirA, "supabase", "migrations"); - const migrationsB = join(dirB, "supabase", "migrations"); - mkdirSync(migrationsA, { recursive: true }); - mkdirSync(migrationsB, { recursive: true }); - writeFileSync(join(migrationsA, "20240101120000_create.sql"), "create table x();"); - writeFileSync(join(migrationsB, "20240101120000_create.sql"), "create table x();"); - return withServices((fs, path) => - Effect.gen(function* () { - const hashA = yield* legacyHashMigrations(fs, path, dirA, migrationsA); - const hashB = yield* legacyHashMigrations(fs, path, dirB, migrationsB); - expect(hashA).toBe(hashB); - }), - ).pipe( - Effect.tap(() => - Effect.sync(() => { - rmSync(dirA, { recursive: true, force: true }); - rmSync(dirB, { recursive: true, force: true }); - }), - ), - ); - }, - ); -}); - -describe("legacyHashDeclarativeSchemas", () => { - it.effect("hashes forward-slash rel path + contents over sorted .sql files", () => { - const dir = withTemp(); - const declDir = join(dir, "supabase", "database"); - mkdirSync(join(declDir, "nested"), { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "A"); - writeFileSync(join(declDir, "nested", "auth.sql"), "B"); - writeFileSync(join(declDir, "skip.txt"), "C"); - const expected = createHash("sha256") - .update("nested/auth.sql", "utf8") - .update(Buffer.from("B")) - .update("public.sql", "utf8") - .update(Buffer.from("A")) - .digest("hex"); - return withServices((fs, path) => legacyHashDeclarativeSchemas(fs, path, declDir)).pipe( - Effect.tap((hash) => - Effect.sync(() => { - expect(hash).toBe(expected); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - // A directory symlink pointing at an ancestor must not loop the walk, and symlinked - // entries are excluded from the hash entirely — matching the walker's no-follow - // semantics (codex review, PR #6162). - it.effect("skips symlinked entries instead of following them", () => { - const dir = withTemp(); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "A"); - symlinkSync(join(dir, "supabase"), join(declDir, "loop")); - const expected = createHash("sha256") - .update("public.sql", "utf8") - .update(Buffer.from("A")) - .digest("hex"); - return withServices((fs, path) => legacyHashDeclarativeSchemas(fs, path, declDir)).pipe( - Effect.tap((hash) => - Effect.sync(() => { - expect(hash).toBe(expected); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - // Retention removal failures must propagate — a silently-failing cleanup would let - // snapshots accumulate forever while every run reports success (codex review, PR #6162). - it.effect("cleanup fails when an old snapshot cannot be removed", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 200, 300]) { - writeFileSync(join(tempDir, `catalog-local-declarative-h-${ts}.json`), "{}"); - } - return withServices((fs, path) => - Effect.gen(function* () { - const err = yield* fs.readDirectory(join(dir, "does-not-exist")).pipe(Effect.flip); - const failing: FileSystem.FileSystem = { ...fs, remove: () => Effect.fail(err) }; - return yield* legacyCleanupOldDeclarativeCatalogs(failing, path, tempDir, "local").pipe( - Effect.exit, - ); - }), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - // A root-level failure that isn't not-found (permissions, I/O) must propagate rather - // than be treated as an empty tree — an empty-tree hash could cache an empty catalog - // and let sync emit destructive drops (codex review, PR #6162). - it.effect("fails when the root existence check itself fails", () => { - const dir = withTemp(); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - return withServices((fs, path) => - Effect.gen(function* () { - const err = yield* fs.readDirectory(join(dir, "does-not-exist")).pipe(Effect.flip); - const failing: FileSystem.FileSystem = { ...fs, exists: () => Effect.fail(err) }; - return yield* legacyHashDeclarativeSchemas(failing, path, declDir).pipe(Effect.exit); - }), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - // A partial hash can collide with an existing cache key and serve a stale catalog, - // so a traversal failure must fail the hash, not shrink it (codex review, PR #6162). - it.effect("fails when part of the tree cannot be read instead of hashing a subset", () => { - const dir = withTemp(); - const declDir = join(dir, "supabase", "database"); - mkdirSync(join(declDir, "nested"), { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "A"); - writeFileSync(join(declDir, "nested", "auth.sql"), "B"); - return withServices((fs, path) => { - const failing: FileSystem.FileSystem = { - ...fs, - readDirectory: (p, opts) => - p.endsWith("nested") - ? fs.readDirectory(join(dir, "does-not-exist")) - : fs.readDirectory(p, opts), - }; - return legacyHashDeclarativeSchemas(failing, path, declDir).pipe(Effect.exit); - }).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); -}); - -describe("legacyResolveDeclarativeCatalogPath + cleanup", () => { - it.effect("resolves the newest snapshot and prunes to the retention count", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 300, 200]) { - writeFileSync(join(tempDir, `catalog-local-declarative-h-${ts}.json`), "{}"); - } - writeFileSync(join(tempDir, "catalog-local-declarative-other-50.json"), "{}"); - return withServices((fs, path) => - Effect.gen(function* () { - const latest = yield* legacyResolveDeclarativeCatalogPath(fs, path, tempDir, "h", "local"); - expect(Option.getOrNull(latest)?.endsWith("catalog-local-declarative-h-300.json")).toBe( - true, - ); - yield* legacyCleanupOldDeclarativeCatalogs(fs, path, tempDir, "local"); - const remaining = (yield* fs.readDirectory(tempDir)).filter((n) => - n.startsWith("catalog-local-declarative-"), - ); - expect(remaining.sort()).toEqual([ - "catalog-local-declarative-h-200.json", - "catalog-local-declarative-h-300.json", - ]); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); -}); - -describe("no-cache catalog file names", () => { - it("matches Go's noCacheBaselineCatalogPath/noCacheDeclarativeCatalogPath literals", () => { - expect(LEGACY_NO_CACHE_BASELINE_CATALOG_NAME).toBe("catalog-nocache-baseline.json"); - expect(LEGACY_NO_CACHE_DECLARATIVE_CATALOG_NAME).toBe("catalog-nocache-declarative.json"); - }); -}); - -describe("legacyWriteDeclarativeCatalogSnapshot + cleanup", () => { - it.effect( - "writes the snapshot and prunes older declarative catalogs past the retention count", - () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 300, 200]) { - writeFileSync(join(tempDir, `catalog-local-declarative-h-${ts}.json`), "{}"); - } - return withServices((fs, path) => - Effect.gen(function* () { - const filePath = yield* legacyWriteDeclarativeCatalogSnapshot( - fs, - path, - tempDir, - "local", - "h", - '{"snapshot":true}', - 400, - ); - expect(filePath.endsWith("catalog-local-declarative-h-400.json")).toBe(true); - expect(yield* fs.readFileString(filePath)).toBe('{"snapshot":true}'); - const remaining = (yield* fs.readDirectory(tempDir)).filter((n) => - n.startsWith("catalog-local-declarative-"), - ); - expect(remaining.sort()).toEqual([ - "catalog-local-declarative-h-300.json", - "catalog-local-declarative-h-400.json", - ]); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }, - ); - - it.effect("creates the temp dir when it doesn't exist yet", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - return withServices((fs, path) => - Effect.gen(function* () { - yield* legacyWriteDeclarativeCatalogSnapshot(fs, path, tempDir, "local", "h", "{}", 100); - expect(yield* fs.exists(join(tempDir, "catalog-local-declarative-h-100.json"))).toBe(true); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); -}); - -describe("legacyCatalogPrefixFromConfig", () => { - const CONN = { host: "127.0.0.1", port: 5432, user: "postgres", database: "postgres" }; - - it("returns 'local' for a local database regardless of host", () => { - expect(legacyCatalogPrefixFromConfig(CONN, true)).toBe("local"); - }); - - it("returns the project ref for a direct db..supabase.{co,red} host", () => { - const ref = "abcdefghijklmnopqrst"; - expect(legacyCatalogPrefixFromConfig({ ...CONN, host: `db.${ref}.supabase.co` }, false)).toBe( - ref, - ); - expect(legacyCatalogPrefixFromConfig({ ...CONN, host: `db.${ref}.supabase.red` }, false)).toBe( - ref, - ); - }); - - it("falls back to a stable url- hash for anything else", () => { - const conn = { - host: "aws-0-us-east-1.pooler.supabase.com", - port: 6543, - user: "postgres.ref", - database: "postgres", - }; - expect(legacyCatalogPrefixFromConfig(conn, false)).toBe( - `url-${sha12(`${conn.user}@${conn.host}:${conn.port}/${conn.database}`)}`, - ); - }); - - it("does not match a host with the wrong ref length or a different TLD", () => { - const conn = { ...CONN, host: "db.tooshort.supabase.co" }; - const digest = createHash("sha256") - .update(`${conn.user}@${conn.host}:${conn.port}/${conn.database}`, "utf8") - .digest("hex"); - expect(legacyCatalogPrefixFromConfig(conn, false)).toBe(`url-${digest.slice(0, 12)}`); - }); -}); - -describe("legacyResolveMigrationCatalogPath", () => { - it.effect("resolves the newest snapshot for the (hash, prefix) family", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 300, 200]) { - writeFileSync(join(tempDir, `catalog-local-migrations-h-${ts}.json`), "{}"); - } - // A different hash in the same prefix family must not be picked up. - writeFileSync(join(tempDir, "catalog-local-migrations-other-500.json"), "{}"); - return withServices((fs, path) => - Effect.gen(function* () { - const latest = yield* legacyResolveMigrationCatalogPath(fs, path, tempDir, "h", "local"); - expect(Option.getOrNull(latest)?.endsWith("catalog-local-migrations-h-300.json")).toBe( - true, - ); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); - - it.effect("returns None on a cache miss (no matching family member)", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - return withServices((fs, path) => - Effect.gen(function* () { - const resolved = yield* legacyResolveMigrationCatalogPath(fs, path, tempDir, "h", "local"); - expect(Option.isNone(resolved)).toBe(true); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); -}); - -describe("legacyResolveSetupInputs", () => { - it.effect("resolves the image and tolerates a missing roles.sql", () => { - const dir = withTemp(); - return withServices((fs, path) => - legacyResolveSetupInputs(fs, path, dir, 17, undefined, { - authEnabled: true, - storageEnabled: false, - realtimeEnabled: true, - apiAutoExposeNewTables: Option.none(), - vaultNames: ["a_secret"], - }), - ).pipe( - Effect.tap((inputs) => - Effect.sync(() => { - expect(inputs).toMatchObject({ - majorVersion: 17, - authEnabled: true, - storageEnabled: false, - realtimeEnabled: true, - autoExpose: true, - vaultNames: ["a_secret"], - rolesSql: "", - }); - expect(inputs.image.length).toBeGreaterThan(0); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - it.effect("reads roles.sql content and resolves the effective auto-expose bool", () => { - const dir = withTemp(); - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(join(dir, "supabase", "roles.sql"), "create role app;"); - return withServices((fs, path) => - legacyResolveSetupInputs(fs, path, dir, 17, undefined, { - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - apiAutoExposeNewTables: Option.some(false), - vaultNames: [], - }), - ).pipe( - Effect.tap((inputs) => - Effect.sync(() => { - expect(inputs.rolesSql).toBe("create role app;"); - expect(inputs.autoExpose).toBe(false); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); -}); - -describe("legacyMigrationCatalogFileName", () => { - it("formats catalog--migrations--.json", () => { - expect(legacyMigrationCatalogFileName("local", "h", 1700)).toBe( - "catalog-local-migrations-h-1700.json", - ); - }); -}); - -describe("legacyWriteMigrationCatalogSnapshot + cleanup", () => { - it.effect( - "writes the snapshot and prunes older migrations catalogs past the retention count", - () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 300, 200]) { - writeFileSync(join(tempDir, `catalog-local-migrations-h-${ts}.json`), "{}"); - } - return withServices((fs, path) => - Effect.gen(function* () { - const filePath = yield* legacyWriteMigrationCatalogSnapshot( - fs, - path, - tempDir, - "local", - "h", - '{"snapshot":true}', - 400, - ); - expect(filePath.endsWith("catalog-local-migrations-h-400.json")).toBe(true); - expect(yield* fs.readFileString(filePath)).toBe('{"snapshot":true}'); - const remaining = (yield* fs.readDirectory(tempDir)).filter((n) => - n.startsWith("catalog-local-migrations-"), - ); - expect(remaining.sort()).toEqual([ - "catalog-local-migrations-h-300.json", - "catalog-local-migrations-h-400.json", - ]); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }, - ); - - it.effect("creates the temp dir when it doesn't exist yet", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - return withServices((fs, path) => - Effect.gen(function* () { - yield* legacyWriteMigrationCatalogSnapshot(fs, path, tempDir, "local", "h", "{}", 100); - expect(yield* fs.exists(join(tempDir, "catalog-local-migrations-h-100.json"))).toBe(true); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); -}); - -describe("legacyTryCacheMigrationsCatalog — timestamp ordering (review CLI-1958)", () => { - // `it.live` (not `it.effect`): the mocked export below uses a real `Effect.sleep` - // to create a measurable time gap, which needs the real wall clock, not - // `it.effect`'s virtual `TestClock` (which never auto-advances and would hang). - it.live( - "reads the clock AFTER the pg-delta export resolves, matching Go's WriteMigrationCatalogSnapshot ordering", - () => { - // Go's `TryCacheMigrationsCatalog` (`pgcache/cache.go:71-91`) resolves `hash` - // and `snapshot` FIRST and only THEN calls `WriteMigrationCatalogSnapshot`, - // which itself reads `time.Now().UTC()` (`pgcache/cache.go:151-163`) — i.e. - // Go's clock read happens LAST, right before the file write. The mocked - // edge-runtime export below sleeps for a real, measurable interval before - // resolving; the written snapshot's embedded timestamp must reflect a moment - // AFTER that sleep, proving the clock was read after the export — not - // captured up front by a caller before this function even started (the - // pre-fix bug). - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - // Mirrors `legacyPgDeltaTempPath` (`/supabase/.temp/pgdelta`). - const tempDir = join(dir, "supabase", ".temp", "pgdelta"); - const beforeCallMillis = Date.now(); - const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: () => - Effect.gen(function* () { - yield* Effect.sleep("30 millis"); - return { stdout: "{}", stderr: "" }; - }), - }); - const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); - const ctx: LegacyPgDeltaContext = { - projectId: "test", - cwd: dir, - npmVersion: undefined, - denoVersion: 1, - projectEnv: {}, - }; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* legacyTryCacheMigrationsCatalog(fs, path, ctx, { - enabled: true, - targetUrl: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", - conn: { host: "127.0.0.1", port: 5432, user: "postgres", database: "postgres" }, - isLocal: true, - migrationsDir, - }); - const names = (yield* fs.readDirectory(tempDir)).filter((n) => - n.startsWith("catalog-local-migrations-"), - ); - expect(names.length).toBe(1); - const match = /-(\d+)\.json$/.exec(names[0]!); - expect(match).not.toBeNull(); - const embeddedMillis = Number(match![1]); - expect(embeddedMillis).toBeGreaterThanOrEqual(beforeCallMillis + 25); - }).pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer, edge, sslProbe)), - Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }, - ); -}); - -describe("legacyCleanupOldMigrationCatalogs", () => { - it.effect("only prunes files matching the given prefix's family", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 200, 300]) { - writeFileSync(join(tempDir, `catalog-local-migrations-h-${ts}.json`), "{}"); - } - writeFileSync(join(tempDir, "catalog-other-migrations-h-50.json"), "{}"); - return withServices((fs, path) => - Effect.gen(function* () { - yield* legacyCleanupOldMigrationCatalogs(fs, path, tempDir, "local"); - const remaining = (yield* fs.readDirectory(tempDir)).sort(); - expect(remaining).toEqual([ - "catalog-local-migrations-h-200.json", - "catalog-local-migrations-h-300.json", - "catalog-other-migrations-h-50.json", - ]); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); - - it.effect( - "propagates a permission-denied directory read instead of treating it as empty (Go ReadDir parity)", - () => { - // Go's CleanupOldMigrationCatalogs only tolerates a genuinely MISSING temp dir - // (ensureTempDir already created it before ReadDir runs) — any other ReadDir - // failure propagates, so a permission-denied listing must fail here too rather - // than silently look like "no cached catalogs" (which would bypass retention - // indefinitely, since the caller's own best-effort warning never fires without - // a propagated failure). - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, "catalog-local-migrations-h-100.json"), "{}"); - chmodSync(tempDir, 0o000); - return withServices((fs, path) => - legacyCleanupOldMigrationCatalogs(fs, path, tempDir, "local").pipe(Effect.exit), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - chmodSync(tempDir, 0o755); - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); -}); diff --git a/apps/cli/src/command-internal/legacy-pgdelta.integration.test.ts b/apps/cli/src/command-internal/legacy-pgdelta.integration.test.ts deleted file mode 100644 index 123246afe9..0000000000 --- a/apps/cli/src/command-internal/legacy-pgdelta.integration.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Cause, Effect, Exit, Layer } from "effect"; - -import { - type LegacyEdgeRuntimeRunOpts, - type LegacyEdgeRuntimeRunResult, - LegacyEdgeRuntimeScript, -} from "./legacy-edge-runtime-script.service.ts"; -import { LegacyEdgeRuntimeScriptError } from "./legacy-edge-runtime-script.errors.ts"; -import { LegacyPgDeltaSslProbe } from "./legacy-pgdelta-ssl-probe.service.ts"; -import { - LEGACY_DEFAULT_PG_DELTA_NPM_VERSION, - LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER, -} from "../commands/db/shared/legacy-pgdelta.deno-templates.ts"; -import { - legacyDeclarativeExportPgDelta, - legacyDiffPgDelta, - legacyExportCatalogPgDelta, - type LegacyPgDeltaContext, -} from "./legacy-pgdelta.ts"; - -const CTX: LegacyPgDeltaContext = { - projectId: "ref", - cwd: "/proj", - npmVersion: undefined, - denoVersion: 2, - projectEnv: {}, -}; - -function fakeEdgeRuntime( - outcome: { - stdout?: string; - stderr?: string; - fail?: string; - docker?: "daemon" | "inspect" | "pull"; - } = {}, -) { - const calls: LegacyEdgeRuntimeRunOpts[] = []; - const layer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (opts: LegacyEdgeRuntimeRunOpts) => { - calls.push(opts); - if (outcome.fail !== undefined) { - return Effect.fail( - new LegacyEdgeRuntimeScriptError({ - message: outcome.fail, - ...(outcome.docker !== undefined ? { docker: outcome.docker } : {}), - }), - ); - } - return Effect.succeed({ - stdout: outcome.stdout ?? "", - stderr: outcome.stderr ?? "", - } satisfies LegacyEdgeRuntimeRunResult); - }, - }); - return { layer, calls }; -} - -// These refs are local (127.0.0.1) endpoints that refuse TLS, so the probe reports -// "not required" — matching the no-SSL-env passthrough these tests assert. -const probe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), -}); - -const failError = (exit: Exit.Exit) => - Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; - -describe("legacyDiffPgDelta", () => { - it.effect( - "returns the SQL + stderr and passes the interpolated diff script + env + binds", - () => { - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ - version: 1, - files: [ - { - order: 1, - name: "schema_changes", - transactionMode: "transactional", - sql: "-- unit 1\n\nALTER TABLE x;", - }, - ], - }), - stderr: "warn", - }); - return legacyDiffPgDelta(CTX, { - targetRef: "postgresql://u:p@127.0.0.1:54320/postgres?connect_timeout=10", - sourceRef: "supabase/.temp/catalog.json", - schema: ["public", "auth"], - formatOptions: '{"indent":2}', - }).pipe( - Effect.tap((result) => - Effect.sync(() => { - // The envelope is parsed into per-unit files and a flattened SQL join. - expect(result.sql).toBe("-- unit 1\n\nALTER TABLE x;"); - expect(result.files).toHaveLength(1); - expect(result.files[0]?.name).toBe("schema_changes"); - expect(result.stderr).toBe("warn"); - const opts = edge.calls[0]!; - expect(opts.errPrefix).toBe("error diffing schema"); - // The (remote-merged) deno_version is forwarded so the edge-runtime - // layer picks the configured Deno image, matching Go. - expect(opts.denoVersion).toBe(2); - // Default npm version interpolated into the template. - expect(opts.script).toContain( - `npm:@supabase/pg-delta@${LEGACY_DEFAULT_PG_DELTA_NPM_VERSION}`, - ); - expect(opts.script).not.toContain( - `npm:@supabase/pg-delta@${LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER}`, - ); - // TARGET is a URL (passthrough); SOURCE catalog file mapped to /workspace. - expect(opts.env["TARGET"]).toBe( - "postgresql://u:p@127.0.0.1:54320/postgres?connect_timeout=10", - ); - expect(opts.env["SOURCE"]).toBe("/workspace/supabase/.temp/catalog.json"); - expect(opts.env["INCLUDED_SCHEMAS"]).toBe("public,auth"); - expect(opts.env["FORMAT_OPTIONS"]).toBe('{"indent":2}'); - expect(opts.binds).toEqual([ - "supabase_edge_runtime_ref:/root/.cache/deno:rw", - "/proj:/workspace", - ]); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }, - ); - - it.effect("omits SOURCE / schema / format when not provided", () => { - const edge = fakeEdgeRuntime({ stdout: "" }); - return legacyDiffPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: " ", - }).pipe( - Effect.tap(() => - Effect.sync(() => { - const env = edge.calls[0]!.env; - expect(env["SOURCE"]).toBeUndefined(); - expect(env["INCLUDED_SCHEMAS"]).toBeUndefined(); - expect(env["FORMAT_OPTIONS"]).toBeUndefined(); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("maps an edge-runtime failure to LegacyDeclarativeEdgeRuntimeError", () => { - const edge = fakeEdgeRuntime({ fail: "error diffing schema: boom" }); - return legacyDiffPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeEdgeRuntimeError"); - expect((failError(exit) as { message: string }).message).toBe( - "error diffing schema: boom", - ); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("preserves docker failure classification through the pg-delta wrapper", () => { - const edge = fakeEdgeRuntime({ - fail: "error diffing schema: docker unavailable", - docker: "daemon", - }); - return legacyDiffPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)).toMatchObject({ - _tag: "LegacyDeclarativeEdgeRuntimeError", - docker: "daemon", - }); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("fails with LegacyPgDeltaDiffParseError on a malformed envelope", () => { - const edge = fakeEdgeRuntime({ stdout: "not json{", stderr: "boom" }); - return legacyDiffPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDiffParseError"); - const message = (failError(exit) as { message: string }).message; - expect(message).toContain("failed to parse pg-delta diff output"); - expect(message).toContain("boom"); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("rejects an unknown transaction mode", () => { - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ - version: 1, - files: [ - { - order: 1, - name: "schema_changes", - transactionMode: "non-transactional", - sql: "SELECT 1;", - }, - ], - }), - }); - return legacyDiffPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDiffParseError"); - expect((failError(exit) as { message: string }).message).toContain( - 'unknown pg-delta transaction mode "non-transactional"', - ); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); -}); - -describe("legacyDeclarativeExportPgDelta", () => { - it.effect("parses the declarative output envelope", () => { - const payload = { - version: 1, - mode: "declarative", - files: [{ path: "public.sql", order: 0, statements: 2, sql: "..." }], - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - return legacyDeclarativeExportPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.tap((out) => - Effect.sync(() => { - expect(out.version).toBe(1); - expect(out.files[0]?.path).toBe("public.sql"); - expect(edge.calls[0]!.errPrefix).toBe("error exporting declarative schema"); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("fails with empty-output error when the script prints nothing", () => { - const edge = fakeEdgeRuntime({ stdout: "", stderr: "stack" }); - return legacyDeclarativeExportPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeEmptyOutputError"); - expect((failError(exit) as { message: string }).message).toBe( - "error exporting declarative schema: edge-runtime script produced no output:\nstack", - ); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("fails with parse error on invalid JSON", () => { - const edge = fakeEdgeRuntime({ stdout: "not json" }); - return legacyDeclarativeExportPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeParseOutputError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse declarative export output:", - ); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); -}); - -describe("legacyExportCatalogPgDelta", () => { - it.effect("returns the trimmed snapshot and sets ROLE / TARGET", () => { - const edge = fakeEdgeRuntime({ stdout: ' {"catalog":true}\n ' }); - return legacyExportCatalogPgDelta(CTX, { - targetRef: "postgresql://t", - role: "postgres", - }).pipe( - Effect.tap((snapshot) => - Effect.sync(() => { - expect(snapshot).toBe('{"catalog":true}'); - const opts = edge.calls[0]!; - expect(opts.errPrefix).toBe("error exporting pg-delta catalog"); - expect(opts.env["TARGET"]).toBe("postgresql://t"); - expect(opts.env["ROLE"]).toBe("postgres"); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("omits ROLE when empty and errors on empty output", () => { - const edge = fakeEdgeRuntime({ stdout: " ", stderr: "oops" }); - return legacyExportCatalogPgDelta(CTX, { targetRef: "postgresql://t", role: "" }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeEmptyOutputError"); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); -}); diff --git a/apps/cli/src/command-internal/legacy-pgdelta.paths.ts b/apps/cli/src/command-internal/legacy-pgdelta.paths.ts index 00a74801d8..0e03b39269 100644 --- a/apps/cli/src/command-internal/legacy-pgdelta.paths.ts +++ b/apps/cli/src/command-internal/legacy-pgdelta.paths.ts @@ -1,11 +1,6 @@ /** * On-disk locations for pg-delta-adjacent cache/snapshot artefacts. * - * Split out of `legacy-pgdelta.cache.ts` (which owns the catalog cache's keys AND its - * shadow-provisioning resolution path) so `db-bootstrap/shadow-cache.ts` — the warm - * shadow-container cache, which `legacy-pgdelta.cache.ts` itself consumes for its own - * shadow provisioning — can reach path helpers without an import cycle between the two. - * * Two roots: * - {@link legacyPgDeltaTempPath}: project-local (`supabase/.temp/pgdelta`) — catalog * snapshots and debug bundles (Go-shared, workspace-mounted). diff --git a/apps/cli/src/command-internal/legacy-pgdelta.ts b/apps/cli/src/command-internal/legacy-pgdelta.ts index 59d8f32745..f4af7a26f6 100644 --- a/apps/cli/src/command-internal/legacy-pgdelta.ts +++ b/apps/cli/src/command-internal/legacy-pgdelta.ts @@ -1,102 +1,24 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Option } from "effect"; -import { legacyViperEnvStringWithProjectFallback } from "../shared/legacy/legacy-viper-env.ts"; -import { - type LegacyEdgeRuntimeFile, - LegacyEdgeRuntimeScript, -} from "./legacy-edge-runtime-script.service.ts"; import { legacyResolveLocalProjectId, legacySanitizeProjectId } from "./legacy-docker-ids.ts"; -import { - LEGACY_PG_DELTA_SOURCE_SSL_ENV, - LEGACY_PG_DELTA_TARGET_SSL_ENV, - legacyPreparePgDeltaRef, -} from "./legacy-pgdelta-ssl.ts"; -import { - legacyInterpolatePgDeltaScript, - legacyPgDeltaCatalogExportScript, - legacyPgDeltaDeclarativeExportScript, - legacyPgDeltaDiffScript, -} from "../commands/db/shared/legacy-pgdelta.deno-templates.ts"; -import { - LegacyDeclarativeEdgeRuntimeError, - LegacyDeclarativeEmptyOutputError, - LegacyDeclarativeParseOutputError, - LegacyPgDeltaDiffParseError, -} from "../commands/db/shared/legacy-pgdelta.errors.ts"; -import type { LegacyMigrationTransactionMode } from "./legacy-migration-file.ts"; - -const PG_DELTA_NPM_REGISTRY_ENV = "PGDELTA_NPM_REGISTRY"; - -/** A per-file payload from pg-delta declarative export. Mirrors Go's `DeclarativeFile`. */ -interface LegacyDeclarativeFile { - readonly path: string; - readonly order: number; - readonly statements: number; - readonly sql: string; -} - -/** The declarative export envelope. Mirrors Go's `DeclarativeOutput`. */ -export interface LegacyDeclarativeOutput { - readonly version: number; - readonly mode: string; - readonly files: ReadonlyArray; -} - -/** - * One execution-aware migration unit from a pg-delta diff plan. Mirrors Go's - * `PgDeltaPlanFile` (`internal/db/diff/pgdelta.go`): a numbered SQL file whose - * header comments record the unit number, transaction mode and boundary reason. - */ -interface LegacyPgDeltaPlanFile { - readonly order: number; - readonly name: string; - readonly transactionMode: LegacyMigrationTransactionMode; - readonly sql: string; -} - -/** The pg-delta diff envelope. Mirrors Go's `PgDeltaDiffOutput`. */ -interface LegacyPgDeltaDiffOutput { - readonly version: number; - readonly files: ReadonlyArray< - Omit & { - readonly transactionMode: string; - } - >; -} /** - * Result of a pg-delta diff: the per-unit plan `files`, a `sql` flattening of - * them (kept for `db diff` / declarative callers that consume one blob), and the - * edge-runtime `stderr`. - */ -interface LegacyPgDeltaDiffResult { - readonly sql: string; - readonly files: ReadonlyArray; - readonly stderr: string; -} - -/** - * Ambient inputs retained for the legacy pg-delta adapter: the project id (for the - * `supabase_edge_runtime_` Deno-cache volume), the working directory (mounted - * at `/workspace`), and the resolved pg-delta npm version (template interpolation). + * Ambient inputs shared by the pg-delta and migra diff workflows: the project id + * (for the `supabase_edge_runtime_` Deno-cache volume migra's edge-runtime + * run binds), the working directory, the effective `edge_runtime.deno_version`, + * and the project's parsed `supabase/.env`. */ export interface LegacyPgDeltaContext { readonly projectId: string; readonly cwd: string; - readonly npmVersion: string | undefined; /** * Effective `edge_runtime.deno_version` from the (remote-merged on `--linked`) - * config, forwarded to the edge-runtime container so pg-delta runs under the + * config, forwarded to the edge-runtime container so migra runs under the * configured Deno image. Mirrors Go, which resolves the image from the loaded * config the command operates on rather than the base `config.toml`. */ readonly denoVersion: number; - /** - * The project's parsed `supabase/.env` (`legacyReadDbToml`'s `projectEnv`), so - * {@link legacyPgDeltaNpmRegistryOption}'s `PGDELTA_NPM_REGISTRY` read matches Go's - * `os.Getenv`, which already observes `.env`-loaded values by this point (see that - * function's doc comment). - */ + /** The project's parsed `supabase/.env` (`legacyReadDbToml`'s `projectEnv`). */ readonly projectEnv: Readonly>; } @@ -118,7 +40,7 @@ export interface LegacyPgDeltaContext { * gate, review: PRRT_kwDOErm0O86XHGDL) — but `legacyResolveLocalProjectId` tries its FIRST * argument before its second, so passing the raw, ungated `cliProjectId` through would let * an unrelated ambient `SUPABASE_PROJECT_ID` win back over the matched remote's own id, - * mounting the wrong Deno-cache volume for a linked pg-delta run. Mirrors the same + * mounting the wrong Deno-cache volume for a linked run. Mirrors the same * suppression `legacy-local-project-context.ts`'s own `legacyLoadLocalProjectContext` * already applies (review: PRRT_kwDOErm0O86XI1w8). */ @@ -141,271 +63,13 @@ export function legacyIsPostgresURL(ref: string): boolean { return ref.startsWith("postgres://") || ref.startsWith("postgresql://"); } -/** - * Maps a host-relative catalog-file path to its in-container path (`cwd` mounted - * at `/workspace`); Postgres URLs and empty strings pass through. Separators are - * normalised to `/` so Windows paths resolve inside the Linux container. Mirrors - * Go's `containerRef` (`internal/db/diff/pgdelta.go:55-60`). - */ -export function legacyPgDeltaContainerRef(ref: string): string { - if (ref === "" || legacyIsPostgresURL(ref)) return ref; - return `/workspace/${ref.split("\\").join("/")}`; -} - /** Mirrors Go's `utils.EdgeRuntimeId` = `GetId("edge_runtime")` = `supabase_edge_runtime_`. */ export function legacyEdgeRuntimeId(projectId: string): string { return `supabase_edge_runtime_${projectId}`; } -/** - * The volume binds for a pg-delta run: the named Deno-cache volume (so npm - * downloads persist across runs) and the project root mounted at `/workspace` - * (so catalog files / `.npmrc` resolve). Mirrors the `binds` in - * `internal/db/diff/pgdelta.go`. - */ -export function legacyPgDeltaBinds(projectId: string, cwd: string): ReadonlyArray { - return [`${legacyEdgeRuntimeId(projectId)}:/root/.cache/deno:rw`, `${cwd}:/workspace`]; -} - /** Mirrors Go's `IsPgDeltaDebugEnabled` (`internal/db/diff/pgdelta_debug.go:11`). */ export function legacyIsPgDeltaDebugEnabled(): boolean { const value = (process.env["PGDELTA_DEBUG"] ?? "").trim().toLowerCase(); return value === "1" || value === "true" || value === "yes"; } - -/** - * Mirrors Go's `PgDeltaNpmRegistryOption` (`internal/utils/pgdelta_local.go:30`): - * when `PGDELTA_NPM_REGISTRY` is set, drop a project-local `.npmrc` scoping the - * `@supabase` registry and forward both `PGDELTA_NPM_REGISTRY` and the universal - * `NPM_CONFIG_REGISTRY` into the container. Exported so `legacy-pgdelta.apply.ts`'s - * declarative-apply runner (CLI-1956) can reuse the same option, matching every other - * pg-delta edge-runtime invocation in this file. - * - * `PGDELTA_NPM_REGISTRY` is a bare `os.Getenv` read in Go (`pgdelta_local.go:30`), not a - * viper-bound flag — but by the time Go reaches it, `config.Load`'s `loadNestedEnv` has - * already run `godotenv.Load` on the project's `supabase/.env`, which calls `os.Setenv` for - * every key not already present in the real process env (`godotenv@v1.5.1/godotenv.go:184- - * 200`). So a project `.env`-only `PGDELTA_NPM_REGISTRY` is visible to this exact `os.Getenv` - * call in Go. `projectEnv` reproduces that merge with the same shell-presence-wins semantics - * (review: PRRT_kwDOErm0O86XFmjf). - */ -export function legacyPgDeltaNpmRegistryOption(projectEnv: Readonly>): { - readonly extraFiles?: ReadonlyArray; - readonly extraEnv?: Readonly>; -} { - const registry = legacyViperEnvStringWithProjectFallback( - PG_DELTA_NPM_REGISTRY_ENV, - projectEnv, - ).trim(); - if (registry.length === 0) return {}; - return { - extraFiles: [{ name: ".npmrc", content: `@supabase:registry=${registry}\n` }], - extraEnv: { [PG_DELTA_NPM_REGISTRY_ENV]: registry, NPM_CONFIG_REGISTRY: registry }, - }; -} - -/** Adds the container ref + any SSL env for a SOURCE/TARGET endpoint (writes a CA bundle for Supabase-hosted remotes). */ -const appendRefEnv = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - cwd: string, - env: Record, - name: "SOURCE" | "TARGET", - ref: string, -) { - const sslRootCertEnv = - name === "SOURCE" ? LEGACY_PG_DELTA_SOURCE_SSL_ENV : LEGACY_PG_DELTA_TARGET_SSL_ENV; - const prepared = yield* legacyPreparePgDeltaRef(fs, path, cwd, ref, sslRootCertEnv); - env[name] = legacyPgDeltaContainerRef(prepared.ref); - Object.assign(env, prepared.sslEnv); -}); - -/** Builds the env shared by diff + declarative export (TARGET, optional SOURCE, schema, format). */ -const buildDiffEnv = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - cwd: string, - params: { - readonly targetRef: string; - readonly sourceRef: string; - readonly schema: ReadonlyArray; - readonly formatOptions: string; - }, -) { - const env: Record = {}; - yield* appendRefEnv(fs, path, cwd, env, "TARGET", params.targetRef); - if (params.sourceRef.length > 0) - yield* appendRefEnv(fs, path, cwd, env, "SOURCE", params.sourceRef); - if (params.schema.length > 0) env["INCLUDED_SCHEMAS"] = params.schema.join(","); - if (params.formatOptions.trim().length > 0) env["FORMAT_OPTIONS"] = params.formatOptions; - if (legacyIsPgDeltaDebugEnabled()) env["PGDELTA_DEBUG"] = "1"; - return env; -}); - -const toDeclarativeEdgeRuntimeError = (error: { - readonly message: string; - readonly docker?: "daemon" | "inspect" | "pull"; -}) => - new LegacyDeclarativeEdgeRuntimeError({ - message: error.message, - ...(error.docker !== undefined ? { docker: error.docker } : {}), - }); - -/** - * Diffs SOURCE → TARGET via the pg-delta diff script. Mirrors Go's - * `DiffPgDeltaRefDetailed` (`internal/db/diff/pgdelta.go:108`). `sourceRef` may - * be empty (diff against an empty source). Refs are either Postgres URLs - * (`legacyToPostgresURL`) or host-relative catalog-file paths. - */ -export const legacyDiffPgDelta = Effect.fnUntraced(function* ( - ctx: LegacyPgDeltaContext, - params: { - readonly targetRef: string; - readonly sourceRef: string; - readonly schema: ReadonlyArray; - readonly formatOptions: string; - }, -) { - const edgeRuntime = yield* LegacyEdgeRuntimeScript; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const env = yield* buildDiffEnv(fs, path, ctx.cwd, params); - const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); - const result = yield* edgeRuntime - .run({ - script: legacyInterpolatePgDeltaScript(legacyPgDeltaDiffScript, ctx.npmVersion), - env, - binds: legacyPgDeltaBinds(ctx.projectId, ctx.cwd), - errPrefix: "error diffing schema", - extraFiles: npm.extraFiles, - extraEnv: npm.extraEnv, - denoVersion: ctx.denoVersion, - workdir: ctx.cwd, - }) - .pipe(Effect.mapError(toDeclarativeEdgeRuntimeError)); - // The template always prints the diff envelope on the success path, even for an - // empty plan (`{"version":1,"files":[]}`); a truly empty stdout means no envelope - // was produced, which we surface as "no changes" rather than a parse error. - // Mirrors Go's `parsePgDeltaDiffOutput` (`internal/db/diff/pgdelta.go`). - if (result.stdout.trim().length === 0) { - return { sql: "", files: [], stderr: result.stderr } satisfies LegacyPgDeltaDiffResult; - } - const envelope = yield* Effect.try({ - try: () => JSON.parse(result.stdout) as LegacyPgDeltaDiffOutput, - catch: (cause) => - new LegacyPgDeltaDiffParseError({ - message: `failed to parse pg-delta diff output: ${ - cause instanceof Error ? cause.message : String(cause) - }:\n${result.stderr}`, - }), - }); - const rawFiles = envelope.files ?? []; - const files: Array = []; - for (const file of rawFiles) { - const transactionMode = file.transactionMode; - if (transactionMode !== "transactional" && transactionMode !== "none") { - return yield* Effect.fail( - new LegacyPgDeltaDiffParseError({ - message: `unknown pg-delta transaction mode ${JSON.stringify(transactionMode)}`, - }), - ); - } - files.push({ ...file, transactionMode }); - } - // Flatten to one blob for callers that need it; unit header comments keep the - // transaction boundaries visible (mirrors Go's `joinPgDeltaFiles`). - const sql = files.map((file) => file.sql).join("\n\n"); - return { sql, files, stderr: result.stderr } satisfies LegacyPgDeltaDiffResult; -}); - -/** - * Exports TARGET as declarative file payloads. Mirrors Go's - * `DeclarativeExportPgDeltaRef` (`internal/db/diff/pgdelta.go:156`): empty output - * is an error, and the JSON envelope is parsed into `LegacyDeclarativeOutput`. - */ -export const legacyDeclarativeExportPgDelta = Effect.fnUntraced(function* ( - ctx: LegacyPgDeltaContext, - params: { - readonly targetRef: string; - readonly sourceRef: string; - readonly schema: ReadonlyArray; - readonly formatOptions: string; - }, -) { - const edgeRuntime = yield* LegacyEdgeRuntimeScript; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const env = yield* buildDiffEnv(fs, path, ctx.cwd, params); - const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); - const result = yield* edgeRuntime - .run({ - script: legacyInterpolatePgDeltaScript(legacyPgDeltaDeclarativeExportScript, ctx.npmVersion), - env, - binds: legacyPgDeltaBinds(ctx.projectId, ctx.cwd), - errPrefix: "error exporting declarative schema", - extraFiles: npm.extraFiles, - extraEnv: npm.extraEnv, - denoVersion: ctx.denoVersion, - workdir: ctx.cwd, - }) - .pipe(Effect.mapError(toDeclarativeEdgeRuntimeError)); - - if (result.stdout.length === 0) { - return yield* Effect.fail( - new LegacyDeclarativeEmptyOutputError({ - message: `error exporting declarative schema: edge-runtime script produced no output:\n${result.stderr}`, - }), - ); - } - - return yield* Effect.try({ - try: () => JSON.parse(result.stdout) as LegacyDeclarativeOutput, - catch: (cause) => - new LegacyDeclarativeParseOutputError({ - message: `failed to parse declarative export output: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - }), - }); -}); - -/** - * Serializes TARGET into a pg-delta catalog snapshot (JSON) for caching. Mirrors - * Go's `ExportCatalogPgDelta` (`internal/db/diff/pgdelta.go:199`): `role` - * optionally steps down the connection; empty output is an error; the snapshot is - * trimmed. - */ -export const legacyExportCatalogPgDelta = Effect.fnUntraced(function* ( - ctx: LegacyPgDeltaContext, - params: { readonly targetRef: string; readonly role: string }, -) { - const edgeRuntime = yield* LegacyEdgeRuntimeScript; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const env: Record = {}; - yield* appendRefEnv(fs, path, ctx.cwd, env, "TARGET", params.targetRef); - if (params.role.length > 0) env["ROLE"] = params.role; - const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); - const result = yield* edgeRuntime - .run({ - script: legacyInterpolatePgDeltaScript(legacyPgDeltaCatalogExportScript, ctx.npmVersion), - env, - binds: legacyPgDeltaBinds(ctx.projectId, ctx.cwd), - errPrefix: "error exporting pg-delta catalog", - extraFiles: npm.extraFiles, - extraEnv: npm.extraEnv, - denoVersion: ctx.denoVersion, - workdir: ctx.cwd, - }) - .pipe(Effect.mapError(toDeclarativeEdgeRuntimeError)); - - const snapshot = result.stdout.trim(); - if (snapshot.length === 0) { - return yield* Effect.fail( - new LegacyDeclarativeEmptyOutputError({ - message: `error exporting pg-delta catalog: edge-runtime script produced no output:\n${result.stderr}`, - }), - ); - } - return snapshot; -}); diff --git a/apps/cli/src/command-internal/legacy-pgdelta.unit.test.ts b/apps/cli/src/command-internal/legacy-pgdelta.unit.test.ts index f012934ce2..3e25b1621f 100644 --- a/apps/cli/src/command-internal/legacy-pgdelta.unit.test.ts +++ b/apps/cli/src/command-internal/legacy-pgdelta.unit.test.ts @@ -4,9 +4,6 @@ import { legacyEdgeRuntimeId, legacyIsPgDeltaDebugEnabled, legacyIsPostgresURL, - legacyPgDeltaBinds, - legacyPgDeltaContainerRef, - legacyPgDeltaNpmRegistryOption, } from "./legacy-pgdelta.ts"; describe("legacyIsPostgresURL", () => { @@ -18,42 +15,12 @@ describe("legacyIsPostgresURL", () => { }); }); -describe("legacyPgDeltaContainerRef", () => { - it("passes through empty strings and Postgres URLs unchanged", () => { - expect(legacyPgDeltaContainerRef("")).toBe(""); - expect(legacyPgDeltaContainerRef("postgresql://u:p@h:5432/db")).toBe( - "postgresql://u:p@h:5432/db", - ); - }); - - it("maps a relative catalog path under /workspace", () => { - expect(legacyPgDeltaContainerRef("supabase/.temp/catalog.json")).toBe( - "/workspace/supabase/.temp/catalog.json", - ); - }); - - it("normalizes Windows separators to forward slashes", () => { - expect(legacyPgDeltaContainerRef("supabase\\.temp\\catalog.json")).toBe( - "/workspace/supabase/.temp/catalog.json", - ); - }); -}); - describe("legacyEdgeRuntimeId", () => { it("names the deno-cache volume per project", () => { expect(legacyEdgeRuntimeId("my-ref")).toBe("supabase_edge_runtime_my-ref"); }); }); -describe("legacyPgDeltaBinds", () => { - it("binds the deno cache volume and the cwd workspace", () => { - expect(legacyPgDeltaBinds("ref", "/proj")).toEqual([ - "supabase_edge_runtime_ref:/root/.cache/deno:rw", - "/proj:/workspace", - ]); - }); -}); - describe("legacyIsPgDeltaDebugEnabled", () => { const prev = process.env["PGDELTA_DEBUG"]; afterEach(() => { @@ -75,43 +42,3 @@ describe("legacyIsPgDeltaDebugEnabled", () => { expect(legacyIsPgDeltaDebugEnabled()).toBe(false); }); }); - -describe("legacyPgDeltaNpmRegistryOption", () => { - const prev = process.env["PGDELTA_NPM_REGISTRY"]; - afterEach(() => { - if (prev === undefined) delete process.env["PGDELTA_NPM_REGISTRY"]; - else process.env["PGDELTA_NPM_REGISTRY"] = prev; - }); - - it("returns no option when unset in both the shell and the project .env", () => { - delete process.env["PGDELTA_NPM_REGISTRY"]; - expect(legacyPgDeltaNpmRegistryOption({})).toEqual({}); - }); - - it("falls back to the project .env when the shell env is unset (Go's godotenv.Load parity)", () => { - delete process.env["PGDELTA_NPM_REGISTRY"]; - const npm = legacyPgDeltaNpmRegistryOption({ - PGDELTA_NPM_REGISTRY: "https://registry.example.com", - }); - expect(npm.extraFiles).toEqual([ - { name: ".npmrc", content: "@supabase:registry=https://registry.example.com\n" }, - ]); - expect(npm.extraEnv).toEqual({ - PGDELTA_NPM_REGISTRY: "https://registry.example.com", - NPM_CONFIG_REGISTRY: "https://registry.example.com", - }); - }); - - it("prefers the shell env over the project .env (shell presence wins)", () => { - process.env["PGDELTA_NPM_REGISTRY"] = "https://shell.example.com"; - const npm = legacyPgDeltaNpmRegistryOption({ - PGDELTA_NPM_REGISTRY: "https://dotenv.example.com", - }); - expect(npm.extraEnv?.["PGDELTA_NPM_REGISTRY"]).toBe("https://shell.example.com"); - }); - - it("treats a whitespace-only value as unset", () => { - delete process.env["PGDELTA_NPM_REGISTRY"]; - expect(legacyPgDeltaNpmRegistryOption({ PGDELTA_NPM_REGISTRY: " " })).toEqual({}); - }); -}); diff --git a/apps/cli/src/command-internal/legacy-postgres-url.ts b/apps/cli/src/command-internal/legacy-postgres-url.ts index 684f5ddcac..317400023c 100644 --- a/apps/cli/src/command-internal/legacy-postgres-url.ts +++ b/apps/cli/src/command-internal/legacy-postgres-url.ts @@ -1,11 +1,7 @@ /** * Build a `postgresql://` URL from a resolved connection, mirroring Go's - * `utils.ToPostgresURL`. Used to - * feed live database endpoints to the pg-delta edge-runtime scripts (SOURCE / - * TARGET). TLS (`sslmode`) is intentionally omitted — `ToPostgresURL` - * serializes only `RuntimeParams` (sslmode lives in `pgconn.Config.TLSConfig`, - * not `RuntimeParams`); pg-delta's SSL is layered on separately by - * `PreparePgDeltaPostgresRef` for remote endpoints. + * `utils.ToPostgresURL`. TLS (`sslmode`) is omitted: `ToPostgresURL` + * serializes only `RuntimeParams` (`sslmode` lives on `pgconn.Config.TLSConfig`). */ /** Mirrors Go's IPv6 check (`net.ParseIP(host) != nil && ip.To4() == nil`). */ diff --git a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md index 75abf7c050..ac9c7d21a9 100644 --- a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md @@ -6,26 +6,20 @@ health poll → write `.env` → `db push` → start suggestion. Every step is n including the migration push (`legacyDbPushCore`, shared with the standalone `supabase db push` command — see Notes). -The push step uses the bundled in-process pg-delta engine by default. Set -`SUPABASE_USE_PG_DELTA_NEXT=false` to retain legacy catalog warming; only that -path uses the runtime pg-delta package/edge-runtime settings and catalog cache. - ## Files Read -| Path | Format | When | -| ------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `~/.supabase/access-token` | plain text | ensure-login token miss (env unset and keyring unavailable) | -| `/.env.example` | dotenv | optional; merged into the generated `.env` | -| `/supabase/{.env..local,.env.local,.env.,.env}` | dotenv | step I (`legacyLoadProjectEnv`), before config.toml validation and again inside `legacyCheckDbToml`; `` is `SUPABASE_ENV` (default `development`), `.env.local` is skipped when `SUPABASE_ENV=test`; first of the 4 files (in this order) to set a key wins, and this `supabase/` directory tier beats the workdir-root tier below — feeds config.toml `env(VAR)` expansion, the push step's `SUPABASE_YES` auto-confirm default, `[experimental.pgdelta]`'s env gate, `SUPABASE_INTERNAL_IMAGE_REGISTRY`, and `PGDELTA_NPM_REGISTRY` | -| `/{.env..local,.env.local,.env.,.env}` | dotenv | same read as above; lower-precedence fallback tier, only consulted for a key none of the `supabase/` directory's 4 files above already set | -| `/supabase/config.toml` | TOML | native push step (embedded defaults used when absent) | -| `/supabase/.temp/pooler-url` | plain text | native push step's connection resolution, only when the direct `db..:5432` host is unreachable (IPv4-only network) — `legacyResolveLinkedConn` falls back through the saved pooler URL `link.LinkServices` wrote in the earlier link-services step | -| `/supabase/migrations/` | directory | native push step, when `[db.migrations].enabled` (default true) | -| `/supabase/migrations/*.sql` | SQL | native push step, for each pending migration applied | -| seed files from `[db.seed].sql_paths` | SQL | native push step (`--include-seed` is always set; gated on `[db.seed].enabled`) | -| `/supabase/roles.sql` | SQL | native push step (`--include-roles` is always set; existence check + apply) | -| `/supabase/.temp/pgdelta-version` | plain text | loaded for compatibility; used only by the legacy pg-delta opt-out | -| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out's catalog warmup image tag, resolved against the bootstrap workdir | +| Path | Format | When | +| ------------------------------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | ensure-login token miss (env unset and keyring unavailable) | +| `/.env.example` | dotenv | optional; merged into the generated `.env` | +| `/supabase/{.env..local,.env.local,.env.,.env}` | dotenv | step I (`legacyLoadProjectEnv`), before config.toml validation and again inside `legacyCheckDbToml`; `` is `SUPABASE_ENV` (default `development`), `.env.local` is skipped when `SUPABASE_ENV=test`; first of the 4 files (in this order) to set a key wins, and this `supabase/` directory tier beats the workdir-root tier below — feeds config.toml `env(VAR)` expansion and the push step's `SUPABASE_YES` auto-confirm default | +| `/{.env..local,.env.local,.env.,.env}` | dotenv | same read as above; lower-precedence fallback tier, only consulted for a key none of the `supabase/` directory's 4 files above already set | +| `/supabase/config.toml` | TOML | native push step (embedded defaults used when absent) | +| `/supabase/.temp/pooler-url` | plain text | native push step's connection resolution, only when the direct `db..:5432` host is unreachable (IPv4-only network) — `legacyResolveLinkedConn` falls back through the saved pooler URL `link.LinkServices` wrote in the earlier link-services step | +| `/supabase/migrations/` | directory | native push step, when `[db.migrations].enabled` (default true) | +| `/supabase/migrations/*.sql` | SQL | native push step, for each pending migration applied | +| seed files from `[db.seed].sql_paths` | SQL | native push step (`--include-seed` is always set; gated on `[db.seed].enabled`) | +| `/supabase/roles.sql` | SQL | native push step (`--include-roles` is always set; existence check + apply) | ## Files Written @@ -36,8 +30,6 @@ path uses the runtime pg-delta package/edge-runtime settings and catalog cache. | `/supabase/.temp/project-ref` | plain text | always (mandatory; fails the command on write error) | | `/supabase/.temp/{pooler-url,rest-version,gotrue-version,storage-version,storage-migration}` | plain text | best-effort, from `link.LinkServices` | | `/.env` | dotenv | best-effort (write failure prints a warning and continues) | -| `/supabase/.temp/pgdelta/catalog--migrations--.json` | JSON | legacy pg-delta opt-out, best-effort after migration apply (write failure only warns) | -| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy pg-delta opt-out, when the target requires SSL | | `/supabase/.temp/linked-project.json` | JSON | PersistentPostRun linked-project cache (`Effect.ensuring`); resolves against the bootstrap workdir (the prompted/`--workdir`/env target), not `cliSettings.workdir` | | `~/.supabase/telemetry.json` | JSON | PersistentPostRun telemetry flush (`Effect.ensuring`) | @@ -69,19 +61,14 @@ neither branch ever reaches the temp-login-role/Management-API path a passwordle ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | -| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | -| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | -| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | -| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | -| `SUPABASE_YES` | auto-confirm the native push step's prompts, read project-`.env`-aware like the standalone `db push` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the legacy opt-out's catalog cache when `[experimental.pgdelta].enabled` is unset, read project-`.env`-aware | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy catalog warming, read project-`.env`-aware | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | legacy opt-out's edge-runtime image registry, read project-`.env`-aware | no | -| `SUPABASE_USE_SLIM_IMAGES` | resolves the legacy opt-out's edge-runtime image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); `deno_version = 1` and historical `.temp/edge-runtime-version` pins stay on docker.io | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's edge-runtime npm registry, read project-`.env`-aware | no | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | +| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | +| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | +| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | +| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | +| `SUPABASE_YES` | auto-confirm the native push step's prompts, read project-`.env`-aware like the standalone `db push` | no | ## Exit Codes diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index f6afdbb42d..79f2b10561 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -377,7 +377,6 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( includeSeed: true, includeVault: true, dnsResolver, - projectId: cliSettings.projectId, toml, yes: pushYes, emitStructuredResult: false, diff --git a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts index b73fe4d9f3..682ea7e8b1 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts @@ -41,8 +41,6 @@ import { type LegacyPgConnInput, } from "../../command-internal/legacy-db-connection.service.ts"; import { legacyDebugLoggerLayer } from "../../command-internal/legacy-debug-logger.layer.ts"; -import { LegacyEdgeRuntimeScript } from "../../command-internal/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyTemplateService, type LegacyStarterTemplate } from "./bootstrap.templates.ts"; import { legacyBootstrap } from "./bootstrap.handler.ts"; import type { LegacyBootstrapFlags } from "./bootstrap.command.ts"; @@ -212,15 +210,6 @@ function setup(opts: SetupOpts = {}) { }); }), }); - const edgeRuntimeLayer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: () => - Effect.die("edge-runtime not needed: scratch/template fixtures never push migrations"), - }); - const sslProbeLayer = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.die("pg-delta ssl probe not needed for this test"), - requireSslForHost: () => Effect.die("pg-delta ssl probe not needed for this test"), - }); - const loginApi = mockLegacyLoginApi({ gotrueId: "gotrue-user" }); const loginCrypto = mockLegacyLoginCrypto(); @@ -241,8 +230,6 @@ function setup(opts: SetupOpts = {}) { credentials.layer, templateLayer, dbConnectionLayer, - edgeRuntimeLayer, - sslProbeLayer, loginApi.layer, loginCrypto.layer, mockBrowser(), diff --git a/apps/cli/src/commands/bootstrap/bootstrap.layers.ts b/apps/cli/src/commands/bootstrap/bootstrap.layers.ts index 86bc64b52c..b298d48f03 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.layers.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.layers.ts @@ -8,10 +8,7 @@ import { legacyCliSettingsLayer } from "../../config/legacy-cli-settings.layer.t import { legacyProjectRefLayer } from "../../config/legacy-project-ref.layer.ts"; import { legacyDbConnectionLayer } from "../../command-internal/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../command-internal/legacy-debug-logger.layer.ts"; -import { legacyDockerRunLayer } from "../../command-internal/legacy-docker-run.layer.ts"; -import { legacyEdgeRuntimeScriptLayer } from "../../command-internal/legacy-edge-runtime-script.layer.ts"; import { legacyIdentityStitchLayer } from "../../command-internal/legacy-identity-stitch.ts"; -import { legacyPgDeltaSslProbeLayer } from "../../command-internal/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyLinkedProjectCacheLayer } from "../../telemetry/legacy-linked-project-cache.layer.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; @@ -50,14 +47,6 @@ const platformApi = legacyPlatformApiLayer.pipe( Layer.provide(legacyIdentityStitchLayer), ); const platformApiFactory = legacyPlatformApiFactoryFromApiLayer.pipe(Layer.provide(platformApi)); -// `legacyDbPushCore` (the native push step, CLI-1953) needs a Postgres connection -// and the edge-runtime/pg-delta stack for its best-effort migrations-catalog cache -// — same sub-layers `db push` itself composes (`push.layers.ts`), reusing this -// file's own `cliSettings` reference rather than a second parallel one. -const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( - Layer.provide(legacyDockerRunLayer), - Layer.provide(cliSettings), -); export const legacyBootstrapRuntimeLayer = Layer.mergeAll( platformApi, @@ -74,9 +63,6 @@ export const legacyBootstrapRuntimeLayer = Layer.mergeAll( ), legacyTelemetryStateLayer, legacyDbConnectionLayer, - legacyDockerRunLayer, - edgeRuntime, - legacyPgDeltaSslProbeLayer, // Exposed bare (not just used to feed sibling sub-layers, as elsewhere in this // file) because `bootstrap.handler.ts` now calls `legacyResolveLinkedConn` // (CLI-1953's IPv4-pooler-fallback push connection) directly, which reads it. diff --git a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index f0aa75a3dd..d7ee344d73 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -41,9 +41,7 @@ import { type LegacyPgConnInput, } from "../../command-internal/legacy-db-connection.service.ts"; import { legacyDebugLoggerLayer } from "../../command-internal/legacy-debug-logger.layer.ts"; -import { LegacyEdgeRuntimeScript } from "../../command-internal/legacy-edge-runtime-script.service.ts"; import { legacyIdentityStitchLayer } from "../../command-internal/legacy-identity-stitch.ts"; -import { LegacyPgDeltaSslProbe } from "../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; import { legacyCliSettingsLayer } from "../../config/legacy-cli-settings.layer.ts"; import { legacyLinkedProjectCacheLayer } from "../../telemetry/legacy-linked-project-cache.layer.ts"; import { LegacyTemplateService } from "./bootstrap.templates.ts"; @@ -152,12 +150,7 @@ describe("legacy bootstrap linked-project cache location", () => { // Native push (CLI-1953): `legacyDbPushCore` needs a `LegacyDbConnection` — // tracked here so the test can assert it targets the created project's ref, - // not a divergent one. The pre-seeded migration below (proving the migrations - // lookup is scoped to the bootstrap workdir) makes the scratch config.toml's - // default `[experimental.pgdelta] enabled = true` actually reach the - // migrations-catalog cache path, so `LegacyEdgeRuntimeScript`/ - // `LegacyPgDeltaSslProbe` need real (if trivial) fakes here — not the - // `Effect.die` stubs the no-migrations happy-path tests use. + // not a divergent one. const pushConnectCalls: Array = []; const dbConnectionLayer = Layer.succeed(LegacyDbConnection, { connect: (conn: LegacyPgConnInput) => @@ -173,13 +166,6 @@ describe("legacy bootstrap linked-project cache location", () => { }; }), }); - const edgeRuntimeLayer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: () => Effect.succeed({ stdout: '{"version":1}', stderr: "" }), - }); - const sslProbeLayer = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); const templateLayer = Layer.succeed(LegacyTemplateService, { listSamples: Effect.succeed([]), download: () => Effect.void, @@ -242,8 +228,6 @@ describe("legacy bootstrap linked-project cache location", () => { mockAnalytics().layer, templateLayer, dbConnectionLayer, - edgeRuntimeLayer, - sslProbeLayer, mockLegacyLoginApi({ gotrueId: "gotrue-user" }).layer, mockLegacyLoginCrypto().layer, mockBrowser(), diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 43573606ac..6daa7e37ea 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -8,12 +8,10 @@ edge-runtime involved). `--use-pg-schema` is the CLI's sole remaining Go delegation on this command — a documented keep-in-Go exception (CLI-1960), not a pending port. -Set `SUPABASE_USE_PG_DELTA_NEXT=false` to use the legacy edge-runtime pg-delta -implementation and its runtime package/catalog cache. The bundled engine has no -automatic fallback; coverage gaps warn, while `--strict-coverage` makes them fatal, -and `PGDELTA_DEBUG` writes diagnostic JSON under -`supabase/.temp/pgdelta/v2/debug//`. Its SQL and transaction-aware file -splits may differ from legacy output; applicable, convergent SQL is the contract. +Pg-delta runs in-process. Coverage gaps warn, while `--strict-coverage` makes +them fatal, and `PGDELTA_DEBUG` writes diagnostic JSON under +`supabase/.temp/pgdelta/v2/debug//`. The engine may emit transaction-aware +file splits; applicable, convergent SQL is the contract. The bundled formatter defaults to lowercase SQL at width 180; config overrides it, and JSON `null` disables formatting without disabling safe compaction. @@ -22,46 +20,38 @@ it, and JSON `null` disables formatting without disabling safe compaction. | Path | Format | When | | --------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | -| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, and the explicit `--from/--to migrations` cache miss) | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, including the explicit `--from/--to migrations` shadow) | | `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | | `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) — `--use-pgadmin` too, via the SAME `legacyMigrateShadowDatabase` | | `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | -| `[db.migrations].schema_paths` globs / `/supabase/database/**` / `/supabase/schemas/**` | SQL | legacy engines only, for the local-target declarative-schema fallback; pg-delta next always compares the migrations baseline directly to the live target | +| `[db.migrations].schema_paths` globs / `/supabase/database/**` / `/supabase/schemas/**` | SQL | migra engine only, for the local-target declarative-schema fallback; pg-delta always compares the migrations baseline directly to the live target | | `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | | `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `/supabase/.temp/{pgdelta-version,edge-runtime-version}` | plain text | legacy pg-delta opt-out only | -| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's explicit `--from/--to migrations` catalog cache | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | non-empty `--file` diff; bundled pg-delta may emit ordered transaction-aware files, while pgAdmin always emits one | -| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output`; flattened review representation, not a portable apply script | -| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's explicit migrations catalog | -| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy opt-out, for a Supabase TLS target | -| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot (native diff targets + the explicit `--from/--to migrations` catalog miss; never `--use-pgadmin`/`--use-pg-schema`); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | -| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| --------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/_.sql` | SQL | non-empty `--file` diff; bundled pg-delta may emit ordered transaction-aware files, while pgAdmin always emits one | +| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output`; flattened review representation, not a portable apply script | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot (native diff targets + the explicit `--from/--to migrations` shadow; never `--use-pgadmin`/`--use-pg-schema`); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker -- Edge-runtime container (migra, or pg-delta under the legacy opt-out; also runs the legacy - declarative apply script and the pg-delta - catalog-export script for explicit `--from/--to migrations` on a cache miss — - CLI-1959, native, no longer the hidden Go `__catalog` seam). +- Edge-runtime container (migra engine only). - Shadow Postgres container — provisioned and torn down natively (`legacyPrepareShadowSource` in `commands/db/shared/legacy-shadow-source.ts`, over the lower-level primitives in `command-internal/db-bootstrap/shadow-database.ts`), no longer via a Go seam. Explicit - `--from/--to migrations` reuses the SAME native primitives on a cache miss - (`legacyResolveMigrationsCatalogRef` -> `exportViaShadowCatalog`, `legacy-pgdelta.cache.ts`), - called with `targetLocal: false`/`usePgDelta: false` to skip the declarative-schema-override - branch — not a second, `__catalog`-specific shadow, and not a shared `mode: "diff"` parameter - (that seam-era concept no longer exists). `--use-pgadmin` provisions its OWN shadow via a + `--from/--to migrations` provisions its migrations shadow through the pg-delta shadow layer + (`legacy-pgdelta-next-shadow.layer.ts`), which builds on the same shadow-baseline cache + primitives (`legacyAcquireShadowDatabase`), with no declarative-schema-override branch. + `--use-pgadmin` provisions its OWN shadow via a narrower composition — `legacyCreateShadowDatabase` -> health-wait -> `legacyMigrateShadowDatabase` directly (`diff.handler.ts`'s pgadmin branch) — with no declarative-schema-override branch and no `targetUrlOverride`. @@ -88,23 +78,20 @@ of this command's own target resolve, ahead of the differ container. ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth for `--linked` | no | -| `SUPABASE_DB_PASSWORD` | remote DB password (linked) | no | -| `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | -| `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | -| `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | -| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | -| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | -| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta engine | no | -| `PGDELTA_DEBUG` | pg-delta debug capture | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's scoped npm registry | no | -| `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the differ's / shadow's image registry (shell **or** project `.env`, applied for the run via `legacyApplyProjectEnv`, matching `db push`/`db pull`/`db dump`) | no | -| `SUPABASE_USE_SLIM_IMAGES` | resolves the current-pin shadow Postgres image, PG15+ realtime/storage/auth migrate-job images (cold shadow), and (for migra / legacy pg-delta) the edge-runtime image from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; the differ image, historical pins, PG14, OrioleDB, flag-off `15.8.1.085`, `deno_version = 1`, and historical `.temp/edge-runtime-version` pins stay on docker.io | no | +| Variable | Purpose | Required? | +| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_ACCESS_TOKEN` | auth for `--linked` | no | +| `SUPABASE_DB_PASSWORD` | remote DB password (linked) | no | +| `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | +| `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | +| `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | +| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | +| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta engine | no | +| `PGDELTA_DEBUG` | pg-delta debug capture | no | +| `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the differ's / shadow's image registry (shell **or** project `.env`, applied for the run via `legacyApplyProjectEnv`, matching `db push`/`db pull`/`db dump`) | no | `SUPABASE_DB_SHADOW_PORT`/`SUPABASE_NETWORK_ID`/`--network-id`/`SUPABASE_PROJECT_ID`/ `SUPABASE_DB_HEALTH_TIMEOUT` all apply to `--use-pgadmin` too — its shadow is provisioned @@ -223,12 +210,6 @@ transaction metadata. per-unit migration files for the CLI apply paths. - Normal mode always compares the migrations shadow to the selected live database; declarative files and `schema_paths` do not replace that baseline. -- Under the legacy opt-out, the explicit `migrations` target resolves natively (CLI-1959): a bare - migrations-content hash cache lookup (`/supabase/.temp/pgdelta/catalog-local-migrations--.json`, - shared with `db push`'s post-apply cache write), and on a miss, a natively-provisioned - shadow database (CLI-1956 — `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`, - no longer the `db __shadow` seam) plus a native pg-delta catalog export. No hidden Go - `db schema declarative __catalog` subprocess runs for this path any more. ### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default ON) diff --git a/apps/cli/src/commands/db/diff/diff.errors.ts b/apps/cli/src/commands/db/diff/diff.errors.ts index 76081340f4..fc438f4008 100644 --- a/apps/cli/src/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/commands/db/diff/diff.errors.ts @@ -132,8 +132,8 @@ export class LegacyDbDiffPgAdminError extends Data.TaggedError("LegacyDbDiffPgAd case "registry_pull": return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; // Malformed pinned-differ wire output is an internal contract violation, not a - // user input mistake — same precedent as pg-delta's own malformed-subprocess- - // output branch (`legacy-pgdelta.apply.ts`'s `"output_parse"` case). + // user input mistake — same classification as pg-delta's own malformed-output + // failures (`LegacyPgDeltaEngineError` with `reason: "output_parse"`). case "invalid_output": return { ...actionability.impossibleState, fingerprint_suffix: "invalid_content" }; case "image_inspect": diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 2a8327e460..6ca4fb1832 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -338,7 +338,6 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy const explicitCtx: LegacyPgDeltaContext = { projectId: legacyResolvePgDeltaProjectId(cliSettings.projectId, cfg, cliSettings.workdir), cwd: cliSettings.workdir, - npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), denoVersion: cfg.denoVersion, projectEnv: cfg.projectEnv, }; @@ -546,7 +545,6 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // that helper's own doc comment. projectId: legacyResolvePgDeltaProjectId(cliSettings.projectId, cfg, cliSettings.workdir), cwd: cliSettings.workdir, - npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), denoVersion: cfg.denoVersion, projectEnv: cfg.projectEnv, }; @@ -565,9 +563,8 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy usePgSchema, pgDeltaDefault, }); - // Only the next engine ignores schema_paths when building its migrations baseline. - const usesPgDeltaNext = useDelta && pgDelta.implementation === "next"; - if (usesPgDeltaNext && cfg.schemaPaths !== undefined && cfg.schemaPaths.length > 0) { + // pg-delta ignores schema_paths when building its migrations baseline. + if (useDelta && cfg.schemaPaths !== undefined && cfg.schemaPaths.length > 0) { yield* output.raw(legacySchemaPathsTransitionWarning, "stderr"); } @@ -677,11 +674,10 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy diffResult = { sql, files: undefined }; } else { yield* output.raw("Creating shadow database...\n", "stderr"); - const migrationMode: "legacy" | "pgdelta-next" = usesPgDeltaNext ? "pgdelta-next" : "legacy"; + const migrationMode: "legacy" | "pgdelta-next" = useDelta ? "pgdelta-next" : "legacy"; const shadowInput = { ...(yield* resolveShadowRunInput()), targetLocal: resolved.isLocal, - usePgDelta: useDelta, migrationMode, // `cfg.schemaPathPatterns`, NOT `localInputs.context.config.db.migrations.schema_paths`: // the latter is the raw `@supabase/config` field, which never applies @@ -689,7 +685,6 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // resolves that env override. schemaPaths: cfg.schemaPathPatterns, pgDelta: cfg.pgDelta, - ctx, }; // `legacyWithShadowDatabase` (`shadow-cache.ts`) owns the interrupt-safe lifecycle and the // cache seam — a plain create/remove pair when `SUPABASE_SHADOW_CACHE` is explicitly @@ -772,13 +767,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy : legacyFindDropStatements(out); const writtenFiles: Array = []; let ignoredDeclarativeAdvisory: ReturnType | undefined; - if ( - out.length >= 2 && - useDelta && - pgDelta.implementation === "next" && - Option.isSome(flags.file) && - flags.file.value.length > 0 - ) { + if (out.length >= 2 && useDelta && Option.isSome(flags.file) && flags.file.value.length > 0) { // This is an informational, best-effort probe only. Declarative files are // intentionally not inputs to normal db diff, so an unreadable or changing // directory must never turn a previously successful diff into a failure. diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index 05881a2d7a..6d91ed0da0 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -77,15 +77,13 @@ interface SetupOpts { readonly diffSql?: string; // When set, the pg-delta strategy mock returns one rendered file per entry. readonly diffFiles?: ReadonlyArray<{ readonly name: string; readonly sql: string }>; - // Exact suffixes returned by the next renderer, parallel to `diffFiles`. + // Exact suffixes returned by the pg-delta renderer, parallel to `diffFiles`. readonly diffSuffixes?: ReadonlyArray; readonly hazards?: LegacyPgDeltaHazardReport; - readonly pgDeltaImplementation?: "legacy" | "next"; readonly oom?: boolean; // edge-runtime OOMs; the bash fallback returns `diffSql` readonly delegateStdout?: string; // stdout returned by a captured Go-delegate run // When set, the PGDELTA_DEBUG shadow-catalog export fails with this message // instead of succeeding. - readonly catalogExportFailWith?: string; readonly diffFailWith?: string; // When set, the shadow's own PG15+ one-shot platform-baseline job(s) exit // non-zero, exercising cleanup-on-partial-failure (the shadow is still removed). @@ -252,9 +250,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const pgDeltaEngine = Layer.succeed( LegacyPgDeltaEngine, LegacyPgDeltaEngine.of({ - // The handler must route through this strategy even when the selected - // implementation is legacy; the strategy owns edge runtime and shadows. - implementation: opts.pgDeltaImplementation ?? "legacy", diffExplicit: (input) => Effect.sync(() => { explicitDiffCalls.push(input); @@ -279,16 +274,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { new LegacyEdgeRuntimeScriptError({ message: "Fatal JavaScript out of memory" }), ); } - // The PGDELTA_DEBUG shadow-catalog export uses a distinct errPrefix (`legacy- - // pgdelta.ts`'s `legacyExportCatalogPgDelta`), same as `db pull`'s own mock. - if (runOpts.errPrefix.includes("catalog")) { - if (opts.catalogExportFailWith !== undefined) { - return Effect.fail( - new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), - ); - } - return Effect.succeed({ stdout: '{"tables":[]}', stderr: "" }); - } if (opts.diffFailWith !== undefined) { return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: opts.diffFailWith })); } @@ -630,15 +615,15 @@ describe("legacy db diff", () => { connectOptions: { isLocal: true, dnsResolver: "native" }, }, }); - // Even the legacy implementation is hidden behind LegacyPgDeltaEngine; - // the handler no longer invokes edge runtime itself. + // pg-delta runs in-process through LegacyPgDeltaEngine; the handler never + // invokes the edge runtime for it. expect(s.edgeCalls).toEqual([]); 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("next local diff ignores schema_paths and declarative files", () => { + it.effect("pg-delta local diff ignores schema_paths and declarative files", () => { mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -657,7 +642,6 @@ describe("legacy db diff", () => { "create table ignored ();\n", ); const s = setup(tmp.current, { - pgDeltaImplementation: "next", diffSql: "create table result ();\n", }); return Effect.gen(function* () { @@ -682,10 +666,10 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); - // The transition warning is only true for the bundled next engine. Every other - // engine still routes a local target with declarative files through the - // declared-schema `contrib_regression` override, so schema_paths DOES still shape - // their output and claiming otherwise would be a lie. + // The transition warning is only true for pg-delta. Migra still routes a local + // target with declarative files through the declared-schema `contrib_regression` + // override, so schema_paths DOES still shape its output and claiming otherwise + // would be a lie. const writeSchemaPathsConfig = (pgDeltaEnabled: boolean) => { mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); writeFileSync( @@ -702,14 +686,13 @@ describe("legacy db diff", () => { writeFileSync(join(tmp.current, "supabase", "configured.sql"), "create table configured ();\n"); }; - it.effect("legacy pg-delta local diff does not print the schema_paths transition warning", () => { - writeSchemaPathsConfig(true); + it.effect("migra local diff does not print the schema_paths transition warning", () => { + writeSchemaPathsConfig(false); const s = setup(tmp.current, { - pgDeltaImplementation: "legacy", diffSql: "create table result ();\n", }); return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); + yield* legacyDbDiff(flags()); expect(stderr(s.out)).not.toContain("schema_paths no longer changes the migrations baseline"); }).pipe(Effect.provide(s.layer)); }); @@ -1444,7 +1427,6 @@ describe("legacy db diff", () => { "create table declarative_only ();\n", ); const s = setup(tmp.current, { - pgDeltaImplementation: "next", diffSql: "create table live_only ();\n", }); return Effect.gen(function* () { @@ -1470,7 +1452,6 @@ describe("legacy db diff", () => { ); const s = setup(tmp.current, { format: "json", - pgDeltaImplementation: "next", diffSql: "create table dogfood_note ();\n", }); return Effect.gen(function* () { @@ -1511,7 +1492,6 @@ describe("legacy db diff", () => { writeFileSync(join(tmp.current, "supabase", "not-a-directory.sql"), "select 1;\n"); const s = setup(tmp.current, { format: "json", - pgDeltaImplementation: "next", diffSql: "create table dogfood_note ();\n", }); return Effect.gen(function* () { @@ -1917,7 +1897,6 @@ describe("legacy db diff", () => { it.effect("warns on semantic data-loss hazards without a DROP statement", () => { const sql = "ALTER TABLE public.accounts ALTER COLUMN email TYPE text;"; const s = setup(tmp.current, { - pgDeltaImplementation: "next", diffSql: sql, hazards: { actions: [{ actionIndex: 0, kinds: ["data_loss"] }], @@ -2619,10 +2598,9 @@ describe("legacy db diff", () => { * Runs `db diff` with the shadow baseline cache on and artifacts under the workdir, * against the stateful Docker model the export/restore round trip needs. */ - const runCached = (implementation: "legacy" | "next") => { + const runCached = (engine: "migra" | "pg-delta") => { const s = setup(tmp.current, { statefulDocker: true, - pgDeltaImplementation: implementation, diffSql: "create table t ();\n", }); return legacyWithEnv( @@ -2631,36 +2609,43 @@ describe("legacy db diff", () => { legacyWithEnv( "SUPABASE_SHADOW_CACHE", "1", - legacyDbDiff(flags({ usePgDelta: Option.some(true) })).pipe(Effect.provide(s.layer)), + legacyDbDiff( + flags( + engine === "pg-delta" + ? { usePgDelta: Option.some(true) } + : { useMigra: Option.some(true) }, + ), + ).pipe(Effect.provide(s.layer)), ), ).pipe(Effect.as(s)); }; // Regression: both migrate paths used to pass a hardcoded `{ webhooks: "enabled" }`, so the - // legacy run's forced-`pg_net` baseline and the next run's config-following baseline keyed + // migra run's forced-`pg_net` baseline and the pg-delta run's config-following baseline keyed // to the SAME tar and silently restored each other's cluster. The handler now forks the // policy on `migrationMode`; `shadow-cache.integration.test.ts` covers the cache's half of // the contract, this covers `db diff`'s call site. - it.live("a legacy-engine baseline is never restored into a pg-delta-next run", () => { + it.live("a migra-engine baseline is never restored into a pg-delta run", () => { mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), "[experimental.pgdelta]\nenabled = true\n", ); return Effect.gen(function* () { - // Legacy migrate forces `pg_net` on regardless of config, and publishes that baseline. - const legacyRun = yield* runCached("legacy"); - expect(legacyRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); - const legacyTars = publishedTars(); - expect(legacyTars).toHaveLength(1); - - // pg-delta next follows the config (webhooks are off here), so it must cold-provision + // Migra's migrate path forces `pg_net` on regardless of config, and publishes + // that baseline. + const migraRun = yield* runCached("migra"); + expect(migraRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); + const migraTars = publishedTars(); + expect(migraTars).toHaveLength(1); + + // pg-delta follows the config (webhooks are off here), so it must cold-provision // and publish its OWN baseline rather than restore the forced-on one above. - const nextRun = yield* runCached("next"); - expect(nextRun.dockerDaemon?.stepCalls("cp-in")).toHaveLength(0); - expect(nextRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); + const pgDeltaRun = yield* runCached("pg-delta"); + expect(pgDeltaRun.dockerDaemon?.stepCalls("cp-in")).toHaveLength(0); + expect(pgDeltaRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); expect(publishedTars()).toHaveLength(2); - expect(publishedTars()).toEqual(expect.arrayContaining(legacyTars)); + expect(publishedTars()).toEqual(expect.arrayContaining(migraTars)); }); }); }); diff --git a/apps/cli/src/commands/db/diff/diff.layers.ts b/apps/cli/src/commands/db/diff/diff.layers.ts index 7cfa3383b9..5e165d3ff4 100644 --- a/apps/cli/src/commands/db/diff/diff.layers.ts +++ b/apps/cli/src/commands/db/diff/diff.layers.ts @@ -5,6 +5,7 @@ import { legacyIdentityStitchLayer } from "../../../command-internal/legacy-iden import { legacyLinkedDbResolverRuntimeLayer } from "../../../command-internal/legacy-management-api-runtime.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { + legacyMigraRuntimeLayer, legacyPgDeltaCommandRuntimeLayer, legacyPgDeltaDbConfigRuntimeLayer, } from "../shared/legacy-pgdelta-engine.layer.ts"; @@ -12,6 +13,7 @@ import { export const legacyDbDiffRuntimeLayer = Layer.mergeAll( legacyPgDeltaDbConfigRuntimeLayer, legacyPgDeltaCommandRuntimeLayer, + legacyMigraRuntimeLayer, legacyIdentityStitchLayer, legacyTelemetryStateLayer, legacyLinkedDbResolverRuntimeLayer(["db", "diff"]).pipe(Layer.provide(legacyIdentityStitchLayer)), diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 3b83633f40..4354afe13d 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -2,34 +2,22 @@ Native Effect port. Pulls the remote schema into either a new timestamped migration (diffing a throwaway shadow against the remote, bundled pg-delta or -migra) or declarative files (`--declarative`, native pg-delta export). The -initial-migra pull (no local migrations) seeds the migration file with a native -`pg_dump` of the remote schema (a Docker `pg_dump` container, with IPv4 -transaction-pooler fallback) and then appends the migra diff. `--experimental`'s -structured-dump sub-branch (Go's `format.WriteStructuredSchemas`) stays -delegated to the bundled Go binary rather than retired or ported (CLI-1957): it -needs a TS PostgreSQL DDL AST parser with no equivalent in this repo. -`--declarative` covers the same per-object-files outcome for schema objects via -pg-delta catalog introspection, though its output tree and cluster-object -coverage differ (see Files Written below), so this mode is on a deprecation -path — the same DECISION CLI-1960 makes for `db diff --use-pg-schema` (keep -delegating, flag for removal), not the same output: Go's own `--use-pg-schema` -prints its experimental warning from inside the delegated child, so the TS -`db diff` parent stays silent; Go's `db pull --experimental` prints nothing of -the kind, so the deprecation line below is a TS-fork-only addition with no Go -counterpart. `db pull --experimental` (or `SUPABASE_EXPERIMENTAL=true`) without -`--declarative` prints that line pointing at `--declarative` to stderr and then -delegates the whole pull to Go. `--experimental --declarative` is unaffected: -Go checks `usePgDelta` before `EXPERIMENTAL`, so that combination never -delegates and just runs the declarative export normally (see the -Notes/Delegation section below). - -Pg-delta runs in-process by default. Set `SUPABASE_USE_PG_DELTA_NEXT=false` for -the legacy edge-runtime implementation and runtime package/catalog cache; there -is no automatic fallback. Coverage gaps warn; `--strict-coverage` makes them +migra) or declarative files (`--declarative`, or the deprecated `--experimental` +gate without `--declarative`). Both export modes run the native pg-delta +export. The initial-migra pull (no local migrations) seeds the migration file +with a native `pg_dump` of the remote schema (a Docker `pg_dump` container, +with IPv4 transaction-pooler fallback) and then appends the migra diff. +`--experimental` without `--declarative` used to dump remote SQL through Go's +`format.WriteStructuredSchemas` (schemas + cluster AST split). That path now +runs the same in-process declarative export (`supabase/schemas` plus +`.pgdelta-export.json`) and prints a deprecation line pointing at +`--declarative`. `--experimental --declarative` does not print that line: +`--declarative` already selected the export. + +Pg-delta runs in-process. Coverage gaps warn; `--strict-coverage` makes them fatal, while `PGDELTA_DEBUG` writes diagnostic JSON under -`supabase/.temp/pgdelta/v2/debug//`. Bundled output may use different SQL -and transaction-aware file splits but must apply and converge. Its formatter +`supabase/.temp/pgdelta/v2/debug//`. The engine may emit transaction-aware +file splits; applicable, convergent SQL is the contract. Its formatter defaults to lowercase SQL at width 180; config overrides it, and JSON `null` disables formatting without disabling safe compaction. @@ -38,41 +26,37 @@ disables formatting without disabling safe compaction. | Path | Format | When | | ----------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`) | -| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (`--declarative` and migration-style pull; not the delegated `--experimental` structured-dump path) | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | migration-style pull's shadow provisioning, and declarative / deprecated-`--experimental` export config/env resolution | | `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | | `/supabase/migrations/*.sql` | SQL | history reconciliation + shadow provisioning | -| `/supabase/roles.sql` | SQL | migration-style pull only (`--declarative`'s bare shadow skips `SetupDatabase`); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | +| `/supabase/roles.sql` | SQL | migration-style pull only (`--declarative` provisions no shadow); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit (migration-style pull) — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | | `~/.supabase/access-token` | plain text | linked target with no `SUPABASE_ACCESS_TOKEN` | | `/supabase/.temp/project-ref` | plain text | linked ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `/supabase/.temp/{pgdelta-version,edge-runtime-version}` | plain text | legacy pg-delta opt-out only | -| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's catalog snapshots | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff, or the initial-migra `pg_dump` seed) | -| `/supabase/schemas/**` | SQL | `--declarative` | -| `/supabase/schemas/.pgdelta-export.json` | JSON | bundled `--declarative` export metadata | -| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | legacy pg-delta opt-out catalog snapshots | -| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy opt-out, for a Supabase TLS target | -| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | -| `/supabase/schemas/**`, `/supabase/cluster/**` | SQL | `--experimental` structured dump (delegated to Go; both dirs are `RemoveAll`'d then rewritten by `format.WriteStructuredSchemas`, not just written to) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot, migration-style pull only (never `--declarative`'s bare shadow or the delegated `--experimental` path); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | -| `~/.supabase//linked-project.json` | JSON | linked (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| --------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff, or the initial-migra `pg_dump` seed) | +| `/supabase/schemas/**` | SQL | `--declarative` or deprecated `--experimental` export | +| `/supabase/schemas/.pgdelta-export.json` | JSON | bundled declarative / deprecated-`--experimental` export metadata | +| `/supabase/config.toml` | TOML | declarative export updates `[db.migrations].schema_paths` when `[experimental.pgdelta] enabled` resolves false (section absent, `enabled` omitted, or `enabled = false` — the default); skipped when `enabled = true` | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot, migration-style pull only (never a declarative / deprecated-`--experimental` export, which provisions no shadow); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | +| `~/.supabase//linked-project.json` | JSON | linked (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker -- Edge-runtime container (migra, or pg-delta under the legacy opt-out). +- Edge-runtime container (migra engine only). - Shadow Postgres container — provisioned and torn down natively (`legacyPrepareShadowSource` in - `commands/db/shared/legacy-shadow-source.ts` / `legacyPrepareRawShadow` in - `command-internal/db-bootstrap/shadow-database.ts`, which also owns the lower-level primitives - both build on), no longer via a Go seam. Torn down with `docker rm -f -v` on every run, - cache or no cache — see the shadow baseline cache section below. + `commands/db/shared/legacy-shadow-source.ts`, over the lower-level primitives in + `command-internal/db-bootstrap/shadow-database.ts`), no longer via a Go seam. Torn down with + `docker rm -f -v` on every run, cache or no cache — see the shadow baseline cache section + below. Migration-style pulls only; `--declarative` provisions no shadow. - `supabase/migra` container — the migra OOM bash fallback only. - `pg_dump` container — the initial-migra pull's native remote-schema dump (`legacyStreamPgDump`, shared with `db dump`). @@ -98,8 +82,8 @@ Session-semantics caveat on the cached paths: migrations run on a session opened platform baseline, so role-level defaults installed by `supabase/roles.sql` (`ALTER ROLE … SET …`) apply to migration execution; with the cache off, the single-session flow runs migrations before those defaults take effect. Each pooler-retry attempt acquires/releases its -own shadow (a warm hit restores the same tar each time); `--declarative`'s bare shadow runs no -baseline, so it is never cached. +own shadow (a warm hit restores the same tar each time); `--declarative` provisions no shadow +at all, so nothing is cached for it. ## API Routes / DB @@ -113,21 +97,19 @@ baseline, so it is never cached. ## Environment Variables -| Variable | Purpose | Required? | -| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth for the linked target | no | -| `SUPABASE_DB_PASSWORD` | remote DB password (overridden by `-p`) | no | -| `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | -| `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | -| `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | -| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | -| `SUPABASE_USE_SLIM_IMAGES` | resolves the current-pin shadow Postgres, `pg_dump`, PG15+ realtime/storage/auth migrate-job images (migration-style cold shadow), and (for migra / legacy pg-delta) the edge-runtime image from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, flag-off `15.8.1.085`, `deno_version = 1`, and historical `.temp/edge-runtime-version` pins stay on docker.io | no | -| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | -| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta diff engine | no | -| `SUPABASE_EXPERIMENTAL` | selects the deprecated structured-dump branch (still delegates to Go, see below) | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's npm registry | no | +| Variable | Purpose | Required? | +| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_ACCESS_TOKEN` | auth for the linked target | no | +| `SUPABASE_DB_PASSWORD` | remote DB password (overridden by `-p`) | no | +| `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | +| `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | +| `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the current-pin shadow Postgres, `pg_dump`, PG15+ realtime/storage/auth migrate-job images (migration-style cold shadow), and (for migra) the edge-runtime image from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, flag-off `15.8.1.085`, and `deno_version = 1` stay on docker.io | no | +| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | +| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta diff engine | no | +| `SUPABASE_EXPERIMENTAL` | selects the deprecated in-process structured-dump export (same as `--declarative`) when `--declarative` is not set | no | ## Exit Codes @@ -135,7 +117,7 @@ baseline, so it is never cached. | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | success (migration written + optional history update; declarative export) | | `1` | target mutex; `--declarative`/`--use-pg-delta` with `--diff-engine`; migration-history conflict; **no schema changes ("No schema changes found")**; connection/shadow/engine failure; file IO error | -| `1` | `--project-ref` set with a resolved target other than linked; `--project-ref` combined with the `--experimental` structured-dump pull (see Notes) | +| `1` | `--project-ref` set with a resolved target other than linked | > Note: unlike `db diff`, an empty diff (`No schema changes found`) is a **non-zero > exit** for `db pull`. The message and exit code match Go, but the stderr footer @@ -166,7 +148,7 @@ 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 / Delegation +## Notes - `--declarative` / deprecated `--use-pg-delta` are mutually exclusive with `--diff-engine`; `--db-url` / `--linked` (default) / `--local` are a target group. @@ -177,11 +159,9 @@ Progress strings still go to stderr; stdout carries a single structured envelope id/labels. It never implies `--linked`: passing it with a resolved `--local`/`--db-url` target is a hard error rather than a silently discarded flag (deliberately stricter than `SUPABASE_PROJECT_ID`, which Go's equivalent - env var simply leaves unused on a non-linked target). It is also rejected up - front when combined with the delegated `--experimental` structured-dump pull - (see below) — `rebuildDelegateArgs` never forwards `--project-ref` to the - delegated Go child, which would otherwise silently re-resolve the workdir's - own linked ref instead. + env var simply leaves unused on a non-linked target). The deprecated + `--experimental` export honors `--project-ref` the same way `--declarative` + does. - `--use-pg-delta` is hidden and emits the cobra deprecation line to stderr. - Migration-style pulls always compare migrations with the live target; declarative files and `schema_paths` do not replace that baseline. @@ -192,16 +172,9 @@ Progress strings still go to stderr; stdout carries a single structured envelope diff after a non-empty dump is swallowed; an empty dump + empty diff is "No schema changes found". - The `--experimental` structured-dump branch (or the `SUPABASE_EXPERIMENTAL` - project-`.env` equivalent) still rebuilds the argv and execs the bundled Go - binary (its side effects are Go's — see Files Written above for what that - actually writes), because Go's `format.WriteStructuredSchemas` needs a - PostgreSQL DDL AST parser that has no TS port yet. It is deprecated - (CLI-1957): a TS-fork-only warning (no Go counterpart) pointing at - `--declarative` prints to stderr before the delegated exec. The Go child's - telemetry is disabled so the single `cli_command_executed` event comes from - this TS command. `--project-ref` combined with this mode is rejected up - front instead of silently dropped or forwarded via `SUPABASE_PROJECT_ID`: - the latter was considered and rejected because it also overrides the - delegated child's own `Config.ProjectId` (and therefore its shadow/ - edge-runtime container labels) — a coupling `--project-ref` deliberately - avoids. Mirrors `db diff --use-pg-schema`'s identical guard. + project-`.env` equivalent) now runs the same in-process declarative export + as `--declarative`. It is deprecated: a warning pointing at `--declarative` + prints to stderr before the export. `--experimental --declarative` does not + print that line. Output is the pg-delta declarative tree under + `supabase/schemas` (plus `.pgdelta-export.json`), not Go's former + `schemas/` + `cluster/` AST split. diff --git a/apps/cli/src/commands/db/pull/pull.command.ts b/apps/cli/src/commands/db/pull/pull.command.ts index f4b540db26..15c90bacd0 100644 --- a/apps/cli/src/commands/db/pull/pull.command.ts +++ b/apps/cli/src/commands/db/pull/pull.command.ts @@ -12,10 +12,9 @@ const config = { 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` and the deprecated `--use-pg-delta` both select declarative + // export and are mutually exclusive with `--diff-engine`. Optional so the + // mutex tracks whether the flag was passed. declarative: Flag.boolean("declarative").pipe( Flag.withDescription( "Replace the declarative schema tree from the selected database instead of creating a migration; migration history is not updated.", @@ -24,9 +23,8 @@ const config = { ), usePgDelta: Flag.boolean("use-pg-delta").pipe( 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. + // Hidden: Effect V4 has no `Flag.withDeprecated`; the handler prints + // cobra's deprecation line. Flag.withHidden, Flag.optional, ), diff --git a/apps/cli/src/commands/db/pull/pull.debug.ts b/apps/cli/src/commands/db/pull/pull.debug.ts deleted file mode 100644 index 3f964cf38f..0000000000 --- a/apps/cli/src/commands/db/pull/pull.debug.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { type FileSystem, Effect, type Path } from "effect"; - -import { Output } from "../../../shared/output/output.service.ts"; -import { legacyBold } from "../../../command-internal/legacy-colors.ts"; -import { - type LegacyDebugBundle, - legacyDebugBundleMessage, - legacySaveDebugBundle, -} from "../shared/legacy-debug-bundle.ts"; -import { legacyPgDeltaTempPath } from "../../../command-internal/legacy-pgdelta.paths.ts"; -import { - type LegacyPgDeltaContext, - legacyExportCatalogPgDelta, -} from "../../../command-internal/legacy-pgdelta.ts"; - -// Established output contract. -const ERR_IN_SYNC = "No schema changes found"; - -const byteLength = (value: string): number => new TextEncoder().encode(value).length; - -/** - * Replaces 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(); -} - -/** A single-line, password-redacted connection 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. */ -export interface LegacyCatalogSummary { - readonly totalObjects: number; - readonly bySchema: Record; -} - -/** - * Best-effort counts catalog objects grouped by schema name: 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). - */ -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 }; -} - -/** Formats a catalog summary line. */ -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(", ")})`; -} - -/** Formats a byte size as `%.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. - */ -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: 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 (connects to the remote 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/commands/db/pull/pull.debug.unit.test.ts b/apps/cli/src/commands/db/pull/pull.debug.unit.test.ts deleted file mode 100644 index e5b483d21f..0000000000 --- a/apps/cli/src/commands/db/pull/pull.debug.unit.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; -import { - legacyFormatByteSize, - legacyFormatCatalogSummary, - legacyFormatConnectionInfo, - legacyFormatEmptyPgDeltaPullSummary, - legacyRedactPostgresURL, - legacySummarizeCatalogJson, -} from "./pull.debug.ts"; - -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/commands/db/pull/pull.handler.ts b/apps/cli/src/commands/db/pull/pull.handler.ts index ad68f28c65..fdf9217389 100644 --- a/apps/cli/src/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/commands/db/pull/pull.handler.ts @@ -9,7 +9,6 @@ import { legacyResolveYesWithProjectEnv, } from "../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; @@ -35,18 +34,12 @@ import { import type { LegacyDbConnType } from "../../../command-internal/legacy-db-target-flags.ts"; import { legacyMakeDir } from "../../../command-internal/legacy-make-dir.ts"; import { legacyToPostgresURL } from "../../../command-internal/legacy-postgres-url.ts"; -import { legacySchemaToCsvField } from "../../../command-internal/legacy-schema-flags.ts"; import { legacyBuildLocalDbContainerInputs, type LegacyLocalDbContainerInputs, } from "../../../command-internal/db-bootstrap/local-container-inputs.ts"; import { legacyWithShadowDatabase } from "../../../command-internal/db-bootstrap/shadow-cache.ts"; -import { - legacyCreateShadowDatabase, - legacyPrepareRawShadow, - legacyRemoveShadowDatabase, - legacyShadowRunInputFromLocalContainerInputs, -} from "../../../command-internal/db-bootstrap/shadow-database.ts"; +import { legacyShadowRunInputFromLocalContainerInputs } from "../../../command-internal/db-bootstrap/shadow-database.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { @@ -78,7 +71,7 @@ import { legacyFormatMigrationTimestamp, legacyGetMigrationPath, } from "../../../command-internal/legacy-migration-file.ts"; -import { legacyDebugBundleMessage, legacyFormatDebugId } from "../shared/legacy-debug-bundle.ts"; +import { legacyDebugBundleMessage } from "../shared/legacy-debug-bundle.ts"; import { LegacyPgDeltaEngine, type LegacyPgDeltaDatabaseEndpoint, @@ -88,7 +81,6 @@ import { legacyIsPgDeltaDebugEnabled, legacyResolvePgDeltaProjectId, } from "../../../command-internal/legacy-pgdelta.ts"; -import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; import { legacyPrepareShadowSource } from "../shared/legacy-shadow-source.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { @@ -122,75 +114,26 @@ const IN_SYNC_SUGGESTION = /** Migration-file mode for the initial pg_dump seed. */ const MIGRATION_FILE_MODE = 0o644; -// `--experimental`'s structured-dump `db pull` mode (Go's `format.WriteStructuredSchemas`) -// stays delegated to the bundled Go binary rather than retired or ported: Go's formatter -// routes DDL through a PostgreSQL AST parser (`multigres`) with no TS equivalent. -// `--declarative` (native pg-delta export) covers the same per-object-files outcome via -// catalog introspection for schema objects, though its output tree and cluster-object -// coverage differ (see SIDE_EFFECTS.md), so this mode is on a deprecation path — the same -// decision `db diff --use-pg-schema` makes (keep delegating, flag for removal), NOT the -// same OUTPUT: Go's `db diff --use-pg-schema` prints its own experimental warning from -// inside the delegated child, so the TS parent deliberately stays silent there. Go's `db -// pull --experimental` prints nothing of the kind — this line is a TS-fork-only, -// forward-looking addition with no Go counterpart (unlike `DEPRECATION_LINE` below, which -// byte-matches pflag's `MarkDeprecated`). Printed to stderr right alongside the existing -// `--use-pg-delta` deprecation line below. +// `--experimental` without `--declarative` used to dump remote SQL through Go's +// multigres AST splitter. That path is deprecated: the in-process declarative +// export covers the same per-object-files outcome. Printed only when the +// experimental gate selected this branch (not when `--declarative` already did). const EXPERIMENTAL_STRUCTURED_DUMP_DEPRECATION_LINE = "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in a future release. Use --declarative instead to pull the remote schema as per-object files."; -/** Rebuilds the `db pull` argv for the Go-delegated `--experimental` structured-dump branch. */ -const rebuildDelegateArgs = (flags: LegacyDbPullFlags): Array => { - const args = ["db", "pull"]; - // Called only once the parent has already decided to delegate (`legacyResolveExperimentalWithProjectEnv`'s - // last-occurrence-wins argv rescan resolved `true`), so state it explicitly rather than - // relying on root's own `globalArgs` forwarding: root derives `--experimental` from the - // PARSED `LegacyExperimentalFlag` (first-occurrence-wins, e.g. `Param.ts`'s - // `providedValues[0]`), which can disagree with the rescan on a repeated flag - // (`--experimental=false --experimental=true` resolves `true` here but `false` there). A - // duplicate `--experimental` is harmless — pflag's own last-`Set()`-wins rule still applies - // in the delegated child. - args.push("--experimental"); - if (Option.isSome(flags.name)) args.push(flags.name.value); - 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`); - }; - // Delegation only ever happens in MIGRATION mode — the declarative branch - // returns before reaching the delegate call sites — so the resolved decision - // here is always `useDeclarative === false`. Go binds `--declarative` and - // `--use-pg-delta` to one last-occurrence-wins variable (`cmd/db.go:531-532`), so - // replaying only the truthy alias (e.g. forwarding `--declarative` for - // `db pull --declarative --use-pg-delta=false`) would flip the child back to - // declarative export. Forward an explicit `--declarative=false` when an alias was - // passed so the child resolves migration mode deterministically. Never forward - // `--use-pg-delta`: the parent already prints its deprecation line and Go's - // MarkDeprecated (`cmd/db.go:533`) would re-print it. The "alias present" guard - // also keeps us clear of Go's mutually-exclusive [declarative diff-engine] group - // (which fires on `Changed`), since an alias and `--diff-engine` can't co-occur. - if (Option.isSome(flags.declarative) || Option.isSome(flags.usePgDelta)) { - args.push("--declarative=false"); - } - if (Option.isSome(flags.diffEngine)) args.push("--diff-engine", flags.diffEngine.value); - // Re-encode each parsed schema as a CSV field so the Go child's pflag StringSlice - // CSV parse doesn't re-split a comma-containing schema (e.g. `"tenant,one"`). - for (const s of flags.schema) args.push("--schema", legacySchemaToCsvField(s)); - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - pushTarget("linked", flags.linked); - pushTarget("local", flags.local); - if (Option.isSome(flags.password)) args.push("--password", flags.password.value); - return args; +export type LegacyDbPullInvoke = { + /** Skip `Finished supabase db pull.` — Go's `db remote commit` has no PostRun line. */ + readonly skipFinishedLine?: boolean; }; -export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: LegacyDbPullFlags) { +export const legacyDbPull = Effect.fn("legacy.db.pull")(function* ( + flags: LegacyDbPullFlags, + invoke?: LegacyDbPullInvoke, +) { const output = yield* Output; const resolver = yield* LegacyDbConfigResolver; const connection = yield* LegacyDbConnection; const pgDeltaEngine = yield* LegacyPgDeltaEngine; - const proxy = yield* LegacyGoProxy; const cliSettings = yield* LegacyCliSettings; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; @@ -236,13 +179,12 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy if (Option.isSome(flags.usePgDelta)) { yield* output.raw(`${DEPRECATION_LINE}\n`, "stderr"); } - // Declarative mode never delegates. Computed once here — reused below for both - // the deprecation print and the branch that actually delegates — so the two - // can never drift. - const delegatesExperimentalPull = !useDeclarative && experimental; - if (delegatesExperimentalPull) { + // Deprecated `--experimental` dump: same in-process export as `--declarative`. + const useExperimentalExport = experimental && !useDeclarative; + if (useExperimentalExport) { yield* output.raw(`${EXPERIMENTAL_STRUCTURED_DUMP_DEPRECATION_LINE}\n`, "stderr"); } + const useDeclarativeExport = useDeclarative || useExperimentalExport; // Mutually exclusive flag groups: `[db-url linked local]`, `[declarative // diff-engine]`, `[use-pg-delta diff-engine]`. "set" means the flag was @@ -289,28 +231,6 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ); } - // `--experimental`'s structured-dump mode delegates the whole pull to the - // bundled Go binary via `rebuildDelegateArgs`, which cannot forward a - // TS-only flag: the delegated child re-resolves the workdir's own linked - // ref itself (Go's `LoadProjectRef`, `internal/utils/flags/ - // project_ref.go:54-76`), so `--project-ref` would be silently dropped and - // the child would target the wrong project — the exact wrong-project - // hazard the guard above exists to prevent for the native paths. Passing - // `SUPABASE_PROJECT_ID` through the child's env instead was considered and - // rejected: that variable ALSO overrides the child's own `Config.ProjectId` - // (and therefore its shadow/edge-runtime container labels, - // `pkg/config/config.go:563-570`) — a coupling `--project-ref` deliberately - // avoids (see `LegacyProjectRefResolver`'s use below). Mirrors - // `diff.handler.ts`'s identical `--use-pg-schema` guard. - if (Option.isSome(flags.projectRef) && delegatesExperimentalPull) { - return yield* Effect.fail( - new LegacyDbPullTargetFlagsError({ - message: - "--project-ref is not supported with the --experimental structured-dump pull; use --declarative instead", - }), - ); - } - // Go's `ParseDatabaseConfig` resolves the linked ref via the hard `LoadProjectRef`, THEN // reads the `[remotes.]`-merged config (`LoadConfig`, which prints "Loading config // override" unconditionally the moment a remote matches — `pkg/config/config.go:605`) — @@ -342,7 +262,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy const runtimeInfo = yield* RuntimeInfo; const networkIdFlag = yield* LegacyNetworkIdFlag; // Validate native shadow inputs before target resolution performs remote side effects. - const localInputs: Option.Option = delegatesExperimentalPull + const localInputs: Option.Option = useExperimentalExport ? Option.none() : Option.some( yield* legacyBuildLocalDbContainerInputs( @@ -381,7 +301,6 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // that helper's own doc comment, and `diff.handler.ts`'s identical call site. projectId: legacyResolvePgDeltaProjectId(cliSettings.projectId, toml, cliSettings.workdir), cwd: cliSettings.workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, projectEnv: toml.projectEnv, }; @@ -445,71 +364,27 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy envEnabled: legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), }), }); - const usesPgDeltaNext = usePgDeltaDiff && pgDeltaEngine.implementation === "next"; - - // Runs the Go-delegated `--experimental` structured dump (still delegated, see - // `EXPERIMENTAL_STRUCTURED_DUMP_DEPRECATION_LINE` above for why). 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 (stdout is payload-only in machine mode). The child is run with a - // non-TTY stdin (`"ignore"`) so any prompt takes its default without blocking the - // JSON caller. The EXPERIMENTAL structured dump returns before writing a migration or - // touching `schema_migrations`, so `remoteHistoryUpdated` is `false`; `schemaWritten` - // stays `null` — the child owns the write and doesn't surface the path on stdout. - const delegatePull = ( - engine: "migra" | "pg-delta", - opts: { readonly remoteHistoryUpdated: boolean }, - ) => - Effect.gen(function* () { - const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; - if (output.format !== "text") { - yield* proxy.execCapture(rebuildDelegateArgs(flags), { - env, - stdin: "ignore", - suppressChildTelemetry: true, - }); - yield* output.success("Schema pulled.", { - declarative: false, - schemaWritten: null, - remoteHistoryUpdated: opts.remoteHistoryUpdated, - engine, - }); - return; - } - yield* proxy.exec(rebuildDelegateArgs(flags), { env, suppressChildTelemetry: true }); - }); // Connectivity check, run before dialing. yield* Effect.scoped( Effect.gen(function* () { - // Local vs remote keyed off the resolver's `isLocal`. The delegated - // `--experimental` branch skips this print: the Go child's own connectivity - // check already prints the line, so the parent printing too would double it. - // (The parent still dials below, so a parent-side connect failure on the - // delegate path surfaces without the line; pre-existing delegate behavior.) - if (!delegatesExperimentalPull) { - yield* output.raw( - `Connecting to ${resolved.isLocal ? "local" : "remote"} database...\n`, - "stderr", - ); - } + yield* output.raw( + `Connecting to ${resolved.isLocal ? "local" : "remote"} database...\n`, + "stderr", + ); const session = yield* connection.connect(resolved.conn, { isLocal: resolved.isLocal, dnsResolver, }); - // Declarative export path. - if (useDeclarative) { + // Declarative export path (`--declarative` or deprecated `--experimental`). + if (useDeclarativeExport) { yield* output.raw("Preparing declarative schema export using pg-delta...\n", "stderr"); const declarativeDirRel = legacyResolveDeclarativeDir(path, toml.pgDelta); const declarativeDir = path.resolve(cliSettings.workdir, declarativeDirRel); - const exportSchema = ( - target: LegacyPgDeltaDatabaseEndpoint, - source?: LegacyPgDeltaDatabaseEndpoint, - ) => + const exportSchema = (target: LegacyPgDeltaDatabaseEndpoint) => pgDeltaEngine.exportDeclarativeSchema({ context: ctx, - ...(source !== undefined ? { source } : {}), target, schema: flags.schema, formatOptions, @@ -518,44 +393,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy : {}), debug: legacyIsPgDeltaDebugEnabled(), strictCoverage: flags.strictCoverage, - noCache: false, }); - // Legacy export owns an interrupt-safe empty-shadow lifecycle; next reads the target. - const exported = - pgDeltaEngine.implementation === "next" - ? yield* withPoolerFallback(targetEndpoint, (target) => exportSchema(target)) - : yield* Effect.gen(function* () { - const declLocalInputs = Option.getOrThrow(localInputs); - const resolvedDeclShadowImage = yield* declLocalInputs.resolvePostgresImage; - // The legacy exporter still needs the historical empty baseline. Keep it - // native and workflow-owned; the bundled next exporter reads only target. - const rawShadowInput = legacyShadowRunInputFromLocalContainerInputs( - declLocalInputs, - resolvedDeclShadowImage, - toml, - fs, - path, - ); - return yield* Effect.acquireUseRelease( - legacyCreateShadowDatabase(spawner, rawShadowInput), - (handle) => - Effect.gen(function* () { - const shadow = yield* legacyPrepareRawShadow( - spawner, - handle, - rawShadowInput, - ); - return yield* withPoolerFallback(targetEndpoint, (target) => - exportSchema(target, { - kind: "database", - ref: shadow.sourceUrl, - connectOptions: { isLocal: true, dnsResolver: "native" }, - }), - ); - }), - (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), - ); - }); + const exported = yield* withPoolerFallback(targetEndpoint, (target) => + exportSchema(target), + ); const written = yield* legacyWriteDeclarativeSchemas( fs, path, @@ -591,32 +432,17 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy remoteHistoryUpdated: false, engine: "pg-delta", }); - } else { + } else if (invoke?.skipFinishedLine !== true) { yield* output.raw(`Finished ${legacyAqua("supabase db pull")}.\n`); } return; } - // Only next ignores schema_paths in favor of the migrations baseline. - if ( - !delegatesExperimentalPull && - usesPgDeltaNext && - toml.schemaPaths !== undefined && - toml.schemaPaths.length > 0 - ) { + // pg-delta ignores schema_paths in favor of the migrations baseline. + if (usePgDeltaDiff && toml.schemaPaths !== undefined && toml.schemaPaths.length > 0) { yield* output.raw(legacySchemaPathsTransitionWarning, "stderr"); } - // Structured dump still delegates to Go's PostgreSQL DDL formatter. - if (delegatesExperimentalPull) { - // The structured-dump path returns before writing a migration or touching - // schema_migrations, so no history repair. - yield* delegatePull(usePgDeltaDiff ? "pg-delta" : "migra", { - remoteHistoryUpdated: false, - }); - return; - } - // Migration-file path. const nowMillis = yield* Clock.currentTimeMillis; const timestamp = legacyFormatMigrationTimestamp(nowMillis); @@ -651,8 +477,8 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // Built above, before `resolver.resolve()` (see that build's doc comment — it's what // used to run here, right before the initial-dump write below, but even that was still // after `resolver.resolve()`/`connection.connect()`). `Option.getOrThrow` is safe here: - // this point is only reached after the `if (delegatesExperimentalPull) { …; return; }` - // check above already returned, so `localInputs` was always built. + // this point is only reached after the declarative-export branch already + // returned, so `localInputs` was always built. const pullLocalInputs = Option.getOrThrow(localInputs); if (seededFromDump) { @@ -764,8 +590,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy Effect.gen(function* () { yield* output.raw("Creating shadow database...\n", "stderr"); const resolvedPullShadowImage = yield* pullLocalInputs.resolvePostgresImage; - // Legacy may substitute a declarative target; next always uses the live target. - const migrationMode: "legacy" | "pgdelta-next" = usesPgDeltaNext + const migrationMode: "legacy" | "pgdelta-next" = usePgDeltaDiff ? "pgdelta-next" : "legacy"; const shadowInput = { @@ -777,7 +602,6 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy path, ), targetLocal: resolved.isLocal, - usePgDelta: usePgDeltaDiff, migrationMode, // `toml.schemaPathPatterns`, NOT `pullLocalInputs.context.config.db.migrations. // schema_paths`: the latter is the raw `@supabase/config` field, which never @@ -785,7 +609,6 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // (`legacyReadDbToml`) already resolves that env override. schemaPaths: toml.schemaPathPatterns, pgDelta: toml.pgDelta, - ctx, }; // `legacyWithShadowDatabase` (`shadow-cache.ts`) owns the interrupt-safe lifecycle // and the cache seam. Each pooler-retry attempt still acquires and releases its own @@ -856,41 +679,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // initial-migra path seeded the file with a pg_dump above, so its empty second // pass is swallowed and falls through to the shared tail below. if (diffEmpty && !seededFromDump) { - // Preserve the legacy empty-diff debug bundle contract. - if (pgDeltaEngine.implementation === "legacy" && diffOutcome.debug !== undefined) { - const debugDir = yield* legacySaveEmptyPgDeltaPullDebug({ - ctx, - conn: resolved.conn, - targetUrl, - sourceCatalog: diffOutcome.debug.sourceSnapshot, - pgDeltaStderr: diffOutcome.debug.stderr, - id: legacyFormatDebugId(yield* Clock.currentTimeMillis), - fs, - path, - workdir: cliSettings.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})`, - suggestion: IN_SYNC_SUGGESTION, - }), - ); - } - } - if ( - pgDeltaEngine.implementation === "next" && - diffOutcome.debug?.directory !== undefined - ) { + if (diffOutcome.debug?.directory !== undefined) { yield* output.raw(legacyDebugBundleMessage(diffOutcome.debug.directory), "stderr"); return yield* Effect.fail( new LegacyDbPullInSyncError({ @@ -1025,7 +814,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy remoteHistoryUpdated, engine: usePgDeltaDiff ? "pg-delta" : "migra", }); - } else { + } else if (invoke?.skipFinishedLine !== true) { yield* output.raw(`Finished ${legacyAqua("supabase db pull")}.\n`); } }), diff --git a/apps/cli/src/commands/db/pull/pull.integration.test.ts b/apps/cli/src/commands/db/pull/pull.integration.test.ts index 31940de3d0..5fee55f620 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -58,6 +58,8 @@ import { LegacyPgDeltaEngine, LegacyPgDeltaEngineError, } from "../shared/legacy-pgdelta-engine.service.ts"; +import { legacyDbRemoteCommit } from "../remote/commit/commit.handler.ts"; +import type { LegacyDbRemoteCommitFlags } from "../remote/commit/commit.command.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { legacyDbPull } from "./pull.handler.ts"; @@ -90,7 +92,6 @@ const pgDeltaDiffEnvelope = ( }); interface SetupOpts { - readonly engineImplementation?: "next" | "legacy"; readonly nextDebugDirectory?: string; readonly format?: OutputFormat; readonly remoteVersions?: ReadonlyArray; @@ -108,7 +109,6 @@ 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 // Initial-migra pull: the bytes the native pg_dump container streams to its sink, // its exit code / stderr, and (when set) an IPv6 stderr that fails the FIRST dump // attempt so the pooler retry runs (the second attempt then streams `dumpStdout`). @@ -167,7 +167,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const pgDeltaEngine = Layer.succeed( LegacyPgDeltaEngine, LegacyPgDeltaEngine.of({ - implementation: opts.engineImplementation ?? "legacy", diffExplicit: () => Effect.die("diffExplicit unused"), diffDatabase: (input) => { engineCalls.push({ @@ -195,14 +194,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { ...(process.env["PGDELTA_DEBUG"] !== undefined ? { debug: - opts.engineImplementation === "next" - ? { - sourceSnapshot: opts.catalogStdout ?? "", - ...(opts.nextDebugDirectory !== undefined - ? { directory: opts.nextDebugDirectory } - : {}), - } - : { sourceSnapshot: opts.catalogStdout ?? "", stderr: "" }, + opts.nextDebugDirectory !== undefined + ? { directory: opts.nextDebugDirectory } + : {}, } : {}), }); @@ -282,11 +276,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { 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: "" }); }, }); @@ -525,6 +514,13 @@ const flags = (over: Partial = {}): LegacyDbPullFlags => ({ password: over.password ?? Option.none(), }); +const commitFlags = (over: Partial = {}): LegacyDbRemoteCommitFlags => ({ + schema: over.schema ?? [], + dbUrl: over.dbUrl ?? Option.none(), + linked: over.linked ?? false, + password: over.password ?? Option.none(), +}); + const streamText = (out: ReturnType, stream: "stdout" | "stderr") => stripAnsi( out.rawChunks @@ -643,22 +639,14 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("rejects --project-ref combined with --experimental before delegating", () => { - // The bundled Go binary's own `db pull --experimental` re-resolves the - // workdir's own linked ref itself, and `rebuildDelegateArgs` never registered - // `--project-ref` to forward — fail up front instead of silently dropping it. + it.effect("honors --project-ref on the deprecated --experimental export", () => { const FLAG_REF = "flagflagflagflagflag"; - const s = setup(tmp.current, { experimental: true }); + const s = setup(tmp.current, { experimental: true, edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { - const exit = yield* legacyDbPull(flags({ projectRef: Option.some(FLAG_REF) })).pipe( - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( - "--project-ref is not supported with the --experimental structured-dump pull; use --declarative instead", - ); + yield* legacyDbPull(flags({ projectRef: Option.some(FLAG_REF) })); + expect(s.engineCalls[0]?.operation).toBe("export"); + expect(s.engineCalls[0]?.projectRef).toBe(FLAG_REF); expect(s.proxyCalls).toEqual([]); - expect(s.proxyCaptureCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); @@ -787,7 +775,6 @@ describe("legacy db pull", () => { ); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], - engineImplementation: "next", // The next engine's mock parses `edgeStdout` as a rendered-file envelope. edgeStdout: JSON.stringify({ files: [ @@ -901,23 +888,17 @@ describe("legacy db pull", () => { scope: "database", files: ["public/t.sql"], }); - // Declarative mode's bare shadow (`legacyPrepareRawShadow`) never connects to set - // up a platform baseline or `contrib_regression` template. The only connects are - // the top-level target connect (`resolved.conn`, port 5432, database "postgres") - // and the shadow's own readiness probe on the shadow port — a single short-lived - // connect that is now the provisioning gate (`legacyWaitForShadowReady`) in place - // of waiting on the shadow container's 10s-interval Docker healthcheck. - expect(s.connectTargets).toEqual([ - { database: "postgres", port: 5432 }, - { database: "postgres", port: 54320 }, - ]); - expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toHaveLength(1); - expect(s.shadowSpawned.filter((call) => call.args[0] === "rm")).toHaveLength(1); + // Declarative export reads only the live target: the sole connect is the + // top-level target connect (`resolved.conn`, port 5432, database "postgres"), + // and no shadow database is ever provisioned. + expect(s.connectTargets).toEqual([{ database: "postgres", port: 5432 }]); + expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toHaveLength(0); + expect(s.shadowSpawned.filter((call) => call.args[0] === "rm")).toHaveLength(0); }).pipe(Effect.provide(s.layer)); }); - it.effect("next declarative export does not provision a baseline shadow", () => { - const s = setup(tmp.current, { engineImplementation: "next" }); + it.effect("declarative export does not provision a baseline shadow", () => { + const s = setup(tmp.current, {}); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true) })); expect(s.engineCalls[0]?.operation).toBe("export"); @@ -1321,51 +1302,6 @@ 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", - () => { - // A debug bundle is saved and its path embedded in the in-sync error when - // PGDELTA_DEBUG is set on an empty pg-delta diff. - 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 (→ 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 }); @@ -1393,7 +1329,6 @@ describe("legacy db pull", () => { const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "", - engineImplementation: "next", nextDebugDirectory: debugDir, }); return Effect.gen(function* () { @@ -1709,9 +1644,9 @@ describe("legacy db pull", () => { ); it.effect( - "SUPABASE_EXPERIMENTAL prints a deprecation warning and delegates the structured-dump pull to Go", + "SUPABASE_EXPERIMENTAL prints a deprecation warning and runs the in-process declarative export", () => { - const s = setup(tmp.current); + const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { const prev = process.env["SUPABASE_EXPERIMENTAL"]; process.env["SUPABASE_EXPERIMENTAL"] = "true"; @@ -1721,117 +1656,80 @@ describe("legacy db pull", () => { 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" }); - // The Go child's own `ConnectByConfig` prints the Connecting line; the - // parent must not print it too (it would appear twice in the stream). - expect(streamText(s.out, "stderr")).not.toContain("Connecting to"); + expect(s.proxyCalls).toHaveLength(0); + expect(s.engineCalls[0]?.operation).toBe("export"); + expect(streamText(s.out, "stderr")).toContain("Connecting to remote database..."); expect(streamText(s.out, "stderr")).toContain( "The --experimental structured-dump mode for `db pull` is deprecated", ); - // The env-sourced SUPABASE_EXPERIMENTAL never reaches the delegated child as - // a real flag on its own — the parent must state --experimental explicitly - // in the rebuilt argv (root's own globalArgs forwarding derives --experimental - // from a DIFFERENT, first-occurrence-wins parse, which can disagree here). - expect(s.proxyCalls[0]?.args).toContain("--experimental"); + expect(streamText(s.out, "stderr")).toContain("Preparing declarative schema export"); }).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 }); + it.effect("--experimental still exports when the last --declarative alias is false", () => { + const s = setup(tmp.current, { + experimental: true, + edgeStdout: EXPORT_JSON, + args: ["db", "pull", "--experimental", "--declarative", "--use-pg-delta=false"], + }); return Effect.gen(function* () { - yield* legacyDbPull(flags({ local: Option.some(false) })); - expect(s.proxyCalls[0]?.args).toContain("--local=false"); + yield* legacyDbPull( + flags({ declarative: Option.some(true), usePgDelta: Option.some(false) }), + ); + expect(s.engineCalls[0]?.operation).toBe("export"); + expect(s.proxyCalls).toHaveLength(0); + expect(streamText(s.out, "stderr")).toContain( + "The --experimental structured-dump mode for `db pull` is deprecated", + ); }).pipe(Effect.provide(s.layer)); }); - it.effect( - "delegated pull forwards resolved migration mode when the last alias occurrence is false", - () => { - // Parent resolves migration mode (last wins = false). The rebuilt delegate - // argv must forward that decision as `--declarative=false`, not replay the - // truthy `--declarative` alone — Go binds both aliases to one variable, so a - // lone `--declarative` would flip the child back to declarative export. The - // deprecated `--use-pg-delta` must NOT be forwarded (the parent already - // printed its deprecation line). - const s = setup(tmp.current, { - experimental: true, - args: ["db", "pull", "--experimental", "--declarative", "--use-pg-delta=false"], - }); - return Effect.gen(function* () { - yield* legacyDbPull( - flags({ declarative: Option.some(true), usePgDelta: Option.some(false) }), - ); - expect(s.proxyCalls[0]?.args).toContain("--declarative=false"); - expect(s.proxyCalls[0]?.args).not.toContain("--declarative"); - expect(s.proxyCalls[0]?.args).not.toContain("--use-pg-delta"); - }).pipe(Effect.provide(s.layer)); - }, - ); - - it.effect("delegated pull with --diff-engine and no alias omits --declarative entirely", () => { - // The "alias present" guard matters: forwarding --declarative=false alongside - // --diff-engine would trip Go's mutually-exclusive [declarative diff-engine] - // group (which fires on Changed regardless of value). With no alias passed, the - // delegate argv must carry only --diff-engine. - const s = setup(tmp.current, { experimental: true }); + it.effect("--experimental with --diff-engine still runs the in-process export", () => { + const s = setup(tmp.current, { experimental: true, edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); - expect(s.proxyCalls[0]?.args).toContain("--diff-engine"); - expect(s.proxyCalls[0]?.args).not.toContain("--declarative=false"); - expect(s.proxyCalls[0]?.args).not.toContain("--declarative"); + expect(s.engineCalls[0]?.operation).toBe("export"); + expect(s.proxyCalls).toHaveLength(0); }).pipe(Effect.provide(s.layer)); }); it.effect( - "the global --experimental flag prints a deprecation warning and delegates the structured-dump pull to Go", + "the global --experimental flag prints a deprecation warning and runs the in-process export", () => { - // 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 }); + const s = setup(tmp.current, { experimental: true, edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { yield* legacyDbPull(flags()); - expect(s.proxyCalls).toHaveLength(1); - expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); - // The Go child's own `ConnectByConfig` prints the Connecting line; the - // parent must not print it too (it would appear twice in the stream). - expect(streamText(s.out, "stderr")).not.toContain("Connecting to"); + expect(s.proxyCalls).toHaveLength(0); + expect(s.engineCalls[0]?.operation).toBe("export"); + expect(streamText(s.out, "stderr")).toContain("Connecting to remote database..."); expect(streamText(s.out, "stderr")).toContain( "The --experimental structured-dump mode for `db pull` is deprecated", ); - expect(s.proxyCalls[0]?.args).toContain("--experimental"); }).pipe(Effect.provide(s.layer)); }, ); - it.effect("an experimental pull in json mode reports no remote-history repair", () => { - // The structured-dump path returns before writing a migration or touching - // schema_migrations, 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( + "an experimental pull in json mode reports a declarative export with no history repair", + () => { + const s = setup(tmp.current, { + experimental: true, + format: "json", + edgeStdout: EXPORT_JSON, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.proxyCaptureCalls).toHaveLength(0); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ + declarative: true, + remoteHistoryUpdated: false, + engine: "pg-delta", + }); + }).pipe(Effect.provide(s.layer)); + }, + ); it.effect( "--declarative wins over --experimental and is unaffected by the deprecated experimental mode", @@ -1855,8 +1753,8 @@ describe("legacy db pull", () => { () => { // A SET flag value wins over env regardless of whether it's true or false, so // `--experimental=false` must NOT be overridden by a truthy - // `SUPABASE_EXPERIMENTAL` — the pull proceeds as normal instead of hitting the - // retirement error. + // `SUPABASE_EXPERIMENTAL` — the pull proceeds as a normal migration instead of + // the deprecated experimental export. const prev = process.env["SUPABASE_EXPERIMENTAL"]; process.env["SUPABASE_EXPERIMENTAL"] = "true"; seedMigration(tmp.current, "20240101000000"); @@ -1889,22 +1787,17 @@ describe("legacy db pull", () => { // flags at the first bare `--` — `db pull -- --experimental=false` passes // "--experimental=false" as the positional migration-name argument, NOT as an // explicit flag occurrence. Unlike the unterminated `--experimental=false` - // case above, this must still delegate to Go. `flags().name` is set to match - // what the real parser would have produced for this argv (the positional - // operand), so the scenario this test exists to protect is actually exercised - // — note this does NOT assert anything about how that name is itself - // forwarded to the delegated child (`rebuildDelegateArgs` pushes it as a bare - // positional with no `--` terminator of its own, a separate, pre-existing, - // unfixed gap: a name that looks like a flag could be re-parsed as one by the - // Go child). + // case above, this must still take the experimental export. const prev = process.env["SUPABASE_EXPERIMENTAL"]; process.env["SUPABASE_EXPERIMENTAL"] = "true"; const s = setup(tmp.current, { args: ["db", "pull", "--", "--experimental=false"], + edgeStdout: EXPORT_JSON, }); return Effect.gen(function* () { yield* legacyDbPull(flags({ name: Option.some("--experimental=false") })); - expect(s.proxyCalls).toHaveLength(1); + expect(s.engineCalls[0]?.operation).toBe("export"); + expect(s.proxyCalls).toHaveLength(0); }).pipe( Effect.ensuring( Effect.sync(() => { @@ -1918,24 +1811,26 @@ describe("legacy db pull", () => { ); it.effect( - "a repeated --experimental=false --experimental=true still delegates (last Set() wins)", + "a repeated --experimental=false --experimental=true still exports (last Set() wins)", () => { // pflag/viper bind ONE variable per flag: repeated occurrences collapse to // whichever Set() call happened LAST. A resolver that only checks "does any // pre-terminator token say false" gets this ordering backwards and would - // incorrectly skip delegating to Go. + // incorrectly skip the experimental export. const s = setup(tmp.current, { args: ["db", "pull", "--experimental=false", "--experimental=true"], + edgeStdout: EXPORT_JSON, }); return Effect.gen(function* () { yield* legacyDbPull(flags()); - expect(s.proxyCalls).toHaveLength(1); + expect(s.engineCalls[0]?.operation).toBe("export"); + expect(s.proxyCalls).toHaveLength(0); }).pipe(Effect.provide(s.layer)); }, ); it.effect( - "a bare --password consumes the following token, so SUPABASE_EXPERIMENTAL still gates the delegated structured-dump pull", + "a bare --password consumes the following token, so SUPABASE_EXPERIMENTAL still gates the experimental export", () => { // pflag accepts `--flag value` (space form) for `--password` (a string flag, // `pull.command.ts`'s `password: Flag.string(...)`), so `--password @@ -1948,10 +1843,12 @@ describe("legacy db pull", () => { process.env["SUPABASE_EXPERIMENTAL"] = "true"; const s = setup(tmp.current, { args: ["db", "pull", "--password", "--experimental=false"], + edgeStdout: EXPORT_JSON, }); return Effect.gen(function* () { yield* legacyDbPull(flags()); - expect(s.proxyCalls).toHaveLength(1); + expect(s.engineCalls[0]?.operation).toBe("export"); + expect(s.proxyCalls).toHaveLength(0); }).pipe( Effect.ensuring( Effect.sync(() => { @@ -1986,7 +1883,6 @@ describe("legacy db pull", () => { mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"); const s = setup(tmp.current, { - engineImplementation: "next", remoteVersions: ["20240101000000"], edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), yes: true, @@ -2243,11 +2139,8 @@ describe("legacy db pull", () => { it.effect("retries the declarative export through the IPv4 pooler on an IPv6 error", () => { // The declarative export retries through the pooler in the same IPv6 - // scenario, but unlike the migration-style diff it prepares the raw shadow - // ONCE before the retry and only re-runs the export against the same shadow — - // a deliberate asymmetry, not a gap to close. Assert the single-shadow-reuse - // shape so a future change doesn't accidentally "fix" this path to - // double-provision like the migration-style diff path correctly does. + // scenario. The export reads only the live target, so no shadow database is + // ever provisioned on this path. const s = setup(tmp.current, { edgeFailFirstWith: "error exporting declarative schema:\nnetwork is unreachable", edgeStdout: EXPORT_JSON, @@ -2260,7 +2153,7 @@ describe("legacy db pull", () => { expect(streamText(s.out, "stderr")).toContain( `Declarative schema written to ${join("supabase", "schemas")}\n`, ); - expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(0); }).pipe(Effect.provide(s.layer)); }); @@ -2329,8 +2222,8 @@ describe("legacy db pull", () => { * the second run's behaviour — the cache key is global and deliberately workdir-independent, * so two worktrees with identical settings still collide on the same tar. */ - const runCached = (implementation: "legacy" | "next") => { - const workdir = join(tmp.current, `${implementation}-worktree`); + const runCached = (engine: "migra" | "pg-delta") => { + const workdir = join(tmp.current, `${engine}-worktree`); seedMigration(workdir, "20240101000000"); writeFileSync( join(workdir, "supabase", "config.toml"), @@ -2338,9 +2231,11 @@ describe("legacy db pull", () => { ); const s = setup(workdir, { statefulDocker: true, - engineImplementation: implementation, remoteVersions: ["20240101000000"], - edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table t ();" }]), + edgeStdout: + engine === "pg-delta" + ? pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table t ();" }]) + : "create table t ();\n", yes: true, }); return legacyWithEnv( @@ -2349,32 +2244,108 @@ describe("legacy db pull", () => { legacyWithEnv( "SUPABASE_SHADOW_CACHE", "1", - legacyDbPull(flags()).pipe(Effect.provide(s.layer)), + legacyDbPull(flags(engine === "migra" ? { diffEngine: Option.some("migra") } : {})).pipe( + Effect.provide(s.layer), + ), ), ).pipe(Effect.as(s)); }; // Regression: both migrate paths used to pass a hardcoded `{ webhooks: "enabled" }`, so the - // legacy run's forced-`pg_net` baseline and the next run's config-following baseline keyed + // migra run's forced-`pg_net` baseline and the pg-delta run's config-following baseline keyed // to the SAME tar and silently restored each other's cluster. The handler now forks the // policy on `migrationMode`; `shadow-cache.integration.test.ts` covers the cache's half of // the contract, this covers `db pull`'s call site. - it.live("a legacy-engine baseline is never restored into a pg-delta-next run", () => { + it.live("a migra-engine baseline is never restored into a pg-delta run", () => { return Effect.gen(function* () { - // Legacy migrate forces `pg_net` on regardless of config, and publishes that baseline. - const legacyRun = yield* runCached("legacy"); - expect(legacyRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); - const legacyTars = publishedTars(); - expect(legacyTars).toHaveLength(1); - - // pg-delta next follows the config (webhooks are off here), so it must cold-provision + // Migra's migrate path forces `pg_net` on regardless of config, and publishes + // that baseline. + const migraRun = yield* runCached("migra"); + expect(migraRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); + const migraTars = publishedTars(); + expect(migraTars).toHaveLength(1); + + // pg-delta follows the config (webhooks are off here), so it must cold-provision // and publish its OWN baseline rather than restore the forced-on one above. - const nextRun = yield* runCached("next"); - expect(nextRun.dockerDaemon?.stepCalls("cp-in")).toHaveLength(0); - expect(nextRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); + const pgDeltaRun = yield* runCached("pg-delta"); + expect(pgDeltaRun.dockerDaemon?.stepCalls("cp-in")).toHaveLength(0); + expect(pgDeltaRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); expect(publishedTars()).toHaveLength(2); - expect(publishedTars()).toEqual(expect.arrayContaining(legacyTars)); + expect(publishedTars()).toEqual(expect.arrayContaining(migraTars)); }); }); }); }); + +describe("legacy db remote commit", () => { + it.effect("writes a remote_commit migration in-process and skips the pull PostRun line", () => { + seedMigration(tmp.current, "20240101000000"); + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + "[experimental.pgdelta]\nenabled = true\n", + ); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: pgDeltaDiffEnvelope([ + { + name: "schema_changes", + sql: "-- Migration unit 1: schema_changes\n\ncreate table remote ();", + }, + ]), + yes: true, + args: ["db", "remote", "commit"], + }); + return Effect.gen(function* () { + yield* legacyDbRemoteCommit(commitFlags()); + const dir = join(tmp.current, "supabase", "migrations"); + const written = readdirSync(dir).filter((f) => f.endsWith("_remote_commit.sql")); + expect(written).toHaveLength(1); + expect(readFileSync(join(dir, written[0] ?? ""), "utf8")).toContain( + "create table remote ();", + ); + expect(streamText(s.out, "stderr")).toContain( + `Command "commit" is deprecated, use "db pull" instead.\n`, + ); + expect(streamText(s.out, "stderr")).toContain( + `Schema written to ${join("supabase", "migrations", written[0] ?? "")}\n`, + ); + expect(streamText(s.out, "stdout")).not.toContain("Finished supabase db pull."); + expect(s.engineCalls).toHaveLength(1); + expect(s.engineCalls[0]?.operation).toBe("diff"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("honors --experimental as the same in-process export as db pull", () => { + const s = setup(tmp.current, { + experimental: true, + edgeStdout: EXPORT_JSON, + args: ["db", "remote", "commit", "--experimental"], + }); + return Effect.gen(function* () { + yield* legacyDbRemoteCommit(commitFlags()); + expect(s.engineCalls[0]?.operation).toBe("export"); + const err = streamText(s.out, "stderr"); + expect(err).toContain(`Command "commit" is deprecated, use "db pull" instead.\n`); + expect(err).toContain("The --experimental structured-dump mode for `db pull` is deprecated"); + expect(existsSync(join(tmp.current, "supabase", "schemas", "public", "t.sql"))).toBe(true); + expect(streamText(s.out, "stdout")).not.toContain("Finished supabase db pull."); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("rejects --linked together with --db-url", () => { + const s = setup(tmp.current, {}); + return Effect.gen(function* () { + const exit = yield* legacyDbRemoteCommit( + commitFlags({ linked: true, dbUrl: Option.some("postgresql://u:p@h/db") }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).toContain( + "if any flags in the group [db-url linked local] are set none of the others can be", + ); + expect(streamText(s.out, "stderr")).toContain( + `Command "commit" is deprecated, use "db pull" instead.\n`, + ); + }).pipe(Effect.provide(s.layer)); + }); +}); diff --git a/apps/cli/src/commands/db/pull/pull.layers.ts b/apps/cli/src/commands/db/pull/pull.layers.ts index fca7f25cd5..09dee0e839 100644 --- a/apps/cli/src/commands/db/pull/pull.layers.ts +++ b/apps/cli/src/commands/db/pull/pull.layers.ts @@ -6,16 +6,21 @@ import { legacyLinkedDbResolverRuntimeLayer } from "../../../command-internal/le import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { + legacyMigraRuntimeLayer, legacyPgDeltaCommandRuntimeLayer, legacyPgDeltaDbConfigRuntimeLayer, } from "../shared/legacy-pgdelta-engine.layer.ts"; -export const legacyDbPullRuntimeLayer = Layer.mergeAll( - legacyPgDeltaDbConfigRuntimeLayer, - legacyPgDeltaCommandRuntimeLayer, - legacyIdentityStitchLayer, - legacyTelemetryStateLayer, - legacyLinkedDbResolverRuntimeLayer(["db", "pull"]).pipe(Layer.provide(legacyIdentityStitchLayer)), - commandRuntimeLayer(["db", "pull"]), - stdinLayer, -); +export const legacyDbSchemaPullRuntimeLayer = (command: ReadonlyArray) => + Layer.mergeAll( + legacyPgDeltaDbConfigRuntimeLayer, + legacyPgDeltaCommandRuntimeLayer, + legacyMigraRuntimeLayer, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + legacyLinkedDbResolverRuntimeLayer(command).pipe(Layer.provide(legacyIdentityStitchLayer)), + commandRuntimeLayer(command), + stdinLayer, + ); + +export const legacyDbPullRuntimeLayer = legacyDbSchemaPullRuntimeLayer(["db", "pull"]); diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index c39a990ee8..dc0eb85b86 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -18,12 +18,10 @@ before migrations unless `--skip-vault` is set. ## Files Written -| Path | Format | When | -| ------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | on the `--linked` path (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | -| `/supabase/.temp/pgdelta/catalog--migrations--.json` | JSON | best-effort, after a successful migration apply, when pg-delta is enabled (`[experimental.pgdelta] enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`) AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false`); the default next engine skips this warmup entirely; a failure only warns on stderr and never fails the push | -| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | same gate as above, when the target requires SSL (`legacyPreparePgDeltaRef`) | +| Path | Format | When | +| ------------------------------------------------ | ------ | --------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | on the `--linked` path (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | ## Database Mutations @@ -44,18 +42,13 @@ before migrations unless `--skip-vault` is set. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | -| `SUPABASE_YES` | auto-confirm prompts | no (also `--yes`) | -| `SUPABASE_PROJECT_ID` | linked-ref resolution override, superseded by `--project-ref` when set (same precedence position); also independently feeds the pg-delta migrations-catalog cache's project id, which `--project-ref` does NOT affect — see Notes | no | -| `DOTENV_PRIVATE_KEY*` | decrypts `encrypted:` config secrets; `[db.vault]` values are not decrypted with `--skip-vault` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the migrations-catalog cache when `[experimental.pgdelta].enabled` is unset | no (project `.env` or shell) | -| `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it); shell presence wins over project `.env`, even an empty shell value | no (project `.env` or shell) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the pg-delta edge-runtime image registry for the cache export | no (project `.env` or shell) | -| `SUPABASE_USE_SLIM_IMAGES` | resolves the pg-delta edge-runtime image from the slim `ghcr.io/supabase/cli/edge-runtime` build (`true`/`1` enable); `deno_version = 1` and historical `.temp/edge-runtime-version` pins stay on docker.io | no (ambient shell only) | -| `PGDELTA_NPM_REGISTRY` | overrides the pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward) for the cache export | no (project `.env` or shell) | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | +| `SUPABASE_YES` | auto-confirm prompts | no (also `--yes`) | +| `SUPABASE_PROJECT_ID` | linked-ref resolution override, superseded by `--project-ref` when set (same precedence position) — see Notes | no | +| `DOTENV_PRIVATE_KEY*` | decrypts `encrypted:` config secrets; `[db.vault]` values are not decrypted with `--skip-vault` | no | ## Exit Codes @@ -103,9 +96,7 @@ stdout is payload-only. A single `result` object is emitted: exclusive; with no flag the target defaults to linked. - **`--project-ref`** (TS-only, no Go equivalent on any user-facing `db` command) overrides ONLY the linked-ref resolution `LegacyProjectRefResolver` - performs (flag > `SUPABASE_PROJECT_ID` > `~/.supabase//project-ref`) — - it does not affect the pg-delta migrations-catalog cache's project id, which - still derives from `SUPABASE_PROJECT_ID`/config.toml/workdir basename only. + performs (flag > `SUPABASE_PROJECT_ID` > `~/.supabase//project-ref`). It never implies `--linked`: passing it with a resolved `--local`/`--db-url` target is a hard error rather than a silently discarded flag (deliberately stricter than `SUPABASE_PROJECT_ID`, which simply goes unused on a @@ -130,14 +121,3 @@ stdout is payload-only. A single `result` object is emitted: Prefer idempotent forms (`CREATE INDEX CONCURRENTLY IF NOT EXISTS …`) and isolating such statements in their own migration file. Intentional fix for supabase/cli#5139, adopted into TS in PR supabase/cli#5671 (landed on develop as `b48fad60`). -- **Migrations catalog cache**: after a successful migration apply, when pg-delta - is enabled AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false` - — the default next engine skips this warmup entirely), exports the target's - pg-delta catalog via the edge-runtime stack - and writes it under `supabase/.temp/pgdelta/`, pruning older snapshots for the - same prefix (retains 2). A failure only warns on stderr - (`Warning: failed to cache migrations catalog: …`) and never fails the push. - Reuses `legacyExportCatalogPgDelta` (the same pg-delta export path - `db pull`/`db diff` use, which always mounts the project root at `/workspace`) - rather than a second copy, so an ENOENT bug present in an earlier - implementation (supabase/cli#5921) has no equivalent here. diff --git a/apps/cli/src/commands/db/push/push.handler.ts b/apps/cli/src/commands/db/push/push.handler.ts index 005a64d585..72ed73d783 100644 --- a/apps/cli/src/commands/db/push/push.handler.ts +++ b/apps/cli/src/commands/db/push/push.handler.ts @@ -127,7 +127,6 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy includeSeed: flags.includeSeed, includeVault: !flags.skipVault, dnsResolver, - projectId: cliSettings.projectId, toml, yes, emitStructuredResult: true, diff --git a/apps/cli/src/commands/db/push/push.integration.test.ts b/apps/cli/src/commands/db/push/push.integration.test.ts index 014f2b3de8..f2e2210502 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; @@ -31,12 +31,6 @@ import { type LegacyPgConnInput, type LegacyDbSession, } from "../../../command-internal/legacy-db-connection.service.ts"; -import { LegacyEdgeRuntimeScriptError } from "../../../command-internal/legacy-edge-runtime-script.errors.ts"; -import { - LegacyEdgeRuntimeScript, - type LegacyEdgeRuntimeRunOpts, -} from "../../../command-internal/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; import { legacyDbPush } from "./push.handler.ts"; import type { LegacyDbPushFlags } from "./push.command.ts"; @@ -182,8 +176,6 @@ function setup( noSeedTable?: boolean; failExec?: string; failExecWith?: { message: string; code?: string; detail?: string; position?: number }; - catalogStdout?: string; - catalogExportFailWith?: string; noProjectId?: boolean; // Simulates the real `LegacyDbConfigResolver`'s own "Initialising login // role..." stderr line (`legacy-db-config.layer.ts`'s `initLoginRole`), @@ -209,24 +201,6 @@ function setup( const telemetry = mockLegacyTelemetryStateTracked(); const linkedCache = mockLegacyLinkedProjectCacheTracked(); - const edgeRunCalls: Array = []; - const registryEnvAtRunTime: Array = []; - const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - edgeRunCalls.push(runOpts); - registryEnvAtRunTime.push(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); - if (opts.catalogExportFailWith !== undefined) { - return Effect.fail( - new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), - ); - } - return Effect.succeed({ stdout: opts.catalogStdout ?? '{"version":1}', stderr: "" }); - }, - }); - const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); const projectRefLayer = Layer.succeed(LegacyProjectRefResolver, { resolve: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), resolveForLink: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), @@ -276,8 +250,6 @@ function setup( projectRefLayer, telemetry.layer, linkedCache.layer, - edge, - sslProbe, ); return { layer, @@ -286,8 +258,6 @@ function setup( telemetry, linkedCache, resolver, - edgeRunCalls, - registryEnvAtRunTime, }; } @@ -394,269 +364,6 @@ describe("legacy db push", () => { }); }); - it.live("does not attempt to cache the migrations catalog when pg-delta is disabled", () => { - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(edgeRunCalls).toHaveLength(0); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); - }); - }); - - it.live("does not start edge-runtime for the obsolete catalog warmup under default next", () => { - const { layer, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: migrationFile("20240101000000"), - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(edgeRunCalls).toHaveLength(0); - expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); - }); - }); - - it.live("caches the migrations catalog when project .env enables pg-delta", () => { - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_EXPERIMENTAL_PG_DELTA=true\nSUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - const tempDir = join(tmp.current, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => - name.startsWith("catalog-local-migrations-"), - ); - expect(catalogFiles).toHaveLength(1); - }); - }); - - it.live( - "skips the legacy catalog when an empty shell value shadows a project .env false (godotenv parity)", - () => { - // godotenv.Load never replaces a shell value, including an empty one, so - // an empty `SUPABASE_USE_PG_DELTA_NEXT` in the shell must suppress the - // `supabase/.env` fallback below and resolve to the next implementation — - // matching the engine-selector layer's own precedence rather than - // `toml.envLookup`'s (which treats an empty shell value as unset). - const prev = process.env["SUPABASE_USE_PG_DELTA_NEXT"]; - process.env["SUPABASE_USE_PG_DELTA_NEXT"] = ""; - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(0); - expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_USE_PG_DELTA_NEXT"]; - else process.env["SUPABASE_USE_PG_DELTA_NEXT"] = prev; - }), - ), - ); - }, - ); - - it.live("caches the migrations catalog after a successful push when pg-delta is enabled", () => { - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - const tempDir = join(tmp.current, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => - name.startsWith("catalog-local-migrations-"), - ); - expect(catalogFiles).toHaveLength(1); - expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); - }); - }); - - it.live( - "falls back to config.toml's project_id for the pg-delta volume when SUPABASE_PROJECT_ID is unset", - () => { - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - noProjectId: true, - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - // `project_id` resolves from config.toml (here "test") once no - // `SUPABASE_PROJECT_ID` env override wins — the pg-delta Deno-cache volume - // must key off that same id, not fall through to an empty/shared name. - expect(edgeRunCalls[0]?.binds).toContain("supabase_edge_runtime_test:/root/.cache/deno:rw"); - }); - }, - ); - - it.live( - "falls back to the workdir basename for the pg-delta volume when config.toml has no project_id", - () => { - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: "[experimental.pgdelta]\nenabled = true\n", - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - noProjectId: true, - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - const expectedId = basename(tmp.current); - expect(edgeRunCalls[0]?.binds).toContain( - `supabase_edge_runtime_${expectedId}:/root/.cache/deno:rw`, - ); - }); - }, - ); - - it.live( - "falls back to the linked project ref for the pg-delta volume when config.toml has no project_id", - () => { - // The linked ref seeds `project_id` before config load runs, so on the - // linked path (the default target here — no `--local`/`--db-url`) an - // absent `project_id` retains the linked ref rather than falling to the - // workdir basename; only `--local`/`--db-url` (the previous test, where - // the ref is never seeded) fall through to the basename. - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: "[experimental.pgdelta]\nenabled = true\n", - args: ["db", "push", "--linked"], - isLocal: false, - projectRef: LEGACY_VALID_REF, - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - noProjectId: true, - }); - return Effect.gen(function* () { - yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( - Effect.provide(layer), - ); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - expect(edgeRunCalls[0]?.binds).toContain( - `supabase_edge_runtime_${LEGACY_VALID_REF}:/root/.cache/deno:rw`, - ); - }); - }, - ); - - it.live("sanitizes an invalid config.toml project_id before naming the pg-delta volume", () => { - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "my app"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - noProjectId: true, - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - // Config validation sanitizes an invalid `project_id` (replacing the - // disallowed run with `_`) once at config-load time, so every later - // reader — including `EdgeRuntimeId` — sees the sanitized form, never - // the raw `"my app"`. - expect(edgeRunCalls[0]?.binds).toContain("supabase_edge_runtime_my_app:/root/.cache/deno:rw"); - }); - }); - - it.live("warns without failing the push when the catalog export fails", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogExportFailWith: "edge-runtime script produced no output", - }); - return Effect.gen(function* () { - const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain( - "Warning: failed to cache migrations catalog: edge-runtime script produced no output", - ); - expect(out.stdoutText).toContain("Finished"); - }); - }); - - it.live( - "resolves the pg-delta cache export image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", - () => { - const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - const { layer, registryEnvAtRunTime } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": - "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\nSUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(registryEnvAtRunTime).toEqual(["my-mirror.example.com"]); - expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; - }), - ), - ); - }, - ); - it.live("returns context canceled when the migration prompt is declined", () => { const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/commands/db/push/push.layers.ts b/apps/cli/src/commands/db/push/push.layers.ts index 092800e4dc..fddcd446c6 100644 --- a/apps/cli/src/commands/db/push/push.layers.ts +++ b/apps/cli/src/commands/db/push/push.layers.ts @@ -9,11 +9,8 @@ import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer. import { legacyDbConfigLayer } from "../../../command-internal/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../command-internal/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../../command-internal/legacy-debug-logger.layer.ts"; -import { legacyDockerRunLayer } from "../../../command-internal/legacy-docker-run.layer.ts"; -import { legacyEdgeRuntimeScriptLayer } from "../../../command-internal/legacy-edge-runtime-script.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { legacyIdentityStitchLayer } from "../../../command-internal/legacy-identity-stitch.ts"; -import { legacyPgDeltaSslProbeLayer } from "../../../command-internal/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; @@ -63,17 +60,9 @@ const dbConfig = legacyDbConfigLayer.pipe( Layer.provide(legacyIdentityStitchLayer), ); -const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( - Layer.provide(legacyDockerRunLayer), - Layer.provide(cliSettings), -); - export const legacyDbPushRuntimeLayer = Layer.mergeAll( dbConfig, legacyDbConnectionLayer, - legacyDockerRunLayer, - edgeRuntime, - legacyPgDeltaSslProbeLayer, cliSettings, httpClient, credentials, diff --git a/apps/cli/src/commands/db/remote/commit/SIDE_EFFECTS.md b/apps/cli/src/commands/db/remote/commit/SIDE_EFFECTS.md index 847b4129b4..a51ddc3614 100644 --- a/apps/cli/src/commands/db/remote/commit/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/remote/commit/SIDE_EFFECTS.md @@ -1,57 +1,70 @@ # `supabase db remote commit` +Deprecated wrapper around native `db pull`. Commits remote schema changes as +`_remote_commit.sql` using the in-process pg-delta (or migra) engine. +`--experimental` / `SUPABASE_EXPERIMENTAL` take the same in-process declarative +export as `db pull --experimental` (Go's `pull.Run` honored that gate for +commit too). Does not print `Finished supabase db pull.` Every invocation +prints cobra's `Command "commit" is deprecated, use "db pull" instead.` to +stderr. + +See [`db/pull/SIDE_EFFECTS.md`](../../pull/SIDE_EFFECTS.md) for the shared +migration-style pull surface (shadow, cache, API, history repair). Differences +from `db pull`: + +- Migration stem is always `remote_commit` (not `remote_schema` or a `--name`). +- No `--declarative` / `--use-pg-delta` / `--diff-engine` / `--local` / `--project-ref`. +- `--linked` defaults to false in the TS flag parser; omitting it still targets + the linked project (same as Go's `--linked` default true). Passing `--linked` + is explicit. +- No Go proxy, no edge-runtime pg-delta, no `pgdelta-version` / `PGDELTA_NPM_REGISTRY`. + ## Files Read -| Path | Format | When | -| -------------------------- | ---------- | ---------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset | +Same as migration-style `db pull`. ## Files Written -| Path | Format | When | -| ------------------------------------------------------------- | ------ | ------ | -| `/supabase/migrations/_remote_commit.sql` | SQL | always | +| Path | Format | When | +| ------------------------------------------------------------- | ------ | ---------------------------------------------------- | +| `/supabase/migrations/_remote_commit.sql` | SQL | non-empty diff (or the initial-migra `pg_dump` seed) | +| `/supabase/schemas/**` | SQL | `--experimental` / `SUPABASE_EXPERIMENTAL` export | +| `/supabase/schemas/.pgdelta-export.json` | JSON | experimental export metadata | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | + +Plus the shared pull post-run writes (linked-project cache, telemetry, shadow +baseline cache). -## API Routes +## API Routes / DB -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +Same as migration-style `db pull`. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +Same as `db pull`. `SUPABASE_EXPERIMENTAL` selects the deprecated in-process +declarative export. ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | schema pull error | +| Code | Condition | +| ---- | -------------------------------------------------------- | +| `0` | success | +| `1` | same as migration-style `db pull` (including empty diff) | ## Output ### `--output-format text` -Prints `Schema written to ` to stderr on success (from the shared -`pull.Run`, `internal/db/pull/pull.go:72`); no stdout confirmation message is -printed. The `Finished supabase db pull.` PostRun message belongs only to -`db pull` (`cmd/db.go:198-200`), not `db remote commit`. - -### `--output-format json` - -Not applicable. +Prints the cobra deprecation line, then `Schema written to ` (or the +declarative export lines) to stderr on success. No stdout confirmation and no +`Finished supabase db pull.` PostRun line. -### `--output-format stream-json` +### `--output-format json` / `stream-json` -Not applicable. +Same envelope as migration-style `db pull`. ## Notes - Deprecated: use `db pull` instead. - `--schema` / `-s` restricts the commit to specific schemas. -- `--db-url` and `--linked` (default true) are mutually exclusive. +- `--db-url` and `--linked` are mutually exclusive. diff --git a/apps/cli/src/commands/db/remote/commit/commit.command.ts b/apps/cli/src/commands/db/remote/commit/commit.command.ts index 5fb94631cd..84dcc3942b 100644 --- a/apps/cli/src/commands/db/remote/commit/commit.command.ts +++ b/apps/cli/src/commands/db/remote/commit/commit.command.ts @@ -1,5 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyParseSchemaFlags } from "../../../../command-internal/legacy-schema-flags.ts"; +import { legacyDbSchemaPullRuntimeLayer } from "../../pull/pull.layers.ts"; import { legacyDbRemoteCommit } from "./commit.handler.ts"; const config = { @@ -7,6 +12,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("Connect using the specified Postgres URL (must be percent-encoded)."), @@ -26,7 +35,24 @@ const config = { export type LegacyDbRemoteCommitFlags = CliCommand.Command.Config.Infer; export const legacyDbRemoteCommitCommand = Command.make("commit", config).pipe( - Command.withDescription("Commit remote changes as a new migration."), + Command.withDescription( + "Deprecated: use db pull instead. Commit remote changes as a new migration.", + ), Command.withShortDescription("Commit remote changes as a new migration"), - Command.withHandler((flags) => legacyDbRemoteCommit(flags)), + Command.withHandler((flags) => + legacyDbRemoteCommit(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + schema: flags.schema, + "db-url": flags.dbUrl, + linked: flags.linked, + password: flags.password, + }, + aliases: { s: "schema", p: "password" }, + config, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbSchemaPullRuntimeLayer(["db", "remote", "commit"])), ); diff --git a/apps/cli/src/commands/db/remote/commit/commit.handler.ts b/apps/cli/src/commands/db/remote/commit/commit.handler.ts index c50a2e5fbc..e72270feac 100644 --- a/apps/cli/src/commands/db/remote/commit/commit.handler.ts +++ b/apps/cli/src/commands/db/remote/commit/commit.handler.ts @@ -1,17 +1,34 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyDbPull } from "../../pull/pull.handler.ts"; +import type { LegacyDbPullFlags } from "../../pull/pull.command.ts"; import type { LegacyDbRemoteCommitFlags } from "./commit.command.ts"; +/** Cobra's former `Deprecated` line on Go `db remote commit`. */ +const REMOTE_COMMIT_DEPRECATION = 'Command "commit" is deprecated, use "db pull" instead.\n'; + +/** `db remote commit` is `db pull` with a fixed name and no PostRun line. */ +export const legacyRemoteCommitToPullFlags = ( + flags: LegacyDbRemoteCommitFlags, +): LegacyDbPullFlags => ({ + name: Option.some("remote_commit"), + declarative: Option.none(), + usePgDelta: Option.none(), + diffEngine: Option.none(), + strictCoverage: false, + schema: flags.schema, + dbUrl: flags.dbUrl, + linked: flags.linked ? Option.some(true) : Option.none(), + local: Option.none(), + projectRef: Option.none(), + password: flags.password, +}); + export const legacyDbRemoteCommit = Effect.fn("legacy.db.remote.commit")(function* ( flags: LegacyDbRemoteCommitFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "remote", "commit"]; - 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 (Option.isSome(flags.password)) args.push("--password", flags.password.value); - yield* proxy.exec(args); + const output = yield* Output; + yield* output.raw(REMOTE_COMMIT_DEPRECATION, "stderr"); + yield* legacyDbPull(legacyRemoteCommitToPullFlags(flags), { skipFinishedLine: true }); }); diff --git a/apps/cli/src/commands/db/remote/commit/commit.unit.test.ts b/apps/cli/src/commands/db/remote/commit/commit.unit.test.ts new file mode 100644 index 0000000000..9f2cf9c9c3 --- /dev/null +++ b/apps/cli/src/commands/db/remote/commit/commit.unit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { Option } from "effect"; + +import { legacyRemoteCommitToPullFlags } from "./commit.handler.ts"; +import type { LegacyDbRemoteCommitFlags } from "./commit.command.ts"; + +const flags = (over: Partial = {}): LegacyDbRemoteCommitFlags => ({ + schema: over.schema ?? [], + dbUrl: over.dbUrl ?? Option.none(), + linked: over.linked ?? false, + password: over.password ?? Option.none(), +}); + +describe("legacyRemoteCommitToPullFlags", () => { + it("leaves --linked unset so pull still targets the linked project by default", () => { + expect(legacyRemoteCommitToPullFlags(flags()).linked).toEqual(Option.none()); + }); + + it("passes --linked only when the flag is set", () => { + expect(legacyRemoteCommitToPullFlags(flags({ linked: true })).linked).toEqual( + Option.some(true), + ); + }); +}); diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index 931e223646..1be9fe7be6 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -46,11 +46,10 @@ removed `LegacyDeclarativeSeam.execInherit` seam — see those commands' own ## Files Written -| Path | Format | When | -| ------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | -| `/supabase/.temp/pgdelta/catalog--migrations--.json` | JSON | best-effort, after migrations/seeding succeed, when no `--version`/`--last` resolved a version AND pg-delta is enabled (`[experimental.pgdelta].enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`) AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false`); the default next engine skips this warmup entirely; a failure only warns on stderr and never fails the reset — see Notes. Native TS on both targets: **remote path** (`` = the project ref/URL hash) after either apply branch (schema-files or migrations); **local path** (`` = `"local"`) PG15 only, via the reused `legacyStartSetupLocalDatabase` pipeline (`db-setup.ts`) after `MigrateAndSeed` — the PG≤14 branch never calls this at all, so a PG≤14 local project never writes this file regardless of pg-delta config | +| Path | Format | When | +| ------------------------------------------------ | ------ | --------------------------------- | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | On the local path, the native recreate additionally recreates the `supabase_db_` container/volume (PG15) or the `postgres`/`_supabase` @@ -128,22 +127,19 @@ the whole reset** (not just "skip buckets"). ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | -| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | -| `SUPABASE_EXPERIMENTAL` | selects the schema-files apply branch on either target | no (also `--experimental`) | -| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled`; a truthy value flips the reset gate (`experimental && resolvedVersion === "" && !toml.pgDelta.enabled`) back to timestamped migrations even with `--experimental` set — switches between two different destructive code paths | no | -| `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` | overrides `[db.migrations].schema_paths` (viper `AutomaticEnv`, beats the config-file value) for the schema-files apply branch — genuinely effective on both targets now | no (no dedicated flag — config-file-only otherwise) | -| `SUPABASE_PROJECT_ID` | overrides the local container id; ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-reset migrations-catalog cache (see Files Written) when `[experimental.pgdelta].enabled` is unset — distinct from `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` above, which switches the reset's own apply branch instead | no (project `.env` or shell) | -| `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache (unset/unrecognized defaults to the next engine, which skips it); shell presence wins over project `.env`, even an empty shell value | no (project `.env` or shell) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the pg-delta edge-runtime image registry for the migrations-catalog cache export (scoped for the whole run via `legacyApplyProjectEnv`, matching `db push`) | no (project `.env` or shell) | -| `SUPABASE_USE_SLIM_IMAGES` | resolves the local-reset Postgres image, realtime/storage/auth migrate-job images, and the pg-delta edge-runtime catalog-export image from slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, flag-off `15.8.1.085`, `deno_version = 1`, and historical `.temp/edge-runtime-version` pins stay on docker.io | no (ambient shell only) | -| `PGDELTA_NPM_REGISTRY` | overrides the pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward) for the migrations-catalog cache export (scoped for the whole run via `legacyApplyProjectEnv`, matching `db push`) | no (project `.env` or shell) | -| `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | -| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no | +| Variable | Purpose | Required? | +| ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | +| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL` | selects the schema-files apply branch on either target | no (also `--experimental`) | +| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled`; a truthy value flips the reset gate (`experimental && resolvedVersion === "" && !toml.pgDelta.enabled`) back to timestamped migrations even with `--experimental` set — switches between two different destructive code paths | no | +| `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` | overrides `[db.migrations].schema_paths` (viper `AutomaticEnv`, beats the config-file value) for the schema-files apply branch — genuinely effective on both targets now | no (no dedicated flag — config-file-only otherwise) | +| `SUPABASE_PROJECT_ID` | overrides the local container id; ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the image registry used to resolve the local path's container images (scoped for the whole run via `legacyApplyProjectEnv`) | no (project `.env` or shell) | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the local-reset Postgres image and the realtime/storage/auth migrate-job images from slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, and flag-off `15.8.1.085` stay on docker.io | no (ambient shell only) | +| `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no | ## Connection loss during migration apply @@ -259,31 +255,6 @@ path has no confirmation prompt. decrypts them into `toml.vault`, and `legacyUpsertVaultSecrets` upserts the decrypted values unconditionally, before either branch (schema-files or migrations) runs. -- **Migrations catalog cache**: gated on no `--version`/`--last` having resolved - a version, pg-delta being enabled (`[experimental.pgdelta].enabled` or - `SUPABASE_EXPERIMENTAL_PG_DELTA` — see Environment Variables), AND the legacy engine - being selected (`SUPABASE_USE_PG_DELTA_NEXT=false`; the default next engine skips - this warmup entirely); a versioned reset - never refreshes the cache. A failure only warns on stderr and never fails - the reset. Writes under `supabase/.temp/pgdelta/` (see Files - Written), pruning older snapshots for the same prefix (retains 2). Native TS on - BOTH paths now, on different call chains: - - **Remote path** (ported CLI-1958): after either apply branch - (schema-files or migrations) and seeding complete. Exports the target's pg-delta - catalog via the edge-runtime stack. Reuses `legacyExportCatalogPgDelta` and - `legacyTryCacheMigrationsCatalog` — the same helpers `db push` uses for its own - post-apply cache (see that command's SIDE_EFFECTS Notes) — rather than a second - copy. - - **Local path** (native since CLI-1955/2062, no Go child involved): the reused - `legacyStartSetupLocalDatabase` pipeline (`db-setup.ts`) calls the same - `legacyTryCacheMigrationsCatalog` (with prefix `"local"`) right after - `MigrateAndSeed` succeeds, warning the same way on failure. `reset.layers.ts` - composes `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` for this — - the same pair `db start`/`db push` already compose for their own calls into the - same function. This only happens on the **PG15** recreate branch — the - **PG≤14** branch returns immediately after `MigrateAndSeed` and never calls - `legacyTryCacheMigrationsCatalog` at all, so a PG≤14 local project never writes - this file, no matter how pg-delta is configured. - `db schema declarative`/`db schema sync`'s own local-reset paths now call `legacyResetLocalDatabase` in-process too (CLI-2062) — the previous scope boundary (those two commands shelling out to a second `supabase-go` child via the now-removed diff --git a/apps/cli/src/commands/db/reset/reset.handler.ts b/apps/cli/src/commands/db/reset/reset.handler.ts index 2b5c58ce17..9e3846bf16 100644 --- a/apps/cli/src/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/commands/db/reset/reset.handler.ts @@ -14,8 +14,6 @@ import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.ser import { legacyAqua, legacyYellow } from "../../../command-internal/legacy-colors.ts"; import { legacyResolveResetSeedConfig } from "../../../command-internal/db-bootstrap/db-setup.ts"; import { legacyResetLocalDatabase } from "../../../command-internal/db-bootstrap/reset-local-database.ts"; -import { legacyParseBoolEnv } from "../../../command-internal/legacy-diff-engine.ts"; -import { redactLegacyConnectionString } from "../../../command-internal/legacy-db-config.parse.ts"; import { LegacyDbConfigResolver } from "../../../command-internal/legacy-db-config.service.ts"; import { legacyApplyProjectEnv, @@ -23,22 +21,13 @@ import { legacyLoadProjectEnv, } from "../../../command-internal/legacy-db-config.toml-read.ts"; import { LegacyDbConnection } from "../../../command-internal/legacy-db-connection.service.ts"; -import { - legacyResolveLocalProjectId, - legacySanitizeProjectId, -} from "../../../command-internal/legacy-docker-ids.ts"; import { legacyApplyMigrations, legacyApplySchemaFiles, } from "../../../command-internal/legacy-migration-apply.ts"; import { legacyParseMigrationVersion } from "../../../command-internal/legacy-migration-timestamp.format.ts"; -import { - legacyListLocalMigrations, - legacyTryCacheMigrationsCatalog, -} from "../../../command-internal/legacy-pgdelta.cache.ts"; -import { type LegacyPgDeltaContext } from "../../../command-internal/legacy-pgdelta.ts"; +import { legacyListLocalMigrations } from "../../../command-internal/legacy-migration-list.ts"; import { legacyPathMatch } from "../../../command-internal/legacy-path-match.ts"; -import { legacyToPostgresURL } from "../../../command-internal/legacy-postgres-url.ts"; import { resolveLegacyDbTargetFlags } from "../../../command-internal/legacy-db-target-flags.ts"; import { legacyGetPendingSeeds, @@ -112,12 +101,12 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega const body = Effect.gen(function* () { // The project `.env` is applied to make every key visible to the WHOLE // reset run, not just the flag-gate reads above — in particular - // `legacyGetRegistryImageUrl` / `legacyPgDeltaNpmRegistryOption` read - // `SUPABASE_INTERNAL_IMAGE_REGISTRY` / `PGDELTA_NPM_REGISTRY` straight from - // `process.env` for the pg-delta catalog export below (review CLI-1958). `db push` + // `legacyGetRegistryImageUrl` reads + // `SUPABASE_INTERNAL_IMAGE_REGISTRY` straight from + // `process.env` for the container image resolution below (review CLI-1958). `db push` // (`push.handler.ts`) scopes this the same way, as the first statement of its own // `body` — mirror that exactly so a private/air-gapped registry configured only in - // `supabase/.env` reaches the catalog export instead of silently falling back to the + // `supabase/.env` reaches image resolution instead of silently falling back to the // default registries. yield* legacyApplyProjectEnv(projectEnv); const target = resolveLegacyDbTargetFlags(cliArgs.args); @@ -378,46 +367,6 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega ); yield* legacySeedData(session, fs, workdir, path, seeds, applyError); } - - // Best-effort caches the migrations catalog for pg-delta right after - // the migrate-and-seed step succeeds, warning (never failing the - // reset) on error. The cache call itself no-ops when `resolvedVersion` - // is non-empty — a versioned reset (`--version`/`--last`) never - // refreshes the cache — so gate the call the same way rather than - // threading that check into the shared native helper (already used - // by `db push`, which has no version concept). - const cacheEnabled = - resolvedVersion === "" && - (toml.pgDelta.enabled || - legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA"))); - const pgDeltaCtx: LegacyPgDeltaContext = { - projectId: legacySanitizeProjectId( - legacyResolveLocalProjectId( - Option.getOrUndefined(cliSettings.projectId), - Option.getOrUndefined(toml.projectId) ?? - (linkedRef !== undefined && linkedRef !== "" ? linkedRef : undefined), - workdir, - ), - ), - cwd: workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), - denoVersion: toml.denoVersion, - projectEnv: toml.projectEnv, - }; - yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { - enabled: cacheEnabled, - targetUrl: legacyToPostgresURL(cfg.conn), - conn: cfg.conn, - isLocal: false, - migrationsDir, - }).pipe( - Effect.catch((error) => - output.raw( - `Warning: failed to cache migrations catalog: ${redactLegacyConnectionString(error.message)}\n`, - "stderr", - ), - ), - ); }), ); diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index 8a2d43624a..65ec6bd129 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -41,12 +41,6 @@ import { } from "../../../shared/legacy/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { legacyDockerRunLayer } from "../../../command-internal/legacy-docker-run.layer.ts"; -import { LegacyEdgeRuntimeScriptError } from "../../../command-internal/legacy-edge-runtime-script.errors.ts"; -import { - LegacyEdgeRuntimeScript, - type LegacyEdgeRuntimeRunOpts, -} from "../../../command-internal/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyDbConfigResolver } from "../../../command-internal/legacy-db-config.service.ts"; import type { LegacyDbConfigFlags, @@ -438,10 +432,6 @@ function setup( replicationSlotCounts?: ReadonlyArray; replicationSlotQueryFails?: boolean; failStatement?: { readonly sql: string; readonly code?: string; readonly message: string }; - // pg-delta migrations-catalog cache, wired into the remote-reset path - // after a successful migrate/schema-files + seed. - catalogStdout?: string; - catalogExportFailWith?: string; // Simulates a genuinely unlinked workdir: `loadProjectRef` fails with // `LegacyProjectNotLinkedError` absent an explicit `--project-ref` flag, // instead of silently falling back to `opts.ref ?? LEGACY_VALID_REF`. @@ -473,30 +463,6 @@ function setup( }); const route = opts.route ?? defaultLocalResetRoute(opts.routeOpts); const child = mockContainerCliSpawner(route); - // Backs both the local recreate's post-setup pg-delta migrations-catalog warmup - // (`db-setup.ts`'s `legacyTryCacheMigrationsCatalog`) and the remote path's own - // post-reset catalog-cache call — tracked so tests can assert on it directly - // (`edgeRunCalls`/`registryEnvAtRunTime`), same as `db push`'s own integration - // tests (`push.integration.test.ts`). - const edgeRunCalls: Array = []; - const registryEnvAtRunTime: Array = []; - const edgeRuntime = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - edgeRunCalls.push(runOpts); - registryEnvAtRunTime.push(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); - if (opts.catalogExportFailWith !== undefined) { - return Effect.fail( - new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), - ); - } - return Effect.succeed({ stdout: opts.catalogStdout ?? '{"version":1}', stderr: "" }); - }, - }); - const pgDeltaSslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); - const layer = Layer.mergeAll( out.layer, conn.layer, @@ -511,8 +477,6 @@ function setup( Layer.provide(child.layer), Layer.provide(mockProcessControl().layer), ), - edgeRuntime, - pgDeltaSslProbe, Layer.succeed(LegacyNetworkIdFlag, Option.none()), // The remote-reset confirmation is answered through mockOutput's // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is @@ -555,8 +519,6 @@ function setup( linkedCache, resolver, child, - edgeRunCalls, - registryEnvAtRunTime, }; } @@ -1694,139 +1656,13 @@ describe("legacy db reset", () => { }); }); - it.live( - "caches the migrations catalog after a successful remote reset with SUPABASE_EXPERIMENTAL_PG_DELTA set", - () => { - // Best-effort caches the pg-delta migrations catalog right after the - // migrate-and-seed step succeeds — gated on - // `experimental.pgdelta.enabled` OR the legacy - // `SUPABASE_EXPERIMENTAL_PG_DELTA` env switch, independent of - // `--experimental`'s own schema-files gate. - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_EXPERIMENTAL_PG_DELTA=true\n", - }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - }); - }, - ); - - it.live( - "resolves the pg-delta cache export image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", - () => { - // The project `.env` is applied to make a `supabase/.env`-only - // `SUPABASE_INTERNAL_IMAGE_REGISTRY` visible to the WHOLE reset run, - // including the pg-delta catalog export the reset handler triggers - // after a successful remote reset (review CLI-1958 round 18) — - // mirroring `db push`'s own `legacyApplyProjectEnv(projectEnv)` - // scoping (same-named test in `push.integration.test.ts`). Without - // that scoping, this reads only real `process.env` and falls back to - // the default registry instead. - const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - const { layer, registryEnvAtRunTime } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", - }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(registryEnvAtRunTime).toEqual(["my-mirror.example.com"]); - // The finalizer reverted it — never leaks into the surrounding process. - expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; - }), - ), - ); - }, - ); - - it.live("warns without failing the reset when the migrations-catalog cache write fails", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: migrationFile("20240101000000"), - confirm: [true], - catalogExportFailWith: "edge-runtime script produced no output", - }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain( - "Warning: failed to cache migrations catalog: edge-runtime script produced no output", - ); - }); - }); - - it.live( - "falls back to the linked project ref for the pg-delta cache when config.toml has no project_id", - () => { - // The project id seeds from the ref BEFORE the config loads, so on - // the linked remote path an absent `project_id` retains the linked - // ref rather than falling to the workdir basename. - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: "[experimental.pgdelta]\nenabled = true\n", - ref: LEGACY_VALID_REF, - files: migrationFile("20240101000000"), - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - }); - }, - ); - - it.live( - "skips the migrations-catalog cache for a versioned remote reset even with pg-delta caching enabled", - () => { - // `pgcache.TryCacheMigrationsCatalog` no-ops on any non-empty `version` - // (`pgcache/cache.go:73`, `len(version) > 0`) — a `--version`/`--last` reset - // never refreshes the cache, unlike a full (versionless) reset. - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - ...migrationFile("20240202000000"), - }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - version: Option.some("20240101000000"), - }).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(0); - }); - }, - ); - it.live( "applies configured schema files instead of replaying migrations on an experimental remote reset", () => { // `--linked=false` still selects the linked/remote target (Cobra `Changed` // semantics) — exercised here alongside the schema-files branch itself. const { layer, out, conn, resolver, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schemas/01_users.sql": "create table schema_users ();", ...migrationFile("20240101000000", "create table migrated_table ();"), diff --git a/apps/cli/src/commands/db/reset/reset.layers.ts b/apps/cli/src/commands/db/reset/reset.layers.ts index 371b127f2f..77dc81f4dc 100644 --- a/apps/cli/src/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/commands/db/reset/reset.layers.ts @@ -10,8 +10,6 @@ import { legacyDbConfigLayer } from "../../../command-internal/legacy-db-config. import { legacyDbConnectionLayer } from "../../../command-internal/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../../command-internal/legacy-debug-logger.layer.ts"; import { legacyDockerRunLayer } from "../../../command-internal/legacy-docker-run.layer.ts"; -import { legacyEdgeRuntimeScriptLayer } from "../../../command-internal/legacy-edge-runtime-script.layer.ts"; -import { legacyPgDeltaSslProbeLayer } from "../../../command-internal/legacy-pgdelta-ssl-probe.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { legacyIdentityStitchLayer } from "../../../command-internal/legacy-identity-stitch.ts"; import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; @@ -29,24 +27,11 @@ import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-s * `legacyDockerRunLayer` backs the native local recreate's PG15+ one-shot migrate * jobs (`legacyStartSetupLocalDatabase`, reused via `legacyRecreateLocalDatabase`) * — same reasoning as `db start`'s own `start.layers.ts`. - * `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` back that same shared - * setup pipeline's best-effort pg-delta migrations-catalog warmup (`db-setup.ts`'s - * `legacyTryCacheMigrationsCatalog` call, reachable from `db reset`'s PG15 recreate - * too) AND the remote path's own post-reset catalog-cache call — the exact same - * pair `db start`/`db push` already compose for their own calls to that function - * (`db/start/start.layers.ts`, `push.layers.ts`). Without them, a versionless reset - * with pg-delta enabled would hit an unhandled missing-service defect — not caught - * by the handler's typed `Effect.catch` — AFTER the database has already been - * reset, instead of writing the catalog or emitting the established best-effort - * warning (review CLI-1958). `LegacyCliSettings`/`ChildProcessSpawner`/`FileSystem`/`Path`/ - * `RuntimeInfo` are ambient from the root runtime (`shared/cli/run.ts`). + * `LegacyCliSettings`/`ChildProcessSpawner`/`FileSystem`/`Path`/`RuntimeInfo` are + * ambient from the root runtime (`shared/cli/run.ts`). */ const cliSettings = legacyCliSettingsLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); -const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( - Layer.provide(legacyDockerRunLayer), - Layer.provide(cliSettings), -); const credentials = legacyCredentialsLayer.pipe( Layer.provide(cliSettings), Layer.provide(legacyDebugLoggerLayer), @@ -97,10 +82,7 @@ export const legacyDbResetRuntimeLayer = Layer.mergeAll( // without it a CI/piped remote `db reset` that reaches the confirmation // prompt fails with a missing-service defect instead of the default. stdinLayer, - // Backs the native local recreate's PG15+ one-shot migrate jobs, and the remote - // path's own post-reset pg-delta catalog-cache call. + // Backs the native local recreate's PG15+ one-shot migrate jobs. legacyDockerRunLayer, - edgeRuntime, - legacyPgDeltaSslProbeLayer, commandRuntimeLayer(["db", "reset"]), ); diff --git a/apps/cli/src/commands/db/reset/reset.layers.unit.test.ts b/apps/cli/src/commands/db/reset/reset.layers.unit.test.ts deleted file mode 100644 index 87a1260736..0000000000 --- a/apps/cli/src/commands/db/reset/reset.layers.unit.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Layer-exposure test for `legacyDbResetRuntimeLayer`. - * - * Regression guard (review CLI-1958): the post-reset best-effort pg-delta - * catalog cache (`legacyTryCacheMigrationsCatalog` in `reset.handler.ts`, gated - * on `[experimental.pgdelta].enabled` / `SUPABASE_EXPERIMENTAL_PG_DELTA`) reaches - * `LegacyEdgeRuntimeScript` and `LegacyPgDeltaSslProbe` via - * `legacyExportCatalogPgDelta` (`legacy-pgdelta.ts`). `legacyDbResetRuntimeLayer` - * previously omitted both services (and the `LegacyDockerRun` layer the real - * edge-runtime implementation needs) — unlike `legacyDbPushRuntimeLayer`, which - * already composes all three. That gap was invisible to `reset.integration.test.ts` - * because that suite drives `legacyDbReset` directly with its own hand-built layer - * (which mocks `LegacyEdgeRuntimeScript`/`LegacyPgDeltaSslProbe` in), bypassing - * `reset.layers.ts` entirely — so a versionless remote reset with pg-delta enabled - * would crash on a missing-service defect (uncaught by the handler's typed - * `Effect.catch`) AFTER the remote database was already reset. This test builds - * the REAL `legacyDbResetRuntimeLayer` (not a mock of the pg-delta services) and - * asserts both are actually present in its context. - * - * See `db/lint/lint.layers.unit.test.ts` for the canonical ambient-stub pattern. - */ - -import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option } from "effect"; - -import { - mockAnalytics, - mockOutput, - mockProcessControl, - mockRuntimeInfo, - mockStdin, - mockTelemetryRuntime, - mockTty, -} from "../../../../tests/helpers/mocks.ts"; -import { - mockLegacyCliSettings, - mockLegacyCredentialsLayer, - mockLegacyLinkedProjectCacheLayer, - mockLegacyTelemetryStateLayer, -} from "../../../../tests/helpers/legacy-mocks.ts"; - -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { - LegacyDebugFlag, - LegacyDnsResolverFlag, - LegacyExperimentalFlag, - LegacyNetworkIdFlag, - LegacyOutputFlag, - LegacyProfileFlag, - LegacyWorkdirFlag, -} from "../../../shared/legacy/global-flags.ts"; - -import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; -import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; -import { LegacyDbConfigResolver } from "../../../command-internal/legacy-db-config.service.ts"; -import { LegacyDbConnection } from "../../../command-internal/legacy-db-connection.service.ts"; -import { LegacyEdgeRuntimeScript } from "../../../command-internal/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; - -import { legacyDbResetRuntimeLayer } from "./reset.layers.ts"; - -/** - * Builds a stub ambient layer that satisfies every external service required by - * `legacyDbResetRuntimeLayer` from the root runtime. Services whose logic is not - * under test are no-op stubs; `LegacyEdgeRuntimeScript` and `LegacyPgDeltaSslProbe` - * are deliberately NOT stubbed here — the point of this test is to prove the real - * `legacyDbResetRuntimeLayer` provides them itself. - */ -function ambientStubs() { - const analytics = mockAnalytics(); - const out = mockOutput(); - - const flagLayers = Layer.mergeAll( - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(LegacyProfileFlag, "supabase"), - Layer.succeed(LegacyWorkdirFlag, Option.none()), - Layer.succeed(LegacyOutputFlag, Option.none()), - Layer.succeed(LegacyDnsResolverFlag, "native"), - Layer.succeed(LegacyNetworkIdFlag, Option.none()), - Layer.succeed(LegacyExperimentalFlag, false), - Layer.succeed(CliArgs, { args: ["db", "reset"] }), - ); - - // Stub out the heavy service layers so layer construction doesn't require a - // real DB, real API, or real credentials. - const heavyServiceStubs = Layer.mergeAll( - Layer.succeed(LegacyDbConnection, { - connect: () => Effect.die("db-connection not needed for layer-exposure test"), - }), - Layer.succeed(LegacyDbConfigResolver, { - resolve: () => Effect.die("db-config-resolver not needed for layer-exposure test"), - resolvePoolerFallback: () => - Effect.die("db-config-resolver not needed for layer-exposure test"), - }), - Layer.succeed(LegacyProjectRefResolver, { - resolve: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - resolveForLink: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - resolveOptional: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - loadProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - promptProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - }), - Layer.succeed(LegacyPlatformApiFactory, { - make: Effect.die("platform-api-factory not needed for layer-exposure test"), - }), - ); - - return Layer.mergeAll( - BunServices.layer, - mockRuntimeInfo(), - mockTty(), - mockProcessControl().layer, - mockStdin(false), - analytics.layer, - mockTelemetryRuntime(), - out.layer, - flagLayers, - mockLegacyCliSettings({ workdir: "/tmp/reset-layers-test" }), - mockLegacyCredentialsLayer, - mockLegacyLinkedProjectCacheLayer, - mockLegacyTelemetryStateLayer, - heavyServiceStubs, - ); -} - -describe("legacyDbResetRuntimeLayer — pg-delta service exposure (regression guard, review CLI-1958)", () => { - it.live( - "exposes LegacyEdgeRuntimeScript so the post-reset pg-delta catalog cache does not crash on a missing-service defect", - () => { - return Effect.gen(function* () { - const edgeRuntime = yield* Effect.serviceOption(LegacyEdgeRuntimeScript); - expect(Option.isSome(edgeRuntime)).toBe(true); - }).pipe(Effect.provide(legacyDbResetRuntimeLayer), Effect.provide(ambientStubs())); - }, - ); - - it.live( - "exposes LegacyPgDeltaSslProbe so the post-reset pg-delta catalog cache does not crash on a missing-service defect", - () => { - return Effect.gen(function* () { - const sslProbe = yield* Effect.serviceOption(LegacyPgDeltaSslProbe); - expect(Option.isSome(sslProbe)).toBe(true); - }).pipe(Effect.provide(legacyDbResetRuntimeLayer), Effect.provide(ambientStubs())); - }, - ); -}); diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts index c8a7e3cb87..175df5d66f 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts @@ -1,4 +1,3 @@ -import type { LegacyPgDeltaImplementation } from "../../../../command-internal/legacy-pgdelta-next-flag.ts"; import { legacySchemaToCsvField } from "../../../../command-internal/legacy-schema-flags.ts"; import { legacyDeclaredSqlExtensions, @@ -73,13 +72,12 @@ const emptyCompatibilityGap = (): LegacyDeclarativeCompatibilityGap => ({ recommendedAction: "none", }); -/** Classifies manifest-less pg-delta next removals without performing any I/O. */ +/** Classifies manifest-less pg-delta removals without performing any I/O. */ export function legacyClassifyDeclarativeCompatibilityGap(opts: { - readonly implementation: LegacyPgDeltaImplementation; readonly manifestPresent: boolean; readonly removals: LegacyPgDeltaRemovalSummary; }): LegacyDeclarativeCompatibilityGap { - if (opts.implementation !== "next" || opts.manifestPresent) return emptyCompatibilityGap(); + if (opts.manifestPresent) return emptyCompatibilityGap(); const extensions = [...new Set(opts.removals.extensions)].sort(); const repairableExtensions = extensions.filter((extension) => @@ -202,15 +200,14 @@ function locateSignature( /** * Classifies known legacy implicit-extension misses that prevent a manifestless - * declarative tree from loading on pg-delta next's isolated desired shadow. + * declarative tree from loading on pg-delta's isolated desired shadow. */ export function legacyClassifyDeclarativeLoadCompatibility(opts: { - readonly implementation: LegacyPgDeltaImplementation; readonly manifestPresent: boolean; readonly diagnostics: readonly LegacyDeclarativeLoadDiagnostic[]; readonly files: readonly LegacyDeclarativeSqlFile[]; }): ReadonlyArray { - if (opts.implementation !== "next" || opts.manifestPresent) return []; + if (opts.manifestPresent) return []; const declared = declaredImplicitExtensions(opts.files); const findings: LegacyDeclarativeLoadCompatibilityFinding[] = []; diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.flow.unit.test.ts b/apps/cli/src/commands/db/schema/declarative/declarative.flow.unit.test.ts index 7d35d95c97..4ff8d6a722 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.flow.unit.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.flow.unit.test.ts @@ -30,7 +30,6 @@ const classifyGap = ( overrides: Partial[0]> = {}, ) => legacyClassifyDeclarativeCompatibilityGap({ - implementation: "next", manifestPresent: false, removals, ...overrides, @@ -40,7 +39,6 @@ const classifyLoad = ( overrides: Partial[0]>, ) => legacyClassifyDeclarativeLoadCompatibility({ - implementation: "next", manifestPresent: false, diagnostics: [], files: [], @@ -97,11 +95,6 @@ describe("legacyClassifyDeclarativeCompatibilityGap", () => { overrides: { manifestPresent: true }, expected: { recommendedAction: "none" }, }, - { - name: "leaves legacy behavior unchanged", - overrides: { implementation: "legacy" as const }, - expected: { recommendedAction: "none" }, - }, { name: "ignores an empty removal set", overrides: { removals: { extensions: [], extensionIntents: [] } }, @@ -342,28 +335,23 @@ describe("legacyClassifyDeclarativeLoadCompatibility", () => { }); }); - it("requires next, no manifest, and an error-level non-converging diagnostic", () => { + it("requires no manifest and an error-level non-converging diagnostic", () => { const files = [{ name: "members.sql", sql: "select extensions.uuid_generate_v4();" }]; const diagnostic = stuck("members.sql: function extensions.uuid_generate_v4() does not exist"); const classify = ( - implementation: "legacy" | "next", manifestPresent: boolean, diagnostics: ReadonlyArray<{ code: string; severity: string; message: string }>, ) => classifyLoad({ - implementation, manifestPresent, diagnostics, files, }); - expect(classify("legacy", false, [diagnostic])).toEqual([]); - expect(classify("next", true, [diagnostic])).toEqual([]); - expect(classify("next", false, [{ ...diagnostic, severity: "warning" }])).toEqual([]); - expect(classify("next", false, [{ ...diagnostic, code: "invalid_routine_body" }])).toEqual([]); - expect(classify("next", false, [{ ...diagnostic, code: "max_rounds_exceeded" }])).toHaveLength( - 1, - ); + expect(classify(true, [diagnostic])).toEqual([]); + expect(classify(false, [{ ...diagnostic, severity: "warning" }])).toEqual([]); + expect(classify(false, [{ ...diagnostic, code: "invalid_routine_body" }])).toEqual([]); + expect(classify(false, [{ ...diagnostic, code: "max_rounds_exceeded" }])).toHaveLength(1); }); it("does not classify an extension already declared anywhere in the tree", () => { diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 7c793c0814..9bb12bb75c 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -1,162 +1,25 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; -import { - mockLegacyShadowContainerCliSpawner, - useLegacyShadowCacheDisabled, -} from "../../../../../tests/helpers/legacy-mocks.ts"; -import { alwaysReadyHttpClientLayer } from "../../../../../tests/helpers/legacy-local-reset.ts"; -import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; -import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { - LegacyDebugFlag, - LegacyExperimentalFlag, - LegacyNetworkIdFlag, -} from "../../../../shared/legacy/global-flags.ts"; import type { LegacyDbTomlValues } from "../../../../command-internal/legacy-db-config.toml-read.ts"; -import { - LegacyDbConnection, - type LegacyDbSession, - type LegacyPgConnInput, -} from "../../../../command-internal/legacy-db-connection.service.ts"; -import { LegacyDockerRun } from "../../../../command-internal/legacy-docker-run.service.ts"; -import { - type LegacyEdgeRuntimeRunOpts, - LegacyEdgeRuntimeScript, -} from "../../../../command-internal/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; -import { legacyPgDeltaLegacyEngineLayer } from "../../shared/legacy-pgdelta-engine.legacy.layer.ts"; import { LegacyPgDeltaEngine, type LegacyPgDeltaDeclarativePlanInput, } from "../../shared/legacy-pgdelta-engine.service.ts"; -import { - legacyBaselineCatalogFileName, - legacyBaselineCatalogKey, - legacyHashMigrations, - legacyMigrationCatalogFileName, - legacyMigrationsCatalogCacheKey, - legacySetupInputsToken, - type LegacySetupInputs, -} from "../../../../command-internal/legacy-pgdelta.cache.ts"; -import { - type LegacyCatalogMode, - LegacyDeclarativeSeam, -} from "../../shared/legacy-pgdelta.seam.service.ts"; import { type LegacyDeclarativeRunContext, legacyDiffDeclarativeToMigrations, legacyGenerateDeclarativeOutput, } from "./declarative.orchestrate.ts"; -function mockSeam(paths: Record) { - const calls: Array<{ mode: LegacyCatalogMode; noCache: boolean }> = []; - const layer = Layer.succeed(LegacyDeclarativeSeam, { - exportCatalog: ({ mode, noCache }) => { - calls.push({ mode, noCache }); - return Effect.succeed(paths[mode]); - }, - ensureLocalDatabaseStarted: () => Effect.void, - ensureLocalPostgresImageCurrent: () => Effect.void, - }); - return { layer, calls }; -} - -/** - * The native shadow-provisioning stack `legacyGetMigrationsCatalogRef`'s - * cache-miss path needs (CLI-1956): the SAME `legacyCreateShadowDatabase`/ - * `legacyPrepareShadowSource`/`legacyRemoveShadowDatabase` primitives `db diff`/ - * `db pull` use for their own shadow, not the retired `db __shadow` seam — see - * `legacy-pgdelta.cache.ts`'s `exportViaShadowCatalog` doc comment. Mirrors - * `diff.integration.test.ts`'s own shadow mocks (`mockLegacyShadowContainerCliSpawner` - * + a fake `LegacyDbConnection`/`LegacyDockerRun`), scoped down to this file's - * lower-level, seam-free tests. - */ -function mockShadowInfra() { - const spawner = mockLegacyShadowContainerCliSpawner(); - const connectedDatabases: Array = []; - const dbConnection = Layer.succeed(LegacyDbConnection, { - connect: (cfg: LegacyPgConnInput) => - Effect.sync(() => { - connectedDatabases.push(cfg.database); - const session: LegacyDbSession = { - exec: () => Effect.void, - execBatch: () => Effect.void, - query: () => Effect.succeed([]), - extensionExists: () => Effect.succeed(false), - copyToCsv: () => Effect.succeed(new Uint8Array()), - queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - }; - return session; - }), - }); - // The shadow's own PG15+ one-shot platform-baseline job(s) — Go's `initSchema15`. - const docker = Layer.succeed(LegacyDockerRun, { - run: () => Effect.die("run unused"), - runCapture: () => Effect.die("runCapture unused"), - runStream: () => Effect.succeed({ exitCode: 0, stderr: "" }), - }); - const layer = Layer.mergeAll( - spawner.layer, - dbConnection, - docker, - mockRuntimeInfo(), - Layer.succeed(LegacyNetworkIdFlag, Option.none()), - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(LegacyExperimentalFlag, false), - Layer.succeed(CliArgs, { args: [] }), - alwaysReadyHttpClientLayer, - ); - return { layer, spawned: spawner.spawned, connectedDatabases }; -} - -function mockEdge(stdout: string) { - const calls: LegacyEdgeRuntimeRunOpts[] = []; - const layer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (opts: LegacyEdgeRuntimeRunOpts) => { - calls.push(opts); - // The catalog-export script (uniquely identified by its errPrefix) backs the - // native migrations-catalog resolution's shadow export — return a fixed, - // non-empty snapshot so it never trips `legacyExportCatalogPgDelta`'s - // empty-output check regardless of what `stdout` the diff/export scripts use. - if (opts.errPrefix === "error exporting pg-delta catalog") { - return Effect.succeed({ stdout: '{"schemas":[]}', stderr: "" }); - } - // The pg-delta diff script (uniquely identified by `renderPlanFiles`) prints a - // JSON envelope with one file per plan unit; wrap the test's raw SQL into a - // single-unit envelope so `legacyDiffPgDelta` parses it. Other scripts - // (declarative export) return their stdout unchanged. - const wrapped = - opts.script.includes("renderPlanFiles") && stdout.length > 0 - ? JSON.stringify({ - version: 1, - files: [ - { order: 1, name: "schema_changes", transactionMode: "transactional", sql: stdout }, - ], - }) - : stdout; - return Effect.succeed({ stdout: wrapped, stderr: "" }); - }, - }); - return { layer, calls }; -} - -// Remote refs in these tests are non-Supabase hosts that refuse TLS → probe -// reports "not required", so no CA bundle/SSL env is injected. -const probe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), -}); - const ctx = (cwd: string, declarativeDir: string): LegacyDeclarativeRunContext => ({ pgDelta: { projectId: "cferry", cwd, - npmVersion: undefined, denoVersion: 2, projectEnv: {}, }, @@ -170,15 +33,43 @@ const ctx = (cwd: string, declarativeDir: string): LegacyDeclarativeRunContext = dnsResolver: "native", }); -const engineLayer = ( - seam: Layer.Layer, - edge: Layer.Layer, - output: ReturnType["layer"], - runtime: ReturnType["layer"], -) => - legacyPgDeltaLegacyEngineLayer.pipe( - Layer.provide(Layer.mergeAll(seam, edge, probe, output, BunServices.layer, runtime)), - ); +// A minimal, valid `LegacyDbTomlValues` — matches `legacy-db-config.toml-read.ts`'s +// own unconfigured defaults so this fixture doesn't silently drift from what +// `legacyReadDbToml` would resolve for these tests' bare temp dirs (none of them +// write a `config.toml`). +const toml: LegacyDbTomlValues = { + projectEnv: {}, + envLookup: () => undefined, + apiSchemas: ["public", "graphql_public"], + port: 54322, + shadowPort: 54320, + password: "postgres", + poolerConnectionString: Option.none(), + projectId: Option.none(), + majorVersion: 17, + orioledbVersion: Option.none(), + denoVersion: 2, + pgDelta: { + enabled: false, + declarativeSchemaPath: Option.none(), + formatOptions: Option.none(), + }, + webhooksEnabled: false, + baseline: { + authEnabled: true, + storageEnabled: true, + realtimeEnabled: true, + apiAutoExposeNewTables: Option.none(), + vaultNames: [], + }, + migrationsEnabled: true, + schemaPaths: [], + schemaPathPatterns: [], + seed: { enabled: true, sqlPaths: [] }, + vault: [], + appliedRemote: undefined, + remoteOverrideKeys: new Set(), +}; describe("legacyDiffDeclarativeToMigrations", () => { it.effect("loads nested SQL and its manifest in stable order for the engine", () => { @@ -196,7 +87,6 @@ describe("legacyDiffDeclarativeToMigrations", () => { const engine = Layer.succeed( LegacyPgDeltaEngine, LegacyPgDeltaEngine.of({ - implementation: "next", diffExplicit: () => Effect.die("diffExplicit not used"), diffDatabase: () => Effect.die("diffDatabase not used"), exportDeclarativeSchema: () => Effect.die("exportDeclarativeSchema not used"), @@ -232,7 +122,6 @@ describe("legacyDiffDeclarativeToMigrations", () => { return legacyDiffDeclarativeToMigrations( { ...ctx(dir, declDir), debug: true, noCache: true, strictCoverage: true }, toml, - setupInputs, ).pipe( Effect.tap((result) => Effect.sync(() => { @@ -259,18 +148,10 @@ describe("legacyDiffDeclarativeToMigrations", () => { ); }); - // The legacy engine's `planDeclarativeSchema` never looks at `input.manifest`, so - // validating the manifest for it turned a stale/hand-edited `.pgdelta-export.json` - // into a hard failure of the documented `SUPABASE_USE_PG_DELTA_NEXT=false` escape - // hatch. The next engine, which does consume it, must still reject it. - const stubEngine = ( - implementation: "legacy" | "next", - calls: LegacyPgDeltaDeclarativePlanInput[], - ) => + const stubEngine = (calls: LegacyPgDeltaDeclarativePlanInput[]) => Layer.succeed( LegacyPgDeltaEngine, LegacyPgDeltaEngine.of({ - implementation, diffExplicit: () => Effect.die("diffExplicit not used"), diffDatabase: () => Effect.die("diffDatabase not used"), exportDeclarativeSchema: () => Effect.die("exportDeclarativeSchema not used"), @@ -287,38 +168,14 @@ describe("legacyDiffDeclarativeToMigrations", () => { }), ); - const withCorruptManifest = () => { + it.effect("rejects a corrupt export manifest before planning", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); const declDir = join(dir, "supabase", "database"); mkdirSync(declDir, { recursive: true }); writeFileSync(join(declDir, "public.sql"), "create table public.accounts();"); writeFileSync(join(declDir, ".pgdelta-export.json"), "{ not json at all"); - return { dir, declDir }; - }; - - it.effect("ignores a corrupt export manifest under the legacy engine opt-out", () => { - const { dir, declDir } = withCorruptManifest(); const calls: LegacyPgDeltaDeclarativePlanInput[] = []; - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(calls[0]?.files).toEqual([ - { name: "public.sql", sql: "create table public.accounts();" }, - ]); - expect(calls[0]?.manifest).toBeUndefined(); - expect(result.manifestPresent).toBe(false); - expect(result.diffSQL).toBe("create table public.accounts();"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(Layer.mergeAll(stubEngine("legacy", calls), BunServices.layer)), - ); - }); - - it.effect("still rejects a corrupt export manifest under the next engine", () => { - const { dir, declDir } = withCorruptManifest(); - const calls: LegacyPgDeltaDeclarativePlanInput[] = []; - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( + return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -333,479 +190,14 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(stubEngine("next", calls), BunServices.layer)), - ); - }); -}); - -// A minimal, valid `LegacySetupInputs` — the exact field values don't matter to -// these tests (they only exercise the cache-miss/shadow-provision path), only -// that a real cache key can be derived from them. -const setupInputs: LegacySetupInputs = { - image: "supabase/postgres:17.6.1.135", - majorVersion: 17, - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - autoExpose: true, - vaultNames: [], - rolesSql: "", -}; - -// A minimal, valid `LegacyDbTomlValues` — threaded into `legacyGetMigrationsCatalogRef` -// for the migrations-catalog shadow's own container spec (CLI-1956). Matches -// `legacy-db-config.toml-read.ts`'s own unconfigured defaults so this fixture -// doesn't silently drift from what `legacyReadDbToml` would resolve for these -// tests' bare temp dirs (none of them write a `config.toml`). -const toml: LegacyDbTomlValues = { - projectEnv: {}, - envLookup: () => undefined, - apiSchemas: ["public", "graphql_public"], - port: 54322, - shadowPort: 54320, - password: "postgres", - poolerConnectionString: Option.none(), - projectId: Option.none(), - majorVersion: 17, - orioledbVersion: Option.none(), - denoVersion: 2, - pgDelta: { - enabled: false, - declarativeSchemaPath: Option.none(), - formatOptions: Option.none(), - npmVersion: Option.none(), - }, - webhooksEnabled: false, - baseline: { - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - apiAutoExposeNewTables: Option.none(), - vaultNames: [], - }, - migrationsEnabled: true, - schemaPaths: [], - schemaPathPatterns: [], - seed: { enabled: true, sqlPaths: [] }, - vault: [], - appliedRemote: undefined, - remoteOverrideKeys: new Set(), -}; - -describe("legacyDiffDeclarativeToMigrations", () => { - useLegacyShadowCacheDisabled(); - it.effect( - "resolves the migrations catalog natively and diffs it against the seam-provisioned declarative catalog", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\nDROP TABLE z;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.tap((result) => - Effect.sync(() => { - // "declarative" still resolves via the seam; "migrations" no longer does - // (it resolves natively, provisioning its shadow the same way `db diff`/ - // `db pull` do — CLI-1956). - expect(seam.calls.map((c) => c.mode)).toEqual(["declarative"]); - expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadow.spawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - // No local migrations in the fresh temp dir → the zero-migrations branch - // writes (and returns) the platform-baseline catalog, workdir-relative. - expect(result.sourceRef).toMatch( - /^supabase[/\\]\.temp[/\\]pgdelta[/\\]catalog-baseline-.*\.json$/, - ); - expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); - expect(result.targetRef).toBe("supabase/.temp/pgdelta/decl.json"); - expect(result.diffSQL).toContain("ALTER TABLE x"); - expect(result.dropWarnings).toEqual(["DROP TABLE z"]); - // The edge-runtime diff received the migrations ref (workdir-relative, - // mapped to /workspace) and the seam's declarative ref as SOURCE/TARGET. - const diffCall = edge.calls.find((c) => c.script.includes("renderPlanFiles")); - expect(diffCall?.env["SOURCE"]).toBe(`/workspace/${result.sourceRef}`); - expect(diffCall?.env["TARGET"]).toBe("/workspace/supabase/.temp/pgdelta/decl.json"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, - ); - - // `--strict-coverage` is enforced entirely by the next engine's diagnostic report; - // the legacy engine has no coverage diagnostics, so the flag silently did nothing - // under `SUPABASE_USE_PG_DELTA_NEXT=false`. It must say so instead. - const runWithStrictCoverageOnLegacyEngine = () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return { - dir, - out, - effect: legacyDiffDeclarativeToMigrations( - { ...ctx(dir, declDir), strictCoverage: true }, - toml, - setupInputs, - ).pipe( - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ), - }; - }; - - it.effect("warns that --strict-coverage does nothing on the legacy engine", () => { - const { dir, out, effect } = runWithStrictCoverageOnLegacyEngine(); - return effect.pipe( - Effect.tap(() => - Effect.sync(() => { - expect(out.stderrText).toContain( - '"--strict-coverage" has no effect with the legacy pg-delta engine.', - ); - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.provide(Layer.mergeAll(stubEngine(calls), BunServices.layer)), ); }); - it.effect( - "reuses an already-warmed platform-baseline catalog without provisioning a shadow", - () => { - // A baseline catalog pre-warmed by a prior generate/sync run (same setup - // inputs, still zero local migrations) must be reused as-is — this is the - // whole point of the zero-migrations special case in - // `legacyGetMigrationsCatalogRef` (mirrors Go's `getMigrationsCatalogRef`, - // `declarative.go:380-392`). - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const tempDir = join(dir, "supabase", ".temp", "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - const baselineKey = legacyBaselineCatalogKey(setupInputs); - const baselinePath = join(tempDir, legacyBaselineCatalogFileName(baselineKey)); - writeFileSync(baselinePath, '{"warmed":true}'); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(shadow.spawned).toEqual([]); - expect(result.sourceRef).toBe( - join("supabase", ".temp", "pgdelta", `catalog-baseline-${baselineKey}.json`), - ); - expect(readFileSync(baselinePath, "utf8")).toBe('{"warmed":true}'); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, - ); - - it.effect( - "fails when the zero-migrations baseline cache probe itself fails, before any shadow work", - () => { - // A probe failure that isn't not-found (permissions, I/O under `.temp/pgdelta`) must - // propagate — matching Go's `getMigrationsCatalogRef` returning the `afero.Exists` - // error immediately — instead of being converted into a cache miss that provisions a - // Docker shadow and only surfaces the filesystem problem at the eventual write to the - // same location (codex review, PR #6162). - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const baselineFileName = legacyBaselineCatalogFileName(legacyBaselineCatalogKey(setupInputs)); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - // Wraps the real Bun `FileSystem` so only the baseline probe fails, with a genuine - // `PlatformError` (same construction as the cache unit tests' failing-fs fakes). - // Merged LAST so it overrides `BunServices.layer`'s own `FileSystem`. - const failingFsLayer = Layer.effect( - FileSystem.FileSystem, - Effect.gen(function* () { - const real = yield* FileSystem.FileSystem; - const err = yield* real.readDirectory(join(dir, "does-not-exist")).pipe(Effect.flip); - const failing: FileSystem.FileSystem = { - ...real, - exists: (p) => (p.endsWith(baselineFileName) ? Effect.fail(err) : real.exists(p)), - }; - return failing; - }), - ).pipe(Layer.provide(BunServices.layer)); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - // The whole point: the failure surfaces BEFORE any Docker side effect. - expect(shadow.spawned).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - BunServices.layer, - seam.layer, - edge.layer, - probe, - out.layer, - shadow.layer, - legacyPgDeltaLegacyEngineLayer.pipe( - Layer.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - BunServices.layer, - shadow.layer, - failingFsLayer, - ), - ), - ), - failingFsLayer, - ), - ), - ); - }, - ); - - it.effect( - "with local migrations present and cache enabled, provisions a shadow and caches the resulting catalog", - () => { - // The dominant real-world code path (a project WITH local migrations, cache - // enabled) — `legacyGetMigrationsCatalogRef`'s cache-miss/non-zero-migrations - // branch (declarative.go:393-430) — was previously never exercised by any - // test; every other test here uses a fresh temp dir with zero migrations. - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_init.sql"), "create table a();\n"); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const migrationsHash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); - const key = legacyMigrationsCatalogCacheKey( - legacySetupInputsToken(setupInputs), - migrationsHash, - ); - const result = yield* legacyDiffDeclarativeToMigrations( - ctx(dir, declDir), - toml, - setupInputs, - ); - expect(result.sourceRef).toMatch( - new RegExp( - `^supabase[/\\\\]\\.temp[/\\\\]pgdelta[/\\\\]catalog-local-migrations-${key}-\\d+\\.json$`, - ), - ); - expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); - expect(out.stderrText).toContain("Creating shadow database...\n"); - expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadow.spawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, - ); - - it.effect( - "reuses an already-cached migrations catalog for local migrations without provisioning a new shadow", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_init.sql"), "create table a();\n"); - const tempDir = join(dir, "supabase", ".temp", "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const migrationsHash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); - const key = legacyMigrationsCatalogCacheKey( - legacySetupInputsToken(setupInputs), - migrationsHash, - ); - const cachedPath = join( - tempDir, - legacyMigrationCatalogFileName("local", key, 1_700_000_000_000), - ); - writeFileSync(cachedPath, '{"cached":true}'); - const result = yield* legacyDiffDeclarativeToMigrations( - ctx(dir, declDir), - toml, - setupInputs, - ); - expect(result.sourceRef).toBe(path.relative(dir, cachedPath)); - expect(readFileSync(cachedPath, "utf8")).toBe('{"cached":true}'); - expect(shadow.spawned).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, - ); - - it.effect( - "--no-cache ignores an already-cached migrations catalog, provisions a fresh shadow, and writes catalog-nocache-migrations.json", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_init.sql"), "create table a();\n"); - const tempDir = join(dir, "supabase", ".temp", "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - // Pre-warm the cache entry that a cache-enabled run would hit, proving - // --no-cache really skips the lookup rather than merely never having - // written that entry. - const migrationsHash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); - const key = legacyMigrationsCatalogCacheKey( - legacySetupInputsToken(setupInputs), - migrationsHash, - ); - const cachedPath = join( - tempDir, - legacyMigrationCatalogFileName("local", key, 1_700_000_000_000), - ); - writeFileSync(cachedPath, '{"cached":true}'); - const result = yield* legacyDiffDeclarativeToMigrations( - { ...ctx(dir, declDir), noCache: true }, - toml, - setupInputs, - ); - expect(result.sourceRef).toBe( - join("supabase", ".temp", "pgdelta", "catalog-nocache-migrations.json"), - ); - expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); - expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, - ); it.effect("fails when the declarative dir is absent", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const seam = mockSeam({ declarative: "d", baseline: "b" }); - const edge = mockEdge(""); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return legacyDiffDeclarativeToMigrations( - ctx(dir, join(dir, "missing")), - toml, - setupInputs, - ).pipe( + const calls: LegacyPgDeltaDeclarativePlanInput[] = []; + return legacyDiffDeclarativeToMigrations(ctx(dir, join(dir, "missing")), toml).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -816,55 +208,40 @@ describe("legacyDiffDeclarativeToMigrations", () => { "No declarative schema directory found", ); } - expect(seam.calls).toEqual([]); - expect(shadow.spawned).toEqual([]); + expect(calls).toEqual([]); rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), + Effect.provide(Layer.mergeAll(stubEngine(calls), BunServices.layer)), ); }); }); describe("legacyGenerateDeclarativeOutput", () => { - it.effect("propagates debug, no-cache, and strict coverage to the selected engine", () => { + it.effect("propagates debug and strict coverage to the engine", () => { const calls: Array<{ readonly debug: boolean; - readonly noCache: boolean; - readonly sourceRef: string | undefined; readonly strictCoverage: boolean; }> = []; const engine = Layer.succeed( LegacyPgDeltaEngine, LegacyPgDeltaEngine.of({ - implementation: "next", diffExplicit: () => Effect.die("diffExplicit not used"), diffDatabase: () => Effect.die("diffDatabase not used"), exportDeclarativeSchema: (input) => { calls.push({ debug: input.debug, - noCache: input.noCache, - sourceRef: input.source?.ref, strictCoverage: input.strictCoverage, }); - return Effect.succeed({ files: [] }); + return Effect.succeed({ + files: [], + manifest: { redactSecrets: true, scope: "database" }, + }); }, planDeclarativeSchema: () => Effect.die("planDeclarativeSchema not used"), }), ); const dir = mkdtempSync(join(tmpdir(), "legacy-decl-export-")); - const shadow = mockShadowInfra(); - const out = mockOutput(); return legacyGenerateDeclarativeOutput( { ...ctx(dir, join(dir, "supabase", "database")), @@ -872,7 +249,6 @@ describe("legacyGenerateDeclarativeOutput", () => { noCache: true, strictCoverage: true, }, - toml, { kind: "database", ref: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", @@ -884,64 +260,13 @@ describe("legacyGenerateDeclarativeOutput", () => { expect(calls).toEqual([ { debug: true, - noCache: true, - sourceRef: undefined, strictCoverage: true, }, ]); - expect(shadow.spawned).toEqual([]); rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(engine, out.layer, BunServices.layer, shadow.layer)), - ); - }); - - it.effect("diffs a native raw shadow against the live DB and returns files", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-export-")); - const seam = mockSeam({ - declarative: "d", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const payload = { - version: 1, - mode: "declarative", - files: [{ path: "public.sql", order: 0, statements: 1, sql: "create table a();" }], - }; - const edge = mockEdge(JSON.stringify(payload)); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return legacyGenerateDeclarativeOutput(ctx(dir, join(dir, "supabase", "database")), toml, { - kind: "database", - ref: "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10", - connectOptions: { isLocal: true, dnsResolver: "native" }, - }).pipe( - Effect.tap((output) => - Effect.sync(() => { - expect(seam.calls).toEqual([]); - expect(output.files[0]?.name).toBe("public.sql"); - expect(edge.calls[0]!.env["SOURCE"]).toBe( - "postgresql://postgres:postgres@127.0.0.1:54320/postgres?connect_timeout=10", - ); - expect(edge.calls[0]!.env["TARGET"]).toBe( - "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10", - ); - expect(shadow.spawned.filter((call) => call.args[0] === "create")).toHaveLength(1); - expect(shadow.spawned.filter((call) => call.args[0] === "rm")).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), + Effect.provide(Layer.mergeAll(engine, BunServices.layer)), ); }); }); diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts index e87dcc1b04..25d482165d 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts @@ -1,20 +1,6 @@ import { Effect, FileSystem, Path } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { - LegacyNetworkIdFlag, - legacyResolveDebugWithProjectEnv, -} from "../../../../shared/legacy/global-flags.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { legacyBuildLocalDbContainerInputs } from "../../../../command-internal/db-bootstrap/local-container-inputs.ts"; -import { - legacyCreateShadowDatabase, - legacyPrepareRawShadow, - legacyRemoveShadowDatabase, - legacyShadowRunInputFromLocalContainerInputs, -} from "../../../../command-internal/db-bootstrap/shadow-database.ts"; import type { LegacyPgDeltaContext } from "../../../../command-internal/legacy-pgdelta.ts"; -import type { LegacySetupInputs } from "../../../../command-internal/legacy-pgdelta.cache.ts"; import type { LegacyDbTomlValues } from "../../../../command-internal/legacy-db-config.toml-read.ts"; import { legacyFindDropStatements } from "../../../../command-internal/legacy-sql-split.ts"; import { @@ -88,15 +74,12 @@ const formatImplicitExtensionLoadFailure = ( /** * Computes the diff between local migrations state and the declarative schema. - * Mirrors Go's `DiffDeclarativeToMigrations` (`declarative.go:170`): the - * selected pg-delta engine owns both sides of the plan. The legacy engine - * resolves migrations natively via `legacyGetMigrationsCatalogRef` (CLI-1959), - * while pg-delta next plans against its scoped migrations/declarative shadows. + * The pg-delta engine owns both sides of the plan, planning against its scoped + * migrations/declarative shadows. */ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( run: LegacyDeclarativeRunContext, toml: LegacyDbTomlValues, - setupInputs: LegacySetupInputs, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -112,20 +95,10 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( const files = yield* LegacyLoadPgDeltaSqlFiles(fs, path, run.declarativeDir).pipe( Effect.mapError((error) => declarativeError(error.message)), ); - // Only the next engine consumes the export manifest (its planner reads ownership - // metadata from it); the legacy engine's `planDeclarativeSchema` ignores - // `input.manifest` entirely. Reading it unconditionally made the strict manifest - // validation (`LegacyReadPgDeltaExportManifest` fails on malformed JSON or missing - // policy metadata) fail a legacy-engine sync over a file the legacy planner never - // looks at, defeating the `SUPABASE_USE_PG_DELTA_NEXT=false` escape hatch. Under - // the legacy engine the manifest is treated as absent, exactly as if the file did - // not exist. - const manifest = - engine.implementation === "next" - ? yield* LegacyReadPgDeltaExportManifest(fs, path, run.declarativeDir).pipe( - Effect.mapError((error) => declarativeError(error.message)), - ) - : undefined; + // The planner reads ownership metadata from the export manifest when present. + const manifest = yield* LegacyReadPgDeltaExportManifest(fs, path, run.declarativeDir).pipe( + Effect.mapError((error) => declarativeError(error.message)), + ); const result = yield* engine .planDeclarativeSchema({ context: run.pgDelta, @@ -136,14 +109,12 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( files, noCache: run.noCache, toml, - setupInputs, ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), ...(manifest !== undefined ? { manifest } : {}), }) .pipe( Effect.mapError((error) => { const findings = legacyClassifyDeclarativeLoadCompatibility({ - implementation: engine.implementation, manifestPresent: manifest !== undefined, diagnostics: error.diagnostics ?? [], files, @@ -163,7 +134,7 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( sourceRef: result.sourceRef, targetRef: result.targetRef, dropWarnings: - engine.implementation === "next" && result.hazards !== undefined + result.hazards !== undefined ? result.hazards.dataLoss.map((action) => action.sql) : legacyFindDropStatements(result.sql), manifestPresent: manifest !== undefined, @@ -173,61 +144,16 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( export const legacyGenerateDeclarativeOutput = Effect.fnUntraced(function* ( run: LegacyDeclarativeRunContext, - toml: LegacyDbTomlValues, target: LegacyPgDeltaDatabaseEndpoint, ) { const engine = yield* LegacyPgDeltaEngine; - const exportInput = { + return yield* engine.exportDeclarativeSchema({ context: run.pgDelta, target, schema: run.schema, formatOptions: run.formatOptions, debug: run.debug, strictCoverage: run.strictCoverage, - noCache: run.noCache, ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), - }; - if (engine.implementation === "next") { - return yield* engine.exportDeclarativeSchema(exportInput); - } - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runtimeInfo = yield* RuntimeInfo; - const networkIdFlag = yield* LegacyNetworkIdFlag; - const debug = yield* legacyResolveDebugWithProjectEnv(toml.projectEnv); - const localInputs = yield* legacyBuildLocalDbContainerInputs( - spawner, - run.pgDelta.cwd, - networkIdFlag, - runtimeInfo.platform, - debug, - run.linkedProjectRef, - toml.remoteOverrideKeys, - ); - const resolvedImage = yield* localInputs.resolvePostgresImage; - const rawShadowInput = legacyShadowRunInputFromLocalContainerInputs( - localInputs, - resolvedImage, - toml, - fs, - path, - ); - return yield* Effect.acquireUseRelease( - legacyCreateShadowDatabase(spawner, rawShadowInput), - (handle) => - Effect.gen(function* () { - const shadow = yield* legacyPrepareRawShadow(spawner, handle, rawShadowInput); - return yield* engine.exportDeclarativeSchema({ - ...exportInput, - source: { - kind: "database", - ref: shadow.sourceUrl, - connectOptions: { isLocal: true, dnsResolver: "native" }, - }, - }); - }), - (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), - ); + }); }); diff --git a/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 045e45024b..b7ef380025 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -3,63 +3,49 @@ Generates declarative schema files from a database using pg-delta's managed platform view. -Pg-delta runs in-process by default. Set `SUPABASE_USE_PG_DELTA_NEXT=false` for -the legacy catalog/edge-runtime implementation; there is no automatic fallback. +Pg-delta runs in-process. Coverage gaps warn; `--strict-coverage` makes them fatal, and `PGDELTA_DEBUG` writes diagnostic JSON under `supabase/.temp/pgdelta/v2/debug//`. -`--no-cache` affects only the legacy opt-out (its catalog cache and the shadow -baseline snapshot those catalog exports use). The bundled formatter defaults to +`--no-cache` (a flag shared across the `declarative` group) has no effect on +`generate` — the export connects directly to the target and provisions no +shadow. The bundled formatter defaults to lowercase SQL at width 180; config overrides it, and JSON `null` disables formatting without disabling safe compaction. ## Files Read -| Path | Format | When | -| --------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always — pg-delta gate, ports, format options | -| `/supabase/.temp/pgdelta-version` | plain text | loaded for compatibility; legacy opt-out only | -| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out's edge-runtime image tag | -| `/supabase/.temp/postgres-version` | plain text | legacy opt-out's shadow-DB image resolution | -| `/supabase/migrations/*.sql` | SQL | smart mode — detect whether migrations exist | -| `/supabase/roles.sql` | SQL | legacy opt-out — hashed into the catalog cache key, and on a catalog miss also into the shadow-baseline cache key (on warm hits too, not just cold ones) and applied to a cold shadow's baseline; missing file tolerated (hashed as empty) | -| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's catalog cache | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | legacy opt-out catalog miss, warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU/TTL (`SUPABASE_HOME` overrides the root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | legacy opt-out catalog miss — abandoned-partial sweep on every cache-eligible acquire; removed when older than 5 minutes | -| `~/.supabase/access-token` | plain text | `--linked` (token resolution) | +| Path | Format | When | +| ------------------------------------------- | ---------- | ------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always — pg-delta gate, ports, format options | +| `/supabase/.temp/postgres-version` | plain text | smart-mode Local flow — the local Postgres image-currency check's version-pin lookup | +| `/supabase/migrations/*.sql` | SQL | smart mode — detect whether migrations exist | +| `~/.supabase/access-token` | plain text | `--linked` (token resolution) | ## Files Written -| Path | Format | When | -| ----------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/schemas/**/*.sql` (default declarative dir, or invocation-local `--output`) | SQL | selected destination is wiped + rewritten after confirmation | -| `/.pgdelta-export.json` | JSON | bundled-engine export metadata | -| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | legacy opt-out's catalog cache | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | legacy opt-out catalog miss, cache-enabled COLD shadow provision creates the current key's snapshot; a warm hit `touch`es its mtime; LRU/TTL may delete other keys (`SUPABASE_HOME` overrides the root; `--no-cache` neither reads nor writes) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | legacy opt-out catalog miss, during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | -| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| Path | Format | When | +| ----------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------ | +| `/supabase/schemas/**/*.sql` (default declarative dir, or invocation-local `--output`) | SQL | selected destination is wiped + rewritten after confirmation | +| `/.pgdelta-export.json` | JSON | export metadata | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | with `PGDELTA_DEBUG` | ## Subprocesses / Containers -| What | When | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| Natively-provisioned shadow Postgres container (create, health-wait, platform-baseline setup via one-shot auth/storage/realtime migrate jobs, then remove) — the same primitives `db diff`/`db pull` use for their own shadow, exports the baseline catalog | legacy opt-out only | -| Edge-runtime container (`supabase/edge-runtime`) running the pg-delta declarative-export Deno script (host network, deno-cache volume `supabase_edge_runtime_`) | legacy opt-out only | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` | smart-mode Local choice when reset is confirmed (or `--reset`) | +| What | When | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` | smart-mode Local choice when reset is confirmed (or `--reset`) | ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no | -| `DB_PASSWORD` | password for `--linked` / `--db-url` | no | -| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the legacy opt-out's shadow baseline cache | no | -| `SUPABASE_SHADOW_CACHE` | shadow baseline cache for the legacy opt-out's catalog-miss shadows; on by default, opt-out (`0`/`false`) | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | -| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | -| `SUPABASE_SERVICES_HOSTNAME` | local DB host for `--local` | no | -| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | -| `SUPABASE_USE_SLIM_IMAGES` | resolves current-pin shadow Postgres, PG15+ realtime/storage/auth migrate-job images, and (legacy opt-out) the edge-runtime catalog/export image from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, flag-off `15.8.1.085`, `deno_version = 1`, and historical `.temp/edge-runtime-version` pins stay on docker.io | no | +| Variable | Purpose | Required? | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no | +| `DB_PASSWORD` | password for `--linked` / `--db-url` | no | +| `SUPABASE_HOME` | overrides the `~/.supabase` root (access token and other CLI state) | no | +| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | +| `SUPABASE_SERVICES_HOSTNAME` | local DB host for `--local` | no | +| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the expected local `db` image for the stale-container guard from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, and flag-off `15.8.1.085` stay on docker.io | no | ## Exit Codes @@ -69,7 +55,7 @@ formatting without disabling safe compaction. | `1` | pg-delta not enabled (no `--experimental` / `[experimental.pgdelta]`) | | `1` | conflicting `--db-url`/`--linked`/`--local` (mutually exclusive) | | `1` | non-interactive mode with no explicit target | -| `1` | shadow-database / selected pg-delta engine / export failure | +| `1` | local-database bring-up / pg-delta engine / export failure | The pg-delta gate and the mutex check are both raised before any side effects run, but the gate wins when both conditions apply simultaneously: the gate check runs @@ -102,14 +88,8 @@ always go to stderr, in every `--output-format`. On success: or an export manifest, a WARNING on stderr explains the default move and how to keep the existing tree. Read-only probe; never changes behavior or exit codes. -- Under the legacy opt-out, remote Supabase targets get the embedded pg-delta CA - bundle written under `supabase/.temp/pgdelta/` and the URL rewritten to - `sslmode=verify-ca`; the bundled engine uses the shared connection/TLS behavior. -- **Architecture:** the bundled engine extracts and renders the target in-process. - Under the legacy opt-out, the shadow-database platform baseline is provisioned - in-process (create the shadow container, wait for health, run the - auth/storage/realtime one-shot migrate jobs, export the catalog, remove the - container) using the same primitives as `db diff` and `db pull`. +- Remote Supabase targets use the shared connection/TLS behavior. +- **Architecture:** the engine extracts and renders the target in-process. - **Stale local-container guard.** `--local`/smart-mode's Local target inspects the running local `db` container's actual image and compares it against the currently-configured/resolved one before reading from it. A same-tag family diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts index 99c28a86e9..563437908b 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts @@ -18,15 +18,12 @@ import { } from "../../../../../command-internal/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 "../../../../../command-internal/legacy-pgdelta.cache.ts"; +import { legacyListLocalMigrations } from "../../../../../command-internal/legacy-migration-list.ts"; import { legacyIsPgDeltaDebugEnabled, legacyResolvePgDeltaProjectId, } from "../../../../../command-internal/legacy-pgdelta.ts"; -import { - LegacyPgDeltaEngine, - type LegacyPgDeltaDatabaseEndpoint, -} from "../../../shared/legacy-pgdelta-engine.service.ts"; +import type { LegacyPgDeltaDatabaseEndpoint } from "../../../shared/legacy-pgdelta-engine.service.ts"; import { LegacyDeclarativeWriteError } from "../../../shared/legacy-pgdelta.errors.ts"; import { LegacyDeclarativeMutuallyExclusiveFlagsError, @@ -62,7 +59,6 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; const dnsResolver = yield* LegacyDnsResolverFlag; - const engine = yield* LegacyPgDeltaEngine; // Go's `dbDeclarativeCmd.PersistentPreRunE` calls `flags.LoadConfig` — which runs // `loadNestedEnv` and `os.Setenv`s each project-.env key — BEFORE reading // `viper.GetBool("EXPERIMENTAL")` for the gate below (`apps/cli-go/cmd/ @@ -174,7 +170,6 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec cliSettings.workdir, ), cwd: cliSettings.workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), // Merged config's deno_version (re-loaded with the linked ref above on // `--linked`), so pg-delta runs under the remote-configured Deno image. denoVersion: toml.denoVersion, @@ -279,7 +274,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec overwrite = true; } - const result = yield* legacyGenerateDeclarativeOutput(run, toml, target); + const result = yield* legacyGenerateDeclarativeOutput(run, target); if (!overwrite && (yield* confirmOverwriteHasFiles(fs, declarativeDir))) { // Go's confirmOverwrite goes through Console.PromptYesNo (`internal/db/ @@ -304,31 +299,6 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec // next writer only prunes what an export manifest claimed — say so when a // manifest-less directory kept files the export did not replace. yield* legacyWarnPreservedUnmanagedDeclarativeFiles(declarativeDirRel, written); - - // Warm the declarative catalog cache after writing the files and before the - // success message, gated on `!--no-cache` — Go's `Generate` - // (`apps/cli-go/internal/db/declarative/declarative.go:133-157`). This applies - // the generated schema to the shadow DB and caches the catalog under the - // `local` key a subsequent `sync` reuses; a schema that cannot be applied makes - // `generate` fail here rather than succeeding and forcing `sync` to reprovision. - // - // On explicit `--linked`, thread the resolved ref into the legacy cache-warm seam, - // so it loads the `[remotes.]`-merged config and its own `GetDeclarativeDir()` - // resolves the remote-overridden `declarative_schema_path` — i.e. the warm builds - // from the same merged config and targets the same dir the handler wrote to (also - // computed from the merged `toml`). Go warms against the in-process merged config - // identically (`declarative.go:138-154`), so this always runs when `!--no-cache`. - // A command-local --output-dir is deliberately not activated in config. The - // legacy catalog seam resolves the configured declarative path itself, so - // warming here would inspect the wrong tree. Skip that optional legacy-only - // cache warm; the generated output remains complete and usable on its own. - if (!flags.noCache && engine.implementation === "legacy" && Option.isNone(flags.outputDir)) { - yield* (yield* LegacyDeclarativeSeam).exportCatalog({ - mode: "declarative", - noCache: flags.noCache, - ...(linkedProjectRef !== undefined ? { projectRef: linkedProjectRef } : {}), - }); - } yield* output.raw(legacyDeclarativeSchemaWrittenLine(declarativeDirRel), "stderr"); }).pipe( // Go's `ensureProjectGroupsCached` PersistentPostRun (`cmd/root.go:176,214-234`) diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index c728d1d94b..04e5395aae 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -46,33 +46,14 @@ import { LegacyDbConnection, } from "../../../../../command-internal/legacy-db-connection.service.ts"; import { - type LegacyEdgeRuntimeRunOpts, - LegacyEdgeRuntimeScript, -} from "../../../../../command-internal/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; -import { legacyPgDeltaLegacyEngineLayer } from "../../../shared/legacy-pgdelta-engine.legacy.layer.ts"; -import { LegacyPgDeltaEngine } from "../../../shared/legacy-pgdelta-engine.service.ts"; + LegacyPgDeltaEngine, + LegacyPgDeltaEngineError, +} from "../../../shared/legacy-pgdelta-engine.service.ts"; import { LegacyDeclarativeShadowDbError } from "../../../shared/legacy-pgdelta.errors.ts"; -import { - type LegacyCatalogMode, - LegacyDeclarativeSeam, -} from "../../../shared/legacy-pgdelta.seam.service.ts"; +import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbSchemaDeclarativeGenerateFlags } from "./generate.command.ts"; import { legacyDbSchemaDeclarativeGenerate } from "./generate.handler.ts"; -const EXPORT_JSON = JSON.stringify({ - version: 1, - mode: "declarative", - files: [ - { - path: "schemas/public/tables/players.sql", - order: 0, - statements: 1, - sql: "create table players ();", - }, - ], -}); - interface SetupOpts { experimental?: boolean; args?: ReadonlyArray; @@ -81,7 +62,6 @@ interface SetupOpts { promptConfirmResponses?: ReadonlyArray; promptSelectResponses?: ReadonlyArray; promptTextResponses?: ReadonlyArray; - exportJson?: string; /** * Makes the local-reset prompt's `legacyResetLocalDatabase` fail immediately * with `LegacyResetLocalDbNotRunningError` (the local `db` container reports as @@ -90,9 +70,16 @@ interface SetupOpts { resetShouldFail?: boolean; networkId?: Option.Option; projectId?: Option.Option; - exportFailsForMode?: LegacyCatalogMode; + /** Makes the engine's `exportDeclarativeSchema` fail after recording the call. */ + exportFails?: boolean; staleLocalImage?: boolean; - engineImplementation?: "legacy" | "next"; +} + +/** What the handler handed the engine for one `exportDeclarativeSchema` call. */ +interface EngineExportCall { + readonly targetRef: string; + readonly projectRef: string | undefined; + readonly strictCoverage: boolean; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -103,8 +90,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); - const seamCalls: LegacyCatalogMode[] = []; - const seamExportCalls: Array<{ mode: LegacyCatalogMode; projectRef?: string }> = []; const localPostgresImageChecks: Array = []; let ensureStartedCalls = 0; const platformApi = mockLegacyPlatformApiService({}); @@ -138,13 +123,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); const seam = Layer.succeed(LegacyDeclarativeSeam, { - exportCatalog: ({ mode, projectRef }) => { - seamCalls.push(mode); - seamExportCalls.push({ mode, projectRef }); - return opts.exportFailsForMode === mode - ? Effect.fail(new LegacyDeclarativeShadowDbError({ message: `export failed for ${mode}` })) - : Effect.succeed("supabase/.temp/pgdelta/base.json"); - }, ensureLocalDatabaseStarted: () => Effect.sync(() => { ensureStartedCalls += 1; @@ -164,13 +142,34 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), ), }); - const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; - const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - edgeCalls.push(runOpts); - return Effect.succeed({ stdout: opts.exportJson ?? EXPORT_JSON, stderr: "" }); - }, - }); + const engineExportCalls: EngineExportCall[] = []; + const engine = Layer.succeed( + LegacyPgDeltaEngine, + LegacyPgDeltaEngine.of({ + diffExplicit: () => Effect.die("diffExplicit not used in generate tests"), + diffDatabase: () => Effect.die("diffDatabase not used in generate tests"), + planDeclarativeSchema: () => Effect.die("planDeclarativeSchema not used in generate tests"), + exportDeclarativeSchema: (input) => + Effect.suspend(() => { + engineExportCalls.push({ + targetRef: input.target.ref, + projectRef: input.projectRef, + strictCoverage: input.strictCoverage, + }); + return opts.exportFails === true + ? Effect.fail( + new LegacyPgDeltaEngineError({ + message: "declarative export failed", + cause: undefined, + }), + ) + : Effect.succeed({ + files: [{ name: "public/tables/players.sql", sql: "create table players ();" }], + manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, + }); + }), + }), + ); const resolverCalls: unknown[] = []; const resolver = Layer.succeed(LegacyDbConfigResolver, { resolve: (flags) => { @@ -193,10 +192,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { exec: (args) => Effect.sync(() => void proxyCalls.push(args)), execCapture: () => Effect.succeed(""), }); - const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); const runtimeInfo = mockRuntimeInfo({ platform: "linux" }); const processControl = mockProcessControl(); const experimentalFlag = Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true); @@ -209,47 +204,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.provide(child.layer), Layer.provide(processControl.layer), ); - const engineRuntime = Layer.mergeAll( - seam, - edge, - sslProbe, - out.layer, - dbConn, - runtimeInfo, - experimentalFlag, - cliArgs, - networkIdFlag, - debugFlag, - processControl.layer, - alwaysReadyHttpClientLayer, - dockerRun, - BunServices.layer, - child.layer, - ); - const engine = - opts.engineImplementation === "next" - ? Layer.succeed( - LegacyPgDeltaEngine, - LegacyPgDeltaEngine.of({ - implementation: "next", - diffExplicit: () => Effect.die("diffExplicit not used in generate tests"), - diffDatabase: () => Effect.die("diffDatabase not used in generate tests"), - planDeclarativeSchema: () => - Effect.die("planDeclarativeSchema not used in generate tests"), - exportDeclarativeSchema: () => - Effect.succeed({ - files: [{ name: "public/tables/players.sql", sql: "create table players ();" }], - manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, - }), - }), - ) - : legacyPgDeltaLegacyEngineLayer.pipe(Layer.provide(engineRuntime)); const layer = Layer.mergeAll( out.layer, telemetry.layer, cache.layer, seam, - edge, engine, resolver, proxy, @@ -263,8 +222,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { networkIdFlag, Layer.succeed(LegacyDnsResolverFlag, "native"), debugFlag, - // The remote ref is a non-Supabase host that refuses TLS → no SSL env. - sslProbe, // The local-reset bucket-seed core statically requires the (lazy) Management-API // factory; never invoked on the local reset (projectRef === ""). Layer.succeed(LegacyPlatformApiFactory, { @@ -287,9 +244,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry, child, dbExec, - seamCalls, - seamExportCalls, - edgeCalls, + engineExportCalls, resolverCalls, proxyCalls, localPostgresImageChecks, @@ -480,22 +435,17 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --local: provisions a raw shadow, exports, and writes files", () => { + it.effect("explicit --local: exports from the local database and writes files", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); - // Only the optional legacy post-write warm remains seam-backed. The export - // source is a workflow-owned native raw shadow. - expect(s.seamCalls).toEqual(["declarative"]); - expect(s.edgeCalls[0]!.env["SOURCE"]).toContain( - "postgresql://postgres:postgres@127.0.0.1:54320", - ); - expect(s.edgeCalls[0]!.env["TARGET"]).toContain( + // The engine receives the local database endpoint as the export target. + expect(s.engineExportCalls[0]!.targetRef).toContain( "postgresql://postgres:postgres@127.0.0.1:54322", ); const written = yield* Effect.promise(async () => (await import("node:fs")).readFileSync( - join(tmp.current, "supabase", "schemas", "schemas", "public", "tables", "players.sql"), + join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql"), "utf8", ), ); @@ -515,7 +465,7 @@ describe("legacy db schema declarative generate integration", () => { }); it.effect( - "--output-dir writes a complete next export relative to the project without activating it", + "--output-dir writes a complete export relative to the project without activating it", () => { mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "database", "configured.sql"), "select 1;"); @@ -528,7 +478,7 @@ describe("legacy db schema declarative generate integration", () => { ].join("\n"); writeFileSync(configPath, config); const destination = join("supabase", "database-next"); - const s = setup(tmp.current, { experimental: true, engineImplementation: "next" }); + const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate( flags({ local: Option.some(true), outputDir: Option.some(destination) }), @@ -564,7 +514,6 @@ describe("legacy db schema declarative generate integration", () => { writeFileSync(join(destination, "keep.sql"), "select 'keep';"); const s = setup(tmp.current, { experimental: true, - engineImplementation: "next", promptConfirmResponses: [false], }); return Effect.gen(function* () { @@ -582,7 +531,7 @@ describe("legacy db schema declarative generate integration", () => { mkdirSync(projectDir, { recursive: true }); const sentinel = join(projectDir, "project-sentinel.txt"); writeFileSync(sentinel, "keep"); - const s = setup(projectDir, { experimental: true, engineImplementation: "next" }); + const s = setup(projectDir, { experimental: true }); return Effect.gen(function* () { for (const output of ["", ".", "..", dirname(projectDir)]) { const exit = yield* legacyDbSchemaDeclarativeGenerate( @@ -600,17 +549,14 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("--output-dir does not warm the configured legacy declarative tree", () => { + it.effect("--output-dir leaves the configured declarative tree untouched", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate( flags({ local: Option.some(true), outputDir: Option.some("staged-schema") }), ); - expect(s.seamCalls).toEqual([]); expect( - existsSync( - join(tmp.current, "staged-schema", "schemas", "public", "tables", "players.sql"), - ), + existsSync(join(tmp.current, "staged-schema", "public", "tables", "players.sql")), ).toBe(true); expect(existsSync(join(tmp.current, "supabase", "schemas"))).toBe(false); }).pipe(Effect.provide(s.layer)); @@ -629,7 +575,7 @@ describe("legacy db schema declarative generate integration", () => { }); expect(s.localPostgresImageChecks).toHaveLength(1); expect(s.ensureStartedCalls).toBe(0); - expect(s.edgeCalls).toEqual([]); + expect(s.engineExportCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); @@ -645,7 +591,7 @@ describe("legacy db schema declarative generate integration", () => { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); const written = yield* Effect.promise(async () => (await import("node:fs")).readFileSync( - join(tmp.current, "supabase", "schemas", "schemas", "public", "tables", "players.sql"), + join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql"), "utf8", ), ); @@ -685,7 +631,7 @@ describe("legacy db schema declarative generate integration", () => { flags({ dbUrl: Option.some("postgres://remote/db") }), ); expect(s.resolverCalls.length).toBe(1); - expect(s.edgeCalls[0]!.env["TARGET"]).toContain("@db.remote:5432"); + expect(s.engineExportCalls[0]!.targetRef).toContain("@db.remote:5432"); // Remote target → the local stack is never started. expect(s.ensureStartedCalls).toBe(0); }).pipe(Effect.provide(s.layer)); @@ -709,10 +655,10 @@ describe("legacy db schema declarative generate integration", () => { return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); // File lands under the absolute path, NOT tmp.current/. - expect(existsSync(join(absSchema, "schemas", "public", "tables", "players.sql"))).toBe(true); - expect( - readFileSync(join(absSchema, "schemas", "public", "tables", "players.sql"), "utf8"), - ).toBe("create table players ();"); + expect(existsSync(join(absSchema, "public", "tables", "players.sql"))).toBe(true); + expect(readFileSync(join(absSchema, "public", "tables", "players.sql"), "utf8")).toBe( + "create table players ();", + ); // Go prints the configured value verbatim — absolute here, never workdir-prefixed. expect( s.out.rawChunks.map((c) => ({ text: stripAnsi(c.text), stream: c.stream })), @@ -748,25 +694,14 @@ describe("legacy db schema declarative generate integration", () => { yield* legacyDbSchemaDeclarativeGenerate(flags({ linked: Option.some(true) })); const written = yield* Effect.promise(async () => (await import("node:fs")).readFileSync( - join( - tmp.current, - "supabase", - "remote_schema", - "schemas", - "public", - "tables", - "players.sql", - ), + join(tmp.current, "supabase", "remote_schema", "public", "tables", "players.sql"), "utf8", ), ); expect(written).toBe("create table players ();"); - // The post-write cache warm now RUNS and is threaded the resolved ref as - // SUPABASE_PROJECT_ID, so the __catalog subprocess loads the [remotes.]-merged - // config and resolves the remote-overridden declarative dir — matching Go's - // in-process merged warm (declarative.go:138-154) rather than skipping. - const declWarm = s.seamExportCalls.find((c) => c.mode === "declarative"); - expect(declWarm?.projectRef).toBe(ref); + // The resolved linked ref is threaded into the engine export as projectRef, so + // the export's platform setup uses the [remotes.]-merged config. + expect(s.engineExportCalls[0]!.projectRef).toBe(ref); }).pipe(Effect.provide(s.layer)); }); @@ -800,8 +735,8 @@ describe("legacy db schema declarative generate integration", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(false) })); - // Took the explicit local target and completed the optional legacy warm ... - expect(s.seamCalls).toContain("declarative"); + // Took the explicit local target and ran the export ... + expect(s.engineExportCalls).toHaveLength(1); // ... but did NOT auto-start (value is false). expect(s.ensureStartedCalls).toBe(0); expect(s.localPostgresImageChecks).toHaveLength(1); @@ -860,7 +795,7 @@ describe("legacy db schema declarative generate integration", () => { }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); - expect(s.seamCalls).toEqual([]); + expect(s.engineExportCalls).toEqual([]); expect( s.out.rawChunks.some((c) => c.text.includes("Skipped generating declarative schema")), ).toBe(true); @@ -878,7 +813,7 @@ describe("legacy db schema declarative generate integration", () => { const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); - expect(s.seamCalls).toEqual(["declarative"]); + expect(s.engineExportCalls).toHaveLength(1); // Go's PromptYesNo echoes the auto-accepted question to stderr under the // global YES flag (`console.go:70-72`) — the echo must not be skipped, and // the prompt renders the relative dir (`db_schema_declarative.go:268`). @@ -902,7 +837,7 @@ describe("legacy db schema declarative generate integration", () => { const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: false }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); - expect(s.seamCalls).toEqual(["declarative"]); + expect(s.engineExportCalls).toHaveLength(1); expect(stripAnsi(s.out.stderrText)).toContain( `Declarative schema already exists at ${join("supabase", "schemas")}. Regenerate from database? This will overwrite existing files. [y/N] y\n`, ); @@ -917,24 +852,27 @@ describe("legacy db schema declarative generate integration", () => { ); }); - it.effect("warms the declarative catalog cache after writing (skipped with --no-cache)", () => { + it.effect("passes --strict-coverage through to the engine export", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { - yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true), noCache: true })); - // --no-cache skips the post-write warm; the raw source never uses the seam. - expect(s.seamCalls).toEqual([]); + yield* legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), noCache: true, strictCoverage: true }), + ); + expect(s.engineExportCalls).toEqual([expect.objectContaining({ strictCoverage: true })]); }).pipe(Effect.provide(s.layer)); }); - it.effect("fails generate when the post-write catalog warm cannot apply to the shadow", () => { - // Go returns the warm error from Generate (declarative.go:144-153), so a schema that - // can't apply to the shadow DB fails generate rather than reporting success. - const s = setup(tmp.current, { experimental: true, exportFailsForMode: "declarative" }); + it.effect("fails generate when the engine export fails", () => { + const s = setup(tmp.current, { experimental: true, exportFails: true }); return Effect.gen(function* () { const exit = yield* legacyDbSchemaDeclarativeGenerate( flags({ local: Option.some(true) }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyPgDeltaEngineError", + message: "declarative export failed", + }); expect(s.out.rawChunks.some((c) => c.text.includes("Declarative schema written to"))).toBe( false, ); @@ -1003,7 +941,7 @@ describe("legacy db schema declarative generate integration", () => { message: "local Postgres container image is stale", }); expect(s.localPostgresImageChecks).toHaveLength(1); - expect(s.edgeCalls).toEqual([]); + expect(s.engineExportCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); @@ -1198,12 +1136,14 @@ describe("legacy db schema declarative generate integration", () => { return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); // Normalized via ToPostgresURL → connect_timeout appended, like Go. - expect(s.edgeCalls[0]!.env["TARGET"]).toContain("@db.example.com:5432/app?connect_timeout="); + expect(s.engineExportCalls[0]!.targetRef).toContain( + "@db.example.com:5432/app?connect_timeout=", + ); }).pipe(Effect.provide(s.layer)); }); - it.effect("next engine writes its manifest and skips legacy catalog warming", () => { - const s = setup(tmp.current, { experimental: true, engineImplementation: "next" }); + it.effect("writes the engine's export manifest alongside the declarative tree", () => { + const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); const manifest = JSON.parse( @@ -1215,7 +1155,6 @@ describe("legacy db schema declarative generate integration", () => { scope: "database", files: ["public/tables/players.sql"], }); - expect(s.seamCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); }); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 456a5c1276..2eb4d64992 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -3,65 +3,55 @@ Diffs local migrations state against declarative schema files and writes the delta as a new timestamped migration. -Pg-delta runs in-process by default and uses two scoped shadow databases. Set -`SUPABASE_USE_PG_DELTA_NEXT=false` for the legacy catalog/edge-runtime path; -there is no automatic fallback. Coverage gaps warn; `--strict-coverage` makes +Pg-delta runs in-process and uses two scoped shadow databases. Coverage gaps +warn; `--strict-coverage` makes them fatal, while `PGDELTA_DEBUG` writes diagnostic JSON under -`supabase/.temp/pgdelta/v2/debug//`. Bundled output may use different SQL -and ordered transaction-aware files but must apply and converge. `--no-cache` -bypasses the bundled engine's shadow baseline cache and the legacy opt-out's -catalog + snapshot caches. The bundled formatter defaults to lowercase SQL +`supabase/.temp/pgdelta/v2/debug//`. The engine may emit ordered +transaction-aware files; applicable, convergent SQL is the contract. `--no-cache` +bypasses the engine's shadow baseline cache. The bundled formatter defaults to +lowercase SQL at width 180; config overrides it, and JSON `null` disables formatting without disabling safe compaction. ## Files Read -| Path | Format | When | -| --------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always — pg-delta gate, format options | -| `/supabase/.temp/pgdelta-version` | plain text | loaded for compatibility; legacy opt-out only | -| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out's edge-runtime image tag | -| `/supabase/schemas/**/*.sql` (default declarative dir) | SQL | always — must exist (else error) | -| `/supabase/migrations/*.sql` | SQL | bundled engine applies them to a live shadow; legacy opt-out resolves a migrations catalog | -| `/supabase/roles.sql` | SQL | legacy migrations-catalog cache key (empty when absent); separately hashed into the shadow-baseline cache key on every cache-eligible acquire — bundled-engine shadows and the legacy opt-out's catalog miss alike, warm hits included — and applied to a cold shadow's baseline | -| `/supabase/schemas/.pgdelta-export.json` | JSON | bundled export metadata, when present | -| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's migrations/declarative catalog cache | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — bundled-engine migrations/declarative shadows, and the legacy opt-out's catalog miss; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | +| Path | Format | When | +| --------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — pg-delta gate, format options | +| `/supabase/schemas/**/*.sql` (default declarative dir) | SQL | always — must exist (else error) | +| `/supabase/migrations/*.sql` | SQL | applied to the live migrations shadow | +| `/supabase/roles.sql` | SQL | hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included, and applied to a cold shadow's baseline; missing file tolerated (hashed as empty) | +| `/supabase/schemas/.pgdelta-export.json` | JSON | export metadata, when present | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit (migrations/declarative shadows); every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/_[_].sql` | SQL | changes; bundled engine may emit ordered segments | -| `/supabase/schemas/extension.sql` | SQL | accepted legacy-extension repair | -| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | legacy opt-out's catalog cache | -| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot — bundled-engine migrations/declarative shadows, and the legacy opt-out's catalog miss (a catalog hit provisions no shadow; `--no-cache` bypasses the snapshot cache entirely — neither read nor written); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | +| Path | Format | When | +| --------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/_[_].sql` | SQL | changes; bundled engine may emit ordered segments | +| `/supabase/schemas/extension.sql` | SQL | accepted legacy-extension repair | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot — migrations/declarative shadows (`--no-cache` bypasses the snapshot cache entirely — neither read nor written); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | ## Subprocesses / Containers -| What | When | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| Two natively-provisioned shadows (migrated source + declarative target) via `legacyAcquireShadowDatabase` — ephemeral host ports, settings-keyed global baseline cache | bundled engine | -| Natively-provisioned shadow Postgres container (`legacyCreateShadowDatabase`/`legacyPrepareShadowSource`) + native migrate; the catalog itself is exported via edge-runtime | legacy opt-out, migrations-catalog cache miss | -| Natively-provisioned shadow Postgres container (platform-baseline setup via one-shot auth/storage/realtime migrate jobs, then the declarative directory applied via the pg-delta edge-runtime apply script) → catalog export | legacy opt-out, declarative-catalog cache miss | -| Edge-runtime container running the pg-delta diff and, on a catalog cache miss, catalog-export/declarative-apply scripts | legacy opt-out | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | +| What | When | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Two natively-provisioned shadows (migrated source + declarative target) via `legacyAcquireShadowDatabase` — ephemeral host ports, settings-keyed global baseline cache | always | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | -| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | -| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | -| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | -| `SUPABASE_SERVICES_HOSTNAME` | local DB host for the bootstrap generate | no | -| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | -| `SUPABASE_USE_SLIM_IMAGES` | resolves current-pin shadow Postgres, PG15+ realtime/storage/auth migrate-job images, and (legacy opt-out) the edge-runtime catalog/export image from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, flag-off `15.8.1.085`, `deno_version = 1`, and historical `.temp/edge-runtime-version` pins stay on docker.io | no | +| Variable | Purpose | Required? | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | +| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | +| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | +| `SUPABASE_SERVICES_HOSTNAME` | local DB host for the bootstrap generate | no | +| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the current-pin shadow Postgres and PG15+ realtime/storage/auth migrate-job images from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, and flag-off `15.8.1.085` stay on docker.io | no | ## Exit Codes @@ -85,8 +75,7 @@ first, so a closed gate (missing `--experimental`) surfaces before an Text mode only. The generated SQL, the created-migration path, drop-statement warnings, and apply status are written to stderr. The no-files bootstrap also prints `Declarative schema written to

` (the relative declarative dir) to -stderr after generating and writing (and, under the -legacy opt-out, warming the catalog cache) — on both interactive and `--yes` paths. +stderr after generating and writing — on both interactive and `--yes` paths. `--no-apply` writes the migration only (never prompts/applies); `--apply` applies without prompting; both override the global `--yes`. `--no-apply` and `--apply` are mutually exclusive. @@ -135,10 +124,8 @@ existing SQL or creates an export manifest. (the reset itself is native too — `legacyResetLocalDatabase` — run in-process, sharing this command's own telemetry/linked-project-cache finalizer cycle rather than firing a second one from a child process). -- **Architecture:** the bundled engine plans and renders in-process from two live - shadows. Under the legacy opt-out, both catalog shadows are provisioned - in-process using the same primitives as `db diff`; catalog export, - declarative apply, and diff run through the edge-runtime pg-delta scripts. +- **Architecture:** the engine plans and renders in-process from two live + shadows. - **Stale local-container guard.** Before diffing against the running local `db` target, the running container's actual image is inspected and compared against the currently-configured/resolved one. A same-tag family mismatch @@ -175,10 +162,3 @@ Session-semantics caveat on the cached paths: migrations run on a session opened platform baseline, so role-level defaults installed by `supabase/roles.sql` (`ALTER ROLE … SET …`) apply to migration execution; with the cache off, the single-session flow runs migrations before those defaults take effect. - -Under the legacy opt-out, every catalog-miss shadow (migrations, baseline, declarative) goes -through `exportViaShadowCatalog` (`legacy-pgdelta.cache.ts`), the same -`legacyWithShadowDatabase` seam `db diff`/`db pull` use. `--no-cache` bypasses that snapshot -cache along with the catalog cache. Catalog provisioners wait with `legacyWaitForShadowReady` -and thread baseline state through `legacySetupShadowDatabase` so a warm hit does not -double-apply the baseline. diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts index 4ddcd20c99..547f05ceac 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts @@ -18,7 +18,6 @@ const CLEANUP_HOOK_TIMEOUT_MS = CLEANUP_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; const SCENARIO_COMMAND_TIMEOUT_MS = 280_000; const BEFORE_ALL_TIMEOUT_MS = CLI_COMMAND_TIMEOUT_MS + STACK_START_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; const SCENARIO_TIMEOUT_MS = 900_000; -const NEXT_ENV = { SUPABASE_USE_PG_DELTA_NEXT: "true" }; const initialDesiredSchema = `create type public.account_state as enum ('pending', 'active'); @@ -139,7 +138,6 @@ describe("db schema declarative sync (e2e)", () => { { entrypoint: "legacy", cwd: projectDir, - env: NEXT_ENV, exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, }, ); @@ -170,7 +168,6 @@ describe("db schema declarative sync (e2e)", () => { { entrypoint: "legacy", cwd: projectDir, - env: NEXT_ENV, exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, }, ); @@ -187,7 +184,6 @@ describe("db schema declarative sync (e2e)", () => { { entrypoint: "legacy", cwd: projectDir, - env: NEXT_ENV, exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, }, ); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index c48b1fafbf..e0bd52a369 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -28,12 +28,8 @@ import { LEGACY_ENABLE_LOCAL_WEBHOOKS_SUGGESTION } from "../../../../../command- import { legacyReadProjectRefFile } from "../../../../../command-internal/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, - legacyResolveSetupInputs, -} from "../../../../../command-internal/legacy-pgdelta.cache.ts"; +import { legacyListLocalMigrations } from "../../../../../command-internal/legacy-migration-list.ts"; import { legacyPgDeltaTempPath } from "../../../../../command-internal/legacy-pgdelta.paths.ts"; -import { LegacyPgDeltaEngine } from "../../../shared/legacy-pgdelta-engine.service.ts"; import { legacyIsPgDeltaDebugEnabled, legacyResolvePgDeltaProjectId, @@ -117,7 +113,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); const dnsResolver = yield* LegacyDnsResolverFlag; const seam = yield* LegacyDeclarativeSeam; - const engine = yield* LegacyPgDeltaEngine; const linkedProjectCache = yield* LegacyLinkedProjectCache; // Go's sync bootstrap delegates to `runDeclarativeGenerate`, whose @@ -185,7 +180,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara cliSettings.workdir, ), cwd: cliSettings.workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, projectEnv: toml.projectEnv, }, @@ -282,7 +276,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara linkedRef, ensureLocalPostgresImageCurrent, ); - const generated = yield* legacyGenerateDeclarativeOutput(run, toml, target); + const generated = yield* legacyGenerateDeclarativeOutput(run, target); const written = yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, generated); // A manifest-less directory keeps files the export did not replace, and those // files go straight into the plan below — warn before diffing against them. @@ -294,16 +288,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara }), ); } - // Go's bootstrap delegates to the full `declarative.Generate`, which warms the - // declarative catalog cache when --no-cache is unset (`declarative.go:133-157`, - // `cmd/db_schema_declarative.go:321`) — applying the just-generated schema to a - // shadow DB so an unappliable schema fails HERE, before building the migrations - // catalog / emitting a diff debug bundle, and warming the catalog the following - // diff reuses. (sync is target-less and writes to the single toml-resolved dir, - // so the generate handler's remote-override dir guard isn't needed here.) - if (!run.noCache && engine.implementation === "legacy") { - yield* seam.exportCatalog({ mode: "declarative", noCache: run.noCache }); - } // Go's delegated `declarative.Generate` prints the written-to line to stderr // after the write and the catalog warm (`declarative.go:133→138-155→156`), on // both the interactive-accept and --yes/SUPABASE_YES bootstrap paths, and @@ -314,17 +298,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara } // Step 2: diff migrations state vs declarative; on error, save a debug bundle. - // `setupInputs` is the cache-key/baseline-setup subset of `toml` that the now- - // native migrations-catalog resolution needs (CLI-1959) — see - // `legacyResolveSetupInputs`'s doc comment. - const setupInputs = yield* legacyResolveSetupInputs( - fs, - path, - cliSettings.workdir, - toml.majorVersion, - Option.getOrUndefined(toml.orioledbVersion), - toml.baseline, - ); const stageNextExport = Effect.fnUntraced(function* () { const stagedDir = path.resolve(cliSettings.workdir, stagedDirRel); // Reject the active directory itself AND anything nested under it: a @@ -387,7 +360,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara } const generated = yield* legacyGenerateDeclarativeOutput( { ...run, declarativeDir: stagedDir }, - toml, legacyLocalEndpoint({ port: toml.port, password: toml.password }, dnsResolver), ); const written = yield* legacyWriteDeclarativeSchemas(fs, path, stagedDir, generated); @@ -407,7 +379,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara }); const planDeclarativeSync = () => - legacyDiffDeclarativeToMigrations(run, toml, setupInputs).pipe( + legacyDiffDeclarativeToMigrations(run, toml).pipe( Effect.tapError((error) => error instanceof LegacyDeclarativeCompatibilityError ? Effect.void @@ -509,7 +481,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // migration writing after the first missing extension is declared. while (true) { if ( - engine.implementation === "next" && !result.manifestPresent && !toml.webhooksEnabled && result.removals.extensions.includes("pg_net") @@ -525,7 +496,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara ); } const compatibility = legacyClassifyDeclarativeCompatibilityGap({ - implementation: engine.implementation, manifestPresent: result.manifestPresent, removals: result.removals, }); @@ -621,7 +591,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // Step 5: write the timestamped migration file. const nowMillis = yield* Clock.currentTimeMillis; let migrationPaths: ReadonlyArray; - if (engine.implementation === "next" && result.files.length > 1) { + if (result.files.length > 1) { const written = yield* legacyWritePgDeltaMigrations(fs, path, { workdir: cliSettings.workdir, baseMillis: nowMillis, @@ -646,9 +616,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara if (result.dropWarnings.length > 0) { yield* output.raw( `${legacyYellow( - engine.implementation === "next" - ? "Found destructive changes in schema diff. Please double check if these are expected:" - : "Found drop statements in schema diff. Please double check if these are expected:", + "Found destructive changes in schema diff. Please double check if these are expected:", )}\n`, "stderr", ); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index ec2cb310b2..04a58c9433 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -44,12 +44,6 @@ import { LegacyDbConnection, type LegacyPgConnInput, } from "../../../../../command-internal/legacy-db-connection.service.ts"; -import { - type LegacyEdgeRuntimeRunOpts, - LegacyEdgeRuntimeScript, -} from "../../../../../command-internal/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; -import { legacyPgDeltaLegacyEngineLayer } from "../../../shared/legacy-pgdelta-engine.legacy.layer.ts"; import { LegacyPgDeltaEngine, LegacyPgDeltaEngineError, @@ -61,19 +55,6 @@ import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.servi import type { LegacyDbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; import { legacyDbSchemaDeclarativeSync } from "./sync.handler.ts"; -const EXPORT_JSON = JSON.stringify({ - version: 1, - mode: "declarative", - files: [ - { - path: "schemas/public/tables/players.sql", - order: 0, - statements: 1, - sql: "create table players ();", - }, - ], -}); - interface SetupOpts { experimental?: boolean; args?: ReadonlyArray; @@ -94,8 +75,6 @@ interface SetupOpts { networkId?: string; projectId?: Option.Option; staleLocalImage?: boolean; - exportJson?: string; - engineImplementation?: "legacy" | "next"; renderedFiles?: ReadonlyArray; removals?: LegacyPgDeltaRemovalSummary; planErrors?: ReadonlyArray; @@ -117,25 +96,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const child = mockContainerCliSpawner( defaultLocalResetRoute("test", { running: opts.resetShouldFail !== true }), ); - // Each catalog export records how many raw chunks had been emitted when it fired, - // so tests can assert output ordering relative to the exports (e.g. the bootstrap's - // written-to line lands after the declarative warm, before the diff's exports). - const exportCatalogCalls: Array<{ mode: string; rawChunksAt: number }> = []; - // The migrations-catalog source now resolves natively (CLI-1959 cache mechanics - // + CLI-1956 shadow provisioning) via `legacyGetMigrationsCatalogRef`, which - // provisions its shadow through the SAME `legacyCreateShadowDatabase`/ - // `legacyPrepareShadowSource`/`legacyRemoveShadowDatabase` primitives `db - // diff`/`db pull` use for their own shadow — via `child.layer`/ - // `legacyDockerRunLayer` below (the same real container-lifecycle mocks - // `legacyResetLocalDatabase`'s own recovery-reset flow already needs), not the - // retired `db __shadow` seam. "baseline"/"declarative" still go through - // `exportCatalog`. const seam = Layer.succeed(LegacyDeclarativeSeam, { - exportCatalog: ({ mode }) => - Effect.sync(() => { - exportCatalogCalls.push({ mode, rawChunksAt: out.rawChunks.length }); - return `supabase/.temp/pgdelta/${mode}.json`; - }), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.sync(() => { @@ -152,41 +113,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), ), }); - const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - // The native migrations-catalog resolution's shadow export — return a fixed, - // non-empty snapshot so it never trips `legacyExportCatalogPgDelta`'s - // empty-output check regardless of what `opts.diffSql` a given test sets. - if (runOpts.errPrefix === "error exporting pg-delta catalog") { - return Effect.succeed({ stdout: '{"schemas":[]}', stderr: "" }); - } - if ( - opts.exportJson !== undefined && - runOpts.errPrefix === "error exporting declarative schema" - ) { - return Effect.succeed({ stdout: opts.exportJson, stderr: "" }); - } - const diffSql = opts.diffSql ?? ""; - // The pg-delta diff script (uniquely identified by `renderPlanFiles`) prints a - // JSON envelope with one file per plan unit; wrap the test's raw SQL into a - // single-unit envelope so `legacyDiffPgDelta` parses it. - const stdout = - runOpts.script.includes("renderPlanFiles") && diffSql.length > 0 - ? JSON.stringify({ - version: 1, - files: [ - { - order: 1, - name: "schema_changes", - transactionMode: "transactional", - sql: diffSql, - }, - ], - }) - : diffSql; - return Effect.succeed({ stdout, stderr: "" }); - }, - }); const dbExec: string[] = []; const dbBatches: Array> = []; // Go's default `[db] shadow_port` (`legacy-db-config.toml-read.ts`'s @@ -252,10 +178,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); - const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); const runtimeInfo = mockRuntimeInfo({ platform: "linux" }); const processControl = mockProcessControl(); const experimentalFlag = Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true); @@ -271,80 +193,56 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.provide(child.layer), Layer.provide(processControl.layer), ); - const engineRuntime = Layer.mergeAll( - seam, - edge, - sslProbe, - out.layer, - dbConn, - runtimeInfo, - experimentalFlag, - cliArgs, - networkIdFlag, - debugFlag, - processControl.layer, - alwaysReadyHttpClientLayer, - dockerRun, - BunServices.layer, - child.layer, - ); const nextFiles = opts.renderedFiles ?? []; const planErrors = [...(opts.planErrors ?? [])]; let planCalls = 0; const declarativeExportCalls: Array> = []; - const engine = - opts.engineImplementation === "next" - ? Layer.succeed( - LegacyPgDeltaEngine, - LegacyPgDeltaEngine.of({ - implementation: "next", - diffExplicit: () => Effect.die("diffExplicit not used in sync tests"), - diffDatabase: () => Effect.die("diffDatabase not used in sync tests"), - exportDeclarativeSchema: (input) => - Effect.sync(() => { - declarativeExportCalls.push(input.schema); - return { - files: [{ name: "public/tables/players.sql", sql: "create table players ();" }], - manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, - }; - }), - planDeclarativeSchema: () => { - planCalls += 1; - const planError = planErrors.shift(); - if (planError !== undefined) return Effect.fail(planError); - const extensionPath = join(workdir, "supabase", "schemas", "extension.sql"); - const extensionSql = existsSync(extensionPath) - ? readFileSync(extensionPath, "utf8") - : ""; - const remainingExtensions = (opts.removals?.extensions ?? []).filter( - (extension) => !extensionSql.includes(`"${extension}"`), - ); - const extensionsRepaired = - remainingExtensions.length < (opts.removals?.extensions.length ?? 0); - return Effect.succeed({ - changes: nextFiles.length > 0, - sql: - extensionsRepaired && opts.replannedDiffSql !== undefined - ? opts.replannedDiffSql - : (opts.diffSql ?? nextFiles.map((file) => file.sql).join("\n")), - files: nextFiles, - sourceRef: "migrations", - targetRef: "declarative", - removals: - opts.removals === undefined - ? undefined - : { ...opts.removals, extensions: remainingExtensions }, - }); - }, - }), - ) - : legacyPgDeltaLegacyEngineLayer.pipe(Layer.provide(engineRuntime)); + const engine = Layer.succeed( + LegacyPgDeltaEngine, + LegacyPgDeltaEngine.of({ + diffExplicit: () => Effect.die("diffExplicit not used in sync tests"), + diffDatabase: () => Effect.die("diffDatabase not used in sync tests"), + exportDeclarativeSchema: (input) => + Effect.sync(() => { + declarativeExportCalls.push(input.schema); + return { + files: [{ name: "public/tables/players.sql", sql: "create table players ();" }], + manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, + }; + }), + planDeclarativeSchema: () => { + planCalls += 1; + const planError = planErrors.shift(); + if (planError !== undefined) return Effect.fail(planError); + const extensionPath = join(workdir, "supabase", "schemas", "extension.sql"); + const extensionSql = existsSync(extensionPath) ? readFileSync(extensionPath, "utf8") : ""; + const remainingExtensions = (opts.removals?.extensions ?? []).filter( + (extension) => !extensionSql.includes(`"${extension}"`), + ); + const extensionsRepaired = + remainingExtensions.length < (opts.removals?.extensions.length ?? 0); + return Effect.succeed({ + changes: nextFiles.length > 0, + sql: + extensionsRepaired && opts.replannedDiffSql !== undefined + ? opts.replannedDiffSql + : (opts.diffSql ?? nextFiles.map((file) => file.sql).join("\n")), + files: nextFiles, + sourceRef: "migrations", + targetRef: "declarative", + removals: + opts.removals === undefined + ? undefined + : { ...opts.removals, extensions: remainingExtensions }, + }); + }, + }), + ); const layer = Layer.mergeAll( out.layer, telemetry.layer, cache.layer, seam, - edge, engine, dbConn, resolver, @@ -357,8 +255,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { networkIdFlag, Layer.succeed(LegacyDnsResolverFlag, "native"), debugFlag, - // Sync diffs against the local DB, which refuses TLS → no SSL env injected. - sslProbe, // The local-reset bucket-seed core statically requires the (lazy) Management-API // factory; never invoked on the local recovery reset (projectRef === ""). Layer.succeed(LegacyPlatformApiFactory, { @@ -383,7 +279,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { cache, telemetry, localPostgresImageChecks, - exportCatalogCalls, declarativeExportCalls, get planCalls() { return planCalls; @@ -713,59 +608,41 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("bootstrap prints the declarative-schema-written line after the catalog warm", () => { - // Go's bootstrap delegates to `declarative.Generate`, which prints - // `Declarative schema written to ` to stderr AFTER WriteDeclarativeSchemas - // and the catalog warm (`declarative.go:133→138-155→156`), before sync's own - // diff (step 2). It prints `utils.GetDeclarativeDir()` — the relative - // `supabase/schemas` default — never the absolute resolved dir (CLI-1980). + it.effect("bootstrap prints the declarative-schema-written line after generating", () => { + // The bootstrap prints `Declarative schema written to ` to stderr after + // writing the generated files, before sync's own diff (step 2). It prints the + // relative `supabase/schemas` default — never the absolute resolved dir + // (CLI-1980). const s = setup(tmp.current, { experimental: true, stdinIsTty: true, diffSql: "", - exportJson: EXPORT_JSON, promptConfirmResponses: [true], // generate a new one? yes (no migrations → no reset prompt) }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); const line = `Declarative schema written to ${join("supabase", "schemas")}\n`; const written = s.out.rawChunks - .map((c, index) => ({ text: stripAnsi(c.text), stream: c.stream, index })) + .map((c) => ({ text: stripAnsi(c.text), stream: c.stream })) .filter((c) => c.text === line); expect(written).toHaveLength(1); expect(written[0]?.stream).toBe("stderr"); - const lineAt = written[0]?.index ?? -1; - // The warm (first declarative-mode export) fires before the line is printed… - const warm = s.exportCatalogCalls.find((c) => c.mode === "declarative"); - expect(warm?.rawChunksAt).toBeLessThanOrEqual(lineAt); - // …and the diff's migrations-catalog resolution (native, CLI-1959 cache - // mechanics + CLI-1956 native shadow provisioning — no seam `exportCatalog` - // call for it at all) fires after it, so the line sits at the end of the - // bootstrap, matching Go's ordering. `legacyGetMigrationsCatalogRef` prints - // "Creating shadow database..." right before provisioning; use that line's - // own position as the "diff's shadow started" signal. - const diffStartIndex = s.out.rawChunks.findIndex( - (c) => c.stream === "stderr" && stripAnsi(c.text) === "Creating shadow database...\n", - ); - expect(diffStartIndex).toBeGreaterThan(lineAt); // The generated files actually landed in the printed (resolved) dir. expect( - existsSync( - join(tmp.current, "supabase", "schemas", "schemas", "public", "tables", "players.sql"), - ), + existsSync(join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql")), ).toBe(true); + expect(s.declarativeExportCalls).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); it.effect("--yes bootstrap prints the declarative-schema-written line too", () => { - // Go reaches the same delegated `declarative.Generate` print on the - // auto-confirmed (--yes / SUPABASE_YES) bootstrap as on the interactive accept. + // The auto-confirmed (--yes / SUPABASE_YES) bootstrap reaches the same + // written-to print as the interactive accept. const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: true, diffSql: "", - exportJson: EXPORT_JSON, }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); @@ -778,73 +655,6 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("--no-cache bootstrap still prints the declarative-schema-written line", () => { - // Go's print sits OUTSIDE the `if !noCache` warm gate (`declarative.go:138-156`): - // skipping the catalog warm must not skip the line. - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: false, - yes: true, - diffSql: "", - exportJson: EXPORT_JSON, - }); - return Effect.gen(function* () { - yield* legacyDbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })); - const line = `Declarative schema written to ${join("supabase", "schemas")}\n`; - const written = s.out.rawChunks - .map((c, index) => ({ text: stripAnsi(c.text), stream: c.stream, index })) - .filter((c) => c.text === line); - expect(written).toHaveLength(1); - expect(written[0]?.stream).toBe("stderr"); - // The warm really was skipped: the only declarative-mode export is the diff's, - // which fires after the line — yet the line still printed. - const lineAt = written[0]?.index ?? -1; - const declarativeExports = s.exportCatalogCalls.filter((c) => c.mode === "declarative"); - expect(declarativeExports).toHaveLength(1); - expect(declarativeExports[0]?.rawChunksAt).toBeGreaterThan(lineAt); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect( - "validates the migrations-catalog shadow's own local config (api.tls cert file) BEFORE printing 'Creating shadow database...'", - () => { - // `legacyGetMigrationsCatalogRef`'s own second `@supabase/config` load - // (`legacyBuildLocalDbContainerInputs`, run via `legacyBuildShadowCatalogInputs`) - // validates fields (e.g. an enabled API TLS's cert/key files) that `toml` never - // reads — Go performs this exact validation once, in the root - // `PersistentPreRunE`, strictly before `declarative.go`'s `createShadowContainer` - // ever prints "Creating shadow database..." (`declarative.go:490`). So a broken - // build must fail here without ever printing that banner. - seedDeclarative(tmp.current); - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[api]", - "enabled = true", - "[api.tls]", - "enabled = true", - 'cert_path = "missing-cert.pem"', - 'key_path = "missing-key.pem"', - "", - ].join("\n"), - ); - const s = setup(tmp.current, { experimental: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags())); - expect(Exit.isFailure(exit)).toBe(true); - expect((failError(exit) as { message: string }).message).toContain( - "failed to read TLS cert", - ); - expect( - s.out.rawChunks.some( - (c) => c.stream === "stderr" && stripAnsi(c.text) === "Creating shadow database...\n", - ), - ).toBe(false); - }).pipe(Effect.provide(s.layer)); - }, - ); - it.effect("bootstrap with migrations offers the smart target choice (not local-only)", () => { // Go delegates the no-files bootstrap to runDeclarativeGenerate; with migrations // present it offers local/linked/custom rather than silently generating from @@ -902,7 +712,6 @@ describe("legacy db schema declarative sync integration", () => { staleLocalImage: true, projectId: Option.some("abcdefghijklmnopqrst"), diffSql: "ALTER TABLE a ADD COLUMN b int;\n", - exportJson: EXPORT_JSON, promptConfirmResponses: [true], // generate a new one? yes promptSelectResponses: ["linked"], }); @@ -966,11 +775,13 @@ describe("legacy db schema declarative sync integration", () => { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })), ); - // The probe was softened: it reached generation and failed downstream on the - // empty edge-runtime output, NOT on the migrations directory read. - const msg = JSON.stringify(exit); - expect(msg).not.toContain("failed to read directory"); - expect(msg).toContain("edge-runtime script produced no output"); + // The probe was softened: it reached generation (files written, sync + // completed on the empty diff), NOT an abort on the migrations directory read. + expect(JSON.stringify(exit)).not.toContain("failed to read directory"); + expect(Exit.isSuccess(exit)).toBe(true); + expect( + existsSync(join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql")), + ).toBe(true); }).pipe(Effect.provide(s.layer)); }); @@ -1003,12 +814,11 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("bootstrap caches the linked project even when a later step fails (Go PostRun)", () => { - // Go's bootstrap delegates to runDeclarativeGenerate, whose LoadProjectRef (under - // hasMigrationFiles) sets flags.ProjectRef; root ensureProjectGroupsCached then - // writes the linked-project cache on success OR failure (cmd/root.go:176,214-218). - // Here the bootstrap resolves the linked ref then fails (empty generate output), - // and the linked-project cache must still be written. + it.effect("bootstrap caches the linked project after resolving the ref", () => { + // The bootstrap resolves the linked ref (config project_id → .temp/project-ref) + // when migrations exist, and the handler's finalizer writes the linked-project + // cache whether sync succeeds or fails. Here it resolves the ref, bootstraps + // from local, and completes on the empty diff — the cache must be written. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { @@ -1067,7 +877,9 @@ describe("legacy db schema declarative sync integration", () => { const migrations = readdirSync(join(tmp.current, "supabase", "migrations")); expect(migrations).toHaveLength(1); expect(migrations[0]).toMatch(/^\d{14}_declarative_sync\.sql$/); - expect(s.out.rawChunks.some((c) => c.text.includes("Found drop statements"))).toBe(true); + expect( + s.out.rawChunks.some((c) => c.text.includes("Found destructive changes in schema diff")), + ).toBe(true); expect(s.dbExec).toEqual([]); // not applied }).pipe(Effect.provide(s.layer)); }, @@ -1096,7 +908,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("refuses a known implicit-extension load failure under --yes", () => { seedLegacyUuidDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", yes: true, planErrors: [legacyUuidLoadError()], }); @@ -1125,7 +936,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("adds a missing load-time extension declaration and re-plans", () => { seedLegacyUuidDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, planErrors: [legacyUuidLoadError()], promptSelectResponses: ["repair"], @@ -1152,7 +962,6 @@ describe("legacy db schema declarative sync integration", () => { ); const before = readFileSync(activeMember, "utf8"); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, planErrors: [legacyUuidLoadError()], promptSelectResponses: ["stage"], @@ -1195,7 +1004,6 @@ describe("legacy db schema declarative sync integration", () => { ); const before = readFileSync(activeMember, "utf8"); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, planErrors: [legacyUuidLoadError()], promptSelectResponses: ["stage"], @@ -1225,7 +1033,6 @@ describe("legacy db schema declarative sync integration", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, - engineImplementation: "next", yes: true, diffSql: "select cron.unschedule('refresh download metrics');\nDROP EXTENSION \"pgcrypto\";\n", @@ -1258,7 +1065,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("writes cron job and pgmq queue removals without a legacy-export refusal", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", yes: true, diffSql: "select cron.unschedule('refresh metrics');\nselect pgmq.drop_queue('emails');\n", removals: { @@ -1283,7 +1089,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("directs pg_net users to enable Database Webhooks before writing", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, diffSql: 'DROP EXTENSION "pg_net";\n', removals: { extensions: ["pg_net"], extensionIntents: [] }, @@ -1305,7 +1110,6 @@ describe("legacy db schema declarative sync integration", () => { () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, diffSql: 'DROP EXTENSION "pgcrypto";\n', removals: { extensions: ["pgcrypto"], extensionIntents: [] }, @@ -1321,7 +1125,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("repairs the active tree in place when the user picks the advanced choice", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, diffSql: 'DROP EXTENSION "pgcrypto";\n', replannedDiffSql: "ALTER TABLE a ADD COLUMN b int;\n", @@ -1341,7 +1144,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("stages a next export from the repair prompt without touching the tree", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, diffSql: 'DROP EXTENSION "pgcrypto";\n', removals: { extensions: ["pgcrypto"], extensionIntents: [] }, @@ -1368,7 +1170,6 @@ describe("legacy db schema declarative sync integration", () => { // the spawner route's assumption (same as the apply-failure reset test). writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, diffSql: 'DROP EXTENSION "pgcrypto";\n', removals: { extensions: ["pgcrypto"], extensionIntents: [] }, @@ -1393,7 +1194,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("cancels compatibility resolution without schema or migration writes", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, diffSql: 'DROP EXTENSION "uuid-ossp";\n', removals: { extensions: ["uuid-ossp"], extensionIntents: [] }, @@ -1414,14 +1214,13 @@ describe("legacy db schema declarative sync integration", () => { ); const s = setup(tmp.current, { experimental: true, - engineImplementation: "next", diffSql: 'DROP EXTENSION "pgcrypto";\n', removals: { extensions: ["pgcrypto"], extensionIntents: [] }, }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); const output = stripAnsi(s.out.rawChunks.map((chunk) => chunk.text).join("")); - expect(output).not.toContain("may have been generated by the legacy engine"); + expect(output).not.toContain("looks like a legacy pg-delta export"); expect(output).toContain("Found destructive changes"); }).pipe(Effect.provide(s.layer)); }); @@ -1537,11 +1336,10 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("next engine preserves ordered migration segments as separate files", () => { + it.effect("preserves ordered migration segments as separate files", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, - engineImplementation: "next", renderedFiles: [ { sequence: 1, @@ -1565,7 +1363,6 @@ describe("legacy db schema declarative sync integration", () => { expect(migrations).toHaveLength(2); expect(migrations[0]).toMatch(/^\d{14}_declarative_sync_1\.sql$/); expect(migrations[1]).toMatch(/^\d{14}_declarative_sync_2\.sql$/); - expect(s.exportCatalogCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); }); diff --git a/apps/cli/src/commands/db/shared/legacy-debug-bundle.ts b/apps/cli/src/commands/db/shared/legacy-debug-bundle.ts index c8658192ea..8f4c24685e 100644 --- a/apps/cli/src/commands/db/shared/legacy-debug-bundle.ts +++ b/apps/cli/src/commands/db/shared/legacy-debug-bundle.ts @@ -1,28 +1,19 @@ import { Effect, type FileSystem, type Path } from "effect"; import { legacyBold, legacyYellow } from "../../../command-internal/legacy-colors.ts"; -import { legacyListLocalMigrations } from "../../../command-internal/legacy-pgdelta.cache.ts"; +import { legacyListLocalMigrations } from "../../../command-internal/legacy-migration-list.ts"; /** - * 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). + * Diagnostic artifacts collected when a declarative pg-delta operation fails + * (`db schema declarative sync`): the catalog refs, the generated migration, the + * error, and the local migration files. */ 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; @@ -70,24 +61,17 @@ export const legacySaveDebugBundle = Effect.fnUntraced(function* ( // directory that was never created. yield* fs.makeDirectory(debugDir, { recursive: true }); - // 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). 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) { + // The catalog refs are workdir-relative paths (`supabase/.temp/pgdelta/...`), so + // resolve them against `workdir` rather than the process cwd (`path.resolve` + // leaves absolute refs unchanged). + if (bundle.sourceRef !== undefined && bundle.sourceRef.length > 0) { yield* copyBestEffort( fs, path.resolve(workdir, bundle.sourceRef), path.join(debugDir, "source-catalog.json"), ); } - 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) { + if (bundle.targetRef !== undefined && bundle.targetRef.length > 0) { yield* copyBestEffort( fs, path.resolve(workdir, bundle.targetRef), @@ -100,12 +84,6 @@ export const legacySaveDebugBundle = Effect.fnUntraced(function* ( if (bundle.error !== undefined && bundle.error.length > 0) { yield* writeBestEffort(fs, path.join(debugDir, "error.txt"), bundle.error); } - 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/commands/db/shared/legacy-go-string.ts b/apps/cli/src/commands/db/shared/legacy-go-string.ts index 5c9dfabba6..93d84a5288 100644 --- a/apps/cli/src/commands/db/shared/legacy-go-string.ts +++ b/apps/cli/src/commands/db/shared/legacy-go-string.ts @@ -2,9 +2,10 @@ * Go string-primitive helpers shared across the `db` command family. Currently * just `strings.TrimSpace`/`bytes.TrimSpace` — hoisted here (per the repo's * "hoist before you duplicate" rule, AGENTS.md) once a second `db`-family caller - * needed the exact same primitive: `legacy-pgdelta.apply.ts` (CLI-1956, apply - * error-detail trimming) and `legacy-pgadmin-diff.ts` (CLI-1968, `diff_ddl` - * trimming) each carried their own private, verbatim copy before this move. + * needed the exact same primitive: the since-removed legacy pg-delta apply + * module (CLI-1956, apply error-detail trimming) and `legacy-pgadmin-diff.ts` + * (CLI-1968, `diff_ddl` trimming) each carried their own private, verbatim copy + * before this move. */ /** diff --git a/apps/cli/src/commands/db/shared/legacy-migra.deno-templates.ts b/apps/cli/src/commands/db/shared/legacy-migra.deno-templates.ts index b765f2dd72..0ae44f1e82 100644 --- a/apps/cli/src/commands/db/shared/legacy-migra.deno-templates.ts +++ b/apps/cli/src/commands/db/shared/legacy-migra.deno-templates.ts @@ -3,8 +3,8 @@ // 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` + +// Rollback engine for `db diff --use-migra` and `db pull --diff-engine migra`. +// 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. diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.layer.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.layer.ts index fe054c8103..fae2d32afb 100644 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.layer.ts +++ b/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.layer.ts @@ -1,75 +1,21 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Layer } from "effect"; import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; import { legacyCliSettingsLayer } from "../../../config/legacy-cli-settings.layer.ts"; -import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { legacyDbConfigLayer } from "../../../command-internal/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../command-internal/legacy-db-connection.layer.ts"; -import { legacyLoadProjectEnv } from "../../../command-internal/legacy-db-config.toml-read.ts"; import { legacyDebugLoggerLayer } from "../../../command-internal/legacy-debug-logger.layer.ts"; -import { LegacyDebugLogger } from "../../../command-internal/legacy-debug-logger.service.ts"; import { legacyDockerRunLayer } from "../../../command-internal/legacy-docker-run.layer.ts"; import { legacyEdgeRuntimeScriptLayer } from "../../../command-internal/legacy-edge-runtime-script.layer.ts"; import { legacyIdentityStitchLayer } from "../../../command-internal/legacy-identity-stitch.ts"; import { legacyPgDeltaSslProbeLayer } from "../../../command-internal/legacy-pgdelta-ssl-probe.layer.ts"; -import { - LEGACY_PG_DELTA_NEXT_FLAG_NAME, - legacyPgDeltaImplementationFlag, - legacyResolvePgDeltaImplementation, -} from "../../../command-internal/legacy-pgdelta-next-flag.ts"; -import { legacyPgDeltaLegacyEngineLayer } from "./legacy-pgdelta-engine.legacy.layer.ts"; import { legacyPgDeltaNextEngineLayer } from "./legacy-pgdelta-engine.next.layer.ts"; -import { LegacyPgDeltaEngine } from "./legacy-pgdelta-engine.service.ts"; import { legacyPgDeltaNextAdapterLayer } from "./legacy-pgdelta-next-adapter.layer.ts"; import { legacyPgDeltaNextShadowLayer } from "./legacy-pgdelta-next-shadow.layer.ts"; import { legacyDeclarativeSeamLayer } from "./legacy-pgdelta.seam.layer.ts"; -const resolveAndLog = Effect.fnUntraced(function* (raw: string | undefined) { - const debug = yield* LegacyDebugLogger; - const implementation = legacyResolvePgDeltaImplementation(raw); - yield* debug.debug(`Using pg-delta ${implementation} implementation.`); - return implementation; -}); - -/** - * Selects exactly one implementation layer. There is intentionally no catch or - * retry path between implementations: a selected next-engine failure must - * propagate without invoking the legacy adapter. - */ -export function legacyPgDeltaEngineSelectorLayer( - raw: string | undefined, - layers: { - readonly next: Layer.Layer; - readonly legacy: Layer.Layer; - }, -): Layer.Layer { - return Layer.unwrap( - Effect.gen(function* () { - const implementation = yield* resolveAndLog(raw); - const selected: Layer.Layer = - implementation === "next" ? layers.next : layers.legacy; - return selected; - }), - ); -} - -/** Resolves the rollout flag once when the command-scoped layer is constructed. */ -export const legacyPgDeltaEngineLayer = Layer.unwrap( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cliSettings = yield* LegacyCliSettings; - const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliSettings.workdir); - const raw = legacyPgDeltaImplementationFlag( - process.env[LEGACY_PG_DELTA_NEXT_FLAG_NAME], - projectEnv[LEGACY_PG_DELTA_NEXT_FLAG_NAME], - ); - return legacyPgDeltaEngineSelectorLayer(raw, { - next: legacyPgDeltaNextEngineLayer, - legacy: legacyPgDeltaLegacyEngineLayer, - }); - }), -); +/** The in-process pg-delta engine — the only implementation. */ +export const legacyPgDeltaEngineLayer = legacyPgDeltaNextEngineLayer; export const legacyPgDeltaCliSettingsRuntimeLayer = legacyCliSettingsLayer.pipe( Layer.provide(legacyDebugLoggerLayer), @@ -82,17 +28,25 @@ export const legacyPgDeltaDbConfigRuntimeLayer = legacyDbConfigLayer.pipe( Layer.provide(legacyIdentityStitchLayer), ); -const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( - Layer.provide(legacyDockerRunLayer), - Layer.provide(legacyPgDeltaCliSettingsRuntimeLayer), +/** + * The migra runtime: the edge-runtime script runner and the TLS probe migra's + * containerized diff needs. Only `db diff` / migration-style `db pull` can select + * migra; the declarative commands run the in-process pg-delta engine alone, so + * this is composed by those two command layers rather than by + * {@link legacyPgDeltaCommandRuntimeLayer}. + */ +export const legacyMigraRuntimeLayer = Layer.mergeAll( + legacyEdgeRuntimeScriptLayer.pipe( + Layer.provide(legacyDockerRunLayer), + Layer.provide(legacyPgDeltaCliSettingsRuntimeLayer), + ), + legacyPgDeltaSslProbeLayer, ); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const seam = legacyDeclarativeSeamLayer.pipe( Layer.provide(legacyPgDeltaCliSettingsRuntimeLayer), Layer.provide(legacyDbConnectionLayer), Layer.provide(legacyDockerRunLayer), - Layer.provide(edgeRuntime), - Layer.provide(legacyPgDeltaSslProbeLayer), Layer.provide(httpClient), ); const nextShadow = legacyPgDeltaNextShadowLayer.pipe( @@ -104,9 +58,6 @@ const engine = legacyPgDeltaEngineLayer.pipe( Layer.provide(legacyPgDeltaCliSettingsRuntimeLayer), Layer.provide(legacyPgDeltaNextAdapterLayer), Layer.provide(nextShadow), - Layer.provide(edgeRuntime), - Layer.provide(legacyPgDeltaSslProbeLayer), - Layer.provide(seam), Layer.provide(legacyDockerRunLayer), Layer.provide(legacyDbConnectionLayer), Layer.provide(httpClient), @@ -116,8 +67,6 @@ const engine = legacyPgDeltaEngineLayer.pipe( export const legacyPgDeltaCommandRuntimeLayer = Layer.mergeAll( legacyDbConnectionLayer, legacyDockerRunLayer, - edgeRuntime, - legacyPgDeltaSslProbeLayer, httpClient, seam, engine, diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts deleted file mode 100644 index 23a39b5460..0000000000 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Effect, Exit, Layer } from "effect"; -import { it } from "@effect/vitest"; -import { describe, expect } from "vitest"; - -import { LegacyDebugLogger } from "../../../command-internal/legacy-debug-logger.service.ts"; -import { legacyPgDeltaEngineSelectorLayer } from "./legacy-pgdelta-engine.layer.ts"; -import { LegacyPgDeltaEngine } from "./legacy-pgdelta-engine.service.ts"; - -function debugLayer(messages: Array) { - return Layer.succeed(LegacyDebugLogger, { - debug: (message) => Effect.sync(() => messages.push(message)), - http: () => Effect.void, - }); -} - -function metadataLayer(implementation: "next" | "legacy") { - return Layer.succeed( - LegacyPgDeltaEngine, - LegacyPgDeltaEngine.of({ - implementation, - diffExplicit: () => Effect.die(`${implementation} explicit diff not needed`), - diffDatabase: () => Effect.die(`${implementation} database diff not needed`), - exportDeclarativeSchema: () => Effect.die(`${implementation} export not needed`), - planDeclarativeSchema: () => Effect.die(`${implementation} plan not needed`), - }), - ); -} - -describe("legacyPgDeltaEngineSelectorLayer", () => { - it.effect("selects next by default and logs the decision once", () => { - const messages: Array = []; - return Effect.gen(function* () { - const engine = yield* LegacyPgDeltaEngine; - expect(engine.implementation).toBe("next"); - expect(messages).toEqual(["Using pg-delta next implementation."]); - }).pipe( - Effect.provide( - legacyPgDeltaEngineSelectorLayer(undefined, { - next: metadataLayer("next"), - legacy: metadataLayer("legacy"), - }).pipe(Layer.provide(debugLayer(messages))), - ), - ); - }); - - it.effect("does not invoke legacy after a selected next operation fails", () => { - const messages: Array = []; - let nextCalls = 0; - let legacyCalls = 0; - const next = Layer.succeed( - LegacyPgDeltaEngine, - LegacyPgDeltaEngine.of({ - implementation: "next", - diffExplicit: () => - Effect.sync(() => { - nextCalls += 1; - }).pipe(Effect.andThen(Effect.die("next diff failed"))), - diffDatabase: () => Effect.die("next database diff failed"), - exportDeclarativeSchema: () => Effect.die("next export failed"), - planDeclarativeSchema: () => Effect.die("next plan failed"), - }), - ); - const legacy = Layer.succeed( - LegacyPgDeltaEngine, - LegacyPgDeltaEngine.of({ - implementation: "legacy", - diffExplicit: () => - Effect.sync(() => { - legacyCalls += 1; - return { - changes: false, - sql: "", - files: [], - }; - }), - diffDatabase: () => Effect.die("legacy database diff should not run"), - exportDeclarativeSchema: () => Effect.die("legacy export should not run"), - planDeclarativeSchema: () => Effect.die("legacy plan should not run"), - }), - ); - - return Effect.gen(function* () { - const engine = yield* LegacyPgDeltaEngine; - const exit = yield* engine - .diffExplicit({ - context: { - projectId: "test", - cwd: "/tmp/test", - npmVersion: undefined, - denoVersion: 2, - projectEnv: {}, - }, - source: { - kind: "database", - ref: "postgresql://localhost/source", - connectOptions: { isLocal: true, dnsResolver: "native" }, - }, - desired: { - kind: "database", - ref: "postgresql://localhost/desired", - connectOptions: { isLocal: true, dnsResolver: "native" }, - }, - schema: [], - formatOptions: "", - debug: false, - strictCoverage: false, - }) - .pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(nextCalls).toBe(1); - expect(legacyCalls).toBe(0); - }).pipe( - Effect.provide( - legacyPgDeltaEngineSelectorLayer("true", { next, legacy }).pipe( - Layer.provide(debugLayer(messages)), - ), - ), - ); - }); -}); diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts deleted file mode 100644 index e7e940c3fe..0000000000 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; -import * as HttpClient from "effect/unstable/http/HttpClient"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { - LegacyDebugFlag, - LegacyExperimentalFlag, - LegacyNetworkIdFlag, -} from "../../../shared/legacy/global-flags.ts"; -import { Output } from "../../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; -import { legacyYellow } from "../../../command-internal/legacy-colors.ts"; -import { LegacyDbConnection } from "../../../command-internal/legacy-db-connection.service.ts"; -import { LegacyEdgeRuntimeScript } from "../../../command-internal/legacy-edge-runtime-script.service.ts"; -import { LegacyDockerRun } from "../../../command-internal/legacy-docker-run.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; -import type { LegacyDbTomlValues } from "../../../command-internal/legacy-db-config.toml-read.ts"; -import { legacyFindDropStatements } from "../../../command-internal/legacy-sql-split.ts"; -import { - LegacyPgDeltaEngine, - LegacyPgDeltaEngineError, - type LegacyPgDeltaDiffResult, - type LegacyPgDeltaEndpoint, -} from "./legacy-pgdelta-engine.service.ts"; -import type { LegacyMigrationTransactionMode } from "../../../command-internal/legacy-migration-file.ts"; -import { - type LegacyPgDeltaContext, - legacyDeclarativeExportPgDelta, - legacyDiffPgDelta, - legacyExportCatalogPgDelta, -} from "../../../command-internal/legacy-pgdelta.ts"; -import { - legacyGetMigrationsCatalogRef, - legacyResolveMigrationsCatalogRef, -} from "../../../command-internal/legacy-pgdelta.cache.ts"; -import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; - -const mapError = (cause: { readonly message: string }) => - new LegacyPgDeltaEngineError({ message: cause.message, cause }); - -/** - * `--strict-coverage` is enforced entirely by the next engine's diagnostic report - * (`legacy-pgdelta-next-diagnostics.ts`); this adapter has no coverage diagnostics - * to reject, so the flag is a silent no-op under - * `SUPABASE_USE_PG_DELTA_NEXT=false`. Say so once instead, rather than letting a - * user believe an unsupported-object guard is active. Mirrors `db diff`'s - * `warnPgSchemaDeprecated` line shape. - */ -export const legacyStrictCoverageIgnoredWarning = `${legacyYellow( - "WARNING:", -)} "--strict-coverage" has no effect with the legacy pg-delta engine.`; - -function normalizeDiff( - result: { - readonly sql: string; - readonly stderr: string; - readonly files: ReadonlyArray<{ - readonly order: number; - readonly name: string; - readonly transactionMode: LegacyMigrationTransactionMode; - readonly sql: string; - }>; - }, - debug: boolean, -): LegacyPgDeltaDiffResult { - return { - changes: result.sql.trim().length > 0, - sql: result.sql, - files: result.files.map((file) => ({ - sequence: file.order, - name: file.name, - sql: file.sql, - transactionMode: file.transactionMode, - })), - ...(debug ? { debug: { stderr: result.stderr } } : {}), - }; -} - -/** Behavior-preserving adapter for the alpha.33 edge-runtime implementation. */ -export const legacyPgDeltaLegacyEngineLayer = Layer.effect( - LegacyPgDeltaEngine, - Effect.gen(function* () { - const edgeRuntime = yield* LegacyEdgeRuntimeScript; - const sslProbe = yield* LegacyPgDeltaSslProbe; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const seam = yield* LegacyDeclarativeSeam; - const output = yield* Output; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runtimeInfo = yield* RuntimeInfo; - const dbConnection = yield* LegacyDbConnection; - const docker = yield* LegacyDockerRun; - const httpClient = yield* HttpClient.HttpClient; - const cliArgs = yield* CliArgs; - const debugFlag = yield* LegacyDebugFlag; - const experimentalFlag = yield* LegacyExperimentalFlag; - const networkIdFlag = yield* LegacyNetworkIdFlag; - - const runtime = Layer.mergeAll( - Layer.succeed(LegacyEdgeRuntimeScript, edgeRuntime), - Layer.succeed(LegacyPgDeltaSslProbe, sslProbe), - Layer.succeed(FileSystem.FileSystem, fs), - Layer.succeed(Path.Path, path), - Layer.succeed(LegacyDeclarativeSeam, seam), - Layer.succeed(Output, output), - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - Layer.succeed(RuntimeInfo, runtimeInfo), - Layer.succeed(LegacyDbConnection, dbConnection), - Layer.succeed(LegacyDockerRun, docker), - Layer.succeed(HttpClient.HttpClient, httpClient), - Layer.succeed(CliArgs, cliArgs), - Layer.succeed(LegacyDebugFlag, debugFlag), - Layer.succeed(LegacyExperimentalFlag, experimentalFlag), - Layer.succeed(LegacyNetworkIdFlag, networkIdFlag), - ); - - const provideRuntime = ( - operation: Effect.Effect, - ) => operation.pipe(Effect.provide(runtime)); - - // Emitted from the engine layer, not from each handler: this is the single place - // where the resolved implementation and the per-operation input meet, so all four - // workflows (`db diff`, `db pull`, and declarative `generate`/`sync`) get the line - // with no per-command wiring. Once per process — `sync` can plan twice (extension - // repair re-plans) and a repeated line adds nothing. - let strictCoverageWarned = false; - const warnStrictCoverageIgnored = (strictCoverage: boolean) => - Effect.suspend(() => { - if (!strictCoverage || strictCoverageWarned) return Effect.void; - strictCoverageWarned = true; - return output.raw(`${legacyStrictCoverageIgnoredWarning}\n`, "stderr"); - }); - - const endpointRef = ( - context: LegacyPgDeltaContext, - endpoint: LegacyPgDeltaEndpoint, - toml: LegacyDbTomlValues | undefined, - ) => - endpoint.kind === "database" - ? Effect.succeed(endpoint.ref) - : toml === undefined - ? Effect.fail( - new LegacyPgDeltaEngineError({ - message: "pg-delta migrations endpoint requires loaded database config", - cause: "missing database config", - }), - ) - : legacyResolveMigrationsCatalogRef( - fs, - path, - context, - toml, - endpoint.projectRef !== undefined ? { projectRef: endpoint.projectRef } : {}, - ).pipe(provideRuntime); - - return LegacyPgDeltaEngine.of({ - implementation: "legacy", - diffExplicit: (input) => - Effect.gen(function* () { - yield* warnStrictCoverageIgnored(input.strictCoverage); - const sourceRef = yield* endpointRef(input.context, input.source, input.toml); - const targetRef = yield* endpointRef(input.context, input.desired, input.toml); - const result = yield* provideRuntime( - legacyDiffPgDelta(input.context, { - sourceRef, - targetRef, - schema: input.schema, - formatOptions: input.formatOptions, - }), - ); - return normalizeDiff(result, input.debug); - }).pipe(Effect.mapError(mapError)), - diffDatabase: (input) => - Effect.gen(function* () { - yield* warnStrictCoverageIgnored(input.strictCoverage); - const sourceSnapshot = input.debug - ? yield* provideRuntime( - legacyExportCatalogPgDelta(input.context, { - targetRef: input.source.ref, - role: "postgres", - }), - ).pipe(Effect.orElseSucceed(() => undefined)) - : undefined; - return yield* provideRuntime( - legacyDiffPgDelta(input.context, { - sourceRef: input.source.ref, - targetRef: input.target.ref, - schema: input.schema, - formatOptions: input.formatOptions, - }), - ).pipe( - Effect.map((result) => { - const normalized = normalizeDiff(result, input.debug); - return input.debug - ? { - ...normalized, - debug: { - ...(sourceSnapshot !== undefined ? { sourceSnapshot } : {}), - stderr: result.stderr, - }, - } - : normalized; - }), - ); - }).pipe(Effect.mapError(mapError)), - exportDeclarativeSchema: (input) => - Effect.gen(function* () { - yield* warnStrictCoverageIgnored(input.strictCoverage); - if (input.source === undefined) { - return yield* Effect.fail( - new LegacyPgDeltaEngineError({ - message: "legacy pg-delta declarative export requires an empty shadow database", - cause: "missing declarative export source", - }), - ); - } - const result = yield* provideRuntime( - legacyDeclarativeExportPgDelta(input.context, { - sourceRef: input.source.ref, - targetRef: input.target.ref, - schema: input.schema, - formatOptions: input.formatOptions, - }), - ); - return { - files: result.files.map((file) => ({ name: file.path, sql: file.sql })), - }; - }).pipe(Effect.mapError(mapError)), - planDeclarativeSchema: (input) => - Effect.gen(function* () { - yield* warnStrictCoverageIgnored(input.strictCoverage); - const sourceRef = yield* legacyGetMigrationsCatalogRef( - fs, - path, - input.context, - input.toml, - input.setupInputs, - { - noCache: input.noCache, - ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), - }, - ).pipe(provideRuntime); - const targetRef = yield* seam.exportCatalog({ - mode: "declarative", - noCache: input.noCache, - }); - const result = yield* provideRuntime( - legacyDiffPgDelta(input.context, { - sourceRef, - targetRef, - schema: input.schema, - formatOptions: input.formatOptions, - }), - ); - return { - ...normalizeDiff(result, input.debug), - sourceRef, - targetRef, - dropWarnings: legacyFindDropStatements(result.sql), - }; - }).pipe(Effect.mapError(mapError)), - }); - }), -); diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts index 25503aeb3f..09619ade50 100644 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts +++ b/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts @@ -15,7 +15,6 @@ const common = { context: { projectId: "test", cwd: "/tmp/test", - npmVersion: undefined, denoVersion: 2, projectEnv: {}, }, @@ -41,7 +40,6 @@ const toml: LegacyDbTomlValues = { enabled: false, declarativeSchemaPath: Option.none(), formatOptions: Option.none(), - npmVersion: Option.none(), }, webhooksEnabled: false, baseline: { @@ -162,16 +160,6 @@ describe("pg-delta next shadow selection", () => { toml, files: [{ name: "schema.sql", sql: "create table example(id int);" }], noCache: false, - setupInputs: { - image: "postgres:17", - majorVersion: 17, - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - autoExpose: true, - vaultNames: [], - rolesSql: "", - }, }) .pipe(Effect.exit); @@ -191,16 +179,6 @@ describe("pg-delta next shadow selection", () => { toml, files: [{ name: "schema.sql", sql: "create table example(id int);" }], noCache: true, - setupInputs: { - image: "postgres:17", - majorVersion: 17, - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - autoExpose: true, - vaultNames: [], - rolesSql: "", - }, }) .pipe(Effect.exit); diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.next.layer.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.next.layer.ts index 60d208486b..05fc1351f6 100644 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.next.layer.ts +++ b/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.next.layer.ts @@ -233,7 +233,6 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( }); return LegacyPgDeltaEngine.of({ - implementation: "next", diffExplicit: (input) => Effect.scoped( Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.service.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.service.ts index fffa40e4d4..612381d0e2 100644 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.service.ts +++ b/apps/cli/src/commands/db/shared/legacy-pgdelta-engine.service.ts @@ -5,8 +5,6 @@ import type { LegacyPgConnInput, } from "../../../command-internal/legacy-db-connection.service.ts"; import type { LegacyPgDeltaContext } from "../../../command-internal/legacy-pgdelta.ts"; -import type { LegacySetupInputs } from "../../../command-internal/legacy-pgdelta.cache.ts"; -import type { LegacyPgDeltaImplementation } from "../../../command-internal/legacy-pgdelta-next-flag.ts"; import type { LegacyMigrationTransactionMode } from "../../../command-internal/legacy-migration-file.ts"; import type { LegacyDbTomlValues } from "../../../command-internal/legacy-db-config.toml-read.ts"; import { @@ -17,9 +15,9 @@ import { export interface LegacyPgDeltaDatabaseEndpoint { readonly kind: "database"; - /** URL/reference used by the legacy edge-runtime implementation. */ + /** Postgres connection URL; parsed when `connection` is absent. */ readonly ref: string; - /** Full parsed connection, preferred by the next implementation. */ + /** Full parsed connection, preferred over parsing `ref`. */ readonly connection?: LegacyPgConnInput; readonly connectOptions: LegacyDbConnectOptions; } @@ -139,15 +137,13 @@ export interface LegacyPgDeltaDatabaseDiffInput extends LegacyPgDeltaCommonInput } interface LegacyPgDeltaDeclarativeExportInput extends LegacyPgDeltaCommonInput { - /** Workflow-owned empty shadow used only by the legacy declarative exporter. */ - readonly source?: LegacyPgDeltaDatabaseEndpoint; readonly target: LegacyPgDeltaDatabaseEndpoint; - readonly noCache: boolean; } export interface LegacyPgDeltaDeclarativeExportResult { readonly files: ReadonlyArray; - readonly manifest?: LegacyPgDeltaExportManifest; + /** Ownership metadata the declarative writer records alongside the files. */ + readonly manifest: LegacyPgDeltaExportManifest; } export interface LegacyPgDeltaDeclarativePlanInput extends LegacyPgDeltaCommonInput { @@ -156,7 +152,6 @@ export interface LegacyPgDeltaDeclarativePlanInput extends LegacyPgDeltaCommonIn readonly noCache: boolean; /** Already-loaded config used by native shadow/catalog provisioning. */ readonly toml: LegacyDbTomlValues; - readonly setupInputs: LegacySetupInputs; } interface LegacyPgDeltaDeclarativePlanResult extends LegacyPgDeltaDiffResult { @@ -185,7 +180,6 @@ export class LegacyPgDeltaEngineError extends Data.TaggedError("LegacyPgDeltaEng } export interface LegacyPgDeltaEngineShape { - readonly implementation: LegacyPgDeltaImplementation; readonly diffExplicit: ( input: LegacyPgDeltaExplicitDiffInput, ) => Effect.Effect; diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta.apply.integration.test.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta.apply.integration.test.ts deleted file mode 100644 index 075b6a9aa6..0000000000 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta.apply.integration.test.ts +++ /dev/null @@ -1,983 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer } from "effect"; - -import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; -import { LegacyDebugFlag } from "../../../shared/legacy/global-flags.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; -import { - type LegacyEdgeRuntimeRunOpts, - type LegacyEdgeRuntimeRunResult, - LegacyEdgeRuntimeScript, -} from "../../../command-internal/legacy-edge-runtime-script.service.ts"; -import { LegacyEdgeRuntimeScriptError } from "../../../command-internal/legacy-edge-runtime-script.errors.ts"; -import { legacyApplyDeclarativePgDelta } from "./legacy-pgdelta.apply.ts"; -import type { LegacyPgDeltaContext } from "../../../command-internal/legacy-pgdelta.ts"; - -const CTX: LegacyPgDeltaContext = { - projectId: "ref", - cwd: "/proj", - npmVersion: undefined, - denoVersion: 2, - projectEnv: {}, -}; - -function fakeEdgeRuntime(outcome: { stdout?: string; stderr?: string; fail?: string } = {}) { - const calls: Array = []; - const layer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (opts: LegacyEdgeRuntimeRunOpts) => { - calls.push(opts); - if (outcome.fail !== undefined) { - return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: outcome.fail })); - } - return Effect.succeed({ - stdout: outcome.stdout ?? "", - stderr: outcome.stderr ?? "", - } satisfies LegacyEdgeRuntimeRunResult); - }, - }); - return { layer, calls }; -} - -function makeDeclarativeDir(): string { - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-apply-")); - mkdirSync(join(dir, "declarative"), { recursive: true }); - writeFileSync(join(dir, "declarative", "public.sql"), "create table t ();"); - return join(dir, "declarative"); -} - -const failError = (exit: Exit.Exit) => - Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; - -describe("legacyApplyDeclarativePgDelta", () => { - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError interpolating the RELATIVE dir, not the absolute one, when the declarative dir doesn't exist", - () => { - // Go's `ApplyDeclarative` interpolates `utils.GetDeclarativeDir()` (relative) into - // this error, never the `filepath.Abs`-resolved dir it separately computes only for - // the bind (`apply.go:304-307`). - const edge = fakeEdgeRuntime(); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: "/does/not/exist", - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toBe( - "declarative schema directory not found: supabase/database", - ); - // Never even reaches the edge-runtime — the exists() check runs first. - expect(edge.calls).toHaveLength(0); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect("maps an edge-runtime failure to LegacyPgDeltaDeclarativeApplyError", () => { - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ fail: "error running pg-delta script: boom" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toBe( - "error running pg-delta script: boom", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }); - - it.effect("fails with a parse error WITHOUT the raw stdout when --debug is unset", () => { - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: "not json{" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - const message = (failError(exit) as { message: string }).message; - expect(message).toContain("failed to parse pg-delta apply output"); - expect(message).not.toContain("stdout:"); - expect(message).not.toContain("not json{"); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }); - - it.effect("fails with a parse error INCLUDING the raw stdout when --debug is set", () => { - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: "not json{" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - const message = (failError(exit) as { message: string }).message; - expect(message).toContain("failed to parse pg-delta apply output"); - expect(message).toContain("stdout: not json{"); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, true), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }); - - it.effect( - "fails with a parse error INCLUDING the raw stdout when SUPABASE_DEBUG is set only in the project .env", - () => { - // Go's `Config.Load` -> `loadNestedEnv` `os.Setenv`s the project `supabase/.env` into the - // process before `pgdelta.ApplyDeclarative` ever reads `viper.GetBool("DEBUG")` - // (review: PRRT_kwDOErm0O86XL_oz) — so a `SUPABASE_DEBUG` set only in `supabase/.env`, - // never in the shell or via `--debug`, still surfaces the raw stdout. Delete any shell - // `SUPABASE_DEBUG` first: shell *presence* (even `false`) would otherwise suppress the - // project value entirely, per `legacyViperEnvBoolWithProjectFallback`'s own semantics. - const previous = process.env["SUPABASE_DEBUG"]; - delete process.env["SUPABASE_DEBUG"]; - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: "not json{" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta( - { ...CTX, projectEnv: { SUPABASE_DEBUG: "true" } }, - { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }, - ).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - const message = (failError(exit) as { message: string }).message; - expect(message).toContain("failed to parse pg-delta apply output"); - expect(message).toContain("stdout: not json{"); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DEBUG"]; - else process.env["SUPABASE_DEBUG"] = previous; - }), - ), - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with a normal status-failure summary (not a parse error) when stdout is a top-level JSON null", - () => { - // Go's `json.Unmarshal([]byte("null"), &result)` into the zero-valued (non-pointer) - // `ApplyResult` struct is a no-op that returns no error (verified empirically) — Go falls - // through to the normal `result.Status != "success"` branch and prints the usual - // failed-apply summary with every counter at its zero value, rather than treating `null` - // as a parse failure. `legacyApplyDeclarativePgDelta` must normalize `null` to `{}` before - // its own structural guard, matching that behavior (review: PRRT_kwDOErm0O86W8ZYo). - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: "null" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toBe( - "pg-delta declarative apply failed with status: ", - ); - expect((failError(exit) as { message: string }).message).not.toContain( - "failed to parse pg-delta apply output", - ); - expect(out.stderrText).toContain('pg-delta apply returned status "".'); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when stdout is syntactically valid but non-object, non-null JSON", - () => { - // Unlike `null` (see the sibling test above), Go's `json.Unmarshal` genuinely rejects an - // array/string/number/bool payload for a struct destination with an UnmarshalTypeError — - // so a bare `JSON.parse(...) as LegacyPgDeltaApplyResult` cast would let `parsed.status` - // throw an unhandled TypeError instead of failing typed. - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: "42" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect("fails with LegacyPgDeltaDeclarativeApplyError when stdout is a JSON array", () => { - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: "[1,2,3]" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when a field typed as an array arrives as an object", - () => { - // A configured or future pg-delta emitting `{"status":"error","errors":{"length":1}}` must - // not reach `legacyFormatApplyFailure`'s `for (const issue of errors)`, which would throw an - // unhandled TypeError on a non-iterable object — Go's `json.Unmarshal` rejects this the same - // way, since `Errors` is declared `[]ApplyIssue` (`apps/cli-go/internal/pgdelta/apply.go:33`). - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ status: "error", errors: { length: 1 } }), - }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not treated as a false success) when an errors array element is a number", - () => { - // A configured or future pg-delta emitting `{"status":"success","errors":[123]}` must not - // be accepted as a successful apply. Verified against Go's real `ApplyIssue.UnmarshalJSON` - // (`apps/cli-go/internal/pgdelta/apply.go:124-142`): a numeric element fails BOTH its - // string-arm and its object-arm unmarshal, which fails the WHOLE `ApplyResult` decode — - // Go never reaches a "success" status in this case, so the TS guard must reject it too. - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ status: "success", errors: [123] }), - }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not treated as a false success) when a diagnostics array element is a bare string", - () => { - // Unlike `ApplyIssue`, Go's `ApplyDiagnosis.UnmarshalJSON` (`apply.go:79-116`) has no - // bare-string acceptance branch, so `{"diagnostics":["boom"]}` fails Go's whole decode too - // (verified: unmarshaling a JSON string into `ApplyDiagnosis`'s shadow struct errors). - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ status: "success", diagnostics: ["boom"] }), - }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "accepts a diagnostics element whose statementId is a mistyped, non-object/non-string value (Go degrades it silently)", - () => { - // Unlike a top-level array-element shape mismatch, Go's `ApplyDiagnosis.UnmarshalJSON` - // decodes `statementId` into a `json.RawMessage` first (accepts ANY valid JSON value), then - // tries `ApplyStatementLocation`, then a bare string, and silently leaves `StatementID` nil - // if BOTH fail — never propagating an error. A mistyped `statementId` must NOT fail the - // whole parse. - const dir = makeDeclarativeDir(); - const payload = { - status: "success", - diagnostics: [{ message: "note", statementId: 42 }], - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "drops a diagnostics element's statementId when a nested field is mistyped, instead of rendering a bogus location (Go's nil fallback)", - () => { - // Unlike the mistyped-non-object/non-string `statementId` case above, this reproduces a - // mistyped FIELD INSIDE an otherwise object-shaped `statementId` - // (`{"filePath":123,...}`). Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:100-115`) - // tries the `ApplyStatementLocation` object shape first — the mistyped `filePath` fails - // that decode — then falls back to a bare string, which ALSO fails (it's an object, not a - // string) — so Go silently leaves `StatementID` nil rather than erroring the whole parse, - // verified empirically. Rendering the raw object anyway would show a bogus `(123#1)` - // location Go never emits. - const dir = makeDeclarativeDir(); - const payload = { - status: "success", - diagnostics: [{ message: "note", statementId: { filePath: 123, statementIndex: 1 } }], - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "accepts a null scalar field on an errors/diagnostics element and formats it as absent (Go's encoding/json leaves the zero value)", - () => { - // `ApplyIssue`'s non-`Statement` fields (`Code`/`Message`/`IsDependencyError`/`Position`/ - // `Detail`/`Hint`) and `ApplyDiagnosis`'s (`Code`/`Message`/`SuggestedFix`) are all plain, - // non-pointer Go types decoded via the default `encoding/json` — verified empirically that - // a JSON `null` for a non-pointer struct field produces NO error and leaves the zero value, - // so `{"errors":[{"message":null}]}` is a valid, Go-accepted payload, not a parse failure. - // The formatter's existing `String(issue.message ?? "")` already renders a zero-value - // message as "unknown pg-delta issue" once the guard lets the `null` through. - const dir = makeDeclarativeDir(); - const payload = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [{ message: null, code: null, isDependencyError: null, position: null }], - diagnostics: [{ message: null, code: null, suggestedFix: null }], - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect(out.stderrText).toContain("- unknown pg-delta issue"); - expect(out.stderrText).toContain("- unknown pg-delta diagnostic"); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, true), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "accepts a null top-level counter and formats it as zero (Go's encoding/json leaves the zero value)", - () => { - // `ApplyResult` has no custom `UnmarshalJSON` of its own, so its plain, non-pointer `int` - // counters (`TotalStatements`/`TotalRounds`/`TotalApplied`/`TotalSkipped`) decode via the - // default `encoding/json` — verified empirically that a JSON `null` for a non-pointer `int` - // field produces NO error and leaves the zero value, so - // `{"status":"success","totalApplied":null}` is a valid, Go-accepted payload, not a parse - // failure — same "null means absent" rule already applied to nested issue/diagnostic - // scalar fields above. - const dir = makeDeclarativeDir(); - const payload = { status: "success", totalApplied: null, totalRounds: null }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain("Applied 0 statements in 0 round(s)."); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "accepts an absent or null top-level status and formats it as the empty-string zero value (Go's encoding/json)", - () => { - // `ApplyResult.Status` has no custom `UnmarshalJSON` of its own, so it's a plain, - // non-pointer `string` field decoded via the default `encoding/json` — verified - // empirically that `{}` and `{"status":null}` both decode with `err == nil` and - // `Status == ""`, reaching the normal failed-apply summary (not a parse failure). - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: JSON.stringify({}) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toBe( - "pg-delta declarative apply failed with status: ", - ); - expect(out.stderrText).toContain('pg-delta apply returned status "".'); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "accepts a null errors/stuckStatements/validationErrors/diagnostics array and treats it as empty (Go's encoding/json leaves a nil slice)", - () => { - // `ApplyResult`'s array fields have no custom `UnmarshalJSON` of their own, so Go's - // `encoding/json` accepts a JSON `null` for a `[]T` slice field with no error, leaving a - // nil (zero-length) slice — verified empirically: - // `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` - // with `len(r.Errors) == 0`. A payload reporting all four as `null` must format as if none - // were reported at all, not fail the parse. - const dir = makeDeclarativeDir(); - const payload = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: null, - stuckStatements: null, - validationErrors: null, - diagnostics: null, - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect(out.stderrText).toContain("No per-statement diagnostics were reported by pg-delta."); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when a field typed as a number arrives as a string", - () => { - // Same reasoning as the array-typed-field test above, for `ApplyResult`'s numeric fields - // (`TotalApplied int`, etc.) — a malformed counter must fail the parse, not be silently - // treated as a genuine successful-apply summary. - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ status: "success", totalApplied: "5" }), - }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when a field typed as an int arrives as a fractional number", - () => { - // Go's `TotalApplied int` (and its `int`-typed siblings) reject any JSON number literal - // with a decimal point via `strconv.ParseInt` on the raw literal text — verified - // empirically that `json.Unmarshal` on `{"totalApplied":1.5}` errors identically to a - // string-typed field mismatch, so `1.5` must fail the parse here too, not be treated as a - // truncated/rounded successful-apply count. - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ status: "success", totalApplied: 1.5 }), - }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when an int field arrives as a value outside Go's int64 range", - () => { - // `Number.isInteger(1e20)` is `true`, but Go's `json.Unmarshal` of that same literal - // into `int` fails with "value out of range" (`strconv.ParseInt`'s int64 width) — so a - // mistyped/oversized numeric field must be rejected here too, not accepted as a (false) - // successful-apply count. - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ status: "success", totalApplied: 1e20 }), - }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "on a non-success status, prints the formatted failure to stderr but not the raw payload when --debug is unset", - () => { - const dir = makeDeclarativeDir(); - const payload = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: ["boom"], - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toBe( - "pg-delta declarative apply failed with status: error", - ); - expect(out.stderrText).toContain('pg-delta apply returned status "error".'); - expect(out.stderrText).toContain("- boom"); - expect(out.stderrText).not.toContain("pg-delta apply result:"); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "on a non-success status with --debug set, additionally dumps the pretty-printed raw payload", - () => { - const dir = makeDeclarativeDir(); - const payload = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: ["boom"], - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(out.stderrText).toContain("pg-delta apply result:"); - expect(out.stderrText).toContain(JSON.stringify(payload, null, 2)); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, true), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "on success, prints the applied-statements summary and forwards SCHEMA_PATH/TARGET/binds", - () => { - const dir = makeDeclarativeDir(); - const payload = { - status: "success", - totalStatements: 3, - totalApplied: 3, - totalRounds: 2, - totalSkipped: 0, - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }); - expect(out.stderrText).toContain("Applying declarative schemas via pg-delta..."); - expect(out.stderrText).toContain("Applied 3 statements in 2 round(s)."); - const opts = edge.calls[0]!; - expect(opts.env["SCHEMA_PATH"]).toBe("/declarative"); - expect(opts.env["TARGET"]).toBe( - "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - ); - expect(opts.binds).toEqual([ - "supabase_edge_runtime_ref:/root/.cache/deno:rw", - `${dir}:/declarative:ro`, - ]); - expect(opts.errPrefix).toBe("error running pg-delta script"); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); -}); diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta.apply.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta.apply.ts deleted file mode 100644 index a25d0172a3..0000000000 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta.apply.ts +++ /dev/null @@ -1,1002 +0,0 @@ -/** - * Port of Go's `pgdelta.ApplyDeclarative` (`apps/cli-go/internal/pgdelta/apply.go:303-354`) — - * CLI-1956's declarative-apply runner: applies `supabase/schemas` (or the configured - * declarative dir) to the shadow's `contrib_regression` override database via pg-delta's - * declarative apply engine, run inside the edge-runtime container. - * - * This is genuinely NEW work, not a seam removal: the Deno script template itself - * (`legacyPgDeltaDeclarativeApplyScript`) already existed (ported for a different, now-dead - * seam), but nothing in TS ever invoked it — every declarative apply ran through the bundled - * Go binary until now. - */ - -import { Data, Effect, type FileSystem } from "effect"; - -import { legacyResolveDebugWithProjectEnv } from "../../../shared/legacy/global-flags.ts"; -import { Output } from "../../../shared/output/output.service.ts"; -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityId, -} from "../../../shared/telemetry/error-actionability.ts"; -import { LegacyEdgeRuntimeScript } from "../../../command-internal/legacy-edge-runtime-script.service.ts"; -import { legacyGoQuote } from "../../../command-internal/legacy-go-quote.ts"; -import { legacyTrimGoSpace } from "./legacy-go-string.ts"; -import { - legacyInterpolatePgDeltaScript, - legacyPgDeltaDeclarativeApplyScript, -} from "./legacy-pgdelta.deno-templates.ts"; -import { - legacyEdgeRuntimeId, - legacyPgDeltaNpmRegistryOption, - type LegacyPgDeltaContext, -} from "../../../command-internal/legacy-pgdelta.ts"; - -const errMessage = (e: unknown): string => - typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" - ? e.message - : String(e); - -/** - * `pgdelta.ApplyDeclarative` failed — Go's own error messages at each step (see call sites - * below). `reason` narrows the actionability classification below beyond the "user's own - * SQL/schema" (`dbFinding`) default that a failed-status apply (Go's own `pg-delta declarative - * apply failed with status: %s`) and a plain reset/apply fallback (`sync.handler.ts`, - * `declarative.smart-target.ts`) both keep: `missing_schema_dir`/`output_parse` (this file's - * own directory-not-found/malformed-subprocess-output branches) and `connect`/`daemon`/`pull`/ - * `inspect` (a local-Postgres connect failure, or a docker-boundary failure threaded from - * `LegacyEdgeRuntimeScriptError.docker` — see that class's own doc comment for the same three - * values) are genuinely NOT the user's schema/SQL failing, and must not be misclassified as - * such. - */ -export class LegacyPgDeltaDeclarativeApplyError extends Data.TaggedError( - "LegacyPgDeltaDeclarativeApplyError", -)<{ - readonly message: string; - readonly reason?: - | "missing_schema_dir" - | "output_parse" - | "connect" - | "daemon" - | "pull" - | "inspect"; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - switch (this.reason) { - case "missing_schema_dir": - return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; - case "output_parse": - return { ...actionability.impossibleState, fingerprint_suffix: "invalid_content" }; - case "connect": - return { ...actionability.dbConnection, fingerprint_suffix: "connect" }; - case "daemon": - return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; - case "pull": - return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; - case "inspect": - return { ...actionability.invalidConfig, fingerprint_suffix: "image_inspect" }; - default: - return actionability.dbFinding; - } - } -} - -/** Go's `containerSchemaPath` (`apply.go:313`). */ -const LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH = "/declarative"; - -/** One statement/error entry — Go's `ApplyIssue`, which may arrive as a bare string or an object. */ -export interface LegacyPgDeltaApplyIssue { - readonly statement?: { - // Optional (not required): `legacyIsValidApplyIssueElement` only checks the TYPE of each - // present field (matching Go's per-field `json.Unmarshal` type check), not that every - // field is present — so a partially-populated `statement` object (e.g. a future pg-delta - // release that only reports `id`) must still render, not throw — see - // `legacyFormatApplyIssue`'s defensive `?? ""` handling below. Go's own `(i - // *ApplyIssue) UnmarshalJSON` is deliberately just as permissive about ABSENT fields, - // while still rejecting a MISTYPED one for the whole payload — see - // `legacyIsValidApplyIssueElement`'s own doc comment. - // - // `| null` on each of `id`/`sql`/`statementClass` (not just `?`): these are plain, - // non-pointer `string` fields on Go's `ApplyStatement`, which has no custom - // `UnmarshalJSON` of its own — so they decode via the default `encoding/json`, which - // (verified empirically) accepts a JSON `null` for a non-pointer field with NO error and - // leaves the zero value (`""`), the same "null means absent" rule as every other scalar - // on this interface — see {@link LegacyPgDeltaApplyIssue.code}'s doc comment. - readonly id?: string | null; - readonly sql?: string | null; - readonly statementClass?: string | null; - // `| null` (not just `?`): Go's `Statement *ApplyStatement` is a pointer, so a JSON - // `"statement":null` entry (e.g. `{"statement":null,"message":"failed"}`) unmarshals to a - // nil pointer — `formatApplyIssue`'s `issue.Statement == nil` (`apply.go:202`) treats that - // identically to a missing field. `legacyFormatApplyIssue`'s guard below must check for - // `null` as well as `undefined`, or a `JSON.parse`'d `null` reaches `issue.statement.*` and - // throws a `TypeError` instead of rendering the message. - } | null; - // `| null` on every scalar below (not just `?`): `ApplyIssue`'s non-`Statement` fields - // (`Code`/`Message`/`IsDependencyError`/`Position`/`Detail`/`Hint`) are all plain, - // non-pointer Go types (`string`/`bool`/`int`) decoded via the default `encoding/json` - // inside `(i *ApplyIssue) UnmarshalJSON`'s `json.Unmarshal(trimmed, &parsed)` call - // (`apply.go:135-140`) — verified empirically that unmarshaling a JSON `null` into a - // non-pointer struct field produces NO error and leaves the zero value untouched (Go's - // documented "null means absent" rule applies to any Go type, not just pointers/maps/ - // slices/interfaces). So `{"message":null}` is a valid, Go-accepted `ApplyIssue` element — - // rejecting it here would turn an otherwise-parseable pg-delta payload into a spurious - // "failed to parse pg-delta apply output" instead of rendering `unknown pg-delta issue` - // the way `legacyFormatApplyIssueMessage`'s existing `String(issue.message ?? "")` already - // does once this type (and `legacyIsValidApplyIssueElement`) let a null through. - readonly code?: string | null; - readonly message?: string | null; - readonly isDependencyError?: boolean | null; - readonly position?: number | null; - readonly detail?: string | null; - readonly hint?: string | null; -} - -/** - * Go's `ApplyStatementLocation` (pg-topo's `StatementId` shape). `ApplyStatementLocation` - * has no custom `UnmarshalJSON` of its own, so `filePath`/`statementIndex`/`sourceOffset` - * are plain, non-pointer Go types decoded via the default `encoding/json` — same "null - * means absent" rule as every other scalar in this file (verified empirically, see {@link - * LegacyPgDeltaApplyIssue.code}'s doc comment), hence `| null` on all three. `sourceOffset` - * is never read by {@link legacyFormatStatementLocation} (Go's own `formatStatementLocation` - * doesn't display it either), but it still must be validated in - * {@link legacyNormalizeApplyStatementId}: Go's struct-level `json.Unmarshal` fails the - * WHOLE object the moment any declared field — including this unused one — has the wrong - * type, not just the fields the formatter happens to read. - */ -export interface LegacyPgDeltaApplyStatementLocation { - readonly filePath?: string | null; - readonly statementIndex?: number | null; - readonly sourceOffset?: number | null; -} - -/** Go's `ApplyDiagnosis` — a pg-topo static-analysis diagnostic. */ -export interface LegacyPgDeltaApplyDiagnosis { - // `| null` on `code`/`message`/`suggestedFix` (not just `?`): `(d *ApplyDiagnosis) - // UnmarshalJSON`'s shadow `raw` struct (`apply.go:88-93`) declares these as plain, - // non-pointer `string` fields with no custom unmarshaler of their own, so — same - // empirically-verified "null means absent" `encoding/json` rule as - // {@link LegacyPgDeltaApplyIssue.code} — a JSON `null` for any of them decodes with no - // error and leaves `""`, not a rejected payload. - readonly code?: string | null; - readonly message?: string | null; - // `| null` (not just `?`): Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-108`) - // explicitly maps a JSON `"statementId":null` to a nil `*ApplyStatementLocation`, and - // `formatStatementLocation` (`apply.go:263-274`) returns `""` for a nil pointer — so the TS - // path must accept `null` here as absent too, or a `JSON.parse`'d `null` reaches - // `legacyFormatStatementLocation`'s `resolved.filePath` and throws a `TypeError` instead of - // rendering the rest of the diagnostic. - readonly statementId?: LegacyPgDeltaApplyStatementLocation | string | null; - readonly suggestedFix?: string | null; -} - -/** - * The JSON payload `pgdelta_declarative_apply.ts` prints on stdout. Go's `ApplyResult`. - * - * `| null` on each `total*` counter (not just `?`): `ApplyResult` has no custom - * `UnmarshalJSON` of its own, so these plain, non-pointer `int` fields decode via the - * default `encoding/json`, which — verified empirically, same rule as {@link - * LegacyPgDeltaApplyIssue.code} — accepts a JSON `null` for a non-pointer `int` field with - * NO error and leaves the zero value. So `{"status":"success","totalApplied":null}` is a - * valid, Go-accepted `ApplyResult`, not a parse failure. - * - * `| null` on each array field too (`errors`/`stuckStatements`/`validationErrors`/ - * `diagnostics`): these are plain, non-pointer Go `[]T` slice fields with no custom - * unmarshaler on `ApplyResult` itself, and `encoding/json` accepts a JSON `null` for a - * slice field with NO error, leaving a nil (zero-length) slice — verified empirically: - * `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` - * with `r.Errors == nil` (`len(r.Errors) == 0`). `formatApplyFailure`'s `len(result.Errors) - * > 0` guards treat a nil slice identically to an empty one, so `{"status":"error", - * "errors":null}` must be accepted here too, not rejected as a parse failure. - * - * `status?: string | null` (not required non-null `string`): like every other field here, - * `Status` has no custom unmarshaler on `ApplyResult` itself, so an absent key or a JSON - * `null` decodes with NO error and leaves Go's zero value `""` — verified empirically: - * `json.Unmarshal([]byte(\`{}\`), &r)` and the `{"status":null}` variant both return - * `err == nil` with `r.Status == ""`. So `{}`/`{"status":null}` must reach the normal - * failed-apply summary (status rendered as `""`), not a rejected parse failure. - */ -export interface LegacyPgDeltaApplyResult { - readonly status?: string | null; - readonly totalStatements?: number | null; - readonly totalRounds?: number | null; - readonly totalApplied?: number | null; - readonly totalSkipped?: number | null; - readonly errors?: ReadonlyArray | null; - readonly stuckStatements?: ReadonlyArray | null; - readonly validationErrors?: ReadonlyArray | null; - readonly diagnostics?: ReadonlyArray | null; -} - -/** - * Go's `int`-typed fields (`TotalStatements`/`TotalRounds`/`TotalApplied`/`TotalSkipped` on - * `ApplyResult`, `Position` on `ApplyIssue`) reject any JSON number literal containing a decimal - * point or exponent — Go's `json.Unmarshal` parses the literal text via `strconv.ParseInt` - * rather than decoding a `float64` and truncating it, so even a "whole" float like `1.0` fails - * identically to `1.5` (verified empirically: `json.Unmarshal([]byte(\`{"totalApplied":1.0}\`), - * &r)` and the `1.5` variant both return `cannot unmarshal number ... into ... type int`). A - * `JSON.parse`'d `1.0` is already indistinguishable from the integer `1` by the time it reaches - * this guard — `JSON.parse` itself collapses that distinction, so that exact literal-text - * sub-case can't be reproduced post-parse — but `Number.isInteger` still correctly rejects any - * genuinely fractional value like `1.5`, which is the reachable and observable part of this - * parity gap. - * - * The `[-2^63, 2^63)` bound mirrors Go's `int64` range (`strconv.ParseInt`'s target width on - * every build this CLI ships for): `Number.isInteger(1e20)` is `true`, but Go's `json.Unmarshal` - * of that same literal into `int` fails with "value out of range" — so a mistyped/oversized - * numeric field must be rejected here too, not accepted as a (false) match. Residual gap, same - * class as the `1.0`/exponent one above: the exact boundary literal `9223372036854775807` - * (`2^63-1`, the largest valid `int64`) round-trips through `JSON.parse`'s double-precision - * `float64` as `9223372036854775808` (`2^63`) — indistinguishable from the boundary this check - * rejects — so that one exact literal is spuriously rejected where Go would accept it. - */ -function legacyIsGoIntNumber(value: unknown): value is number { - return ( - typeof value === "number" && Number.isInteger(value) && value >= -(2 ** 63) && value < 2 ** 63 - ); -} - -/** - * Go's `(i *ApplyIssue) UnmarshalJSON` (`apply.go:124-142`) accepts `null`, a bare string, or - * an object whose PRESENT fields each match `ApplyIssue`'s declared JSON types — anything else - * (a number, boolean, array, or an object with a mistyped field) fails Go's `json.Unmarshal` - * for the WHOLE `ApplyResult`, not just that element. Verified empirically against Go's real - * struct definitions: `{"errors":[123]}` returns `cannot unmarshal number into Go struct field - * ApplyResult.errors of type main.alias`, and `{"errors":[{"message":123}]}` returns `cannot - * unmarshal number into Go struct field ApplyResult.errors.message of type string` — both abort - * the ENTIRE parse rather than degrading that one element, so a payload like - * `{"status":"success","errors":[123]}` must be rejected here too, not accepted as a (false) - * success. Nested `statement` is checked the same way, one level deep — Go's `ApplyStatement` - * has no custom `UnmarshalJSON`, so a mistyped `id`/`sql`/`statementClass` fails identically. - * - * A JSON `null` for any INDIVIDUAL scalar field, though — top-level (`code`/`message`/ - * `isDependencyError`/`position`/`detail`/`hint`) or nested under `statement` - * (`id`/`sql`/`statementClass`) — is NOT a mistyped field: every one of these is a plain, - * non-pointer Go type with no custom unmarshaler, and `encoding/json` accepts `null` for those - * with no error, leaving the zero value (verified empirically — see - * {@link LegacyPgDeltaApplyIssue.code}'s doc comment). So `null` is tolerated alongside each - * field's declared type below, matching Go exactly instead of rejecting an otherwise - * Go-compatible payload like `{"message":null}`. - */ -function legacyIsValidApplyIssueElement(value: unknown): boolean { - if (value === null || typeof value === "string") return true; - if (typeof value !== "object" || Array.isArray(value)) return false; - if ("statement" in value) { - const statement = value.statement; - if (statement !== null && statement !== undefined) { - if (typeof statement !== "object" || Array.isArray(statement)) return false; - if ("id" in statement && statement.id !== null && typeof statement.id !== "string") { - return false; - } - if ("sql" in statement && statement.sql !== null && typeof statement.sql !== "string") { - return false; - } - if ( - "statementClass" in statement && - statement.statementClass !== null && - typeof statement.statementClass !== "string" - ) { - return false; - } - } - } - if ("code" in value && value.code !== null && typeof value.code !== "string") return false; - if ("message" in value && value.message !== null && typeof value.message !== "string") { - return false; - } - if ( - "isDependencyError" in value && - value.isDependencyError !== null && - typeof value.isDependencyError !== "boolean" - ) { - return false; - } - if ("position" in value && value.position !== null && !legacyIsGoIntNumber(value.position)) { - return false; - } - if ("detail" in value && value.detail !== null && typeof value.detail !== "string") return false; - if ("hint" in value && value.hint !== null && typeof value.hint !== "string") return false; - return true; -} - -/** - * Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-116`) — unlike `ApplyIssue`, there is - * NO bare-string acceptance branch, so only `null` or an object is valid; a bare - * string/number/boolean/array element fails the whole `ApplyResult` unmarshal. Verified - * empirically: `{"diagnostics":["boom"]}` returns `cannot unmarshal string into Go struct field - * ApplyResult.diagnostics of type struct {...}`. `statementId` is deliberately NOT type-checked - * here: Go decodes it into a `json.RawMessage` first (accepts any valid JSON value), then tries - * `ApplyStatementLocation`, then a bare string, and silently leaves `StatementID` nil if BOTH - * fail — it never propagates an error for a mistyped `statementId` (verified empirically: - * `{"statementId":42}` and `{"statementId":{"filePath":123}}` both unmarshal with `err: `), - * so `legacyNormalizeApplyDiagnosis`/`legacyFormatStatementLocation`'s existing defensive - * handling is the correct (and only) place that degrades gracefully. - * - * Same "null tolerated on a scalar field" rule as {@link legacyIsValidApplyIssueElement} - * applies to `code`/`message`/`suggestedFix` here too: `UnmarshalJSON`'s shadow `raw` struct - * (`apply.go:88-93`) decodes them via the default `encoding/json`, which accepts a JSON - * `null` for a plain `string` field with no error (verified empirically). - */ -function legacyIsValidApplyDiagnosisElement(value: unknown): boolean { - if (value === null) return true; - if (typeof value !== "object" || Array.isArray(value)) return false; - if ("code" in value && value.code !== null && typeof value.code !== "string") return false; - if ("message" in value && value.message !== null && typeof value.message !== "string") { - return false; - } - if ( - "suggestedFix" in value && - value.suggestedFix !== null && - typeof value.suggestedFix !== "string" - ) { - return false; - } - return true; -} - -/** - * Structural guard for Go's `ApplyResult` JSON shape, applied to an untrusted - * `JSON.parse` of the pg-delta subprocess's stdout. A syntactically valid but non-object - * payload — an array, a bare string/number/bool (e.g. a future pg-delta release that - * changes its output shape) — must fail typed as {@link LegacyPgDeltaDeclarativeApplyError}, not - * crash `parsed.status` with an unhandled `TypeError`. A top-level `null` is NOT one of - * these: `json.Unmarshal([]byte("null"), &result)` into Go's zero-valued (non-pointer) - * `ApplyResult` struct is a no-op that returns no error (verified empirically), unlike the - * array/string/number/bool cases, which genuinely fail with an `UnmarshalTypeError` — so the - * caller normalizes a top-level `null` to `{}` before this guard ever sees it (review: - * PRRT_kwDOErm0O86W8ZYo), and this function only needs to reject the cases Go actually - * rejects. - * - * Every field `ApplyResult` itself declares a type for is checked when present — Go's - * `json.Unmarshal` rejects the whole payload with an `UnmarshalTypeError` the moment any of - * these doesn't match its struct field's declared type (`Errors []ApplyIssue`, `TotalApplied - * int`, etc., `apps/cli-go/internal/pgdelta/apply.go:27-40`), so e.g. an `errors` field that - * arrives as an object (`{"length":1}`) instead of an array must fail here too, not reach - * `legacyFormatApplyFailure`'s `for (const issue of errors)` and throw an unhandled - * `TypeError` defect. Each ARRAY field's elements are also validated ({@link - * legacyIsValidApplyIssueElement}/{@link legacyIsValidApplyDiagnosisElement}) since Go's own - * per-element `UnmarshalJSON` implementations reject a malformed element by failing the WHOLE - * `ApplyResult` decode, not by skipping just that element — see those functions' own doc - * comments for the empirical verification. This is also the AGENTS.md-mandated way to narrow - * `unknown` without an `as` cast. - * - * Each array field also tolerates a JSON `null` (not just an absent key): `ApplyResult`'s - * `[]ApplyIssue`/`[]ApplyDiagnosis` fields have no custom unmarshaler of their own, and - * Go's `encoding/json` accepts `null` for a slice field with no error, leaving a nil - * (zero-length) slice — verified empirically, see {@link LegacyPgDeltaApplyResult}'s own - * doc comment. So `{"status":"error","errors":null}` is a valid, Go-accepted payload, not - * a rejected one. - * - * `status` is checked the same "null/absent tolerated" way as every other field, NOT - * required to be present and non-null: an absent key or `"status":null` is Go's zero - * value `""`, not a parse failure — see {@link LegacyPgDeltaApplyResult}'s own doc comment - * for the empirical verification. - */ -function legacyIsPgDeltaApplyResult(value: unknown): value is LegacyPgDeltaApplyResult { - if ( - typeof value !== "object" || - value === null || - Array.isArray(value) || - ("status" in value && value.status !== null && typeof value.status !== "string") - ) { - return false; - } - if ( - "totalStatements" in value && - value.totalStatements !== null && - !legacyIsGoIntNumber(value.totalStatements) - ) { - return false; - } - if ( - "totalRounds" in value && - value.totalRounds !== null && - !legacyIsGoIntNumber(value.totalRounds) - ) { - return false; - } - if ( - "totalApplied" in value && - value.totalApplied !== null && - !legacyIsGoIntNumber(value.totalApplied) - ) { - return false; - } - if ( - "totalSkipped" in value && - value.totalSkipped !== null && - !legacyIsGoIntNumber(value.totalSkipped) - ) { - return false; - } - if ("errors" in value && value.errors !== null) { - if (!Array.isArray(value.errors) || !value.errors.every(legacyIsValidApplyIssueElement)) { - return false; - } - } - if ("stuckStatements" in value && value.stuckStatements !== null) { - if ( - !Array.isArray(value.stuckStatements) || - !value.stuckStatements.every(legacyIsValidApplyIssueElement) - ) { - return false; - } - } - if ("validationErrors" in value && value.validationErrors !== null) { - if ( - !Array.isArray(value.validationErrors) || - !value.validationErrors.every(legacyIsValidApplyIssueElement) - ) { - return false; - } - } - if ("diagnostics" in value && value.diagnostics !== null) { - if ( - !Array.isArray(value.diagnostics) || - !value.diagnostics.every(legacyIsValidApplyDiagnosisElement) - ) { - return false; - } - } - return true; -} - -/** Go's `(i *ApplyIssue) UnmarshalJSON` string/object dual shape, applied post-`JSON.parse`. */ -function legacyNormalizeApplyIssue( - raw: LegacyPgDeltaApplyIssue | string | null | undefined, -): LegacyPgDeltaApplyIssue { - if (raw === null || raw === undefined) return {}; - if (typeof raw === "string") return { message: raw }; - return raw; -} - -/** - * Go's `(d *ApplyDiagnosis) UnmarshalJSON` three-way `statementId` fallback - * (`apply.go:100-115`): decode into `ApplyStatementLocation` first — an object whose - * PRESENT `filePath`/`statementIndex` fields each match the declared type (`null` - * tolerated per field, same rule as {@link legacyIsValidApplyIssueElement}) — and if - * that fails (a non-object, or an object with a mistyped field), fall back to a bare - * string; if BOTH fail, Go silently leaves `StatementID` nil rather than erroring the - * whole `ApplyResult` parse. Verified empirically: `{"statementId":{"filePath":123, - * "statementIndex":1}}` decodes with `StatementID == nil` in Go — the object-shape - * unmarshal fails on the mistyped `filePath`, and the string fallback also fails since - * the value is an object, not a string. `legacyIsValidApplyDiagnosisElement` deliberately - * does NOT check `statementId`'s shape (see its own doc comment — Go defers this into a - * `json.RawMessage` that never fails the outer parse), so this is the only place that can - * drop a malformed location instead of `legacyFormatStatementLocation`'s `String(...)` - * coercion rendering a bogus location (e.g. `123#1`) Go would never have shown. - * - * `sourceOffset` is validated here too, even though {@link legacyFormatStatementLocation} - * never reads it: Go's struct-level unmarshal (`apply.go:105`) fails on ANY declared field - * with the wrong type, not just the ones a later formatter happens to display. Verified - * empirically: `json.Unmarshal([]byte(\`{"filePath":"x.sql","sourceOffset":"bad"}\`), &loc)` - * returns a non-nil `UnmarshalTypeError` even though `filePath` itself is well-typed, so - * the object-shape decode fails, the string fallback also fails (the value is an object), - * and Go leaves `StatementID` nil — dropping the location entirely rather than keeping a - * `{filePath:"x.sql"}` that misattributes the diagnostic to the wrong file. - */ -function legacyNormalizeApplyStatementId( - raw: LegacyPgDeltaApplyStatementLocation | string | null | undefined, -): LegacyPgDeltaApplyStatementLocation | undefined { - if (raw === null || raw === undefined) return undefined; - if (typeof raw === "string") return { filePath: raw }; - if (typeof raw !== "object" || Array.isArray(raw)) return undefined; - const filePathOk = - !("filePath" in raw) || raw.filePath === null || typeof raw.filePath === "string"; - const indexOk = - !("statementIndex" in raw) || - raw.statementIndex === null || - legacyIsGoIntNumber(raw.statementIndex); - const sourceOffsetOk = - !("sourceOffset" in raw) || raw.sourceOffset === null || legacyIsGoIntNumber(raw.sourceOffset); - if (filePathOk && indexOk && sourceOffsetOk) return raw; - return undefined; -} - -/** Go's `(d *ApplyDiagnosis) UnmarshalJSON` defensive `statementId` handling. */ -function legacyNormalizeApplyDiagnosis( - raw: LegacyPgDeltaApplyDiagnosis | null | undefined, -): LegacyPgDeltaApplyDiagnosis { - if (raw === null || raw === undefined) return {}; - return { ...raw, statementId: legacyNormalizeApplyStatementId(raw.statementId) }; -} - -/** - * Go's `formatStatementLocation` (`apply.go:262-275`). `String(... ?? "")` rather than a bare - * `?? ""` before `.trim()`: `filePath` is typed as `string | undefined`, but this whole module - * types an untrusted `JSON.parse` of subprocess output, so a malformed payload can hand this a - * non-string value (e.g. a number) at runtime — `?? ""` alone only substitutes `null`/ - * `undefined`, so a non-string, non-nullish value would still reach `.trim()` and throw. The - * `resolved === null` check (not just `undefined`) is the same shape: Go's `StatementID - * *ApplyStatementLocation` is a pointer, so `"statementId":null` unmarshals to `nil` and - * `formatStatementLocation`'s own `loc == nil` (`apply.go:264`) treats it as absent — checking - * only `undefined` here would fall through to `resolved.filePath` on a `null` and throw a - * `TypeError` instead of rendering the rest of the diagnostic. - */ -function legacyFormatStatementLocation( - loc: LegacyPgDeltaApplyStatementLocation | string | null | undefined, -): string { - const resolved = typeof loc === "string" ? { filePath: loc } : loc; - if (resolved === null || resolved === undefined) return ""; - const path = legacyTrimGoSpace(String(resolved.filePath ?? "")); - if (path.length === 0) return ""; - if ((resolved.statementIndex ?? 0) > 0) return `${path}#${resolved.statementIndex}`; - return path; -} - -/** - * Go's `formatStatementSQL` (`apply.go:277-283`): collapse whitespace, then truncate at 120 - * UTF-8 bytes — not JS UTF-16 code units. Go's `len(normalized)` and `normalized[:maxLen-3]` - * both count/slice raw bytes, so a statement with multibyte (e.g. non-ASCII identifier) - * characters can be far longer in bytes than in UTF-16 units — a `.length`/`.slice()` guard - * would under-truncate (or not truncate at all) relative to Go's 120-byte limit, changing the - * legacy stderr contract for an already-failed apply. - * - * `\p{White_Space}+`, not `\s+`: `sql` is a user-authored SQL statement pulled verbatim from - * `supabase/declarative`, so — unlike this file's JSON envelope, whose key/shape is controlled - * by the embedded producer script — it can genuinely contain any Unicode code point a user's - * editor wrote, including NEL (code point 0x85) or a BOM (code point 0xFEFF) pasted into a - * comment or string literal. Go's `strings.Fields`/`unicode.IsSpace` and ECMAScript's `\s` - * disagree on both: verified empirically — Go's `unicode.IsSpace(rune(0x85))` (NEL) is `true` - * (`strings.Fields` collapses it, splitting `"a"+NEL+"b"` into two fields) while - * `unicode.IsSpace(rune(0xFEFF))` (BOM) is `false` (`strings.Fields` preserves it inside one - * field); ECMAScript's `\s` is the exact opposite (`/\s/u.test(String.fromCodePoint(0x85))` is - * `false`, `/\s/u.test(String.fromCodePoint(0xfeff))` is `true`). `\p{White_Space}` matches the - * Unicode `White_Space` property Go's `unicode.IsSpace` is itself built from (confirmed - * empirically against the same two code points, plus NBSP `0xA0` and ideographic space - * `0x3000`), so it reproduces Go's classification instead of ECMAScript's — both the rendered - * SQL text and, for a statement long enough to need it, the 120-byte truncation boundary now - * line up with Go's. - * - * Returns a `Buffer`, not a `string`: Go's `[:maxLen-3]` is a raw byte slice with no regard - * for codepoint boundaries, so a multibyte (e.g. non-ASCII identifier) character straddling - * byte 117 is cut mid-sequence, leaving an intentionally INVALID trailing UTF-8 fragment — - * exactly what Go writes to stderr, unvalidated. `Buffer#toString("utf-8")` on that same - * fragment does NOT reproduce it: Node's UTF-8 decoder substitutes U+FFFD for the incomplete - * sequence, and re-encoding that string back to bytes for output yields a DIFFERENT (and - * differently-sized) byte sequence than Go's raw slice — verified empirically: slicing Go's - * own `formatStatementSQL` at a non-boundary-aligned cut produces a 120-byte, deliberately - * invalid-UTF-8 result (`utf8.ValidString` reports `false`), while - * `Buffer.from(sql,"utf-8").subarray(...).toString("utf-8")` on that exact byte range - * decodes+re-encodes to a 121-byte result containing U+FFFD instead. Keeping this a `Buffer` - * all the way to `output.rawBytes` (see {@link legacyFormatApplyFailure}) avoids that - * lossy string round-trip and reproduces Go's bytes exactly, valid or not. - */ -function legacyFormatStatementSql(sql: string): Buffer { - const normalized = sql - .split(/\p{White_Space}+/u) - .filter((part) => part.length > 0) - .join(" "); - const maxLen = 120; - const normalizedBytes = Buffer.from(normalized, "utf-8"); - if (normalizedBytes.byteLength <= maxLen) return normalizedBytes; - return Buffer.concat([normalizedBytes.subarray(0, maxLen - 3), Buffer.from("...", "utf-8")]); -} - -/** - * Joins Buffer "lines" with `\n` — a Buffer-safe equivalent of `Array#join("\n")`, used so - * {@link legacyFormatApplyIssue}/{@link legacyFormatApplyFailure} can embed - * {@link legacyFormatStatementSql}'s raw (possibly invalid-UTF-8) bytes without ever - * decoding them back into a JS string. - */ -function legacyJoinLines(lines: ReadonlyArray): Buffer { - const newline = Buffer.from("\n", "utf-8"); - const parts: Array = []; - lines.forEach((line, index) => { - if (index > 0) parts.push(newline); - parts.push(line); - }); - return Buffer.concat(parts); -} - -/** - * Go's `json.Indent` (`encoding/json/indent.go`): re-flows compact/pretty JSON by inserting - * whitespace between tokens ONLY — every token (string, number, `true`/`false`/`null`) is - * copied byte-for-byte from `src`, never decoded into a value and re-encoded. This is NOT the - * same as `JSON.parse` + `JSON.stringify`: parsing a number decodes it into a JS `float64`, - * which silently loses precision for an integer literal beyond - * `Number.MAX_SAFE_INTEGER` (e.g. a snowflake-style id), and re-stringifying a string - * re-escapes it using `JSON.stringify`'s own rules, which can change an existing escape's - * representation (e.g. `\/` becomes a literal `/`) — both would corrupt the exact debug - * payload users are asked to attach to bug reports. `legacyGoJsonIndentTokens` instead scans - * `src` as a token stream (only tracking string boundaries, via backslash-escape skipping, to - * avoid misreading punctuation inside a string as structural) and reproduces Go's exact - * spacing rules: verified empirically against `encoding/json.Indent` for nested objects/ - * arrays, empty `{}`/`[]` (no inserted newline), a `\/`-escaped string, an emoji (multi-UTF-16 - * code point) string, and an integer literal beyond `Number.MAX_SAFE_INTEGER` — all byte- - * identical to Go's own output. Caller ({@link legacyFormatDebugJson}) is responsible for - * validating `src` is well-formed JSON first; this function assumes it and does not itself - * detect malformed input. - */ -function legacyGoJsonIndentTokens(src: string): string { - let out = ""; - let depth = 0; - let needIndent = false; - let i = 0; - const n = src.length; - const newline = (): void => { - out += `\n${" ".repeat(depth)}`; - }; - const openIndentIfNeeded = (): void => { - if (!needIndent) return; - needIndent = false; - depth++; - newline(); - }; - while (i < n) { - const c = src[i]; - if (c === " " || c === "\t" || c === "\r" || c === "\n") { - i++; - continue; - } - if (c === '"') { - const start = i; - i++; - while (i < n) { - if (src[i] === "\\") { - i += 2; - continue; - } - if (src[i] === '"') { - i++; - break; - } - i++; - } - openIndentIfNeeded(); - out += src.slice(start, i); - continue; - } - if (c === "{" || c === "[") { - openIndentIfNeeded(); - out += c; - needIndent = true; - i++; - continue; - } - if (c === "}" || c === "]") { - if (needIndent) { - needIndent = false; - } else { - depth--; - newline(); - } - out += c; - i++; - continue; - } - if (c === ",") { - openIndentIfNeeded(); - out += c; - newline(); - i++; - continue; - } - if (c === ":") { - openIndentIfNeeded(); - out += ": "; - i++; - continue; - } - openIndentIfNeeded(); - out += c; - i++; - } - return out; -} - -/** - * Go's `formatDebugJSON` (`apply.go:286-296`): pretty-print if parseable, else the trimmed raw - * bytes. `JSON.parse` here is used ONLY as a well-formedness check (its result is discarded); - * the actual reformatting goes through {@link legacyGoJsonIndentTokens} so token values are - * never decoded and re-encoded — see that function's own doc comment for why - * `JSON.stringify(JSON.parse(...))` would corrupt the payload Go's `json.Indent` preserves. - */ -export function legacyFormatDebugJson(raw: string): string { - const trimmed = legacyTrimGoSpace(raw); - if (trimmed.length === 0) return ""; - try { - JSON.parse(trimmed); - } catch { - return trimmed; - } - return legacyGoJsonIndentTokens(trimmed); -} - -/** Go's `formatApplyIssueMessage` (`apply.go:223-242`). `String(x ?? "")` throughout — see {@link legacyFormatApplyIssue}'s own doc comment for why. */ -function legacyFormatApplyIssueMessage(issue: LegacyPgDeltaApplyIssue): string { - const trimmed = legacyTrimGoSpace(String(issue.message ?? "")); - const message = trimmed.length > 0 ? trimmed : "unknown pg-delta issue"; - const metadata: Array = []; - const code = String(issue.code ?? ""); - if (code.length > 0) metadata.push(`SQLSTATE ${code}`); - if ((issue.position ?? 0) > 0) metadata.push(`position ${issue.position}`); - if (issue.isDependencyError === true) metadata.push("dependency error"); - if (metadata.length === 0) return message; - return `${message} (${metadata.join(", ")})`; -} - -/** - * Go's `formatApplyIssue` (`apply.go:202-221`). Every `issue.statement.*`/`issue.*` field is - * defaulted with `String(x ?? "")` before use — not a bare `?? ""`: a malformed subprocess - * payload (e.g. a pg-delta release that reports `detail`/`hint`/`sql` as a number) can hand any - * of these a non-string value, which `?? ""` alone does not catch (it only substitutes - * `null`/`undefined`), and the very next call on several of these fields is a string-only - * method (`.trim()`, `legacyFormatStatementSql`'s `.split()`) that throws a `TypeError` on - * anything else — turning an actionable SQL error into an unhandled defect, the worst place for - * a rendering bug to exist, since this only ever runs on an ALREADY-FAILED apply. - * - * The no-statement guard checks both `undefined` and `null`: Go's `Statement *ApplyStatement` - * is a pointer, so `{"statement":null,...}` unmarshals to `nil` and `issue.Statement == nil` - * (`apply.go:202`) treats it exactly like a missing field. A `JSON.parse`'d `null` is not - * `=== undefined`, so checking only `undefined` would fall through to `issue.statement.*` and - * throw a `TypeError` instead of rendering the message. - * - * Returns a `Buffer`, not a `string`: the `SQL: ` line embeds {@link legacyFormatStatementSql}'s - * raw bytes directly (via {@link legacyJoinLines}) rather than interpolating them into a - * template string, so a truncation that lands mid-codepoint reaches `output.rawBytes` - * unmodified instead of being silently corrupted by a UTF-8 decode/re-encode round-trip. - */ -function legacyFormatApplyIssue(rawIssue: LegacyPgDeltaApplyIssue | string | null): Buffer { - const issue = legacyNormalizeApplyIssue(rawIssue); - if (issue.statement === undefined || issue.statement === null) { - return Buffer.from(`- ${legacyFormatApplyIssueMessage(issue)}`, "utf-8"); - } - const statementClass = String(issue.statement.statementClass ?? ""); - const classSuffix = statementClass.length > 0 ? ` [${statementClass}]` : ""; - const lines: Array = [ - Buffer.from(`- ${String(issue.statement.id ?? "")}${classSuffix}`, "utf-8"), - Buffer.from(` ${legacyFormatApplyIssueMessage(issue)}`, "utf-8"), - ]; - const detail = legacyTrimGoSpace(String(issue.detail ?? "")); - if (detail.length > 0) lines.push(Buffer.from(` Detail: ${detail}`, "utf-8")); - const hint = legacyTrimGoSpace(String(issue.hint ?? "")); - if (hint.length > 0) lines.push(Buffer.from(` Hint: ${hint}`, "utf-8")); - const sql = legacyFormatStatementSql(String(issue.statement.sql ?? "")); - if (sql.byteLength > 0) { - lines.push(Buffer.concat([Buffer.from(" SQL: ", "utf-8"), sql])); - } - return legacyJoinLines(lines); -} - -/** Go's `formatApplyDiagnosis` (`apply.go:244-261`). `String(x ?? "")` throughout — see {@link legacyFormatApplyIssue}'s own doc comment for why. */ -function legacyFormatApplyDiagnosis(rawDiagnosis: LegacyPgDeltaApplyDiagnosis | null): string { - const diagnosis = legacyNormalizeApplyDiagnosis(rawDiagnosis); - const trimmed = legacyTrimGoSpace(String(diagnosis.message ?? "")); - const message = trimmed.length > 0 ? trimmed : "unknown pg-delta diagnostic"; - let out = "- "; - const code = legacyTrimGoSpace(String(diagnosis.code ?? "")); - if (code.length > 0) out += `[${code}] `; - out += message; - const loc = legacyFormatStatementLocation(diagnosis.statementId); - if (loc.length > 0) out += ` (${loc})`; - const fix = legacyTrimGoSpace(String(diagnosis.suggestedFix ?? "")); - if (fix.length > 0) out += `\n Suggested fix: ${fix}`; - return out; -} - -/** - * Port of Go's `formatApplyFailure` (`apply.go:150-199`): a human-readable summary of an - * unsuccessful pg-delta apply, rendered on failure regardless of `--debug`. `verbose` - * (Go's `viper.GetBool("DEBUG")`) only expands pg-topo diagnostics inline — collapsed to a - * one-line count by default since a large schema can produce hundreds of them. - * - * Returns a `Buffer`, not a `string` — see {@link legacyFormatStatementSql}'s doc comment: - * an embedded truncated SQL statement can be intentionally invalid UTF-8 (matching Go's raw - * byte slice), and only a `Buffer` carried through to `output.rawBytes` reproduces those - * exact bytes instead of a lossy decode/re-encode round-trip. Callers that only need the - * text for display/assertions (this module's own unit tests) can `.toString("utf-8")` it — - * safe for every case except the one pathological truncation this return type exists to - * preserve exactly. - */ -export function legacyFormatApplyFailure( - result: LegacyPgDeltaApplyResult, - verbose: boolean, -): Buffer { - const errors = result.errors ?? []; - const stuckStatements = result.stuckStatements ?? []; - const validationErrors = result.validationErrors ?? []; - const diagnostics = result.diagnostics ?? []; - - let totalStatements = result.totalStatements ?? 0; - if (totalStatements === 0) { - totalStatements = - (result.totalApplied ?? 0) + (result.totalSkipped ?? 0) + stuckStatements.length; - } - - const lines: Array = [ - // Go renders the status with `%q` (`apply.go:156`) — plain quotes diverge the - // moment a malformed payload puts a quote/control char in `status`. - Buffer.from( - `pg-delta apply returned status ${legacyGoQuote( - Buffer.from(String(result.status ?? ""), "utf-8"), - )}.`, - "utf-8", - ), - Buffer.from( - `${result.totalApplied ?? 0}/${totalStatements} statements applied in ${ - result.totalRounds ?? 0 - } round(s); ${result.totalSkipped ?? 0} skipped.`, - "utf-8", - ), - ]; - if (errors.length > 0) { - lines.push(Buffer.from("Errors:", "utf-8")); - for (const issue of errors) lines.push(legacyFormatApplyIssue(issue)); - } - if (stuckStatements.length > 0) { - lines.push(Buffer.from("Stuck statements:", "utf-8")); - for (const issue of stuckStatements) lines.push(legacyFormatApplyIssue(issue)); - } - if (validationErrors.length > 0) { - lines.push(Buffer.from("Validation errors (from check_function_bodies=on pass):", "utf-8")); - for (const issue of validationErrors) lines.push(legacyFormatApplyIssue(issue)); - } - if (diagnostics.length > 0) { - if (verbose) { - lines.push(Buffer.from("Diagnostics:", "utf-8")); - for (const diagnosis of diagnostics) { - lines.push(Buffer.from(legacyFormatApplyDiagnosis(diagnosis), "utf-8")); - } - } else { - lines.push( - Buffer.from( - `${diagnostics.length} pg-topo diagnostic(s) omitted (re-run with --debug to view).`, - "utf-8", - ), - ); - } - } - // pg-delta may report status "error" without populating any issue arrays (e.g. an internal - // assertion in a future pg-delta release) — point the user at how to get more information - // rather than leaving them with just the bare status line. - if (errors.length === 0 && stuckStatements.length === 0 && validationErrors.length === 0) { - lines.push( - Buffer.from( - [ - "No per-statement diagnostics were reported by pg-delta.", - "Re-run with --debug to print the raw pg-delta payload, or open an issue at", - "https://github.com/supabase/pg-toolbelt/issues with the debug bundle attached.", - ].join("\n"), - "utf-8", - ), - ); - } - return legacyJoinLines(lines); -} - -/** - * Port of Go's `pgdelta.ApplyDeclarative` (`apps/cli-go/internal/pgdelta/apply.go:303-354`): - * applies `declarativeDirAbs` to `target` (the shadow's `contrib_regression` override - * database) via pg-delta's declarative apply engine. Unlike the diff/export/catalog scripts - * (`legacy-pgdelta.ts`), this binds the declarative directory itself read-only at - * `/declarative` rather than mounting the whole project at `/workspace` — Go's own - * `ApplyDeclarative` never needs the wider project tree, only the schema files. `target` is - * always a LOCAL shadow connection (never a remote/Supabase-hosted endpoint), so — unlike - * `legacyDiffPgDelta`'s SOURCE/TARGET — no SSL/CA-bundle preparation applies here, matching - * Go's own plain `"TARGET="+utils.ToPostgresURL(config)` (no TLS handling at all). - */ -export const legacyApplyDeclarativePgDelta = Effect.fnUntraced(function* ( - ctx: LegacyPgDeltaContext, - params: { - readonly fs: FileSystem.FileSystem; - /** Absolute host path to the declarative schema directory (stat/bind use this). */ - readonly declarativeDirAbs: string; - /** - * Go's `utils.GetDeclarativeDir()` (`apply.go:304`) — the config value verbatim - * (already `supabase/`-prefixed when relative) or the relative `supabase/schemas` - * default. Used ONLY in the not-found error message below: Go interpolates this - * relative value, never the `filepath.Abs`-resolved `absDir` it separately computes - * for the bind. - */ - readonly declarativeDirRel: string; - /** The shadow override database's Postgres URL. */ - readonly target: string; - }, -) { - const exists = yield* params.fs - .exists(params.declarativeDirAbs) - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return yield* Effect.fail( - new LegacyPgDeltaDeclarativeApplyError({ - message: `declarative schema directory not found: ${params.declarativeDirRel}`, - reason: "missing_schema_dir", - }), - ); - } - - const output = yield* Output; - const edgeRuntime = yield* LegacyEdgeRuntimeScript; - // Go's `pgdelta.ApplyDeclarative` reads `viper.GetBool("DEBUG")` (`apply.go:332,342`), which - // falls back to `SUPABASE_DEBUG` via `AutomaticEnv` when `--debug` itself is unset — - // `legacyResolveDebugWithProjectEnv` (not the bare `LegacyDebugFlag`) reproduces that (review: - // PRRT_kwDOErm0O86XDr4V). By the time either `db diff`/`db pull` reaches here, - // `ParseDatabaseConfig` has already run `Config.Load` -> `loadNestedEnv`, which really - // `os.Setenv`s the merged project `supabase/.env` into the process (`godotenv.Load`, - // `godotenv@v1.5.1/godotenv.go:184-200`) — unlike this port's own `legacyLoadProjectEnv`, - // which is deliberately pure — so a `SUPABASE_DEBUG` set only in `supabase/.env` is visible - // to Go's `viper.GetBool("DEBUG")` here. `legacyResolveDebugWithProjectEnv` reproduces that - // with `ctx.projectEnv` (`legacyReadDbToml`'s merged map, threaded by both `db diff` and - // `db pull`, review: PRRT_kwDOErm0O86XL_oz). - const debug = yield* legacyResolveDebugWithProjectEnv(ctx.projectEnv); - - yield* output.raw("Applying declarative schemas via pg-delta...\n", "stderr"); - - const env: Record = { - SCHEMA_PATH: LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH, - TARGET: params.target, - }; - const binds = [ - `${legacyEdgeRuntimeId(ctx.projectId)}:/root/.cache/deno:rw`, - `${params.declarativeDirAbs}:${LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH}:ro`, - ]; - const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); - const result = yield* edgeRuntime - .run({ - script: legacyInterpolatePgDeltaScript(legacyPgDeltaDeclarativeApplyScript, ctx.npmVersion), - env, - binds, - errPrefix: "error running pg-delta script", - extraFiles: npm.extraFiles, - extraEnv: npm.extraEnv, - denoVersion: ctx.denoVersion, - workdir: ctx.cwd, - }) - .pipe( - Effect.mapError( - (cause) => - new LegacyPgDeltaDeclarativeApplyError({ message: cause.message, reason: cause.docker }), - ), - ); - - const parsed = yield* Effect.try({ - try: () => { - const raw: unknown = JSON.parse(result.stdout); - // Go's `json.Unmarshal` accepts a top-level JSON `null` for the non-pointer - // `ApplyResult` destination and leaves it zero-valued, with no error (verified - // empirically) — so a `null` payload must fall through to the normal - // `status !== "success"` failure path below, not be misclassified as a parse - // failure. See {@link legacyIsPgDeltaApplyResult}'s own doc comment. - const normalized: unknown = raw === null ? {} : raw; - if (!legacyIsPgDeltaApplyResult(normalized)) { - throw new Error("pg-delta apply output was not a JSON object"); - } - return normalized; - }, - catch: (cause) => - new LegacyPgDeltaDeclarativeApplyError({ - message: debug - ? `failed to parse pg-delta apply output: ${errMessage(cause)}\nstdout: ${result.stdout}` - : `failed to parse pg-delta apply output: ${errMessage(cause)}`, - reason: "output_parse", - }), - }); - - if (parsed.status !== "success") { - // `output.rawBytes`, not `output.raw`: `legacyFormatApplyFailure` returns a `Buffer` that - // may contain intentionally-invalid trailing UTF-8 bytes (a truncated SQL statement cut - // mid-codepoint, matching Go's raw byte slice) — decoding it into a string here would - // corrupt exactly the bytes that Buffer exists to preserve. See its own doc comment. - yield* output.rawBytes( - Buffer.concat([legacyFormatApplyFailure(parsed, debug), Buffer.from("\n", "utf-8")]), - "stderr", - ); - if (debug) { - const debugJson = legacyFormatDebugJson(result.stdout); - if (debugJson.length > 0) { - yield* output.raw("pg-delta apply result:\n", "stderr"); - yield* output.raw(`${debugJson}\n`, "stderr"); - } - } - return yield* Effect.fail( - new LegacyPgDeltaDeclarativeApplyError({ - message: `pg-delta declarative apply failed with status: ${parsed.status ?? ""}`, - }), - ); - } - yield* output.raw( - `Applied ${parsed.totalApplied ?? 0} statements in ${parsed.totalRounds ?? 0} round(s).\n`, - "stderr", - ); -}); diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta.apply.unit.test.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta.apply.unit.test.ts deleted file mode 100644 index 409eeac15f..0000000000 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta.apply.unit.test.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { - legacyFormatApplyFailure, - legacyFormatDebugJson, - type LegacyPgDeltaApplyDiagnosis, - type LegacyPgDeltaApplyIssue, - type LegacyPgDeltaApplyResult, - type LegacyPgDeltaApplyStatementLocation, -} from "./legacy-pgdelta.apply.ts"; - -describe("legacyFormatApplyFailure", () => { - test("renders the status + counts summary line, with no per-statement sections when there are no issues", () => { - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalStatements: 4, - totalRounds: 2, - totalApplied: 3, - totalSkipped: 1, - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain('pg-delta apply returned status "error".'); - expect(message).toContain("3/4 statements applied in 2 round(s); 1 skipped."); - expect(message).toContain("No per-statement diagnostics were reported by pg-delta."); - expect(message).toContain("https://github.com/supabase/pg-toolbelt/issues"); - }); - - test("derives totalStatements from applied + skipped + stuck when omitted", () => { - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalRounds: 1, - totalApplied: 2, - totalSkipped: 1, - stuckStatements: ["stuck one"], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain("2/4 statements applied in 1 round(s); 1 skipped."); - }); - - test("renders a structured issue with no `statement` field as its message, with SQLSTATE/position/dependency metadata appended", () => { - const issue: LegacyPgDeltaApplyIssue = { - message: "relation already exists", - code: "42P07", - position: 15, - isDependencyError: true, - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [issue], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain("Errors:"); - expect(message).toContain( - "- relation already exists (SQLSTATE 42P07, position 15, dependency error)", - ); - }); - - test("renders a genuine bare string issue (Go's ApplyIssue string-arm) as its own message", () => { - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: ["relation already exists"], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain("Errors:\n- relation already exists"); - }); - - test("renders a structured issue with its statement id/class, detail, hint, and truncated SQL", () => { - const issue: LegacyPgDeltaApplyIssue = { - message: "column does not exist", - statement: { - id: "001_add_column", - statementClass: "alter_table", - sql: "alter table t add column c int;", - }, - detail: "Column c was dropped earlier in this plan.", - hint: "Check the plan ordering.", - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [issue], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain("- 001_add_column [alter_table]"); - expect(message).toContain(" column does not exist"); - expect(message).toContain(" Detail: Column c was dropped earlier in this plan."); - expect(message).toContain(" Hint: Check the plan ordering."); - expect(message).toContain(" SQL: alter table t add column c int;"); - }); - - test("truncates a multibyte SQL statement by UTF-8 bytes, not UTF-16 code units", () => { - // Go's `formatStatementSQL` (`apply.go:277-283`) truncates via `len(normalized)` and - // `normalized[:maxLen-3]`, both of which count/slice raw UTF-8 bytes. 70 repetitions of a - // single 3-byte CJK character is only 70 JS UTF-16 code units (well under the 120-char - // threshold a naive `.length`/`.slice()` guard would use — it would never truncate at all), - // but 210 UTF-8 bytes — well over Go's 120-byte limit. `117 / 3 === 39` lands the byte cut - // exactly on a codepoint boundary, so the expected output is unambiguous. - const sql = "字".repeat(70); - const issue: LegacyPgDeltaApplyIssue = { - message: "boom", - statement: { id: "001_a", sql }, - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [issue], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(sql.length).toBeLessThanOrEqual(120); - expect(Buffer.byteLength(sql, "utf-8")).toBe(210); - expect(message).toContain(` SQL: ${"字".repeat(39)}...`); - expect(message).not.toContain(sql); - }); - - test("collapses a NEL (U+0085) as whitespace, matching Go's unicode.IsSpace, unlike ECMAScript's `\\s`", () => { - // Go's `formatStatementSQL` (`apply.go:277-283`) normalizes via `strings.Fields`, which - // splits on `unicode.IsSpace` — and `unicode.IsSpace(0x85)` (NEL) is `true` (verified - // empirically), so a NEL embedded in a user's SQL statement is collapsed like any other - // run of whitespace. ECMAScript's `\s` does NOT match NEL, so a naive `.split(/\s+/u)` - // would preserve it verbatim instead of collapsing it. - const nel = String.fromCodePoint(0x85); - const sql = `select${nel}1;`; - const issue: LegacyPgDeltaApplyIssue = { - message: "boom", - statement: { id: "001_a", sql }, - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [issue], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain(" SQL: select 1;"); - expect(message).not.toContain(nel); - }); - - test("preserves a BOM (U+FEFF) instead of treating it as whitespace, matching Go's unicode.IsSpace, unlike ECMAScript's `\\s`", () => { - // The opposite gap from the NEL case above: `unicode.IsSpace(0xFEFF)` (BOM) is `false` - // (verified empirically), so Go's `strings.Fields` keeps a BOM embedded mid-statement as - // part of the surrounding "word" rather than treating it as a separator. ECMAScript's `\s` - // DOES match a BOM, so a naive `.split(/\s+/u)` would incorrectly split on it. - const bom = String.fromCodePoint(0xfeff); - const sql = `select${bom}1;`; - const issue: LegacyPgDeltaApplyIssue = { - message: "boom", - statement: { id: "001_a", sql }, - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [issue], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain(` SQL: select${bom}1;`); - }); - - test("preserves Go's exact (possibly invalid-UTF-8) truncated bytes when the byte cut lands mid-codepoint", () => { - // Unlike the boundary-aligned CJK-repeat case above, a single leading ASCII byte shifts - // every subsequent 3-byte CJK character by one, so the byte-117 cut now lands ONE byte - // into a character instead of exactly on a boundary — reproducing the pathological case - // where Go's raw `normalized[:117]` slice is intentionally invalid UTF-8. Verified against - // Go's own `formatStatementSQL` (`apply.go:277-283`): slicing this exact byte range - // produces a 120-byte result that `unicode/utf8.ValidString` reports as `false`. A naive - // `Buffer#toString("utf-8")` truncation would instead substitute U+FFFD for the incomplete - // trailing sequence, corrupting the byte-exact stderr contract. - const sql = `a${"字".repeat(60)}`; - const issue: LegacyPgDeltaApplyIssue = { - message: "boom", - statement: { id: "001_a", sql }, - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [issue], - }; - const message = legacyFormatApplyFailure(result, false); - const normalizedBytes = Buffer.from(sql, "utf-8"); - const expectedTruncatedTail = Buffer.concat([ - normalizedBytes.subarray(0, 117), - Buffer.from("...", "utf-8"), - ]); - expect(expectedTruncatedTail.byteLength).toBe(120); - expect( - message.includes(Buffer.concat([Buffer.from(" SQL: ", "utf-8"), expectedTruncatedTail])), - ).toBe(true); - // No replacement character (the tell-tale sign of a lossy UTF-8 decode/re-encode - // round-trip) should ever appear in the output. - expect(message.includes(Buffer.from("�", "utf-8"))).toBe(false); - }); - - test("treats a null errors/stuckStatements/validationErrors/diagnostics array as empty, matching Go's nil-slice decode", () => { - // Go's `encoding/json` accepts a JSON `null` for a `[]T` slice field with no error, - // leaving a nil (zero-length) slice — verified empirically: - // `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` - // with `len(r.Errors) == 0`. `legacyFormatApplyFailure` itself already treats a JS `null`/ - // `undefined` array as empty via `?? []`; this exercises that the TYPE also tolerates it - // (the earlier structural-guard bug — `legacyIsPgDeltaApplyResult` — is covered by the - // integration test in `legacy-pgdelta.apply.integration.test.ts`, since it isn't exported). - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: null, - stuckStatements: null, - validationErrors: null, - diagnostics: null, - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain("No per-statement diagnostics were reported by pg-delta."); - expect(message).not.toContain("Errors:"); - expect(message).not.toContain("Stuck statements:"); - }); - - test("stuck statements and validation errors get their own labeled sections", () => { - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - stuckStatements: ["still stuck"], - validationErrors: ["bad function body"], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain("Stuck statements:\n- still stuck"); - expect(message).toContain( - "Validation errors (from check_function_bodies=on pass):\n- bad function body", - ); - }); - - test("diagnostics collapse to a one-line count unless verbose", () => { - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 1, - totalRounds: 1, - totalSkipped: 0, - errors: ["some error"], - diagnostics: [{ message: "unused index" }, { message: "missing default" }], - }; - const collapsed = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(collapsed).toContain("2 pg-topo diagnostic(s) omitted (re-run with --debug to view)."); - expect(collapsed).not.toContain("unused index"); - - const verbose = legacyFormatApplyFailure(result, true).toString("utf-8"); - expect(verbose).toContain("Diagnostics:"); - expect(verbose).toContain("- unused index"); - expect(verbose).toContain("- missing default"); - }); - - test("renders a partially-populated statement (missing sql/statementClass) without throwing", () => { - // Reproduces feeding a real pg-delta subprocess's malformed stdout - // (`{"errors":[{"message":"boom","statement":{"id":"s1"}}]}`) through - // `legacyApplyDeclarativePgDelta` — that function only validates the top-level shape - // (`{status: string}`), not nested fields, and this only ever runs on an - // ALREADY-FAILED apply, so a formatter crash here would turn an actionable SQL error - // into an unhandled defect. - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":[{"message":"boom","statement":{"id":"s1"}}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, false).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, false).toString("utf-8"); - expect(message).toContain("- s1"); - expect(message).toContain(" boom"); - expect(message).not.toContain("undefined"); - }); - - test("renders an issue with a null `statement` field as its message, without throwing", () => { - // Reproduces feeding a real pg-delta subprocess's stdout - // (`{"errors":[{"statement":null,"message":"failed"}]}`) through - // `legacyApplyDeclarativePgDelta` — Go's `Statement *ApplyStatement` is a pointer, so - // `"statement":null` unmarshals to `nil` and `formatApplyIssue`'s `issue.Statement == nil` - // (`apply.go:202`) treats it identically to a missing field. A no-statement guard that only - // checks `=== undefined` would fall through to `issue.statement.statementClass` on `null` - // and throw a `TypeError` instead of rendering the message. - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":[{"statement":null,"message":"failed"}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, false).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, false).toString("utf-8"); - expect(message).toContain("Errors:\n- failed"); - }); - - test("renders an issue whose detail/hint/sql/statementClass arrived as non-strings without throwing", () => { - // A malformed pg-delta payload can hand any of these fields a non-string value (e.g. a - // future release that reports a numeric `detail`) — a bare `?? ""` guard (rather than - // `String(x ?? "")`) would still pass the number straight to `.trim()`/`.split()` and throw. - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":[{"message":"boom","statement":{"id":"s1","statementClass":42,"sql":7},"detail":123,"hint":456}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, false).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, false).toString("utf-8"); - expect(message).toContain("- s1 [42]"); - expect(message).toContain(" Detail: 123"); - expect(message).toContain(" Hint: 456"); - expect(message).toContain(" SQL: 7"); - }); - - test("renders a diagnosis whose message/code/suggestedFix arrived as non-strings without throwing", () => { - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":123,"code":456,"suggestedFix":789}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); - expect(message).toContain("[456] 123"); - expect(message).toContain("Suggested fix: 789"); - }); - - test("drops a diagnosis's statementId when a nested field is mistyped, matching Go's nil fallback", () => { - // Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-108`) tries decoding `statementId` - // as an `ApplyStatementLocation` object first; a mistyped `filePath` (a number, not a - // string) fails that decode, and its bare-string fallback ALSO fails since the value is an - // object, not a string — so Go silently leaves `StatementID` nil, never erroring the whole - // `ApplyResult` parse. Verified empirically against Go's real struct + fallback chain: - // `{"statementId":{"filePath":123,"statementIndex":1}}` decodes with `StatementID == nil`. - // Rendering the raw object anyway (coercing `filePath` via `String(123)`) would show a - // bogus `(123#1)` location Go never emits. - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":"d","statementId":{"filePath":123,"statementIndex":1}}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); - expect(message).toContain("- d"); - expect(message).not.toContain("123#1"); - expect(message).not.toContain("(123"); - }); - - test("drops a diagnosis's statementId when sourceOffset is mistyped, even though the location renderer never reads it", () => { - // Go's struct-level `json.Unmarshal` into `ApplyStatementLocation` (`apply.go:73-77`) - // fails the moment ANY declared field has the wrong type — including `sourceOffset`, - // which `legacyFormatStatementLocation`/Go's own `formatStatementLocation` never - // display. Verified empirically against Go's real struct: - // `json.Unmarshal([]byte(\`{"filePath":"x.sql","sourceOffset":"bad"}\`), &loc)` returns a - // non-nil error even though `filePath` itself is well-typed, so the object-shape decode - // fails, the bare-string fallback also fails (the value is an object, not a string), and - // Go leaves `StatementID` nil — the location must be dropped, not rendered as `(x.sql)`, - // which would misattribute the diagnostic to a file Go never resolved. - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":"d","statementId":{"filePath":"x.sql","sourceOffset":"bad"}}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); - expect(message).toContain("- d"); - expect(message).not.toContain("x.sql"); - }); - - test("renders a diagnosis with a null statementId as having no location, without throwing", () => { - // Reproduces a real pg-delta subprocess emitting - // `{"diagnostics":[{"message":"failed","statementId":null}]}` — Go's - // `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-108`) explicitly maps a JSON - // `"statementId":null` to a nil `*ApplyStatementLocation`, and `formatStatementLocation` - // (`apply.go:263-274`) returns `""` for a nil pointer. A guard that only checked - // `resolved === undefined` (not `null`) would fall through to - // `legacyFormatStatementLocation`'s `resolved.filePath` and dereference a `null`, throwing a - // `TypeError` instead of rendering the rest of the diagnostic. - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":"failed","statementId":null}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); - expect(message).toContain("- failed"); - expect(message).not.toContain("undefined"); - }); - - test("a diagnosis with a statementId location and suggestedFix renders both", () => { - const statementId: LegacyPgDeltaApplyStatementLocation = { - filePath: "001_a.sql", - statementIndex: 2, - }; - const diagnosis: LegacyPgDeltaApplyDiagnosis = { - code: "PGT001", - message: "circular dependency", - statementId, - suggestedFix: "Split the statement across two files.", - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 1, - totalRounds: 1, - totalSkipped: 0, - errors: ["some error"], - diagnostics: [diagnosis], - }; - const message = legacyFormatApplyFailure(result, true).toString("utf-8"); - expect(message).toContain("- [PGT001] circular dependency (001_a.sql#2)"); - expect(message).toContain("Suggested fix: Split the statement across two files."); - }); -}); - -describe("legacyFormatDebugJson", () => { - test("pretty-prints valid JSON", () => { - expect(legacyFormatDebugJson('{"status":"error","totalApplied":1}')).toBe( - JSON.stringify({ status: "error", totalApplied: 1 }, null, 2), - ); - }); - - test("returns the trimmed raw string when it isn't valid JSON", () => { - expect(legacyFormatDebugJson(" not json ")).toBe("not json"); - }); - - test("returns empty for blank input", () => { - expect(legacyFormatDebugJson(" ")).toBe(""); - }); - - test("preserves an integer literal beyond Number.MAX_SAFE_INTEGER byte-for-byte", () => { - // Go's `json.Indent` (`encoding/json/indent.go`) only inserts whitespace between existing - // tokens — it never decodes a number into a value and re-encodes it. `JSON.parse` would - // decode this literal into a `float64`-backed JS number, silently rounding it (verified: - // `JSON.parse("9007199254740993").toString()` is `"9007199254740992"`), and - // `JSON.stringify` would then re-emit the ROUNDED value — corrupting the exact debug - // payload users are asked to attach to bug reports. - const raw = '{"id":9007199254740993}'; - expect(legacyFormatDebugJson(raw)).toBe('{\n "id": 9007199254740993\n}'); - }); - - test("preserves an existing string escape's exact representation (e.g. an escaped forward slash)", () => { - // Go's `json.Indent` copies string tokens byte-for-byte, so an existing `\/` escape stays - // `\/`. `JSON.stringify(JSON.parse(...))` would instead re-escape the decoded `/` using its - // own (unescaped) convention, changing the payload's exact bytes. - const raw = '{"path":"a\\/b"}'; - expect(legacyFormatDebugJson(raw)).toBe('{\n "path": "a\\/b"\n}'); - }); - - test("matches Go's json.Indent shape for nested objects/arrays, including empty ones", () => { - const raw = '{"a":1,"b":{"c":2,"d":[1,{"e":3}]},"empty":{},"emptyArr":[]}'; - expect(legacyFormatDebugJson(raw)).toBe( - [ - "{", - ' "a": 1,', - ' "b": {', - ' "c": 2,', - ' "d": [', - " 1,", - " {", - ' "e": 3', - " }", - " ]", - " },", - ' "empty": {},', - ' "emptyArr": []', - "}", - ].join("\n"), - ); - }); -}); diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta.deno-templates.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta.deno-templates.ts deleted file mode 100644 index f2a0a13a09..0000000000 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta.deno-templates.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Verbatim copies of the Go pg-delta Deno templates. These embed the scripts -// 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- -// export live in `apps/cli-go/internal/db/diff/templates/`, and the declarative -// *apply* template (used by `getDeclarativeCatalogRef` → `pgdelta.ApplyDeclarative` -// to build the declarative target catalog on the shadow database) lives in -// `apps/cli-go/internal/pgdelta/templates/`. The migra.* templates back the -// non-pgdelta diff path, which declarative commands never reach. -// -// Each template pins `npm:@supabase/pg-delta@1.0.0-alpha.20` as a placeholder -// that `legacyInterpolatePgDeltaScript` rewrites to the effective npm version -// (`apps/cli-go/pkg/config/pgdelta_version.go`). - -/** `templates/pgdelta.ts` — diffs SOURCE→TARGET and prints SQL statements. */ -export const legacyPgDeltaDiffScript = - 'import {\n createPlan,\n deserializeCatalog,\n renderPlanFiles,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n // CompositionPattern `and` is valid FilterDSL; Deno\'s structural typing is strict on `or` branches.\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nconst parsedFormatOptions = formatOptionsRaw ? JSON.parse(formatOptionsRaw) : undefined;\n// Format the emitted SQL by default with the same sensible settings the\n// declarative export uses (`exportDeclarativeSchema` in @supabase/pg-delta:\n// `{ ...DEFAULT_OPTIONS, maxWidth: 180, keywordCase: "upper", ...userOptions }`),\n// so `db pull` / `db diff` produce readable migrations even when config sets no\n// `[experimental.pgdelta] format_options`. The formatter fills DEFAULT_OPTIONS\n// for missing keys itself, so only the two overrides are passed here. Setting\n// `format_options = "null"` (parsed to `null`) is the explicit opt-out: raw,\n// unformatted statements, mirroring declarative export\'s `formatOptions === null`.\nconst sqlFormatOptions =\n parsedFormatOptions === null\n ? undefined\n : { maxWidth: 180, keywordCase: "upper", ...parsedFormatOptions };\n\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n // pg-delta >= 1.0.0-alpha.32 groups plan statements into execution-aware\n // `units` with transaction boundaries. `renderPlanFiles` turns those into one\n // numbered SQL file per unit (header comments included). `includeTransactions:\n // false` because the CLI appliers already wrap each migration file in a single\n // transaction (Go and TS implicit extended-protocol batches), so embedded\n // BEGIN/COMMIT would override that file-level boundary. Format options are\n // applied per unit here instead of a manual `formatSqlStatements` pass.\n const files = result\n ? renderPlanFiles(result.plan, {\n includeTransactions: false,\n sqlFormatOptions,\n })\n : [];\n const envelope = files.map((file, index) => ({\n order: index + 1,\n // The unit name is the rendered path minus its numeric prefix and `.sql`\n // extension (e.g. `001_after_enum_values.sql` -> `after_enum_values`).\n name: file.path.replace(/^\\d+_/, "").replace(/\\.sql$/, ""),\n transactionMode: file.unit.transactionMode,\n sql: file.sql,\n }));\n if (Deno.env.get("PGDELTA_DEBUG")) {\n console.error(\n JSON.stringify({\n statementCount: files.reduce((total, file) => total + file.unit.statements.length, 0),\n fileCount: files.length,\n source: source ? "connected" : "null",\n target: target ? "connected" : "null",\n includedSchemas: includedSchemas ?? null,\n skipDefaultPrivilegeSubtraction: true,\n }),\n );\n }\n console.log(JSON.stringify({ version: 1, files: envelope }));\n} catch (e) {\n console.error(e);\n // Emit a sentinel so the CLI runner can distinguish a real script crash from a\n // successful empty diff, even though the forced-exit non-zero code below is\n // suppressed by the "main worker has been destroyed" handling.\n console.error("PGDELTA_SCRIPT_ERROR");\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the diff has been written, so the container\n// never exits and the CLI — which follows this container\'s logs — hangs\n// indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; - -/** `templates/pgdelta_declarative_export.ts` — exports declarative file payloads. */ -export const legacyPgDeltaDeclarativeExportScript = - '// This script is executed inside Edge Runtime by the CLI to export a target\n// schema as declarative file payloads. It accepts either live DB URLs or\n// catalog-file references for SOURCE/TARGET, which enables cached sync flows.\nimport {\n createPlan,\n deserializeCatalog,\n exportDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as unknown as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n if (!result) {\n console.log(\n JSON.stringify({\n version: 1,\n mode: "declarative",\n files: [],\n }),\n );\n } else {\n const output = exportDeclarativeSchema(result, {\n integration: supabase,\n formatOptions,\n });\n console.log(\n JSON.stringify(output, (_key, value) =>\n typeof value === "bigint" ? Number(value) : value,\n ),\n );\n }\n} catch (e) {\n console.error(e);\n // Emit a sentinel so the CLI runner can distinguish a real script crash from a\n // successful empty export, even though the forced-exit non-zero code below is\n // suppressed by the "main worker has been destroyed" handling.\n console.error("PGDELTA_SCRIPT_ERROR");\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the export has been written, so the\n// container never exits and the CLI — which follows this container\'s logs —\n// hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; - -/** `templates/pgdelta_catalog_export.ts` — serializes a catalog snapshot for caching. */ -export const legacyPgDeltaCatalogExportScript = - '// This script serializes a database catalog for caching/reuse in declarative\n// sync workflows, so later diff/export operations can run from file references.\nimport {\n createManagedPool,\n extractCatalog,\n serializeCatalog,\n stringifyCatalogSnapshot,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\n\nconst target = Deno.env.get("TARGET");\nconst role = Deno.env.get("ROLE") ?? undefined;\n\nif (!target) {\n console.error("TARGET is required");\n // Emit a sentinel so the CLI runner treats this as a real script crash rather\n // than a successful empty catalog, even though the forced-exit non-zero code is\n // suppressed by the "main worker has been destroyed" handling.\n console.error("PGDELTA_SCRIPT_ERROR");\n throw new Error("");\n}\nconst { pool, close } = await createManagedPool(target, { role });\n\ntry {\n const catalog = await extractCatalog(pool);\n console.log(stringifyCatalogSnapshot(serializeCatalog(catalog)));\n} catch (e) {\n console.error(e);\n // Emit a sentinel so the CLI runner can distinguish a real script crash from a\n // successful empty catalog, even though the forced-exit non-zero code below is\n // suppressed by the "main worker has been destroyed" handling.\n console.error("PGDELTA_SCRIPT_ERROR");\n // Force close event loop\n throw new Error("");\n} finally {\n await close();\n}\n// Force close the event loop on the success path too. The connection pool can\n// leave keepalive handles registered even after close() resolves, which keeps\n// the Edge Runtime worker (and therefore the container) alive after the catalog\n// has already been written to stdout. The CLI streams this container\'s logs with\n// Follow:true, so a worker that never exits hangs the parent `__catalog`\n// subprocess — and the declarative-sync command that spawned it — indefinitely\n// at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; - -/** `internal/pgdelta/templates/pgdelta_declarative_apply.ts` — applies declarative files to TARGET. */ -export const legacyPgDeltaDeclarativeApplyScript = - '// This script applies declarative schema files to a target database and emits\n// structured JSON so the Go caller can report success/failure deterministically.\nimport {\n applyDeclarativeSchema,\n loadDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20/declarative";\n\nconst schemaPath = Deno.env.get("SCHEMA_PATH");\nconst target = Deno.env.get("TARGET");\n\nif (!schemaPath) {\n throw new Error("SCHEMA_PATH is required");\n}\nif (!target) {\n throw new Error("TARGET is required");\n}\n\ntry {\n const content = await loadDeclarativeSchema(schemaPath);\n if (content.length === 0) {\n console.log(JSON.stringify({ status: "success", totalStatements: 0 }));\n } else {\n const result = await applyDeclarativeSchema({\n content,\n targetUrl: target,\n });\n const apply = result?.apply;\n if (!apply) {\n throw new Error("pg-delta apply returned no result");\n }\n const payload = {\n status: apply.status,\n totalStatements: result.totalStatements ?? 0,\n totalRounds: apply.totalRounds ?? 0,\n totalApplied: apply.totalApplied ?? 0,\n totalSkipped: apply.totalSkipped ?? 0,\n errors: apply.errors ?? [],\n stuckStatements: apply.stuckStatements ?? [],\n // validationErrors is populated when the final\n // check_function_bodies=on pass catches issues that didn\'t surface during\n // the initial apply rounds (e.g. a function body that references a\n // column whose type changed). Without surfacing this field, callers see\n // status=error with empty errors/stuckStatements and no actionable info.\n validationErrors: apply.validationErrors ?? [],\n diagnostics: result.diagnostics ?? [],\n };\n console.log(JSON.stringify(payload));\n if (apply.status !== "success") {\n throw new Error("pg-delta apply failed with status: " + apply.status);\n }\n }\n} catch (e) {\n throw e instanceof Error ? e : new Error(String(e));\n}\n// Force close the event loop on the success path. applyDeclarativeSchema opens a\n// connection to TARGET whose keepalive handles can keep the Edge Runtime worker\n// alive after the result JSON has been written, so the container never exits and\n// the CLI — which follows this container\'s logs — hangs indefinitely at 0% CPU\n// (supabase/pg-toolbelt#312). The catch above re-throws the real error, so this\n// only runs once a successful apply has been reported on stdout.\nthrow new Error("");\n'; - -/** - * The npm dist-tag/version used for `@supabase/pg-delta` when - * `supabase/.temp/pgdelta-version` (the `[experimental.pgdelta].npm_version` - * config field) is absent or empty. Mirrors Go's `DefaultPgDeltaNpmVersion` - * (`apps/cli-go/pkg/config/pgdelta_version.go:7`). - */ -export const LEGACY_DEFAULT_PG_DELTA_NPM_VERSION = "1.0.0-alpha.33"; - -/** - * The literal version baked into the embedded templates above, replaced by - * `legacyInterpolatePgDeltaScript`. Mirrors Go's `pgDeltaNpmVersionPlaceholder` - * (`apps/cli-go/pkg/config/pgdelta_version.go:9`). - */ -export const LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER = "1.0.0-alpha.20"; - -/** - * Returns the pg-delta npm version from config, or the default when unset. - * Mirrors Go's `EffectivePgDeltaNpmVersion` - * (`apps/cli-go/pkg/config/pgdelta_version.go:13`). - */ -export function legacyEffectivePgDeltaNpmVersion(npmVersion: string | undefined): string { - const trimmed = npmVersion?.trim(); - return trimmed !== undefined && trimmed.length > 0 - ? trimmed - : LEGACY_DEFAULT_PG_DELTA_NPM_VERSION; -} - -/** - * Substitutes the pg-delta npm version placeholder in an embedded template. - * Mirrors Go's `InterpolatePgDeltaScript` - * (`apps/cli-go/pkg/config/pgdelta_version.go:26`). - */ -export function legacyInterpolatePgDeltaScript( - script: string, - npmVersion: string | undefined, -): string { - return script.replaceAll( - LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER, - legacyEffectivePgDeltaNpmVersion(npmVersion), - ); -} diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts deleted file mode 100644 index cfe5ecabfa..0000000000 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -import { - LEGACY_DEFAULT_PG_DELTA_NPM_VERSION, - LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER, - legacyEffectivePgDeltaNpmVersion, - legacyInterpolatePgDeltaScript, - legacyPgDeltaCatalogExportScript, - legacyPgDeltaDeclarativeApplyScript, - legacyPgDeltaDeclarativeExportScript, - legacyPgDeltaDiffScript, -} 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), -); -const goPgDeltaTemplatesDir = fileURLToPath( - new URL("../../../../../cli-go/internal/pgdelta/templates/", import.meta.url), -); -const readGoTemplate = (name: string) => readFileSync(`${goDiffTemplatesDir}${name}`, "utf8"); - -describe("embedded pg-delta Deno templates", () => { - it("match the Go sources byte-for-byte", () => { - expect(legacyPgDeltaDiffScript).toBe(readGoTemplate("pgdelta.ts")); - expect(legacyPgDeltaDeclarativeExportScript).toBe( - readGoTemplate("pgdelta_declarative_export.ts"), - ); - expect(legacyPgDeltaCatalogExportScript).toBe(readGoTemplate("pgdelta_catalog_export.ts")); - expect(legacyPgDeltaDeclarativeApplyScript).toBe( - readFileSync(`${goPgDeltaTemplatesDir}pgdelta_declarative_apply.ts`, "utf8"), - ); - }); - - it("pin the placeholder npm version that interpolation rewrites", () => { - expect(legacyPgDeltaDiffScript).toContain( - `npm:@supabase/pg-delta@${LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER}`, - ); - expect(legacyPgDeltaDeclarativeExportScript).toContain( - `npm:@supabase/pg-delta@${LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER}`, - ); - expect(legacyPgDeltaCatalogExportScript).toContain( - `npm:@supabase/pg-delta@${LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER}`, - ); - }); -}); - -describe("legacyEffectivePgDeltaNpmVersion", () => { - it("returns the default when the version is unset, empty, or whitespace", () => { - expect(legacyEffectivePgDeltaNpmVersion(undefined)).toBe(LEGACY_DEFAULT_PG_DELTA_NPM_VERSION); - expect(legacyEffectivePgDeltaNpmVersion("")).toBe(LEGACY_DEFAULT_PG_DELTA_NPM_VERSION); - expect(legacyEffectivePgDeltaNpmVersion(" ")).toBe(LEGACY_DEFAULT_PG_DELTA_NPM_VERSION); - }); - - it("trims and returns a configured version", () => { - expect(legacyEffectivePgDeltaNpmVersion(" 1.2.3 ")).toBe("1.2.3"); - }); -}); - -describe("legacyInterpolatePgDeltaScript", () => { - it("rewrites every placeholder occurrence to the effective version", () => { - const out = legacyInterpolatePgDeltaScript(legacyPgDeltaDiffScript, "9.9.9"); - expect(out).not.toContain(`npm:@supabase/pg-delta@${LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER}`); - expect(out).toContain("npm:@supabase/pg-delta@9.9.9"); - expect(out).toContain("npm:@supabase/pg-delta@9.9.9/integrations/supabase"); - }); - - it("rewrites to the default version when unset", () => { - const out = legacyInterpolatePgDeltaScript(legacyPgDeltaCatalogExportScript, undefined); - expect(out).toContain(`npm:@supabase/pg-delta@${LEGACY_DEFAULT_PG_DELTA_NPM_VERSION}`); - }); -}); diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta.errors.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta.errors.ts index a50252e92d..06b3f3cdc3 100644 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta.errors.ts +++ b/apps/cli/src/commands/db/shared/legacy-pgdelta.errors.ts @@ -6,33 +6,6 @@ import { ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; -/** - * 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; - readonly docker?: "daemon" | "inspect" | "pull"; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - if (this.docker === "daemon") { - return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; - } - if (this.docker === "pull") { - return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; - } - if (this.docker === "inspect") { - return { ...actionability.invalidConfig, fingerprint_suffix: "image_inspect" }; - } - return actionability.dbFinding; - } -} - /** * Setting up / connecting to / migrating the throwaway shadow database failed. * Wraps the errors from `CreateShadowDatabase` / `ConnectShadowDatabase` / @@ -56,50 +29,6 @@ export class LegacyDeclarativeShadowDbError extends Data.TaggedError( } } -/** - * 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; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.impossibleState; - } -} - -/** - * 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; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.impossibleState; - } -} - -/** - * Parsing the pg-delta diff envelope failed. Byte-matches Go's - * `"failed to parse pg-delta diff output: " + err + ":\n" + stderr` - * (`apps/cli-go/internal/db/diff/pgdelta.go`, `parsePgDeltaDiffOutput`). - */ -export class LegacyPgDeltaDiffParseError extends Data.TaggedError("LegacyPgDeltaDiffParseError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.impossibleState; - } -} - /** * Materializing the declarative export on disk failed. Byte-matches Go's * `WriteDeclarativeSchemas` errors (`declarative.go:239`): diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta.errors.unit.test.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta.errors.unit.test.ts index e2190fd935..9a5d59c331 100644 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta.errors.unit.test.ts +++ b/apps/cli/src/commands/db/shared/legacy-pgdelta.errors.unit.test.ts @@ -1,33 +1,8 @@ import { describe, expect, it } from "vitest"; import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; -import { - LegacyDeclarativeEdgeRuntimeError, - LegacyDeclarativeShadowDbError, -} from "./legacy-pgdelta.errors.ts"; +import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; describe("pg-delta error actionability", () => { - it.each([ - ["daemon", "user_actionable", "docker_not_running", "docker_not_running"], - ["pull", "external_service", "network", "registry_pull"], - ["inspect", "user_actionable", "invalid_config", "image_inspect"], - ] as const)("classifies edge-runtime docker %s failures", (docker, kind, category, suffix) => { - const result = classifyCliErrorActionability( - new LegacyDeclarativeEdgeRuntimeError({ message: "redacted", docker }), - ); - expect(result.error_kind).toBe(kind); - expect(result.error_category).toBe(category); - expect(result.error_fingerprint).toBe(`tag:LegacyDeclarativeEdgeRuntimeError:${suffix}`); - }); - - it("keeps non-docker edge-runtime failures in the database family", () => { - const result = classifyCliErrorActionability( - new LegacyDeclarativeEdgeRuntimeError({ message: "redacted" }), - ); - expect(result.error_kind).toBe("user_actionable"); - expect(result.error_category).toBe("invalid_config"); - expect(result.error_fingerprint).toBe("tag:LegacyDeclarativeEdgeRuntimeError"); - }); - it("distinguishes an unreachable Docker daemon from a missing shadow stack", () => { const daemon = classifyCliErrorActionability( new LegacyDeclarativeShadowDbError({ message: "redacted", docker: "daemon" }), diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta.seam.integration.test.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta.seam.integration.test.ts index cd60c3bf17..807d64a226 100644 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta.seam.integration.test.ts +++ b/apps/cli/src/commands/db/shared/legacy-pgdelta.seam.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -26,13 +26,8 @@ import { type LegacyPgConnInput, } from "../../../command-internal/legacy-db-connection.service.ts"; import { LegacyDockerRun } from "../../../command-internal/legacy-docker-run.service.ts"; -import { - type LegacyEdgeRuntimeRunOpts, - LegacyEdgeRuntimeScript, -} from "../../../command-internal/legacy-edge-runtime-script.service.ts"; import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; import { LEGACY_SUGGEST_DOCKER_INSTALL } from "../../../command-internal/legacy-docker-suggest.ts"; -import { LegacyPgDeltaSslProbe } from "../../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; import { legacyDeclarativeSeamLayer } from "./legacy-pgdelta.seam.layer.ts"; import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; @@ -41,11 +36,8 @@ import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; * Integration coverage for the fully-native `legacyDeclarativeSeamLayer` (CLI-1970) — * `generate`/`sync`'s own integration tests stub `LegacyDeclarativeSeam` entirely * (per its own service doc comment), so this file is the only place the real - * shadow-provisioning composition (`legacy-pgdelta.cache.ts`'s - * `legacyExportBaselineCatalogRef`/`legacyExportDeclarativeCatalogRef`) gets - * exercised end-to-end. Mirrors `declarative.orchestrate.integration.test.ts`'s - * real-shadow-stack pattern (`mockLegacyShadowContainerCliSpawner` + a fake - * `LegacyDbConnection`/`LegacyDockerRun`/`LegacyEdgeRuntimeScript`). + * local-database bring-up composition gets exercised end-to-end, with a fake + * `LegacyDbConnection`/`LegacyDockerRun`. */ const alwaysReadyHttpClientLayer = Layer.succeed( @@ -83,39 +75,6 @@ function fakeShadowSetupDocker() { return { layer }; } -/** - * Distinguishes the two pg-delta edge-runtime scripts this seam invokes by `errPrefix` - * (`legacyApplyDeclarativePgDelta`'s declarative-apply script vs. - * `legacyExportCatalogPgDelta`'s catalog-export script — `legacy-pgdelta.apply.ts`/ - * `legacy-pgdelta.ts`'s own literal `errPrefix` strings). - */ -function fakeEdgeRuntime() { - const calls: Array = []; - const layer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (opts: LegacyEdgeRuntimeRunOpts) => { - calls.push(opts); - if (opts.errPrefix === "error running pg-delta script") { - return Effect.succeed({ - stdout: JSON.stringify({ - status: "success", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - }), - stderr: "", - }); - } - return Effect.succeed({ stdout: '{"schemas":[]}', stderr: "" }); - }, - }); - return { layer, calls }; -} - -const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), -}); - useLegacyShadowCacheDisabled(); function setup( @@ -134,7 +93,6 @@ function setup( }); const dbConnection = fakeShadowDbConnection(); const docker = fakeShadowSetupDocker(); - const edge = fakeEdgeRuntime(); const cliSettings = mockLegacyCliSettings({ workdir, projectId: Option.none() }); // Every service `legacyDeclarativeSeamLayer` needs must be provided directly into `seam` @@ -147,8 +105,6 @@ function setup( Layer.provide(cliSettings), Layer.provide(dbConnection.layer), Layer.provide(docker.layer), - Layer.provide(edge.layer), - Layer.provide(sslProbe), Layer.provide(alwaysReadyHttpClientLayer), Layer.provide(out.layer), Layer.provide(mockRuntimeInfo()), @@ -170,8 +126,6 @@ function setup( shadowSpawner.layer, dbConnection.layer, docker.layer, - edge.layer, - sslProbe, alwaysReadyHttpClientLayer, cliSettings, mockRuntimeInfo(), @@ -182,77 +136,12 @@ function setup( seam, ); - return { layer, out, edgeCalls: edge.calls, shadowSpawned: shadowSpawner.spawned }; + return { layer, out, shadowSpawned: shadowSpawner.spawned }; } const failError = (exit: Exit.Exit) => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; -describe("legacyDeclarativeSeamLayer.exportCatalog", () => { - it.effect( - "provisions a shadow on a baseline cache miss, then reuses the cached catalog with no further container work", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); - const { layer, out, shadowSpawned } = setup(dir); - return Effect.gen(function* () { - const seam = yield* LegacyDeclarativeSeam; - - const firstRef = yield* seam.exportCatalog({ mode: "baseline", noCache: false }); - expect(firstRef).toMatch(/^supabase[/\\]\.temp[/\\]pgdelta[/\\]catalog-baseline-.*\.json$/); - expect(readFileSync(join(dir, firstRef), "utf8")).toBe('{"schemas":[]}'); - expect(out.stderrText).toContain("Creating shadow database...\n"); - expect(shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - - // Cache hit: same ref, zero additional container work. - const secondRef = yield* seam.exportCatalog({ mode: "baseline", noCache: false }); - expect(secondRef).toBe(firstRef); - expect(shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - - rmSync(dir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect( - "writes catalog-nocache-declarative.json on --no-cache, applying the declarative directory first", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); - const declDir = join(dir, "supabase", "schemas"); - mkdirSync(declDir, { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "create table t ();"); - const { layer, edgeCalls, shadowSpawned } = setup(dir); - return Effect.gen(function* () { - const seam = yield* LegacyDeclarativeSeam; - const ref = yield* seam.exportCatalog({ mode: "declarative", noCache: true }); - expect(ref).toBe(join("supabase", ".temp", "pgdelta", "catalog-nocache-declarative.json")); - expect(readFileSync(join(dir, ref), "utf8")).toBe('{"schemas":[]}'); - expect(edgeCalls.some((c) => c.errPrefix === "error running pg-delta script")).toBe(true); - expect(shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect("maps a shadow-provisioning failure to LegacyDeclarativeShadowDbError", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); - const { layer } = setup(dir, { failCreate: true }); - return Effect.gen(function* () { - const seam = yield* LegacyDeclarativeSeam; - const exit = yield* seam.exportCatalog({ mode: "baseline", noCache: true }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - const error = failError(exit); - expect(error).toBeInstanceOf(LegacyDeclarativeShadowDbError); - expect((error as LegacyDeclarativeShadowDbError).message).toContain( - "failed to provision the shadow database:", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); - }); -}); - describe("legacyDeclarativeSeamLayer.ensureLocalDatabaseStarted", () => { it.effect( "carries the inspect failure's daemon marker AND recovery suggestion onto the seam error", diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta.seam.layer.ts index af7aecc1b7..12e5d870d1 100644 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -10,10 +10,6 @@ import { legacyIsDockerDaemonUnreachable } from "../../../command-internal/legac import { isSlimImageRef } from "../../../shared/services/slim-images.ts"; import { legacyIsLocalDbRunning } from "../../../command-internal/db-bootstrap/local-db-running.ts"; import { legacyStartLocalDatabase } from "../../../command-internal/db-bootstrap/start-local-database.ts"; -import { - legacyExportBaselineCatalogRef, - legacyExportDeclarativeCatalogRef, -} from "../../../command-internal/legacy-pgdelta.cache.ts"; import { legacyResolveLocalProjectId, localDbContainerId, @@ -30,8 +26,7 @@ const legacyShadowDockerCause = ( * Whether an underlying failure signals the Docker daemon is unreachable, across every tagged * error class this seam composes over — `LegacyShadowDbError.reason === "docker_daemon"`, * `LegacyImagePrepullError.reason === "docker_daemon"`, `LegacyLocalDbRunningError.daemonDown`, - * `LegacyPgDeltaDeclarativeApplyError.reason === "daemon"`, and every `*.docker === "daemon"` - * field (`LegacyDeclarativeEdgeRuntimeError`, …). Checked structurally rather than per-tag so a + * and every `*.docker === "daemon"` field. Checked structurally rather than per-tag so a * new error class in the union doesn't silently drop its own daemon signal. */ function legacyHasDaemonSignal(cause: { @@ -49,13 +44,10 @@ function legacyHasDaemonSignal(cause: { } /** - * Maps any failure from the native shadow-provisioning stack (`legacy-pgdelta.cache.ts`'s - * `legacyExportBaselineCatalogRef`/`legacyExportDeclarativeCatalogRef`, and everything they - * compose — shadow create/setup, health checks, the pg-delta edge-runtime scripts, the - * declarative-apply engine, config loading) into the seam's own - * {@link LegacyDeclarativeShadowDbError}, carrying the underlying message. Every component error - * class in that stack declares `message: string`, so this accepts the whole union structurally - * rather than enumerating each tag. + * Maps any failure from the native local-database bring-up stack (shadow create/setup, health + * checks, config loading) into the seam's own {@link LegacyDeclarativeShadowDbError}, carrying + * the underlying message. Every component error class in that stack declares `message: string`, + * so this accepts the whole union structurally rather than enumerating each tag. */ export const legacyToShadowDbError = (cause: { readonly message: string; @@ -71,11 +63,9 @@ export const legacyToShadowDbError = (cause: { }); /** - * Real `LegacyDeclarativeSeam`: fully native. `exportCatalog` composes the shadow-database - * platform-baseline/declarative catalog export (`legacy-pgdelta.cache.ts`'s - * `legacyExportBaselineCatalogRef`/`legacyExportDeclarativeCatalogRef`); `ensureLocalDatabaseStarted` - * shares the same `legacyStartLocalDatabase` bring-up `db start` uses; - * `ensureLocalPostgresImageCurrent` was already native (CLI-1956) and is unchanged here. + * Real `LegacyDeclarativeSeam`: fully native. `ensureLocalDatabaseStarted` shares the same + * `legacyStartLocalDatabase` bring-up `db start` uses; `ensureLocalPostgresImageCurrent` was + * already native (CLI-1956) and is unchanged here. */ export const legacyDeclarativeSeamLayer = Layer.effect( LegacyDeclarativeSeam, @@ -84,42 +74,16 @@ export const legacyDeclarativeSeamLayer = Layer.effect( const spawner = yield* ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // Captures every OTHER service `legacyExportBaselineCatalogRef`/ - // `legacyExportDeclarativeCatalogRef`/`legacyStartLocalDatabase` need internally (Output, - // RuntimeInfo, HttpClient, LegacyDbConnection, LegacyEdgeRuntimeScript, LegacyDockerRun, - // LegacyPgDeltaSslProbe, LegacyNetworkIdFlag, the `--experimental`/CliArgs global-flag - // machinery, …) into a plain `Context` so each closure below can `Effect.provideContext` it - // and satisfy `LegacyDeclarativeSeamShape`'s `Effect` (no leftover requirements) - // without hand-enumerating every transitive dependency — mirrors - // `legacy-platform-api-factory.layer.ts`'s identical capture-and-provide shape. - // `legacyExportDeclarativeCatalogRef`'s requirements are today identical to - // `LegacyExportBaselineCatalogDeps`; if they ever diverge, the declarative - // closure below stops typechecking and the new deps must be added here. - const context = yield* Effect.context< - LegacyExportBaselineCatalogDeps | LegacyStartLocalDatabaseDeps - >(); + // Captures every OTHER service `legacyStartLocalDatabase` needs internally (Output, + // RuntimeInfo, HttpClient, LegacyDbConnection, LegacyDockerRun, LegacyNetworkIdFlag, the + // `--experimental`/CliArgs global-flag machinery, …) into a plain `Context` so each closure + // below can `Effect.provideContext` it and satisfy `LegacyDeclarativeSeamShape`'s + // `Effect` (no leftover requirements) without hand-enumerating every transitive + // dependency — mirrors `legacy-platform-api-factory.layer.ts`'s identical + // capture-and-provide shape. + const context = yield* Effect.context(); return LegacyDeclarativeSeam.of({ - exportCatalog: ({ mode, noCache, projectRef }) => - (mode === "baseline" - ? legacyExportBaselineCatalogRef(fs, path, cliSettings.workdir, cliSettings.projectId, { - noCache, - projectRef, - }) - : legacyExportDeclarativeCatalogRef( - fs, - path, - cliSettings.workdir, - cliSettings.projectId, - { - noCache, - projectRef, - }, - ) - ).pipe( - Effect.provideContext(context), - Effect.catch((cause) => Effect.fail(legacyToShadowDbError(cause))), - ), ensureLocalDatabaseStarted: () => Effect.gen(function* () { const running = yield* legacyIsLocalDbRunning( @@ -288,15 +252,6 @@ export const legacyDeclarativeSeamLayer = Layer.effect( }), ); -type LegacyExportBaselineCatalogDeps = - ReturnType extends Effect.Effect< - infer _A, - infer _E, - infer R - > - ? R - : never; - type LegacyStartLocalDatabaseDeps = ReturnType extends Effect.Effect ? R diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta.seam.service.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta.seam.service.ts index 87afbaf407..2e48fb1c42 100644 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta.seam.service.ts +++ b/apps/cli/src/commands/db/shared/legacy-pgdelta.seam.service.ts @@ -2,33 +2,7 @@ import { Context, type Effect } from "effect"; import type { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; -/** - * Which shadow-database catalog `exportCatalog` should produce: the Supabase platform baseline - * (auth/storage/realtime) with nothing else applied, or that same baseline with the declarative - * directory applied on top. Local migrations never go through this seam — `db diff`'s explicit - * `--from/--to migrations` and `db schema declarative sync`'s migrations-catalog diff source both - * resolve their own shadow natively (`legacy-pgdelta.cache.ts`'s `legacyResolveMigrationsCatalogRef` - * and `legacyGetMigrationsCatalogRef`). - */ -export type LegacyCatalogMode = "baseline" | "declarative"; - interface LegacyDeclarativeSeamShape { - /** - * Provisions a shadow database with the Supabase platform baseline (and, for `declarative`, - * applies the declarative directory on top), exports its pg-delta catalog, and returns the - * workdir-relative path of the persisted snapshot (cached under `supabase/.temp/pgdelta/`). - * Progress ("Creating shadow database...") is written to stderr. - */ - readonly exportCatalog: (opts: { - readonly mode: LegacyCatalogMode; - readonly noCache: boolean; - /** - * Resolved linked project ref for `generate --linked`: the config read this builds the - * baseline/declarative catalog from merges the matching `[remotes.]` override when - * set. Absent → base config only. - */ - readonly projectRef?: string; - }) => Effect.Effect; /** * For the `--local` declarative paths: when the local Postgres container is not already * running, starts it (the same DB-only bring-up `db start` uses) so diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta.write.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta.write.ts index 9a82d4dff1..8192291aab 100644 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta.write.ts +++ b/apps/cli/src/commands/db/shared/legacy-pgdelta.write.ts @@ -4,7 +4,6 @@ import { classifySqlFiles } from "@supabase/pg-delta/frontends"; import { Output } from "../../../shared/output/output.service.ts"; import { legacyBold, legacyYellow } from "../../../command-internal/legacy-colors.ts"; import { legacyWalkSqlFiles } from "../../../command-internal/legacy-glob.ts"; -import type { LegacyDeclarativeOutput } from "../../../command-internal/legacy-pgdelta.ts"; import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; import { LegacyReadPgDeltaExportManifest } from "./legacy-pgdelta-files.ts"; import type { @@ -14,11 +13,6 @@ import type { const EXPORT_MANIFEST_FILE = ".pgdelta-export.json"; -type LegacyDeclarativeWriteOutput = LegacyDeclarativeOutput | LegacyPgDeltaDeclarativeExportResult; -type LegacyPgDeltaNextDeclarativeOutput = LegacyPgDeltaDeclarativeExportResult & { - readonly manifest: LegacyPgDeltaExportManifest; -}; - function legacyDeclarativeWriteError(message: string): LegacyDeclarativeWriteError { return new LegacyDeclarativeWriteError({ message }); } @@ -29,22 +23,13 @@ function legacyDeclarativeWriteError(message: string): LegacyDeclarativeWriteErr */ export interface LegacyDeclarativeWriteResult { /** - * Pre-existing `.sql` files the next writer preserved because no export - * manifest claimed ownership of them — see - * {@link legacyPreservedUnmanagedDeclarativeFilesWarning}. Always empty for the - * legacy writer, which wipes the directory outright. + * Pre-existing `.sql` files the writer preserved because no export manifest + * claimed ownership of them — see + * {@link legacyPreservedUnmanagedDeclarativeFilesWarning}. */ readonly preservedUnmanagedFiles: ReadonlyArray; } -const NO_PRESERVED_FILES: LegacyDeclarativeWriteResult = { preservedUnmanagedFiles: [] }; - -function isNextDeclarativeOutput( - output: LegacyDeclarativeWriteOutput, -): output is LegacyPgDeltaNextDeclarativeOutput { - return "manifest" in output && output.manifest !== undefined; -} - function safeDeclarativeExportName(path: Path.Path, name: string): string { const rel = path.normalize(name.split("\\").join("/")); if (rel.startsWith("..") || path.isAbsolute(rel)) { @@ -88,48 +73,28 @@ const readManagedDeclarativeSqlFiles = Effect.fnUntraced(function* ( return files; }); -const writeLegacyDeclarativeSchemas = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - declarativeDir: string, - output: LegacyDeclarativeWriteOutput, -) { - yield* fs - .remove(declarativeDir, { recursive: true }) - .pipe( - Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" - ? Effect.void - : Effect.fail( - legacyDeclarativeWriteError( - `failed to clean declarative schema directory: ${error.message}`, - ), - ), - ), - ); - yield* fs.makeDirectory(declarativeDir, { recursive: true }); - - for (const file of output.files) { - const name = "name" in file ? file.name : file.path; - const rel = yield* Effect.try({ - try: () => safeDeclarativeExportName(path, name), - catch: (error) => - error instanceof LegacyDeclarativeWriteError - ? error - : legacyDeclarativeWriteError(String(error)), - }); - const targetPath = path.join(declarativeDir, rel); - yield* fs.makeDirectory(path.dirname(targetPath), { recursive: true }); - yield* fs.writeFileString(targetPath, file.sql); - } - return NO_PRESERVED_FILES; -}); - -const writeNextDeclarativeSchemas = Effect.fnUntraced(function* ( +/** + * Materializes pg-delta declarative export output under the declarative dir + * using the export manifest's ownership and file classification: only stale + * files owned by the previous export are removed, unchanged files are not + * rewritten, unmanaged files are preserved, and the reserved root `_custom/` + * tree is never read as managed output or deleted. Returns which unmanaged + * files that preservation kept, so the caller can warn (see + * {@link legacyWarnPreservedUnmanagedDeclarativeFiles}). + * + * Go also updates `[db.migrations] schema_paths` afterwards, but only when + * pg-delta is *disabled* in config (`if utils.IsPgDeltaEnabled() { return nil }`). + * `db schema declarative generate/sync` force-enable pg-delta, so that branch is + * unreachable for them; `db pull --declarative` does NOT force-enable it, so the + * pull caller invokes `legacyUpdateDeclarativeSchemaPathsConfig` (below) when + * config pg-delta is disabled. Keeping the config edit at the caller leaves this + * writer a pure file-materializer shared unchanged by generate/sync. + */ +export const legacyWriteDeclarativeSchemas = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, declarativeDir: string, - output: LegacyPgDeltaNextDeclarativeOutput, + output: LegacyPgDeltaDeclarativeExportResult, ) { const proposed = yield* Effect.forEach(output.files, (file) => Effect.try({ @@ -294,35 +259,6 @@ export const legacyWarnPreservedUnmanagedDeclarativeFiles = Effect.fnUntraced(fu ); }); -/** - * Materializes pg-delta declarative export output under the declarative dir. - * Legacy-engine output keeps Go's wipe-and-rewrite behavior. Next-engine output - * uses pg-delta's manifest ownership and file classification: only stale files - * owned by the previous export are removed, unchanged files are not rewritten, - * unmanaged files are preserved, and the reserved root `_custom/` tree is never - * read as managed output or deleted. Returns which unmanaged files that preservation - * kept, so the caller can warn (see - * {@link legacyWarnPreservedUnmanagedDeclarativeFiles}). - * - * Go also updates `[db.migrations] schema_paths` afterwards, but only when - * pg-delta is *disabled* in config (`if utils.IsPgDeltaEnabled() { return nil }`). - * `db schema declarative generate/sync` force-enable pg-delta, so that branch is - * unreachable for them; `db pull --declarative` does NOT force-enable it, so the - * pull caller invokes `legacyUpdateDeclarativeSchemaPathsConfig` (below) when - * config pg-delta is disabled. Keeping the config edit at the caller leaves this - * writer a pure file-materializer shared unchanged by generate/sync. - */ -export const legacyWriteDeclarativeSchemas = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - declarativeDir: string, - output: LegacyDeclarativeWriteOutput, -) { - return yield* isNextDeclarativeOutput(output) - ? writeNextDeclarativeSchemas(fs, path, declarativeDir, output) - : writeLegacyDeclarativeSchemas(fs, path, declarativeDir, output); -}); - // 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`). diff --git a/apps/cli/src/commands/db/shared/legacy-pgdelta.write.unit.test.ts b/apps/cli/src/commands/db/shared/legacy-pgdelta.write.unit.test.ts index f8cea08f21..3b0b0c62c9 100644 --- a/apps/cli/src/commands/db/shared/legacy-pgdelta.write.unit.test.ts +++ b/apps/cli/src/commands/db/shared/legacy-pgdelta.write.unit.test.ts @@ -7,7 +7,6 @@ import { Effect, FileSystem, Path } from "effect"; import { useLegacyTempWorkdir } from "../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; -import type { LegacyDeclarativeOutput } from "../../../command-internal/legacy-pgdelta.ts"; import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; import type { LegacyPgDeltaDeclarativeExportResult } from "./legacy-pgdelta-engine.service.ts"; import { @@ -15,10 +14,7 @@ import { legacyWriteDeclarativeSchemas, } from "./legacy-pgdelta.write.ts"; -const write = ( - declarativeDir: string, - output: LegacyDeclarativeOutput | LegacyPgDeltaDeclarativeExportResult, -) => +const write = (declarativeDir: string, output: LegacyPgDeltaDeclarativeExportResult) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -34,30 +30,6 @@ describe("legacyWriteDeclarativeSchemas", () => { const tmp = useLegacyTempWorkdir("legacy-decl-write-"); const declarativeDir = () => join(tmp.current, "supabase", "database"); - it.effect("keeps the legacy wipe-and-rewrite behavior", () => { - const dir = declarativeDir(); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "stale.sql"), "-- should be removed"); - return write(dir, { - version: 1, - mode: "declarative", - files: [ - { path: "public.sql", order: 0, statements: 1, sql: "create table a();" }, - { path: "auth/roles.sql", order: 1, statements: 1, sql: "create role app;" }, - ], - }).pipe( - Effect.tap((written) => - Effect.sync(() => { - expect(written.preservedUnmanagedFiles).toEqual([]); - expect(existsSync(join(dir, "stale.sql"))).toBe(false); - expect(readFileSync(join(dir, "public.sql"), "utf8")).toBe("create table a();"); - expect(readFileSync(join(dir, "auth", "roles.sql"), "utf8")).toBe("create role app;"); - expect(existsSync(join(dir, ".pgdelta-export.json"))).toBe(false); - }), - ), - ); - }); - it.effect("tracks next-engine ownership while preserving custom and unmanaged files", () => { const dir = declarativeDir(); return Effect.gen(function* () { @@ -157,11 +129,10 @@ describe("legacyWriteDeclarativeSchemas", () => { expect(reserved).toBeInstanceOf(LegacyDeclarativeWriteError); expect(reserved.message).toContain("reserved declarative schema path"); - const escaping = yield* write(join(tmp.current, "escaping"), { - version: 1, - mode: "declarative", - files: [{ path: "../escape.sql", order: 0, statements: 0, sql: "x" }], - }).pipe(Effect.flip); + const escaping = yield* write( + join(tmp.current, "escaping"), + nextOutput([{ name: "../escape.sql", sql: "x" }]), + ).pipe(Effect.flip); expect(escaping).toBeInstanceOf(LegacyDeclarativeWriteError); expect(escaping.message).toContain("unsafe declarative export path"); }), diff --git a/apps/cli/src/commands/db/shared/legacy-shadow-source.ts b/apps/cli/src/commands/db/shared/legacy-shadow-source.ts index ec27a58320..c45d421199 100644 --- a/apps/cli/src/commands/db/shared/legacy-shadow-source.ts +++ b/apps/cli/src/commands/db/shared/legacy-shadow-source.ts @@ -1,29 +1,20 @@ /** - * The composed shadow-database shapes `db diff`/`db pull` actually call — Go's - * `PrepareShadowSource`/`PrepareRawShadow` (`apps/cli-go/internal/db/diff/shadow.go`), built - * on top of `shared/db-bootstrap/shadow-database.ts`'s lower-level primitives plus the - * `--target-local` declarative-schema branch (Go's `loadDeclaredSchemas`/ - * `shouldApplyDeclarativeWithPgDelta`/`migrateBaseDatabase`, `internal/db/diff/diff.go:52-115, - * 261-274`) and pg-delta's declarative apply engine (`legacy-pgdelta.apply.ts`). - * - * Go's `PrepareShadowSource(ctx, schema []string, targetLocal, usePgDelta bool, fsys, - * options...)` takes a `schema` parameter that is NEVER referenced anywhere in the function - * body (verified by reading the whole function) — dead code in Go itself, making the `--schema` - * flag the now-removed `db __shadow` hidden seam used to forward here a no-op even before - * CLI-1956 deleted that seam in favor of this native port. Deliberately NOT ported here: there - * is nothing to port. + * The composed shadow-provisioning shape `db diff`/`db pull` actually call: + * {@link legacyPrepareShadowSource} builds on `shared/db-bootstrap/shadow-database.ts`'s + * lower-level primitives (create → health-wait → platform baseline → migrations replay) and + * adds the migra `--target-local` declarative-schema branch, which applies declarative files + * to a second database on the same shadow container instead of diffing the user's local DB + * directly. Schema selection deliberately plays no part in shadow provisioning — the `--schema` + * flag only scopes the diff itself, never what the shadow contains. */ import { Effect, Result, type FileSystem, type Path } from "effect"; -import type { GlobalFlag } from "effect/unstable/cli"; import type * as HttpClient from "effect/unstable/http/HttpClient"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import type { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import type { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { legacyBold } from "../../../command-internal/legacy-colors.ts"; -import type { LegacyEdgeRuntimeScript } from "../../../command-internal/legacy-edge-runtime-script.service.ts"; import { LegacyDbConnection, type LegacyPgConnInput, @@ -58,12 +49,7 @@ import { type LegacyShadowSourceResult, } from "../../../command-internal/db-bootstrap/shadow-database.ts"; import type { LegacyStartSetupLocalDatabaseError } from "../../../command-internal/db-bootstrap/db-setup.ts"; -import { - LegacyPgDeltaDeclarativeApplyError, - legacyApplyDeclarativePgDelta, -} from "./legacy-pgdelta.apply.ts"; import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; -import type { LegacyPgDeltaContext } from "../../../command-internal/legacy-pgdelta.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -76,15 +62,11 @@ export type { LegacyShadowSourceResult }; export interface LegacyPrepareShadowSourceInput extends LegacyShadowSetupInput { /** Go's `utils.IsLocalDatabase(config)` — the only target-derived input the shadow prep needs. */ readonly targetLocal: boolean; - /** Selects the declarative-apply engine for the local-declared branch, matching `DiffDatabase`. */ - readonly usePgDelta: boolean; - /** Selects the shadow baseline and whether a local target may use the legacy declarative override. */ + /** Selects the shadow baseline and whether a local target may use the migra declarative override. */ readonly migrationMode?: "legacy" | "pgdelta-next"; /** `db.migrations.schema_paths`, RAW (unresolved) — Go's `Config.Db.Migrations.SchemaPaths` pre-`config.go:976-979`-resolution form. */ readonly schemaPaths: ReadonlyArray; readonly pgDelta: LegacyPgDeltaTomlConfig; - /** Ambient pg-delta edge-runtime context, only read on the pg-delta declarative-apply sub-branch. */ - readonly ctx: LegacyPgDeltaContext; } /** Every failure {@link legacyPrepareShadowSource} can produce, beyond its own `E` (JWKS resolution). */ @@ -93,8 +75,7 @@ export type LegacyPrepareShadowSourceError = | LegacyDeclarativeShadowDbError | LegacyHealthCheckTimeoutError | LegacyStartSetupLocalDatabaseError - | LegacyImagePrepullError - | LegacyPgDeltaDeclarativeApplyError; + | LegacyImagePrepullError; /** * Port of Go's `PrepareShadowSource` (`apps/cli-go/internal/db/diff/shadow.go:37-91`): @@ -134,19 +115,7 @@ export const legacyPrepareShadowSource = ( ): Effect.Effect< LegacyShadowSourceResult, LegacyPrepareShadowSourceError | E, - | Output - | LegacyDockerRun - | RuntimeInfo - | HttpClient.HttpClient - | LegacyDbConnection - | LegacyEdgeRuntimeScript - | GlobalFlag.Setting.Identifier<"debug"> - // `legacyApplyDeclarativePgDelta`'s own `legacyResolveDebugWithProjectEnv` (viper - // `AutomaticEnv` `SUPABASE_DEBUG` fallback, plus the project `.env` Go's `loadNestedEnv` - // has already `os.Setenv`'d into the process by this point, review: PRRT_kwDOErm0O86XDr4V, - // PRRT_kwDOErm0O86XL_oz) needs `CliArgs` to detect an explicit `--debug=false`, same as - // `legacyResolveYes`/`legacyResolveExperimental`. - | CliArgs + Output | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient | LegacyDbConnection > => Effect.gen(function* () { const { containerId } = handle; @@ -201,41 +170,13 @@ export const legacyPrepareShadowSource = ( ); if (declared.length > 0) { const overrideConn: LegacyPgConnInput = { ...connConfig, database: "contrib_regression" }; - const useDeclarativePgDelta = legacyShouldApplyDeclarativeWithPgDelta( + yield* legacyMigrateBaseDatabase( + input.fs, input.path, - input.usePgDelta, - input.schemaPaths, - input.pgDelta, + input.workdir, + overrideConn, + declared, ); - let appliedViaPgDelta = false; - if (useDeclarativePgDelta) { - const declDirRel = legacyResolveDeclarativeDir(input.path, input.pgDelta); - const declDirAbs = legacyResolveUnderWorkdir(input.path, input.workdir, declDirRel); - // Go's `afero.DirExists` (`shadow.go:72`) — a non-directory path is treated as - // absent here too, same reasoning as `legacyLoadDeclaredSchemas` below. - const declDirExists = yield* input.fs.stat(declDirAbs).pipe( - Effect.map((info) => info.type === "Directory"), - Effect.orElseSucceed(() => false), - ); - if (declDirExists) { - yield* legacyApplyDeclarativePgDelta(input.ctx, { - fs: input.fs, - declarativeDirAbs: declDirAbs, - declarativeDirRel: declDirRel, - target: legacyToPostgresURL(overrideConn), - }); - appliedViaPgDelta = true; - } - } - if (!appliedViaPgDelta) { - yield* legacyMigrateBaseDatabase( - input.fs, - input.path, - input.workdir, - overrideConn, - declared, - ); - } targetUrlOverride = legacyToPostgresURL(overrideConn); } } @@ -598,31 +539,6 @@ export function legacyCleanSchemaPath( return volume + (isAbsolute ? "/" : "") + joined; } -/** - * Port of Go's `shouldApplyDeclarativeWithPgDelta` (`apps/cli-go/internal/db/diff/diff.go: - * 103-115`): `usePgDelta` false -> false; zero `schema_paths` -> true; more than one - * `schema_paths` entry -> false; exactly one entry -> true only when it resolves (Go's - * `config.go:976-979` resolution, matching `legacyResolveSeedSqlPath`) to the SAME cleaned - * path as the effective declarative dir. - */ -export function legacyShouldApplyDeclarativeWithPgDelta( - path: Path.Path, - usePgDelta: boolean, - schemaPaths: ReadonlyArray, - pgDelta: LegacyPgDeltaTomlConfig, - platform: NodeJS.Platform = process.platform, -): boolean { - if (!usePgDelta) return false; - if (schemaPaths.length === 0) return true; - if (schemaPaths.length !== 1) return false; - const resolvedSchema = legacyCleanSchemaPath( - legacyResolveSeedSqlPath(path, schemaPaths[0]!), - platform, - ); - const declDir = legacyCleanSchemaPath(legacyResolveDeclarativeDir(path, pgDelta), platform); - return resolvedSchema === declDir; -} - /** * Port of Go's `migrateBaseDatabase` (`apps/cli-go/internal/db/diff/diff.go:261-274`): prints * the declarative-schema file list, connects to `config` (the shadow's `contrib_regression` diff --git a/apps/cli/src/commands/db/shared/legacy-shadow-source.unit.test.ts b/apps/cli/src/commands/db/shared/legacy-shadow-source.unit.test.ts index 8f64f04a08..4c19685653 100644 --- a/apps/cli/src/commands/db/shared/legacy-shadow-source.unit.test.ts +++ b/apps/cli/src/commands/db/shared/legacy-shadow-source.unit.test.ts @@ -5,11 +5,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Layer, Option, Path, PlatformError } from "effect"; -import { - legacyCleanSchemaPath, - legacyLoadDeclaredSchemas, - legacyShouldApplyDeclarativeWithPgDelta, -} from "./legacy-shadow-source.ts"; +import { legacyCleanSchemaPath, legacyLoadDeclaredSchemas } from "./legacy-shadow-source.ts"; import type { LegacyPgDeltaTomlConfig } from "../../../command-internal/legacy-db-config.toml-read.ts"; function pgDelta(overrides: Partial = {}): LegacyPgDeltaTomlConfig { @@ -17,7 +13,6 @@ function pgDelta(overrides: Partial = {}): LegacyPgDelt enabled: false, declarativeSchemaPath: Option.none(), formatOptions: Option.none(), - npmVersion: Option.none(), ...overrides, }; } @@ -29,90 +24,6 @@ function makeWorkdir(): string { // Root bypasses POSIX permission bits, so chmod 000 wouldn't block readdir() there. const isRoot = typeof process.getuid === "function" && process.getuid() === 0; -describe("legacyShouldApplyDeclarativeWithPgDelta", () => { - it.effect("is false whenever usePgDelta is false, regardless of schema_paths", () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect(legacyShouldApplyDeclarativeWithPgDelta(path, false, [], pgDelta())).toBe(false); - expect( - legacyShouldApplyDeclarativeWithPgDelta(path, false, ["schemas/x.sql"], pgDelta()), - ).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("is true when usePgDelta and zero schema_paths are configured", () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, [], pgDelta())).toBe(true); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("is false when more than one schema_paths entry is configured", () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect( - legacyShouldApplyDeclarativeWithPgDelta(path, true, ["a.sql", "b.sql"], pgDelta()), - ).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "is true when exactly one schema_paths entry resolves to the effective declarative dir", - () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, ["schemas"], pgDelta())).toBe( - true, - ); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("is false when the single schema_paths entry does not match the declarative dir", () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, ["database"], pgDelta())).toBe( - false, - ); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("matches a configured (non-default) declarative_schema_path the same way", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const configured = pgDelta({ declarativeSchemaPath: Option.some("supabase/custom-decl") }); - expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, ["custom-decl"], configured)).toBe( - true, - ); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "on POSIX, a backslash in schema_paths is a literal character, not a path separator", - () => - Effect.gen(function* () { - const path = yield* Path.Path; - // Go's `filepath.Clean`/`ToSlash` only treat `\` as a separator on a Windows build — - // on darwin/linux it's untouched, so a `foo\bar` schema_paths entry (which - // `legacyResolveSeedSqlPath` joins under `supabase/` unresolved) must NOT be treated - // as equivalent to the slash-separated declarative dir `supabase/foo/bar`. - const configured = pgDelta({ declarativeSchemaPath: Option.some("supabase/foo/bar") }); - expect( - legacyShouldApplyDeclarativeWithPgDelta(path, true, ["foo\\bar"], configured, "darwin"), - ).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("on win32, a backslash in schema_paths normalizes as a path separator", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const configured = pgDelta({ declarativeSchemaPath: Option.some("supabase/foo/bar") }); - expect( - legacyShouldApplyDeclarativeWithPgDelta(path, true, ["foo\\bar"], configured, "win32"), - ).toBe(true); - }).pipe(Effect.provide(BunServices.layer)), - ); -}); - describe("legacyCleanSchemaPath", () => { // Go's `filepath.Clean` (windows build) never cleans INTO a leading UNC volume — verified // empirically against a standalone extraction of Go's own windows `internal/filepathlite` diff --git a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md index 97317d03be..6af7ca7f1c 100644 --- a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md @@ -78,13 +78,12 @@ volume was confirmed fresh this run). ## Files Written -| Path | Format | When | -| ---------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/.branches/_current_branch` | text | only if absent — writes `"main"` (see the step-by-step sequence above for exactly when) | -| `/supabase/.temp/pgdelta/catalog-local-migrations--.json` | JSON | best-effort, on a fresh volume with no `--from-backup`, after `MigrateAndSeed`, when pg-delta is enabled (`[experimental.pgdelta] enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`) AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false`); the default next engine skips this warmup entirely; a failure only warns on stderr and never fails `db start` | -| local Docker volume `supabase_db_` | — | the Postgres data volume, created on first start (or first `--from-backup` restore) | -| local Docker network `supabase_network_` (or `--network-id`) | — | created if it doesn't already exist | -| `~/.supabase/telemetry.json` | JSON | always — telemetry flush (`Effect.ensuring(telemetryState.flush)`), success and failure | +| Path | Format | When | +| --------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------- | +| `/supabase/.branches/_current_branch` | text | only if absent — writes `"main"` (see the step-by-step sequence above for exactly when) | +| local Docker volume `supabase_db_` | — | the Postgres data volume, created on first start (or first `--from-backup` restore) | +| local Docker network `supabase_network_` (or `--network-id`) | — | created if it doesn't already exist | +| `~/.supabase/telemetry.json` | JSON | always — telemetry flush (`Effect.ensuring(telemetryState.flush)`), success and failure | ## Subprocesses @@ -115,7 +114,6 @@ native container command in this codebase — never `supabase-go`. | Variable | Purpose | Required? | | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | `SUPABASE_PROJECT_ID` | overrides the local container id | no | -| `SUPABASE_USE_SLIM_IMAGES` | resolves the current Dockerfile pin (and majors 13/15's published slim PG15 pin, `15.14.1.167`) and PG15+ realtime/storage/auth migrate-job images from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); historical `.temp` pins, PG14, OrioleDB, and flag-off majors 13/15 (`15.8.1.085`) stay on docker.io | no | | `SUPABASE_DB_PORT` | overrides `db.port` (the published host port) | no | | `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | | `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | @@ -130,9 +128,8 @@ native container command in this codebase — never `supabase-go`. | `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | | `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | | `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it) | no | | `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file, installed into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no | +| `SUPABASE_USE_SLIM_IMAGES` | resolves the current Dockerfile pin (and majors 13/15's published slim PG15 pin, `15.14.1.167`) and PG15+ realtime/storage/auth migrate-job images from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); historical `.temp` pins, PG14, OrioleDB, and flag-off majors 13/15 (`15.8.1.085`) stay on docker.io | no | `--network-id` (a global CLI flag, not an environment variable — `shared/legacy/global-flags.ts`) forces every created container/network onto that Docker network instead of the generated diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index 7e745f4b1c..f692b2f612 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -33,12 +33,6 @@ import { type LegacyDbSession, } from "../../../command-internal/legacy-db-connection.service.ts"; import { legacyDockerRunLayer } from "../../../command-internal/legacy-docker-run.layer.ts"; -import { LegacyEdgeRuntimeScriptError } from "../../../command-internal/legacy-edge-runtime-script.errors.ts"; -import { - LegacyEdgeRuntimeScript, - type LegacyEdgeRuntimeRunOpts, -} from "../../../command-internal/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; import { legacyDbStart } from "./start.handler.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; @@ -280,10 +274,6 @@ interface SetupOpts { readonly experimental?: boolean; /** `--debug`. Defaults to `false`. */ readonly debug?: boolean; - /** `LegacyEdgeRuntimeScript`'s mocked stdout for the pg-delta catalog-export call (`db-setup.ts`'s `legacyTryCacheMigrationsCatalog`). Only ever reached on a fresh volume with pg-delta enabled. */ - readonly catalogStdout?: string; - /** Fails the mocked catalog-export call with this message instead of succeeding. */ - readonly catalogExportFailWith?: string; /** Number of initial `LegacyDbConnection.connect` attempts that fail before succeeding. */ readonly connectFailures?: number; /** Whether the mocked connect failures are dial-level (`retryable`). Defaults to `true`. */ @@ -310,22 +300,6 @@ function setup(opts: SetupOpts = {}) { : baseRoute; const child = mockContainerCliSpawner(route); const dbSession = fakeDbSession(); - const edgeRunCalls: Array = []; - const edgeRuntime = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - edgeRunCalls.push(runOpts); - if (opts.catalogExportFailWith !== undefined) { - return Effect.fail( - new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), - ); - } - return Effect.succeed({ stdout: opts.catalogStdout ?? '{"version":1}', stderr: "" }); - }, - }); - const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); let connectAttempts = 0; const connectFailures = opts.connectFailures ?? 0; @@ -367,8 +341,6 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(CliArgs, { args: ["db", "start"] }), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), Layer.succeed(LegacyDebugFlag, opts.debug ?? false), - edgeRuntime, - sslProbe, ); return { layer, @@ -376,7 +348,6 @@ function setup(opts: SetupOpts = {}) { telemetry, child, dbSession, - edgeRunCalls, get connectAttempts() { return connectAttempts; }, @@ -538,64 +509,6 @@ describe("legacy db start", () => { }, ); - it.live( - "caches the migrations catalog after a fresh-volume setup with the legacy pg-delta engine", - () => { - const { layer, out, edgeRunCalls } = setup({ - configContents: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - projectEnvContents: "SUPABASE_USE_PG_DELTA_NEXT=false\n", - route: freshVolumeRoute(defaultRoute()), - catalogStdout: '{"snapshot":"ok"}', - }); - return Effect.gen(function* () { - yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - // Runs once, AFTER the fresh-volume migrate+seed pipeline — the - // catalog cache runs immediately after the migrate-and-seed step. - expect(edgeRunCalls).toHaveLength(1); - const tempDir = join(tempRoot.current, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => - name.startsWith("catalog-local-migrations-"), - ); - expect(catalogFiles).toHaveLength(1); - expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); - }); - }, - ); - - it.live( - "warns without failing db start when the legacy migrations-catalog export fails on a fresh volume", - () => { - const { layer, out } = setup({ - configContents: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - projectEnvContents: "SUPABASE_USE_PG_DELTA_NEXT=false\n", - route: freshVolumeRoute(defaultRoute()), - catalogExportFailWith: "edge-runtime script produced no output", - }); - return Effect.gen(function* () { - const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain( - "Warning: failed to cache migrations catalog: edge-runtime script produced no output", - ); - expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); - }); - }, - ); - - it.live( - "does not attempt to cache the migrations catalog on a fresh volume when pg-delta is disabled", - () => { - const { layer, out, edgeRunCalls } = setup({ route: freshVolumeRoute(defaultRoute()) }); - return Effect.gen(function* () { - yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(edgeRunCalls).toHaveLength(0); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(existsSync(join(tempRoot.current, "supabase", ".temp", "pgdelta"))).toBe(false); - }); - }, - ); - it.live( "restarts against an existing volume: skips the SetupLocalDatabase-equivalent pipeline but still writes _current_branch", () => { diff --git a/apps/cli/src/commands/db/start/start.layers.ts b/apps/cli/src/commands/db/start/start.layers.ts index 0286069c84..d88558d708 100644 --- a/apps/cli/src/commands/db/start/start.layers.ts +++ b/apps/cli/src/commands/db/start/start.layers.ts @@ -6,8 +6,6 @@ import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts" import { legacyDbConnectionLayer } from "../../../command-internal/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../../command-internal/legacy-debug-logger.layer.ts"; import { legacyDockerRunLayer } from "../../../command-internal/legacy-docker-run.layer.ts"; -import { legacyEdgeRuntimeScriptLayer } from "../../../command-internal/legacy-edge-runtime-script.layer.ts"; -import { legacyPgDeltaSslProbeLayer } from "../../../command-internal/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; /** @@ -28,18 +26,9 @@ import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-s * wait (`legacyWaitForHealthyServices`) requires `HttpClient.HttpClient` in its type signature * even though `db start` never uses the PostgREST/Edge-Runtime gateway probes — same reasoning * as `start.command.ts`'s own composition of all three. - * - * `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` back that same fresh-volume - * pipeline's best-effort pg-delta migrations-catalog warmup (`db-setup.ts`'s - * `legacyTryCacheMigrationsCatalog` call) — the exact same pair `db push` already composes - * for its own call to that function (`push.layers.ts`). */ const cliSettings = legacyCliSettingsLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); -const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( - Layer.provide(legacyDockerRunLayer), - Layer.provide(cliSettings), -); export const legacyDbStartRuntimeLayer = Layer.mergeAll( cliSettings, @@ -48,6 +37,4 @@ export const legacyDbStartRuntimeLayer = Layer.mergeAll( legacyDockerRunLayer, legacyDbConnectionLayer, httpClient, - edgeRuntime, - legacyPgDeltaSslProbeLayer, ); diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index c8aa18de2d..47fb808344 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -95,11 +95,10 @@ command. ## Files Written -| Path | Format | When | -| ---------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `/supabase/.branches/_current_branch` | text | on every start, only if absent — writes `"main"` | -| `/supabase/.temp/start-secrets//{env,multiline-env}/` | varies | Edge Runtime's own JWT/service-role-key/secret env artifacts — see below | -| `/supabase/.temp/pgdelta/catalog-local-migrations--.json` | JSON | best-effort, on a fresh volume, after `MigrateAndSeed`, when pg-delta is enabled (`[experimental.pgdelta] enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`) AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false`); the default next engine skips this warmup entirely; a failure only warns on stderr and never fails `start` | +| Path | Format | When | +| ---------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------ | +| `/supabase/.branches/_current_branch` | text | on every start, only if absent — writes `"main"` | +| `/supabase/.temp/start-secrets//{env,multiline-env}/` | varies | Edge Runtime's own JWT/service-role-key/secret env artifacts — see below | Kong's `custom_nginx.template`, Vector's `vector.yaml`, and Postgres's own bootstrap script (`postgresql.conf`-equivalent setup) are all rendered in memory and injected @@ -176,16 +175,14 @@ not implemented. | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | `SUPABASE_*` (any dotted config field) | Generic Viper-style `AutomaticEnv` override of any `config.toml` field (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`) | no | | `SUPABASE_EXPERIMENTAL` (or `--experimental`) | Fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (see "Fresh-volume DB setup" above) | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | Enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | Selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it) | no | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | Overrides the image registry used to resolve every service's image | no | -| `SUPABASE_USE_SLIM_IMAGES` | Ambient `process.env` only (`true`/`1` enable) — not project dotenv. Rewrites current Dockerfile pins to `ghcr.io/supabase/cli/` (including PG15+ realtime/storage/auth migrate jobs). Kong, PG14, OrioleDB, historical Postgres pins, and `deno_version = 1` remain non-slim (still subject to `SUPABASE_INTERNAL_IMAGE_REGISTRY`). Majors 13/15 use the published slim PG15 pin (`15.14.1.167`) when the flag is on; flag-off keeps `15.8.1.085`. `SUPABASE_INTERNAL_IMAGE_REGISTRY` does not apply to slim refs | no | | `SUPABASE_PROJECT_ID` | Overrides the resolved local project id (env → config.toml → workdir basename) | no | | `SUPABASE_WORKDIR` | Resolves `LegacyCliSettings.workdir` | no | | `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | | `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file) to discover the Docker daemon this whole command talks to; `DOCKER_HOST` is also re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | | `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | | `HTTP_PROXY` / `http_proxy` / `HTTPS_PROXY` / `https_proxy` / `NO_PROXY` / `no_proxy` | Bun proxy settings. After project dotenv and container creation, `start` appends `localhost,127.0.0.1,[::1]` to the effective no-proxy value before local Kong probes and seeding; it never changes project/container env and ends with this CLI process. | no | +| `SUPABASE_USE_SLIM_IMAGES` | Ambient `process.env` only (`true`/`1` enable) — not project dotenv. Rewrites current Dockerfile pins to `ghcr.io/supabase/cli/` (including PG15+ realtime/storage/auth migrate jobs). Kong, PG14, OrioleDB, historical Postgres pins, and `deno_version = 1` remain non-slim (still subject to `SUPABASE_INTERNAL_IMAGE_REGISTRY`). Majors 13/15 use the published slim PG15 pin (`15.14.1.167`) when the flag is on; flag-off keeps `15.8.1.085`. `SUPABASE_INTERNAL_IMAGE_REGISTRY` does not apply to slim refs | no | `docker`/`podman` must be resolvable on `PATH` — same fallback behavior as `stop`/`status`. diff --git a/apps/cli/src/commands/start/start.command.ts b/apps/cli/src/commands/start/start.command.ts index f7ca5f24bc..cbcbaf9fdc 100644 --- a/apps/cli/src/commands/start/start.command.ts +++ b/apps/cli/src/commands/start/start.command.ts @@ -9,8 +9,6 @@ import { legacyCliSettingsLayer } from "../../config/legacy-cli-settings.layer.t import { legacyDbConnectionLayer } from "../../command-internal/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../command-internal/legacy-debug-logger.layer.ts"; import { legacyDockerRunLayer } from "../../command-internal/legacy-docker-run.layer.ts"; -import { legacyEdgeRuntimeScriptLayer } from "../../command-internal/legacy-edge-runtime-script.layer.ts"; -import { legacyPgDeltaSslProbeLayer } from "../../command-internal/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyStringSliceFlag } from "../../command-internal/legacy-string-slice-flag.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; @@ -58,16 +56,8 @@ export type LegacyStartFlags = CliCommand.Command.Config.Infer; // `SetupLocalDatabase` equivalent (`start.handler.ts`'s `legacyStartSetupLocalDatabase` // call) needs both: the PG15+ one-shot migrate jobs run through `LegacyDockerRun`, and // the schema/globals/API-privileges SQL runs over a direct `LegacyDbConnection` session. -// `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` back that same fresh-volume -// pipeline's best-effort pg-delta migrations-catalog warmup (`db-setup.ts`'s -// `legacyTryCacheMigrationsCatalog` call) — the exact same pair `db push` already composes -// for its own call to that function (`push.layers.ts`). const cliSettings = legacyCliSettingsLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); -const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( - Layer.provide(legacyDockerRunLayer), - Layer.provide(cliSettings), -); const legacyStartRuntimeLayer = Layer.mergeAll( cliSettings, @@ -76,8 +66,6 @@ const legacyStartRuntimeLayer = Layer.mergeAll( legacyDockerRunLayer, legacyDbConnectionLayer, httpClient, - edgeRuntime, - legacyPgDeltaSslProbeLayer, ); export const legacyStartCommand = Command.make("start", config).pipe( diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index 539035fdc1..4b39a220ef 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -1,5 +1,5 @@ import { generateKeyPairSync } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -42,12 +42,6 @@ import { type LegacyDbSession, } from "../../command-internal/legacy-db-connection.service.ts"; import { legacyDockerRunLayer } from "../../command-internal/legacy-docker-run.layer.ts"; -import { LegacyEdgeRuntimeScriptError } from "../../command-internal/legacy-edge-runtime-script.errors.ts"; -import { - LegacyEdgeRuntimeScript, - type LegacyEdgeRuntimeRunOpts, -} from "../../command-internal/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../command-internal/legacy-pgdelta-ssl-probe.service.ts"; import { LEGACY_START_EXCLUDABLE_KEYS } from "./start.exclude.ts"; import type { LegacyStartFlags } from "./start.command.ts"; import { legacyStart } from "./start.handler.ts"; @@ -416,10 +410,6 @@ interface SetupOpts { readonly networkId?: Option.Option; /** `--experimental`/`SUPABASE_EXPERIMENTAL`. Defaults to `false`. */ readonly experimental?: boolean; - /** `LegacyEdgeRuntimeScript`'s mocked stdout for the pg-delta catalog-export call (`db-setup.ts`'s `legacyTryCacheMigrationsCatalog`). Only ever reached on a fresh volume with pg-delta enabled. */ - readonly catalogStdout?: string; - /** Fails the mocked catalog-export call with this message instead of succeeding. */ - readonly catalogExportFailWith?: string; } function setup(opts: SetupOpts = {}) { @@ -439,22 +429,6 @@ function setup(opts: SetupOpts = {}) { onSecretCopy: opts.onSecretCopy, }); const dbSession = fakeDbSession(); - const edgeRunCalls: Array = []; - const edgeRuntime = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - edgeRunCalls.push(runOpts); - if (opts.catalogExportFailWith !== undefined) { - return Effect.fail( - new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), - ); - } - return Effect.succeed({ stdout: opts.catalogStdout ?? '{"version":1}', stderr: "" }); - }, - }); - const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); const layer = Layer.mergeAll( BunServices.layer, @@ -491,11 +465,9 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), mockTty({ stdinIsTty: false }), mockStdin(false), - edgeRuntime, - sslProbe, ); - return { workdir, out, telemetry, analytics, child, dbSession, edgeRunCalls, layer }; + return { workdir, out, telemetry, analytics, child, dbSession, layer }; } /** @@ -2418,61 +2390,6 @@ content_path = "./supabase/templates/custom_notice.html" }, ); - it.live( - "caches the migrations catalog after a fresh-volume setup for the legacy engine", - () => { - const { layer, out, workdir, edgeRunCalls } = setup({ - configContents: 'project_id = "demo"\n[experimental.pgdelta]\nenabled = true\n', - route: freshVolumeRoute(defaultRoute()), - catalogStdout: '{"snapshot":"ok"}', - }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); - return Effect.gen(function* () { - yield* legacyStart(flags({ exclude: ["edge-runtime"] })); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - // Runs once, immediately AFTER the fresh-volume migrate+seed pipeline. - expect(edgeRunCalls).toHaveLength(1); - const tempDir = join(workdir, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => - name.startsWith("catalog-local-migrations-"), - ); - expect(catalogFiles).toHaveLength(1); - expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); - }).pipe(Effect.provide(layer)); - }, - ); - - it.live( - "warns without failing supabase start when the migrations-catalog export fails on a fresh volume", - () => { - const { layer, out, workdir } = setup({ - configContents: 'project_id = "demo"\n[experimental.pgdelta]\nenabled = true\n', - route: freshVolumeRoute(defaultRoute()), - catalogExportFailWith: "edge-runtime script produced no output", - }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); - return Effect.gen(function* () { - const exit = yield* legacyStart(flags({ exclude: ["edge-runtime"] })).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain( - "Warning: failed to cache migrations catalog: edge-runtime script produced no output", - ); - }).pipe(Effect.provide(layer)); - }, - ); - - it.live( - "does not attempt to cache the migrations catalog on a fresh volume when pg-delta is disabled", - () => { - const { layer, out, edgeRunCalls } = setup({ route: freshVolumeRoute(defaultRoute()) }); - return Effect.gen(function* () { - yield* legacyStart(flags({ exclude: ["edge-runtime"] })); - expect(edgeRunCalls).toHaveLength(0); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - }).pipe(Effect.provide(layer)); - }, - ); - it.live( "resolves an excluded service's migrate-job image through a project-dotenv-only registry override", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38c9053ad7..1ab6608359 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,8 +251,8 @@ importers: specifier: workspace:* version: link:../../packages/config '@supabase/pg-delta': - specifier: 1.0.0-alpha.47 - version: 1.0.0-alpha.47(@supabase/pg-topo@1.0.0-alpha.6(supports-color@7.2.0))(supports-color@7.2.0) + specifier: 1.0.0-alpha.49 + version: 1.0.0-alpha.49(@supabase/pg-topo@1.0.0-alpha.6(supports-color@7.2.0))(supports-color@7.2.0) '@supabase/pg-topo': specifier: 1.0.0-alpha.6 version: 1.0.0-alpha.6(supports-color@7.2.0) @@ -2641,8 +2641,8 @@ packages: resolution: {integrity: sha512-DQ0aVH8wSQAccVqNoEkec62qCu2QRNyoGN53RqsVZ1k6F1zq4/v8scrlR6LNT2RJmT97apiTmORijPVhErCS2g==} engines: {node: '>=22.0.0'} - '@supabase/pg-delta@1.0.0-alpha.47': - resolution: {integrity: sha512-0i3hBSv60BqNUvc6VeBUFgHwLDO0kG/p2bsvvDbdQWAgR2f+8ywDMj5a71d34zXHj727AEKEOI2tMcBpBJUTFQ==} + '@supabase/pg-delta@1.0.0-alpha.49': + resolution: {integrity: sha512-sVsi4VTV3xWYwDo04kLuoO7VBtmJSLhhN3ac/1l1+Dfh+RIyLoFZlLh9wBiGU6cOEt9awZaX/xWI2v5KpT4TZw==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: @@ -8299,7 +8299,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/pg-delta@1.0.0-alpha.47(@supabase/pg-topo@1.0.0-alpha.6(supports-color@7.2.0))(supports-color@7.2.0)': + '@supabase/pg-delta@1.0.0-alpha.49(@supabase/pg-topo@1.0.0-alpha.6(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@types/debug': 4.1.13 '@types/pg': 8.23.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9cc38e0ec7..5dc98fc92b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -102,8 +102,8 @@ minimumReleaseAgeExclude: - "@effect/platform-node-shared@4.0.0-rc.111" - "@effect/sql-pg@4.0.0-rc.111" - "@effect/vitest@4.0.0-rc.111" - - "@supabase/pg-delta@1.0.0-alpha.46" - - "@supabase/pg-topo@1.0.0-alpha.5" + - "@supabase/pg-delta@1.0.0-alpha.49" + - "@supabase/pg-topo@1.0.0-alpha.6" - "@types/bun@1.4.0" - "bun-types@1.4.0" - "effect@4.0.0-rc.111"