diff --git a/apps/cli-go/CONTRIBUTING.md b/apps/cli-go/CONTRIBUTING.md index 39d0d33d85..25fa9709eb 100644 --- a/apps/cli-go/CONTRIBUTING.md +++ b/apps/cli-go/CONTRIBUTING.md @@ -44,9 +44,48 @@ 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) +Pg-delta and pg-topo run in-process by default and are bundled into the CLI binary. +Their versions are fixed by `apps/cli/package.json` and the lockfile at CLI build +time. `PGDELTA_NPM_REGISTRY` and a test project's +`supabase/.temp/pgdelta-version` do not affect this implementation. + +### Default bundled implementation + +To exercise unpublished [pg-toolbelt](https://github.com/supabase/pg-toolbelt) +changes locally: + +1. Build the `@supabase/pg-delta` and `@supabase/pg-topo` packages in pg-toolbelt. +2. Temporarily point both dependencies in `apps/cli/package.json` at those local + package directories (for a sibling checkout, use + `file:../../../pg-toolbelt/packages/pg-delta` and + `file:../../../pg-toolbelt/packages/pg-topo`). +3. Run `pnpm install` from the CLI repository root, then run the CLI from source + with `pnpm --dir apps/cli dev:legacy -- db diff ...`, or rebuild the legacy + binary before testing it. + +Both packages must be updated together so local source runs and built binaries +use the same planner/reorder implementation. Restore the package references and +lockfile after local testing. New-engine SQL need not byte-match the legacy +renderer; verify that the SQL executes and a subsequent operation converges to +an empty diff. + +When `PGDELTA_DEBUG` is enabled, the bundled engine writes non-reusable debug +artifacts under `supabase/.temp/pgdelta/v2/debug//`: `metadata.json` plus +`source-snapshot.json`, `desired-snapshot.json`, `plan.json`, and +`diagnostics.json` when those values are available. Legacy catalogs remain at +the `supabase/.temp/pgdelta/` root and are never consumed by the bundled engine. +For declarative `generate` and `sync`, `--no-cache` bypasses legacy catalog +reuse/warming; the bundled engine already extracts live state and has no +reusable catalog cache. + +### Legacy edge-runtime implementation + +To exercise unpublished legacy `@supabase/pg-delta` changes inside edge-runtime +scripts, select `SUPABASE_USE_PG_DELTA_NEXT=false`, publish a local build via +Verdaccio, and point the CLI at that registry. These instructions describe the +temporary compatibility implementation only. + +#### 1. Start Verdaccio (pg-toolbelt) ```sh cd pg-toolbelt @@ -55,7 +94,7 @@ 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 +#### 2. Publish a local pg-delta build After changing `packages/pg-delta`: @@ -68,7 +107,7 @@ This publishes a fresh `0.0.0-local.` version and restores `package.j Re-run whenever you change pg-delta source. -### 3. Run the CLI against the local registry +#### 3. Run the CLI against the local registry Set `PGDELTA_NPM_REGISTRY` to a URL reachable **from inside the edge-runtime Docker container**: @@ -79,6 +118,8 @@ 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 + +export SUPABASE_USE_PG_DELTA_NEXT=false ``` Then run any pg-delta-backed command, for example: @@ -89,4 +130,7 @@ 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`. +Unset `PGDELTA_NPM_REGISTRY` to return to the legacy npmjs version pinned in +config / `supabase/.temp/pgdelta-version`. Unset +`SUPABASE_USE_PG_DELTA_NEXT` (or set it to `true`) to return to the bundled +default implementation. diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index df04364e44..13e6a94b91 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -1,8 +1,12 @@ package cmd import ( + "bufio" + "context" + "encoding/json" "errors" "fmt" + "io" "os" "path" "path/filepath" @@ -30,6 +34,78 @@ import ( "github.com/supabase/cli/pkg/migration" ) +type pgDeltaNextShadowEndpoint struct { + ContainerID string `json:"containerId"` + URL string `json:"url"` +} + +type pgDeltaNextShadowHandoff struct { + Migrations pgDeltaNextShadowEndpoint `json:"migrations"` + Declarative pgDeltaNextShadowEndpoint `json:"declarative"` +} + +// handoffPgDeltaNextShadow transfers cleanup ownership only after the caller +// has received and acknowledged the complete JSON description. Until then Go +// removes both containers on every exit path, including cancellation and I/O +// failure. +func handoffPgDeltaNextShadow(ctx context.Context, shadow diff.PgDeltaNextShadow, in io.Reader, out io.Writer, remove func(string)) error { + transferred := false + defer func() { + if transferred { + return + } + remove(shadow.Migrations.Container) + remove(shadow.Declarative.Container) + }() + + payload := pgDeltaNextShadowHandoff{ + Migrations: pgDeltaNextShadowEndpoint{ + ContainerID: shadow.Migrations.Container, + URL: utils.ToPostgresURLWithoutPassword(shadow.Migrations.Config), + }, + Declarative: pgDeltaNextShadowEndpoint{ + ContainerID: shadow.Declarative.Container, + URL: utils.ToPostgresURLWithoutPassword(shadow.Declarative.Config), + }, + } + if err := json.NewEncoder(out).Encode(payload); err != nil { + return fmt.Errorf("failed to encode pg-delta shadow handoff: %w", err) + } + if flusher, ok := out.(interface{ Flush() error }); ok { + if err := flusher.Flush(); err != nil { + return fmt.Errorf("failed to flush pg-delta shadow handoff: %w", err) + } + } + + type readResult struct { + line string + err error + } + result := make(chan readResult, 1) + go func() { + line, err := bufio.NewReader(in).ReadString('\n') + result <- readResult{line: line, err: err} + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case read := <-result: + if err := ctx.Err(); err != nil { + return err + } + if read.err != nil { + return fmt.Errorf("failed to read pg-delta shadow handoff acknowledgment: %w", read.err) + } + if read.line != "ack\n" { + return fmt.Errorf("unexpected pg-delta shadow handoff acknowledgment %q", read.line) + } + } + + transferred = true + return nil +} + var ( dbCmd = &cobra.Command{ GroupID: groupLocalDev, @@ -200,20 +276,17 @@ var ( }, } - shadowMode string - shadowTargetLocal bool - shadowUsePgDelta bool - shadowSchema []string - shadowProjectRef string + shadowMode string + shadowSchema []string + shadowProjectRef string // dbShadowCmd is a hidden seam used by the native-TypeScript db diff/pull - // commands to provision the throwaway shadow database that the diff "source" - // runs against, then leave it running so the TS caller can run the differ - // (migra or pg-delta) itself and remove the container afterwards. It prints - // three newline-separated lines to stdout: the container id, the source - // Postgres URL, and an optional target-override URL (empty unless the - // local-target declarative branch redirects the diff target to a second - // shadow database). The URLs are emitted WITHOUT the password + // commands to provision throwaway shadow databases, then leave them running + // so the TS caller can run the differ itself and remove the containers + // afterwards. Legacy modes print two newline-separated lines. pgdelta-next + // emits a JSON object describing its two isolated clusters, then retains + // cleanup ownership until the caller acknowledges receipt. URLs are emitted + // WITHOUT the password // (ToPostgresURLWithoutPassword) so we never log a credential to stdout // (CWE-312); the TS caller re-injects the local Postgres password it already // resolves from config.toml, which is the same value the shadow uses. Shadow @@ -246,13 +319,20 @@ var ( if err := flags.LoadConfig(fsys); err != nil { return err } + if shadowMode == "pgdelta-next" { + nextShadow, err := diff.PreparePgDeltaNextShadow(cmd.Context(), fsys) + if err != nil { + return err + } + return handoffPgDeltaNextShadow(cmd.Context(), nextShadow, os.Stdin, os.Stdout, utils.DockerRemove) + } var src diff.ShadowSource var err error switch shadowMode { case "declarative": src, err = diff.PrepareRawShadow(cmd.Context()) case "diff", "": - src, err = diff.PrepareShadowSource(cmd.Context(), shadowSchema, shadowTargetLocal, shadowUsePgDelta, fsys) + src, err = diff.PrepareShadowSource(cmd.Context(), fsys) default: return fmt.Errorf("unknown shadow mode: %s", shadowMode) } @@ -261,11 +341,6 @@ var ( } fmt.Println(src.Container) fmt.Println(utils.ToPostgresURLWithoutPassword(src.Source)) - if src.TargetOverride != nil { - fmt.Println(utils.ToPostgresURLWithoutPassword(*src.TargetOverride)) - } else { - fmt.Println("") - } return nil }, } @@ -614,9 +689,7 @@ func init() { dbCmd.AddCommand(dbPullCmd) // Build hidden shadow-provisioning seam command shadowFlags := dbShadowCmd.Flags() - shadowFlags.StringVar(&shadowMode, "mode", "diff", "Shadow mode: diff (baseline + migrations) or declarative (bare shadow).") - shadowFlags.BoolVar(&shadowTargetLocal, "target-local", false, "Whether the diff target is the local database (enables the declarative-schema branch).") - shadowFlags.BoolVar(&shadowUsePgDelta, "use-pg-delta", false, "Whether pg-delta is the active diff engine (selects the declarative-apply path).") + shadowFlags.StringVar(&shadowMode, "mode", "diff", "Shadow mode: diff (baseline + migrations), declarative (bare shadow), or pgdelta-next (migrated + empty scratch).") shadowFlags.StringSliceVarP(&shadowSchema, "schema", "s", []string{}, "Comma separated list of schema to include.") shadowFlags.StringVar(&shadowProjectRef, "project-ref", "", "Linked project ref, so the shadow merges the matching [remotes.] config override.") dbCmd.AddCommand(dbShadowCmd) diff --git a/apps/cli-go/cmd/db_shadow_test.go b/apps/cli-go/cmd/db_shadow_test.go new file mode 100644 index 0000000000..b5df3742af --- /dev/null +++ b/apps/cli-go/cmd/db_shadow_test.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + + "github.com/jackc/pgconn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/db/diff" +) + +func TestHandoffPgDeltaNextShadowTransfersOwnershipAfterAck(t *testing.T) { + shadow := testPgDeltaNextShadow() + var output bytes.Buffer + var removed []string + + err := handoffPgDeltaNextShadow(context.Background(), shadow, strings.NewReader("ack\n"), &output, func(container string) { + removed = append(removed, container) + }) + + require.NoError(t, err) + assert.Equal(t, "{\"migrations\":{\"containerId\":\"migrations-container\",\"url\":\"postgresql://postgres@migrations-host:6543/postgres?connect_timeout=10\"},\"declarative\":{\"containerId\":\"declarative-container\",\"url\":\"postgresql://postgres@declarative-host:7654/postgres?connect_timeout=10\"}}\n", output.String()) + assert.Empty(t, removed) +} + +func TestHandoffPgDeltaNextShadowRetainsOwnershipOnHandshakeFailure(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + {name: "EOF", wantErr: "failed to read"}, + {name: "ack without newline", input: "ack", wantErr: "failed to read"}, + {name: "bad acknowledgment", input: "nope\n", wantErr: "unexpected"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var removed []string + err := handoffPgDeltaNextShadow(context.Background(), testPgDeltaNextShadow(), strings.NewReader(tt.input), io.Discard, func(container string) { + removed = append(removed, container) + }) + + assert.ErrorContains(t, err, tt.wantErr) + assert.Equal(t, []string{"migrations-container", "declarative-container"}, removed) + }) + } +} + +func TestHandoffPgDeltaNextShadowCleansBothOnEncodingFailure(t *testing.T) { + var removed []string + err := handoffPgDeltaNextShadow(context.Background(), testPgDeltaNextShadow(), strings.NewReader("ack\n"), failingWriter{}, func(container string) { + removed = append(removed, container) + }) + + assert.ErrorContains(t, err, "failed to encode") + assert.Equal(t, []string{"migrations-container", "declarative-container"}, removed) +} + +func TestHandoffPgDeltaNextShadowCleansBothOnCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reader, writer := io.Pipe() + cancel() + t.Cleanup(func() { + _ = reader.Close() + _ = writer.Close() + }) + var removed []string + + err := handoffPgDeltaNextShadow(ctx, testPgDeltaNextShadow(), reader, io.Discard, func(container string) { + removed = append(removed, container) + }) + + assert.ErrorIs(t, err, context.Canceled) + assert.Equal(t, []string{"migrations-container", "declarative-container"}, removed) +} + +func testPgDeltaNextShadow() diff.PgDeltaNextShadow { + return diff.PgDeltaNextShadow{ + Migrations: diff.PgDeltaNextShadowDatabase{ + Container: "migrations-container", + Config: pgconn.Config{ + Host: "migrations-host", + Port: 6543, + User: "postgres", + Password: "must-not-be-emitted", + Database: "postgres", + }, + }, + Declarative: diff.PgDeltaNextShadowDatabase{ + Container: "declarative-container", + Config: pgconn.Config{ + Host: "declarative-host", + Port: 7654, + User: "postgres", + Password: "must-not-be-emitted", + Database: "postgres", + }, + }, + } +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { + return 0, errors.New("write failed") +} diff --git a/apps/cli-go/docs/supabase/db/diff.md b/apps/cli-go/docs/supabase/db/diff.md index 0c0cf05a4d..419d2e6b06 100644 --- a/apps/cli-go/docs/supabase/db/diff.md +++ b/apps/cli-go/docs/supabase/db/diff.md @@ -6,11 +6,27 @@ Requires the local development stack to be running when diffing against the loca Runs [djrobstep/migra](https://github.com/djrobstep/migra) in a container to compare schema differences between the target database and a shadow database. The shadow database is created by applying migrations in local `supabase/migrations` directory in a separate container. Output is written to stdout by default. For convenience, you can also save the schema diff as a new migration file by passing in `-f` flag. +`-f dogfood_note` names the generated migration; it does not filter the diff to the `dogfood_note` object. + +| Command | Baseline/source | Compared with/destination | Writes | +| -------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------- | +| `db diff` | `supabase/migrations/` | Live database (`--local` default, `--linked`, or `--db-url`) | stdout, or migration file(s) with `-f` | +| `db pull` | `supabase/migrations/` plus selected database history | Live database (`--linked` default) | migration file(s), then optionally selected database history | +| `db pull --declarative` | Selected live database | `supabase/database/` | replaces the declarative tree; no migration or history update | +| `db schema declarative generate` | Selected live database | `supabase/database/` | replaces declarative files only | +| `db schema declarative sync` | `supabase/migrations/` | `supabase/database/` | migration file(s), optionally applied to the local database | + +Normal diff mode always compares the migrations shadow with the selected live database. Declarative files under `supabase/database/` and `[db.migrations].schema_paths` do not replace the migrations baseline. If migrations are empty or outdated, a saved diff can therefore include objects already represented by declarative files. Use `supabase db schema declarative sync --no-apply` to generate and review a migration from the declarative desired state before making later live changes. + By default, all schemas in the target database are diffed. Use the `--schema public,extensions` flag to restrict diffing to a subset of schemas. Projects created by a recent `supabase init` default to the pg-delta diff engine (`[experimental.pgdelta] enabled = true` in `config.toml`). Existing projects are unaffected and keep using migra unless they opt in. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]`, or pass `--use-migra` for a single run. -With the pg-delta engine the diff SQL is formatted by default with the same settings the declarative export uses (uppercase keywords, wrapped at a max width of 180, indented and column-aligned); execution-aware transaction boundaries are preserved as per-unit header comments in the output. Configure overrides with `[experimental.pgdelta] format_options`, or set `format_options = "null"` to emit raw, unformatted statements. +The pg-delta engine runs in-process by default and is bundled into the CLI together with pg-topo at build time. Set `SUPABASE_USE_PG_DELTA_NEXT=false` to temporarily select the legacy edge-runtime implementation. `PGDELTA_NPM_REGISTRY`, `supabase/.temp/pgdelta-version`, and legacy catalogs under `supabase/.temp/pgdelta/` affect only that opt-out; there is no automatic fallback. + +With the pg-delta engine the diff SQL is compacted and formatted by default with pg-delta's human-facing settings (lowercase keywords, wrapped at a max width of 180, indented and column-aligned); execution-aware transaction boundaries are preserved as per-unit header comments in the output. Configure partial overrides with `[experimental.pgdelta] format_options`, or set `format_options = "null"` to emit raw, unformatted statements. Compaction remains enabled in raw mode because it is a separate, semantics-preserving planning step. + +The bundled and legacy renderers can produce different SQL bytes or file segmentation. The compatibility contract is executable SQL and convergence: after applying the result, a subsequent diff should be empty. With `PGDELTA_DEBUG=1`, bundled-engine snapshots, plans, and diagnostics are stored under `supabase/.temp/pgdelta/v2/debug//`; those files are diagnostic artifacts, not reusable catalogs. While the diff command is able to capture most schema changes, there are cases where it is known to fail. Currently, this could happen if you schema contains: diff --git a/apps/cli-go/docs/supabase/db/pull.md b/apps/cli-go/docs/supabase/db/pull.md index e10c0679f7..a55b30d545 100644 --- a/apps/cli-go/docs/supabase/db/pull.md +++ b/apps/cli-go/docs/supabase/db/pull.md @@ -10,11 +10,15 @@ Optionally, a new row can be inserted into the migration history table to reflec If no entries exist in the migration history table, the default diff engine uses `pg_dump` to capture all contents of the remote schemas you have created. Otherwise, this command will only diff schema changes against the remote database, similar to running `db diff --linked`. -Pass `--diff-engine pg-delta` to keep the migration-file `db pull` workflow while using pg-delta for the shadow diff step. On initial pull, pg-delta replaces `pg_dump` and produces the full migration from the shadow diff alone. Pass `--declarative` to switch to the declarative pg-delta export workflow instead. +Pass `--diff-engine pg-delta` to keep the migration-file `db pull` workflow while using pg-delta for the shadow diff step. On initial pull, pg-delta replaces `pg_dump` and produces the full migration from the shadow diff alone. Pass `--declarative` to switch to the declarative pg-delta export workflow instead; that mode replaces the declarative tree and does not create migrations or update migration history. + +Migration-style pull always compares the local migrations shadow with the selected live database. Declarative files and `[db.migrations].schema_paths` do not replace that migrations baseline; use `db schema declarative sync` for declarative comparison. In non-interactive use, the prompt to record newly pulled migrations in the selected database history takes its default of yes. + +Pg-delta runs in-process by default and is bundled with pg-topo at CLI build time. Set `SUPABASE_USE_PG_DELTA_NEXT=false` to temporarily use the legacy edge-runtime implementation. `PGDELTA_NPM_REGISTRY`, `supabase/.temp/pgdelta-version`, and legacy catalogs directly under `supabase/.temp/pgdelta/` affect only that opt-out; the CLI never falls back automatically. pg-delta plans are execution-aware: when a plan crosses a transaction boundary — for example `ALTER TYPE ... ADD VALUE` followed by a statement that uses the new enum value, which cannot run in the same transaction — `db pull` writes one ordered migration file per plan unit instead of a single file (for example `_remote_schema_schema_changes.sql` and `_remote_schema_after_enum_values.sql`), each recorded in the migration history. The common case (a single unit) still produces exactly one `_remote_schema.sql` file. -By default the emitted SQL is formatted with the same settings the declarative export uses (uppercase keywords, wrapped at a max width of 180, indented and column-aligned). Configure overrides with `[experimental.pgdelta] format_options` in `config.toml`, or set `format_options = "null"` to opt out and emit raw, unformatted statements. +By default the emitted SQL is compacted and formatted with pg-delta's human-facing settings (lowercase keywords, wrapped at a max width of 180, indented and column-aligned). Configure partial overrides with `[experimental.pgdelta] format_options` in `config.toml`, or set `format_options = "null"` to opt out and emit raw, unformatted statements. Compaction remains enabled in raw mode because it is a separate, semantics-preserving planning step. When `[experimental.pgdelta] enabled = true` (the default for projects created by a recent `supabase init`), the migration-file `db pull` workflow uses pg-delta for the shadow diff step by default; it does not switch to declarative output. Existing projects without the section are unaffected and keep using migra. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]`, or pass `--diff-engine migra` for a single run. @@ -28,7 +32,16 @@ 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//`: +The bundled engine writes a debug bundle under `supabase/.temp/pgdelta/v2/debug//` and includes its path in the empty-pull error. It contains `metadata.json` and, when available: + +- `source-snapshot.json` — serialized shadow database state +- `desired-snapshot.json` — serialized remote database state +- `plan.json` — serialized pg-delta plan +- `diagnostics.json` — extraction/planning diagnostics + +These files are diagnostic artifacts and are never reused as catalogs. New-engine SQL bytes and transaction-split filenames may differ from the legacy renderer; successful execution and an empty subsequent pull/diff are the contract. + +Under `SUPABASE_USE_PG_DELTA_NEXT=false`, the CLI instead writes the legacy debug bundle under `supabase/.temp/pgdelta/debug//`: - `source-catalog.json` — shadow database baseline pg-delta extracted - `target-catalog.json` — remote database pg-delta extracted @@ -36,6 +49,6 @@ When pg-delta returns zero statements, the CLI writes a debug bundle under `supa - `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`. +Legacy catalog files are not written during normal default-engine `db pull` runs. The `.temp/pgdelta` root is used only by legacy compatibility paths; default-engine artifacts are generation-separated under `.temp/pgdelta/v2/`. For TLS tracing without disabling SSL, use `SUPABASE_SSL_DEBUG=true` alongside `PGDELTA_DEBUG=1`. diff --git a/apps/cli-go/docs/supabase/db/schema-declarative-generate.md b/apps/cli-go/docs/supabase/db/schema-declarative-generate.md index 6c39004e5e..8a6001f8ab 100644 --- a/apps/cli-go/docs/supabase/db/schema-declarative-generate.md +++ b/apps/cli-go/docs/supabase/db/schema-declarative-generate.md @@ -4,4 +4,14 @@ Generate declarative schema files from a database. Exports the schema of a live database (local, linked, or custom URL) into SQL files under the declarative schema directory. This is the entrypoint for bootstrapping declarative mode. +Generate replaces the declarative tree only. It does not create migration files or update migration history, so it does not establish the baseline used by `db diff` or migration-style `db pull`. In non-interactive use, pass `--local`, `--linked`, or `--db-url` explicitly. To materialize declarations as a reviewed migration baseline, run `supabase db schema declarative sync --no-apply` before making later live changes. + +The generated directory becomes the complete desired state: objects omitted from it are intended removals, including extensions, with or without an export manifest. When upgrading from the legacy workflow, regenerate the directory or add declarations for every extension you intend to retain before syncing, then review destructive-change warnings before applying. + +Pg-delta and pg-topo run in-process and are bundled into the CLI at build time. The export includes `.pgdelta-export.json` policy metadata. Set `SUPABASE_USE_PG_DELTA_NEXT=false` to temporarily select the legacy catalog/edge-runtime implementation; `PGDELTA_NPM_REGISTRY`, `.temp/pgdelta-version`, and catalogs at the `.temp/pgdelta/` root are legacy-only. + +Generated SQL is compacted and formatted by default with pg-delta's human-facing settings (lowercase keywords, a maximum width of 180, indentation, and column alignment). Declarative export safely folds additional constraints that remain separate in executable diff plans. Configure partial formatting overrides with `[experimental.pgdelta] format_options`, or set `format_options = "null"` to emit raw SQL while retaining semantic compaction. + +`--no-cache` bypasses legacy catalog reuse/warming. The bundled engine always extracts live state and has no reusable catalog cache. With `PGDELTA_DEBUG=1`, structured diagnostics are written under `.temp/pgdelta/v2/debug//`. SQL bytes and grouping may differ between engines; reloading the export to the same managed state is the contract. + Requires `--experimental` flag or `[experimental.pgdelta] enabled = true` in config. diff --git a/apps/cli-go/docs/supabase/db/schema-declarative-sync.md b/apps/cli-go/docs/supabase/db/schema-declarative-sync.md index 1932b16f11..0d89d8fff4 100644 --- a/apps/cli-go/docs/supabase/db/schema-declarative-sync.md +++ b/apps/cli-go/docs/supabase/db/schema-declarative-sync.md @@ -4,4 +4,14 @@ Generate a new migration by diffing your declarative schema files against the cu When no declarative schema exists yet, the command offers to run `generate` first. After computing the diff, you can optionally name the migration and apply it to the local database. +For non-interactive generation, pass `--no-apply` explicitly. A non-interactive invocation otherwise skips applying by default, but global `--yes` changes that decision and applies the generated migration to the local database and its migration history. + +The declarative directory is a complete, hand-authored desired state. Missing objects are intended removals, including extensions, regardless of whether the files were generated or whether an export manifest exists. When upgrading from the legacy workflow, regenerate the directory or add declarations for extensions you intend to retain, and review destructive-change warnings before applying. + +Pg-delta and pg-topo run in-process and are bundled into the CLI at build time. Set `SUPABASE_USE_PG_DELTA_NEXT=false` to temporarily select the legacy catalog/edge-runtime implementation; `PGDELTA_NPM_REGISTRY`, `.temp/pgdelta-version`, and catalogs at the `.temp/pgdelta/` root are legacy-only. + +Generated migrations are compacted and formatted by default with pg-delta's human-facing settings (lowercase keywords, a maximum width of 180, indentation, and column alignment). Configure partial formatting overrides with `[experimental.pgdelta] format_options`, or set `format_options = "null"` to emit raw SQL while retaining semantic compaction. Dependency-sensitive constraints such as foreign keys may remain separate when folding them would be unsafe for migration execution. + +`--no-cache` bypasses legacy catalog reuse/warming; the bundled engine extracts current state and has no reusable catalog cache. It may emit multiple ordered migration files to preserve transaction boundaries. SQL bytes may differ from the legacy renderer; successful application followed by an empty sync is the contract. With `PGDELTA_DEBUG=1`, snapshots, the plan, and diagnostics are written under `.temp/pgdelta/v2/debug//`. + Requires `--experimental` flag or `[experimental.pgdelta] enabled = true` in config. diff --git a/apps/cli-go/internal/db/declarative/declarative.go b/apps/cli-go/internal/db/declarative/declarative.go index b84087bf9f..9eecfb6f17 100644 --- a/apps/cli-go/internal/db/declarative/declarative.go +++ b/apps/cli-go/internal/db/declarative/declarative.go @@ -673,8 +673,8 @@ func baselineVersionToken() string { return catalogPrefixRegexp.ReplaceAllString(image, "-") } -// setupInputsToken hashes every project input that start.SetupDatabase consumes -// and that therefore shapes the platform baseline: +// setupInputsToken hashes every project input that shapes the legacy shadow +// baseline produced by start.SetupDatabase with WithLegacyPgNetBaseline: // // - the Postgres image (initSchema content); // - the service toggles that gate initSchema — auth/storage/realtime; diff --git a/apps/cli-go/internal/db/declarative/declarative_test.go b/apps/cli-go/internal/db/declarative/declarative_test.go index cbd67d29de..35a3bd92df 100644 --- a/apps/cli-go/internal/db/declarative/declarative_test.go +++ b/apps/cli-go/internal/db/declarative/declarative_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "testing/fstest" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" @@ -468,6 +469,25 @@ func TestBaselineCatalogKeyVariesWithServiceToggles(t *testing.T) { assert.NotEqual(t, on, off, "toggling a service must change the baseline cache key") } +func TestBaselineCatalogKeyIgnoresDatabaseWebhooks(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + fSys := afero.NewMemMapFs() + + enabled := config.NewConfig() + require.NoError(t, enabled.Load("config.toml", fstest.MapFS{ + "config.toml": &fstest.MapFile{Data: []byte("[experimental.webhooks]\nenabled = true\n")}, + })) + utils.Config = enabled + enabledKey, err := baselineCatalogKey(fSys) + require.NoError(t, err) + utils.Config.Experimental.Webhooks = nil + disabledKey, err := baselineCatalogKey(fSys) + require.NoError(t, err) + + assert.Equal(t, disabledKey, enabledKey, "Database Webhooks no longer changes the legacy baseline") +} + 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. diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index 32fabb2c21..819929b35c 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -4,11 +4,8 @@ import ( "context" "fmt" "io" - "io/fs" "os" - "path/filepath" "regexp" - "sort" "strconv" "strings" "time" @@ -23,13 +20,14 @@ import ( "github.com/spf13/afero" "github.com/supabase/cli/internal/db/start" "github.com/supabase/cli/internal/utils" - configpkg "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/migration" "github.com/supabase/cli/pkg/parser" ) type DiffFunc func(context.Context, pgconn.Config, pgconn.Config, []string, ...func(*pgx.ConnConfig)) (string, error) +const schemaPathsTransitionWarning = "WARNING: [db.migrations].schema_paths no longer changes the target of db diff or migration-style db pull. These commands always compare local migrations with the selected database. Use `supabase db schema declarative sync` to compare declarative schema files." + 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...) if err != nil { @@ -49,75 +47,6 @@ func Run(ctx context.Context, schema []string, file string, config pgconn.Config return nil } -func loadDeclaredSchemas(fsys afero.Fs) ([]string, error) { - if schemas := utils.Config.Db.Migrations.SchemaPaths; len(schemas) > 0 { - return schemas.SQLFiles( - afero.NewIOFS(fsys), - configpkg.WithSkipEmptyGlobs(), - configpkg.WithErrorOnAllSkippedGlobs(), - ) - } - // When pg-delta is enabled, declarative path is the source of truth (config or default). - if utils.IsPgDeltaEnabled() { - declDir := utils.GetDeclarativeDir() - if exists, err := afero.DirExists(fsys, declDir); err == nil && exists { - var declared []string - if err := afero.Walk(fsys, declDir, func(path string, info fs.FileInfo, err error) error { - if err != nil { - return err - } - if info.Mode().IsRegular() && filepath.Ext(info.Name()) == ".sql" { - declared = append(declared, path) - } - return nil - }); err != nil { - return nil, errors.Errorf("failed to walk declarative dir: %w", err) - } - sort.Strings(declared) - return declared, nil - } - } - if exists, err := afero.DirExists(fsys, utils.SchemasDir); err != nil { - return nil, errors.Errorf("failed to check schemas: %w", err) - } else if !exists { - return nil, nil - } - var declared []string - if err := afero.Walk(fsys, utils.SchemasDir, func(path string, info fs.FileInfo, err error) error { - if err != nil { - return err - } - if info.Mode().IsRegular() && filepath.Ext(info.Name()) == ".sql" { - declared = append(declared, path) - } - return nil - }); err != nil { - return nil, errors.Errorf("failed to walk dir: %w", err) - } - // Keep file application order deterministic so diff output stays stable across - // filesystems and operating systems. This is only if no schema paths in config are set. - sort.Strings(declared) - 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()) -} - -func cleanSchemaPath(path string) string { - return filepath.ToSlash(filepath.Clean(path)) -} - // https://github.com/djrobstep/migra/blob/master/migra/statements.py#L6 var dropStatementPattern = regexp.MustCompile(`(?i)drop\s+`) @@ -168,8 +97,8 @@ const CREATE_TEMPLATE = "CREATE DATABASE contrib_regression TEMPLATE postgres" // database. It deliberately stops short of applying user migrations so that // callers which only need the platform baseline (declarative apply) share the // exact same starting point as callers that also replay migrations. -func setupShadowConn(ctx context.Context, conn *pgx.Conn, container string, fsys afero.Fs) error { - if err := start.SetupDatabase(ctx, conn, container[:12], os.Stderr, fsys); err != nil { +func setupShadowConn(ctx context.Context, conn *pgx.Conn, container string, fsys afero.Fs, options ...start.SetupDatabaseOption) error { + if err := start.SetupDatabase(ctx, conn, container[:12], os.Stderr, fsys, options...); err != nil { return err } if _, err := conn.Exec(ctx, CREATE_TEMPLATE); err != nil { @@ -189,10 +118,51 @@ func SetupShadowDatabase(ctx context.Context, container string, fsys afero.Fs, o return err } defer conn.Close(context.Background()) - return setupShadowConn(ctx, conn, container, fsys) + return setupShadowConn(ctx, conn, container, fsys, start.WithLegacyPgNetBaseline()) } -func MigrateShadowDatabase(ctx context.Context, container string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { +var pgDeltaNextDeclarativeExtensionDrops = []struct { + name string + sql string +}{ + {name: "pgcrypto", sql: "DROP EXTENSION IF EXISTS pgcrypto"}, + {name: "uuid-ossp", sql: `DROP EXTENSION IF EXISTS "uuid-ossp"`}, +} + +// SetupPgDeltaNextDeclarativeShadowDatabase provisions cluster B with the +// platform baseline but without activating user-managed extensions. Those +// extensions must come exclusively from declarative SQL so deleting their files +// can produce DROP EXTENSION plans. +func SetupPgDeltaNextDeclarativeShadowDatabase(ctx context.Context, container string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { + if utils.Config.Db.MajorVersion != 17 { + return errors.Errorf( + "pg-delta declarative shadow baseline requires Postgres 17 (got major %d, image %q)", + utils.Config.Db.MajorVersion, + utils.Config.Db.Image, + ) + } + conn, err := ConnectShadowDatabase(ctx, 10*time.Second, options...) + if err != nil { + return err + } + defer conn.Close(context.Background()) + if err := start.SetupDatabase(ctx, conn, container[:12], os.Stderr, fsys, start.WithoutUserExtensionActivation()); err != nil { + return err + } + for _, extension := range pgDeltaNextDeclarativeExtensionDrops { + if _, err := conn.Exec(ctx, extension.sql); err != nil { + return errors.Errorf( + "failed to remove user-managed extension %q from pg-delta declarative shadow baseline (image %q): %w", + extension.name, + utils.Config.Db.Image, + err, + ) + } + } + return nil +} + +func migrateShadowDatabase(ctx context.Context, container string, fsys afero.Fs, setupOptions []start.SetupDatabaseOption, options ...func(*pgx.ConnConfig)) error { migrations, err := migration.ListLocalMigrations(utils.MigrationsDir, afero.NewIOFS(fsys)) if err != nil { return err @@ -202,23 +172,35 @@ func MigrateShadowDatabase(ctx context.Context, container string, fsys afero.Fs, return err } defer conn.Close(context.Background()) - if err := setupShadowConn(ctx, conn, container, fsys); err != nil { + if err := setupShadowConn(ctx, conn, container, fsys, setupOptions...); err != nil { return err } return migration.ApplyMigrations(ctx, migrations, conn, afero.NewIOFS(fsys)) } +// MigrateShadowDatabase preserves the historical platform baseline used by the +// legacy diff engines, including pg_net even when Database Webhooks is disabled. +func MigrateShadowDatabase(ctx context.Context, container string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { + return migrateShadowDatabase(ctx, container, fsys, []start.SetupDatabaseOption{start.WithLegacyPgNetBaseline()}, options...) +} + +// MigratePgDeltaNextShadowDatabase provisions the migrations side of the +// isolated pg-delta-next comparison without inheriting legacy baseline behavior. +func MigratePgDeltaNextShadowDatabase(ctx context.Context, container string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { + return migrateShadowDatabase(ctx, container, fsys, nil, options...) +} + 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) { + if len(utils.Config.Db.Migrations.SchemaPaths) > 0 { + fmt.Fprintln(w, schemaPathsTransitionWarning) + } fmt.Fprintln(w, "Creating shadow database...") - shadowSource, err := PrepareShadowSource(ctx, schema, utils.IsLocalDatabase(config), usePgDelta, fsys, options...) + shadowSource, err := PrepareShadowSource(ctx, fsys, options...) if err != nil { return DatabaseDiff{}, err } defer utils.DockerRemove(shadowSource.Container) shadowConfig := shadowSource.Source - if shadowSource.TargetOverride != nil { - config = *shadowSource.TargetOverride - } // Load all user defined schemas if len(schema) > 0 { fmt.Fprintln(w, "Diffing schemas:", strings.Join(schema, ",")) @@ -257,18 +239,3 @@ func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w } return DatabaseDiff{SQL: output}, nil } - -func migrateBaseDatabase(ctx context.Context, config pgconn.Config, migrations []string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - fmt.Fprintln(os.Stderr, "Creating local database from declarative schemas:") - msg := make([]string, len(migrations)) - for i, m := range migrations { - msg[i] = fmt.Sprintf(" • %s", utils.Bold(m)) - } - fmt.Fprintln(os.Stderr, strings.Join(msg, "\n")) - conn, err := utils.ConnectLocalPostgres(ctx, config, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - return migration.SeedGlobals(ctx, migrations, conn, afero.NewIOFS(fsys)) -} diff --git a/apps/cli-go/internal/db/diff/diff_test.go b/apps/cli-go/internal/db/diff/diff_test.go index aff3242699..643cc8181e 100644 --- a/apps/cli-go/internal/db/diff/diff_test.go +++ b/apps/cli-go/internal/db/diff/diff_test.go @@ -1,6 +1,7 @@ package diff import ( + "bytes" "context" "errors" "io" @@ -8,6 +9,7 @@ import ( "os" "path/filepath" "testing" + stdfs "testing/fstest" "time" "github.com/docker/docker/api/types" @@ -37,80 +39,6 @@ var dbConfig = pgconn.Config{ Database: "postgres", } -func TestLoadDeclaredSchemas(t *testing.T) { - t.Run("respects schema_paths order when pg-delta declarative dir exists", 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", - } - utils.Config.Experimental.PgDelta = &pkgconfig.PgDeltaConfig{ - Enabled: true, - DeclarativeSchemaPath: utils.SchemasDir, - } - fsys := afero.NewMemMapFs() - require.NoError(t, fsys.MkdirAll(utils.SchemasDir, 0755)) - require.NoError(t, afero.WriteFile(fsys, "supabase/schemas/a_table.sql", []byte("create table a();"), 0644)) - require.NoError(t, afero.WriteFile(fsys, "supabase/schemas/z_function.sql", []byte("create function z() returns void language sql as $$ select 1 $$;"), 0644)) - - declared, err := loadDeclaredSchemas(fsys) - - require.NoError(t, err) - assert.Equal(t, []string{ - "supabase/schemas/z_function.sql", - "supabase/schemas/a_table.sql", - }, declared) - }) - - t.Run("expands schema_paths directory entries deterministically", func(t *testing.T) { - originalConfig := utils.Config - t.Cleanup(func() { utils.Config = originalConfig }) - utils.Config.Db.Migrations.SchemaPaths = pkgconfig.Glob{utils.DeclarativeDir} - fsys := afero.NewMemMapFs() - require.NoError(t, fsys.MkdirAll(filepath.Join(utils.DeclarativeDir, "nested"), 0755)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(utils.DeclarativeDir, "nested", "b.sql"), []byte("select 2;"), 0644)) - require.NoError(t, afero.WriteFile(fsys, filepath.Join(utils.DeclarativeDir, "a.sql"), []byte("select 1;"), 0644)) - - declared, err := loadDeclaredSchemas(fsys) - - require.NoError(t, err) - assert.Equal(t, []string{ - filepath.Join(utils.DeclarativeDir, "a.sql"), - filepath.Join(utils.DeclarativeDir, "nested", "b.sql"), - }, declared) - }) -} - -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 @@ -144,6 +72,12 @@ func TestRun(t *testing.T) { // Setup mock postgres: with auto_expose_new_tables unset, the shadow database setup // revokes the default Data API GRANTs before creating the regression template. conn := pgtest.NewConn() + conn.Query("BEGIN"). + Reply("BEGIN"). + Query("create extension if not exists pg_net schema extensions"). + Reply("CREATE EXTENSION"). + Query("COMMIT"). + Reply("COMMIT") helper.MockApiPrivilegesRevoke(conn). Query(CREATE_TEMPLATE). Reply("CREATE DATABASE") @@ -173,7 +107,7 @@ 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) { + t.Run("ignores schema_paths and diffs the selected database", func(t *testing.T) { originalConfig := utils.Config t.Cleanup(func() { utils.Config = originalConfig }) utils.Config.Db.MajorVersion = 14 @@ -211,19 +145,27 @@ func TestRun(t *testing.T) { Reply(http.StatusOK) shadowConn := pgtest.NewConn() defer shadowConn.Close(t) - shadowConn.Query(utils.GlobalsSql). + shadowConn.Query("BEGIN"). + Reply("BEGIN"). + Query(utils.GlobalsSql). Reply("CREATE SCHEMA"). + Query("COMMIT"). + Reply("COMMIT"). + Query("BEGIN"). + Reply("BEGIN"). Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") + Reply("CREATE SCHEMA"). + Query("COMMIT"). + Reply("COMMIT"). + Query("BEGIN"). + Reply("BEGIN"). + Query("create extension if not exists pg_net schema extensions"). + Reply("CREATE EXTENSION"). + Query("COMMIT"). + Reply("COMMIT") 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. @@ -232,7 +174,7 @@ func TestRun(t *testing.T) { 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.Contains(t, targetRef, ":54322/postgres") assert.Equal(t, []string{"public"}, schema) return PgDeltaDiffResult{ Files: []PgDeltaPlanFile{{Order: 1, Name: "schema_changes", TransactionMode: "transactional", SQL: generated}}, @@ -251,11 +193,7 @@ func TestRun(t *testing.T) { } 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) - } + shadowConn.Intercept(cc) }) require.NoError(t, err) @@ -301,20 +239,38 @@ func TestMigrateShadow(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(utils.GlobalsSql). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(utils.GlobalsSql). Reply("CREATE SCHEMA"). + Query("COMMIT"). + Reply("COMMIT"). + Query("BEGIN"). + Reply("BEGIN"). Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") + Reply("CREATE SCHEMA"). + Query("COMMIT"). + Reply("COMMIT"). + Query("BEGIN"). + Reply("BEGIN"). + Query("create extension if not exists pg_net schema extensions"). + Reply("CREATE EXTENSION"). + Query("COMMIT"). + Reply("COMMIT") helper.MockApiPrivilegesRevoke(conn). Query(CREATE_TEMPLATE). Reply("CREATE DATABASE") helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(sql). Reply("CREATE SCHEMA"). Query(migration.INSERT_MIGRATION_VERSION, "0", "test", []string{sql}). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") // Run test err := MigrateShadowDatabase(context.Background(), "test-shadow-db", fsys, conn.Intercept) // Check error @@ -351,8 +307,12 @@ func TestMigrateShadow(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(utils.GlobalsSql). - ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`) + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(utils.GlobalsSql). + ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := MigrateShadowDatabase(context.Background(), "test-shadow-db", fsys, conn.Intercept) // Check error @@ -360,7 +320,38 @@ func TestMigrateShadow(t *testing.T) { }) } +func TestMigratePgDeltaNextShadowDatabase(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + utils.Config = pkgconfig.NewConfig() + utils.Config.Db.MajorVersion = 17 + utils.Config.Db.ShadowPort = 54320 + utils.Config.Auth.Enabled = false + utils.Config.Storage.Enabled = false + utils.Config.Realtime.Enabled = false + + conn := pgtest.NewConn() + defer conn.Close(t) + // The config has no Database Webhooks section. Expecting API privileges first + // makes any leaked legacy pg_net activation fail as an unmatched query. + helper.MockApiPrivilegesRevoke(conn). + Query(CREATE_TEMPLATE). + Reply("CREATE DATABASE") + + err := MigratePgDeltaNextShadowDatabase( + context.Background(), + "pg-delta-next-migrations", + afero.NewMemMapFs(), + conn.Intercept, + ) + + require.NoError(t, err) +} + func TestSetupShadowDatabase(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + utils.Config = pkgconfig.NewConfig() utils.Config.Db.MajorVersion = 14 t.Run("sets up platform baseline without applying migrations", func(t *testing.T) { @@ -376,14 +367,29 @@ func TestSetupShadowDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(utils.GlobalsSql). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(utils.GlobalsSql). Reply("CREATE SCHEMA"). + Query("COMMIT"). + Reply("COMMIT"). + Query("BEGIN"). + Reply("BEGIN"). Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") + Reply("CREATE SCHEMA"). + Query("COMMIT"). + Reply("COMMIT"). + Query("BEGIN"). + Reply("BEGIN"). + Query("create extension if not exists pg_net schema extensions"). + Reply("CREATE EXTENSION"). + Query("COMMIT"). + Reply("COMMIT") helper.MockApiPrivilegesRevoke(conn). Query(CREATE_TEMPLATE). Reply("CREATE DATABASE") - // Run test + // Run with Database Webhooks disabled. Legacy shadows still include pg_net + // because declarative directories historically diffed against that baseline. err := SetupShadowDatabase(context.Background(), "test-shadow-db", fsys, conn.Intercept) // Check error assert.NoError(t, err) @@ -395,8 +401,12 @@ func TestSetupShadowDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(utils.GlobalsSql). - ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`) + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(utils.GlobalsSql). + ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := SetupShadowDatabase(context.Background(), "test-shadow-db", afero.NewMemMapFs(), conn.Intercept) // Check error @@ -404,6 +414,85 @@ func TestSetupShadowDatabase(t *testing.T) { }) } +func TestSetupPgDeltaNextDeclarativeShadowDatabase(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + + newPg17Config := func(t *testing.T) { + t.Helper() + cfg := pkgconfig.NewConfig() + require.NoError(t, cfg.Load("config.toml", stdfs.MapFS{ + "config.toml": &stdfs.MapFile{Data: []byte("[experimental.webhooks]\nenabled = true\n")}, + })) + cfg.Db.MajorVersion = 17 + cfg.Db.Image = "public.ecr.aws/supabase/postgres:17.6.1.104" + cfg.Db.ShadowPort = 54320 + cfg.Realtime.Enabled = false + cfg.Storage.Enabled = false + cfg.Auth.Enabled = false + utils.Config = cfg + } + + t.Run("keeps pg-delta-next declarative baseline free of pg_net", func(t *testing.T) { + newPg17Config(t) + conn := pgtest.NewConn() + defer conn.Close(t) + helper.MockApiPrivilegesRevoke(conn). + Query("DROP EXTENSION IF EXISTS pgcrypto"). + Reply("DROP EXTENSION"). + Query(`DROP EXTENSION IF EXISTS "uuid-ossp"`). + Reply("DROP EXTENSION") + + err := SetupPgDeltaNextDeclarativeShadowDatabase( + context.Background(), + "declarative-container", + afero.NewMemMapFs(), + conn.Intercept, + ) + + require.NoError(t, err) + }) + + t.Run("identifies an extension whose non-cascade drop fails", func(t *testing.T) { + newPg17Config(t) + conn := pgtest.NewConn() + defer conn.Close(t) + helper.MockApiPrivilegesRevoke(conn). + Query("DROP EXTENSION IF EXISTS pgcrypto"). + ReplyError(pgerrcode.DependentObjectsStillExist, `cannot drop extension pgcrypto because other objects depend on it`) + + err := SetupPgDeltaNextDeclarativeShadowDatabase( + context.Background(), + "declarative-container", + afero.NewMemMapFs(), + conn.Intercept, + ) + + require.Error(t, err) + assert.ErrorContains(t, err, `user-managed extension "pgcrypto"`) + assert.ErrorContains(t, err, `public.ecr.aws/supabase/postgres:17.6.1.104`) + assert.ErrorContains(t, err, "SQLSTATE 2BP01") + }) + + t.Run("rejects unaudited Postgres majors", func(t *testing.T) { + cfg := pkgconfig.NewConfig() + cfg.Db.MajorVersion = 14 + cfg.Db.Image = "public.ecr.aws/supabase/postgres:14.1.0" + utils.Config = cfg + + err := SetupPgDeltaNextDeclarativeShadowDatabase( + context.Background(), + "declarative-container", + afero.NewMemMapFs(), + ) + + require.Error(t, err) + assert.ErrorContains(t, err, "requires Postgres 17") + assert.ErrorContains(t, err, "major 14") + assert.ErrorContains(t, err, "public.ecr.aws/supabase/postgres:14.1.0") + }) +} + func TestDiffDatabase(t *testing.T) { utils.Config.Db.MajorVersion = 14 utils.Config.Db.ShadowPort = 54320 @@ -411,6 +500,8 @@ func TestDiffDatabase(t *testing.T) { utils.InitialSchemaPg14Sql = "create schema private" t.Run("throws error on failure to create shadow", func(t *testing.T) { + utils.Config.Db.Migrations.SchemaPaths = pkgconfig.Glob{"supabase/database/*.sql"} + t.Cleanup(func() { utils.Config.Db.Migrations.SchemaPaths = nil }) errNetwork := errors.New("network error") // Setup in-memory fs fsys := afero.NewMemMapFs() @@ -421,10 +512,12 @@ 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) + var output bytes.Buffer + result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, &output, fsys, DiffSchemaMigra, false) // Check error assert.Empty(t, result) assert.ErrorIs(t, err, errNetwork) + assert.Contains(t, output.String(), schemaPathsTransitionWarning) assert.Empty(t, apitest.ListUnmatchedRequests()) }) @@ -481,8 +574,12 @@ func TestDiffDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(utils.GlobalsSql). - ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`) + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(utils.GlobalsSql). + ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false, conn.Intercept) // Check error @@ -535,20 +632,38 @@ create schema public`) // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(utils.GlobalsSql). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(utils.GlobalsSql). Reply("CREATE SCHEMA"). + Query("COMMIT"). + Reply("COMMIT"). + Query("BEGIN"). + Reply("BEGIN"). Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") + Reply("CREATE SCHEMA"). + Query("COMMIT"). + Reply("COMMIT"). + Query("BEGIN"). + Reply("BEGIN"). + Query("create extension if not exists pg_net schema extensions"). + Reply("CREATE EXTENSION"). + Query("COMMIT"). + Reply("COMMIT") helper.MockApiPrivilegesRevoke(conn). Query(CREATE_TEMPLATE). Reply("CREATE DATABASE") helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(sql). Reply("CREATE SCHEMA"). Query(migration.INSERT_MIGRATION_VERSION, "0", "test", []string{sql}). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") // Run test result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false, func(cc *pgx.ConnConfig) { if cc.Host == dbConfig.Host { @@ -572,70 +687,3 @@ func TestDropStatements(t *testing.T) { drops := findDropStatements("create table t(); drop table t; alter table t drop column c") assert.Equal(t, []string{"drop table t", "alter table t drop column c"}, drops) } - -func TestLoadSchemas(t *testing.T) { - expected := []string{ - filepath.Join(utils.SchemasDir, "comment", "model.sql"), - filepath.Join(utils.SchemasDir, "model.sql"), - filepath.Join(utils.SchemasDir, "reaction", "dislike", "model.sql"), - filepath.Join(utils.SchemasDir, "reaction", "like", "model.sql"), - } - fsys := afero.NewMemMapFs() - for _, fp := range expected { - require.NoError(t, afero.WriteFile(fsys, fp, nil, 0644)) - } - // Run test - schemas, err := loadDeclaredSchemas(fsys) - // Check error - assert.NoError(t, err) - assert.ElementsMatch(t, expected, schemas) -} - -func TestLoadSchemasSkipsEmptySchemaPathGlobs(t *testing.T) { - fsys := afero.NewMemMapFs() - matched := filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "players.sql") - require.NoError(t, afero.WriteFile(fsys, matched, nil, 0644)) - utils.Config.Db.Migrations.SchemaPaths = []string{ - filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "*.sql"), - filepath.Join(utils.SupabaseDirPath, "schemas", "materialized_views", "*.sql"), - } - t.Cleanup(func() { - utils.Config.Db.Migrations.SchemaPaths = nil - }) - - schemas, err := loadDeclaredSchemas(fsys) - - assert.NoError(t, err) - assert.Equal(t, []string{filepath.ToSlash(matched)}, schemas) -} - -func TestLoadSchemasErrorsOnMissingLiteralSchemaPath(t *testing.T) { - fsys := afero.NewMemMapFs() - utils.Config.Db.Migrations.SchemaPaths = []string{ - filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "players.sql"), - } - t.Cleanup(func() { - utils.Config.Db.Migrations.SchemaPaths = nil - }) - - schemas, err := loadDeclaredSchemas(fsys) - - assert.ErrorContains(t, err, "no files matched pattern") - assert.Empty(t, schemas) -} - -func TestLoadSchemasErrorsWhenAllSchemaPathGlobsAreEmpty(t *testing.T) { - fsys := afero.NewMemMapFs() - utils.Config.Db.Migrations.SchemaPaths = []string{ - filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "*.sql"), - filepath.Join(utils.SupabaseDirPath, "schemas", "views", "*.sql"), - } - t.Cleanup(func() { - utils.Config.Db.Migrations.SchemaPaths = nil - }) - - schemas, err := loadDeclaredSchemas(fsys) - - assert.ErrorContains(t, err, "no files matched pattern") - assert.Empty(t, schemas) -} diff --git a/apps/cli-go/internal/db/diff/pgdelta.go b/apps/cli-go/internal/db/diff/pgdelta.go index 5267f0c6dd..f173c0ac32 100644 --- a/apps/cli-go/internal/db/diff/pgdelta.go +++ b/apps/cli-go/internal/db/diff/pgdelta.go @@ -43,14 +43,30 @@ type DeclarativeOutput struct { Files []DeclarativeFile `json:"files"` } +type PgDeltaTransactionMode string + +const ( + PgDeltaTransactionModeTransactional PgDeltaTransactionMode = "transactional" + PgDeltaTransactionModeNone PgDeltaTransactionMode = "none" +) + +func (m PgDeltaTransactionMode) validate() error { + switch m { + case PgDeltaTransactionModeTransactional, PgDeltaTransactionModeNone: + return nil + default: + return errors.Errorf("unknown pg-delta transaction mode %q", m) + } +} + // 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"` + Order int `json:"order"` + Name string `json:"name"` + TransactionMode PgDeltaTransactionMode `json:"transactionMode"` + SQL string `json:"sql"` } // PgDeltaDiffOutput is the top-level diff envelope emitted by templates/pgdelta.ts. @@ -182,6 +198,11 @@ func parsePgDeltaDiffOutput(stdout, stderr string) (PgDeltaDiffResult, error) { 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) } + for _, file := range envelope.Files { + if err := file.TransactionMode.validate(); err != nil { + return PgDeltaDiffResult{}, err + } + } result.Files = envelope.Files return result, nil } diff --git a/apps/cli-go/internal/db/diff/pgdelta_migrations.go b/apps/cli-go/internal/db/diff/pgdelta_migrations.go index c8f4a8b244..7a743bac6e 100644 --- a/apps/cli-go/internal/db/diff/pgdelta_migrations.go +++ b/apps/cli-go/internal/db/diff/pgdelta_migrations.go @@ -36,6 +36,14 @@ type WrittenMigration struct { // 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) { + // Validate the complete plan before touching the filesystem. The CLI supports + // exactly the two pg-delta execution modes; silently treating a future or + // misspelled mode as transactional would write a migration with wrong semantics. + for _, file := range files { + if err := file.TransactionMode.validate(); err != nil { + return nil, err + } + } single := len(files) == 1 buildSet := func(b time.Time) []WrittenMigration { set := make([]WrittenMigration, len(files)) diff --git a/apps/cli-go/internal/db/diff/pgdelta_migrations_test.go b/apps/cli-go/internal/db/diff/pgdelta_migrations_test.go index 16ace1b7ff..df6f82883e 100644 --- a/apps/cli-go/internal/db/diff/pgdelta_migrations_test.go +++ b/apps/cli-go/internal/db/diff/pgdelta_migrations_test.go @@ -49,6 +49,17 @@ func TestWritePgDeltaMigrations(t *testing.T) { assert.Equal(t, "-- unit 1\n\ncreate table a ();\n", string(contents)) }) + t.Run("rejects an unknown transaction mode before creating files", func(t *testing.T) { + fsys := afero.NewMemMapFs() + files := []PgDeltaPlanFile{{Order: 1, Name: "schema_changes", TransactionMode: "future", SQL: "SELECT 1;"}} + written, err := WritePgDeltaMigrations(files, base, "remote_schema", fsys) + require.ErrorContains(t, err, `unknown pg-delta transaction mode "future"`) + assert.Nil(t, written) + entries, readErr := afero.ReadDir(fsys, ".") + require.NoError(t, readErr) + assert.Empty(t, entries) + }) + t.Run("writes one ordered file per unit with strictly increasing versions", func(t *testing.T) { fsys := afero.NewMemMapFs() files := []PgDeltaPlanFile{ diff --git a/apps/cli-go/internal/db/diff/pgdelta_test.go b/apps/cli-go/internal/db/diff/pgdelta_test.go index ad312273c5..a3395ea6ae 100644 --- a/apps/cli-go/internal/db/diff/pgdelta_test.go +++ b/apps/cli-go/internal/db/diff/pgdelta_test.go @@ -68,4 +68,10 @@ func TestParsePgDeltaDiffOutput(t *testing.T) { assert.ErrorContains(t, err, "failed to parse pg-delta diff output") assert.ErrorContains(t, err, "boom on the edge runtime") }) + + t.Run("rejects an unknown transaction mode", func(t *testing.T) { + stdout := `{"version":1,"files":[{"order":1,"name":"schema_changes","transactionMode":"non-transactional","sql":"SELECT 1;"}]}` + _, err := parsePgDeltaDiffOutput(stdout, "") + assert.ErrorContains(t, err, `unknown pg-delta transaction mode "non-transactional"`) + }) } diff --git a/apps/cli-go/internal/db/diff/shadow.go b/apps/cli-go/internal/db/diff/shadow.go index 2ebd13591f..1092a68175 100644 --- a/apps/cli-go/internal/db/diff/shadow.go +++ b/apps/cli-go/internal/db/diff/shadow.go @@ -2,12 +2,14 @@ package diff import ( "context" + "fmt" + "math" + "time" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" "github.com/supabase/cli/internal/db/start" - "github.com/supabase/cli/internal/pgdelta" "github.com/supabase/cli/internal/utils" ) @@ -21,20 +23,143 @@ 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 *pgconn.Config +} + +// PgDeltaNextShadowDatabase is one isolated database state used by pg-delta. +// Container is left running for the caller, which MUST remove it after use. +type PgDeltaNextShadowDatabase struct { + Container string + Config pgconn.Config +} + +// PgDeltaNextShadow contains the two isolated clusters used by the native +// pg-delta engine. Migrations has the platform baseline plus local migrations; +// Declarative has the same platform baseline and local configuration, ready for +// pg-delta to load declarative SQL into postgres. +type PgDeltaNextShadow struct { + Migrations PgDeltaNextShadowDatabase + Declarative PgDeltaNextShadowDatabase +} + +type pgDeltaNextShadowDependencies struct { + freePort func() (int, error) + create func(context.Context, uint16) (string, error) + wait func(context.Context, time.Duration, ...string) error + migrate func(context.Context, string, afero.Fs, ...func(*pgx.ConnConfig)) error + setup func(context.Context, string, afero.Fs, ...func(*pgx.ConnConfig)) error + remove func(string) +} + +// PreparePgDeltaNextShadow provisions isolated migrated and declarative +// clusters. On failure, every container created so far is removed best-effort +// without replacing the provisioning error. +func PreparePgDeltaNextShadow(ctx context.Context, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (PgDeltaNextShadow, error) { + return preparePgDeltaNextShadow(ctx, fsys, pgDeltaNextShadowDependencies{ + freePort: utils.GetFreeHostPort, + create: CreateShadowDatabase, + wait: start.WaitForHealthyService, + migrate: MigratePgDeltaNextShadowDatabase, + setup: SetupPgDeltaNextDeclarativeShadowDatabase, + remove: utils.DockerRemove, + }, options...) +} + +func preparePgDeltaNextShadow(ctx context.Context, fsys afero.Fs, dependencies pgDeltaNextShadowDependencies, options ...func(*pgx.ConnConfig)) (PgDeltaNextShadow, error) { + var containers []string + ok := false + defer func() { + if !ok { + for _, container := range containers { + dependencies.remove(container) + } + } + }() + + migrationsPort, err := allocatePgDeltaNextPort(dependencies.freePort, 0) + if err != nil { + return PgDeltaNextShadow{}, err + } + migrationsContainer, err := dependencies.create(ctx, migrationsPort) + if migrationsContainer != "" { + containers = append(containers, migrationsContainer) + } + if err != nil { + return PgDeltaNextShadow{}, err + } + if err := dependencies.wait(ctx, utils.Config.Db.HealthTimeout, migrationsContainer); err != nil { + return PgDeltaNextShadow{}, err + } + if err := dependencies.migrate(ctx, migrationsContainer, fsys, append(options, withShadowPort(migrationsPort))...); err != nil { + return PgDeltaNextShadow{}, err + } + + declarativePort, err := allocatePgDeltaNextPort(dependencies.freePort, migrationsPort) + if err != nil { + return PgDeltaNextShadow{}, err + } + declarativeContainer, err := dependencies.create(ctx, declarativePort) + if declarativeContainer != "" { + containers = append(containers, declarativeContainer) + } + if err != nil { + return PgDeltaNextShadow{}, err + } + if err := dependencies.wait(ctx, utils.Config.Db.HealthTimeout, declarativeContainer); err != nil { + return PgDeltaNextShadow{}, err + } + if err := dependencies.setup(ctx, declarativeContainer, fsys, append(options, withShadowPort(declarativePort))...); err != nil { + return PgDeltaNextShadow{}, err + } + + ok = true + return PgDeltaNextShadow{ + Migrations: PgDeltaNextShadowDatabase{ + Container: migrationsContainer, + Config: pgDeltaNextShadowConfig(migrationsPort), + }, + Declarative: PgDeltaNextShadowDatabase{ + Container: declarativeContainer, + Config: pgDeltaNextShadowConfig(declarativePort), + }, + }, nil +} + +func allocatePgDeltaNextPort(freePort func() (int, error), excluded uint16) (uint16, error) { + for range 10 { + port, err := freePort() + if err != nil { + return 0, err + } + if port <= 0 || port > math.MaxUint16 { + return 0, fmt.Errorf("allocated host port %d is outside the valid range", port) + } + if uint16(port) != excluded { + return uint16(port), nil + } + } + return 0, fmt.Errorf("failed to allocate a host port distinct from %d", excluded) +} + +func withShadowPort(port uint16) func(*pgx.ConnConfig) { + return func(config *pgx.ConnConfig) { + config.Port = port + } +} + +func pgDeltaNextShadowConfig(port uint16) pgconn.Config { + return pgconn.Config{ + Host: utils.Config.Hostname, + Port: port, + User: "postgres", + Password: utils.Config.Db.Password, + Database: "postgres", + } } // 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) { +// caller can run the differ itself. On error the shadow container is removed. +func PrepareShadowSource(ctx context.Context, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (ShadowSource, error) { shadow, err := CreateShadowDatabase(ctx, utils.Config.Db.ShadowPort) if err != nil { return ShadowSource{}, err @@ -58,36 +183,8 @@ func PrepareShadowSource(ctx context.Context, schema []string, targetLocal bool, Password: utils.Config.Db.Password, Database: "postgres", } - var targetOverride *pgconn.Config - if targetLocal { - declared, err := loadDeclaredSchemas(fsys) - if err != nil { - return ShadowSource{}, err - } - if len(declared) > 0 { - override := shadowConfig - override.Database = "contrib_regression" - if 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 - } - } - targetOverride = &override - } - } ok = true - return ShadowSource{Container: shadow, Source: shadowConfig, TargetOverride: targetOverride}, nil + return ShadowSource{Container: shadow, Source: shadowConfig}, nil } // PrepareRawShadow provisions a bare shadow database (created + healthy, with no diff --git a/apps/cli-go/internal/db/diff/shadow_test.go b/apps/cli-go/internal/db/diff/shadow_test.go new file mode 100644 index 0000000000..9d293c70dc --- /dev/null +++ b/apps/cli-go/internal/db/diff/shadow_test.go @@ -0,0 +1,181 @@ +package diff + +import ( + "context" + "errors" + "testing" + "time" + + "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 TestPreparePgDeltaNextShadow(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + utils.Config.Hostname = "shadow-host" + utils.Config.Db.Password = "secret" + utils.Config.Db.HealthTimeout = 7 * time.Second + + ports := []int{6543, 7654} + var createdPorts []uint16 + var waitedContainers []string + var migratedPort uint16 + var setupPort uint16 + var removedContainers []string + dependencies := pgDeltaNextShadowDependencies{ + freePort: func() (int, error) { + port := ports[0] + ports = ports[1:] + return port, nil + }, + create: func(_ context.Context, port uint16) (string, error) { + createdPorts = append(createdPorts, port) + if port == 6543 { + return "migrations-container", nil + } + return "declarative-container", nil + }, + wait: func(_ context.Context, timeout time.Duration, containers ...string) error { + assert.Equal(t, 7*time.Second, timeout) + require.Len(t, containers, 1) + waitedContainers = append(waitedContainers, containers[0]) + return nil + }, + migrate: func(_ context.Context, container string, _ afero.Fs, options ...func(*pgx.ConnConfig)) error { + assert.Equal(t, "migrations-container", container) + config := &pgx.ConnConfig{} + for _, option := range options { + option(config) + } + migratedPort = config.Port + return nil + }, + setup: func(_ context.Context, container string, _ afero.Fs, options ...func(*pgx.ConnConfig)) error { + assert.Equal(t, "declarative-container", container) + config := &pgx.ConnConfig{} + for _, option := range options { + option(config) + } + setupPort = config.Port + return nil + }, + remove: func(container string) { removedContainers = append(removedContainers, container) }, + } + + result, err := preparePgDeltaNextShadow(context.Background(), afero.NewMemMapFs(), dependencies) + + require.NoError(t, err) + assert.Equal(t, []uint16{6543, 7654}, createdPorts) + assert.Equal(t, []string{"migrations-container", "declarative-container"}, waitedContainers) + assert.Equal(t, uint16(6543), migratedPort) + assert.Equal(t, uint16(7654), setupPort) + assert.Empty(t, removedContainers) + assert.Equal(t, "migrations-container", result.Migrations.Container) + assert.Equal(t, "declarative-container", result.Declarative.Container) + assert.Equal(t, pgDeltaNextShadowConfig(6543), result.Migrations.Config) + assert.Equal(t, pgDeltaNextShadowConfig(7654), result.Declarative.Config) + assert.Equal(t, "postgres", result.Migrations.Config.Database) + assert.Equal(t, "postgres", result.Declarative.Config.Database) +} + +func TestPreparePgDeltaNextShadowRemovesEveryCreatedContainerOnFailure(t *testing.T) { + wantErr := errors.New("provisioning failed") + tests := []struct { + name string + failAt string + firstID string + secondID string + wantRemoved []string + }{ + {name: "first port", failAt: "first-port"}, + {name: "first create without id", failAt: "first-create"}, + {name: "first create with id", failAt: "first-create", firstID: "migrations", wantRemoved: []string{"migrations"}}, + {name: "first health", failAt: "first-health", firstID: "migrations", wantRemoved: []string{"migrations"}}, + {name: "migrations", failAt: "migrate", firstID: "migrations", wantRemoved: []string{"migrations"}}, + {name: "second port", failAt: "second-port", firstID: "migrations", wantRemoved: []string{"migrations"}}, + {name: "second create without id", failAt: "second-create", firstID: "migrations", wantRemoved: []string{"migrations"}}, + {name: "second create with id", failAt: "second-create", firstID: "migrations", secondID: "declarative", wantRemoved: []string{"migrations", "declarative"}}, + {name: "second health", failAt: "second-health", firstID: "migrations", secondID: "declarative", wantRemoved: []string{"migrations", "declarative"}}, + {name: "declarative setup", failAt: "setup", firstID: "migrations", secondID: "declarative", wantRemoved: []string{"migrations", "declarative"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + portCalls := 0 + createCalls := 0 + var removed []string + dependencies := pgDeltaNextShadowDependencies{ + freePort: func() (int, error) { + portCalls++ + if (portCalls == 1 && tt.failAt == "first-port") || (portCalls == 2 && tt.failAt == "second-port") { + return 0, wantErr + } + return 6000 + portCalls, nil + }, + create: func(context.Context, uint16) (string, error) { + createCalls++ + if createCalls == 1 { + if tt.failAt == "first-create" { + return tt.firstID, wantErr + } + return tt.firstID, nil + } + if tt.failAt == "second-create" { + return tt.secondID, wantErr + } + return tt.secondID, nil + }, + wait: func(_ context.Context, _ time.Duration, containers ...string) error { + if (containers[0] == tt.firstID && tt.failAt == "first-health") || (containers[0] == tt.secondID && tt.failAt == "second-health") { + return wantErr + } + return nil + }, + migrate: func(context.Context, string, afero.Fs, ...func(*pgx.ConnConfig)) error { + if tt.failAt == "migrate" { + return wantErr + } + return nil + }, + setup: func(context.Context, string, afero.Fs, ...func(*pgx.ConnConfig)) error { + if tt.failAt == "setup" { + return wantErr + } + return nil + }, + remove: func(container string) { removed = append(removed, container) }, + } + + result, err := preparePgDeltaNextShadow(context.Background(), afero.NewMemMapFs(), dependencies) + + assert.ErrorIs(t, err, wantErr) + assert.Empty(t, result) + assert.Equal(t, tt.wantRemoved, removed) + }) + } +} + +func TestAllocatePgDeltaNextPort(t *testing.T) { + t.Run("retries a duplicate port", func(t *testing.T) { + ports := []int{6543, 6544} + port, err := allocatePgDeltaNextPort(func() (int, error) { + result := ports[0] + ports = ports[1:] + return result, nil + }, 6543) + + require.NoError(t, err) + assert.Equal(t, uint16(6544), port) + }) + + for _, port := range []int{-1, 0, 65536} { + t.Run("rejects invalid port", func(t *testing.T) { + _, err := allocatePgDeltaNextPort(func() (int, error) { return port, nil }, 0) + assert.ErrorContains(t, err, "outside the valid range") + }) + } +} diff --git a/apps/cli-go/internal/db/push/push_test.go b/apps/cli-go/internal/db/push/push_test.go index b5d0d2c7f3..0357a1875d 100644 --- a/apps/cli-go/internal/db/push/push_test.go +++ b/apps/cli-go/internal/db/push/push_test.go @@ -101,8 +101,12 @@ func TestMigrationPush(t *testing.T) { helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(migration.INSERT_MIGRATION_VERSION, "0", "test", nil). - ReplyError(pgerrcode.NotNullViolation, `null value in column "version" of relation "schema_migrations"`) + ReplyError(pgerrcode.NotNullViolation, `null value in column "version" of relation "schema_migrations"`). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := Run(context.Background(), false, false, false, false, dbConfig, fsys, conn.Intercept) // Check error @@ -125,8 +129,12 @@ func TestPushAll(t *testing.T) { helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(migration.INSERT_MIGRATION_VERSION, "0", "test", nil). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") // Run test err := Run(context.Background(), false, false, true, true, dbConfig, fsys, conn.Intercept) // Check error @@ -185,8 +193,12 @@ func TestPushAll(t *testing.T) { helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(migration.INSERT_MIGRATION_VERSION, "0", "test", nil). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") helper.MockSeedHistory(conn). Query(migration.UPSERT_SEED_FILE, seedPath, digest). ReplyError(pgerrcode.NotNullViolation, `null value in column "hash" of relation "seed_files"`) diff --git a/apps/cli-go/internal/db/reset/reset.go b/apps/cli-go/internal/db/reset/reset.go index 7ac3f8e4f1..e774d56c83 100644 --- a/apps/cli-go/internal/db/reset/reset.go +++ b/apps/cli-go/internal/db/reset/reset.go @@ -150,6 +150,9 @@ func initDatabase(ctx context.Context, options ...func(*pgx.ConnConfig)) error { if err := start.InitSchema14(ctx, conn); err != nil { return err } + if err := start.ApplyDatabaseWebhooks(ctx, conn); err != nil { + return err + } return start.ApplyApiPrivileges(ctx, conn) } diff --git a/apps/cli-go/internal/db/reset/reset_test.go b/apps/cli-go/internal/db/reset/reset_test.go index 5d672ec15f..a95377246d 100644 --- a/apps/cli-go/internal/db/reset/reset_test.go +++ b/apps/cli-go/internal/db/reset/reset_test.go @@ -173,8 +173,12 @@ func TestInitDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(utils.InitialSchemaPg14Sql). + Reply("CREATE SCHEMA"). + Query("COMMIT"). + Reply("COMMIT") helper.MockApiPrivilegesRevoke(conn) // Run test assert.NoError(t, initDatabase(context.Background(), conn.Intercept)) @@ -194,8 +198,12 @@ func TestInitDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(utils.InitialSchemaPg14Sql). - ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`) + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(utils.InitialSchemaPg14Sql). + ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := initDatabase(context.Background(), conn.Intercept) // Check error @@ -209,14 +217,20 @@ func TestRecreateDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). Reply("ALTER DATABASE"). Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). Reply("ALTER DATABASE"). Query(TERMINATE_BACKENDS). Reply("SELECT 1"). + Query("COMMIT"). + Reply("COMMIT"). Query(COUNT_REPLICATION_SLOTS). Reply("SELECT 1", []any{0}). + Query("BEGIN"). + Reply("BEGIN"). Query("DROP DATABASE IF EXISTS postgres WITH (FORCE)"). Reply("DROP DATABASE"). Query("CREATE DATABASE postgres WITH OWNER postgres"). @@ -224,7 +238,9 @@ func TestRecreateDatabase(t *testing.T) { Query("DROP DATABASE IF EXISTS _supabase WITH (FORCE)"). Reply("DROP DATABASE"). Query("CREATE DATABASE _supabase WITH OWNER postgres"). - Reply("CREATE DATABASE") + Reply("CREATE DATABASE"). + Query("COMMIT"). + Reply("COMMIT") // Run test assert.NoError(t, recreateDatabase(context.Background(), conn.Intercept)) }) @@ -239,11 +255,15 @@ func TestRecreateDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). Reply("ALTER DATABASE"). Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). ReplyError(pgerrcode.InvalidCatalogName, `database "_supabase" does not exist`). Query(TERMINATE_BACKENDS). + Query("ROLLBACK"). + Reply("ROLLBACK"). Query(COUNT_REPLICATION_SLOTS). ReplyError(pgerrcode.UndefinedTable, `relation "pg_replication_slots" does not exist`) // Run test @@ -257,10 +277,14 @@ func TestRecreateDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). ReplyError(pgerrcode.InvalidParameterValue, `cannot disallow connections for current database`). Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). - Query(TERMINATE_BACKENDS) + Query(TERMINATE_BACKENDS). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := recreateDatabase(context.Background(), conn.Intercept) // Check error @@ -272,21 +296,29 @@ func TestRecreateDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). Reply("ALTER DATABASE"). Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). Reply("ALTER DATABASE"). Query(TERMINATE_BACKENDS). Reply("SELECT 1"). + Query("COMMIT"). + Reply("COMMIT"). Query(COUNT_REPLICATION_SLOTS). Reply("SELECT 1", []any{0}). + Query("BEGIN"). + Reply("BEGIN"). Query("DROP DATABASE IF EXISTS postgres WITH (FORCE)"). ReplyError(pgerrcode.ObjectInUse, `database "postgres" is used by an active logical replication slot`). Query("CREATE DATABASE postgres WITH OWNER postgres"). Query("DROP DATABASE IF EXISTS _supabase WITH (FORCE)"). Reply("DROP DATABASE"). Query("CREATE DATABASE _supabase WITH OWNER postgres"). - Reply("CREATE DATABASE") + Reply("CREATE DATABASE"). + Query("ROLLBACK"). + Reply("ROLLBACK") err := recreateDatabase(context.Background(), conn.Intercept) // Check error assert.ErrorContains(t, err, `ERROR: database "postgres" is used by an active logical replication slot (SQLSTATE 55006)`) diff --git a/apps/cli-go/internal/db/start/start.go b/apps/cli-go/internal/db/start/start.go index 6cd411791c..ed88f03b81 100644 --- a/apps/cli-go/internal/db/start/start.go +++ b/apps/cli-go/internal/db/start/start.go @@ -380,10 +380,50 @@ func SetupLocalDatabase(ctx context.Context, version string, fsys afero.Fs, w io return nil } -func SetupDatabase(ctx context.Context, conn *pgx.Conn, host string, w io.Writer, fsys afero.Fs) error { +type setupDatabaseOptions struct { + activateUserExtensions bool + legacyPgNetBaseline bool +} + +// SetupDatabaseOption customises platform setup for specialised database +// provisioning paths while keeping the ordinary local setup defaults. +type SetupDatabaseOption func(*setupDatabaseOptions) + +// WithoutUserExtensionActivation keeps platform capabilities such as the +// webhook helpers and event trigger, but leaves activation of user-managed +// extensions such as pg_net to migrations or declarative SQL. +func WithoutUserExtensionActivation() SetupDatabaseOption { + return func(options *setupDatabaseOptions) { + options.activateUserExtensions = false + } +} + +// WithLegacyPgNetBaseline preserves the historical shadow-database baseline, +// where pg_net was installed independently of the Database Webhooks setting. +// New provisioning paths should use the config-gated default instead. +func WithLegacyPgNetBaseline() SetupDatabaseOption { + return func(options *setupDatabaseOptions) { + options.legacyPgNetBaseline = true + } +} + +func SetupDatabase(ctx context.Context, conn *pgx.Conn, host string, w io.Writer, fsys afero.Fs, opts ...SetupDatabaseOption) error { + options := setupDatabaseOptions{activateUserExtensions: true} + for _, option := range opts { + option(&options) + } if err := initSchema(ctx, conn, host, w); err != nil { return err } + if options.legacyPgNetBaseline { + if err := enablePgNet(ctx, conn); err != nil { + return err + } + } else if options.activateUserExtensions { + if err := ApplyDatabaseWebhooks(ctx, conn); err != nil { + return err + } + } if err := ApplyApiPrivileges(ctx, conn); err != nil { return err } @@ -398,6 +438,27 @@ func SetupDatabase(ctx context.Context, conn *pgx.Conn, host string, w io.Writer return err } +const EnableDatabaseWebhooksSql = `create extension if not exists pg_net schema extensions;` + +// ApplyDatabaseWebhooks installs pg_net only when the Database Webhooks feature is enabled. +// The platform webhook helpers and event trigger are part of the baseline regardless, so an +// explicit CREATE EXTENSION in user migrations/declarative SQL remains supported when disabled. +func ApplyDatabaseWebhooks(ctx context.Context, conn *pgx.Conn) error { + webhooks := utils.Config.Experimental.Webhooks + if webhooks == nil || !webhooks.Enabled { + return nil + } + return enablePgNet(ctx, conn) +} + +func enablePgNet(ctx context.Context, conn *pgx.Conn) error { + file, err := migration.NewMigrationFromReader(strings.NewReader(EnableDatabaseWebhooksSql)) + if err != nil { + return err + } + return file.ExecBatch(ctx, conn) +} + // RevokeDefaultDataApiPrivilegesSql matches the SQL that Studio runs at cloud project creation // when the "Default privileges for new entities" toggle is off. It removes the default GRANTs // applied by the initial schema so newly-created entities in `public` owned by `postgres` are diff --git a/apps/cli-go/internal/db/start/start_test.go b/apps/cli-go/internal/db/start/start_test.go index 805691b8ec..04de8b93f6 100644 --- a/apps/cli-go/internal/db/start/start_test.go +++ b/apps/cli-go/internal/db/start/start_test.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "testing" + stdfs "testing/fstest" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" @@ -20,9 +21,19 @@ import ( "github.com/supabase/cli/internal/testing/helper" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/cast" + "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/pgtest" ) +func mockTransactionalStatement(conn *pgtest.MockConn, statement, reply string) { + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(statement). + Reply(reply). + Query("COMMIT"). + Reply("COMMIT") +} + func TestInitBranch(t *testing.T) { t.Run("throws error on permission denied", func(t *testing.T) { // Setup in-memory fs @@ -88,9 +99,8 @@ func TestStartDatabase(t *testing.T) { // the default Data API GRANTs before seeding roles. conn := pgtest.NewConn() defer conn.Close(t) - helper.MockApiPrivilegesRevoke(conn). - Query(roles). - Reply("CREATE ROLE") + helper.MockApiPrivilegesRevoke(conn) + mockTransactionalStatement(conn, roles, "CREATE ROLE") // Run test err := StartDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) // Check error @@ -251,13 +261,10 @@ func TestSetupDatabase(t *testing.T) { // default Data API GRANTs (the May 30 2026 flip) between initial schema and roles.sql conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(utils.GlobalsSql). - Reply("CREATE SCHEMA"). - Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") - helper.MockApiPrivilegesRevoke(conn). - Query(roles). - Reply("CREATE ROLE") + mockTransactionalStatement(conn, utils.GlobalsSql, "CREATE SCHEMA") + mockTransactionalStatement(conn, utils.InitialSchemaPg14Sql, "CREATE SCHEMA") + helper.MockApiPrivilegesRevoke(conn) + mockTransactionalStatement(conn, roles, "CREATE ROLE") // Run test err := SetupLocalDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) // Check error @@ -283,12 +290,9 @@ func TestSetupDatabase(t *testing.T) { // Setup mock postgres: explicit true opts into the legacy behaviour, so no revoke SQL conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(utils.GlobalsSql). - Reply("CREATE SCHEMA"). - Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA"). - Query(roles). - Reply("CREATE ROLE") + mockTransactionalStatement(conn, utils.GlobalsSql, "CREATE SCHEMA") + mockTransactionalStatement(conn, utils.InitialSchemaPg14Sql, "CREATE SCHEMA") + mockTransactionalStatement(conn, roles, "CREATE ROLE") // Run test err := SetupLocalDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) // Check error @@ -314,13 +318,10 @@ func TestSetupDatabase(t *testing.T) { // Setup mock postgres: the revoke SQL must execute between the initial schema and roles.sql conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(utils.GlobalsSql). - Reply("CREATE SCHEMA"). - Query(utils.InitialSchemaPg14Sql). - Reply("CREATE SCHEMA") - helper.MockApiPrivilegesRevoke(conn). - Query(roles). - Reply("CREATE ROLE") + mockTransactionalStatement(conn, utils.GlobalsSql, "CREATE SCHEMA") + mockTransactionalStatement(conn, utils.InitialSchemaPg14Sql, "CREATE SCHEMA") + helper.MockApiPrivilegesRevoke(conn) + mockTransactionalStatement(conn, roles, "CREATE ROLE") // Run test err := SetupLocalDatabase(context.Background(), "", fsys, io.Discard, conn.Intercept) // Check error @@ -379,6 +380,84 @@ func TestSetupDatabase(t *testing.T) { assert.Empty(t, apitest.ListUnmatchedRequests()) }) } + +func TestApplyDatabaseWebhooks(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + + t.Run("does not install pg_net merely because Edge Runtime is enabled", func(t *testing.T) { + cfg := config.NewConfig() + cfg.EdgeRuntime.Enabled = true + utils.Config = cfg + conn := pgtest.NewConn() + defer conn.Close(t) + + require.NoError(t, ApplyDatabaseWebhooks(context.Background(), conn.MockClient(t))) + }) + + t.Run("installs pg_net when Database Webhooks is enabled even without Edge Runtime", func(t *testing.T) { + cfg := config.NewConfig() + require.NoError(t, cfg.Load("config.toml", stdfs.MapFS{ + "config.toml": &stdfs.MapFile{Data: []byte("[experimental.webhooks]\nenabled = true\n")}, + })) + cfg.EdgeRuntime.Enabled = false + utils.Config = cfg + conn := pgtest.NewConn() + defer conn.Close(t) + mockTransactionalStatement(conn, "create extension if not exists pg_net schema extensions", "CREATE EXTENSION") + + require.NoError(t, ApplyDatabaseWebhooks(context.Background(), conn.MockClient(t))) + }) +} + +func TestSetupDatabaseUserExtensionActivation(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + + newWebhookConfig := func(t *testing.T) { + t.Helper() + cfg := config.NewConfig() + require.NoError(t, cfg.Load("config.toml", stdfs.MapFS{ + "config.toml": &stdfs.MapFile{Data: []byte("[experimental.webhooks]\nenabled = true\n")}, + })) + cfg.Db.MajorVersion = 17 + cfg.Realtime.Enabled = false + cfg.Storage.Enabled = false + cfg.Auth.Enabled = false + utils.Config = cfg + } + + t.Run("installs pg_net by default when Database Webhooks is enabled", func(t *testing.T) { + newWebhookConfig(t) + conn := pgtest.NewConn() + defer conn.Close(t) + mockTransactionalStatement(conn, "create extension if not exists pg_net schema extensions", "CREATE EXTENSION") + helper.MockApiPrivilegesRevoke(conn) + + err := SetupDatabase(context.Background(), conn.MockClient(t), "postgres-host", io.Discard, afero.NewMemMapFs()) + + require.NoError(t, err) + }) + + t.Run("can leave user extension activation to declarative SQL", func(t *testing.T) { + newWebhookConfig(t) + conn := pgtest.NewConn() + defer conn.Close(t) + helper.MockApiPrivilegesRevoke(conn) + + err := SetupDatabase( + context.Background(), + conn.MockClient(t), + "postgres-host", + io.Discard, + afero.NewMemMapFs(), + WithoutUserExtensionActivation(), + ) + + require.NoError(t, err) + }) +} + func TestStartDatabaseWithCustomSettings(t *testing.T) { t.Run("starts database with custom MaxConnections", func(t *testing.T) { // Setup diff --git a/apps/cli-go/internal/db/start/templates/webhook.sql b/apps/cli-go/internal/db/start/templates/webhook.sql index 52cd097473..6a895256cc 100644 --- a/apps/cli-go/internal/db/start/templates/webhook.sql +++ b/apps/cli-go/internal/db/start/templates/webhook.sql @@ -1,8 +1,5 @@ BEGIN; --- Create pg_net extension -CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; - -- Create supabase_functions schema CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; diff --git a/apps/cli-go/internal/migration/apply/apply_test.go b/apps/cli-go/internal/migration/apply/apply_test.go index f45891cfb4..0f931a7cb0 100644 --- a/apps/cli-go/internal/migration/apply/apply_test.go +++ b/apps/cli-go/internal/migration/apply/apply_test.go @@ -29,10 +29,14 @@ func TestMigrateDatabase(t *testing.T) { helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(sql). Reply("CREATE SCHEMA"). Query(migration.INSERT_MIGRATION_VERSION, "0", "test", []string{sql}). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") // Run test err := MigrateAndSeed(context.Background(), "", conn.MockClient(t), fsys) // Check error @@ -54,10 +58,14 @@ func TestMigrateDatabase(t *testing.T) { helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(sql). Reply("CREATE SCHEMA"). Query(migration.INSERT_MIGRATION_VERSION, "0", "test", []string{sql}). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") utils.Config.Db.Seed.Enabled = false // Run test err := MigrateAndSeed(context.Background(), "", conn.MockClient(t), fsys) diff --git a/apps/cli-go/internal/migration/down/down_test.go b/apps/cli-go/internal/migration/down/down_test.go index 89d9ff66d4..f55d83ee73 100644 --- a/apps/cli-go/internal/migration/down/down_test.go +++ b/apps/cli-go/internal/migration/down/down_test.go @@ -85,13 +85,21 @@ func TestResetRemote(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(migration.DropObjects). - Reply("INSERT 0") + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(migration.DropObjects). + Reply("INSERT 0"). + Query("COMMIT"). + Reply("COMMIT") helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(migration.INSERT_MIGRATION_VERSION, "0", "schema", nil). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") // Run test err := ResetAll(context.Background(), "", conn.MockClient(t), fsys) // Check error @@ -109,13 +117,21 @@ func TestResetRemote(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(migration.DropObjects). - Reply("INSERT 0") + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(migration.DropObjects). + Reply("INSERT 0"). + Query("COMMIT"). + Reply("COMMIT") helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(migration.INSERT_MIGRATION_VERSION, "0", "schema", nil). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") utils.Config.Db.Seed.Enabled = false // Run test err := ResetAll(context.Background(), "", conn.MockClient(t), fsys) @@ -129,8 +145,12 @@ func TestResetRemote(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(migration.DropObjects). - ReplyError(pgerrcode.InsufficientPrivilege, "permission denied for relation supabase_migrations") + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(migration.DropObjects). + ReplyError(pgerrcode.InsufficientPrivilege, "permission denied for relation supabase_migrations"). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := ResetAll(context.Background(), "", conn.MockClient(t), fsys) // Check error diff --git a/apps/cli-go/internal/migration/squash/squash.go b/apps/cli-go/internal/migration/squash/squash.go index afc52c687e..e7d9589642 100644 --- a/apps/cli-go/internal/migration/squash/squash.go +++ b/apps/cli-go/internal/migration/squash/squash.go @@ -93,7 +93,7 @@ func squashMigrations(ctx context.Context, migrations []string, fsys afero.Fs, o return err } defer conn.Close(context.Background()) - if err := start.SetupDatabase(ctx, conn, shadow[:12], os.Stderr, fsys); err != nil { + if err := start.SetupDatabase(ctx, conn, shadow[:12], os.Stderr, fsys, start.WithLegacyPgNetBaseline()); err != nil { return err } // Assuming entities in managed schemas are not altered, we can simply diff the dumps before and after migrations. diff --git a/apps/cli-go/internal/migration/squash/squash_test.go b/apps/cli-go/internal/migration/squash/squash_test.go index 91d13066a5..b694ed9a7d 100644 --- a/apps/cli-go/internal/migration/squash/squash_test.go +++ b/apps/cli-go/internal/migration/squash/squash_test.go @@ -84,18 +84,32 @@ func TestSquashCommand(t *testing.T) { // revokes the default Data API GRANTs before applying migration history. conn := pgtest.NewConn() defer conn.Close(t) + conn.Query("BEGIN"). + Reply("BEGIN"). + Query("create extension if not exists pg_net schema extensions"). + Reply("CREATE EXTENSION"). + Query("COMMIT"). + Reply("COMMIT") helper.MockApiPrivilegesRevoke(conn) helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(sql). Reply("CREATE SCHEMA"). Query(migration.INSERT_MIGRATION_VERSION, "0", "init", []string{sql}). Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT"). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(migration.INSERT_MIGRATION_VERSION, "1", "target", nil). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") // Run test err := Run(context.Background(), "", pgconn.Config{ Host: "127.0.0.1", @@ -313,14 +327,24 @@ func TestSquashMigrations(t *testing.T) { // revokes the default Data API GRANTs before applying migration history. conn := pgtest.NewConn() defer conn.Close(t) + conn.Query("BEGIN"). + Reply("BEGIN"). + Query("create extension if not exists pg_net schema extensions"). + Reply("CREATE EXTENSION"). + Query("COMMIT"). + Reply("COMMIT") helper.MockApiPrivilegesRevoke(conn) helper.MockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(sql). Reply("CREATE SCHEMA"). Query(migration.INSERT_MIGRATION_VERSION, "0", "init", []string{sql}). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") // Run test err := squashMigrations(context.Background(), []string{path}, afero.NewReadOnlyFs(fsys), conn.Intercept) // Check error diff --git a/apps/cli-go/internal/testing/helper/history.go b/apps/cli-go/internal/testing/helper/history.go index 95c846b7ad..594bda9271 100644 --- a/apps/cli-go/internal/testing/helper/history.go +++ b/apps/cli-go/internal/testing/helper/history.go @@ -6,7 +6,9 @@ import ( ) func MockMigrationHistory(conn *pgtest.MockConn) *pgtest.MockConn { - conn.Query(migration.SET_LOCK_TIMEOUT). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(migration.SET_LOCK_TIMEOUT). Query(migration.CREATE_VERSION_SCHEMA). Reply("CREATE SCHEMA"). Query(migration.CREATE_VERSION_TABLE). @@ -14,15 +16,21 @@ func MockMigrationHistory(conn *pgtest.MockConn) *pgtest.MockConn { Query(migration.ADD_STATEMENTS_COLUMN). Reply("ALTER TABLE"). Query(migration.ADD_NAME_COLUMN). - Reply("ALTER TABLE") + Reply("ALTER TABLE"). + Query("COMMIT"). + Reply("COMMIT") return conn } func MockSeedHistory(conn *pgtest.MockConn) *pgtest.MockConn { - conn.Query(migration.SET_LOCK_TIMEOUT). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(migration.SET_LOCK_TIMEOUT). Query(migration.CREATE_VERSION_SCHEMA). Reply("CREATE SCHEMA"). Query(migration.CREATE_SEED_TABLE). - Reply("CREATE TABLE") + Reply("CREATE TABLE"). + Query("COMMIT"). + Reply("COMMIT") return conn } diff --git a/apps/cli-go/internal/testing/helper/privileges.go b/apps/cli-go/internal/testing/helper/privileges.go index 4dcca23f58..7f043f81dc 100644 --- a/apps/cli-go/internal/testing/helper/privileges.go +++ b/apps/cli-go/internal/testing/helper/privileges.go @@ -9,11 +9,15 @@ import "github.com/supabase/cli/pkg/pgtest" // than imported from the start package to avoid an import cycle with that package's own // internal (package start) tests. func MockApiPrivilegesRevoke(conn *pgtest.MockConn) *pgtest.MockConn { - conn.Query("alter default privileges for role postgres in schema public\n revoke select, insert, update, delete on tables from anon, authenticated, service_role"). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query("alter default privileges for role postgres in schema public\n revoke select, insert, update, delete on tables from anon, authenticated, service_role"). Reply("ALTER DEFAULT PRIVILEGES"). Query("alter default privileges for role postgres in schema public\n revoke usage, select on sequences from anon, authenticated, service_role"). Reply("ALTER DEFAULT PRIVILEGES"). Query("alter default privileges for role postgres in schema public\n revoke execute on functions from anon, authenticated, service_role"). - Reply("ALTER DEFAULT PRIVILEGES") + Reply("ALTER DEFAULT PRIVILEGES"). + Query("COMMIT"). + Reply("COMMIT") return conn } diff --git a/apps/cli-go/internal/utils/edgeruntime.go b/apps/cli-go/internal/utils/edgeruntime.go index 8e54afa628..da3c3af44f 100644 --- a/apps/cli-go/internal/utils/edgeruntime.go +++ b/apps/cli-go/internal/utils/edgeruntime.go @@ -61,8 +61,10 @@ func WithExtraEnv(entries ...string) EdgeRuntimeOption { } } -// getFreeHostPort asks the OS for an unused TCP port on the host. -func getFreeHostPort() (int, error) { +// GetFreeHostPort asks the OS for an unused TCP port on the host. The listener +// is closed before the port is returned, so callers that need more than one +// port should bind each one before requesting the next. +func GetFreeHostPort() (int, error) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return 0, errors.Errorf("failed to allocate free port: %w", err) @@ -80,7 +82,7 @@ func getFreeHostPort() (int, error) { func EdgeRuntimeStartCmd() []string { cmd := []string{"edge-runtime", "start", "--main-service=."} // Skip the flag on the rare allocation failure to preserve prior behavior. - if port, err := getFreeHostPort(); err == nil { + if port, err := GetFreeHostPort(); err == nil { cmd = append(cmd, fmt.Sprintf("--port=%d", port)) } return cmd diff --git a/apps/cli-go/internal/utils/templates/initial_schemas/14.sql b/apps/cli-go/internal/utils/templates/initial_schemas/14.sql index bef44153ec..b0397cbb0b 100644 --- a/apps/cli-go/internal/utils/templates/initial_schemas/14.sql +++ b/apps/cli-go/internal/utils/templates/initial_schemas/14.sql @@ -70,20 +70,6 @@ CREATE SCHEMA IF NOT EXISTS graphql_public; ALTER SCHEMA graphql_public OWNER TO supabase_admin; --- --- Name: pg_net; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions; - - --- --- Name: EXTENSION pg_net; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pg_net IS 'Async HTTP'; - - -- -- Name: pgbouncer; Type: SCHEMA; Schema: -; Owner: pgbouncer -- diff --git a/apps/cli-go/legacy/branch/switch_/switch__test.go b/apps/cli-go/legacy/branch/switch_/switch__test.go index f0e51d521e..4ecdc9b5cc 100644 --- a/apps/cli-go/legacy/branch/switch_/switch__test.go +++ b/apps/cli-go/legacy/branch/switch_/switch__test.go @@ -41,12 +41,16 @@ func TestSwitchCommand(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). Reply("ALTER DATABASE"). Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). Reply("ALTER DATABASE"). Query(reset.TERMINATE_BACKENDS). Reply("SELECT 1"). + Query("COMMIT"). + Reply("COMMIT"). Query(reset.COUNT_REPLICATION_SLOTS). Reply("SELECT 1", []any{0}). Query("ALTER DATABASE postgres RENAME TO main;"). @@ -212,10 +216,14 @@ func TestSwitchDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). ReplyError(pgerrcode.InvalidParameterValue, `cannot disallow connections for current database`). Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). - Query(reset.TERMINATE_BACKENDS) + Query(reset.TERMINATE_BACKENDS). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := switchDatabase(context.Background(), "main", "target", conn.Intercept) // Check error @@ -230,12 +238,16 @@ func TestSwitchDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). Reply("ALTER DATABASE"). Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). Reply("ALTER DATABASE"). Query(reset.TERMINATE_BACKENDS). Reply("SELECT 1"). + Query("COMMIT"). + Reply("COMMIT"). Query(reset.COUNT_REPLICATION_SLOTS). Reply("SELECT 1", []any{0}). Query("ALTER DATABASE postgres RENAME TO main;"). @@ -260,12 +272,16 @@ func TestSwitchDatabase(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query("ALTER DATABASE postgres ALLOW_CONNECTIONS false"). Reply("ALTER DATABASE"). Query("ALTER DATABASE _supabase ALLOW_CONNECTIONS false"). Reply("ALTER DATABASE"). Query(reset.TERMINATE_BACKENDS). Reply("SELECT 1"). + Query("COMMIT"). + Reply("COMMIT"). Query(reset.COUNT_REPLICATION_SLOTS). Reply("SELECT 1", []any{0}). Query("ALTER DATABASE postgres RENAME TO main;"). diff --git a/apps/cli-go/pkg/config/templates/config.toml b/apps/cli-go/pkg/config/templates/config.toml index 98e034f8d8..ec327bc63e 100644 --- a/apps/cli-go/pkg/config/templates/config.toml +++ b/apps/cli-go/pkg/config/templates/config.toml @@ -412,3 +412,4 @@ enabled = {{ .Experimental.PgDeltaInitEnabled }} # declarative_schema_path = "./database" # JSON string passed through to pg-delta SQL formatting. # format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" +# Set to "null" to disable formatting while retaining plan compaction. diff --git a/apps/cli-go/pkg/migration/apply_test.go b/apps/cli-go/pkg/migration/apply_test.go index e6df97721f..3b3340524a 100644 --- a/apps/cli-go/pkg/migration/apply_test.go +++ b/apps/cli-go/pkg/migration/apply_test.go @@ -110,10 +110,14 @@ func TestApplyMigrations(t *testing.T) { mockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(testSchema). Reply("CREATE SCHEMA"). Query(INSERT_MIGRATION_VERSION, "0", "schema", []string{testSchema}). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") // Run test err := ApplyMigrations(context.Background(), pending, conn.MockClient(t), testMigrations) // Check error @@ -126,13 +130,17 @@ func TestApplyMigrations(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(SET_LOCK_TIMEOUT). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(SET_LOCK_TIMEOUT). Query(CREATE_VERSION_SCHEMA). Reply("CREATE SCHEMA"). Query(CREATE_VERSION_TABLE). ReplyError(pgerrcode.InsufficientPrivilege, "permission denied for relation supabase_migrations"). Query(ADD_STATEMENTS_COLUMN). - Query(ADD_NAME_COLUMN) + Query(ADD_NAME_COLUMN). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := ApplyMigrations(context.Background(), pending, conn.MockClient(t), fsys) // Check error @@ -161,10 +169,14 @@ func TestApplyMigrations(t *testing.T) { mockMigrationHistory(conn). Query("RESET ALL"). Reply("RESET"). + Query("BEGIN"). + Reply("BEGIN"). Query(testSchema). ReplyError(pgerrcode.UndefinedTable, `relation "supabase_migrations.schema_migrations" does not exist`). Query(INSERT_MIGRATION_VERSION, "0", "schema", []string{testSchema}). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := ApplyMigrations(context.Background(), pending, conn.MockClient(t), testMigrations) // Check error @@ -187,7 +199,9 @@ func TestApplyMigrations(t *testing.T) { } func mockMigrationHistory(conn *pgtest.MockConn) *pgtest.MockConn { - conn.Query(SET_LOCK_TIMEOUT). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(SET_LOCK_TIMEOUT). Query(CREATE_VERSION_SCHEMA). Reply("CREATE SCHEMA"). Query(CREATE_VERSION_TABLE). @@ -195,6 +209,8 @@ func mockMigrationHistory(conn *pgtest.MockConn) *pgtest.MockConn { Query(ADD_STATEMENTS_COLUMN). Reply("ALTER TABLE"). Query(ADD_NAME_COLUMN). - Reply("ALTER TABLE") + Reply("ALTER TABLE"). + Query("COMMIT"). + Reply("COMMIT") return conn } diff --git a/apps/cli-go/pkg/migration/drop_test.go b/apps/cli-go/pkg/migration/drop_test.go index 644fb69be0..388ca937b8 100644 --- a/apps/cli-go/pkg/migration/drop_test.go +++ b/apps/cli-go/pkg/migration/drop_test.go @@ -14,8 +14,12 @@ func TestDropSchemas(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(DropObjects). - Reply("INSERT 0") + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(DropObjects). + Reply("INSERT 0"). + Query("COMMIT"). + Reply("COMMIT") // Run test err := DropUserSchemas(context.Background(), conn.MockClient(t)) // Check error @@ -26,8 +30,12 @@ func TestDropSchemas(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(DropObjects). - ReplyError(pgerrcode.InsufficientPrivilege, "permission denied for relation supabase_migrations") + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(DropObjects). + ReplyError(pgerrcode.InsufficientPrivilege, "permission denied for relation supabase_migrations"). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := DropUserSchemas(context.Background(), conn.MockClient(t)) // Check error diff --git a/apps/cli-go/pkg/migration/file.go b/apps/cli-go/pkg/migration/file.go index d7526ac4dd..8701ae4aac 100644 --- a/apps/cli-go/pkg/migration/file.go +++ b/apps/cli-go/pkg/migration/file.go @@ -27,13 +27,14 @@ type MigrationFile struct { } var ( - migrateFilePattern = regexp.MustCompile(`^([0-9]+)_(.*)\.sql$`) - typeNamePattern = regexp.MustCompile(`type "([^"]+)" does not exist`) - createIndexPattern = regexp.MustCompile(`^CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY(\s|\z)`) - reindexPattern = regexp.MustCompile(`^REINDEX(\s|\().*\sCONCURRENTLY(\s|\z)`) - vacuumPattern = regexp.MustCompile(`^VACUUM(\s|\(|\z)`) - alterSystemPattern = regexp.MustCompile(`^ALTER\s+SYSTEM(\s|\z)`) - clusterPattern = regexp.MustCompile(`^CLUSTER(\s|\z)`) + migrateFilePattern = regexp.MustCompile(`^([0-9]+)_(.*)\.sql$`) + typeNamePattern = regexp.MustCompile(`type "([^"]+)" does not exist`) + createIndexPattern = regexp.MustCompile(`^CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY(\s|\z)`) + reindexPattern = regexp.MustCompile(`^REINDEX(\s|\().*\sCONCURRENTLY(\s|\z)`) + vacuumPattern = regexp.MustCompile(`^VACUUM(\s|\(|\z)`) + alterSystemPattern = regexp.MustCompile(`^ALTER\s+SYSTEM(\s|\z)`) + clusterPattern = regexp.MustCompile(`^CLUSTER(\s|\z)`) + transactionControlPattern = regexp.MustCompile(`^(BEGIN|START\s+TRANSACTION|COMMIT|END|ROLLBACK|ABORT|PREPARE\s+TRANSACTION)(\s|\z)`) ) func NewMigrationFromFile(path string, fsys fs.FS) (*MigrationFile, error) { @@ -86,6 +87,14 @@ func isPipelineIncompatible(sql string) bool { clusterPattern.MatchString(upper) } +// hasTransactionControl reports whether a statement controls the transaction +// boundary itself. Files containing these statements must be executed exactly as +// authored: automatically adding BEGIN/COMMIT would nest or otherwise change the +// user's transaction semantics. +func hasTransactionControl(sql string) bool { + return transactionControlPattern.MatchString(strings.ToUpper(trimLeadingSQLComments(sql))) +} + func trimLeadingSQLComments(sql string) string { trimmed := strings.TrimLeftFunc(sql, func(r rune) bool { return r == '\ufeff' || r == ' ' || r == '\t' || r == '\n' || r == '\r' @@ -145,22 +154,67 @@ func (m *MigrationFile) ExecBatch(ctx context.Context, conn *pgx.Conn) error { return errors.Errorf("%w\n%s", err, strings.Join(msg, "\n")) } - flushBatch := func() error { + flushBatch := func(transactional, rollbackOnError bool) error { if batchSize == 0 { return nil } + if transactional { + if _, err := conn.Exec(ctx, "BEGIN"); err != nil { + return errors.Errorf("failed to begin migration transaction: %w", err) + } + } if result, err := conn.PgConn().ExecBatch(ctx, batch).ReadAll(); err != nil { + if rollbackOnError { + _, _ = conn.Exec(ctx, "ROLLBACK") + } return formatError(err, executed+len(result)) } + if transactional { + if _, err := conn.Exec(ctx, "COMMIT"); err != nil { + _, _ = conn.Exec(ctx, "ROLLBACK") + return errors.Errorf("failed to commit migration transaction: %w", err) + } + } executed += batchSize batch = &pgconn.Batch{} batchSize = 0 return nil } + // An authored transaction boundary owns the file's transaction semantics. Do + // not add automatic boundaries around any part of such a file. The history + // insert remains last, after every authored SQL statement has succeeded. + authoredTransaction := false + for _, statement := range m.Statements { + if hasTransactionControl(statement) { + authoredTransaction = true + break + } + } + if authoredTransaction { + for _, line := range m.Statements { + batch.ExecParams(line, nil, nil, nil, nil) + batchSize++ + } + if err := flushBatch(false, true); err != nil { + return err + } + // Queue history only after the authored transaction stream has completed + // successfully. In particular, never pipeline it after an authored COMMIT: + // a preceding failure can turn that COMMIT into ROLLBACK while a later + // history insert would otherwise execute in a fresh implicit transaction. + if len(m.Version) > 0 { + if err := m.insertVersionSQL(conn, batch); err != nil { + return err + } + batchSize++ + } + return flushBatch(false, true) + } + for _, line := range m.Statements { if isPipelineIncompatible(line) { - if err := flushBatch(); err != nil { + if err := flushBatch(true, true); err != nil { return err } if _, err := conn.PgConn().Exec(ctx, line).ReadAll(); err != nil { @@ -181,7 +235,7 @@ func (m *MigrationFile) ExecBatch(ctx context.Context, conn *pgx.Conn) error { batchSize++ } - return flushBatch() + return flushBatch(true, true) } func markError(stat string, pos int) string { diff --git a/apps/cli-go/pkg/migration/file_test.go b/apps/cli-go/pkg/migration/file_test.go index 49fb0f7f68..e560944c96 100644 --- a/apps/cli-go/pkg/migration/file_test.go +++ b/apps/cli-go/pkg/migration/file_test.go @@ -49,10 +49,14 @@ func TestMigrationFile(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(migration.Statements[0]). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(migration.Statements[0]). Reply("CREATE SCHEMA"). Query(INSERT_MIGRATION_VERSION, "0", "", migration.Statements). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") // Run test err := migration.ExecBatch(context.Background(), conn.MockClient(t)) // Check error @@ -72,14 +76,22 @@ func TestMigrationFile(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(migration.Statements[0]). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(migration.Statements[0]). Reply("CREATE TABLE"). + Query("COMMIT"). + Reply("COMMIT"). SimpleQuery(migration.Statements[1]). Reply("CREATE INDEX"). + Query("BEGIN"). + Reply("BEGIN"). Query(migration.Statements[2]). Reply("ALTER TABLE"). Query(INSERT_MIGRATION_VERSION, migration.Version, migration.Name, migration.Statements). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") // Run test err := migration.ExecBatch(context.Background(), conn.MockClient(t)) // Check error @@ -94,14 +106,88 @@ func TestMigrationFile(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(INSERT_MIGRATION_VERSION, migration.Version, migration.Name, migration.Statements). - Reply("INSERT 0 1") + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(INSERT_MIGRATION_VERSION, migration.Version, migration.Name, migration.Statements). + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") // Run test err := migration.ExecBatch(context.Background(), conn.MockClient(t)) // Check error assert.NoError(t, err) }) + t.Run("keeps SET LOCAL and history in the same transaction", func(t *testing.T) { + migration := MigrationFile{ + Statements: []string{ + "SET LOCAL check_function_bodies = off", + "CREATE FUNCTION public.answer() RETURNS int LANGUAGE sql AS 'SELECT 42'", + }, + Version: "20260101000000", + Name: "create_answer", + } + conn := pgtest.NewConn() + defer conn.Close(t) + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(migration.Statements[0]). + Reply("SET"). + Query(migration.Statements[1]). + Reply("CREATE FUNCTION"). + Query(INSERT_MIGRATION_VERSION, migration.Version, migration.Name, migration.Statements). + Reply("INSERT 0 1"). + Query("COMMIT"). + Reply("COMMIT") + + err := migration.ExecBatch(context.Background(), conn.MockClient(t)) + assert.NoError(t, err) + }) + + t.Run("preserves user-authored transaction boundaries", func(t *testing.T) { + migration := MigrationFile{ + Statements: []string{"BEGIN", "SET LOCAL check_function_bodies = off", "COMMIT"}, + Version: "20260101000000", + Name: "authored_transaction", + } + conn := pgtest.NewConn() + defer conn.Close(t) + // Exactly the authored BEGIN/COMMIT are sent; no automatic wrapper nests them. + conn.Query(migration.Statements[0]). + Reply("BEGIN"). + Query(migration.Statements[1]). + Reply("SET"). + Query(migration.Statements[2]). + Reply("COMMIT"). + Query(INSERT_MIGRATION_VERSION, migration.Version, migration.Name, migration.Statements). + Reply("INSERT 0 1") + + err := migration.ExecBatch(context.Background(), conn.MockClient(t)) + assert.NoError(t, err) + }) + + t.Run("never records history after an authored transaction fails", func(t *testing.T) { + migration := MigrationFile{ + Statements: []string{"BEGIN", "CREATE TABLE broken (", "COMMIT"}, + Version: "20260101000000", + Name: "broken_authored_transaction", + } + conn := pgtest.NewConn() + defer conn.Close(t) + conn.Query(migration.Statements[0]). + Reply("BEGIN"). + Query(migration.Statements[1]). + ReplyError(pgerrcode.SyntaxError, "syntax error at end of input"). + Query(migration.Statements[2]). + Reply("ROLLBACK"). + Query("ROLLBACK"). + Reply("ROLLBACK") + + err := migration.ExecBatch(context.Background(), conn.MockClient(t)) + assert.ErrorContains(t, err, "syntax error at end of input") + assert.ErrorContains(t, err, "At statement: 1") + }) + t.Run("reports pipeline incompatible statement errors with statement index", func(t *testing.T) { migration := MigrationFile{ Statements: []string{ @@ -115,8 +201,12 @@ func TestMigrationFile(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(migration.Statements[0]). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(migration.Statements[0]). Reply("CREATE TABLE"). + Query("COMMIT"). + Reply("COMMIT"). SimpleQuery(migration.Statements[1]). ReplyError("25001", "CREATE INDEX CONCURRENTLY cannot be executed within a pipeline") // Run test @@ -134,10 +224,14 @@ func TestMigrationFile(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(migration.Statements[0]). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(migration.Statements[0]). ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`). Query(INSERT_MIGRATION_VERSION, "0", "", migration.Statements). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := migration.ExecBatch(context.Background(), conn.MockClient(t)) // Check error @@ -153,10 +247,14 @@ func TestMigrationFile(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(migration.Statements[0]). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(migration.Statements[0]). ReplyError("42704", `type "ltree" does not exist`). Query(INSERT_MIGRATION_VERSION, "0", "", migration.Statements). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := migration.ExecBatch(context.Background(), conn.MockClient(t)) // Check error @@ -175,10 +273,14 @@ func TestMigrationFile(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(migration.Statements[0]). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(migration.Statements[0]). ReplyError("42704", `type "extensions.ltree" does not exist`). Query(INSERT_MIGRATION_VERSION, "0", "", migration.Statements). - Reply("INSERT 0 1") + Reply("INSERT 0 1"). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := migration.ExecBatch(context.Background(), conn.MockClient(t)) // Check error - should NOT contain hint since type is already schema-qualified diff --git a/apps/cli-go/pkg/migration/history.go b/apps/cli-go/pkg/migration/history.go index 9f156faa4a..4a1f1acf29 100644 --- a/apps/cli-go/pkg/migration/history.go +++ b/apps/cli-go/pkg/migration/history.go @@ -10,7 +10,7 @@ import ( ) const ( - SET_LOCK_TIMEOUT = "SET lock_timeout = '4s'" + SET_LOCK_TIMEOUT = "SET LOCAL lock_timeout = '4s'" CREATE_VERSION_SCHEMA = "CREATE SCHEMA IF NOT EXISTS supabase_migrations" CREATE_VERSION_TABLE = "CREATE TABLE IF NOT EXISTS supabase_migrations.schema_migrations (version text NOT NULL PRIMARY KEY)" ADD_STATEMENTS_COLUMN = "ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS statements text[]" @@ -30,16 +30,24 @@ const ( // TODO: support overriding `supabase_migrations.schema_migrations` with user defined . func CreateMigrationTable(ctx context.Context, conn *pgx.Conn) error { // This must be run without prepared statements because each statement in the batch depends on - // the previous schema change. The lock timeout will be reset when implicit transaction ends. + // the previous schema change. The explicit transaction makes SET LOCAL effective and non-leaking. batch := pgconn.Batch{} batch.ExecParams(SET_LOCK_TIMEOUT, nil, nil, nil, nil) batch.ExecParams(CREATE_VERSION_SCHEMA, nil, nil, nil, nil) batch.ExecParams(CREATE_VERSION_TABLE, nil, nil, nil, nil) batch.ExecParams(ADD_STATEMENTS_COLUMN, nil, nil, nil, nil) batch.ExecParams(ADD_NAME_COLUMN, nil, nil, nil, nil) + if _, err := conn.Exec(ctx, "BEGIN"); err != nil { + return errors.Errorf("failed to begin migration table transaction: %w", err) + } if _, err := conn.PgConn().ExecBatch(ctx, &batch).ReadAll(); err != nil { + _, _ = conn.Exec(ctx, "ROLLBACK") return errors.Errorf("failed to create migration table: %w", err) } + if _, err := conn.Exec(ctx, "COMMIT"); err != nil { + _, _ = conn.Exec(ctx, "ROLLBACK") + return errors.Errorf("failed to commit migration table transaction: %w", err) + } return nil } @@ -53,14 +61,22 @@ func ReadMigrationTable(ctx context.Context, conn *pgx.Conn) ([]MigrationFile, e func CreateSeedTable(ctx context.Context, conn *pgx.Conn) error { // This must be run without prepared statements because each statement in the batch depends on - // the previous schema change. The lock timeout will be reset when implicit transaction ends. + // the previous schema change. The explicit transaction makes SET LOCAL effective and non-leaking. batch := pgconn.Batch{} batch.ExecParams(SET_LOCK_TIMEOUT, nil, nil, nil, nil) batch.ExecParams(CREATE_VERSION_SCHEMA, nil, nil, nil, nil) batch.ExecParams(CREATE_SEED_TABLE, nil, nil, nil, nil) + if _, err := conn.Exec(ctx, "BEGIN"); err != nil { + return errors.Errorf("failed to begin seed table transaction: %w", err) + } if _, err := conn.PgConn().ExecBatch(ctx, &batch).ReadAll(); err != nil { + _, _ = conn.Exec(ctx, "ROLLBACK") return errors.Errorf("failed to create seed table: %w", err) } + if _, err := conn.Exec(ctx, "COMMIT"); err != nil { + _, _ = conn.Exec(ctx, "ROLLBACK") + return errors.Errorf("failed to commit seed table transaction: %w", err) + } return nil } diff --git a/apps/cli-go/pkg/migration/seed_test.go b/apps/cli-go/pkg/migration/seed_test.go index db4337b54c..e224e43a37 100644 --- a/apps/cli-go/pkg/migration/seed_test.go +++ b/apps/cli-go/pkg/migration/seed_test.go @@ -127,11 +127,15 @@ func TestSeedData(t *testing.T) { } func mockSeedHistory(conn *pgtest.MockConn) *pgtest.MockConn { - conn.Query(SET_LOCK_TIMEOUT). + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(SET_LOCK_TIMEOUT). Query(CREATE_VERSION_SCHEMA). Reply("CREATE SCHEMA"). Query(CREATE_SEED_TABLE). - Reply("CREATE TABLE") + Reply("CREATE TABLE"). + Query("COMMIT"). + Reply("COMMIT") return conn } @@ -145,8 +149,12 @@ func TestSeedGlobals(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(testGlobals). - Reply("CREATE ROLE") + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(testGlobals). + Reply("CREATE ROLE"). + Query("COMMIT"). + Reply("COMMIT") // Run test err := SeedGlobals(context.Background(), pending, conn.MockClient(t), testMigrations) // Check error @@ -166,8 +174,12 @@ func TestSeedGlobals(t *testing.T) { // Setup mock postgres conn := pgtest.NewConn() defer conn.Close(t) - conn.Query(testGlobals). - ReplyError(pgerrcode.InvalidCatalogName, `database "postgres" does not exist`) + conn.Query("BEGIN"). + Reply("BEGIN"). + Query(testGlobals). + ReplyError(pgerrcode.InvalidCatalogName, `database "postgres" does not exist`). + Query("ROLLBACK"). + Reply("ROLLBACK") // Run test err := SeedGlobals(context.Background(), pending, conn.MockClient(t), testMigrations) // Check error diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 6b2470e42b..328a149997 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -82,11 +82,11 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | | --------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. `--use-pg-schema` is deprecated (CLI-1960: TS-only stderr warning + `--help` note) in favor of the pg-delta engine or the default migra engine — it wraps the in-process `stripe/pg-schema-diff` Go library, which has no TS/container equivalent, so it is a documented keep-in-Go exception, not a pending port. It will be the sole remaining Go delegation once `--use-pgadmin`, the `db __shadow`/`db __db-bootstrap` seams, and the other in-flight M9 issues are done. | +| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Pg-delta runs in-process by default with bundled pg-topo against isolated Go-seam-provisioned shadows (`db __shadow`); `SUPABASE_USE_PG_DELTA_NEXT=false` retains the legacy edge-runtime implementation. Migra remains edge-runtime-backed; `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. `--use-pg-schema` is deprecated (CLI-1960: TS-only stderr warning + `--help` note) in favor of the pg-delta engine or the default migra engine — it wraps the in-process `stripe/pg-schema-diff` Go library, which has no TS/container equivalent, so it is a documented keep-in-Go exception, not a pending port. It will be the sole remaining Go delegation once `--use-pgadmin`, the `db __shadow` seam, and the other in-flight M9 issues are done. | | `db dump` | `ported` | `legacy/commands/db/dump/` | `n/a` | `n/a` | Native TS port. Streams `pg_dump`/`pg_dumpall` via a Docker container (`LegacyDockerRun`); schema/data/role modes, `--dry-run` script print, IPv4 transaction-pooler fallback. | | `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | -| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, deprecated in favor of `--declarative` (CLI-1957) — it needs a TS PostgreSQL DDL parser for Go's `format.WriteStructuredSchemas` that has no equivalent in this repo, and `--declarative` already delivers the same per-object schema split via pg-delta catalog introspection. | -| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets including decrypted `encrypted:` values; `--dry-run`; best-effort pg-delta migrations-catalog cache (warning-only on failure). Pipeline-incompatible statements (`CREATE INDEX CONCURRENTLY`, `VACUUM`, …) run standalone outside the batch transaction — from the closed Go PR supabase/cli#5156, also ported into `apps/cli-go` (CLI-1989 ruling). | +| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native bundled pg-delta / migra migration + `--declarative` pg-delta export; `SUPABASE_USE_PG_DELTA_NEXT=false` retains legacy edge-runtime pg-delta; reconciles `schema_migrations`. The initial-migra pull dumps the remote schema natively (`pg_dump`) then appends the migra diff. Only `--experimental` (structured dump) still delegates to Go, deprecated in favor of `--declarative` (CLI-1957) — it needs a TS PostgreSQL DDL parser for Go's `format.WriteStructuredSchemas` that has no equivalent in this repo, and `--declarative` already delivers the same per-object schema split via pg-delta catalog introspection. | +| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets including decrypted `encrypted:` values; `--dry-run`. The best-effort pg-delta migrations-catalog warmup is retained only under `SUPABASE_USE_PG_DELTA_NEXT=false`. Pipeline-incompatible statements (`CREATE INDEX CONCURRENTLY`, `VACUUM`, …) run standalone outside the batch transaction — from the closed Go PR supabase/cli#5156, also ported into `apps/cli-go` (CLI-1989 ruling). | | `db reset` | `ported` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Fully native TS port (CLI-1955 removed the last Go delegation on this command — the hidden `db __db-bootstrap` seam's `recreate`/`await-storage` modes no longer exist). Remote path: drop user schemas, vault upsert, MigrateAndSeed, `--version`/`--last`, `--sql-paths` seed override. Local path: running check, then a reset-specific PG14/PG15 recreate composition (`legacy/shared/db-bootstrap/recreate-local-database.ts`) over the same container-bootstrap primitives `db start` uses — container/volume remove-then-recreate (PG15) or a template1 `DROP`/`CREATE DATABASE` sequence + `InitSchema14`/`ApplyApiPrivileges` (PG14) — followed by a concurrent satellite-container restart (storage/auth/realtime/pooler) and a Kong `nginx` reload that FAILS the whole command on error (`legacy/shared/db-bootstrap/restart-services.ts`), then storage-gated bucket seeding (reuses `seed buckets`, native storage-health gate in `await-storage-ready.ts`) and the git-branch `Finished…` line. Only the niche `--experimental` schema-files path with no resolved version still delegates to the Go binary, and only for the REMOTE target (the local target's `--experimental` path is fully native via `legacyMigrateAndSeed`'s existing declarative-schema-files branch). The local-reset composition is hoisted into `legacy/shared/db-bootstrap/reset-local-database.ts`'s `legacyResetLocalDatabase` (CLI-2062); `db schema declarative`'s smart-target and `db schema sync` now call it in-process too, instead of the removed `LegacyDeclarativeSeam.execInherit` seam that used to shell out to a second `supabase-go db reset --local` child. The best-effort pg-delta migrations-catalog cache warmup (`pgcache.TryCacheMigrationsCatalog`, reachable via `SetupLocalDatabase` on the PG15 recreate path) IS ported too, same as `db start`. Pipeline-incompatible statements run standalone outside the batch transaction, same as `db push` (closed Go PR supabase/cli#5156, CLI-1989 ruling). | | `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Fully native TS port (CLI-1954 removed the last Go delegation). Validates config, checks "already running" (prints Go's line, native `docker container inspect`), else natively brings up the Postgres container (network/volume/create/start via `legacy/shared/db-bootstrap/`'s shared primitives), waits for health, runs the fresh-volume `SetupLocalDatabase`-equivalent pipeline (including its best-effort pg-delta migrations-catalog warmup), and writes `_current_branch`. `--from-backup` is fully native too: a third entrypoint variant (schema.sql + a ported `restore.sh`, no `webhook.sql`), a `backup volume already exists` guard when the volume isn't fresh, and a swallowed (not failed) health-check timeout. No status table / `cli_stack_started` (those are `supabase start`). | | `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | @@ -319,10 +319,10 @@ Legend: | `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | | `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | | `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go. `--use-pg-schema` is deprecated (CLI-1960) — a keep-in-Go exception (in-process `stripe/pg-schema-diff` library, no TS/container equivalent), not yet the sole remaining Go delegation (`--use-pgadmin` and other in-flight M9 issues still delegate too); migrate to the pg-delta engine or the default migra engine. | +| `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — bundled in-process pg-delta by default (`SUPABASE_USE_PG_DELTA_NEXT=false` retains legacy edge-runtime); native migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go. `--use-pg-schema` is deprecated (CLI-1960) — a keep-in-Go exception (in-process `stripe/pg-schema-diff` library, no TS/container equivalent), not yet the sole remaining Go delegation (`--use-pgadmin` and other in-flight M9 issues still delegate too); migrate to the pg-delta engine or the default migra engine. | | `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | | `db push` | `ported` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | -| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; `--experimental` structured dump still delegates to Go, deprecated in favor of `--declarative` (CLI-1957) | +| `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — bundled in-process pg-delta by default with legacy opt-out; native migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); initial-migra pull dumps the schema natively (`pg_dump`) + appends the diff; `--experimental` structured dump still delegates to Go, deprecated in favor of `--declarative` (CLI-1957) | | `db reset` | `ported` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — includes Go-parity `--sql-paths` override for `[db.seed].sql_paths` | | `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | | `db start` | `ported` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | diff --git a/apps/cli/package.json b/apps/cli/package.json index a3c4aa09e6..4e8d1726b9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -55,6 +55,8 @@ "@parcel/watcher": "^2.6.0", "@supabase/api": "workspace:*", "@supabase/config": "workspace:*", + "@supabase/pg-delta": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@2f1d6b677bb44485f0a6874caf288f2c77896f86", + "@supabase/pg-topo": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@2f1d6b677bb44485f0a6874caf288f2c77896f86", "@supabase/process-compose": "workspace:*", "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", diff --git a/apps/cli/scripts/build-binary.integration.test.ts b/apps/cli/scripts/build-binary.integration.test.ts new file mode 100644 index 0000000000..52dc71ceb3 --- /dev/null +++ b/apps/cli/scripts/build-binary.integration.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const fixturePath = fileURLToPath( + new URL("../tests/fixtures/compiled-libpg-query.ts", import.meta.url), +); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("compiled binary assets", () => { + test("embeds and loads libpg-query.wasm", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "supabase-compiled-wasm-")); + temporaryDirectories.push(directory); + const executable = path.join(directory, "parser-probe"); + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) { + throw new Error("Bun executable not found"); + } + + const build = Bun.spawn( + [bunExecutable, "build", fixturePath, "--compile", `--outfile=${executable}`], + { stdout: "pipe", stderr: "pipe" }, + ); + const [buildExitCode, buildStderr] = await Promise.all([ + build.exited, + new Response(build.stderr).text(), + ]); + expect(buildExitCode, buildStderr).toBe(0); + + const probe = Bun.spawn([executable], { + cwd: directory, + env: {}, + stdout: "pipe", + stderr: "pipe", + }); + const [probeExitCode, stdout, stderr] = await Promise.all([ + probe.exited, + new Response(probe.stdout).text(), + new Response(probe.stderr).text(), + ]); + + expect(probeExitCode, stderr).toBe(0); + expect(stdout).toContain("libpg-query.wasm loaded"); + }, 20_000); +}); diff --git a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md index e5a7694ba9..a510d61e41 100644 --- a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md @@ -6,6 +6,12 @@ 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 embedded push step does not warm pg-delta state under the default bundled +engine: no next-engine consumer uses the legacy catalog. Setting +`SUPABASE_USE_PG_DELTA_NEXT=false` retains Go's edge-runtime catalog warmup. +`PGDELTA_NPM_REGISTRY`, `.temp/pgdelta-version`, and catalogs directly under +`.temp/pgdelta/` are meaningful only for that legacy opt-out. + ## Files Read | Path | Format | When | @@ -20,7 +26,8 @@ command — see Notes). | `/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/edge-runtime-version` | plain text | native push step's migrations-catalog cache (pg-delta), when a pinned edge-runtime image tag exists — resolved against the bootstrap workdir explicitly, not `cliConfig.workdir` (which is stale after this handler's own `process.chdir`) | +| `/supabase/.temp/pgdelta-version` | plain text | always read by push config loading for compatibility; affects the legacy opt-out only | +| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out only: push-step catalog warmup image tag, resolved against the bootstrap workdir | ## Files Written @@ -31,8 +38,8 @@ command — see Notes). | `/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 | native push step, best-effort, after a successful migration apply, when pg-delta is enabled (a failure only warns on stderr and never fails the push) | -| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | native push step, same pg-delta gate, when the target requires SSL | +| `/supabase/.temp/pgdelta/catalog--migrations--.json` | JSON | legacy opt-out push step, best-effort after a successful migration apply when pg-delta is enabled; failure only warns | +| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy opt-out catalog export 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 `cliConfig.workdir` | | `~/.supabase/telemetry.json` | JSON | PersistentPostRun telemetry flush (`Effect.ensuring`) | @@ -64,17 +71,18 @@ 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 (Go's viper `YES`), read project-`.env`-aware like the standalone `db push` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the push step's migrations-catalog cache when `[experimental.pgdelta].enabled` is unset, read project-`.env`-aware (see Files Read) | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the push step's pg-delta edge-runtime image registry, read project-`.env`-aware (see Files Read) | no | -| `PGDELTA_NPM_REGISTRY` | overrides the push step's pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward), read project-`.env`-aware (see Files Read) | 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 (Go's viper `YES`), read project-`.env`-aware like the standalone `db push` | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the legacy opt-out push cache when `[experimental.pgdelta].enabled` is unset, read project-`.env`-aware | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` to retain the legacy push-step catalog warmup, read project-`.env`-aware | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | legacy opt-out only: overrides the push step's edge-runtime image registry, read project-`.env`-aware | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out only: overrides the push-step edge-runtime npm registry, read project-`.env`-aware | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts index d9b7029288..57f4a5becf 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts @@ -72,7 +72,7 @@ const tempRoot = useLegacyTempWorkdir("supabase-branches-create-int-"); interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; - readonly response?: CreatedBranch; + readonly response?: unknown; readonly status?: number; readonly network?: "fail"; readonly gated?: boolean; @@ -328,6 +328,37 @@ describe("legacy branches create integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("accepts branch timestamps with an RFC3339 numeric offset", () => { + const { layer, out } = setup({ + response: { + ...CREATED, + created_at: "2026-05-27T03:32:03+02:30", + updated_at: "2026-05-27T03:32:04+02:30", + }, + }); + return Effect.gen(function* () { + yield* legacyBranchesCreate({ ...baseFlags, name: Option.some("feat-x") }); + expect(out.stdoutText).toContain("2026-05-27"); + }).pipe(Effect.provide(layer)); + }); + + it.live("surfaces malformed successful responses as schema errors, not network errors", () => { + const { layer } = setup({ + response: { ...CREATED, created_at: 42 }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyBranchesCreate({ ...baseFlags, name: Option.some("feat-x") }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyApiResponseSchemaError"); + expect(json).not.toContain("LegacyBranchesCreateNetworkError"); + } + }).pipe(Effect.provide(layer)); + }); + it.live("emits Go-byte-exact indented JSON for --output json", () => { const { layer, out } = setup({ goOutput: "json" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md index 814e5c4a47..b1cf2c33c0 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -2,38 +2,69 @@ Native Effect port. Diffs the local project's expected schema (a throwaway shadow database) against a target database (local / linked / `--db-url`), using either -the native pg-delta or migra engine (both run inside Docker via edge-runtime). The -`--use-pgadmin` / `--use-pg-schema` engines delegate to the bundled Go binary. +pg-delta or migra. Pg-delta runs in-process by default; migra still runs in Docker +via edge-runtime. The `--use-pgadmin` / `--use-pg-schema` engines delegate to the +bundled Go binary. + +## Pg-delta implementation and compatibility + +- The default implementation is the in-process pg-delta engine bundled into the + CLI binary together with pg-topo. Its version is fixed when the CLI is built; + there is no runtime package download or automatic fallback to the legacy engine. +- `SUPABASE_USE_PG_DELTA_NEXT=false` selects the legacy edge-runtime implementation. + Only that opt-out reads legacy catalogs under `supabase/.temp/pgdelta/`, + `supabase/.temp/pgdelta-version`, or `PGDELTA_NPM_REGISTRY`. +- With `PGDELTA_DEBUG`, default-engine snapshots, plans, and diagnostics are written + under `supabase/.temp/pgdelta/v2/debug//`. The directory contains + `metadata.json` and, when available, `source-snapshot.json`, + `desired-snapshot.json`, `plan.json`, and `diagnostics.json`. These are diagnostic + artifacts, not reusable catalogs. +- The default engine always refuses extraction errors. Coverage gaps + (`unmodeled_kind` or `unresolved_security_label`) warn and remain unmanaged by + default; `--strict-coverage` turns them into a refusal. Warnings identify the + diagnostic origin and explain that unsupported changes are absent from the diff; + when debug capture is enabled, the bundle is saved before policy evaluation. +- SQL text and file segmentation may differ from the legacy renderer. Applicable + output and convergence (a subsequent diff is empty) are the compatibility contract. +- Default-engine plans retain pg-delta's safe compaction and are formatted with + its human-facing preset (lowercase keywords, max width 180). A JSON object in + `[experimental.pgdelta].format_options` partially overrides that preset; the + JSON literal `null` disables formatting without disabling compaction. ## Files Read -| Path | Format | When | -| -------------------------------------------------- | ---------- | ----------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | -| `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) | -| `/supabase/database/**` (declarative dir) | SQL | local target when declarative schemas exist | -| `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | -| `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution | -| `/supabase/.temp/pgdelta/*.json` | JSON | explicit `--from/--to migrations` catalog (cache) | +| Path | Format | When | +| ----------------------------------------------- | ---------- | ----------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | +| `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) | +| `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | +| `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution | +| `/supabase/.temp/pgdelta-version` | plain text | always read for compatibility; affects legacy opt-out only | +| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out only: edge-runtime image tag | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out only: explicit `--from/--to migrations` catalog | ## Files Written -| Path | Format | When | -| ----------------------------------------------------------- | ------ | ----------------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | `--file ` and the diff is non-empty | -| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output` | -| `/supabase/.temp/pgdelta/*.json` | JSON | explicit `--from/--to migrations` catalog cache | -| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| ----------------------------------------------------------- | ------ | ------------------------------------------- | +| `/supabase/migrations/_.sql` | SQL | `--file ` and the diff is non-empty | +| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output` | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out only: migrations catalog | +| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy opt-out only: Supabase TLS target | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | default engine with `PGDELTA_DEBUG` | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker -- Edge-runtime container (pg-delta / migra diff scripts; also runs the pg-delta - catalog-export script for explicit `--from/--to migrations` on a cache miss — - CLI-1959, native, no longer the hidden Go `__catalog` seam). -- Shadow Postgres container (provisioned + torn down via the Go `db __shadow` seam; - explicit `--from/--to migrations` reuses this same seam call — `mode: "diff"` — - on a cache miss, rather than a second, `__catalog`-specific shadow). +- Edge-runtime container (migra, or pg-delta only under the legacy opt-out). The + legacy explicit `--from/--to migrations` path also runs the native pg-delta + catalog-export script there on a cache miss (CLI-1959; no hidden `__catalog` + subprocess). +- Shadow Postgres container(s), provisioned through the Go `db __shadow` seam. + The default engine uses isolated migrations and declarative shadows. The legacy + opt-out provisions a single `mode: "diff"` shadow, including on an explicit + migrations-catalog cache miss, and tears it down after export. - `supabase/migra` container — the migra OOM bash fallback only. ## API Routes (linked path, via the db-config resolver) @@ -47,14 +78,15 @@ the native pg-delta or migra engine (both run inside Docker via edge-runtime). T ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------- | ------------------------------------------------ | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth for `--linked` | no | -| `SUPABASE_DB_PASSWORD` | remote DB password (linked) | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta engine | no | -| `PGDELTA_DEBUG` | pg-delta debug capture | no | -| `PGDELTA_NPM_REGISTRY` | scoped `@supabase` npm registry for edge-runtime | no | -| `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | +| Variable | Purpose | Required? | +| -------------------------------- | ------------------------------------------------- | --------- | +| `SUPABASE_ACCESS_TOKEN` | auth for `--linked` | no | +| `SUPABASE_DB_PASSWORD` | remote DB password (linked) | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta engine | no | +| `PGDELTA_DEBUG` | pg-delta debug capture | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for the legacy edge-runtime engine | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out only: scoped npm registry | no | +| `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | ## Exit Codes @@ -69,13 +101,18 @@ the native pg-delta or migra engine (both run inside Docker via edge-runtime). T Progress to stderr (`Creating shadow database...`, `Diffing schemas[: ]`, `Finished supabase db diff on branch .`, drop-statement warning, and the -`--file` write warning). The SQL diff prints to stdout when neither `--file` nor -explicit `--output` is set. +`--file` write warning). A configured `[db.migrations].schema_paths` also prints a +transition warning because it no longer changes the diff target. The SQL diff +prints to stdout when neither `--file` nor explicit `--output` is set. ### `--output-format json` / `stream-json` Progress strings still go to stderr; stdout carries a single structured envelope -`{ diff, file, schemas, engine, dropStatements }` instead of the raw SQL. +`{ diff, file, files, schemas, engine, dropStatements, advisories? }` instead of +the raw SQL. With the default pg-delta implementation, a non-empty `--file` diff +and a non-empty declarative tree add the informational +`DeclarativeSchemaNotUsedAsDiffBaseline` advisory; the same note is written to +stderr. Inspection is best-effort and never changes command success. ## Notes / Delegation @@ -86,11 +123,16 @@ Progress strings still go to stderr; stdout carries a single structured envelope binary (their side effects are Go's); the Go child's telemetry is disabled so the single `cli_command_executed` event comes from this TS command. - Explicit `--from`/`--to` mode always uses pg-delta and writes to `--output` (or stdout). -- The explicit `migrations` target resolves natively (CLI-1959): a bare +- `--strict-coverage` applies to the bundled pg-delta engine and refuses output when + it encounters schema objects it cannot manage. +- Normal mode always compares the migrations shadow with the selected live + database. Declarative files and `schema_paths` never replace that migrations baseline; use + `supabase db schema declarative sync` for declarative comparison. +- 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, the existing - `db __shadow --mode diff` seam call (unchanged — still Go, out of scope for - CLI-1959) plus a native pg-delta catalog export. No hidden Go + `db __shadow --mode diff` seam call plus a native pg-delta catalog export. No hidden Go `db schema declarative __catalog` subprocess runs for this path any more. ### `--use-pg-schema` is deprecated (CLI-1960) — keep-in-Go exception diff --git a/apps/cli/src/legacy/commands/db/diff/diff.command.ts b/apps/cli/src/legacy/commands/db/diff/diff.command.ts index 0aa0d9b1ff..79f1aea427 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.command.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.command.ts @@ -37,6 +37,11 @@ const config = { Flag.withDescription("Use pg-delta to generate schema diff."), Flag.optional, ), + strictCoverage: Flag.boolean("strict-coverage").pipe( + Flag.withDescription( + "Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.", + ), + ), from: Flag.string("from").pipe( Flag.withDescription("Diff from local, linked, migrations, or a Postgres URL."), Flag.optional, @@ -69,7 +74,9 @@ const config = { ), file: Flag.string("file").pipe( Flag.withAlias("f"), - Flag.withDescription("Saves schema diff to a new migration file."), + Flag.withDescription( + "Names and saves the complete schema diff as a new migration; it does not filter objects.", + ), Flag.optional, ), schema: Flag.string("schema").pipe( @@ -89,7 +96,9 @@ const config = { export type LegacyDbDiffFlags = CliCommand.Command.Config.Infer; export const legacyDbDiffCommand = Command.make("diff", config).pipe( - Command.withDescription("Diffs the local database for schema changes."), + Command.withDescription( + "Compares a shadow built from supabase/migrations with a live database (--local by default, --linked, or --db-url). Declarative files under supabase/database are not part of this baseline. Output is printed by default; -f names and saves the complete diff as a migration and does not filter objects.", + ), Command.withShortDescription("Diffs the local database for schema changes"), Command.withHandler((flags) => legacyDbDiff(flags).pipe( @@ -99,6 +108,7 @@ export const legacyDbDiffCommand = Command.make("diff", config).pipe( "use-pgadmin": flags.usePgAdmin, "use-pg-schema": flags.usePgSchema, "use-pg-delta": flags.usePgDelta, + "strict-coverage": flags.strictCoverage, from: flags.from, to: flags.to, output: flags.output, diff --git a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts index 47689ac6d4..3fc065c9f9 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -6,7 +6,10 @@ import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyReadDbToml, + legacyResolveDeclarativeDir, +} from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; @@ -19,6 +22,7 @@ import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state. import { legacyParseBoolEnv, legacyResolveDiffEngine, + legacySchemaPathsTransitionWarning, legacyShouldUsePgDelta, } from "../../../shared/legacy-diff-engine.ts"; import { @@ -26,9 +30,17 @@ import { legacyGetMigrationPath, } from "../../../shared/legacy-migration-file.ts"; import { legacyDiffMigra } from "../shared/legacy-migra.ts"; -import { legacyResolveMigrationsCatalogRef } from "../../../shared/legacy-pgdelta.cache.ts"; +import { + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseEndpoint, + type LegacyPgDeltaEndpoint, +} from "../shared/legacy-pgdelta-engine.service.ts"; +import { LegacyLoadPgDeltaSqlFiles } from "../shared/legacy-pgdelta-files.ts"; import { legacyWritePgDeltaMigrations } from "../shared/legacy-pgdelta-migrations.write.ts"; -import { type LegacyPgDeltaContext, legacyDiffPgDelta } from "../../../shared/legacy-pgdelta.ts"; +import { + legacyIsPgDeltaDebugEnabled, + type LegacyPgDeltaContext, +} from "../../../shared/legacy-pgdelta.ts"; import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbDiffFlags } from "./diff.command.ts"; import { legacyClassifyExplicitRef, legacyUnknownTargetMessage } from "./diff.explicit.ts"; @@ -55,6 +67,20 @@ Run ${legacyAqua("supabase db reset")} to verify that the new migration does not // scope for CLI-1960. const warnPgSchemaDeprecated = `${legacyYellow("WARNING:")} "--use-pg-schema" is deprecated. Use the pg-delta engine ([experimental.pgdelta] enabled = true / --use-pg-delta) or the default migra engine instead.`; +const declarativeBaselineAdvisory = (declarativePath: string | null) => ({ + code: "DeclarativeSchemaNotUsedAsDiffBaseline", + severity: "info", + message: "Declarative schema files were not used as the db diff baseline.", + context: { + baseline: "supabase/migrations", + declarativePath, + fileFlagFiltersObjects: false, + }, +}); + +const declarativeBaselineNote = (displayPath: string) => + `Note: db diff -f uses supabase/migrations as its baseline. Declarative schema files in ${displayPath} are not part of that baseline. If migrations are empty or outdated, the generated migration may include existing declarative objects. -f names the migration; it does not filter objects.\n`; + /** * Rebuilds the `db diff` argv for the pgAdmin / pg-schema delegate path. Flags * stay flags (the Go-proxy channel-parity rule). The explicit `--from`/`--to` and @@ -96,6 +122,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy const output = yield* Output; const resolver = yield* LegacyDbConfigResolver; const seam = yield* LegacyDeclarativeSeam; + const pgDelta = yield* LegacyPgDeltaEngine; const proxy = yield* LegacyGoProxy; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; @@ -204,17 +231,24 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // runs `LoadConfig(ref)` (`explicit.go:78-86`), re-merging the matching // `[remotes.]` block so a later `local` ref read and the trailing // `pgDeltaFormatOptions()` see the override. Thread the merged config through. - const resolveRef = (ref: string) => + const resolveRef = (ref: string): Effect.Effect => Effect.gen(function* () { switch (legacyClassifyExplicitRef(ref)) { - case "local": - return legacyToPostgresURL({ + case "local": { + const connection = { host: legacyGetHostname(), port: cfg.port, user: "postgres", password: cfg.password, database: "postgres", - }); + }; + return { + kind: "database", + ref: legacyToPostgresURL(connection), + connection, + connectOptions: { isLocal: true, dnsResolver }, + } satisfies LegacyPgDeltaDatabaseEndpoint; + } case "linked": { const resolved = yield* resolver.resolve({ dbUrl: Option.none(), @@ -228,52 +262,50 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy mergedLinkedRef = ref2; cfg = yield* legacyReadDbToml(fs, path, cliConfig.workdir, ref2); } - return legacyToPostgresURL(resolved.conn); - } - case "migrations": { - // Native (CLI-1959): mirrors Go's `resolveMigrationsCatalogRef` - // (`explicit.go:88-126`) exactly — see `legacyResolveMigrationsCatalogRef`'s - // doc comment. The pg-delta context is built from whatever `cfg` is - // current at this point in the cascade (possibly re-merged by an - // earlier "linked" ref above), matching Go's stateful pre-run. - const migrationsCtx: LegacyPgDeltaContext = { - projectId: Option.getOrElse(cliConfig.projectId, () => ""), - cwd: cliConfig.workdir, - npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), - denoVersion: cfg.denoVersion, - }; - // Pass the linked ref only if one resolved earlier in the cascade, so - // the shadow merges the same remote override Go's in-process - // migrations catalog sees (`explicit.go:88-126`). Absent otherwise → - // base config, matching Go's resolution order. - return yield* legacyResolveMigrationsCatalogRef( - fs, - path, - migrationsCtx, - mergedLinkedRef !== undefined ? { projectRef: mergedLinkedRef } : {}, - ); + return { + kind: "database", + ref: legacyToPostgresURL(resolved.conn), + connection: resolved.conn, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + } satisfies LegacyPgDeltaDatabaseEndpoint; } + case "migrations": + return { + kind: "migrations", + // Preserve resolution order: only refs resolved before this endpoint + // influence the migrations shadow/catalog. + ...(mergedLinkedRef !== undefined ? { projectRef: mergedLinkedRef } : {}), + } satisfies LegacyPgDeltaEndpoint; case "url": - return ref; + return { + kind: "database", + ref, + // The next engine parses arbitrary explicit URLs itself. They are + // remote by default, matching Go's TLS-safe connection path. + connectOptions: { isLocal: false, dnsResolver }, + } satisfies LegacyPgDeltaDatabaseEndpoint; default: return yield* Effect.fail( new LegacyDbDiffUnknownTargetError({ message: legacyUnknownTargetMessage(ref) }), ); } }); - const sourceRef = yield* resolveRef(from); - const targetRef = yield* resolveRef(to); + const source = yield* resolveRef(from); + const desired = yield* resolveRef(to); const explicitCtx: LegacyPgDeltaContext = { projectId: Option.getOrElse(cliConfig.projectId, () => ""), cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), denoVersion: cfg.denoVersion, }; - const result = yield* legacyDiffPgDelta(explicitCtx, { - sourceRef, - targetRef, + const result = yield* pgDelta.diffExplicit({ + context: explicitCtx, + source, + desired, schema: flags.schema, formatOptions: Option.getOrElse(cfg.pgDelta.formatOptions, () => ""), + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, }); // Explicit-mode output: `--output` file (Go's `writeOutput`) or stdout // (Go's `fmt.Print`, no trailing newline — pg-delta ends each statement `;\n`). @@ -389,6 +421,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy denoVersion: cfg.denoVersion, }; const formatOptions = Option.getOrElse(cfg.pgDelta.formatOptions, () => ""); + if (cfg.schemaPaths !== undefined && cfg.schemaPaths.length > 0) { + yield* output.raw(legacySchemaPathsTransitionWarning, "stderr"); + } // Engine resolution (Go's `db.go:110`): the pg-delta env/config/flag gate, // read from the (possibly remote-merged) config. @@ -405,47 +440,49 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }); yield* output.raw("Creating shadow database...\n", "stderr"); - const shadow = yield* seam.provisionShadow({ - mode: "diff", - targetLocal: resolved.isLocal, - usePgDelta: useDelta, - schema: flags.schema, - // Linked path only: the shadow merges the same `[remotes.]` override - // the engine/format read above (Go builds the shadow from the remote-merged - // config). Default `db diff` is local, which never merges a remote block. - projectRef: connType === "linked" ? linkedRef : undefined, - }); - - const diffResult = yield* Effect.gen(function* () { - const target = shadow.targetUrlOverride ?? targetUrl; - yield* output.raw( - flags.schema.length > 0 - ? `Diffing schemas: ${flags.schema.join(",")}\n` - : "Diffing schemas...\n", - "stderr", - ); - if (useDelta) { - const result = yield* legacyDiffPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef: target, - schema: flags.schema, - formatOptions, + const diffingMessage = + flags.schema.length > 0 + ? `Diffing schemas: ${flags.schema.join(",")}\n` + : "Diffing schemas...\n"; + const diffResult = useDelta + ? yield* Effect.gen(function* () { + // The selected strategy owns pg-delta shadow/pool lifecycles. This message + // precedes the call because the high-level boundary intentionally exposes + // no partially-provisioned resource to the handler. + yield* output.raw(diffingMessage, "stderr"); + const result = yield* pgDelta.diffDatabase({ + context: ctx, + target: { + kind: "database", + ref: targetUrl, + connection: resolved.conn, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }, + schema: flags.schema, + formatOptions, + ...(connType === "linked" && linkedRef !== undefined ? { projectRef: linkedRef } : {}), + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, + }); + return { sql: result.sql, files: result.files }; + }) + : yield* Effect.gen(function* () { + const shadow = yield* seam.provisionShadow({ + mode: "diff", + schema: flags.schema, + ...(connType === "linked" && linkedRef !== undefined ? { projectRef: linkedRef } : {}), + }); + return yield* Effect.gen(function* () { + yield* output.raw(diffingMessage, "stderr"); + const sql = yield* legacyDiffMigra(ctx, { + source: shadow.sourceUrl, + target: targetUrl, + schema: flags.schema, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }); + return { sql, files: undefined }; + }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); }); - // Keep the per-unit plan files so a multi-unit plan can be written as one - // migration file each (Go's `DatabaseDiff.Files`); `sql` stays the flattened - // join for stdout review + machine payloads. - return { sql: result.sql, files: result.files }; - } - const sql = yield* legacyDiffMigra(ctx, { - source: shadow.sourceUrl, - target, - schema: flags.schema, - connectOptions: { isLocal: resolved.isLocal, dnsResolver }, - }); - // The migra engine has no execution-aware plan units, so it always writes a - // single migration file (Go's `SaveDiff` single-file path). - return { sql, files: undefined }; - }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); const out = diffResult.sql; // Detect the branch from the resolved workdir, not the caller's CWD: Go @@ -462,6 +499,32 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy const engine = useDelta ? "pg-delta" : "migra"; const drops = 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 + ) { + // 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. + const declarativeDir = legacyResolveDeclarativeDir(path, cfg.pgDelta); + const declarativeDirAbsolute = path.resolve(cliConfig.workdir, declarativeDir); + const hasDeclarativeSql = yield* Effect.gen(function* () { + if (!(yield* fs.exists(declarativeDirAbsolute))) return false; + return (yield* LegacyLoadPgDeltaSqlFiles(fs, path, declarativeDirAbsolute)).length > 0; + }).pipe(Effect.orElseSucceed(() => false)); + if (hasDeclarativeSql) { + const isAbsolute = path.isAbsolute(declarativeDir); + const displayPath = isAbsolute + ? "the configured declarative schema directory" + : declarativeDir.split("\\").join("/"); + ignoredDeclarativeAdvisory = declarativeBaselineAdvisory(isAbsolute ? null : displayPath); + yield* output.raw(declarativeBaselineNote(displayPath), "stderr"); + } + } if (out.length < 2) { yield* output.raw("No schema changes found\n", "stderr"); // Go's `SaveDiff` gates the file write on `len(file) > 0` (`pgadmin.go`), so @@ -485,7 +548,14 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy workdir: cliConfig.workdir, baseMillis: yield* Clock.currentTimeMillis, name: fileName, - files: planFiles.map((file) => ({ name: file.name, sql: file.sql })), + files: planFiles.map((file) => ({ + name: + file.suffix !== undefined && file.suffix !== null + ? file.suffix.replace(/^_/u, "") + : file.name, + sql: file.sql, + transactionMode: file.transactionMode, + })), }).pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); for (const unit of writtenUnits) writtenFiles.push(unit.path); } else { @@ -524,6 +594,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy schemas: flags.schema, engine, dropStatements: drops, + ...(ignoredDeclarativeAdvisory === undefined + ? {} + : { advisories: [ignoredDeclarativeAdvisory] }), }); } }).pipe( diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index b73557e9b0..4058370433 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -29,6 +28,11 @@ import { LegacyEdgeRuntimeScript, } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseDiffInput, + type LegacyPgDeltaExplicitDiffInput, +} from "../shared/legacy-pgdelta-engine.service.ts"; import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbDiffFlags } from "./diff.command.ts"; import { legacyDbDiff } from "./diff.handler.ts"; @@ -38,10 +42,11 @@ interface SetupOpts { readonly isLocal?: boolean; readonly linkedRef?: string; readonly diffSql?: string; - // When set, the pg-delta edge mock emits a multi-unit plan envelope (one file - // per entry) instead of the single-unit wrap of `diffSql`. + // When set, the pg-delta strategy mock returns one rendered file per entry. readonly diffFiles?: ReadonlyArray<{ readonly name: string; readonly sql: string }>; - readonly targetOverride?: string; + // Exact suffixes returned by the next renderer, parallel to `diffFiles`. + readonly diffSuffixes?: ReadonlyArray; + 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 readonly networkId?: string; // --network-id value forwarded to docker runs @@ -56,35 +61,79 @@ function setup(workdir: string, opts: SetupOpts = {}) { const provisionCalls: Array<{ mode: string; - targetLocal: boolean; - usePgDelta: boolean; projectRef?: string; }> = []; const removedContainers: string[] = []; - const exportCalls: string[] = []; - const exportCatalogCalls: Array<{ mode: string; projectRef?: string }> = []; const seam = Layer.succeed(LegacyDeclarativeSeam, { - exportCatalog: ({ mode, projectRef }) => { - exportCalls.push(mode); - exportCatalogCalls.push({ mode, projectRef }); - return Effect.succeed("supabase/.temp/pgdelta/migrations.json"); - }, + exportCatalog: () => Effect.succeed("supabase/.temp/pgdelta/migrations.json"), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, - provisionShadow: ({ mode, targetLocal, usePgDelta, projectRef }) => { - provisionCalls.push({ mode, targetLocal, usePgDelta, projectRef }); + provisionShadow: ({ mode, projectRef }) => { + provisionCalls.push({ mode, projectRef }); return Effect.succeed({ container: "shadow-1", sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", - targetUrlOverride: opts.targetOverride, }); }, + provisionNextShadow: () => Effect.die("provisionNextShadow not used"), removeShadowContainer: (container) => Effect.sync(() => { removedContainers.push(container); }), }); + const explicitDiffCalls: LegacyPgDeltaExplicitDiffInput[] = []; + const databaseDiffCalls: LegacyPgDeltaDatabaseDiffInput[] = []; + const pgDeltaResult = () => { + const sql = opts.diffSql ?? ""; + const files = + opts.diffFiles !== undefined + ? opts.diffFiles.map((file, index) => ({ + sequence: index + 1, + name: file.name, + ...(opts.diffSuffixes?.[index] !== undefined + ? { suffix: opts.diffSuffixes[index] } + : {}), + sql: file.sql, + transactionMode: "transactional" as const, + })) + : sql.length > 0 + ? [ + { + sequence: 1, + name: "schema_changes", + sql, + transactionMode: "transactional" as const, + }, + ] + : []; + return { + changes: files.length > 0, + sql: opts.diffFiles !== undefined ? files.map((file) => file.sql).join("\n\n") : sql, + files, + }; + }; + 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); + return pgDeltaResult(); + }), + diffDatabase: (input) => + Effect.sync(() => { + databaseDiffCalls.push(input); + return pgDeltaResult(); + }), + exportDeclarativeSchema: () => Effect.die("exportDeclarativeSchema unused"), + planDeclarativeSchema: () => Effect.die("planDeclarativeSchema unused"), + }), + ); + const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: (runOpts: LegacyEdgeRuntimeRunOpts) => { @@ -94,28 +143,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { new LegacyEdgeRuntimeScriptError({ message: "Fatal JavaScript out of memory" }), ); } - 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. The migra script - // returns raw SQL unchanged. - const isPgDelta = runOpts.script.includes("renderPlanFiles"); - const planFiles = - opts.diffFiles !== undefined - ? opts.diffFiles.map((file, i) => ({ - order: i + 1, - name: file.name, - transactionMode: "transactional", - sql: file.sql, - })) - : diffSql.length > 0 - ? [{ order: 1, name: "schema_changes", transactionMode: "transactional", sql: diffSql }] - : []; - const stdout = - isPgDelta && planFiles.length > 0 - ? JSON.stringify({ version: 1, files: planFiles }) - : diffSql; - return Effect.succeed({ stdout, stderr: "" }); + return Effect.succeed({ stdout: opts.diffSql ?? "", stderr: "" }); }, }); @@ -174,6 +202,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry.layer, cache.layer, seam, + pgDeltaEngine, edge, docker, dbConnection, @@ -206,8 +235,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry, provisionCalls, removedContainers, - exportCalls, - exportCatalogCalls, + explicitDiffCalls, + databaseDiffCalls, edgeCalls, resolverCalls, proxyCalls, @@ -221,6 +250,7 @@ const flags = (over: Partial = {}): LegacyDbDiffFlags => ({ usePgAdmin: over.usePgAdmin ?? Option.none(), usePgSchema: over.usePgSchema ?? Option.none(), usePgDelta: over.usePgDelta ?? Option.none(), + strictCoverage: over.strictCoverage ?? false, from: over.from ?? Option.none(), to: over.to ?? Option.none(), output: over.output ?? Option.none(), @@ -253,7 +283,7 @@ describe("legacy db diff", () => { const s = setup(tmp.current, { diffSql: "create table players ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags()); - expect(s.provisionCalls).toEqual([{ mode: "diff", targetLocal: true, usePgDelta: false }]); + expect(s.provisionCalls).toEqual([{ mode: "diff", projectRef: undefined }]); expect(stdout(s.out)).toBe("create table players ();\n\n"); expect(stderr(s.out)).toContain("Creating shadow database..."); expect(stderr(s.out)).toContain("Diffing schemas..."); @@ -266,13 +296,66 @@ describe("legacy db diff", () => { it.effect("diffs local with pgdelta when --use-pg-delta is set", () => { const s = setup(tmp.current, { diffSql: "create table p ();\n" }); return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), schema: ["public"] })); - expect(s.provisionCalls).toEqual([{ mode: "diff", targetLocal: true, usePgDelta: true }]); + yield* legacyDbDiff( + flags({ usePgDelta: Option.some(true), strictCoverage: true, schema: ["public"] }), + ); + expect(s.provisionCalls).toEqual([]); + expect(s.databaseDiffCalls).toHaveLength(1); + expect(s.databaseDiffCalls[0]).toMatchObject({ + schema: ["public"], + strictCoverage: true, + target: { + kind: "database", + connection: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", + }, + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + }); + // Even the legacy implementation is hidden behind LegacyPgDeltaEngine; + // the handler no longer invokes edge runtime itself. + 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", () => { + mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db.migrations]", + 'schema_paths = ["configured.sql"]', + "", + "[experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ); + writeFileSync(join(tmp.current, "supabase", "configured.sql"), "create table configured ();\n"); + writeFileSync( + join(tmp.current, "supabase", "database", "ignored.sql"), + "create table ignored ();\n", + ); + const s = setup(tmp.current, { + pgDeltaImplementation: "next", + diffSql: "create table result ();\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); + expect(s.databaseDiffCalls[0]).not.toHaveProperty("declarativeFiles"); + expect(s.databaseDiffCalls[0]).not.toHaveProperty("declarativeManifest"); + expect(stderr(s.out)).toContain("schema_paths no longer changes the migrations baseline"); + expect(stderr(s.out)).not.toContain("db diff -f uses supabase/migrations"); + expect(stdout(s.out)).toBe("create table result ();\n\n"); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("a linked [remotes.] block enabling pg-delta selects the pg-delta engine", () => { // Go loads the project ref before LoadConfig on the linked path, merging the // matching [remotes.] block before experimental.pgdelta.enabled is read @@ -301,10 +384,9 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ linked: Option.some(true) })); - expect(s.provisionCalls[0]?.usePgDelta).toBe(true); - // The shadow is provisioned with the resolved ref so the `db __shadow` child - // merges the same `[remotes.]` override into the shadow baseline. - expect(s.provisionCalls[0]?.projectRef).toBe("abcdefghijklmnopqrst"); + expect(s.provisionCalls).toEqual([]); + expect(s.databaseDiffCalls[0]?.projectRef).toBe("abcdefghijklmnopqrst"); + expect(s.databaseDiffCalls[0]?.target.connectOptions.isLocal).toBe(false); }).pipe(Effect.provide(s.layer)); }); @@ -329,7 +411,6 @@ describe("legacy db diff", () => { const s = setup(tmp.current, { diffSql: "create table players ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags()); - expect(s.provisionCalls[0]?.usePgDelta).toBe(false); // The local default never passes a ref, so the shadow uses base config. expect(s.provisionCalls[0]?.projectRef).toBeUndefined(); }).pipe(Effect.provide(s.layer)); @@ -343,16 +424,13 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ linked: Option.some(true) })); - expect(s.provisionCalls[0]?.targetLocal).toBe(false); + expect(s.provisionCalls[0]?.projectRef).toBe("abcdefghijklmnopqrst"); expect(s.cache.cached).toBe(true); }).pipe(Effect.provide(s.layer)); }); - it.effect("uses the seam's target override for the local declarative branch", () => { - const s = setup(tmp.current, { - targetOverride: "postgres://postgres:postgres@127.0.0.1:54320/contrib_regression", - diffSql: "create table o ();\n", - }); + it.effect("uses the selected local database as the migra target", () => { + const s = setup(tmp.current, { diffSql: "create table o ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags()); expect(stdout(s.out)).toBe("create table o ();\n\n"); @@ -488,16 +566,130 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("writes a timestamped migration when --file is set instead of printing", () => { - const s = setup(tmp.current, { diffSql: "create table f ();\n" }); + it.effect("writes live-only SQL with --file even when declarative targets are configured", () => { + mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db.migrations]", + 'schema_paths = ["database/*.sql"]', + "", + "[experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ); + writeFileSync( + join(tmp.current, "supabase", "database", "declarative.sql"), + "create table declarative_only ();\n", + ); + const s = setup(tmp.current, { + pgDeltaImplementation: "next", + diffSql: "create table live_only ();\n", + }); return Effect.gen(function* () { - yield* legacyDbDiff(flags({ file: Option.some("my_diff") })); + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), file: Option.some("my_diff") })); expect(stdout(s.out)).toBe(""); + expect(stderr(s.out)).toContain("schema_paths no longer changes the migrations baseline"); + expect(stderr(s.out)).toContain("db diff -f uses supabase/migrations as its baseline"); + expect(stderr(s.out)).toContain("-f names the migration; it does not filter objects"); expect(stderr(s.out)).toContain("WARNING: The diff tool is not foolproof"); const dir = join(tmp.current, "supabase", "migrations"); const files = readdirSync(dir); expect(files).toHaveLength(1); expect(files[0]).toMatch(/^\d{14}_my_diff\.sql$/); + expect(readFileSync(join(dir, files[0]!), "utf8")).toBe("create table live_only ();\n"); + }).pipe(Effect.provide(s.layer)); + }); + + for (const format of ["json", "stream-json"] as const) { + it.effect(`includes the ignored declarative baseline advisory in ${format} output`, () => { + mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "database", "items.sql"), + "create table items ();\n", + ); + const s = setup(tmp.current, { + format, + pgDeltaImplementation: "next", + diffSql: "create table dogfood_note ();\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ usePgDelta: Option.some(true), file: Option.some("dogfood_note") }), + ); + const success = s.out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + diff: "create table dogfood_note ();\n", + engine: "pg-delta", + advisories: [ + { + code: "DeclarativeSchemaNotUsedAsDiffBaseline", + severity: "info", + context: { + baseline: "supabase/migrations", + declarativePath: "supabase/database", + fileFlagFiltersObjects: false, + }, + }, + ], + }); + expect(stderr(s.out)).toContain("db diff -f uses supabase/migrations as its baseline"); + const written = readdirSync(join(tmp.current, "supabase", "migrations")); + expect(written).toHaveLength(1); + expect(readFileSync(join(tmp.current, "supabase", "migrations", written[0]!), "utf8")).toBe( + "create table dogfood_note ();\n", + ); + }).pipe(Effect.provide(s.layer)); + }); + } + + it.effect("does not emit the advisory for the legacy pg-delta implementation", () => { + mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "database", "items.sql"), + "create table items ();\n", + ); + const s = setup(tmp.current, { + format: "json", + pgDeltaImplementation: "legacy", + diffSql: "create table dogfood_note ();\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ usePgDelta: Option.some(true), file: Option.some("dogfood_note") }), + ); + const success = s.out.messages.find((message) => message.type === "success"); + expect(success?.data).not.toHaveProperty("advisories"); + expect(stderr(s.out)).not.toContain("db diff -f uses supabase/migrations"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("ignores declarative inspection errors without changing diff success", () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[experimental.pgdelta]", + "enabled = true", + 'declarative_schema_path = "not-a-directory.sql"', + "", + ].join("\n"), + ); + 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* () { + yield* legacyDbDiff( + flags({ usePgDelta: Option.some(true), file: Option.some("dogfood_note") }), + ); + const success = s.out.messages.find((message) => message.type === "success"); + expect(success?.data).not.toHaveProperty("advisories"); + expect(success?.data).toMatchObject({ diff: "create table dogfood_note ();\n" }); + expect(stderr(s.out)).not.toContain("db diff -f uses supabase/migrations"); }).pipe(Effect.provide(s.layer)); }); @@ -531,6 +723,24 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("uses exact next-renderer suffixes for multi-file migration names", () => { + const s = setup(tmp.current, { + diffFiles: [ + { name: "ignored_legacy_name", sql: "a" }, + { name: "ignored_legacy_name", sql: "b" }, + ], + diffSuffixes: ["_1", "_2"], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), file: Option.some("my_diff") })); + const dir = join(tmp.current, "supabase", "migrations"); + expect(readdirSync(dir).sort()).toEqual([ + "19700101000000_my_diff_1.sql", + "19700101000001_my_diff_2.sql", + ]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("creates nested parent directories for a nested single-unit --file name", () => { // `db diff -f snapshots/remote` must create the `_snapshots/` parent dir // before writing, mirroring Go's `utils.WriteFile`. @@ -623,10 +833,54 @@ describe("legacy db diff", () => { yield* legacyDbDiff(flags({ from: Option.some("local"), to: Option.some("linked") })); // Explicit mode is pg-delta and never provisions a shadow. expect(s.provisionCalls).toEqual([]); + expect(s.explicitDiffCalls[0]).toMatchObject({ + source: { + kind: "database", + connection: { + host: "127.0.0.1", + user: "postgres", + database: "postgres", + }, + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + desired: { + kind: "database", + connection: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", + }, + connectOptions: { isLocal: false, dnsResolver: "native" }, + }, + }); expect(stdout(s.out)).toBe("create table e ();\n"); }).pipe(Effect.provide(s.layer)); }); + it.effect("explicit URL endpoints retain the raw ref and remote connection options", () => { + const s = setup(tmp.current, { diffSql: "create table u ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ + from: Option.some("postgresql://source.example/postgres"), + to: Option.some("postgresql://desired.example/postgres"), + }), + ); + expect(s.explicitDiffCalls[0]?.source).toEqual({ + kind: "database", + ref: "postgresql://source.example/postgres", + connectOptions: { isLocal: false, dnsResolver: "native" }, + }); + expect(s.explicitDiffCalls[0]?.desired).toEqual({ + kind: "database", + ref: "postgresql://desired.example/postgres", + connectOptions: { isLocal: false, dnsResolver: "native" }, + }); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("explicit --output writes raw SQL to the given path", () => { const s = setup(tmp.current, { diffSql: "create table w ();\n" }); return Effect.gen(function* () { @@ -685,72 +939,33 @@ describe("legacy db diff", () => { }, ); - it.effect("explicit --from migrations resolves a shadow catalog natively", () => { - // CLI-1959: the migrations ref now resolves via `provisionShadow` (Go's - // unchanged `db __shadow --mode diff`) + a native pg-delta catalog export, - // instead of the retired `exportCatalog({mode:"migrations"})` seam call. + it.effect("explicit --from migrations routes the migrations endpoint to the strategy", () => { const s = setup(tmp.current, { diffSql: "create table m ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("local") })); - expect(s.exportCalls).toEqual([]); - expect(s.provisionCalls).toEqual([ - { mode: "diff", targetLocal: false, usePgDelta: false, projectRef: undefined }, - ]); - // `resolveMigrationsCatalogRef` (Go's `explicit.go:88-126`) calls the shadow - // primitives directly, without `DiffDatabase`'s own progress line — unlike - // `db schema declarative sync`'s `getMigrationsCatalogRef`, which DOES print - // it (`legacy-pgdelta.cache.ts`'s `legacyGetMigrationsCatalogRef`). This - // stderr asymmetry is the parity fix CLI-1959 makes; pin it here even though - // a shadow was actually provisioned on this cache miss. - expect(s.out.stderrText).not.toContain("Creating shadow database..."); + expect(s.explicitDiffCalls[0]?.source).toEqual({ kind: "migrations" }); + expect(s.edgeCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); - it.effect( - "explicit --from migrations reuses an already-cached catalog without provisioning a shadow", - () => { - // A cache pre-warmed by a prior `db push` (`legacyTryCacheMigrationsCatalog`) - // or `db diff --from migrations` run keys off the BARE migrations hash - // (`pgcache.HashMigrations` — no setup-inputs token; see - // `legacyResolveMigrationsCatalogRef`'s doc comment), so it must be reused - // here without spinning up a new shadow database at all. - const noMigrationsHash = createHash("sha256").digest("hex"); - const tempDir = join(tmp.current, "supabase", ".temp", "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - const cachedPath = join(tempDir, `catalog-local-migrations-${noMigrationsHash}-1000.json`); - writeFileSync(cachedPath, '{"cached":true}'); - const s = setup(tmp.current, { diffSql: "create table m ();\n" }); - return Effect.gen(function* () { - yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("local") })); - expect(s.provisionCalls).toEqual([]); - expect(s.exportCalls).toEqual([]); - const diffCall = s.edgeCalls.find((c) => c.script.includes("renderPlanFiles")); - expect(diffCall?.env["SOURCE"]).toBe( - `/workspace/${join("supabase", ".temp", "pgdelta", `catalog-local-migrations-${noMigrationsHash}-1000.json`)}`, - ); - }).pipe(Effect.provide(s.layer)); - }, - ); - - it.effect( - "explicit --from linked --to migrations provisions the shadow with the linked ref", - () => { - // Go resolves linked first (LoadConfig merges [remotes.]), so the later - // migrations catalog is built from the remote-merged config (explicit.go). - const s = setup(tmp.current, { - isLocal: false, - linkedRef: "abcdefghijklmnopqrst", - diffSql: "create table m ();\n", + it.effect("explicit --from linked --to migrations passes the linked ref to the strategy", () => { + // Go resolves linked first (LoadConfig merges [remotes.]), so the later + // migrations catalog is built from the remote-merged config (explicit.go). + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "create table m ();\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ from: Option.some("linked"), to: Option.some("migrations") })); + expect(s.explicitDiffCalls[0]?.desired).toEqual({ + kind: "migrations", + projectRef: "abcdefghijklmnopqrst", }); - return Effect.gen(function* () { - yield* legacyDbDiff(flags({ from: Option.some("linked"), to: Option.some("migrations") })); - const migrations = s.provisionCalls.find((c) => c.mode === "diff" && !c.targetLocal); - expect(migrations?.projectRef).toBe("abcdefghijklmnopqrst"); - }).pipe(Effect.provide(s.layer)); - }, - ); + }).pipe(Effect.provide(s.layer)); + }); - it.effect("explicit --from migrations --to linked provisions the shadow with base config", () => { + it.effect("explicit --from migrations --to linked passes base config to the strategy", () => { // Migrations is resolved BEFORE linked here, so Go's LoadConfig(ref) hasn't run // yet — the catalog must use base config (no ref forwarded), matching order. const s = setup(tmp.current, { @@ -760,8 +975,7 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("linked") })); - const migrations = s.provisionCalls.find((c) => c.mode === "diff" && !c.targetLocal); - expect(migrations?.projectRef).toBeUndefined(); + expect(s.explicitDiffCalls[0]?.source).toEqual({ kind: "migrations" }); }).pipe(Effect.provide(s.layer)); }); @@ -783,8 +997,10 @@ describe("legacy db diff", () => { linked: Option.some(true), }), ); - const migrations = s.provisionCalls.find((c) => c.mode === "diff" && !c.targetLocal); - expect(migrations?.projectRef).toBe("abcdefghijklmnopqrst"); + expect(s.explicitDiffCalls[0]?.desired).toEqual({ + kind: "migrations", + projectRef: "abcdefghijklmnopqrst", + }); }).pipe(Effect.provide(s.layer)); }); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.layers.ts b/apps/cli/src/legacy/commands/db/diff/diff.layers.ts index 8c2ab09380..e53312db93 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.layers.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.layers.ts @@ -12,13 +12,18 @@ import { legacyLinkedDbResolverRuntimeLayer } from "../../../shared/legacy-manag import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { legacyDeclarativeSeamLayer } from "../shared/legacy-pgdelta.seam.layer.ts"; +import { legacyPgDeltaEngineLayer } from "../shared/legacy-pgdelta-engine.layer.ts"; +import { legacyPgDeltaNextAdapterLayer } from "../shared/legacy-pgdelta-next-adapter.layer.ts"; +import { legacyPgDeltaNextShadowLayer } from "../shared/legacy-pgdelta-next-shadow.layer.ts"; /** * Runtime layer for `supabase db diff`. * * Mirrors `db schema declarative generate` (`generate.layers.ts`): the db-config - * resolver plus the native pg-delta / migra stack — the edge-runtime runner, the - * SSL probe, and the Go shadow-database seam (`provisionShadow`). `LegacyDockerRun` + * resolver plus both pg-delta implementations, migra, the SSL probe, and the Go + * shadow-database seam (`provisionShadow`). The default pg-delta runs in-process; + * the edge-runtime runner is retained only for migra and the explicit legacy opt-out. + * `LegacyDockerRun` * is exposed in the merge (not just provided to the edge-runtime layer) because the * migra OOM bash fallback runs the `supabase/migra` container directly. * Per the "provide doesn't share to siblings" rule, `LegacyCliConfig` is provided @@ -42,6 +47,15 @@ const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( ); const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); +const nextShadow = legacyPgDeltaNextShadowLayer.pipe(Layer.provide(seam)); +const pgDeltaEngine = legacyPgDeltaEngineLayer.pipe( + Layer.provide(legacyPgDeltaNextAdapterLayer), + Layer.provide(nextShadow), + Layer.provide(edgeRuntime), + Layer.provide(legacyPgDeltaSslProbeLayer), + Layer.provide(seam), + Layer.provide(legacyDebugLoggerLayer), +); export const legacyDbDiffRuntimeLayer = Layer.mergeAll( dbConfig, @@ -50,6 +64,7 @@ export const legacyDbDiffRuntimeLayer = Layer.mergeAll( edgeRuntime, legacyPgDeltaSslProbeLayer, seam, + pgDeltaEngine, cliConfig, legacyIdentityStitchLayer, legacyTelemetryStateLayer, diff --git a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md index 8e6fcf5d65..7130ced6d6 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -3,6 +3,9 @@ Native Effect port. Pulls the remote schema into either a new timestamped migration (diffing a throwaway shadow against the remote, native pg-delta or migra) or declarative files (`--declarative`, native pg-delta export). The +migration-style path always compares migrations with the selected live database; +declarative files and `[db.migrations].schema_paths` cannot replace its migrations +baseline. 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 @@ -10,7 +13,7 @@ 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 +pg-delta managed-state extraction, 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` @@ -24,14 +27,42 @@ 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 implementation and compatibility + +- Pg-delta diff and declarative export use the in-process engine bundled into the + CLI binary by default. Pg-topo is bundled with it and the version is fixed at + CLI build time; the command never downloads it or falls back automatically. +- `SUPABASE_USE_PG_DELTA_NEXT=false` selects the legacy edge-runtime path. + `PGDELTA_NPM_REGISTRY`, `supabase/.temp/pgdelta-version`, and legacy catalogs + directly below `supabase/.temp/pgdelta/` apply only to that opt-out. +- With `PGDELTA_DEBUG`, default-engine diagnostic data is stored under + `supabase/.temp/pgdelta/v2/debug//` as `metadata.json` plus available + snapshot, plan, and diagnostics JSON files. These artifacts are never catalog + cache inputs. +- The default engine always refuses extraction errors. Coverage gaps + (`unmodeled_kind` or `unresolved_security_label`) warn and remain unmanaged by + default; `--strict-coverage` turns them into a refusal. Declarative warnings make + clear that unsupported objects are absent from the exported files. Debug + artifacts are saved before policy evaluation when capture is enabled. +- New-engine SQL bytes and transaction-split filenames may differ. Successful + execution and convergence on a subsequent pull/diff are the contract. +- Default-engine migration and declarative SQL retains pg-delta's safe compaction + and uses its human-facing formatter (lowercase keywords, max width 180). A JSON + object in `[experimental.pgdelta].format_options` partially overrides the + preset; the JSON literal `null` disables formatting without disabling + compaction. + ## Files Read -| Path | Format | When | -| -------------------------------------- | ---------- | --------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`) | -| `/supabase/migrations/*.sql` | SQL | history reconciliation + shadow provisioning | -| `~/.supabase/access-token` | plain text | linked target with no `SUPABASE_ACCESS_TOKEN` | -| `/supabase/.temp/project-ref` | plain text | linked ref resolution | +| Path | Format | When | +| ----------------------------------------------- | ---------- | --------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`) | +| `/supabase/migrations/*.sql` | SQL | history reconciliation + shadow provisioning | +| `~/.supabase/access-token` | plain text | linked target with no `SUPABASE_ACCESS_TOKEN` | +| `/supabase/.temp/project-ref` | plain text | linked ref resolution | +| `/supabase/.temp/pgdelta-version` | plain text | always read for compatibility; affects legacy only | +| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out only: edge-runtime image tag | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out only: migrations/baseline catalogs | ## Files Written @@ -39,13 +70,17 @@ Notes/Delegation section below). | ---------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff, or the initial-migra `pg_dump` seed) | | `/supabase/database/**` | SQL | `--declarative` | +| `/supabase/database/.pgdelta-export.json` | JSON | default-engine `--declarative` export metadata | +| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | legacy opt-out only: catalog snapshots | +| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy opt-out only: Supabase TLS target | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | default pg-delta 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//linked-project.json` | JSON | linked (post-run cache) | | `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker -- Edge-runtime container (pg-delta export / pg-delta or migra diff). +- Edge-runtime container (migra, or pg-delta only under the legacy opt-out). - Shadow Postgres container (provisioned + torn down via the Go `db __shadow` seam). - `supabase/migra` container — the migra OOM bash fallback only. - `pg_dump` container — the initial-migra pull's native remote-schema dump @@ -69,7 +104,8 @@ Notes/Delegation section below). | `SUPABASE_DB_PASSWORD` | remote DB password (overridden by `-p`) | no | | `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta diff engine | no | | `SUPABASE_EXPERIMENTAL` | selects the deprecated structured-dump branch (still delegates to Go, see below) | no | -| `PGDELTA_NPM_REGISTRY` | scoped npm registry for edge-runtime | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for the legacy edge-runtime engine | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out only: scoped npm registry for edge-runtime | no | ## Exit Codes @@ -93,6 +129,9 @@ written to `. Plus the `--use-pg-delta` deprecation line, the prompt. On success the PostRun line `Finished supabase db pull.` is printed to stdout. +A configured `[db.migrations].schema_paths` prints a transition warning on the +migration path directing users to `supabase db schema declarative sync`. + ### `--output-format json` / `stream-json` Progress strings still go to stderr; stdout carries a single structured envelope @@ -104,6 +143,8 @@ Progress strings still go to stderr; stdout carries a single structured envelope - `--declarative` / deprecated `--use-pg-delta` are mutually exclusive with `--diff-engine`; `--db-url` / `--linked` (default) / `--local` are a target group. - `--use-pg-delta` is hidden and emits the cobra deprecation line to stderr. +- `--strict-coverage` applies to bundled pg-delta diff and declarative-export paths; + it refuses output when pg-delta encounters schema objects it cannot manage. - The initial-migra pull (no local migrations) is native: it streams a `pg_dump` of the remote schema into the migration file, then appends the migra diff. An empty diff after a non-empty dump is swallowed (Go's `swallowInitialInSync`); an empty diff --git a/apps/cli/src/legacy/commands/db/pull/pull.command.ts b/apps/cli/src/legacy/commands/db/pull/pull.command.ts index d024c5e789..0878af696a 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.command.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.command.ts @@ -18,7 +18,7 @@ const config = { // pflag `Changed`. declarative: Flag.boolean("declarative").pipe( Flag.withDescription( - "Pull schema as declarative files using pg-delta instead of creating a migration.", + "Replace the declarative schema tree from the selected database instead of creating a migration; migration history is not updated.", ), Flag.optional, ), @@ -34,6 +34,11 @@ const config = { Flag.withDescription("Diff engine to use for migration-style db pull."), Flag.optional, ), + strictCoverage: Flag.boolean("strict-coverage").pipe( + Flag.withDescription( + "Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.", + ), + ), schema: Flag.string("schema").pipe( Flag.withAlias("s"), Flag.withDescription("Comma separated list of schema to include."), @@ -67,7 +72,9 @@ const config = { export type LegacyDbPullFlags = CliCommand.Command.Config.Infer; export const legacyDbPullCommand = Command.make("pull", config).pipe( - Command.withDescription("Pull schema from the remote database."), + Command.withDescription( + "Migration mode compares supabase/migrations with the selected live database (--linked by default), writes the complete difference as migration files, and may record them in that database's migration history. --declarative instead replaces the declarative schema tree and does not create migrations or update migration history.", + ), Command.withShortDescription("Pull schema from the remote database"), Command.withHandler((flags) => legacyDbPull(flags).pipe( @@ -76,6 +83,7 @@ export const legacyDbPullCommand = Command.make("pull", config).pipe( declarative: flags.declarative, "use-pg-delta": flags.usePgDelta, "diff-engine": flags.diffEngine, + "strict-coverage": flags.strictCoverage, schema: flags.schema, "db-url": flags.dbUrl, linked: flags.linked, diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index 75a4716de8..c3891be574 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -42,6 +42,7 @@ import { legacyParseBoolEnv, legacyResolveDeclarativeFromArgs, legacyResolvePullDiffEngine, + legacySchemaPathsTransitionWarning, legacyShouldUsePgDelta, } from "../../../shared/legacy-diff-engine.ts"; import { legacyDiffMigra } from "../shared/legacy-migra.ts"; @@ -58,12 +59,13 @@ import { legacyFormatMigrationTimestamp, legacyGetMigrationPath, } from "../../../shared/legacy-migration-file.ts"; -import { legacyFormatDebugId } from "../shared/legacy-debug-bundle.ts"; +import { legacyDebugBundleMessage, legacyFormatDebugId } from "../shared/legacy-debug-bundle.ts"; +import { + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseEndpoint, +} from "../shared/legacy-pgdelta-engine.service.ts"; import { type LegacyPgDeltaContext, - legacyDeclarativeExportPgDelta, - legacyDiffPgDelta, - legacyExportCatalogPgDelta, legacyIsPgDeltaDebugEnabled, } from "../../../shared/legacy-pgdelta.ts"; import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; @@ -160,6 +162,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy const resolver = yield* LegacyDbConfigResolver; const connection = yield* LegacyDbConnection; const seam = yield* LegacyDeclarativeSeam; + const pgDeltaEngine = yield* LegacyPgDeltaEngine; const proxy = yield* LegacyGoProxy; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; @@ -304,9 +307,15 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // `db..` connection (Go's `PoolerFallbackEligible` + // `ProjectRefFromDirectDbHost`). The error message embeds the container stderr // (edge-runtime/migra errors wrap it), which is what Go classifies. + const targetEndpoint: LegacyPgDeltaDatabaseEndpoint = { + kind: "database", + ref: targetUrl, + connection: resolved.conn, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }; const withPoolerFallback = ( - directTarget: string, - attempt: (targetRef: string) => Effect.Effect, + directTarget: LegacyPgDeltaDatabaseEndpoint, + attempt: (target: LegacyPgDeltaDatabaseEndpoint) => Effect.Effect, ) => attempt(directTarget).pipe( Effect.catch((error) => @@ -333,7 +342,12 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy .pipe(Effect.orElseSucceed(() => Option.none())); if (Option.isSome(pooler)) { yield* legacyEmitPoolerFallbackWarning(resolved.conn.host); - return yield* attempt(legacyToPostgresURL(pooler.value)); + return yield* attempt({ + kind: "database", + ref: legacyToPostgresURL(pooler.value), + connection: pooler.value, + connectOptions: { isLocal: false, dnsResolver }, + }); } } return yield* Effect.fail(error); @@ -407,23 +421,18 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy yield* output.raw("Preparing declarative schema export using pg-delta...\n", "stderr"); const declarativeDirRel = legacyResolveDeclarativeDir(path, toml.pgDelta); const declarativeDir = path.resolve(cliConfig.workdir, declarativeDirRel); - const shadow = yield* seam.provisionShadow({ - mode: "declarative", - targetLocal: false, - usePgDelta: true, - schema: flags.schema, - // Linked path only: merge the same `[remotes.]` override into the - // shadow baseline (Go builds the shadow from the remote-merged config). - projectRef: connType === "linked" ? linkedRef : undefined, - }); - const exported = yield* withPoolerFallback(targetUrl, (targetRef) => - legacyDeclarativeExportPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef, + const exported = yield* withPoolerFallback(targetEndpoint, (target) => + pgDeltaEngine.exportDeclarativeSchema({ + context: ctx, + target, schema: flags.schema, formatOptions, + projectRef: connType === "linked" ? linkedRef : undefined, + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, + noCache: false, }), - ).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + ); yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, exported).pipe( Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), ); @@ -432,8 +441,9 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // (declarative.go:260-268, gated on IsPgDeltaEnabled which reads the config // value). db pull --declarative does not force-enable pg-delta // (cmd/db.go:180-182), so unlike generate/sync this branch is reachable: - // without it, subsequent db reset/db diff keep reading supabase/migrations - // and ignore the files just pulled. + // it preserves the legacy experimental db-reset schema-files workflow. + // Normal db diff and migration-style db pull still use migrations as + // their baseline and ignore this setting. if (!toml.pgDelta.enabled) { yield* legacyUpdateDeclarativeSchemaPathsConfig( fs, @@ -465,6 +475,14 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy return; } + if ( + !delegatesExperimentalPull && + toml.schemaPaths !== undefined && + toml.schemaPaths.length > 0 + ) { + yield* output.raw(legacySchemaPathsTransitionWarning, "stderr"); + } + // Go's `EXPERIMENTAL` structured-dump branch (`pull.go:49-61`) stays // delegated to Go. pg_dump itself is now native (used by the initial-migra // path below), but this branch also calls `format.WriteStructuredSchemas` @@ -472,7 +490,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // dumped statement with a PostgreSQL DDL AST parser (`multigres`, ~50 node // types) to route objects into structured files. No Postgres DDL parser // exists in TS yet, and `--declarative` already covers the same per-object - // outcome via pg-delta catalog introspection, so this path is deprecated + // outcome via pg-delta managed-state extraction, so this path is deprecated // rather than ported (CLI-1957) — see the deprecation line printed above. if (delegatesExperimentalPull) { // Go's structured-dump path returns before writing a migration or @@ -621,76 +639,45 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // (`internal/db/diff/diff.go:189,234-237`); the shadow seam doesn't, so the // pull handler emits them itself to match the migration-style `db pull` output. yield* output.raw("Creating shadow database...\n", "stderr"); - const shadow = yield* seam.provisionShadow({ - mode: "diff", - // Mirror Go's `DiffDatabase` → `PrepareShadowSource(ctx, schema, - // utils.IsLocalDatabase(config), …)` (`internal/db/diff/diff.go:190`): - // a local target with declarative schema files gets a second - // `contrib_regression` shadow returned as the target override. - targetLocal: resolved.isLocal, - usePgDelta: usePgDeltaDiff, - schema: diffSchema, - // Linked path only: merge the same `[remotes.]` override into the - // shadow baseline (Go builds the shadow from the remote-merged config). - projectRef: connType === "linked" ? linkedRef : undefined, - }); - const diffOutcome = yield* Effect.gen(function* () { - // Use the declarative target override when present (Go substitutes it - // for the diff target, `diff.go:196-197`); for remote pulls it's - // undefined, so this is the direct target URL as before. - const target = shadow.targetUrlOverride ?? targetUrl; - yield* output.raw( - diffSchema.length > 0 - ? `Diffing schemas: ${diffSchema.join(",")}\n` - : "Diffing schemas...\n", - "stderr", - ); - return yield* withPoolerFallback(target, (targetRef) => - // Wrap the engine choice in a gen so both branches' error/requirement - // channels unify into one `Effect` the helper can retry generically. - Effect.gen(function* () { - if (usePgDeltaDiff) { - // With PGDELTA_DEBUG set, capture the shadow baseline catalog so an - // empty diff can be inspected later (Go's DiffDatabase, - // `internal/db/diff/diff.go:205-214`); a failed export only warns. - const debug = legacyIsPgDeltaDebugEnabled(); - const sourceCatalog = debug - ? yield* legacyExportCatalogPgDelta(ctx, { - targetRef: shadow.sourceUrl, - role: "postgres", - }).pipe( - Effect.catch((error) => - output - .raw( - `Warning: failed to export shadow pg-delta catalog: ${error.message}\n`, - "stderr", - ) - .pipe(Effect.as(undefined)), - ), - ) - : undefined; - const result = yield* legacyDiffPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef, - schema: diffSchema, - formatOptions, - }); - return { - sql: result.sql, - files: result.files, - capture: debug ? { sourceCatalog, stderr: result.stderr } : undefined, - }; - } - const sql = yield* legacyDiffMigra(ctx, { + yield* output.raw( + diffSchema.length > 0 + ? `Diffing schemas: ${diffSchema.join(",")}\n` + : "Diffing schemas...\n", + "stderr", + ); + const diffOutcome = usePgDeltaDiff + ? yield* withPoolerFallback(targetEndpoint, (target) => + pgDeltaEngine.diffDatabase({ + context: ctx, + target, + schema: diffSchema, + formatOptions, + projectRef: connType === "linked" ? linkedRef : undefined, + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, + }), + ) + : yield* Effect.gen(function* () { + const shadow = yield* seam.provisionShadow({ + mode: "diff", + schema: diffSchema, + projectRef: connType === "linked" ? linkedRef : undefined, + }); + return yield* legacyDiffMigra(ctx, { source: shadow.sourceUrl, - target: targetRef, + target: targetUrl, schema: diffSchema, connectOptions: { isLocal: resolved.isLocal, dnsResolver }, - }); - return { sql, files: undefined, capture: undefined }; - }), - ); - }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + }).pipe( + Effect.map((sql) => ({ + changes: sql.trim().length > 0, + sql, + files: [], + debug: undefined, + })), + Effect.ensuring(seam.removeShadowContainer(shadow.container)), + ); + }); const out = diffOutcome.sql; const diffEmpty = out.trim().length === 0; @@ -702,13 +689,13 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // Go saves a pg-delta debug bundle and embeds its path in the in-sync // error when PGDELTA_DEBUG is set (`internal/db/pull/pull.go:176-185`); a // bundle-save failure falls through to the plain in-sync error. - if (diffOutcome.capture !== undefined) { + if (pgDeltaEngine.implementation === "legacy" && diffOutcome.debug !== undefined) { const debugDir = yield* legacySaveEmptyPgDeltaPullDebug({ ctx, conn: resolved.conn, targetUrl, - sourceCatalog: diffOutcome.capture.sourceCatalog, - pgDeltaStderr: diffOutcome.capture.stderr, + sourceCatalog: diffOutcome.debug.sourceSnapshot, + pgDeltaStderr: diffOutcome.debug.stderr, id: legacyFormatDebugId(yield* Clock.currentTimeMillis), fs, path, @@ -731,6 +718,17 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ); } } + if ( + pgDeltaEngine.implementation === "next" && + diffOutcome.debug?.directory !== undefined + ) { + yield* output.raw(legacyDebugBundleMessage(diffOutcome.debug.directory), "stderr"); + return yield* Effect.fail( + new LegacyDbPullInSyncError({ + message: `No schema changes found (debug bundle: ${diffOutcome.debug.directory})`, + }), + ); + } return yield* Effect.fail( new LegacyDbPullInSyncError({ message: "No schema changes found" }), ); @@ -756,7 +754,12 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy workdir: cliConfig.workdir, baseMillis: nowMillis, name, - files: planFiles.map((file) => ({ name: file.name, sql: file.sql })), + files: planFiles.map((file) => ({ + name: file.name, + suffix: file.suffix, + sql: file.sql, + transactionMode: file.transactionMode, + })), }).pipe( Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), ); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 128205371d..80e1e6531f 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -37,6 +37,10 @@ import { LegacyEdgeRuntimeScript, } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { + LegacyPgDeltaEngine, + LegacyPgDeltaEngineError, +} from "../shared/legacy-pgdelta-engine.service.ts"; import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { legacyDbPull } from "./pull.handler.ts"; @@ -63,6 +67,8 @@ const pgDeltaDiffEnvelope = ( }); interface SetupOpts { + readonly engineImplementation?: "next" | "legacy"; + readonly nextDebugDirectory?: string; readonly format?: OutputFormat; readonly remoteVersions?: ReadonlyArray; readonly edgeStdout?: string; // diff SQL or declarative export JSON @@ -71,7 +77,6 @@ interface SetupOpts { readonly pipedAnswers?: ReadonlyArray; readonly yes?: boolean; readonly experimental?: boolean; - readonly shadowTargetOverride?: string; readonly promptConfirmResponses?: ReadonlyArray; readonly resolvedRef?: string; // Fail the first edge-runtime run with this message (the second succeeds with @@ -110,8 +115,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const provisionCalls: Array<{ mode: string; - usePgDelta: boolean; - targetLocal: boolean; projectRef?: string; }> = []; const removedContainers: string[] = []; @@ -119,20 +122,134 @@ function setup(workdir: string, opts: SetupOpts = {}) { exportCatalog: () => Effect.succeed("supabase/.temp/pgdelta/x.json"), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, - provisionShadow: ({ mode, usePgDelta, targetLocal, projectRef }) => { - provisionCalls.push({ mode, usePgDelta, targetLocal, projectRef }); + provisionShadow: ({ mode, projectRef }) => { + provisionCalls.push({ mode, projectRef }); return Effect.succeed({ container: "shadow-1", sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", - targetUrlOverride: opts.shadowTargetOverride, }); }, + provisionNextShadow: () => Effect.die("provisionNextShadow not used"), removeShadowContainer: (container) => Effect.sync(() => { removedContainers.push(container); }), }); + const engineCalls: Array<{ + operation: "diff" | "export"; + targetRef: string; + projectRef?: string; + strictCoverage: boolean; + }> = []; + let engineDiffCount = 0; + const pgDeltaEngine = Layer.succeed( + LegacyPgDeltaEngine, + LegacyPgDeltaEngine.of({ + implementation: opts.engineImplementation ?? "legacy", + diffExplicit: () => Effect.die("diffExplicit unused"), + diffDatabase: (input) => { + engineCalls.push({ + operation: "diff", + targetRef: input.target.ref, + projectRef: input.projectRef, + strictCoverage: input.strictCoverage, + }); + engineDiffCount += 1; + if (opts.edgeFailFirstWith !== undefined && engineDiffCount === 1) { + return Effect.fail( + new LegacyPgDeltaEngineError({ + message: opts.edgeFailFirstWith, + cause: opts.edgeFailFirstWith, + }), + ); + } + const stdout = opts.edgeStdout ?? ""; + if (stdout.trim().length === 0) { + return Effect.succeed({ + changes: false, + sql: "", + files: [], + ...(process.env["PGDELTA_DEBUG"] !== undefined + ? { + debug: + opts.engineImplementation === "next" + ? { + sourceSnapshot: opts.catalogStdout ?? "", + ...(opts.nextDebugDirectory !== undefined + ? { directory: opts.nextDebugDirectory } + : {}), + } + : { sourceSnapshot: opts.catalogStdout ?? "", stderr: "" }, + } + : {}), + }); + } + try { + const parsed: unknown = JSON.parse(stdout); + if (typeof parsed !== "object" || parsed === null) throw new Error("invalid envelope"); + const rawFiles = Reflect.get(parsed, "files"); + if (!Array.isArray(rawFiles)) throw new Error("invalid envelope"); + const files = rawFiles.map((raw, index) => { + if (typeof raw !== "object" || raw === null) throw new Error("invalid file"); + const sql = Reflect.get(raw, "sql"); + const name = Reflect.get(raw, "name"); + const transactionMode = Reflect.get(raw, "transactionMode"); + if (typeof sql !== "string" || typeof name !== "string") { + throw new Error("invalid file"); + } + if (transactionMode !== "transactional" && transactionMode !== "none") { + throw new Error(`unknown transaction mode ${String(transactionMode)}`); + } + return { + sequence: index + 1, + name, + sql, + transactionMode, + }; + }); + return Effect.succeed({ + changes: files.length > 0, + sql: files.map((file) => file.sql).join("\n"), + files, + }); + } catch (cause) { + return Effect.fail( + new LegacyPgDeltaEngineError({ + message: "failed to parse pg-delta diff output", + cause, + }), + ); + } + }, + exportDeclarativeSchema: (input) => { + engineCalls.push({ + operation: "export", + targetRef: input.target.ref, + projectRef: input.projectRef, + strictCoverage: input.strictCoverage, + }); + if (opts.edgeFailFirstWith !== undefined && engineCalls.length === 1) { + return Effect.fail( + new LegacyPgDeltaEngineError({ + message: opts.edgeFailFirstWith, + cause: opts.edgeFailFirstWith, + }), + ); + } + return Effect.succeed({ + files: [{ name: "schemas/public/t.sql", sql: "create table t ();" }], + manifest: { + redactSecrets: true, + scope: "database", + profile: "supabase", + }, + }); + }, + planDeclarativeSchema: () => Effect.die("planDeclarativeSchema unused"), + }), + ); + let edgeRunCount = 0; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: (runOpts: LegacyEdgeRuntimeRunOpts) => { @@ -259,6 +376,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry.layer, cache.layer, seam, + pgDeltaEngine, edge, docker, dbConnection, @@ -302,6 +420,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { execLog, poolerFallbackCalls, dumpCalls, + engineCalls, get edgeRunCount() { return edgeRunCount; }, @@ -313,6 +432,7 @@ const flags = (over: Partial = {}): LegacyDbPullFlags => ({ declarative: over.declarative ?? Option.none(), usePgDelta: over.usePgDelta ?? Option.none(), diffEngine: over.diffEngine ?? Option.none(), + strictCoverage: over.strictCoverage ?? false, schema: over.schema ?? [], dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? Option.none(), @@ -350,7 +470,7 @@ describe("legacy db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })); + yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta"), strictCoverage: true })); const dir = join(tmp.current, "supabase", "migrations"); expect(existsSync(join(dir, `${"20240101000000"}_local.sql`))).toBe(true); // A single-unit plan keeps the unchanged `_remote_schema.sql` filename. @@ -365,6 +485,10 @@ describe("legacy db pull", () => { ); expect(streamText(s.out, "stderr")).not.toContain(tmp.current); expect(s.historyUpserts.length).toBe(1); + expect(s.engineCalls).toHaveLength(1); + expect(s.engineCalls[0]?.operation).toBe("diff"); + expect(s.engineCalls[0]?.strictCoverage).toBe(true); + expect(s.edgeRunCount).toBe(0); expect(streamText(s.out, "stdout")).toContain("Finished supabase db pull."); // The linked ref is pre-loaded (cheap, local-only) before `resolve()` runs, so // the post-run linked-project cache still gets the ref Go would cache via @@ -507,8 +631,12 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("pulls with the default migra engine", () => { + it.effect("pulls with migra and warns that schema_paths cannot replace the target", () => { seedMigration(tmp.current, "20240101000000"); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[db.migrations]", 'schema_paths = ["database/*.sql"]', ""].join("\n"), + ); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", @@ -516,8 +644,9 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags()); - expect(s.provisionCalls[0]?.usePgDelta).toBe(false); + expect(s.provisionCalls[0]?.mode).toBe("diff"); const err = streamText(s.out, "stderr"); + expect(err).toContain("schema_paths no longer changes the migrations baseline"); // Go's `ConnectByConfig` prints the Connecting line to stderr before dialing // (`internal/utils/connect.go:348`), ahead of any other pull output. expect(err).toContain("Connecting to remote database...\n"); @@ -534,7 +663,10 @@ describe("legacy db pull", () => { it.effect("pull --declarative exports declarative files (no migration)", () => { const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { - yield* legacyDbPull(flags({ declarative: Option.some(true) })); + yield* legacyDbPull(flags({ declarative: Option.some(true), strictCoverage: true })); + expect(s.engineCalls[0]?.operation).toBe("export"); + expect(s.engineCalls[0]?.strictCoverage).toBe(true); + expect(s.edgeRunCount).toBe(0); const err = streamText(s.out, "stderr"); // Go's order: `ConnectByConfig` prints Connecting (`pull.go:40`), then // `pullDeclarativePgDelta` prints Preparing (`pull.go:93`). @@ -549,7 +681,17 @@ describe("legacy db pull", () => { expect( existsSync(join(tmp.current, "supabase", "database", "schemas", "public", "t.sql")), ).toBe(true); - expect(s.provisionCalls[0]?.mode).toBe("declarative"); + expect( + JSON.parse( + readFileSync(join(tmp.current, "supabase", "database", ".pgdelta-export.json"), "utf8"), + ), + ).toMatchObject({ + formatVersion: 1, + redactSecrets: true, + scope: "database", + files: ["schemas/public/t.sql"], + }); + expect(s.provisionCalls).toHaveLength(0); }).pipe(Effect.provide(s.layer)); }); @@ -666,7 +808,8 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true), usePgDelta: Option.some(true) })); - expect(s.provisionCalls[0]?.mode).toBe("declarative"); + expect(s.provisionCalls).toHaveLength(0); + expect(s.engineCalls[0]?.operation).toBe("export"); }).pipe(Effect.provide(s.layer)); }); @@ -699,7 +842,7 @@ describe("legacy db pull", () => { expect(s.dumpCalls[0]?.env["EXTRA_SED"]).toBe("/^--/d"); expect(s.dumpCalls[0]?.env["EXCLUDED_SCHEMAS"]).toContain("auth"); // The diff ran against the shadow with the migra engine (no schema filter). - expect(s.provisionCalls[0]?.usePgDelta).toBe(false); + expect(s.provisionCalls[0]?.mode).toBe("diff"); // The migration file holds the dump output followed by the appended diff. const dir = join(tmp.current, "supabase", "migrations"); const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); @@ -933,6 +1076,40 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("reports the next-generation debug directory for an empty pg-delta diff", () => { + seedMigration(tmp.current, "20240101000000"); + const debugDir = join( + tmp.current, + "supabase", + ".temp", + "pgdelta", + "v2", + "debug", + "20240102-030405-678-diff", + ); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "", + engineImplementation: "next", + nextDebugDirectory: debugDir, + }); + return Effect.gen(function* () { + const previous = 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).toBe(`No schema changes found (debug bundle: ${debugDir})`); + expect(streamText(s.out, "stderr")).toContain(`Debug information saved to`); + expect(streamText(s.out, "stderr")).toContain(debugDir); + } finally { + if (previous === undefined) delete process.env["PGDELTA_DEBUG"]; + else process.env["PGDELTA_DEBUG"] = previous; + } + }).pipe(Effect.provide(s.layer)); + }); + it.effect("prompts to update history and inserts on yes (tty)", () => { seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { @@ -1470,24 +1647,20 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags()); - expect(s.provisionCalls[0]?.usePgDelta).toBe(true); + expect(s.engineCalls[0]?.operation).toBe("diff"); }).pipe(Effect.provide(s.layer)); }); - it.effect("db pull --local provisions a local-target shadow and uses the target override", () => { - // Go derives the shadow targetLocal from utils.IsLocalDatabase and substitutes - // the declarative contrib_regression target override (diff.go:190,196-197); - // the native handler must pass targetLocal and honor shadow.targetUrlOverride. + it.effect("db pull --local diffs migrations against the selected local database", () => { seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", yes: true, - shadowTargetOverride: "postgres://postgres:postgres@127.0.0.1:54320/contrib_regression", }); return Effect.gen(function* () { yield* legacyDbPull(flags({ local: Option.some(true) })); - expect(s.provisionCalls[0]?.targetLocal).toBe(true); + expect(s.provisionCalls[0]).toEqual({ mode: "diff", projectRef: undefined }); // A local target prints the local wording (Go's `IsLocalDatabase` branch in // `ConnectByConfigStream`, `internal/utils/connect.go:344-346`). expect(streamText(s.out, "stderr")).toContain("Connecting to local database...\n"); @@ -1597,10 +1770,10 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags({ linked: Option.some(true) })); - expect(s.provisionCalls[0]?.usePgDelta).toBe(true); - // The resolved ref is forwarded to the shadow so the `db __shadow` child - // merges the same `[remotes.]` override into the shadow baseline. - expect(s.provisionCalls[0]?.projectRef).toBe("abcdefghijklmnopqrst"); + expect(s.engineCalls[0]?.operation).toBe("diff"); + // The resolved ref is forwarded through the strategy so the selected + // implementation can build the remote-merged shadow baseline. + expect(s.engineCalls[0]?.projectRef).toBe("abcdefghijklmnopqrst"); }).pipe(Effect.provide(s.layer)); }); @@ -1623,7 +1796,7 @@ describe("legacy db pull", () => { ); expect(streamText(s.out, "stderr")).toContain("does not support IPv6"); expect(streamText(s.out, "stderr")).toContain("Retrying via the IPv4 connection pooler"); - expect(s.edgeRunCount).toBe(2); + expect(s.engineCalls.filter((call) => call.operation === "diff")).toHaveLength(2); expect(streamText(s.out, "stderr")).toMatch( /Schema written to supabase[/\\]migrations[/\\]\d{14}_remote_schema\.sql\n/u, ); @@ -1641,7 +1814,7 @@ describe("legacy db pull", () => { return Effect.gen(function* () { yield* legacyDbPull(flags({ linked: Option.some(true), declarative: Option.some(true) })); expect(streamText(s.out, "stderr")).toContain("Retrying via the IPv4 connection pooler"); - expect(s.edgeRunCount).toBe(2); + expect(s.engineCalls.filter((call) => call.operation === "export")).toHaveLength(2); expect(streamText(s.out, "stderr")).toContain( `Declarative schema written to ${join("supabase", "database")}\n`, ); @@ -1664,7 +1837,7 @@ describe("legacy db pull", () => { ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(streamText(s.out, "stderr")).not.toContain("Retrying via the IPv4 connection pooler"); - expect(s.edgeRunCount).toBe(1); + expect(s.engineCalls.filter((call) => call.operation === "diff")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); @@ -1684,7 +1857,7 @@ describe("legacy db pull", () => { ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(s.poolerFallbackCalls).toHaveLength(0); - expect(s.edgeRunCount).toBe(1); + expect(s.engineCalls.filter((call) => call.operation === "diff")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.layers.ts b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts index 821fd07acd..6a9401ac98 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.layers.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts @@ -13,11 +13,15 @@ import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-p import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyDeclarativeSeamLayer } from "../shared/legacy-pgdelta.seam.layer.ts"; +import { legacyPgDeltaEngineLayer } from "../shared/legacy-pgdelta-engine.layer.ts"; +import { legacyPgDeltaNextAdapterLayer } from "../shared/legacy-pgdelta-next-adapter.layer.ts"; +import { legacyPgDeltaNextShadowLayer } from "../shared/legacy-pgdelta-next-shadow.layer.ts"; /** * Runtime layer for `supabase db pull`. Same composition as `db diff`: the - * db-config resolver, the native pg-delta / migra stack (edge-runtime, SSL probe, - * the Go shadow seam), `LegacyDbConnection` (remote connect + `schema_migrations` + * db-config resolver, both pg-delta implementations, migra, the SSL probe, and + * the Go shadow seam. The default pg-delta runs in-process; edge-runtime remains + * for migra and the explicit legacy opt-out. `LegacyDbConnection` (remote connect + `schema_migrations` * reconciliation / history update), and `LegacyDockerRun` for the migra fallback. */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); @@ -35,6 +39,15 @@ const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( ); const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); +const nextShadow = legacyPgDeltaNextShadowLayer.pipe(Layer.provide(seam)); +const pgDeltaEngine = legacyPgDeltaEngineLayer.pipe( + Layer.provide(legacyPgDeltaNextAdapterLayer), + Layer.provide(nextShadow), + Layer.provide(edgeRuntime), + Layer.provide(legacyPgDeltaSslProbeLayer), + Layer.provide(seam), + Layer.provide(legacyDebugLoggerLayer), +); export const legacyDbPullRuntimeLayer = Layer.mergeAll( dbConfig, @@ -43,6 +56,7 @@ export const legacyDbPullRuntimeLayer = Layer.mergeAll( edgeRuntime, legacyPgDeltaSslProbeLayer, seam, + pgDeltaEngine, cliConfig, legacyIdentityStitchLayer, legacyTelemetryStateLayer, diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index d193dd88b6..58f37c142d 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -4,26 +4,33 @@ Native TypeScript port of `apps/cli-go/internal/db/push/push.go`. Applies pendin local migrations (and optionally seed data and custom roles) to the local or linked/remote Postgres database. +Pg-delta's default bundled engine has no reusable migrations-catalog consumer, +so a normal push does not warm a pg-delta cache or start edge-runtime. Setting +`SUPABASE_USE_PG_DELTA_NEXT=false` preserves Go's legacy best-effort catalog +warmup. Legacy catalogs remain directly under `supabase/.temp/pgdelta/` and are +never read by the default engine. + ## Files Read -| Path | Format | When | -| ------------------------------------- | ---------- | ----------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (embedded defaults used when absent) | -| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | -| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | -| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | -| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | -| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | +| Path | Format | When | +| ------------------------------------------ | ---------- | ----------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (embedded defaults used when absent) | +| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | +| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | +| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | +| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | +| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | +| `/supabase/.temp/pgdelta-version` | plain text | always read for compatibility; affects legacy opt-out only | ## Files Written -| Path | Format | When | -| ------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | on the `--linked` path (post-run cache, Go's `ensureProjectGroupsCached`) | -| `~/.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`); a failure only warns on stderr and never fails the push (Go's `pgcache.TryCacheMigrationsCatalog`) | -| `/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, Go's `ensureProjectGroupsCached`) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | +| `/supabase/.temp/pgdelta/catalog--migrations--.json` | JSON | legacy opt-out only, best-effort after a successful migration apply when pg-delta is enabled; failure only warns (Go's `pgcache.TryCacheMigrationsCatalog`) | +| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy opt-out cache export when the target requires SSL | ## Database Mutations @@ -43,14 +50,15 @@ linked/remote Postgres database. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | -| `SUPABASE_YES` | auto-confirm prompts (Go's `viper YES`) | no (also `--yes`) | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the migrations-catalog cache when `[experimental.pgdelta].enabled` is unset | no (project `.env` or shell) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the pg-delta edge-runtime image registry for the cache export | no (project `.env` or shell) | -| `PGDELTA_NPM_REGISTRY` | overrides the pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward) for the cache export | no (project `.env` or shell) | +| Variable | Purpose | Required? | +| ---------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | +| `SUPABASE_YES` | auto-confirm prompts (Go's `viper YES`) | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the legacy opt-out cache when `[experimental.pgdelta].enabled` is unset | no (project `.env` or shell) | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` to retain the legacy migrations-catalog warmup | no (project `.env` or shell) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | legacy opt-out only: overrides the edge-runtime image registry for the cache export | no (project `.env` or shell) | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out only: overrides the edge-runtime npm registry for the cache export | no (project `.env` or shell) | ## Exit Codes @@ -117,12 +125,15 @@ stdout is payload-only. A single `result` object is emitted: directly into TS in PR supabase/cli#5671 (landed on develop as `b48fad60`) and back-ported to the pinned `apps/cli-go` oracle under the CLI-1989 parity ruling (2026-07-30). -- **Migrations catalog cache**: ported (Go's best-effort `pgcache.TryCacheMigrationsCatalog`). - After a successful migration apply, when pg-delta is enabled, exports the target's - pg-delta catalog via the edge-runtime stack and writes it under +- **Migrations catalog cache**: retained only for + `SUPABASE_USE_PG_DELTA_NEXT=false` (Go's best-effort + `pgcache.TryCacheMigrationsCatalog`). After a successful migration apply, when + pg-delta is enabled, the legacy implementation exports the target's 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, matching Go exactly. 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 the ENOENT bug fixed in - Go's `pgcache/cache.go` (supabase/cli#5921) has no TS equivalent. + Go's `pgcache/cache.go` (supabase/cli#5921) has no TS equivalent. The bundled + default engine extracts live state for commands that need it and never reads this cache. diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts index 97731ea39a..3abcc35f87 100644 --- a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -357,12 +357,25 @@ describe("legacy db push", () => { }); }); + 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\n", + "supabase/.env": "SUPABASE_EXPERIMENTAL_PG_DELTA=true\nSUPABASE_USE_PG_DELTA_NEXT=false\n", }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', @@ -382,7 +395,10 @@ describe("legacy db push", () => { 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"), + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', }); @@ -404,7 +420,10 @@ describe("legacy db push", () => { () => { const { layer, out, edgeRunCalls } = setup(tmp.current, { toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: migrationFile("20240101000000"), + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', noProjectId: true, @@ -426,7 +445,10 @@ describe("legacy db push", () => { () => { const { layer, out, edgeRunCalls } = setup(tmp.current, { toml: "[experimental.pgdelta]\nenabled = true\n", - files: migrationFile("20240101000000"), + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', noProjectId: true, @@ -457,7 +479,10 @@ describe("legacy db push", () => { args: ["db", "push", "--linked"], isLocal: false, projectRef: LEGACY_VALID_REF, - files: migrationFile("20240101000000"), + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', noProjectId: true, @@ -478,7 +503,10 @@ describe("legacy db push", () => { 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"), + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', noProjectId: true, @@ -498,7 +526,10 @@ describe("legacy db push", () => { 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"), + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, confirm: [true], catalogExportFailWith: "edge-runtime script produced no output", }); @@ -521,7 +552,8 @@ describe("legacy db push", () => { toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', files: { ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", + "supabase/.env": + "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\nSUPABASE_USE_PG_DELTA_NEXT=false\n", }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts index 4ff6cb9ab5..5c0274556e 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts @@ -71,6 +71,13 @@ export class LegacyDeclarativeDiffError extends Data.TaggedError("LegacyDeclarat readonly message: string; }> {} +/** Sync stopped because a manifest-less legacy schema needs an explicit migration choice. */ +export class LegacyDeclarativeCompatibilityError extends Data.TaggedError( + "LegacyDeclarativeCompatibilityError", +)<{ + readonly message: string; +}> {} + /** * Applying the generated migration to the local database failed. Wraps Go's * `applyMigrationToLocal` error; in interactive mode the handler offers a diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.ts new file mode 100644 index 0000000000..3f5db7862a --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.ts @@ -0,0 +1,50 @@ +import { Effect, FileSystem, Path } from "effect"; + +import { legacyExtensionDeclaration } from "./declarative.flow.ts"; + +interface LegacyExtensionRepairResult { + readonly path: string; + readonly addedExtensions: ReadonlyArray; + readonly addedDeclarations: ReadonlyArray; +} + +const declaredExtensions = (sql: string): ReadonlySet => { + const extensions = new Set(); + const pattern = + /\bCREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|([a-zA-Z_][\w$-]*))/gi; + for (const match of sql.matchAll(pattern)) { + const extension = match[1] ?? match[2]; + if (extension !== undefined) extensions.add(extension); + } + return extensions; +}; + +/** Appends missing legacy extension declarations without replacing existing SQL. */ +export const legacyAppendExtensionDeclarations = Effect.fnUntraced(function* ( + declarativeDir: string, + extensions: ReadonlyArray, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const extensionPath = path.join(declarativeDir, "extension.sql"); + const exists = yield* fs.exists(extensionPath); + const existing = exists ? yield* fs.readFileString(extensionPath) : ""; + const declared = declaredExtensions(existing); + const addedExtensions = [...new Set(extensions)] + .filter((extension) => !declared.has(extension)) + .sort(); + const addedDeclarations = addedExtensions.map(legacyExtensionDeclaration); + + if (addedDeclarations.length > 0) { + const newline = existing.includes("\r\n") ? "\r\n" : "\n"; + const separator = existing.length === 0 || existing.endsWith("\n") ? "" : newline; + const appended = `${separator}${addedDeclarations.join(newline)}${newline}`; + yield* fs.writeFileString(extensionPath, `${existing}${appended}`); + } + + return { + path: extensionPath, + addedExtensions, + addedDeclarations, + } satisfies LegacyExtensionRepairResult; +}); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.unit.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.unit.test.ts new file mode 100644 index 0000000000..ab603f9bf9 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.unit.test.ts @@ -0,0 +1,65 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { useLegacyTempWorkdir } from "../../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyAppendExtensionDeclarations } from "./declarative.extension-repair.ts"; + +describe("legacyAppendExtensionDeclarations", () => { + const tmp = useLegacyTempWorkdir(); + + it.effect("creates root extension.sql with sorted idempotent declarations", () => { + return Effect.gen(function* () { + const result = yield* legacyAppendExtensionDeclarations(tmp.current, [ + "uuid-ossp", + "pgcrypto", + "pgcrypto", + ]); + expect(result.addedExtensions).toEqual(["pgcrypto", "uuid-ossp"]); + expect(readFileSync(join(tmp.current, "extension.sql"), "utf8")).toBe( + [ + 'CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions";', + 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions";', + "", + ].join("\n"), + ); + + const repeated = yield* legacyAppendExtensionDeclarations(tmp.current, ["uuid-ossp"]); + expect(repeated.addedDeclarations).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect("preserves existing contents and CRLF newlines", () => { + const extensionPath = join(tmp.current, "extension.sql"); + writeFileSync(extensionPath, 'CREATE EXTENSION "pgcrypto";\r\n-- keep me'); + return Effect.gen(function* () { + const result = yield* legacyAppendExtensionDeclarations(tmp.current, ["pgcrypto", "pg_net"]); + expect(result.addedExtensions).toEqual(["pg_net"]); + expect(readFileSync(extensionPath, "utf8")).toBe( + 'CREATE EXTENSION "pgcrypto";\r\n-- keep me\r\n' + + 'CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions";\r\n', + ); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect("appends to the representative legacy root extension.sql", () => { + const fixture = join( + dirname(fileURLToPath(import.meta.url)), + "fixtures", + "legacy", + "extension.sql", + ); + const extensionPath = join(tmp.current, "extension.sql"); + writeFileSync(extensionPath, readFileSync(fixture, "utf8")); + return Effect.gen(function* () { + yield* legacyAppendExtensionDeclarations(tmp.current, ["uuid-ossp"]); + const updated = readFileSync(extensionPath, "utf8"); + expect(updated).toContain('CREATE EXTENSION IF NOT EXISTS "vector"'); + expect(updated).toContain('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); + }).pipe(Effect.provide(BunServices.layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts index 008c1e6426..a2e0a34f9a 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts @@ -1,15 +1,23 @@ -/** - * Pure control-flow helpers ported 1:1 from - * `apps/cli-go/cmd/db_schema_declarative.go`. Kept free of Effect/services so - * the precedence rules are unit-testable in isolation; the handlers run the - * actual TTY prompt for the `"prompt"` decision. - */ +import type { LegacyPgDeltaImplementation } from "../../../../shared/legacy-pgdelta-next-flag.ts"; +import type { LegacyPgDeltaRemovalSummary } from "../../shared/legacy-pgdelta-engine.service.ts"; + +/** Extensions that legacy pg-delta treated as part of its implicit Supabase baseline. */ +const LEGACY_IMPLICIT_EXTENSIONS = ["pg_net", "pgcrypto", "uuid-ossp"] as const; + +type LegacyDeclarativeCompatibilityAction = "none" | "repair-extensions" | "stage-next-export"; + +export interface LegacyDeclarativeCompatibilityGap { + readonly repairableExtensions: ReadonlyArray; + readonly extensionIntents: LegacyPgDeltaRemovalSummary["extensionIntents"]; + readonly ambiguousRemovals: ReadonlyArray; + readonly recommendedAction: LegacyDeclarativeCompatibilityAction; +} /** - * Resolves the migration name. The explicit `--name` wins over `--file` - * (default `declarative_sync`). Mirrors Go's `resolveDeclarativeMigrationName` - * (`:99-104`). + * Pure control-flow helpers ported from the legacy Go implementation and kept + * free of Effect/services so handler decisions remain unit-testable. */ + export function legacyResolveDeclarativeMigrationName(name: string, file: string): string { return name.length > 0 ? name : file; } @@ -17,11 +25,6 @@ export function legacyResolveDeclarativeMigrationName(name: string, file: string /** Whether sync applies the generated migration, prompts, or skips. */ export type LegacyDeclarativeApplyDecision = "apply" | "skip" | "prompt"; -/** - * Decides whether to apply the generated migration to the local database. - * Precedence (Go's `resolveDeclarativeSyncShouldApply`, `:106-124`): - * `--no-apply` > `--apply` > global `--yes` > TTY prompt > non-TTY default (skip). - */ export function legacyResolveDeclarativeSyncApplyDecision(opts: { readonly apply: boolean; readonly noApply: boolean; @@ -34,3 +37,69 @@ export function legacyResolveDeclarativeSyncApplyDecision(opts: { if (opts.tty) return "prompt"; return "skip"; } + +const emptyCompatibilityGap = (): LegacyDeclarativeCompatibilityGap => ({ + repairableExtensions: [], + extensionIntents: [], + ambiguousRemovals: [], + recommendedAction: "none", +}); + +/** Classifies manifest-less pg-delta next 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(); + + const extensions = [...new Set(opts.removals.extensions)].sort(); + const repairableExtensions = extensions.filter((extension) => + LEGACY_IMPLICIT_EXTENSIONS.some((implicit) => implicit === extension), + ); + const ambiguousRemovals = extensions.filter( + (extension) => !LEGACY_IMPLICIT_EXTENSIONS.some((implicit) => implicit === extension), + ); + const extensionIntents = opts.removals.extensionIntents; + + if (extensions.length === 0 && extensionIntents.length === 0) return emptyCompatibilityGap(); + const repairable = + repairableExtensions.length > 0 && + ambiguousRemovals.length === 0 && + extensionIntents.length === 0; + return { + repairableExtensions, + extensionIntents, + ambiguousRemovals, + recommendedAction: repairable ? "repair-extensions" : "stage-next-export", + }; +} + +export const legacyExtensionDeclaration = (extension: string): string => + `CREATE EXTENSION IF NOT EXISTS "${extension}" WITH SCHEMA "extensions";`; + +export function legacyFormatStagedExportRecommendation( + gap: LegacyDeclarativeCompatibilityGap, +): string { + const detected = [ + ...(gap.repairableExtensions.length > 0 + ? [`Legacy-implicit extensions: ${gap.repairableExtensions.join(", ")}`] + : []), + ...(gap.ambiguousRemovals.length > 0 + ? [`Extensions: ${gap.ambiguousRemovals.join(", ")}`] + : []), + ...(gap.extensionIntents.length > 0 + ? [ + `Extension-managed objects: ${gap.extensionIntents + .map((intent) => `${intent.extension} ${intent.intentKind} ${intent.key}`) + .join(", ")}`, + ] + : []), + ]; + return [ + "WARNING: pg-delta next manages schema state that the legacy export did not represent.", + ...detected, + "Generate a next-compatible schema into a separate directory, review it, and adopt it when ready:", + "supabase db schema declarative generate --output supabase/database-next", + ].join("\n"); +} diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.unit.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.unit.test.ts index 388c20c475..d84756e0a3 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.unit.test.ts @@ -1,10 +1,90 @@ import { describe, expect, it } from "vitest"; import { + legacyClassifyDeclarativeCompatibilityGap, + legacyExtensionDeclaration, + legacyFormatStagedExportRecommendation, legacyResolveDeclarativeMigrationName, legacyResolveDeclarativeSyncApplyDecision, } from "./declarative.flow.ts"; +const removals = { + extensions: ["pgcrypto", "uuid-ossp"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, + { extension: "pgmq", intentKind: "queue", key: "emails" }, + ], +}; + +describe("legacyClassifyDeclarativeCompatibilityGap", () => { + it("repairs only the known legacy-implicit extension set", () => { + const gap = legacyClassifyDeclarativeCompatibilityGap({ + implementation: "next", + manifestPresent: false, + removals: { extensions: ["uuid-ossp", "pgcrypto", "pgcrypto"], extensionIntents: [] }, + }); + expect(gap).toEqual({ + repairableExtensions: ["pgcrypto", "uuid-ossp"], + extensionIntents: [], + ambiguousRemovals: [], + recommendedAction: "repair-extensions", + }); + expect(legacyExtensionDeclaration("uuid-ossp")).toBe( + 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions";', + ); + }); + + it("stages a next export for mixed or unknown extension removals", () => { + const gap = legacyClassifyDeclarativeCompatibilityGap({ + implementation: "next", + manifestPresent: false, + removals: { extensions: ["pgcrypto", "postgis"], extensionIntents: [] }, + }); + expect(gap.repairableExtensions).toEqual(["pgcrypto"]); + expect(gap.ambiguousRemovals).toEqual(["postgis"]); + expect(gap.recommendedAction).toBe("stage-next-export"); + }); + + it("stages a next export when extension intents are present", () => { + const gap = legacyClassifyDeclarativeCompatibilityGap({ + implementation: "next", + manifestPresent: false, + removals, + }); + expect(gap.recommendedAction).toBe("stage-next-export"); + expect(legacyFormatStagedExportRecommendation(gap)).toContain( + "generate --output supabase/database-next", + ); + }); + + it("is suppressed for next exports with a manifest", () => { + expect( + legacyClassifyDeclarativeCompatibilityGap({ + implementation: "next", + manifestPresent: true, + removals, + }).recommendedAction, + ).toBe("none"); + }); + + it("is suppressed for the legacy engine and irrelevant removals", () => { + expect( + legacyClassifyDeclarativeCompatibilityGap({ + implementation: "legacy", + manifestPresent: false, + removals, + }), + ).toMatchObject({ recommendedAction: "none" }); + expect( + legacyClassifyDeclarativeCompatibilityGap({ + implementation: "next", + manifestPresent: false, + removals: { extensions: [], extensionIntents: [] }, + }), + ).toMatchObject({ recommendedAction: "none" }); + }); +}); + describe("legacyResolveDeclarativeMigrationName", () => { it("prefers an explicit --name over --file", () => { expect(legacyResolveDeclarativeMigrationName("my_change", "declarative_sync")).toBe( diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 00455e786d..f85315a3c3 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -11,6 +11,11 @@ import { LegacyEdgeRuntimeScript, } from "../../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../../shared/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, @@ -34,8 +39,6 @@ function mockSeam(paths: Record) { const calls: Array<{ mode: LegacyCatalogMode; noCache: boolean }> = []; const provisionCalls: Array<{ mode: string; - targetLocal: boolean; - usePgDelta: boolean; projectRef?: string; }> = []; const removedContainers: string[] = []; @@ -46,12 +49,13 @@ function mockSeam(paths: Record) { }, ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, + provisionNextShadow: () => Effect.die("provisionNextShadow not used in declarative tests"), // The migrations-catalog source now resolves natively (CLI-1959) via // `legacyGetMigrationsCatalogRef`, which provisions its shadow through this // EXISTING `provisionShadow` (Go's unchanged `db __shadow --mode diff`) rather // than the retired `exportCatalog({mode:"migrations"})` seam call. - provisionShadow: ({ mode, targetLocal, usePgDelta, projectRef }) => { - provisionCalls.push({ mode, targetLocal, usePgDelta, projectRef }); + provisionShadow: ({ mode, projectRef }) => { + provisionCalls.push({ mode, projectRef }); return Effect.succeed({ container: "shadow-1", sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", @@ -110,6 +114,83 @@ const ctx = (cwd: string, declarativeDir: string): LegacyDeclarativeRunContext = declarativeDir, schema: [], noCache: false, + debug: false, + strictCoverage: false, + dnsResolver: "native", +}); + +const engineLayer = ( + seam: Layer.Layer, + edge: Layer.Layer, + output: ReturnType["layer"], +) => + legacyPgDeltaLegacyEngineLayer.pipe( + Layer.provide(Layer.mergeAll(seam, edge, probe, output, BunServices.layer)), + ); + +describe("legacyDiffDeclarativeToMigrations", () => { + it.effect("loads nested SQL and its manifest in stable order for the engine", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); + const declDir = join(dir, "supabase", "database"); + mkdirSync(join(declDir, "nested"), { recursive: true }); + writeFileSync(join(declDir, "z.sql"), "select 'z';"); + writeFileSync(join(declDir, "nested", "a.sql"), "select 'a';"); + writeFileSync(join(declDir, "ignored.txt"), "ignored"); + writeFileSync( + join(declDir, ".pgdelta-export.json"), + JSON.stringify({ formatVersion: 1, redactSecrets: true, scope: "database" }), + ); + const calls: LegacyPgDeltaDeclarativePlanInput[] = []; + 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"), + planDeclarativeSchema: (input) => { + calls.push(input); + return Effect.succeed({ + changes: false, + sql: "", + files: [], + sourceRef: "migrations", + targetRef: "declarative", + removals: { + extensions: ["pgcrypto"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh metrics" }, + ], + }, + }); + }, + }), + ); + return legacyDiffDeclarativeToMigrations( + { ...ctx(dir, declDir), debug: true, noCache: true, strictCoverage: true }, + setupInputs, + ).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(calls[0]?.files).toEqual([ + { name: "nested/a.sql", sql: "select 'a';" }, + { name: "z.sql", sql: "select 'z';" }, + ]); + expect(calls[0]?.manifest).toEqual({ redactSecrets: true, scope: "database" }); + expect(calls[0]?.debug).toBe(true); + expect(calls[0]?.noCache).toBe(true); + expect(calls[0]?.strictCoverage).toBe(true); + expect(result.manifestPresent).toBe(true); + expect(result.removals).toEqual({ + extensions: ["pgcrypto"], + extensionIntents: [{ extension: "pg_cron", intentKind: "job", key: "refresh metrics" }], + }); + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(Layer.mergeAll(engine, BunServices.layer)), + ); + }); }); // A minimal, valid `LegacySetupInputs` — the exact field values don't matter to @@ -145,9 +226,7 @@ describe("legacyDiffDeclarativeToMigrations", () => { // "declarative" still resolves via the seam; "migrations" no longer does // (it resolves natively, provisioning through `provisionShadow` instead). expect(seam.calls.map((c) => c.mode)).toEqual(["declarative"]); - expect(seam.provisionCalls).toEqual([ - { mode: "diff", targetLocal: false, usePgDelta: false, projectRef: undefined }, - ]); + expect(seam.provisionCalls).toEqual([{ mode: "diff", projectRef: undefined }]); expect(seam.removedContainers).toEqual(["shadow-1"]); // No local migrations in the fresh temp dir → the zero-migrations branch // writes (and returns) the platform-baseline catalog, workdir-relative. @@ -166,7 +245,16 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer), + BunServices.layer, + ), + ), ); }, ); @@ -204,7 +292,16 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer), + BunServices.layer, + ), + ), ); }, ); @@ -244,13 +341,20 @@ describe("legacyDiffDeclarativeToMigrations", () => { ); expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); expect(out.stderrText).toContain("Creating shadow database...\n"); - expect(seam.provisionCalls).toEqual([ - { mode: "diff", targetLocal: false, usePgDelta: false, projectRef: undefined }, - ]); + expect(seam.provisionCalls).toEqual([{ mode: "diff", projectRef: undefined }]); expect(seam.removedContainers).toEqual(["shadow-1"]); rmSync(dir, { recursive: true, force: true }); }).pipe( - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer), + BunServices.layer, + ), + ), ); }, ); @@ -291,7 +395,16 @@ describe("legacyDiffDeclarativeToMigrations", () => { expect(seam.provisionCalls).toEqual([]); rmSync(dir, { recursive: true, force: true }); }).pipe( - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer), + BunServices.layer, + ), + ), ); }, ); @@ -337,16 +450,22 @@ describe("legacyDiffDeclarativeToMigrations", () => { join("supabase", ".temp", "pgdelta", "catalog-nocache-migrations.json"), ); expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); - expect(seam.provisionCalls).toEqual([ - { mode: "diff", targetLocal: false, usePgDelta: false, projectRef: undefined }, - ]); + expect(seam.provisionCalls).toEqual([{ mode: "diff", projectRef: undefined }]); rmSync(dir, { recursive: true, force: true }); }).pipe( - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer), + BunServices.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" }); @@ -368,12 +487,66 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer), + BunServices.layer, + ), + ), ); }); }); describe("legacyGenerateDeclarativeOutput", () => { + it.effect("propagates debug, no-cache, and strict coverage to the selected engine", () => { + const calls: Array<{ + readonly debug: boolean; + readonly noCache: boolean; + 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, + strictCoverage: input.strictCoverage, + }); + return Effect.succeed({ files: [] }); + }, + planDeclarativeSchema: () => Effect.die("planDeclarativeSchema not used"), + }), + ); + return legacyGenerateDeclarativeOutput( + { + ...ctx("/proj", "/proj/supabase/database"), + debug: true, + noCache: true, + strictCoverage: true, + }, + { + kind: "database", + ref: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + ).pipe( + Effect.tap(() => + Effect.sync(() => + expect(calls).toEqual([{ debug: true, noCache: true, strictCoverage: true }]), + ), + ), + Effect.provide(engine), + ); + }); + it.effect("diffs the baseline catalog against the live DB and returns files", () => { const seam = mockSeam({ declarative: "d", @@ -385,14 +558,16 @@ describe("legacyGenerateDeclarativeOutput", () => { files: [{ path: "public.sql", order: 0, statements: 1, sql: "create table a();" }], }; const edge = mockEdge(JSON.stringify(payload)); - return legacyGenerateDeclarativeOutput( - ctx("/proj", "/proj/supabase/database"), - "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10", - ).pipe( + const out = mockOutput(); + return legacyGenerateDeclarativeOutput(ctx("/proj", "/proj/supabase/database"), { + 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([{ mode: "baseline", noCache: false }]); - expect(output.files[0]?.path).toBe("public.sql"); + expect(output.files[0]?.name).toBe("public.sql"); // SOURCE = baseline catalog (mapped to /workspace); TARGET = live URL (passthrough). expect(edge.calls[0]!.env["SOURCE"]).toBe("/workspace/supabase/.temp/pgdelta/base.json"); expect(edge.calls[0]!.env["TARGET"]).toBe( @@ -400,7 +575,16 @@ describe("legacyGenerateDeclarativeOutput", () => { ); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, BunServices.layer)), + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer), + BunServices.layer, + ), + ), ); }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts index c423d987d3..e12adcf17a 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts @@ -1,53 +1,52 @@ import { Effect, FileSystem, Path } from "effect"; +import type { LegacyPgDeltaContext } from "../../../../shared/legacy-pgdelta.ts"; +import type { LegacySetupInputs } from "../../../../shared/legacy-pgdelta.cache.ts"; +import { legacyFindDropStatements } from "../../../../shared/legacy-sql-split.ts"; import { - type LegacyPgDeltaContext, - legacyDeclarativeExportPgDelta, - legacyDiffPgDelta, -} from "../../../../shared/legacy-pgdelta.ts"; + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseEndpoint, + type LegacyPgDeltaRemovalSummary, + type LegacyPgDeltaRenderedFile, +} from "../../shared/legacy-pgdelta-engine.service.ts"; import { - type LegacySetupInputs, - legacyGetMigrationsCatalogRef, -} from "../../../../shared/legacy-pgdelta.cache.ts"; + LegacyLoadPgDeltaSqlFiles, + LegacyReadPgDeltaExportManifest, +} from "../../shared/legacy-pgdelta-files.ts"; import { LegacyDeclarativeDiffError } from "./declarative.errors.ts"; -import { LegacyDeclarativeSeam } from "../../shared/legacy-pgdelta.seam.service.ts"; -import { legacyFindDropStatements } from "../../../../shared/legacy-sql-split.ts"; /** Ambient inputs shared by the orchestration steps. */ export interface LegacyDeclarativeRunContext { readonly pgDelta: LegacyPgDeltaContext; - /** `experimental.pgdelta.format_options` (trimmed; "" when unset). */ readonly formatOptions: string; - /** Resolved declarative schema dir (workdir-relative, e.g. `supabase/database`). */ readonly declarativeDir: string; readonly schema: ReadonlyArray; readonly noCache: boolean; - /** - * Resolved linked project ref for an explicit `generate --linked`. Threaded into - * the baseline `__catalog` export so the Go config load merges the matching - * `[remotes.]` override into the platform baseline (auth/storage/realtime/api/ - * vault settings), matching Go's `Generate`, which builds the baseline from the - * remote-merged config. `undefined` for local/db-url/smart targets. - */ + readonly debug: boolean; + readonly strictCoverage: boolean; + readonly dnsResolver: "native" | "https"; readonly linkedProjectRef?: string; } /** The output of a declarative-to-migrations diff. Mirrors Go's `SyncResult`. */ export interface LegacyDeclarativeSyncResult { readonly diffSQL: string; + readonly files: ReadonlyArray; readonly sourceRef: string; readonly targetRef: string; readonly dropWarnings: ReadonlyArray; + readonly manifestPresent: boolean; + readonly removals: LegacyPgDeltaRemovalSummary; } +const declarativeError = (message: string) => new LegacyDeclarativeDiffError({ message }); + /** * Computes the diff between local migrations state and the declarative schema. * Mirrors Go's `DiffDeclarativeToMigrations` (`declarative.go:170`): the - * declarative catalog (target) is still provisioned via the Go seam (shadow DB + - * `SetupDatabase` + declarative apply); the migrations catalog (source) resolves - * natively (CLI-1959) via `legacyGetMigrationsCatalogRef`, which mirrors Go's - * `getMigrationsCatalogRef` (`declarative.go:368-430`) exactly. Both are then - * diffed natively with pg-delta, as before. + * 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. */ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( run: LegacyDeclarativeRunContext, @@ -55,58 +54,57 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const seam = yield* LegacyDeclarativeSeam; - + const engine = yield* LegacyPgDeltaEngine; const exists = yield* fs.exists(run.declarativeDir).pipe(Effect.orElseSucceed(() => false)); if (!exists) { return yield* Effect.fail( - new LegacyDeclarativeDiffError({ - message: - "No declarative schema directory found. Run supabase db schema declarative generate first.", - }), + declarativeError( + "No declarative schema directory found. Run supabase db schema declarative generate first.", + ), ); } - - const sourceRef = yield* legacyGetMigrationsCatalogRef(fs, path, run.pgDelta, setupInputs, { - noCache: run.noCache, - ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), - }); - const targetRef = yield* seam.exportCatalog({ mode: "declarative", noCache: run.noCache }); - const diff = yield* legacyDiffPgDelta(run.pgDelta, { - sourceRef, - targetRef, + const files = yield* LegacyLoadPgDeltaSqlFiles(fs, path, run.declarativeDir).pipe( + Effect.mapError((error) => declarativeError(error.message)), + ); + const manifest = yield* LegacyReadPgDeltaExportManifest(fs, path, run.declarativeDir).pipe( + Effect.mapError((error) => declarativeError(error.message)), + ); + const result = yield* engine.planDeclarativeSchema({ + context: run.pgDelta, schema: run.schema, formatOptions: run.formatOptions, + debug: run.debug, + strictCoverage: run.strictCoverage, + files, + noCache: run.noCache, + setupInputs, + ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), + ...(manifest !== undefined ? { manifest } : {}), }); return { - diffSQL: diff.sql, - sourceRef, - targetRef, - dropWarnings: legacyFindDropStatements(diff.sql), + diffSQL: result.sql, + files: result.files, + sourceRef: result.sourceRef, + targetRef: result.targetRef, + dropWarnings: legacyFindDropStatements(result.sql), + manifestPresent: manifest !== undefined, + removals: result.removals ?? { extensions: [], extensionIntents: [] }, } satisfies LegacyDeclarativeSyncResult; }); -/** - * Exports a live database's schema as declarative file payloads, diffing it - * against the platform-baseline catalog (provisioned via the Go seam). Mirrors - * the catalog half of Go's `Generate` (`declarative.go:110`): the live database - * URL is the target, the baseline is the source. The handler writes the - * returned files after the overwrite prompt. - */ export const legacyGenerateDeclarativeOutput = Effect.fnUntraced(function* ( run: LegacyDeclarativeRunContext, - targetDbUrl: string, + target: LegacyPgDeltaDatabaseEndpoint, ) { - const seam = yield* LegacyDeclarativeSeam; - const baselineRef = yield* seam.exportCatalog({ - mode: "baseline", - noCache: run.noCache, - ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), - }); - return yield* legacyDeclarativeExportPgDelta(run.pgDelta, { - sourceRef: baselineRef, - targetRef: targetDbUrl, + const engine = yield* LegacyPgDeltaEngine; + return yield* engine.exportDeclarativeSchema({ + context: run.pgDelta, schema: run.schema, formatOptions: run.formatOptions, + debug: run.debug, + strictCoverage: run.strictCoverage, + noCache: run.noCache, + ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), + target, }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts index 1295e979bc..165ca947f2 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts @@ -16,5 +16,10 @@ export const legacyDbSchemaDeclarativeSharedBase = Command.make("declarative").p noCache: Flag.boolean("no-cache").pipe( Flag.withDescription("Disable catalog cache and force fresh shadow database setup."), ), + strictCoverage: Flag.boolean("strict-coverage").pipe( + Flag.withDescription( + "Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.", + ), + ), }), ); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts index 86823af931..f7a8289213 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts @@ -16,6 +16,7 @@ import { } from "../../../../shared/legacy-db-config.parse.ts"; import { legacyGetHostname } from "../../../../shared/legacy-hostname.ts"; import { legacyToPostgresURL } from "../../../../shared/legacy-postgres-url.ts"; +import type { LegacyPgDeltaDatabaseEndpoint } from "../../shared/legacy-pgdelta-engine.service.ts"; import { LegacyDeclarativeApplyError, LegacyDeclarativeInvalidDbUrlError, @@ -46,30 +47,48 @@ export interface LegacySmartTargetFlags { readonly reset: boolean; } -export const legacyLocalUrl = (local: LegacyLocalConn): string => - legacyToPostgresURL({ - // Go derives the local host from `utils.Config.Hostname` (`GetHostname()`: - // SUPABASE_SERVICES_HOSTNAME → tcp DOCKER_HOST → 127.0.0.1), not a hardcoded - // loopback (`apps/cli-go/internal/utils/misc.go:298-312`). - host: legacyGetHostname(), - port: local.port, - user: "postgres", - password: local.password, - database: "postgres", - }); +const legacyLocalConnection = (local: LegacyLocalConn) => ({ + // Go derives the local host from `utils.Config.Hostname` (`GetHostname()`: + // SUPABASE_SERVICES_HOSTNAME → tcp DOCKER_HOST → 127.0.0.1), not a hardcoded + // loopback (`apps/cli-go/internal/utils/misc.go:298-312`). + host: legacyGetHostname(), + port: local.port, + user: "postgres", + password: local.password, + database: "postgres", +}); + +export const legacyLocalEndpoint = ( + local: LegacyLocalConn, + dnsResolver: "native" | "https", +): LegacyPgDeltaDatabaseEndpoint => { + const connection = legacyLocalConnection(local); + return { + kind: "database", + ref: legacyToPostgresURL(connection), + connection, + connectOptions: { isLocal: true, dnsResolver }, + }; +}; -/** Resolves `--linked` / `--db-url` to a Postgres URL via the shared resolver. */ -export const legacyResolveRemoteUrl = Effect.fnUntraced(function* (flags: LegacySmartTargetFlags) { +/** Resolves a remote target without discarding TLS and connection options. */ +export const legacyResolveRemoteEndpoint = Effect.fnUntraced(function* ( + flags: LegacySmartTargetFlags, +) { const resolver = yield* LegacyDbConfigResolver; const dnsResolver = yield* LegacyDnsResolverFlag; const resolved = yield* resolver.resolve({ dbUrl: flags.dbUrl, - // Remote-only resolution: `--db-url` wins, otherwise the linked project. connType: Option.isSome(flags.dbUrl) ? "db-url" : "linked", dnsResolver, password: flags.password, }); - return legacyToPostgresURL(resolved.conn); + return { + kind: "database", + ref: legacyToPostgresURL(resolved.conn), + connection: resolved.conn, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + } satisfies LegacyPgDeltaDatabaseEndpoint; }); /** @@ -78,7 +97,7 @@ export const legacyResolveRemoteUrl = Effect.fnUntraced(function* (flags: Legacy * Shared by `generate` (smart mode) and `sync` (no-declarative-files bootstrap) so * both offer the same local / linked / custom choice and local-reset prompt. */ -export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( +export const legacyResolveSmartTargetEndpoint = Effect.fnUntraced(function* ( flags: LegacySmartTargetFlags, local: LegacyLocalConn, hasMigrations: boolean, @@ -93,7 +112,7 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( // (db_schema_declarative.go:291), starting a stopped stack. yield* beforeLocalTarget; yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted(); - return legacyLocalUrl(local); + return legacyLocalEndpoint(local, yield* LegacyDnsResolverFlag); } const output = yield* Output; @@ -124,7 +143,7 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( if (choice === "linked") { // Same path as an explicit `--linked` (Go calls `NewDbConfigWithPassword`): // login-role mint + pooler fallback, then `ToPostgresURL`. - return yield* legacyResolveRemoteUrl({ ...flags, linked: Option.some(true) }); + return yield* legacyResolveRemoteEndpoint({ ...flags, linked: Option.some(true) }); } if (choice === "custom") { @@ -150,7 +169,12 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( }), ); } - return legacyToPostgresURL(conn); + return { + kind: "database", + ref: legacyToPostgresURL(conn), + connection: conn, + connectOptions: { isLocal: false, dnsResolver: yield* LegacyDnsResolverFlag }, + } satisfies LegacyPgDeltaDatabaseEndpoint; } // "Local database" choice: Go runs ensureLocalDatabaseStarted before the reset @@ -185,5 +209,5 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( ), ); } - return legacyLocalUrl(local); + return legacyLocalEndpoint(local, yield* LegacyDnsResolverFlag); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/fixtures/legacy/extension.sql b/apps/cli/src/legacy/commands/db/schema/declarative/fixtures/legacy/extension.sql new file mode 100644 index 0000000000..9c5102c4c0 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/fixtures/legacy/extension.sql @@ -0,0 +1,2 @@ +-- Representative root extension file from a legacy declarative export. +CREATE EXTENSION IF NOT EXISTS "vector" WITH SCHEMA "extensions"; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 3e99f70643..9895dac8a4 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -1,34 +1,63 @@ # `supabase db schema declarative generate` -Generates declarative schema files from a database by diffing a platform-baseline -pg-delta catalog (source) against the target database's catalog (target). +Generates declarative schema files from a database using pg-delta's managed +platform view. + +## Pg-delta implementation and compatibility + +- The default pg-delta engine runs in-process. Pg-delta and pg-topo are bundled + into the CLI binary at build time, so the installed CLI fixes their version and + performs no runtime package download or automatic legacy fallback. +- `SUPABASE_USE_PG_DELTA_NEXT=false` selects the legacy catalog/edge-runtime + implementation. Only that opt-out uses `supabase/.temp/pgdelta-version`, + `PGDELTA_NPM_REGISTRY`, edge-runtime, or legacy catalogs directly below + `supabase/.temp/pgdelta/`. +- `--no-cache` bypasses legacy catalog reuse/warming. The default engine already + extracts live state and has no reusable catalog cache, so the flag does not + change its extraction behavior. +- With `PGDELTA_DEBUG`, default-engine export diagnostics are written below + `supabase/.temp/pgdelta/v2/debug//`; they are never reused as catalogs. +- The default engine always refuses extraction errors. Coverage gaps + (`unmodeled_kind` or `unresolved_security_label`) warn by default and explain that + unsupported objects are absent from the generated files; `--strict-coverage` + turns them into a refusal. Debug artifacts are saved before policy evaluation + when capture is enabled. +- Generated SQL bytes and grouping may differ between engines. Reloading the + export to the same managed state is the compatibility contract. +- The default engine applies pg-delta's human-facing formatter (lowercase + keywords, max width 180) and export-specific safe constraint folding. A JSON + object in `[experimental.pgdelta].format_options` partially overrides the + formatter; the JSON literal `null` disables formatting without disabling plan + compaction. ## Files Read | Path | Format | When | | ----------------------------------------------- | ---------- | -------------------------------------------------- | | `/supabase/config.toml` | TOML | always — pg-delta gate, ports, format options | -| `/supabase/.temp/pgdelta-version` | plain text | always — pins the `@supabase/pg-delta` npm version | -| `/supabase/.temp/edge-runtime-version` | plain text | always — pins the edge-runtime image tag | +| `/supabase/.temp/pgdelta-version` | plain text | always read for compatibility; affects legacy only | +| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out only — edge-runtime image tag | | `/supabase/.temp/postgres-version` | plain text | shadow-DB image resolution (Go seam) | | `/supabase/migrations/*.sql` | SQL | smart mode — detect whether migrations exist | -| `/supabase/.temp/pgdelta/*.json` | JSON | catalog cache (read/written by the Go seam) | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out only: catalog cache | | `~/.supabase/access-token` | plain text | `--linked` (token resolution) | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------- | -| `/supabase/database/**/*.sql` (declarative dir; configurable via `[experimental.pgdelta] declarative_schema_path`) | SQL | always — the entire dir is wiped + rewritten | -| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | catalog cache (written by the Go seam) | +| Path | Format | When | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------- | +| `/supabase/database/**/*.sql` (declarative dir; configurable via `[experimental.pgdelta] declarative_schema_path`, or invocation-local `--output`) | SQL | the selected destination is wiped + rewritten after overwrite confirmation | +| `/.pgdelta-export.json` | JSON | default-engine export policy/manifest | +| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | legacy opt-out only: catalog cache | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | default engine with `PGDELTA_DEBUG` | ## Subprocesses / Containers -| What | When | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| `supabase-go db schema declarative __catalog --mode baseline --experimental` (hidden seam) — provisions a shadow Postgres + `start.SetupDatabase`, exports the baseline catalog | always | -| Edge-runtime container (`supabase/edge-runtime`) running the pg-delta declarative-export Deno script (host network, deno-cache volume `supabase_edge_runtime_`) | always | -| `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 | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `supabase-go db schema declarative __catalog --mode baseline --experimental` — provisions and exports the legacy baseline catalog | legacy opt-out only | +| Edge-runtime container running the pg-delta declarative-export Deno script | 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`) | ## Environment Variables @@ -36,8 +65,9 @@ pg-delta catalog (source) against the target database's catalog (target). | ---------------------------- | -------------------------------------------------- | --------- | | `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no | | `DB_PASSWORD` | password for `--linked` / `--db-url` | no | -| `PGDELTA_NPM_REGISTRY` | private `@supabase` npm registry for pg-delta | no | -| `PGDELTA_DEBUG` | verbose pg-delta diagnostics | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for the legacy edge-runtime engine | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out only: private npm registry | no | +| `PGDELTA_DEBUG` | structured default-engine debug artifacts | no | | `SUPABASE_GO_BINARY` | override the `supabase-go` seam binary | no | | `SUPABASE_SERVICES_HOSTNAME` | local DB host for `--local` (Go `GetHostname`) | no | | `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | @@ -50,7 +80,7 @@ pg-delta catalog (source) against the target database's catalog (target). | `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 / edge-runtime / export failure | +| `1` | shadow-database / selected 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: Go's @@ -75,10 +105,14 @@ always go to stderr, in every `--output-format`. On success: - Requires `--experimental` or `[experimental.pgdelta] enabled = true`. - `--db-url` / `--linked` / `--local` are mutually exclusive; absent all three, smart mode prompts (existing-files overwrite → Local/Custom choice + reset offer). -- Remote Supabase targets (`--linked` / `--db-url`) get the embedded pg-delta CA - bundle written under `supabase/.temp/pgdelta/` and the URL rewritten to - `sslmode=verify-ca`; local / non-Supabase targets connect without it. -- **Architecture:** the shadow-database platform baseline is provisioned by the - bundled `supabase-go` via the hidden `db schema declarative __catalog` command - (it runs `start.SetupDatabase`'s auth/storage/realtime service migrations). The - rest — orchestration, pg-delta diff/export, file writes, prompts — is native. +- `--output ` selects a destination for this invocation only. Relative paths + resolve from the project workdir; it does not edit config or activate the output + for later syncs. A non-empty destination still requires confirmation or + `--overwrite`, and the configured declarative tree is left untouched. +- The default engine preserves the shared direct/pooler, DNS, TLS, and client + certificate connection behavior. The legacy opt-out retains its embedded CA + file and `sslmode=verify-ca` URL rewrite. +- **Architecture:** the default engine extracts the target directly using the + bundled Supabase management profile, then renders and writes the export + in-process. Under the opt-out, Go provisions/exports a legacy baseline catalog + and edge-runtime runs the Deno script. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts index 05214b0f24..f029fe56b4 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts @@ -15,6 +15,13 @@ const config = { overwrite: Flag.boolean("overwrite").pipe( Flag.withDescription("Overwrite declarative schema files without confirmation."), ), + output: Flag.string("output").pipe( + Flag.withAlias("o"), + Flag.withDescription( + "Write the generated declarative schema to this directory without changing the configured declarative schema path.", + ), + Flag.optional, + ), reset: Flag.boolean("reset").pipe( Flag.withDescription("Reset local database before generating (local data will be lost)."), ), @@ -61,16 +68,22 @@ const config = { // so the handler input merges it in alongside the leaf's own flags. export type LegacyDbSchemaDeclarativeGenerateFlags = CliCommand.Command.Config.Infer< typeof config -> & { readonly noCache: boolean }; +> & { readonly noCache: boolean; readonly strictCoverage: boolean }; export const legacyDbSchemaDeclarativeGenerateCommand = Command.make("generate", config).pipe( - Command.withDescription("Generate declarative schema from a database."), + Command.withDescription( + "Exports a live database into the complete declarative schema tree. This replaces declarative files only; it does not create migration files or update migration history. Use --output to stage an export without changing the configured declarative path. In non-interactive use, pass --local, --linked, or --db-url explicitly.", + ), Command.withShortDescription("Generate declarative schema from a database"), Command.withHandler((flags) => Effect.gen(function* () { // `--no-cache` is shared on the parent group; read the resolved value there. const shared = yield* legacyDbSchemaDeclarativeSharedBase; - const merged: LegacyDbSchemaDeclarativeGenerateFlags = { ...flags, noCache: shared.noCache }; + const merged: LegacyDbSchemaDeclarativeGenerateFlags = { + ...flags, + noCache: shared.noCache, + strictCoverage: shared.strictCoverage, + }; return yield* legacyDbSchemaDeclarativeGenerate(merged).pipe( // Go's PostRun prints this on success via `fmt.Println` → stdout // (`cmd/db_schema_declarative.go:93`), so keep it on stdout in text mode. In @@ -91,7 +104,9 @@ export const legacyDbSchemaDeclarativeGenerateCommand = Command.make("generate", withLegacyCommandInstrumentation({ flags: { "no-cache": merged.noCache, + "strict-coverage": merged.strictCoverage, overwrite: merged.overwrite, + output: merged.output, reset: merged.reset, schema: merged.schema, "db-url": merged.dbUrl, @@ -106,7 +121,7 @@ export const legacyDbSchemaDeclarativeGenerateCommand = Command.make("generate", // (StringVarP) (`cmd/db_schema_declarative.go:495,500`); telemetry reports // changed flags by canonical `flag.Name` via `pflag.Visit`, so map the // shorthands so `generate -s public -p secret` logs `schema`/`password`. - aliases: { s: "schema", p: "password" }, + aliases: { o: "output", s: "schema", p: "password" }, }), withJsonErrorHandling, ); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts index 25cce1ccbe..46ad222f12 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts @@ -1,6 +1,7 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { + LegacyDnsResolverFlag, legacyResolveExperimentalWithProjectEnv, legacyResolveYesWithProjectEnv, } from "../../../../../../shared/legacy/global-flags.ts"; @@ -18,6 +19,11 @@ import { import { LegacyLinkedProjectCache } from "../../../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyListLocalMigrations } from "../../../../../shared/legacy-pgdelta.cache.ts"; +import { legacyIsPgDeltaDebugEnabled } from "../../../../../shared/legacy-pgdelta.ts"; +import { + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseEndpoint, +} from "../../../shared/legacy-pgdelta-engine.service.ts"; import { LegacyDeclarativeMutuallyExclusiveFlagsError, LegacyDeclarativeNonInteractiveError, @@ -35,9 +41,9 @@ import { import type { LegacyDbSchemaDeclarativeGenerateFlags } from "./generate.command.ts"; import { type LegacyLocalConn, - legacyLocalUrl, - legacyResolveRemoteUrl, - legacyResolveSmartTargetUrl, + legacyLocalEndpoint, + legacyResolveRemoteEndpoint, + legacyResolveSmartTargetEndpoint, } from "../declarative.smart-target.ts"; export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.declarative.generate")( @@ -49,6 +55,8 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec const cliConfig = yield* LegacyCliConfig; 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/ @@ -117,13 +125,13 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec } } - // Go prints `utils.GetDeclarativeDir()` verbatim (`declarative.go:156`, - // `db_schema_declarative.go:268`) — the config value, relative unless a user - // configures an absolute `declarative_schema_path` — so user-facing renders use - // `declarativeDirRel`. File I/O needs the resolved dir: `path.resolve` (not - // `path.join`) so an absolute config value is used as-is, matching Go's - // `config.resolve`, which only prefixes the workdir onto a RELATIVE path. - const declarativeDirRel = legacyResolveDeclarativeDir(path, toml.pgDelta); + // Preserve the selected value for user-facing output: invocation-local + // `--output` wins, otherwise use the configured declarative path. File I/O + // resolves relative values from the project workdir while keeping absolute + // values unchanged. + const declarativeDirRel = Option.getOrElse(flags.output, () => + legacyResolveDeclarativeDir(path, toml.pgDelta), + ); const declarativeDir = path.resolve(cliConfig.workdir, declarativeDirRel); const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); const local: LegacyLocalConn = { port: toml.port, password: toml.password }; @@ -141,13 +149,16 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec declarativeDir, schema: flags.schema, noCache: flags.noCache, + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, + dnsResolver, ...(linkedProjectRef !== undefined ? { linkedProjectRef } : {}), }; const hasExplicitTarget = Option.isSome(flags.local) || Option.isSome(flags.linked) || Option.isSome(flags.dbUrl); - let targetUrl: string; + let target: LegacyPgDeltaDatabaseEndpoint; let overwrite: boolean; if (hasExplicitTarget) { const seam = yield* LegacyDeclarativeSeam; @@ -161,9 +172,9 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec if (Option.getOrElse(flags.local, () => false)) { yield* seam.ensureLocalDatabaseStarted(); } - targetUrl = legacyLocalUrl(local); + target = legacyLocalEndpoint(local, dnsResolver); } else { - targetUrl = yield* legacyResolveRemoteUrl(flags); + target = yield* legacyResolveRemoteEndpoint(flags); } overwrite = flags.overwrite; } else { @@ -219,7 +230,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec linkedProjectRef = linkedRef.value; } } - targetUrl = yield* legacyResolveSmartTargetUrl( + target = yield* legacyResolveSmartTargetEndpoint( flags, local, hasMigrations, @@ -232,7 +243,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec overwrite = true; } - const result = yield* legacyGenerateDeclarativeOutput(run, targetUrl); + const result = yield* legacyGenerateDeclarativeOutput(run, target); if (!overwrite && (yield* confirmOverwriteHasFiles(fs, declarativeDir))) { // Go's confirmOverwrite goes through Console.PromptYesNo (`internal/db/ @@ -268,7 +279,11 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec // 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`. - if (!flags.noCache) { + // A command-local --output 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.output)) { yield* (yield* LegacyDeclarativeSeam).exportCatalog({ mode: "declarative", noCache: flags.noCache, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index 389df21455..791784be25 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -46,6 +46,8 @@ import { LegacyEdgeRuntimeScript, } from "../../../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../../../shared/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"; import { LegacyDeclarativeShadowDbError } from "../../../shared/legacy-pgdelta.errors.ts"; import { type LegacyCatalogMode, @@ -86,6 +88,7 @@ interface SetupOpts { projectId?: Option.Option; exportFailsForMode?: LegacyCatalogMode; staleLocalImage?: boolean; + engineImplementation?: "legacy" | "next"; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -152,6 +155,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), ), provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), + provisionNextShadow: () => Effect.die("provisionNextShadow not used in declarative tests"), removeShadowContainer: () => Effect.void, }); const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; @@ -183,12 +187,39 @@ 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 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: "schemas/public/tables/players.sql", sql: "create table players ();" }, + ], + manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, + }), + }), + ) + : legacyPgDeltaLegacyEngineLayer.pipe( + Layer.provide(Layer.mergeAll(seam, edge, sslProbe, out.layer, BunServices.layer)), + ); const layer = Layer.mergeAll( out.layer, telemetry.layer, cache.layer, seam, edge, + engine, resolver, proxy, dbConn, @@ -202,10 +233,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed(LegacyDebugFlag, false), // The remote ref is a non-Supabase host that refuses TLS → no SSL env. - Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), + sslProbe, // The local-reset bucket-seed core statically requires the (lazy) Management-API // factory; never invoked on the local reset (projectRef === ""). Layer.succeed(LegacyPlatformApiFactory, { @@ -247,7 +275,9 @@ const flags = ( over: Partial = {}, ): LegacyDbSchemaDeclarativeGenerateFlags => ({ noCache: over.noCache ?? false, + strictCoverage: over.strictCoverage ?? false, overwrite: over.overwrite ?? false, + output: over.output ?? Option.none(), reset: over.reset ?? false, schema: over.schema ?? [], dbUrl: over.dbUrl ?? Option.none(), @@ -436,6 +466,117 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect( + "--output writes a complete next 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;"); + const configPath = join(tmp.current, "supabase", "config.toml"); + const config = [ + "[experimental.pgdelta]", + "enabled = true", + 'declarative_schema_path = "supabase/database"', + "", + ].join("\n"); + writeFileSync(configPath, config); + const destination = join("supabase", "database-next"); + const s = setup(tmp.current, { experimental: true, engineImplementation: "next" }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), output: Option.some(destination) }), + ); + + expect( + readFileSync( + join(tmp.current, destination, "schemas", "public", "tables", "players.sql"), + "utf8", + ), + ).toBe("create table players ();"); + expect( + JSON.parse(readFileSync(join(tmp.current, destination, ".pgdelta-export.json"), "utf8")), + ).toMatchObject({ + formatVersion: 1, + profile: "supabase", + files: ["schemas/public/tables/players.sql"], + }); + expect( + readFileSync(join(tmp.current, "supabase", "database", "configured.sql"), "utf8"), + ).toBe("select 1;"); + expect(readFileSync(configPath, "utf8")).toBe(config); + expect( + s.out.rawChunks.map((chunk) => ({ text: stripAnsi(chunk.text), stream: chunk.stream })), + ).toContainEqual({ + text: `Declarative schema written to ${destination}\n`, + stream: "stderr", + }); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("--output protects a non-empty destination without --overwrite", () => { + const destination = join(tmp.current, "staged-schema"); + mkdirSync(destination, { recursive: true }); + writeFileSync(join(destination, "keep.sql"), "select 'keep';"); + const s = setup(tmp.current, { + experimental: true, + engineImplementation: "next", + promptConfirmResponses: [false], + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), output: Option.some(destination) }), + ); + expect(readFileSync(join(destination, "keep.sql"), "utf8")).toBe("select 'keep';"); + expect(existsSync(join(destination, ".pgdelta-export.json"))).toBe(false); + expect(s.out.rawChunks.some((chunk) => chunk.text.includes("Skipped writing"))).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--output does not warm the configured legacy declarative tree", () => { + const s = setup(tmp.current, { experimental: true }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), output: Option.some("staged-schema") }), + ); + expect(s.seamCalls).toEqual(["baseline"]); + expect( + existsSync( + join(tmp.current, "staged-schema", "schemas", "public", "tables", "players.sql"), + ), + ).toBe(true); + expect(existsSync(join(tmp.current, "supabase", "database"))).toBe(false); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--overwrite replaces only the absolute --output destination", () => { + const destination = mkdtempSync(join(tmpdir(), "legacy-decl-output-")); + mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "database", "configured.sql"), "select 1;"); + writeFileSync(join(destination, "stale.sql"), "select 'stale';"); + const s = setup(tmp.current, { experimental: true, engineImplementation: "next" }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeGenerate( + flags({ + local: Option.some(true), + output: Option.some(destination), + overwrite: true, + }), + ); + expect(existsSync(join(destination, "stale.sql"))).toBe(false); + expect(existsSync(join(destination, ".pgdelta-export.json"))).toBe(true); + expect( + readFileSync(join(tmp.current, "supabase", "database", "configured.sql"), "utf8"), + ).toBe("select 1;"); + expect( + s.out.rawChunks.map((chunk) => ({ text: stripAnsi(chunk.text), stream: chunk.stream })), + ).toContainEqual({ + text: `Declarative schema written to ${destination}\n`, + stream: "stderr", + }); + rmSync(destination, { recursive: true, force: true }); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("explicit --local checks the local Postgres image before generating", () => { const s = setup(tmp.current, { experimental: true, staleLocalImage: true }); return Effect.gen(function* () { @@ -1045,4 +1186,21 @@ describe("legacy db schema declarative generate integration", () => { expect(s.edgeCalls[0]!.env["TARGET"]).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" }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + const manifest = JSON.parse( + readFileSync(join(tmp.current, "supabase", "database", ".pgdelta-export.json"), "utf8"), + ); + expect(manifest).toMatchObject({ + formatVersion: 1, + redactSecrets: true, + scope: "database", + files: ["schemas/public/tables/players.sql"], + }); + expect(s.seamCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts index 61eaf96544..3c0167732f 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts @@ -13,15 +13,19 @@ import { legacyLinkedDbResolverRuntimeLayer } from "../../../../../shared/legacy import { legacyPgDeltaSslProbeLayer } from "../../../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../../../telemetry/legacy-telemetry-state.layer.ts"; import { legacyDeclarativeSeamLayer } from "../../../shared/legacy-pgdelta.seam.layer.ts"; +import { legacyPgDeltaEngineLayer } from "../../../shared/legacy-pgdelta-engine.layer.ts"; +import { legacyPgDeltaNextAdapterLayer } from "../../../shared/legacy-pgdelta-next-adapter.layer.ts"; +import { legacyPgDeltaNextShadowLayer } from "../../../shared/legacy-pgdelta-next-shadow.layer.ts"; /** * Runtime layer for `supabase db schema declarative generate`. * * `Output` / `LegacyGoProxy` / global flags come from the legacy root; the Bun * platform (FileSystem / Path / ChildProcessSpawner / ProcessControl / Tty) from - * `runCli`. This layer adds the declarative-specific services: the edge-runtime - * pg-delta runner and the Go shadow-database seam, plus the db-config resolver - * for `--linked` / `--db-url`. Per the "provide doesn't share to siblings" rule, + * `runCli`. This layer adds both pg-delta implementations and the Go + * shadow-database seam, plus the db-config resolver for `--linked` / `--db-url`. + * The bundled implementation runs in-process by default; edge-runtime is retained + * only for the explicit legacy opt-out. Per the "provide doesn't share to siblings" rule, * `LegacyCliConfig` is provided to every layer that needs it. `legacyDockerRunLayer` * is ALSO exposed directly (not just provided to `edgeRuntime`): the smart-target * local-reset prompt now calls `legacyResetLocalDatabase` in-process (CLI-2062), @@ -46,6 +50,15 @@ const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( ); const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); +const nextShadow = legacyPgDeltaNextShadowLayer.pipe(Layer.provide(seam)); +const pgDeltaEngine = legacyPgDeltaEngineLayer.pipe( + Layer.provide(legacyPgDeltaNextAdapterLayer), + Layer.provide(nextShadow), + Layer.provide(edgeRuntime), + Layer.provide(legacyPgDeltaSslProbeLayer), + Layer.provide(seam), + Layer.provide(legacyDebugLoggerLayer), +); export const legacyDbSchemaDeclarativeGenerateRuntimeLayer = Layer.mergeAll( dbConfig, @@ -54,6 +67,7 @@ export const legacyDbSchemaDeclarativeGenerateRuntimeLayer = Layer.mergeAll( edgeRuntime, legacyPgDeltaSslProbeLayer, seam, + pgDeltaEngine, cliConfig, legacyIdentityStitchLayer, legacyTelemetryStateLayer, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 35b3ac0534..8f049bc5ae 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -3,41 +3,69 @@ Diffs local migrations state against declarative schema files and writes the delta as a new timestamped migration. +## Pg-delta implementation and compatibility + +- The default pg-delta and bundled pg-topo run in-process at the versions fixed + when the CLI is built. There is no runtime download or automatic fallback. +- `SUPABASE_USE_PG_DELTA_NEXT=false` selects the legacy catalog/edge-runtime + implementation. `supabase/.temp/pgdelta-version`, `PGDELTA_NPM_REGISTRY`, and + catalogs directly below `supabase/.temp/pgdelta/` are legacy-only. +- `--no-cache` bypasses legacy catalog reuse/warming. The default engine always + extracts current state and maintains no reusable catalog cache. +- With `PGDELTA_DEBUG`, default-engine snapshots, plan, and diagnostics are + written below `supabase/.temp/pgdelta/v2/debug//` and are not reusable. +- The default engine always refuses extraction or declarative-loading errors. + Fatal diagnostics are always shown. By default, `unmodeled_kind` coverage gaps + are summarized once while nonfatal internal diagnostics remain quiet; + `--strict-coverage` refuses coverage gaps and prints the exact blockers. Debug + mode prints every diagnostic. Artifacts are saved before policy evaluation. +- Default-engine migrations may differ byte-for-byte and may be split into + ordered files to preserve transaction boundaries. Successful execution and an + empty subsequent sync are the compatibility contract. +- Default-engine migrations use pg-delta's human-facing formatter (lowercase + keywords, max width 180) after safe plan compaction. A JSON object in + `[experimental.pgdelta].format_options` partially overrides the formatter; + the JSON literal `null` disables formatting without disabling compaction. + ## Files Read -| Path | Format | When | -| -------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always — pg-delta gate, format options | -| `/supabase/.temp/pgdelta-version` | plain text | always — pins the `@supabase/pg-delta` npm version | -| `/supabase/.temp/edge-runtime-version` | plain text | always — pins the edge-runtime image tag | -| `/supabase/database/**/*.sql` (declarative dir) | SQL | always — must exist (else error) | -| `/supabase/migrations/*.sql` | SQL | migrations-catalog resolution (native, CLI-1959) — hashed for the cache key and, on a miss, replayed onto the shadow via `db __shadow --mode diff` | -| `/supabase/roles.sql` | SQL | native migrations-catalog cache key (setup-inputs token; empty when absent) | -| `/supabase/.temp/pgdelta/*.json` | JSON | migrations catalog cache (native, CLI-1959); declarative catalog cache (still the Go seam) | +| Path | Format | When | +| -------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — pg-delta gate, format options | +| `/supabase/.temp/pgdelta-version` | plain text | always read for compatibility; affects legacy only | +| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out only — edge-runtime image tag | +| `/supabase/database/**/*.sql` (declarative dir) | SQL | always — must exist (else error) | +| `/supabase/migrations/*.sql` | SQL | default: applied to live shadow; legacy: native migrations-catalog resolution/cache | +| `/supabase/roles.sql` | SQL | legacy migrations-catalog cache key (empty when absent) | +| `/supabase/database/.pgdelta-export.json` | JSON | default-engine export policy, when present | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out only: migrations/declarative catalog cache | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------- | ------ | --------------------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | when schema changes are found | -| `/supabase/.temp/pgdelta/catalog-*-migrations-*.json` | JSON | migrations catalog cache write (native, CLI-1959) | -| `/supabase/.temp/pgdelta/catalog-*-declarative-*.json` | JSON | declarative catalog cache write (still the Go seam) | +| Path | Format | When | +| ------------------------------------------------------------------ | ------ | ---------------------------------------------------- | +| `/supabase/migrations/_[_].sql` | SQL | changes; default engine may emit ordered segments | +| `/supabase/database/extension.sql` | SQL | interactive, explicit legacy-extension repair only | +| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | legacy opt-out only: native/Go-backed catalog caches | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | default engine with `PGDELTA_DEBUG` | ## Subprocesses / Containers -| What | When | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| `supabase-go db __shadow --mode diff` (seam, unchanged) — shadow Postgres + `SetupDatabase` + apply migrations; the catalog itself is exported natively via edge-runtime (CLI-1959 — no longer the hidden `db schema declarative __catalog --mode migrations` subprocess) | migrations-catalog cache miss only | -| `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | -| Edge-runtime container running the pg-delta diff Deno script, and (on a migrations-catalog cache miss) the pg-delta catalog-export Deno script | always / cache miss | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` (CLI-2062: in-process, no `supabase-go` child) — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | +| What | When | +| --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `supabase-go db __shadow` / declarative shadow seam — platform baseline plus migrations and clean declarative target | default engine | +| `supabase-go db __shadow --mode diff` — shadow + migrations; catalog exported natively (CLI-1959) | legacy opt-out, migrations-catalog cache miss | +| `supabase-go db schema declarative __catalog --mode declarative --experimental` — declarative catalog target | legacy opt-out | +| Edge-runtime container running pg-delta diff/catalog-export scripts | legacy opt-out | +| `docker`/`podman` container recreate for local `db` (+ satellite restarts, Kong reload) via in-process `legacyResetLocalDatabase` | TTY only, apply failed, and the user confirms "reset and reapply" | ## Environment Variables | Variable | Purpose | Required? | | ---------------------------- | ----------------------------------------------------------- | --------- | -| `PGDELTA_NPM_REGISTRY` | private `@supabase` npm registry for pg-delta | no | -| `PGDELTA_DEBUG` | verbose pg-delta diagnostics | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for the legacy edge-runtime engine | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out only: private npm registry | no | +| `PGDELTA_DEBUG` | structured default-engine debug artifacts | no | | `SUPABASE_GO_BINARY` | override the `supabase-go` seam binary | no | | `SUPABASE_SERVICES_HOSTNAME` | local DB host for the bootstrap generate (Go `GetHostname`) | no | | `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | @@ -50,8 +78,9 @@ as a new timestamped migration. | `1` | pg-delta not enabled | | `1` | conflicting `--apply`/`--no-apply` (mutually exclusive) | | `1` | no declarative schema files found | -| `1` | shadow-database / edge-runtime / diff failure | +| `1` | shadow-database / selected pg-delta engine / diff failure | | `1` | apply failure (when applied) — propagated from the native migration apply (`applyMigrationToLocal`) | +| `1` | repairable legacy extension omissions in non-interactive mode | 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: Go's @@ -64,15 +93,35 @@ surfaces before an `--apply`/`--no-apply` conflict is ever checked. 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, Go's -`GetDeclarativeDir()`) to stderr after generating, writing, and warming the -catalog cache — on both the interactive-accept and `--yes` paths. +`GetDeclarativeDir()`) to stderr after generation and writing. Under the legacy +opt-out it prints after catalog warming — 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. +Before writing a migration, a manifest-less legacy tree that would remove only +`pgcrypto`, `uuid-ossp`, or `pg_net` offers three explicit choices: append the +detected declarations to root `extension.sql` and re-plan, continue with the +removals, or cancel. The repair uses `CREATE EXTENSION IF NOT EXISTS ... WITH +SCHEMA "extensions"`, never overwrites existing SQL, never creates a next-export +manifest, and proceeds only when the re-plan removes the compatibility gap. +Non-interactive execution, including global `--yes`, does not modify declarations +and stops with the exact SQL to add. + ## Notes - Requires `--experimental` or `[experimental.pgdelta] enabled = true`. +- The declarative directory is the complete, hand-authored desired state. An + object omitted from it is intended to be removed, including extensions. This + is deterministic regardless of whether the directory was generated, written + by hand, or has a `.pgdelta-export.json` manifest. +- For gaps involving unknown extensions or extension-managed state such as + `pg_cron` jobs, generate a staged next-compatible tree with + `generate --output supabase/database-next`, review it, and adopt or + merge it explicitly. `--output` neither changes `config.toml` nor activates the + staged tree. +- The targeted `extension.sql` repair preserves detected installed extensions; + it does not certify the legacy tree as a complete pg-delta next export. - `--file` sets the migration filename stem (default `declarative_sync`); `--name` overrides it. In a TTY without `--name`/`--yes`, the name is prompted. - When no declarative files exist, a TTY offers to generate them (from local) first. @@ -82,7 +131,9 @@ are mutually exclusive. (the reset itself is native too — `legacyResetLocalDatabase`, CLI-2062 — run in-process, sharing this command's own telemetry/linked-project-cache finalizer cycle rather than firing a second one from a `supabase-go` child). -- **Architecture:** the migrations-catalog diff source resolves natively (CLI-1959): +- **Architecture:** the default engine uses two scoped live shadow databases and + plans/renders in-process. Under the legacy opt-out, the migrations-catalog diff + source resolves natively (CLI-1959): the setup-inputs-folded cache key, the zero-local-migrations → platform-baseline reuse, and the pg-delta catalog export are all native TS; only the shadow-database platform-baseline provisioning + migrations apply still runs via the bundled @@ -91,5 +142,5 @@ are mutually exclusive. still provisions its shadow-database platform baseline (and applies declarative files) via the hidden `db schema declarative __catalog --mode declarative` seam, since neither a baseline-only shadow nor `pgdelta.ApplyDeclarative` has a native - TS port yet (tracked by CLI-1956/CLI-1823). The diff itself is native pg-delta - either way. + TS port yet (tracked by CLI-1956/CLI-1823). Its diff still uses the legacy Deno + script. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts index db9da924de..5ca71bd824 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts @@ -52,20 +52,28 @@ const config = { // so the handler input merges it in alongside the leaf's own flags. export type LegacyDbSchemaDeclarativeSyncFlags = CliCommand.Command.Config.Infer & { readonly noCache: boolean; + readonly strictCoverage: boolean; }; export const legacyDbSchemaDeclarativeSyncCommand = Command.make("sync", config).pipe( - Command.withDescription("Generate a new migration from declarative schema."), + Command.withDescription( + "Compares the supabase/migrations baseline with the complete declarative schema tree and writes the difference as migration files. When a legacy export omits known implicit extensions, interactive sync can add declarations and re-plan before writing. Use --no-apply for non-interactive generation without changing the local database; --apply or global --yes applies locally and updates local migration history.", + ), Command.withShortDescription("Generate a new migration from declarative schema"), Command.withHandler((flags) => Effect.gen(function* () { // `--no-cache` is shared on the parent group; read the resolved value there. const shared = yield* legacyDbSchemaDeclarativeSharedBase; - const merged: LegacyDbSchemaDeclarativeSyncFlags = { ...flags, noCache: shared.noCache }; + const merged: LegacyDbSchemaDeclarativeSyncFlags = { + ...flags, + noCache: shared.noCache, + strictCoverage: shared.strictCoverage, + }; return yield* legacyDbSchemaDeclarativeSync(merged).pipe( withLegacyCommandInstrumentation({ flags: { "no-cache": merged.noCache, + "strict-coverage": merged.strictCoverage, schema: merged.schema, file: merged.file, name: merged.name, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index c7f0ac387a..62c034e8c2 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -28,7 +28,10 @@ import { legacyPgDeltaTempPath, legacyResolveSetupInputs, } from "../../../../../shared/legacy-pgdelta.cache.ts"; -import { legacyResolveSmartTargetUrl } from "../declarative.smart-target.ts"; +import { LegacyPgDeltaEngine } from "../../../shared/legacy-pgdelta-engine.service.ts"; +import { legacyIsPgDeltaDebugEnabled } from "../../../../../shared/legacy-pgdelta.ts"; +import { legacyWritePgDeltaMigrations } from "../../../shared/legacy-pgdelta-migrations.write.ts"; +import { legacyResolveSmartTargetEndpoint } from "../declarative.smart-target.ts"; import { type LegacyDebugBundle, legacyCollectMigrationsList, @@ -38,14 +41,19 @@ import { } from "../../../shared/legacy-debug-bundle.ts"; import { LegacyDeclarativeApplyError, + LegacyDeclarativeCompatibilityError, LegacyDeclarativeMutuallyExclusiveFlagsError, LegacyDeclarativeNoFilesGeneratedError, LegacyDeclarativeNonInteractiveError, } from "../declarative.errors.ts"; import { + legacyClassifyDeclarativeCompatibilityGap, + legacyExtensionDeclaration, + legacyFormatStagedExportRecommendation, legacyResolveDeclarativeMigrationName, legacyResolveDeclarativeSyncApplyDecision, } from "../declarative.flow.ts"; +import { legacyAppendExtensionDeclarations } from "../declarative.extension-repair.ts"; import { legacyRequirePgDelta } from "../declarative.gate.ts"; import { type LegacyDeclarativeRunContext, @@ -92,6 +100,7 @@ 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 @@ -152,6 +161,9 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara declarativeDir, schema: flags.schema, noCache: flags.noCache, + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, + dnsResolver, }; const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent(); const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); @@ -225,7 +237,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara } // sync has no target flags (Go passes its target-less `cmd` into generate), // so reset stays interactive (the prompt fires under the local choice). - const targetUrl = yield* legacyResolveSmartTargetUrl( + const target = yield* legacyResolveSmartTargetEndpoint( { dbUrl: Option.none(), linked: Option.none(), password: Option.none(), reset: false }, { port: toml.port, password: toml.password }, hasMigrations, @@ -235,7 +247,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara linkedRef, ensureLocalPostgresImageCurrent, ); - const generated = yield* legacyGenerateDeclarativeOutput(run, targetUrl); + const generated = yield* legacyGenerateDeclarativeOutput(run, target); yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, generated); if (!(yield* declarativeDirHasFiles(fs, declarativeDir))) { return yield* Effect.fail( @@ -251,7 +263,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // 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) { + 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 @@ -275,28 +287,101 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara Option.getOrUndefined(toml.orioledbVersion), toml.baseline, ); - const result: LegacyDeclarativeSyncResult = yield* legacyDiffDeclarativeToMigrations( - run, - setupInputs, - ).pipe( - Effect.tapError((error) => - Effect.gen(function* () { - const migrations = yield* legacyCollectMigrationsList(fs, path, migrationsDir); - yield* legacySaveDebugBundle(fs, path, cliConfig.workdir, tempDir, migrationsDir, { - id: formatDebugId(yield* Clock.currentTimeMillis), - error: error.message, - migrations, - }).pipe( - Effect.matchEffect({ - // Go prints nothing when SaveDebugBundle errors on the diff path - // (`db_schema_declarative.go:337-340`: `if saveErr == nil`). - onFailure: () => Effect.void, - onSuccess: (debugDir) => output.raw(legacyDebugBundleMessage(debugDir), "stderr"), + const planDeclarativeSync = () => + legacyDiffDeclarativeToMigrations(run, setupInputs).pipe( + Effect.tapError((error) => + Effect.gen(function* () { + const migrations = yield* legacyCollectMigrationsList(fs, path, migrationsDir); + yield* legacySaveDebugBundle(fs, path, cliConfig.workdir, tempDir, migrationsDir, { + id: formatDebugId(yield* Clock.currentTimeMillis), + error: error.message, + migrations, + }).pipe( + Effect.matchEffect({ + // Go prints nothing when SaveDebugBundle errors on the diff path + // (`db_schema_declarative.go:337-340`: `if saveErr == nil`). + onFailure: () => Effect.void, + onSuccess: (debugDir) => output.raw(legacyDebugBundleMessage(debugDir), "stderr"), + }), + ); + }), + ), + ); + let result: LegacyDeclarativeSyncResult = yield* planDeclarativeSync(); + + // Resolve manifest-less legacy compatibility before printing or writing a + // migration. A repair is always explicit, even when global --yes is set. + const compatibility = legacyClassifyDeclarativeCompatibilityGap({ + implementation: engine.implementation, + manifestPresent: result.manifestPresent, + removals: result.removals, + }); + if (compatibility.recommendedAction === "repair-extensions") { + const statements = compatibility.repairableExtensions.map(legacyExtensionDeclaration); + const explanation = [ + "This declarative schema appears to use legacy pg-delta behavior. Legacy pg-delta treated these installed extensions as implicit, while pg-delta next treats their omission as removal:", + "", + ...compatibility.repairableExtensions.map((extension) => `- ${extension}`), + ].join("\n"); + if (!tty.stdinIsTty || yes) { + return yield* Effect.fail( + new LegacyDeclarativeCompatibilityError({ + message: [ + explanation, + "", + "Non-interactive sync will not modify the declarative schema automatically. Add these statements to extension.sql, then run sync again:", + ...statements, + "", + "Or generate a next-compatible schema into a separate directory:", + "supabase db schema declarative generate --output supabase/database-next", + ].join("\n"), + }), + ); + } + + yield* output.raw(`${legacyYellow(explanation)}\n`, "stderr"); + const choice = yield* output.promptSelect("How would you like to continue?", [ + { + value: "repair", + label: "Add declarations and re-plan", + hint: "recommended", + }, + { value: "continue", label: "Continue with removals" }, + { value: "cancel", label: "Cancel" }, + ]); + if (choice === "cancel") return; + if (choice === "repair") { + const repaired = yield* legacyAppendExtensionDeclarations( + declarativeDir, + compatibility.repairableExtensions, + ); + yield* output.raw( + `Updated ${legacyBold(repaired.path)} with:\n${repaired.addedDeclarations.join("\n")}\n`, + "stderr", + ); + result = yield* planDeclarativeSync(); + const remaining = legacyClassifyDeclarativeCompatibilityGap({ + implementation: engine.implementation, + manifestPresent: result.manifestPresent, + removals: result.removals, + }); + if (remaining.recommendedAction !== "none") { + return yield* Effect.fail( + new LegacyDeclarativeCompatibilityError({ + message: [ + "The compatibility removals remain after adding extension declarations.", + legacyFormatStagedExportRecommendation(remaining), + ].join("\n"), }), ); - }), - ), - ); + } + } + } else if (compatibility.recommendedAction === "stage-next-export") { + yield* output.raw( + `${legacyYellow(legacyFormatStagedExportRecommendation(compatibility))}\n`, + "stderr", + ); + } // Step 3: empty diff. if (result.diffSQL.trim().length < 2) { @@ -318,11 +403,28 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara } // Step 5: write the timestamped migration file. - const timestamp = formatTimestamp(yield* Clock.currentTimeMillis); - const migrationPath = path.join(migrationsDir, `${timestamp}_${migrationName}.sql`); - yield* legacyMakeDir(fs, migrationsDir); - yield* fs.writeFileString(migrationPath, result.diffSQL); - yield* output.raw(`Created new migration at ${legacyBold(migrationPath)}\n`, "stderr"); + const nowMillis = yield* Clock.currentTimeMillis; + let migrationPaths: ReadonlyArray; + if (engine.implementation === "next" && result.files.length > 1) { + const written = yield* legacyWritePgDeltaMigrations(fs, path, { + workdir: cliConfig.workdir, + baseMillis: nowMillis, + name: migrationName, + files: result.files, + }).pipe( + Effect.mapError((error) => new LegacyDeclarativeApplyError({ message: error.message })), + ); + migrationPaths = written.map((migration) => migration.path); + } else { + const timestamp = formatTimestamp(nowMillis); + const migrationPath = path.join(migrationsDir, `${timestamp}_${migrationName}.sql`); + yield* legacyMakeDir(fs, migrationsDir); + yield* fs.writeFileString(migrationPath, result.diffSQL); + migrationPaths = [migrationPath]; + } + for (const migrationPath of migrationPaths) { + yield* output.raw(`Created new migration at ${legacyBold(migrationPath)}\n`, "stderr"); + } // Step 6: drop warnings. if (result.dropWarnings.length > 0) { @@ -356,7 +458,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara yield* ensureLocalPostgresImageCurrent; const applyExit = yield* applyMigrationToLocal( { port: toml.port, password: toml.password, dnsResolver }, - migrationPath, + migrationPaths, ).pipe(Effect.exit); if (Exit.isSuccess(applyExit)) { @@ -468,10 +570,10 @@ const declarativeDirHasFiles = Effect.fnUntraced(function* ( return entries.length > 0; }); -/** Connects to the local database and applies the single migration file (Go's `applyMigrationToLocal`). */ +/** Connects once and applies the ordered migration files (Go's `applyMigrationToLocal`). */ const applyMigrationToLocal = ( local: { port: number; password: string; dnsResolver: "native" | "https" }, - migrationPath: string, + migrationPaths: ReadonlyArray, ) => Effect.gen(function* () { const dbConnection = yield* LegacyDbConnection; @@ -494,11 +596,13 @@ const applyMigrationToLocal = ( .pipe( Effect.mapError((error) => new LegacyDeclarativeApplyError({ message: error.message })), ); - yield* legacyApplyMigrationFile( - session, - fs, - path, - migrationPath, - (message) => new LegacyDeclarativeApplyError({ message }), - ); + for (const migrationPath of migrationPaths) { + yield* legacyApplyMigrationFile( + session, + fs, + path, + migrationPath, + (message) => new LegacyDeclarativeApplyError({ message }), + ); + } }).pipe(Effect.scoped); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index c4995d51df..32add16120 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; @@ -44,6 +44,12 @@ import { LegacyEdgeRuntimeScript, } from "../../../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { legacyPgDeltaLegacyEngineLayer } from "../../../shared/legacy-pgdelta-engine.legacy.layer.ts"; +import { + LegacyPgDeltaEngine, + type LegacyPgDeltaRemovalSummary, + type LegacyPgDeltaRenderedFile, +} from "../../../shared/legacy-pgdelta-engine.service.ts"; import { LegacyDeclarativeShadowDbError } from "../../../shared/legacy-pgdelta.errors.ts"; import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; @@ -68,6 +74,7 @@ interface SetupOpts { yes?: boolean; stdinIsTty?: boolean; diffSql?: string; + replannedDiffSql?: string; applyFails?: boolean; /** * Makes the recovery reset's `legacyResetLocalDatabase` fail immediately with @@ -82,6 +89,9 @@ interface SetupOpts { projectId?: Option.Option; staleLocalImage?: boolean; exportJson?: string; + engineImplementation?: "legacy" | "next"; + renderedFiles?: ReadonlyArray; + removals?: LegacyPgDeltaRemovalSummary; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -109,8 +119,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { // `provisionShadow` (Go's unchanged `db __shadow --mode diff`) instead of the // retired `exportCatalog({mode:"migrations"})` seam call. "baseline"/ // "declarative" still go through `exportCatalog`. - const provisionShadowCalls: Array<{ mode: string; targetLocal: boolean; rawChunksAt: number }> = - []; + const provisionShadowCalls: Array<{ mode: string; rawChunksAt: number }> = []; const seam = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: ({ mode }) => Effect.sync(() => { @@ -132,9 +141,10 @@ function setup(workdir: string, opts: SetupOpts = {}) { : Effect.void, ), ), - provisionShadow: ({ mode, targetLocal }) => + provisionNextShadow: () => Effect.die("provisionNextShadow not used in declarative tests"), + provisionShadow: ({ mode }) => Effect.sync(() => { - provisionShadowCalls.push({ mode, targetLocal, rawChunksAt: out.rawChunks.length }); + provisionShadowCalls.push({ mode, rawChunksAt: out.rawChunks.length }); return { container: "shadow-1", sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", @@ -214,12 +224,63 @@ 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 nextFiles = opts.renderedFiles ?? []; + 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: () => + Effect.succeed({ + files: [ + { name: "schemas/public/tables/players.sql", sql: "create table players ();" }, + ], + manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, + }), + planDeclarativeSchema: () => { + const extensionPath = join(workdir, "supabase", "database", "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(Layer.mergeAll(seam, edge, sslProbe, out.layer, BunServices.layer)), + ); const layer = Layer.mergeAll( out.layer, telemetry.layer, cache.layer, seam, edge, + engine, dbConn, resolver, mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), @@ -235,10 +296,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed(LegacyDebugFlag, false), // Sync diffs against the local DB, which refuses TLS → no SSL env injected. - Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), + 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, { @@ -274,6 +332,7 @@ const flags = ( over: Partial = {}, ): LegacyDbSchemaDeclarativeSyncFlags => ({ noCache: over.noCache ?? false, + strictCoverage: over.strictCoverage ?? false, schema: over.schema ?? [], file: over.file ?? Option.none(), name: over.name ?? Option.none(), @@ -558,7 +617,7 @@ describe("legacy db schema declarative sync integration", () => { // provisions its shadow via `provisionShadow` instead of a seam `exportCatalog` // call) fires after it, so the line sits at the end of the bootstrap, matching // Go's ordering. - const diffStart = s.provisionShadowCalls.find((c) => c.mode === "diff" && !c.targetLocal); + const diffStart = s.provisionShadowCalls.find((c) => c.mode === "diff"); expect(diffStart?.rawChunksAt).toBeGreaterThan(lineAt); // The generated files actually landed in the printed (resolved) dir. expect( @@ -845,6 +904,136 @@ describe("legacy db schema declarative sync integration", () => { }, ); + it.effect( + "recommends a staged next export before writing for extension-managed legacy gaps", + () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + experimental: true, + engineImplementation: "next", + diffSql: + "select cron.unschedule('refresh download metrics');\nDROP EXTENSION \"pgcrypto\";\n", + removals: { + extensions: ["pgcrypto", "uuid-ossp"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, + ], + }, + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + const chunks = s.out.rawChunks.map((chunk) => stripAnsi(chunk.text)); + const warningAt = chunks.findIndex((chunk) => + chunk.includes("legacy export did not represent"), + ); + const createdAt = chunks.findIndex((chunk) => chunk.includes("Created new migration at")); + expect(warningAt).toBeGreaterThan(-1); + expect(chunks[warningAt]).toContain("pg_cron job refresh download metrics"); + expect(chunks[warningAt]).toContain("--output supabase/database-next"); + expect(warningAt).toBeLessThan(createdAt); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("adds detected legacy extension declarations and re-plans before writing", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + engineImplementation: "next", + stdinIsTty: true, + diffSql: 'DROP EXTENSION "pg_net";\n', + replannedDiffSql: "", + removals: { extensions: ["pg_net"], extensionIntents: [] }, + promptSelectResponses: ["repair"], + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect(readFileSync(join(tmp.current, "supabase", "database", "extension.sql"), "utf8")).toBe( + 'CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions";\n', + ); + expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(stripAnsi(s.out.rawChunks.map((chunk) => chunk.text).join(""))).toContain( + "No schema changes found", + ); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "continues with intentional legacy extension removals only after explicit choice", + () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + engineImplementation: "next", + stdinIsTty: true, + diffSql: 'DROP EXTENSION "pgcrypto";\n', + removals: { extensions: ["pgcrypto"], extensionIntents: [] }, + promptSelectResponses: ["continue"], + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect(readdirSync(join(tmp.current, "supabase", "migrations"))).toHaveLength(1); + expect(existsSync(join(tmp.current, "supabase", "database", "extension.sql"))).toBe(false); + }).pipe(Effect.provide(s.layer)); + }, + ); + + 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: [] }, + promptSelectResponses: ["cancel"], + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(existsSync(join(tmp.current, "supabase", "database", "extension.sql"))).toBe(false); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("fails safely instead of repairing when sync is non-interactive", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + engineImplementation: "next", + diffSql: 'DROP EXTENSION "pgcrypto";\n', + removals: { extensions: ["pgcrypto"], extensionIntents: [] }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })).pipe( + Effect.exit, + ); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeCompatibilityError", + message: expect.stringContaining( + 'CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions";', + ), + }); + expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(existsSync(join(tmp.current, "supabase", "database", "extension.sql"))).toBe(false); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("suppresses the compatibility warning when a next export manifest is present", () => { + seedDeclarative(tmp.current); + writeFileSync( + join(tmp.current, "supabase", "database", ".pgdelta-export.json"), + JSON.stringify({ formatVersion: 1, redactSecrets: true, scope: "database" }), + ); + 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).toContain("Found drop statements"); + }).pipe(Effect.provide(s.layer)); + }); + it.effect( "--apply: applies the migration natively (BEGIN … statements … COMMIT + history)", () => { @@ -981,4 +1170,36 @@ describe("legacy db schema declarative sync integration", () => { expect(createArgs?.[networkIndex + 1]).toBe("my_net"); }).pipe(Effect.provide(s.layer)); }); + + it.effect("next engine preserves ordered migration segments as separate files", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + experimental: true, + engineImplementation: "next", + renderedFiles: [ + { + sequence: 1, + name: "transactional", + suffix: "_1", + sql: "ALTER TABLE a ADD COLUMN b int;", + transactionMode: "transactional", + }, + { + sequence: 2, + name: "non_transactional", + suffix: "_2", + sql: "ALTER TYPE mood ADD VALUE 'fine';", + transactionMode: "none", + }, + ], + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + const migrations = readdirSync(join(tmp.current, "supabase", "migrations")).sort(); + 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/legacy/commands/db/schema/declarative/sync/sync.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts index a54b47476d..e05ef58297 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts @@ -13,6 +13,9 @@ import { legacyLinkedDbResolverRuntimeLayer } from "../../../../../shared/legacy import { legacyPgDeltaSslProbeLayer } from "../../../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../../../telemetry/legacy-telemetry-state.layer.ts"; import { legacyDeclarativeSeamLayer } from "../../../shared/legacy-pgdelta.seam.layer.ts"; +import { legacyPgDeltaEngineLayer } from "../../../shared/legacy-pgdelta-engine.layer.ts"; +import { legacyPgDeltaNextAdapterLayer } from "../../../shared/legacy-pgdelta-next-adapter.layer.ts"; +import { legacyPgDeltaNextShadowLayer } from "../../../shared/legacy-pgdelta-next-shadow.layer.ts"; /** * Runtime layer for `supabase db schema declarative sync`. Sync diffs against the @@ -45,6 +48,15 @@ const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( ); const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); +const nextShadow = legacyPgDeltaNextShadowLayer.pipe(Layer.provide(seam)); +const pgDeltaEngine = legacyPgDeltaEngineLayer.pipe( + Layer.provide(legacyPgDeltaNextAdapterLayer), + Layer.provide(nextShadow), + Layer.provide(edgeRuntime), + Layer.provide(legacyPgDeltaSslProbeLayer), + Layer.provide(seam), + Layer.provide(legacyDebugLoggerLayer), +); export const legacyDbSchemaDeclarativeSyncRuntimeLayer = Layer.mergeAll( dbConfig, @@ -52,6 +64,7 @@ export const legacyDbSchemaDeclarativeSyncRuntimeLayer = Layer.mergeAll( edgeRuntime, legacyPgDeltaSslProbeLayer, seam, + pgDeltaEngine, legacyDbConnectionLayer, cliConfig, legacyIdentityStitchLayer, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts new file mode 100644 index 0000000000..d662e1e7d3 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts @@ -0,0 +1,69 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.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 { LegacyPgDeltaNextAdapter } from "./legacy-pgdelta-next-adapter.service.ts"; +import { LegacyPgDeltaNextShadow } from "./legacy-pgdelta-next-shadow.service.ts"; +import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; +import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { legacyResolvePgDeltaImplementation } from "../../../shared/legacy-pgdelta-next-flag.ts"; + +const FLAG = "SUPABASE_USE_PG_DELTA_NEXT"; + +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; + }, +) { + return Layer.unwrap( + Effect.gen(function* () { + const implementation = yield* resolveAndLog(raw); + return implementation === "next" ? layers.next : layers.legacy; + }), + ); +} + +/** Reads the rollout flag once when the command-scoped layer is constructed. */ +export const legacyPgDeltaEngineLayer = Layer.unwrap( + Effect.gen(function* () { + const raw = process.env[FLAG]; + const implementation = yield* resolveAndLog(raw); + return selectProductionLayer(implementation); + }), +); + +function selectProductionLayer( + implementation: "next" | "legacy", +): Layer.Layer< + LegacyPgDeltaEngine, + never, + | LegacyPgDeltaNextAdapter + | LegacyPgDeltaNextShadow + | LegacyDebugLogger + | LegacyDeclarativeSeam + | LegacyEdgeRuntimeScript + | LegacyPgDeltaSslProbe + | FileSystem.FileSystem + | Output + | Path.Path +> { + return implementation === "next" ? legacyPgDeltaNextEngineLayer : legacyPgDeltaLegacyEngineLayer; +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts new file mode 100644 index 0000000000..ce9fb08d30 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts @@ -0,0 +1,199 @@ +import { Effect, Exit, Layer } from "effect"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { it } from "@effect/vitest"; +import { afterEach, describe, expect } from "vitest"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; +import { LegacyPgDeltaNextAdapter } from "./legacy-pgdelta-next-adapter.service.ts"; +import { LegacyPgDeltaNextShadow } from "./legacy-pgdelta-next-shadow.service.ts"; +import { + legacyPgDeltaEngineLayer, + legacyPgDeltaEngineSelectorLayer, +} from "./legacy-pgdelta-engine.layer.ts"; +import { LegacyPgDeltaEngine } from "./legacy-pgdelta-engine.service.ts"; + +const FLAG = "SUPABASE_USE_PG_DELTA_NEXT"; + +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`), + }), + ); +} + +const unusedLegacyRuntime = Layer.mergeAll( + BunServices.layer, + Layer.succeed(LegacyEdgeRuntimeScript, { + run: () => Effect.die("edge runtime not needed"), + }), + Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.die("SSL probe not needed"), + requireSslForHost: () => Effect.die("SSL probe not needed"), + }), + Layer.succeed(LegacyDeclarativeSeam, { + exportCatalog: () => Effect.die("catalog not needed"), + ensureLocalDatabaseStarted: () => Effect.die("local start not needed"), + ensureLocalPostgresImageCurrent: () => Effect.die("image check not needed"), + provisionShadow: () => Effect.die("shadow not needed"), + provisionNextShadow: () => Effect.die("next shadow not needed"), + removeShadowContainer: () => Effect.die("cleanup not needed"), + }), + Layer.succeed(LegacyPgDeltaNextAdapter, { + diff: () => Effect.die("adapter not needed"), + exportDeclarativeSchema: () => Effect.die("adapter not needed"), + planDeclarativeSchema: () => Effect.die("adapter not needed"), + captureSnapshot: () => Effect.die("adapter not needed"), + }), + Layer.succeed(LegacyPgDeltaNextShadow, { + provision: () => Effect.die("next shadow not needed"), + }), + mockOutput().layer, +); + +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("selects legacy only for an explicit false value", () => { + const messages: Array = []; + return Effect.gen(function* () { + const engine = yield* LegacyPgDeltaEngine; + expect(engine.implementation).toBe("legacy"); + expect(messages).toEqual(["Using pg-delta legacy implementation."]); + }).pipe( + Effect.provide( + legacyPgDeltaEngineSelectorLayer("false", { + 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 }, + 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)), + ), + ), + ); + }); +}); + +describe("legacyPgDeltaEngineLayer", () => { + afterEach(() => { + delete process.env[FLAG]; + }); + + it.effect("reads the environment once for the command-scoped service", () => { + const messages: Array = []; + process.env[FLAG] = "false"; + + return Effect.gen(function* () { + const first = yield* LegacyPgDeltaEngine; + process.env[FLAG] = "true"; + const second = yield* LegacyPgDeltaEngine; + + expect(first).toBe(second); + expect(second.implementation).toBe("legacy"); + expect(messages).toEqual(["Using pg-delta legacy implementation."]); + }).pipe( + Effect.provide( + legacyPgDeltaEngineLayer.pipe( + Layer.provide(unusedLegacyRuntime), + Layer.provide(debugLayer(messages)), + ), + ), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts new file mode 100644 index 0000000000..7f61efd428 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts @@ -0,0 +1,203 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { legacyFindDropStatements } from "../../../shared/legacy-sql-split.ts"; +import { + LegacyPgDeltaEngine, + LegacyPgDeltaEngineError, + type LegacyPgDeltaDiffResult, + type LegacyPgDeltaEndpoint, + type LegacyPgDeltaTransactionMode, +} from "./legacy-pgdelta-engine.service.ts"; +import { + type LegacyPgDeltaContext, + legacyDeclarativeExportPgDelta, + legacyDiffPgDelta, + legacyExportCatalogPgDelta, +} from "../../../shared/legacy-pgdelta.ts"; +import { + legacyGetMigrationsCatalogRef, + legacyResolveMigrationsCatalogRef, +} from "../../../shared/legacy-pgdelta.cache.ts"; +import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; + +const mapError = (cause: { readonly message: string }) => + new LegacyPgDeltaEngineError({ message: cause.message, cause }); + +function normalizeDiff( + result: { + readonly sql: string; + readonly stderr: string; + readonly files: ReadonlyArray<{ + readonly order: number; + readonly name: string; + readonly transactionMode: LegacyPgDeltaTransactionMode; + 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 provideRuntime = ( + operation: Effect.Effect< + Success, + Error, + | LegacyDeclarativeSeam + | LegacyEdgeRuntimeScript + | LegacyPgDeltaSslProbe + | FileSystem.FileSystem + | Output + | Path.Path + >, + ) => + operation.pipe( + Effect.provideService(LegacyEdgeRuntimeScript, edgeRuntime), + Effect.provideService(LegacyPgDeltaSslProbe, sslProbe), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(LegacyDeclarativeSeam, seam), + Effect.provideService(Output, output), + ); + + const endpointRef = (context: LegacyPgDeltaContext, endpoint: LegacyPgDeltaEndpoint) => + endpoint.kind === "database" + ? Effect.succeed(endpoint.ref) + : legacyResolveMigrationsCatalogRef( + fs, + path, + context, + endpoint.projectRef !== undefined ? { projectRef: endpoint.projectRef } : {}, + ).pipe(provideRuntime); + + return LegacyPgDeltaEngine.of({ + implementation: "legacy", + diffExplicit: (input) => + Effect.gen(function* () { + const sourceRef = yield* endpointRef(input.context, input.source); + const targetRef = yield* endpointRef(input.context, input.desired); + 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* () { + const shadow = yield* seam.provisionShadow({ + mode: "diff", + schema: input.schema, + ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), + }); + const sourceSnapshot = input.debug + ? yield* provideRuntime( + legacyExportCatalogPgDelta(input.context, { + targetRef: shadow.sourceUrl, + role: "postgres", + }), + ).pipe(Effect.orElseSucceed(() => undefined)) + : undefined; + return yield* provideRuntime( + legacyDiffPgDelta(input.context, { + sourceRef: shadow.sourceUrl, + 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; + }), + Effect.ensuring(seam.removeShadowContainer(shadow.container)), + ); + }).pipe(Effect.mapError(mapError)), + exportDeclarativeSchema: (input) => + Effect.gen(function* () { + const baselineRef = yield* seam.exportCatalog({ + mode: "baseline", + noCache: input.noCache, + ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), + }); + const result = yield* provideRuntime( + legacyDeclarativeExportPgDelta(input.context, { + sourceRef: baselineRef, + 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* () { + const sourceRef = yield* legacyGetMigrationsCatalogRef( + fs, + path, + input.context, + 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/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts new file mode 100644 index 0000000000..60c727dd93 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts @@ -0,0 +1,371 @@ +import { Clock, Effect, FileSystem, Layer, Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { parseLegacyConnectionString } from "../../../shared/legacy-db-config.parse.ts"; +import { LegacyDbConnectError } from "../../../shared/legacy-db-connection.errors.ts"; +import { legacyAcquirePgPool } from "../../../shared/legacy-db-connection.sql-pg.layer.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { + LegacyPgDeltaEngine, + LegacyPgDeltaEngineError, + type LegacyPgDeltaDatabaseEndpoint, + type LegacyPgDeltaDiffResult, + type LegacyPgDeltaEndpoint, +} from "./legacy-pgdelta-engine.service.ts"; +import { + LegacyPgDeltaNextAdapter, + type LegacyPgDeltaNextOperation, +} from "./legacy-pgdelta-next-adapter.service.ts"; +import { + legacyFormatPgDeltaNextDebugId, + legacySavePgDeltaNextDebugArtifacts, + type LegacyPgDeltaNextDebugArtifacts, +} from "./legacy-pgdelta-next-artifacts.ts"; +import { LegacyPgDeltaNextShadow } from "./legacy-pgdelta-next-shadow.service.ts"; +import { + legacyPgDeltaNextDiagnosticReport, + legacyReportPgDeltaNextDiagnostics, +} from "./legacy-pgdelta-next-diagnostics.ts"; + +/** Shared by both declarative planner entrypoints over the full isolated baseline. */ +export const legacyPgDeltaNextIsolatedShadowPlanOptions = { + isolatedShadow: true, + seedAssumedSchemas: false, +} as const; + +function legacyPgDeltaNextConnectSuggestion(cause: unknown): string | undefined { + if (cause instanceof LegacyDbConnectError) return cause.suggestion; + if (typeof cause !== "object" || cause === null) return undefined; + const nested = Reflect.get(cause, "cause"); + return nested === cause ? undefined : legacyPgDeltaNextConnectSuggestion(nested); +} + +export const legacyPgDeltaNextEngineError = (cause: unknown) => { + if (cause instanceof LegacyPgDeltaEngineError) return cause; + const suggestion = legacyPgDeltaNextConnectSuggestion(cause); + return new LegacyPgDeltaEngineError({ + message: + typeof cause === "object" && + cause !== null && + typeof Reflect.get(cause, "message") === "string" + ? String(Reflect.get(cause, "message")) + : String(cause), + cause, + ...(suggestion !== undefined ? { suggestion } : {}), + }); +}; + +function normalizeNextDiff( + result: { + readonly changes: boolean; + readonly sql: string; + readonly files: ReadonlyArray<{ + readonly sequence: number; + readonly suffix: string | null; + readonly sql: string; + readonly transactionMode: "transactional" | "none"; + readonly actionCount: number; + }>; + readonly removals?: LegacyPgDeltaDiffResult["removals"]; + readonly debug?: { + readonly sourceSnapshot?: string; + readonly desiredSnapshot?: string; + readonly plan?: string; + }; + }, + debugDirectory?: string, +): LegacyPgDeltaDiffResult { + return { + changes: result.changes, + sql: result.sql, + files: result.files.map((file) => ({ + sequence: file.sequence, + name: `segment_${file.sequence}`, + suffix: file.suffix, + sql: file.sql, + transactionMode: file.transactionMode, + actionCount: file.actionCount, + })), + ...(result.removals !== undefined ? { removals: result.removals } : {}), + ...(result.debug !== undefined + ? { + debug: { + ...result.debug, + ...(debugDirectory !== undefined ? { directory: debugDirectory } : {}), + }, + } + : {}), + }; +} + +function parseEndpoint(endpoint: LegacyPgDeltaDatabaseEndpoint) { + if (endpoint.connection !== undefined) return endpoint.connection; + const parsed = parseLegacyConnectionString(endpoint.ref); + if (parsed !== undefined) return parsed; + throw new LegacyPgDeltaEngineError({ + message: "failed to parse Postgres connection string for pg-delta", + cause: endpoint.ref.replace(/:[^:@/]+@/, ":***@"), + }); +} + +/** In-process pg-delta next implementation. Every pool and shadow is scope-owned. */ +export const legacyPgDeltaNextEngineLayer = Layer.effect( + LegacyPgDeltaEngine, + Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const shadowService = yield* LegacyPgDeltaNextShadow; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const debugLogger = yield* LegacyDebugLogger; + const output = yield* Output; + let feedbackInvitationShown = false; + + const saveDebugArtifacts = ( + workdir: string, + operation: LegacyPgDeltaNextOperation, + artifacts: LegacyPgDeltaNextDebugArtifacts, + ) => + Effect.gen(function* () { + const id = legacyFormatPgDeltaNextDebugId(yield* Clock.currentTimeMillis, operation); + const debugDir = yield* legacySavePgDeltaNextDebugArtifacts( + fs, + path, + workdir, + id, + operation, + artifacts, + ); + yield* debugLogger.debug(`Saved pg-delta next debug artifacts to ${debugDir}.`); + return debugDir; + }).pipe( + Effect.catch((cause) => + debugLogger + .debug( + `Failed to save pg-delta next debug artifacts: ${ + typeof cause === "object" && + cause !== null && + typeof Reflect.get(cause, "message") === "string" + ? String(Reflect.get(cause, "message")) + : String(cause) + }`, + ) + .pipe(Effect.as(undefined)), + ), + ); + + const acquireDatabase = (endpoint: LegacyPgDeltaDatabaseEndpoint) => + legacyAcquirePgPool(parseEndpoint(endpoint), endpoint.connectOptions); + + const reportDiagnostics = ( + operation: LegacyPgDeltaNextOperation, + diagnostics: Parameters[1], + strictCoverage: boolean, + verboseDiagnostics: boolean, + ) => { + const report = legacyPgDeltaNextDiagnosticReport(diagnostics, strictCoverage); + const showFeedback = !feedbackInvitationShown && report.unmodeledKinds.length > 0; + if (showFeedback) feedbackInvitationShown = true; + return legacyReportPgDeltaNextDiagnostics( + operation, + diagnostics, + strictCoverage, + showFeedback, + verboseDiagnostics, + ).pipe( + Effect.provideService(Output, output), + Effect.provideService(LegacyDebugLogger, debugLogger), + ); + }; + + return LegacyPgDeltaEngine.of({ + implementation: "next", + diffExplicit: (input) => + Effect.scoped( + Effect.gen(function* () { + let shadow: + | { readonly migrationsUrl: string; readonly declarativeUrl: string } + | undefined; + const migrationsEndpoint = + input.source.kind === "migrations" + ? input.source + : input.desired.kind === "migrations" + ? input.desired + : undefined; + if (migrationsEndpoint !== undefined) { + shadow = yield* shadowService.provision({ + schema: input.schema, + ...(migrationsEndpoint.projectRef !== undefined + ? { projectRef: migrationsEndpoint.projectRef } + : {}), + }); + } + const endpointPool = (endpoint: LegacyPgDeltaEndpoint) => + Effect.gen(function* () { + if (endpoint.kind === "database") return yield* acquireDatabase(endpoint); + if (shadow === undefined) { + return yield* Effect.die("missing pg-delta migrations shadow"); + } + const connection = parseLegacyConnectionString(shadow.migrationsUrl); + if (connection === undefined) { + return yield* Effect.fail( + new LegacyPgDeltaEngineError({ + message: "failed to parse pg-delta migrations shadow URL", + cause: shadow.migrationsUrl.replace(/:[^:@/]+@/, ":***@"), + }), + ); + } + return yield* legacyAcquirePgPool(connection, { + isLocal: true, + dnsResolver: "native", + }); + }); + const [sourcePool, desiredPool] = yield* Effect.all( + [endpointPool(input.source), endpointPool(input.desired)], + { concurrency: 2 }, + ); + const result = yield* adapter.diff({ + sourcePool, + desiredPool, + allowDrops: true, + debug: input.debug, + schema: input.schema, + formatOptions: input.formatOptions, + }); + const debugDirectory = + result.debug !== undefined + ? yield* saveDebugArtifacts(input.context.cwd, "diff", { + ...result.debug, + diagnostics: result.diagnostics, + }) + : undefined; + yield* reportDiagnostics("diff", result.diagnostics, input.strictCoverage, input.debug); + return normalizeNextDiff(result, debugDirectory); + }), + ).pipe(Effect.mapError(legacyPgDeltaNextEngineError)), + diffDatabase: (input) => + Effect.scoped( + Effect.gen(function* () { + const shadow = yield* shadowService.provision({ + schema: input.schema, + ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), + }); + const migrations = parseLegacyConnectionString(shadow.migrationsUrl); + if (migrations === undefined) { + return yield* Effect.fail( + new LegacyPgDeltaEngineError({ + message: "failed to parse pg-delta next shadow database URL", + cause: "invalid password-free shadow output", + }), + ); + } + const migrationsPool = yield* legacyAcquirePgPool(migrations, { + isLocal: true, + dnsResolver: "native", + }); + const desiredPool = yield* acquireDatabase(input.target); + const result = yield* adapter.diff({ + sourcePool: migrationsPool, + desiredPool, + allowDrops: true, + debug: input.debug, + schema: input.schema, + formatOptions: input.formatOptions, + }); + const debugDirectory = + result.debug !== undefined + ? yield* saveDebugArtifacts(input.context.cwd, "diff", { + ...result.debug, + diagnostics: result.diagnostics, + }) + : undefined; + yield* reportDiagnostics("diff", result.diagnostics, input.strictCoverage, input.debug); + return normalizeNextDiff(result, debugDirectory); + }), + ).pipe(Effect.mapError(legacyPgDeltaNextEngineError)), + exportDeclarativeSchema: (input) => + Effect.scoped( + Effect.gen(function* () { + const pool = yield* acquireDatabase(input.target); + const result = yield* adapter.exportDeclarativeSchema({ + pool, + layout: "grouped", + schema: input.schema, + formatOptions: input.formatOptions, + }); + if (input.debug) { + const capture = yield* adapter + .captureSnapshot({ pool, redactSecrets: true }) + .pipe(Effect.orElseSucceed(() => undefined)); + yield* saveDebugArtifacts(input.context.cwd, "declarativeExport", { + ...(capture !== undefined ? { desiredSnapshot: capture.snapshot } : {}), + diagnostics: + capture === undefined + ? result.diagnostics + : [...result.diagnostics, ...capture.diagnostics], + }); + } + yield* reportDiagnostics( + "declarativeExport", + result.diagnostics, + input.strictCoverage, + input.debug, + ); + return { files: result.files, manifest: result.manifest }; + }), + ).pipe(Effect.mapError(legacyPgDeltaNextEngineError)), + planDeclarativeSchema: (input) => + Effect.scoped( + Effect.gen(function* () { + const shadow = yield* shadowService.provision({ schema: input.schema }); + const migrations = parseLegacyConnectionString(shadow.migrationsUrl); + const declarative = parseLegacyConnectionString(shadow.declarativeUrl); + if (migrations === undefined || declarative === undefined) { + return yield* Effect.fail( + new LegacyPgDeltaEngineError({ + message: "failed to parse pg-delta next shadow database URL", + cause: "invalid password-free shadow output", + }), + ); + } + const [migrationsPool, declarativePool] = yield* Effect.all( + [ + legacyAcquirePgPool(migrations, { isLocal: true, dnsResolver: "native" }), + legacyAcquirePgPool(declarative, { isLocal: true, dnsResolver: "native" }), + ], + { concurrency: 2 }, + ); + const result = yield* adapter.planDeclarativeSchema({ + targetPool: migrationsPool, + shadowPool: declarativePool, + files: input.files, + allowDrops: true, + debug: input.debug, + reorder: true, + ...legacyPgDeltaNextIsolatedShadowPlanOptions, + schema: input.schema, + formatOptions: input.formatOptions, + ...(input.manifest !== undefined ? { manifest: input.manifest } : {}), + }); + const debugDirectory = + result.debug !== undefined + ? yield* saveDebugArtifacts(input.context.cwd, "declarativePlan", { + ...result.debug, + diagnostics: result.diagnostics, + }) + : undefined; + yield* reportDiagnostics( + "declarativePlan", + result.diagnostics, + input.strictCoverage, + input.debug, + ); + return { + ...normalizeNextDiff(result, debugDirectory), + sourceRef: "pg-delta-next:migrations", + targetRef: "pg-delta-next:declarative", + }; + }), + ).pipe(Effect.mapError(legacyPgDeltaNextEngineError)), + }); + }), +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.unit.test.ts new file mode 100644 index 0000000000..7b8921c48e --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.unit.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +import { legacyPgDeltaNextIsolatedShadowPlanOptions } from "./legacy-pgdelta-engine.next.layer.ts"; + +describe("legacyPgDeltaNextIsolatedShadowPlanOptions", () => { + it("uses the isolated full-baseline mode shared by both declarative planner entrypoints", () => { + expect(legacyPgDeltaNextIsolatedShadowPlanOptions).toEqual({ + isolatedShadow: true, + seedAssumedSchemas: false, + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.unit.test.ts new file mode 100644 index 0000000000..d1db522f46 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.unit.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { LegacyDbConnectError } from "../../../shared/legacy-db-connection.errors.ts"; +import { legacyPgDeltaNextEngineError } from "./legacy-pgdelta-engine.next.layer.ts"; +import { LegacyPgDeltaEngineError } from "./legacy-pgdelta-engine.service.ts"; +import { LegacyPgDeltaNextError } from "./legacy-pgdelta-next-adapter.service.ts"; + +describe("pg-delta next engine errors", () => { + it("preserves database connection suggestions when wrapping failures", () => { + const cause = new LegacyDbConnectError({ + message: "failed to connect to postgres", + suggestion: "Retry with --dns-resolver https.", + }); + + expect(legacyPgDeltaNextEngineError(cause)).toEqual( + new LegacyPgDeltaEngineError({ + message: "failed to connect to postgres", + suggestion: "Retry with --dns-resolver https.", + cause, + }), + ); + }); + + it("finds connection suggestions nested in adapter failures", () => { + const cause = new LegacyDbConnectError({ + message: "failed to connect to postgres", + suggestion: "Retry with --dns-resolver https.", + }); + const adapterError = new LegacyPgDeltaNextError({ + operation: "diff", + message: "Database diff failed", + cause, + }); + + expect(legacyPgDeltaNextEngineError(adapterError).suggestion).toBe( + "Retry with --dns-resolver https.", + ); + }); + + it("does not wrap an existing engine error again", () => { + const error = new LegacyPgDeltaEngineError({ message: "blocked", cause: "diagnostic" }); + expect(legacyPgDeltaNextEngineError(error)).toBe(error); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts new file mode 100644 index 0000000000..c478cd5cd8 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts @@ -0,0 +1,150 @@ +import { Context, Data, type Effect } from "effect"; + +import type { + LegacyDbConnectOptions, + LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import type { LegacyPgDeltaContext } from "../../../shared/legacy-pgdelta.ts"; +import type { LegacySetupInputs } from "../../../shared/legacy-pgdelta.cache.ts"; +import type { LegacyPgDeltaImplementation } from "../../../shared/legacy-pgdelta-next-flag.ts"; + +export interface LegacyPgDeltaDatabaseEndpoint { + readonly kind: "database"; + /** URL/reference used by the legacy edge-runtime implementation. */ + readonly ref: string; + /** Full parsed connection, preferred by the next implementation. */ + readonly connection?: LegacyPgConnInput; + readonly connectOptions: LegacyDbConnectOptions; +} + +interface LegacyPgDeltaMigrationsEndpoint { + readonly kind: "migrations"; + readonly projectRef?: string; +} + +export type LegacyPgDeltaEndpoint = LegacyPgDeltaDatabaseEndpoint | LegacyPgDeltaMigrationsEndpoint; + +export interface LegacyPgDeltaSqlFile { + readonly name: string; + readonly sql: string; +} + +export interface LegacyPgDeltaExportManifest { + readonly redactSecrets: boolean; + readonly scope: "database" | "cluster"; + readonly profile?: string; + readonly baselineDigest?: string; + readonly defaultOwner?: string | null; + readonly files?: ReadonlyArray; +} + +export type LegacyPgDeltaTransactionMode = "transactional" | "none"; + +export interface LegacyPgDeltaRenderedFile { + readonly sequence: number; + /** Legacy semantic unit name. */ + readonly name: string; + /** Next renderer's exact filename suffix (`null`, `_1`, `_2`, ...). */ + readonly suffix?: string | null; + readonly sql: string; + readonly transactionMode: LegacyPgDeltaTransactionMode; + readonly actionCount?: number; +} + +interface LegacyPgDeltaExtensionIntentRemoval { + readonly extension: string; + readonly intentKind: string; + readonly key: string; +} + +/** Root object removals retained from a semantic pg-delta plan. */ +export interface LegacyPgDeltaRemovalSummary { + readonly extensions: ReadonlyArray; + readonly extensionIntents: ReadonlyArray; +} + +interface LegacyPgDeltaDebugArtifacts { + readonly sourceSnapshot?: string; + readonly desiredSnapshot?: string; + readonly plan?: string; + readonly stderr?: string; + /** Persisted debug directory, when the selected implementation writes one. */ + readonly directory?: string; +} + +export interface LegacyPgDeltaDiffResult { + readonly changes: boolean; + readonly sql: string; + readonly files: ReadonlyArray; + readonly removals?: LegacyPgDeltaRemovalSummary; + readonly debug?: LegacyPgDeltaDebugArtifacts; +} + +interface LegacyPgDeltaCommonInput { + readonly context: LegacyPgDeltaContext; + readonly schema: ReadonlyArray; + readonly formatOptions: string; + readonly projectRef?: string; + readonly debug: boolean; + /** Refuse coverage-gap diagnostics instead of continuing with those objects unmanaged. */ + readonly strictCoverage: boolean; +} + +export interface LegacyPgDeltaExplicitDiffInput extends LegacyPgDeltaCommonInput { + readonly source: LegacyPgDeltaEndpoint; + readonly desired: LegacyPgDeltaEndpoint; +} + +export interface LegacyPgDeltaDatabaseDiffInput extends LegacyPgDeltaCommonInput { + readonly target: LegacyPgDeltaDatabaseEndpoint; +} + +interface LegacyPgDeltaDeclarativeExportInput extends LegacyPgDeltaCommonInput { + readonly target: LegacyPgDeltaDatabaseEndpoint; + readonly noCache: boolean; +} + +export interface LegacyPgDeltaDeclarativeExportResult { + readonly files: ReadonlyArray; + readonly manifest?: LegacyPgDeltaExportManifest; +} + +export interface LegacyPgDeltaDeclarativePlanInput extends LegacyPgDeltaCommonInput { + readonly files: ReadonlyArray; + readonly manifest?: LegacyPgDeltaExportManifest; + readonly noCache: boolean; + readonly setupInputs: LegacySetupInputs; +} + +interface LegacyPgDeltaDeclarativePlanResult extends LegacyPgDeltaDiffResult { + /** Debug labels retained for the legacy apply/reset bundle. */ + readonly sourceRef: string; + readonly targetRef: string; +} + +export class LegacyPgDeltaEngineError extends Data.TaggedError("LegacyPgDeltaEngineError")<{ + readonly message: string; + readonly cause: unknown; + readonly suggestion?: string; +}> {} + +export interface LegacyPgDeltaEngineShape { + readonly implementation: LegacyPgDeltaImplementation; + readonly diffExplicit: ( + input: LegacyPgDeltaExplicitDiffInput, + ) => Effect.Effect; + readonly diffDatabase: ( + input: LegacyPgDeltaDatabaseDiffInput, + ) => Effect.Effect; + readonly exportDeclarativeSchema: ( + input: LegacyPgDeltaDeclarativeExportInput, + ) => Effect.Effect; + readonly planDeclarativeSchema: ( + input: LegacyPgDeltaDeclarativePlanInput, + ) => Effect.Effect; +} + +export class LegacyPgDeltaEngine extends Context.Service< + LegacyPgDeltaEngine, + LegacyPgDeltaEngineShape +>()("supabase/legacy/PgDeltaEngine") {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts new file mode 100644 index 0000000000..368a8cf20e --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts @@ -0,0 +1,135 @@ +import { Data, Effect, type FileSystem, type Path } from "effect"; + +import type { + LegacyPgDeltaExportManifest, + LegacyPgDeltaSqlFile, +} from "./legacy-pgdelta-engine.service.ts"; + +const EXPORT_MANIFEST_FILE = ".pgdelta-export.json"; + +class LegacyPgDeltaFilesError extends Data.TaggedError("LegacyPgDeltaFilesError")<{ + readonly message: string; +}> {} + +const filesError = (message: string) => new LegacyPgDeltaFilesError({ message }); + +function readManifestValue(doc: object, key: string): unknown { + return Reflect.get(doc, key); +} + +/** Reads a next-engine export manifest from an explicit declarative directory. */ +export const LegacyReadPgDeltaExportManifest = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + directory: string, +) { + const manifestPath = path.join(directory, EXPORT_MANIFEST_FILE); + const exists = yield* fs + .exists(manifestPath) + .pipe( + Effect.mapError((error) => filesError(`cannot inspect export manifest: ${error.message}`)), + ); + if (!exists) return undefined; + + const raw = yield* fs + .readFileString(manifestPath) + .pipe( + Effect.mapError((error) => + filesError(`cannot read export manifest ${manifestPath}: ${error.message}`), + ), + ); + const decoded = yield* Effect.try({ + try: (): unknown => JSON.parse(raw), + catch: (cause) => + filesError( + `malformed export manifest ${manifestPath}: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + }); + if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) { + return yield* Effect.fail(filesError(`malformed export manifest ${manifestPath}`)); + } + + const formatVersion = readManifestValue(decoded, "formatVersion"); + const redactSecrets = readManifestValue(decoded, "redactSecrets"); + const scope = readManifestValue(decoded, "scope"); + if ( + (formatVersion !== undefined && formatVersion !== 1) || + typeof redactSecrets !== "boolean" || + (scope !== "database" && scope !== "cluster") + ) { + return yield* Effect.fail( + filesError(`export manifest ${manifestPath} is missing required policy metadata`), + ); + } + + const profile = readManifestValue(decoded, "profile"); + const baselineDigest = readManifestValue(decoded, "baselineDigest"); + const defaultOwner = readManifestValue(decoded, "defaultOwner"); + const files = readManifestValue(decoded, "files"); + return { + redactSecrets, + scope, + ...(typeof profile === "string" ? { profile } : {}), + ...(typeof baselineDigest === "string" ? { baselineDigest } : {}), + ...(typeof defaultOwner === "string" || defaultOwner === null ? { defaultOwner } : {}), + ...(Array.isArray(files) && files.every((file) => typeof file === "string") ? { files } : {}), + } satisfies LegacyPgDeltaExportManifest; +}); + +/** Recursively loads path-safe `.sql` files in stable POSIX-relative order. */ +export const LegacyLoadPgDeltaSqlFiles = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + directory: string, +) { + const pending = [directory]; + const paths: Array<{ readonly full: string; readonly name: string }> = []; + + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + const entries = yield* fs + .readDirectory(current) + .pipe( + Effect.mapError((error) => + filesError(`failed to read declarative schema directory: ${error.message}`), + ), + ); + for (const entry of entries) { + const full = path.join(current, entry); + const stat = yield* fs + .stat(full) + .pipe( + Effect.mapError((error) => + filesError(`failed to inspect declarative schema file: ${error.message}`), + ), + ); + if (stat.type === "Directory") { + pending.push(full); + continue; + } + if (path.extname(entry).toLowerCase() !== ".sql") continue; + + const name = path.relative(directory, full).split("\\").join("/"); + const normalized = path.normalize(name); + if (normalized.startsWith("..") || path.isAbsolute(normalized)) { + return yield* Effect.fail(filesError(`unsafe declarative schema path: ${name}`)); + } + paths.push({ full, name }); + } + } + + paths.sort((left, right) => left.name.localeCompare(right.name)); + const files: Array = []; + for (const file of paths) { + const sql = yield* fs + .readFileString(file.full) + .pipe( + Effect.mapError((error) => + filesError(`failed to read declarative schema file: ${error.message}`), + ), + ); + files.push({ name: file.name, sql }); + } + return files; +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts index e02b5c720f..0546c042f0 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts @@ -5,6 +5,7 @@ import { legacyFormatMigrationTimestamp, legacyGetMigrationPath, } from "../../../shared/legacy-migration-file.ts"; +import type { LegacyPgDeltaTransactionMode } from "./legacy-pgdelta-engine.service.ts"; /** A migration file written by a diff/pull, paired with its history version. */ export interface LegacyWrittenMigration { @@ -58,16 +59,34 @@ export const legacyWritePgDeltaMigrations = ( readonly workdir: string; readonly baseMillis: number; readonly name: string; - readonly files: ReadonlyArray<{ readonly name: string; readonly sql: string }>; + readonly files: ReadonlyArray<{ + readonly name: string; + readonly suffix?: string | null; + readonly sql: string; + readonly transactionMode: LegacyPgDeltaTransactionMode; + }>; }, ): Effect.Effect, LegacyPgDeltaMigrationWriteError> => Effect.gen(function* () { const { workdir, name, files } = opts; + for (const file of files) { + if (file.transactionMode !== "transactional" && file.transactionMode !== "none") { + return yield* Effect.fail( + new LegacyPgDeltaMigrationWriteError({ + message: `unknown pg-delta transaction mode ${JSON.stringify(file.transactionMode)}`, + }), + ); + } + } const single = files.length === 1; const buildSet = (baseMillis: number): Array => files.map((file, i) => { const version = legacyFormatMigrationTimestamp(baseMillis + i * 1000); - const unitName = single ? name : `${name}_${file.name}`; + const unitName = single + ? name + : file.suffix !== undefined && file.suffix !== null + ? `${name}${file.suffix}` + : `${name}_${file.name}`; return { path: legacyGetMigrationPath(pathSvc, workdir, version, unitName), version }; }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts new file mode 100644 index 0000000000..ff1bd0f861 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts @@ -0,0 +1,689 @@ +import { Effect, Layer } from "effect"; +import type { Pool } from "pg"; +import { serializeSnapshot, encodeId } from "@supabase/pg-delta/core"; +import { + buildSchemaExport, + planSchemaFiles, + renderPlanFiles, + ShadowLoadError, +} from "@supabase/pg-delta/frontends"; +import { + type IntegrationProfile, + resolveProfile, + supabaseProfile, +} from "@supabase/pg-delta/integrations"; +import { plan, serializePlan } from "@supabase/pg-delta/plan"; +import type { Plan as PgDeltaPlan } from "@supabase/pg-delta/plan"; +import type { Policy } from "@supabase/pg-delta/policy"; +import { formatSqlStatements, type SqlFormatOptions } from "@supabase/pg-delta/sql-format"; + +import { + LegacyPgDeltaNextAdapter, + LegacyPgDeltaNextError, + type LegacyPgDeltaNextAdapterShape, + type LegacyPgDeltaNextDeclarativeExportInput, + type LegacyPgDeltaNextDeclarativeManifestInput, + type LegacyPgDeltaNextDeclarativePlanInput, + type LegacyPgDeltaNextDiagnostic, + type LegacyPgDeltaNextDiagnosticOrigin, + type LegacyPgDeltaNextDiffInput, + type LegacyPgDeltaNextExportManifest, + type LegacyPgDeltaNextRenderedFile, + type LegacyPgDeltaNextSnapshotCaptureInput, + type LegacyPgDeltaNextSqlFile, + type LegacyPgDeltaNextOperation, +} from "./legacy-pgdelta-next-adapter.service.ts"; +import type { LegacyPgDeltaRemovalSummary } from "./legacy-pgdelta-engine.service.ts"; + +interface LegacyPgDeltaNextLibraryDiagnostic { + readonly code: string; + readonly severity: "error" | "warning" | "info"; + readonly subject?: Subject; + readonly message: string; + readonly context?: Readonly>; +} + +interface LegacyPgDeltaNextLibraryExtractResult { + readonly factBase: FactBase; + readonly pgVersion: string; + readonly diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[]; +} + +interface LegacyPgDeltaNextResolvedProfile { + readonly id: string; + readonly planOptions: PlanOptions; + readonly extract: ( + pool: Pool, + options?: { readonly redactSecrets?: boolean; readonly statementTimeoutMs?: number }, + ) => Promise>; +} + +interface LegacyPgDeltaNextLibraryRenderedFile { + readonly suffix: string | null; + readonly contents: string; + readonly transactional: boolean; + readonly actionCount: number; +} + +interface LegacyPgDeltaNextLibraryRenderedResult { + readonly changes: boolean; + readonly files: readonly LegacyPgDeltaNextLibraryRenderedFile[]; +} + +interface LegacyPgDeltaNextLibrarySchemaExport { + readonly files: readonly LegacyPgDeltaNextSqlFile[]; + readonly diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[]; + readonly manifest: LegacyPgDeltaNextExportManifest; +} + +type LegacyPgDeltaNextLibraryExportOptions = ReturnType; + +interface LegacyPgDeltaNextLibrarySchemaPlan { + readonly plan: Plan; + readonly loadDiagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[]; + readonly targetDiagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[]; + readonly skipped: readonly { readonly file: string; readonly stmt: string }[]; +} + +export interface LegacyPgDeltaNextLibraries { + readonly resolveProfile: ( + pool: Pool, + options: { + readonly restrictToApplier?: boolean; + readonly redactSecrets?: boolean; + readonly skipBaseline?: boolean; + }, + schema?: readonly string[], + ) => Promise>; + readonly plan: ( + source: FactBase, + desired: FactBase, + options: PlanOptions & { readonly redactSecrets: boolean }, + ) => Plan; + readonly renderPlanFiles: ( + plan: Plan, + options: { readonly allowDrops: boolean }, + ) => LegacyPgDeltaNextLibraryRenderedResult; + readonly buildSchemaExport: ( + pool: Pool, + input: LegacyPgDeltaNextLibraryExportOptions, + ) => Promise>; + readonly planSchemaFiles: ( + targetPool: Pool, + shadowPool: Pool, + files: readonly LegacyPgDeltaNextSqlFile[], + input: LegacyPgDeltaNextDeclarativePlanInput, + ) => Promise>; + readonly serializeSnapshot: ( + factBase: FactBase, + metadata: { + readonly pgVersion: string; + readonly redactSecrets: boolean; + readonly profile: string; + }, + ) => string; + readonly serializePlan: (plan: Plan) => string; + readonly summarizeRemovals: (plan: Plan) => LegacyPgDeltaRemovalSummary; + readonly encodeSubject: (subject: Subject) => string; +} + +export function legacySummarizePgDeltaNextRemovals( + generatedPlan: Pick, +): LegacyPgDeltaRemovalSummary { + const extensions = new Set(); + const extensionIntents = new Map< + string, + LegacyPgDeltaRemovalSummary["extensionIntents"][number] + >(); + for (const delta of generatedPlan.deltas) { + if (delta.verb !== "remove" || delta.fact.parent !== undefined) continue; + const id = delta.fact.id; + if (id.kind === "extension") { + extensions.add(id.name); + continue; + } + if (id.kind !== "extensionIntent") continue; + const removal = { extension: id.ext, intentKind: id.intentKind, key: id.key }; + extensionIntents.set(`${id.ext}\u0000${id.intentKind}\u0000${id.key}`, removal); + } + return { + extensions: [...extensions].sort(), + extensionIntents: [...extensionIntents.values()].sort( + (left, right) => + left.extension.localeCompare(right.extension) || + left.intentKind.localeCompare(right.intentKind) || + left.key.localeCompare(right.key), + ), + }; +} + +function legacyPgDeltaNextMessage(operation: LegacyPgDeltaNextOperation, cause: unknown): string { + const detail = cause instanceof Error ? cause.message : String(cause); + const diagnostics = + cause instanceof ShadowLoadError ? cause.details.map((diagnostic) => diagnostic.message) : []; + const label = + operation === "declarativeExport" + ? "Declarative schema export" + : operation === "declarativePlan" + ? "Declarative schema planning" + : operation === "snapshotCapture" + ? "Snapshot capture" + : "Database diff"; + const renderedDiagnostics = diagnostics.map((diagnostic) => ` - ${diagnostic}`).join("\n"); + return `${label} failed: ${detail}${renderedDiagnostics === "" ? "" : `\n${renderedDiagnostics}`}`; +} + +function legacyTryPgDeltaNext( + operation: LegacyPgDeltaNextOperation, + run: () => Promise, +) { + return Effect.tryPromise({ + try: run, + catch: (cause) => + new LegacyPgDeltaNextError({ + operation, + message: legacyPgDeltaNextMessage(operation, cause), + cause, + }), + }); +} + +function legacyNormalizePgDeltaNextDiagnostics( + diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[], + origin: LegacyPgDeltaNextDiagnosticOrigin, + encodeSubject: (subject: Subject) => string, +): LegacyPgDeltaNextDiagnostic[] { + return diagnostics.map((diagnostic) => ({ + origin, + code: diagnostic.code, + severity: diagnostic.severity, + ...(diagnostic.subject !== undefined ? { subject: encodeSubject(diagnostic.subject) } : {}), + message: diagnostic.message, + ...(diagnostic.context !== undefined ? { context: diagnostic.context } : {}), + })); +} + +function legacyIsPgDeltaNextParameterAclDiagnostic( + diagnostic: LegacyPgDeltaNextLibraryDiagnostic, +): boolean { + return diagnostic.code === "unmodeled_kind" && diagnostic.context?.["kind"] === "parameter ACL"; +} + +/** + * The parameter-ACL catalog is cluster-wide, so a co-located declarative shadow + * observes Supabase platform grants too. Keep strict coverage for every ACL + * other than the exact platform bootstrap grant while removing the aggregate + * diagnostic when that bootstrap grant is the only observed parameter ACL. + */ +export function legacyFilterPgDeltaNextPlatformParameterAclDiagnostics( + diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[], + userOwnedParameterAcls: readonly string[], +): LegacyPgDeltaNextLibraryDiagnostic[] { + const names = [...new Set(userOwnedParameterAcls)].sort(); + const filtered: LegacyPgDeltaNextLibraryDiagnostic[] = []; + for (const diagnostic of diagnostics) { + if (!legacyIsPgDeltaNextParameterAclDiagnostic(diagnostic)) { + filtered.push(diagnostic); + continue; + } + if (names.length === 0) continue; + const samples = names.slice(0, 5); + const more = names.length > samples.length ? ", …" : ""; + filtered.push({ + ...diagnostic, + message: + `${names.length} unmodeled "parameter ACL" object${names.length === 1 ? "" : "s"} ` + + `not managed by this engine (e.g. ${samples.join(", ")}${more}) — ` + + "v1 detects but does not model this kind", + context: { kind: "parameter ACL", count: names.length, samples }, + }); + } + return filtered; +} + +interface LegacyPgDeltaNextParameterAclGrant { + readonly name: string; + readonly grantee: string; + readonly privilege: string; +} + +// Supabase's platform bootstrap grants these so privileged platform roles can +// manage the setting and the Realtime owner can replay routines whose proconfig +// contains `SET log_min_messages ...`. Parameter ACLs have cluster scope, so +// the grants are also visible from sibling shadow DBs. +const legacyPgDeltaNextPlatformParameterAcls = new Set([ + "log_min_messages\u0000supabase_admin\u0000ALTER SYSTEM", + "log_min_messages\u0000supabase_admin\u0000SET", + "log_min_messages\u0000supabase_realtime_admin\u0000SET", +]); + +function legacyPgDeltaNextParameterAclKey(grant: LegacyPgDeltaNextParameterAclGrant): string { + return `${grant.name}\u0000${grant.grantee}\u0000${grant.privilege}`; +} + +export function legacyPgDeltaNextUserOwnedParameterAcls( + grants: readonly LegacyPgDeltaNextParameterAclGrant[], +): string[] { + return [ + ...new Set( + grants + .filter( + (grant) => + !legacyPgDeltaNextPlatformParameterAcls.has(legacyPgDeltaNextParameterAclKey(grant)), + ) + .map((grant) => grant.name), + ), + ].sort(); +} + +async function legacyFilterPgDeltaNextPlatformDiagnostics( + pool: Pool, + diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[], +): Promise[]> { + if (!diagnostics.some(legacyIsPgDeltaNextParameterAclDiagnostic)) return [...diagnostics]; + const result = await pool.query( + `SELECT DISTINCT pa.parname AS name, + COALESCE(grantee.rolname, 'PUBLIC') AS grantee, + acl.privilege_type AS privilege + FROM pg_parameter_acl pa + CROSS JOIN LATERAL aclexplode(pa.paracl) acl + LEFT JOIN pg_roles grantee ON grantee.oid = acl.grantee + ORDER BY pa.parname, grantee, privilege`, + ); + return legacyFilterPgDeltaNextPlatformParameterAclDiagnostics( + diagnostics, + legacyPgDeltaNextUserOwnedParameterAcls(result.rows), + ); +} + +function legacyNormalizePgDeltaNextRenderedFiles( + files: readonly LegacyPgDeltaNextLibraryRenderedFile[], +): LegacyPgDeltaNextRenderedFile[] { + return files.map((file, index) => ({ + sequence: index + 1, + suffix: file.suffix, + sql: file.contents, + transactionMode: file.transactional ? "transactional" : "none", + actionCount: file.actionCount, + })); +} + +export function legacyPgDeltaNextProfile( + schema: readonly string[] | undefined, +): IntegrationProfile { + if (schema === undefined || schema.length === 0 || supabaseProfile.policy === undefined) { + return supabaseProfile; + } + const selected = [...schema]; + const policy: Policy = { + id: `supabase-cli-schemas:${selected.join(",")}`, + filter: [ + { + match: { all: [{ schema: "*" }, { not: { schema: selected } }] }, + action: "exclude", + }, + { + match: { all: [{ kind: "schema" }, { not: { name: selected } }] }, + action: "exclude", + }, + { + match: { + all: [{ target: { schema: "*" } }, { not: { target: { schema: selected } } }], + }, + action: "exclude", + }, + ], + extends: [supabaseProfile.policy], + }; + return { ...supabaseProfile, policy }; +} + +const legacyPgDeltaNextHumanFormatOptions: SqlFormatOptions = { + keywordCase: "lower", + maxWidth: 180, +}; + +function legacyPgDeltaNextFormatOptions(raw: string | undefined): SqlFormatOptions | undefined { + if (raw === undefined || raw.trim().length === 0) return legacyPgDeltaNextHumanFormatOptions; + const parsed: unknown = JSON.parse(raw); + if (parsed === null) return undefined; + if (typeof parsed !== "object" || Array.isArray(parsed)) { + return legacyPgDeltaNextHumanFormatOptions; + } + const value = (key: string): unknown => Reflect.get(parsed, key); + const keywordCase = value("keywordCase"); + const commaStyle = value("commaStyle"); + const indent = value("indent"); + const maxWidth = value("maxWidth"); + const alignColumns = value("alignColumns"); + const alignKeyValues = value("alignKeyValues"); + const preserveRoutineBodies = value("preserveRoutineBodies"); + const preserveViewBodies = value("preserveViewBodies"); + const preserveRuleBodies = value("preserveRuleBodies"); + return { + ...legacyPgDeltaNextHumanFormatOptions, + ...(keywordCase === "upper" || keywordCase === "lower" || keywordCase === "preserve" + ? { keywordCase } + : {}), + ...(commaStyle === "trailing" || commaStyle === "leading" ? { commaStyle } : {}), + ...(typeof indent === "number" ? { indent } : {}), + ...(typeof maxWidth === "number" ? { maxWidth } : {}), + ...(typeof alignColumns === "boolean" ? { alignColumns } : {}), + ...(typeof alignKeyValues === "boolean" ? { alignKeyValues } : {}), + ...(typeof preserveRoutineBodies === "boolean" ? { preserveRoutineBodies } : {}), + ...(typeof preserveViewBodies === "boolean" ? { preserveViewBodies } : {}), + ...(typeof preserveRuleBodies === "boolean" ? { preserveRuleBodies } : {}), + }; +} + +function legacyTerminatePgDeltaNextStatement(sql: string): string { + const trimmed = sql.trimEnd(); + return trimmed.endsWith(";") ? trimmed : `${trimmed};`; +} + +function legacyFormatPgDeltaNextRenderedFiles( + files: readonly LegacyPgDeltaNextLibraryRenderedFile[], + format: SqlFormatOptions | undefined, +): readonly LegacyPgDeltaNextLibraryRenderedFile[] { + if (format === undefined) return files; + return files.map((file) => ({ + ...file, + contents: `${formatSqlStatements([file.contents], format) + .map(legacyTerminatePgDeltaNextStatement) + .join("\n\n")}\n`, + })); +} + +function legacyPgDeltaNextExportOptions(input: LegacyPgDeltaNextDeclarativeExportInput) { + const format = legacyPgDeltaNextFormatOptions(input.formatOptions); + return { + profile: legacyPgDeltaNextProfile(input.schema), + ...(input.scope !== undefined ? { scope: input.scope } : {}), + ...(input.redactSecrets !== undefined ? { redactSecrets: input.redactSecrets } : {}), + ...(input.restrictToApplier !== undefined + ? { resolveOptions: { restrictToApplier: input.restrictToApplier } } + : {}), + ...(input.layout !== undefined ? { layout: input.layout } : {}), + ...(input.grouping !== undefined + ? { + grouping: { + ...(input.grouping.mode !== undefined ? { mode: input.grouping.mode } : {}), + ...(input.grouping.groupPatterns !== undefined + ? { groupPatterns: [...input.grouping.groupPatterns] } + : {}), + ...(input.grouping.flatSchemas !== undefined + ? { flatSchemas: [...input.grouping.flatSchemas] } + : {}), + ...(input.grouping.autoGroupPartitions !== undefined + ? { autoGroupPartitions: input.grouping.autoGroupPartitions } + : {}), + }, + } + : {}), + ...(input.defaultOwner !== undefined ? { defaultOwner: input.defaultOwner } : {}), + ...(format !== undefined ? { format } : {}), + ...(input.onWarning !== undefined ? { onWarning: input.onWarning } : {}), + }; +} + +function legacyPgDeltaNextManifest(manifest: LegacyPgDeltaNextDeclarativeManifestInput) { + return { + ...(manifest.redactSecrets !== undefined ? { redactSecrets: manifest.redactSecrets } : {}), + ...(manifest.profile !== undefined ? { profile: manifest.profile } : {}), + ...(manifest.scope !== undefined ? { scope: manifest.scope } : {}), + ...(manifest.baselineDigest !== undefined ? { baselineDigest: manifest.baselineDigest } : {}), + ...(manifest.defaultOwner !== undefined ? { defaultOwner: manifest.defaultOwner } : {}), + ...(manifest.files !== undefined ? { files: [...manifest.files] } : {}), + }; +} + +function legacyPgDeltaNextPlanOptions(input: LegacyPgDeltaNextDeclarativePlanInput) { + return { + profile: legacyPgDeltaNextProfile(input.schema), + ...(input.scope !== undefined ? { scope: input.scope } : {}), + ...(input.manifest !== undefined + ? { manifest: legacyPgDeltaNextManifest(input.manifest) } + : {}), + ...(input.redactSecrets !== undefined ? { redactSecrets: input.redactSecrets } : {}), + ...(input.skipClusterDdl !== undefined ? { skipClusterDdl: input.skipClusterDdl } : {}), + ...(input.isolatedShadow !== undefined ? { isolatedShadow: input.isolatedShadow } : {}), + ...(input.seedAssumedSchemas !== undefined + ? { seedAssumedSchemas: input.seedAssumedSchemas } + : {}), + ...(input.restrictToApplier !== undefined + ? { resolveOptions: { restrictToApplier: input.restrictToApplier } } + : {}), + ...(input.strictFunctionBodies !== undefined + ? { strictFunctionBodies: input.strictFunctionBodies } + : {}), + reorder: input.reorder ?? true, + ...(input.onWarning !== undefined ? { onWarning: input.onWarning } : {}), + }; +} + +function legacyMakePgDeltaNextAdapter( + libraries: LegacyPgDeltaNextLibraries, +): LegacyPgDeltaNextAdapterShape { + return { + diff: (input: LegacyPgDeltaNextDiffInput) => + legacyTryPgDeltaNext("diff", async () => { + const format = legacyPgDeltaNextFormatOptions(input.formatOptions); + const redactSecrets = input.redactSecrets ?? true; + const profile = await libraries.resolveProfile( + input.sourcePool, + { + redactSecrets, + ...(input.restrictToApplier !== undefined + ? { restrictToApplier: input.restrictToApplier } + : {}), + }, + input.schema, + ); + const [source, desired] = await Promise.all([ + profile.extract(input.sourcePool, { redactSecrets }), + profile.extract(input.desiredPool, { redactSecrets }), + ]); + const generatedPlan = libraries.plan(source.factBase, desired.factBase, { + ...profile.planOptions, + redactSecrets, + }); + const rendered = libraries.renderPlanFiles(generatedPlan, { + allowDrops: input.allowDrops, + }); + const renderedFiles = legacyFormatPgDeltaNextRenderedFiles(rendered.files, format); + const diagnostics = [ + ...legacyNormalizePgDeltaNextDiagnostics( + source.diagnostics, + "source", + libraries.encodeSubject, + ), + ...legacyNormalizePgDeltaNextDiagnostics( + desired.diagnostics, + "desired", + libraries.encodeSubject, + ), + ]; + return { + changes: rendered.changes, + sql: renderedFiles.map((file) => file.contents).join("\n\n"), + files: legacyNormalizePgDeltaNextRenderedFiles(renderedFiles), + diagnostics, + ...(input.debug + ? { + debug: { + sourceSnapshot: libraries.serializeSnapshot(source.factBase, { + pgVersion: source.pgVersion, + redactSecrets, + profile: profile.id, + }), + desiredSnapshot: libraries.serializeSnapshot(desired.factBase, { + pgVersion: desired.pgVersion, + redactSecrets, + profile: profile.id, + }), + plan: libraries.serializePlan(generatedPlan), + }, + } + : {}), + }; + }), + exportDeclarativeSchema: (input: LegacyPgDeltaNextDeclarativeExportInput) => + legacyTryPgDeltaNext("declarativeExport", async () => { + const result = await libraries.buildSchemaExport( + input.pool, + legacyPgDeltaNextExportOptions(input), + ); + return { + files: result.files.map((file) => ({ name: file.name, sql: file.sql })), + manifest: { + ...result.manifest, + files: result.files.map((file) => file.name).sort(), + }, + diagnostics: legacyNormalizePgDeltaNextDiagnostics( + result.diagnostics, + "export", + libraries.encodeSubject, + ), + }; + }), + planDeclarativeSchema: (input: LegacyPgDeltaNextDeclarativePlanInput) => + legacyTryPgDeltaNext("declarativePlan", async () => { + const format = legacyPgDeltaNextFormatOptions(input.formatOptions); + const planningInput = { ...input, reorder: input.reorder ?? true }; + const result = await libraries.planSchemaFiles( + input.targetPool, + input.shadowPool, + input.files, + planningInput, + ); + const rendered = libraries.renderPlanFiles(result.plan, { + allowDrops: input.allowDrops, + }); + const renderedFiles = legacyFormatPgDeltaNextRenderedFiles(rendered.files, format); + return { + changes: rendered.changes, + sql: renderedFiles.map((file) => file.contents).join("\n\n"), + files: legacyNormalizePgDeltaNextRenderedFiles(renderedFiles), + diagnostics: [ + ...legacyNormalizePgDeltaNextDiagnostics( + result.loadDiagnostics, + "declarativeLoad", + libraries.encodeSubject, + ), + ...legacyNormalizePgDeltaNextDiagnostics( + result.targetDiagnostics, + "declarativeTarget", + libraries.encodeSubject, + ), + ], + skipped: result.skipped.map((skipped) => ({ + file: skipped.file, + statement: skipped.stmt, + })), + removals: libraries.summarizeRemovals(result.plan), + ...(input.debug ? { debug: { plan: libraries.serializePlan(result.plan) } } : {}), + }; + }), + captureSnapshot: (input: LegacyPgDeltaNextSnapshotCaptureInput) => + legacyTryPgDeltaNext("snapshotCapture", async () => { + const redactSecrets = input.redactSecrets ?? true; + const profile = await libraries.resolveProfile(input.pool, { + redactSecrets, + skipBaseline: true, + }); + const result = await profile.extract(input.pool, { + redactSecrets, + ...(input.statementTimeoutMs !== undefined + ? { statementTimeoutMs: input.statementTimeoutMs } + : {}), + }); + return { + generation: "v2", + snapshot: libraries.serializeSnapshot(result.factBase, { + pgVersion: result.pgVersion, + redactSecrets, + profile: profile.id, + }), + pgVersion: result.pgVersion, + diagnostics: legacyNormalizePgDeltaNextDiagnostics( + result.diagnostics, + "snapshot", + libraries.encodeSubject, + ), + }; + }), + }; +} + +const legacyPgDeltaNextRealLibraries = { + resolveProfile: async ( + pool: Pool, + options: Parameters[2], + schema?: readonly string[], + ) => { + const resolved = await resolveProfile(pool, legacyPgDeltaNextProfile(schema), options); + return { + ...resolved, + extract: async ( + extractPool: Pool, + extractOptions?: Parameters[1], + ) => { + const result = await resolved.extract(extractPool, extractOptions); + return { + ...result, + diagnostics: await legacyFilterPgDeltaNextPlatformDiagnostics( + extractPool, + result.diagnostics, + ), + }; + }, + }; + }, + plan, + renderPlanFiles, + buildSchemaExport: async (pool: Pool, input: LegacyPgDeltaNextLibraryExportOptions) => { + const result = await buildSchemaExport(pool, input); + return { + ...result, + diagnostics: await legacyFilterPgDeltaNextPlatformDiagnostics(pool, result.diagnostics), + }; + }, + planSchemaFiles: async ( + targetPool: Pool, + shadowPool: Pool, + files: readonly LegacyPgDeltaNextSqlFile[], + input: LegacyPgDeltaNextDeclarativePlanInput, + ) => { + const result = await planSchemaFiles( + targetPool, + shadowPool, + files.map((file) => ({ name: file.name, sql: file.sql })), + legacyPgDeltaNextPlanOptions(input), + ); + const [loadDiagnostics, targetDiagnostics] = await Promise.all([ + legacyFilterPgDeltaNextPlatformDiagnostics(shadowPool, result.loadDiagnostics), + legacyFilterPgDeltaNextPlatformDiagnostics(targetPool, result.targetDiagnostics), + ]); + return { ...result, loadDiagnostics, targetDiagnostics }; + }, + serializeSnapshot, + serializePlan, + summarizeRemovals: legacySummarizePgDeltaNextRemovals, + encodeSubject: encodeId, +}; + +export function legacyPgDeltaNextAdapterLayerFromLibraries< + FactBase, + PlanOptions extends object, + Plan, + Subject, +>(libraries: LegacyPgDeltaNextLibraries) { + return Layer.succeed( + LegacyPgDeltaNextAdapter, + LegacyPgDeltaNextAdapter.of(legacyMakePgDeltaNextAdapter(libraries)), + ); +} + +export const legacyPgDeltaNextAdapterLayer = legacyPgDeltaNextAdapterLayerFromLibraries( + legacyPgDeltaNextRealLibraries, +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts new file mode 100644 index 0000000000..aba46a3f66 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts @@ -0,0 +1,197 @@ +import type { Pool } from "pg"; +import { Context, Data, type Effect } from "effect"; + +import type { + LegacyPgDeltaRemovalSummary, + LegacyPgDeltaTransactionMode, +} from "./legacy-pgdelta-engine.service.ts"; + +export type LegacyPgDeltaNextOperation = + | "diff" + | "declarativeExport" + | "declarativePlan" + | "snapshotCapture"; + +export type LegacyPgDeltaNextDiagnosticOrigin = + | "source" + | "desired" + | "export" + | "declarativeLoad" + | "declarativeTarget" + | "snapshot"; + +export interface LegacyPgDeltaNextDiagnostic { + readonly origin: LegacyPgDeltaNextDiagnosticOrigin; + readonly code: string; + readonly severity: "error" | "warning" | "info"; + readonly subject?: string; + readonly message: string; + readonly context?: Readonly>; +} + +export interface LegacyPgDeltaNextRenderedFile { + readonly sequence: number; + readonly suffix: string | null; + readonly sql: string; + readonly transactionMode: LegacyPgDeltaTransactionMode; + readonly actionCount: number; +} + +export interface LegacyPgDeltaNextSqlFile { + readonly name: string; + readonly sql: string; +} + +interface LegacyPgDeltaNextDebugArtifacts { + readonly sourceSnapshot?: string; + readonly desiredSnapshot?: string; + readonly plan?: string; +} + +export interface LegacyPgDeltaNextDiffInput { + /** The live database the rendered migration will be applied to. */ + readonly sourcePool: Pool; + /** The live database whose state is desired. */ + readonly desiredPool: Pool; + readonly allowDrops: boolean; + readonly debug: boolean; + readonly redactSecrets?: boolean; + readonly restrictToApplier?: boolean; + readonly schema?: readonly string[]; + readonly formatOptions?: string; +} + +interface LegacyPgDeltaNextDiffResult { + readonly changes: boolean; + readonly sql: string; + readonly files: readonly LegacyPgDeltaNextRenderedFile[]; + readonly diagnostics: readonly LegacyPgDeltaNextDiagnostic[]; + readonly debug?: LegacyPgDeltaNextDebugArtifacts; +} + +type LegacyPgDeltaNextManagementScope = "database" | "cluster"; +type LegacyPgDeltaNextExportLayout = "by-object" | "ordered" | "grouped"; + +interface LegacyPgDeltaNextExportGroupingPattern { + readonly pattern: string; + readonly name: string; +} + +interface LegacyPgDeltaNextExportGrouping { + readonly mode?: "single-file" | "subdirectory"; + readonly groupPatterns?: readonly LegacyPgDeltaNextExportGroupingPattern[]; + readonly flatSchemas?: readonly string[]; + readonly autoGroupPartitions?: boolean; +} + +export interface LegacyPgDeltaNextDeclarativeExportInput { + readonly pool: Pool; + readonly scope?: LegacyPgDeltaNextManagementScope; + readonly redactSecrets?: boolean; + readonly restrictToApplier?: boolean; + readonly layout?: LegacyPgDeltaNextExportLayout; + readonly grouping?: LegacyPgDeltaNextExportGrouping; + readonly defaultOwner?: string | null; + readonly onWarning?: (message: string) => void; + readonly schema?: readonly string[]; + readonly formatOptions?: string; +} + +export interface LegacyPgDeltaNextExportManifest { + readonly redactSecrets: boolean; + readonly scope: LegacyPgDeltaNextManagementScope; + readonly profile?: string; + readonly baselineDigest?: string; + readonly defaultOwner?: string | null; + readonly files?: readonly string[]; +} + +interface LegacyPgDeltaNextDeclarativeExportResult { + readonly files: readonly LegacyPgDeltaNextSqlFile[]; + readonly manifest: LegacyPgDeltaNextExportManifest; + readonly diagnostics: readonly LegacyPgDeltaNextDiagnostic[]; +} + +export interface LegacyPgDeltaNextDeclarativeManifestInput { + readonly redactSecrets?: boolean; + readonly profile?: string; + readonly scope?: LegacyPgDeltaNextManagementScope; + readonly baselineDigest?: string; + readonly defaultOwner?: string | null; + readonly files?: readonly string[]; +} + +export interface LegacyPgDeltaNextDeclarativePlanInput { + readonly targetPool: Pool; + readonly shadowPool: Pool; + readonly files: readonly LegacyPgDeltaNextSqlFile[]; + readonly allowDrops: boolean; + readonly debug: boolean; + readonly scope?: LegacyPgDeltaNextManagementScope; + readonly manifest?: LegacyPgDeltaNextDeclarativeManifestInput; + readonly redactSecrets?: boolean; + readonly skipClusterDdl?: boolean; + readonly isolatedShadow?: boolean; + readonly seedAssumedSchemas?: boolean; + readonly restrictToApplier?: boolean; + readonly strictFunctionBodies?: boolean; + readonly formatOptions?: string; + /** Defaults to true, preserving pg-topo statement-level reorder support. */ + readonly reorder?: boolean; + readonly onWarning?: (message: string) => void; + readonly schema?: readonly string[]; +} + +interface LegacyPgDeltaNextSkippedStatement { + readonly file: string; + readonly statement: string; +} + +interface LegacyPgDeltaNextDeclarativePlanResult { + readonly changes: boolean; + readonly sql: string; + readonly files: readonly LegacyPgDeltaNextRenderedFile[]; + readonly diagnostics: readonly LegacyPgDeltaNextDiagnostic[]; + readonly skipped: readonly LegacyPgDeltaNextSkippedStatement[]; + readonly removals: LegacyPgDeltaRemovalSummary; + readonly debug?: LegacyPgDeltaNextDebugArtifacts; +} + +export interface LegacyPgDeltaNextSnapshotCaptureInput { + readonly pool: Pool; + readonly redactSecrets?: boolean; + readonly statementTimeoutMs?: number; +} + +interface LegacyPgDeltaNextSnapshotCaptureResult { + readonly generation: "v2"; + readonly snapshot: string; + readonly pgVersion: string; + readonly diagnostics: readonly LegacyPgDeltaNextDiagnostic[]; +} + +export class LegacyPgDeltaNextError extends Data.TaggedError("LegacyPgDeltaNextError")<{ + readonly operation: LegacyPgDeltaNextOperation; + readonly message: string; + readonly cause: unknown; +}> {} + +export interface LegacyPgDeltaNextAdapterShape { + readonly diff: ( + input: LegacyPgDeltaNextDiffInput, + ) => Effect.Effect; + readonly exportDeclarativeSchema: ( + input: LegacyPgDeltaNextDeclarativeExportInput, + ) => Effect.Effect; + readonly planDeclarativeSchema: ( + input: LegacyPgDeltaNextDeclarativePlanInput, + ) => Effect.Effect; + readonly captureSnapshot: ( + input: LegacyPgDeltaNextSnapshotCaptureInput, + ) => Effect.Effect; +} + +export class LegacyPgDeltaNextAdapter extends Context.Service< + LegacyPgDeltaNextAdapter, + LegacyPgDeltaNextAdapterShape +>()("supabase/legacy/PgDeltaNextAdapter") {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts new file mode 100644 index 0000000000..98fdaedd9e --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts @@ -0,0 +1,684 @@ +import { it } from "@effect/vitest"; +import { ShadowLoadError } from "@supabase/pg-delta/frontends"; +import { Effect } from "effect"; +import { Pool } from "pg"; +import { describe, expect } from "vitest"; + +import { + legacyPgDeltaNextAdapterLayer, + legacyPgDeltaNextAdapterLayerFromLibraries, + legacyFilterPgDeltaNextPlatformParameterAclDiagnostics, + legacyPgDeltaNextProfile, + legacyPgDeltaNextUserOwnedParameterAcls, + legacySummarizePgDeltaNextRemovals, + type LegacyPgDeltaNextLibraries, +} from "./legacy-pgdelta-next-adapter.layer.ts"; +import { + LegacyPgDeltaNextAdapter, + LegacyPgDeltaNextError, +} from "./legacy-pgdelta-next-adapter.service.ts"; + +interface FakeFactBase { + readonly id: string; +} + +interface FakePlanOptions { + readonly managedView: string; +} + +interface FakePlan { + readonly source: string; + readonly desired: string; +} + +interface FakeSubject { + readonly id: string; +} + +function fakeDiagnostic(code: string, subject: string) { + return { + code, + severity: "warning" as const, + subject: { id: subject }, + message: `${code} message`, + context: { detail: code }, + }; +} + +function setupLibraries(sourcePool: Pool, desiredPool: Pool) { + const state = { + resolveCalls: [] as Array<{ + pool: Pool; + options: { + restrictToApplier?: boolean; + redactSecrets?: boolean; + skipBaseline?: boolean; + }; + schema?: readonly string[]; + }>, + extractCalls: [] as Array<{ pool: Pool; options: object | undefined }>, + planCalls: [] as Array<{ + source: FakeFactBase; + desired: FakeFactBase; + options: FakePlanOptions & { redactSecrets: boolean }; + }>, + renderOptions: [] as Array<{ allowDrops: boolean }>, + exportInputs: [] as object[], + declarativeInputs: [] as object[], + snapshotMetadata: [] as object[], + serializedPlans: [] as FakePlan[], + renderChanges: true, + }; + + const extract = async ( + pool: Pool, + options?: { redactSecrets?: boolean; statementTimeoutMs?: number }, + ) => { + state.extractCalls.push({ pool, options }); + const source = pool === sourcePool; + if (!source && pool !== desiredPool) { + throw new Error("unexpected pool passed to fake extractor"); + } + return { + factBase: { id: source ? "source-facts" : "desired-facts" }, + pgVersion: source ? "15.9" : "17.6", + diagnostics: [ + fakeDiagnostic(source ? "source-warning" : "desired-warning", source ? "s" : "d"), + ], + }; + }; + + const libraries: LegacyPgDeltaNextLibraries< + FakeFactBase, + FakePlanOptions, + FakePlan, + FakeSubject + > = { + resolveProfile: async (pool, options, schema) => { + state.resolveCalls.push({ pool, options, ...(schema !== undefined ? { schema } : {}) }); + return { + id: "supabase", + planOptions: { managedView: "shared-profile-options" }, + extract, + }; + }, + plan: (source, desired, options) => { + state.planCalls.push({ source, desired, options }); + return { source: source.id, desired: desired.id }; + }, + renderPlanFiles: (_generatedPlan, options) => { + state.renderOptions.push(options); + if (!state.renderChanges) return { changes: false, files: [] }; + return { + changes: true, + files: [ + { + suffix: "_1", + contents: "CREATE TABLE public.widgets (id integer, display_name text);\n", + transactional: true, + actionCount: 2, + }, + { + suffix: "_2", + contents: + "-- pg-delta: transaction=false\nSET check_function_bodies = off;\n\nGRANT SELECT ON TABLE public.widgets TO anon;\n\nRESET ALL;\n", + transactional: false, + actionCount: 1, + }, + ], + }; + }, + buildSchemaExport: async (_pool, input) => { + state.exportInputs.push(input); + return { + files: [{ name: "schemas/public/tables/items.sql", sql: "create table items();" }], + diagnostics: [fakeDiagnostic("export-warning", "export")], + manifest: { + redactSecrets: true, + scope: "database", + profile: "supabase", + defaultOwner: "postgres", + }, + }; + }, + planSchemaFiles: async (_targetPool, _shadowPool, _files, input) => { + state.declarativeInputs.push(input); + return { + plan: { source: "target-facts", desired: "loaded-files" }, + loadDiagnostics: [fakeDiagnostic("load-warning", "load")], + targetDiagnostics: [fakeDiagnostic("target-warning", "target")], + skipped: [{ file: "roles.sql", stmt: "create role ignored" }], + }; + }, + serializeSnapshot: (factBase, metadata) => { + state.snapshotMetadata.push(metadata); + return JSON.stringify({ factBase: factBase.id, metadata }); + }, + serializePlan: (generatedPlan) => { + state.serializedPlans.push(generatedPlan); + return JSON.stringify(generatedPlan); + }, + summarizeRemovals: () => ({ + extensions: ["pgcrypto"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, + ], + }), + encodeSubject: (subject) => `subject:${subject.id}`, + }; + + return { + state, + layer: legacyPgDeltaNextAdapterLayerFromLibraries(libraries), + }; +} + +describe("LegacyPgDeltaNextAdapter", () => { + it("summarizes only root extension and extension-intent removals", () => { + expect( + legacySummarizePgDeltaNextRemovals({ + deltas: [ + { verb: "remove", fact: { id: { kind: "extension", name: "uuid-ossp" }, payload: {} } }, + { verb: "remove", fact: { id: { kind: "extension", name: "pgcrypto" }, payload: {} } }, + { + verb: "remove", + fact: { + id: { kind: "extension", name: "nested-extension" }, + parent: { kind: "schema", name: "extensions" }, + payload: {}, + }, + }, + { + verb: "remove", + fact: { + id: { + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "job", + key: "refresh download metrics", + }, + payload: {}, + }, + }, + { + verb: "remove", + fact: { + id: { kind: "comment", target: { kind: "extension", name: "pgcrypto" } }, + payload: {}, + }, + }, + { + verb: "unlink", + edge: { + from: { kind: "extension", name: "pgcrypto" }, + to: { kind: "schema", name: "extensions" }, + kind: "depends", + }, + }, + ], + }), + ).toEqual({ + extensions: ["pgcrypto", "uuid-ossp"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, + ], + }); + }); + + it("filters platform parameter ACL coverage without hiding user-owned ACLs", () => { + const diagnostics = [ + { + origin: "declarativeLoad" as const, + code: "unmodeled_kind", + severity: "warning" as const, + message: "2 unmodeled parameter ACLs", + context: { + kind: "parameter ACL", + count: 2, + samples: ["log_min_messages", "work_mem"], + }, + }, + { + origin: "declarativeLoad" as const, + code: "unsupported_extension", + severity: "warning" as const, + message: "extension is externally managed", + }, + ]; + + expect(legacyFilterPgDeltaNextPlatformParameterAclDiagnostics(diagnostics, [])).toEqual([ + diagnostics[1], + ]); + expect( + legacyFilterPgDeltaNextPlatformParameterAclDiagnostics(diagnostics, ["work_mem"]), + ).toEqual([ + { + ...diagnostics[0], + message: + '1 unmodeled "parameter ACL" object not managed by this engine (e.g. work_mem) — v1 detects but does not model this kind', + context: { kind: "parameter ACL", count: 1, samples: ["work_mem"] }, + }, + diagnostics[1], + ]); + }); + + it("recognizes only the exact Supabase platform parameter grant tuples", () => { + expect( + legacyPgDeltaNextUserOwnedParameterAcls([ + { name: "log_min_messages", grantee: "supabase_admin", privilege: "SET" }, + { name: "log_min_messages", grantee: "app_user", privilege: "SET" }, + { name: "work_mem", grantee: "supabase_realtime_admin", privilege: "SET" }, + { name: "work_mem", grantee: "app_user", privilege: "SET" }, + ]), + ).toEqual(["log_min_messages", "work_mem"]); + expect( + legacyPgDeltaNextUserOwnedParameterAcls([ + { name: "log_min_messages", grantee: "supabase_admin", privilege: "ALTER SYSTEM" }, + { name: "log_min_messages", grantee: "supabase_admin", privilege: "SET" }, + { name: "log_min_messages", grantee: "supabase_realtime_admin", privilege: "SET" }, + ]), + ).toEqual([]); + expect( + legacyPgDeltaNextUserOwnedParameterAcls([ + { name: "log_min_messages", grantee: "supabase_realtime_admin", privilege: "ALTER SYSTEM" }, + ]), + ).toEqual(["log_min_messages"]); + }); + + it("composes schema exclusions ahead of the Supabase managed-view policy", () => { + const profile = legacyPgDeltaNextProfile(["public", "tenant"]); + expect(profile.id).toBe("supabase"); + expect(profile.policy?.filter).toEqual([ + { + match: { all: [{ schema: "*" }, { not: { schema: ["public", "tenant"] } }] }, + action: "exclude", + }, + { + match: { + all: [{ kind: "schema" }, { not: { name: ["public", "tenant"] } }], + }, + action: "exclude", + }, + { + match: { + all: [{ target: { schema: "*" } }, { not: { target: { schema: ["public", "tenant"] } } }], + }, + action: "exclude", + }, + ]); + expect(profile.policy?.extends).toHaveLength(1); + }); + + it.effect("constructs the real adapter from supported public pg-delta subpaths", () => + Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + expect(adapter.diff).toBeTypeOf("function"); + expect(adapter.exportDeclarativeSchema).toBeTypeOf("function"); + expect(adapter.planDeclarativeSchema).toBeTypeOf("function"); + expect(adapter.captureSnapshot).toBeTypeOf("function"); + }).pipe(Effect.provide(legacyPgDeltaNextAdapterLayer)), + ); + + it.effect( + "resolves one shared profile for a pool-to-pool diff and emits structured debug data", + () => { + const sourcePool = new Pool(); + const desiredPool = new Pool(); + const { layer, state } = setupLibraries(sourcePool, desiredPool); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const result = yield* adapter.diff({ + sourcePool, + desiredPool, + allowDrops: true, + debug: true, + redactSecrets: false, + restrictToApplier: true, + schema: ["public"], + formatOptions: '{"keywordCase":"upper","indent":4}', + }); + + expect(state.resolveCalls).toEqual([ + { + pool: sourcePool, + options: { redactSecrets: false, restrictToApplier: true }, + schema: ["public"], + }, + ]); + expect(state.extractCalls).toEqual([ + { pool: sourcePool, options: { redactSecrets: false } }, + { pool: desiredPool, options: { redactSecrets: false } }, + ]); + expect(state.planCalls).toEqual([ + { + source: { id: "source-facts" }, + desired: { id: "desired-facts" }, + options: { redactSecrets: false, managedView: "shared-profile-options" }, + }, + ]); + expect(state.renderOptions).toEqual([{ allowDrops: true }]); + expect(result.files).toEqual([ + { + sequence: 1, + suffix: "_1", + sql: "CREATE TABLE public.widgets (\n id integer,\n display_name text\n);\n", + transactionMode: "transactional", + actionCount: 2, + }, + { + sequence: 2, + suffix: "_2", + sql: "-- pg-delta: transaction=false\nSET check_function_bodies = off;\n\nGRANT SELECT ON TABLE public.widgets TO anon;\n\nRESET ALL;\n", + transactionMode: "none", + actionCount: 1, + }, + ]); + expect(result.sql).toBe( + "CREATE TABLE public.widgets (\n id integer,\n display_name text\n);\n\n\n-- pg-delta: transaction=false\nSET check_function_bodies = off;\n\nGRANT SELECT ON TABLE public.widgets TO anon;\n\nRESET ALL;\n", + ); + expect(result.diagnostics).toEqual([ + { + origin: "source", + code: "source-warning", + severity: "warning", + subject: "subject:s", + message: "source-warning message", + context: { detail: "source-warning" }, + }, + { + origin: "desired", + code: "desired-warning", + severity: "warning", + subject: "subject:d", + message: "desired-warning message", + context: { detail: "desired-warning" }, + }, + ]); + expect(result.debug).toEqual({ + sourceSnapshot: expect.stringContaining("source-facts"), + desiredSnapshot: expect.stringContaining("desired-facts"), + plan: JSON.stringify({ source: "source-facts", desired: "desired-facts" }), + }); + expect(state.snapshotMetadata).toEqual([ + { pgVersion: "15.9", redactSecrets: false, profile: "supabase" }, + { pgVersion: "17.6", redactSecrets: false, profile: "supabase" }, + ]); + expect(sourcePool.ending).toBe(false); + expect(sourcePool.ended).toBe(false); + expect(desiredPool.ending).toBe(false); + expect(desiredPool.ended).toBe(false); + yield* Effect.promise(() => Promise.all([sourcePool.end(), desiredPool.end()])); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("preserves a no-change result without creating debug artifacts", () => { + const sourcePool = new Pool(); + const desiredPool = new Pool(); + const { layer, state } = setupLibraries(sourcePool, desiredPool); + state.renderChanges = false; + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const result = yield* adapter.diff({ + sourcePool, + desiredPool, + allowDrops: false, + debug: false, + }); + expect(result.changes).toBe(false); + expect(result.sql).toBe(""); + expect(result.files).toEqual([]); + expect(result.debug).toBeUndefined(); + expect(state.snapshotMetadata).toEqual([]); + expect(state.renderOptions).toEqual([{ allowDrops: false }]); + yield* Effect.promise(() => Promise.all([sourcePool.end(), desiredPool.end()])); + }).pipe(Effect.provide(layer)); + }); + + it.effect("formats rendered migration files with the human-readable defaults", () => { + const sourcePool = new Pool(); + const desiredPool = new Pool(); + const { layer } = setupLibraries(sourcePool, desiredPool); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const result = yield* adapter.diff({ + sourcePool, + desiredPool, + allowDrops: false, + debug: false, + }); + expect(result.files[0]?.sql).toBe( + "create table public.widgets (\n id integer,\n display_name text\n);\n", + ); + expect(result.files[1]?.sql).toBe( + "-- pg-delta: transaction=false\nset check_function_bodies = off;\n\ngrant select on table public.widgets to anon;\n\nreset all;\n", + ); + yield* Effect.promise(() => Promise.all([sourcePool.end(), desiredPool.end()])); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "normalizes declarative export and planning results with reorder enabled by default", + () => { + const targetPool = new Pool(); + const shadowPool = new Pool(); + const { layer, state } = setupLibraries(targetPool, shadowPool); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const exported = yield* adapter.exportDeclarativeSchema({ + pool: targetPool, + layout: "grouped", + restrictToApplier: true, + formatOptions: + '{"keywordCase":"lower","commaStyle":"leading","indent":4,"maxWidth":100,"alignColumns":true,"alignKeyValues":false,"preserveRoutineBodies":true,"preserveViewBodies":false,"preserveRuleBodies":true,"ignored":"value"}', + }); + expect(exported.files).toEqual([ + { name: "schemas/public/tables/items.sql", sql: "create table items();" }, + ]); + expect(exported.manifest).toEqual({ + redactSecrets: true, + scope: "database", + profile: "supabase", + defaultOwner: "postgres", + files: ["schemas/public/tables/items.sql"], + }); + expect(exported.diagnostics[0]).toMatchObject({ + origin: "export", + subject: "subject:export", + }); + expect(state.exportInputs).toHaveLength(1); + expect(state.exportInputs[0]).toMatchObject({ + layout: "grouped", + resolveOptions: { restrictToApplier: true }, + format: { + keywordCase: "lower", + commaStyle: "leading", + indent: 4, + maxWidth: 100, + alignColumns: true, + alignKeyValues: false, + preserveRoutineBodies: true, + preserveViewBodies: false, + preserveRuleBodies: true, + }, + }); + expect(state.exportInputs[0]).not.toHaveProperty("formatOptions"); + + yield* adapter.exportDeclarativeSchema({ + pool: targetPool, + layout: "grouped", + }); + expect(state.exportInputs[1]).toMatchObject({ + format: { keywordCase: "lower", maxWidth: 180 }, + }); + + const planned = yield* adapter.planDeclarativeSchema({ + targetPool, + shadowPool, + files: exported.files, + allowDrops: true, + debug: true, + isolatedShadow: true, + seedAssumedSchemas: true, + formatOptions: "null", + }); + expect(state.declarativeInputs).toHaveLength(1); + expect(state.declarativeInputs[0]).toMatchObject({ + reorder: true, + seedAssumedSchemas: true, + }); + expect(planned.diagnostics.map((diagnostic) => diagnostic.origin)).toEqual([ + "declarativeLoad", + "declarativeTarget", + ]); + expect(planned.skipped).toEqual([{ file: "roles.sql", statement: "create role ignored" }]); + expect(planned.removals).toEqual({ + extensions: ["pgcrypto"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, + ], + }); + expect(planned.debug).toEqual({ + plan: JSON.stringify({ source: "target-facts", desired: "loaded-files" }), + }); + expect(planned.files.map((file) => file.sql)).toEqual([ + "CREATE TABLE public.widgets (id integer, display_name text);\n", + "-- pg-delta: transaction=false\nSET check_function_bodies = off;\n\nGRANT SELECT ON TABLE public.widgets TO anon;\n\nRESET ALL;\n", + ]); + expect(state.renderOptions).toEqual([{ allowDrops: true }]); + yield* Effect.promise(() => Promise.all([targetPool.end(), shadowPool.end()])); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("captures a v2 snapshot with a single baseline-free profile resolution", () => { + const pool = new Pool(); + const unusedDesiredPool = new Pool(); + const { layer, state } = setupLibraries(pool, unusedDesiredPool); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const result = yield* adapter.captureSnapshot({ + pool, + statementTimeoutMs: 4_000, + }); + expect(result.generation).toBe("v2"); + expect(result.pgVersion).toBe("15.9"); + expect(result.snapshot).toContain("source-facts"); + expect(state.resolveCalls).toEqual([ + { + pool, + options: { redactSecrets: true, skipBaseline: true }, + }, + ]); + expect(state.extractCalls).toEqual([ + { + pool, + options: { redactSecrets: true, statementTimeoutMs: 4_000 }, + }, + ]); + yield* Effect.promise(() => Promise.all([pool.end(), unusedDesiredPool.end()])); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps library rejections to an actionable typed error", () => { + const sourcePool = new Pool(); + const desiredPool = new Pool(); + const cause = new Error("connection refused for desired database"); + const failingLayer = legacyPgDeltaNextAdapterLayerFromLibraries({ + resolveProfile: async () => { + throw cause; + }, + plan: () => ({ source: "unused", desired: "unused" }), + renderPlanFiles: () => ({ changes: false, files: [] }), + buildSchemaExport: async () => ({ + files: [], + diagnostics: [], + manifest: { redactSecrets: true, scope: "database" }, + }), + planSchemaFiles: async () => ({ + plan: { source: "unused", desired: "unused" }, + loadDiagnostics: [], + targetDiagnostics: [], + skipped: [], + }), + serializeSnapshot: () => "unused", + serializePlan: () => "unused", + summarizeRemovals: () => ({ extensions: [], extensionIntents: [] }), + encodeSubject: (subject: string) => subject, + }); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const error = yield* adapter + .diff({ sourcePool, desiredPool, allowDrops: false, debug: false }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(LegacyPgDeltaNextError); + expect(error.operation).toBe("diff"); + expect(error.message).toBe("Database diff failed: connection refused for desired database"); + expect(error.cause).toBe(cause); + yield* Effect.promise(() => Promise.all([sourcePool.end(), desiredPool.end()])); + }).pipe(Effect.provide(failingLayer)); + }); + + it.effect("preserves shadow-load diagnostics in the actionable error", () => { + const targetPool = new Pool(); + const shadowPool = new Pool(); + const cause = new ShadowLoadError("2 files cannot apply", [ + { + code: "stuck_statement", + severity: "error", + message: 'extensions/pg_cron.sql: extension "pg_cron" already exists', + }, + { + code: "stuck_statement", + severity: "error", + message: 'extensions/pg_net.sql: extension "pg_net" already exists', + }, + ]); + const failingLayer = legacyPgDeltaNextAdapterLayerFromLibraries({ + resolveProfile: async () => { + throw new Error("unused"); + }, + plan: () => ({ source: "unused", desired: "unused" }), + renderPlanFiles: () => ({ changes: false, files: [] }), + buildSchemaExport: async () => ({ + files: [], + diagnostics: [], + manifest: { redactSecrets: true, scope: "database" }, + }), + planSchemaFiles: async () => { + throw cause; + }, + serializeSnapshot: () => "unused", + serializePlan: () => "unused", + summarizeRemovals: () => ({ extensions: [], extensionIntents: [] }), + encodeSubject: (subject: string) => subject, + }); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const error = yield* adapter + .planDeclarativeSchema({ + targetPool, + shadowPool, + files: [], + allowDrops: false, + debug: false, + isolatedShadow: true, + seedAssumedSchemas: false, + }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(LegacyPgDeltaNextError); + expect(error.message).toBe( + 'Declarative schema planning failed: 2 files cannot apply\n - extensions/pg_cron.sql: extension "pg_cron" already exists\n - extensions/pg_net.sql: extension "pg_net" already exists', + ); + expect(error.cause).toBe(cause); + yield* Effect.promise(() => Promise.all([targetPool.end(), shadowPool.end()])); + }).pipe(Effect.provide(failingLayer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.ts new file mode 100644 index 0000000000..ebff9b0a4a --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.ts @@ -0,0 +1,81 @@ +import { Effect, type FileSystem, type Path } from "effect"; + +import { legacyPgDeltaTempPath } from "../../../shared/legacy-pgdelta.cache.ts"; +import type { + LegacyPgDeltaNextDiagnostic, + LegacyPgDeltaNextOperation, +} from "./legacy-pgdelta-next-adapter.service.ts"; + +export interface LegacyPgDeltaNextDebugArtifacts { + readonly sourceSnapshot?: string; + readonly desiredSnapshot?: string; + readonly plan?: string; + readonly diagnostics?: ReadonlyArray; +} + +/** Explicit cache/artifact generation for the bundled pg-delta implementation. */ +export function legacyPgDeltaNextTempPath(path: Path.Path, workdir: string): string { + return path.join(legacyPgDeltaTempPath(path, workdir), "v2"); +} + +/** Millisecond-resolution id so multiple operations in one command do not collide. */ +export function legacyFormatPgDeltaNextDebugId( + millis: number, + operation: LegacyPgDeltaNextOperation, +): string { + const digits = new Date(millis).toISOString().replace(/\D/gu, "").slice(0, 17); + return `${digits.slice(0, 8)}-${digits.slice(8, 14)}-${digits.slice(14)}-${operation}`; +} + +interface LegacyPgDeltaNextArtifactMetadata { + readonly version: 1; + readonly generation: "v2"; + readonly implementation: "next"; + readonly operation: LegacyPgDeltaNextOperation; + readonly cacheReusable: false; + readonly files: ReadonlyArray; +} + +/** + * Writes bundled-engine debug data below the v2 generation. These files are + * diagnostics only: they are never considered catalog-cache inputs. + */ +export const legacySavePgDeltaNextDebugArtifacts = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + id: string, + operation: LegacyPgDeltaNextOperation, + artifacts: LegacyPgDeltaNextDebugArtifacts, +) { + const debugDir = path.join(legacyPgDeltaNextTempPath(path, workdir), "debug", id); + yield* fs.makeDirectory(debugDir, { recursive: true }); + + const files: Array = []; + const write = Effect.fnUntraced(function* (name: string, contents: string | undefined) { + if (contents === undefined || contents.length === 0) return; + yield* fs.writeFileString(path.join(debugDir, name), contents); + files.push(name); + }); + + yield* write("source-snapshot.json", artifacts.sourceSnapshot); + yield* write("desired-snapshot.json", artifacts.desiredSnapshot); + yield* write("plan.json", artifacts.plan); + if (artifacts.diagnostics !== undefined) { + yield* write("diagnostics.json", `${JSON.stringify(artifacts.diagnostics, null, 2)}\n`); + } + + const metadata: LegacyPgDeltaNextArtifactMetadata = { + version: 1, + generation: "v2", + implementation: "next", + operation, + cacheReusable: false, + files: [...files].sort(), + }; + yield* fs.writeFileString( + path.join(debugDir, "metadata.json"), + `${JSON.stringify(metadata, null, 2)}\n`, + ); + return debugDir; +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.unit.test.ts new file mode 100644 index 0000000000..567d2ad2f6 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.unit.test.ts @@ -0,0 +1,74 @@ +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, Path } from "effect"; + +import { + legacyFormatPgDeltaNextDebugId, + legacyPgDeltaNextTempPath, + legacySavePgDeltaNextDebugArtifacts, +} from "./legacy-pgdelta-next-artifacts.ts"; +import { legacyPgDeltaTempPath } from "../../../shared/legacy-pgdelta.cache.ts"; + +describe("pg-delta next artifact generation", () => { + it.effect("isolates v2 artifacts from legacy catalog paths", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(legacyPgDeltaTempPath(path, "/project")).toBe( + join("/project", "supabase", ".temp", "pgdelta"), + ); + expect(legacyPgDeltaNextTempPath(path, "/project")).toBe( + join("/project", "supabase", ".temp", "pgdelta", "v2"), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it("uses millisecond-resolution, operation-qualified debug ids", () => { + expect(legacyFormatPgDeltaNextDebugId(Date.UTC(2024, 0, 2, 3, 4, 5, 678), "diff")).toBe( + "20240102-030405-678-diff", + ); + }); + + it.effect("writes structured non-cache artifacts and metadata under v2", () => { + const root = mkdtempSync(join(tmpdir(), "pgdelta-next-artifacts-")); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const debugDir = yield* legacySavePgDeltaNextDebugArtifacts( + fs, + path, + root, + "20240102-030405-678-diff", + "diff", + { + sourceSnapshot: '{"source":true}\n', + desiredSnapshot: '{"desired":true}\n', + plan: '{"plan":true}\n', + diagnostics: [ + { origin: "source", code: "PG001", severity: "warning", message: "warning" }, + ], + }, + ); + + expect(debugDir).toBe( + join(root, "supabase", ".temp", "pgdelta", "v2", "debug", "20240102-030405-678-diff"), + ); + expect(JSON.parse(readFileSync(join(debugDir, "metadata.json"), "utf8"))).toEqual({ + version: 1, + generation: "v2", + implementation: "next", + operation: "diff", + cacheReusable: false, + files: ["desired-snapshot.json", "diagnostics.json", "plan.json", "source-snapshot.json"], + }); + expect(JSON.parse(readFileSync(join(debugDir, "diagnostics.json"), "utf8"))).toEqual([ + { origin: "source", code: "PG001", severity: "warning", message: "warning" }, + ]); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.ts new file mode 100644 index 0000000000..df89b0d79b --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.ts @@ -0,0 +1,164 @@ +import { Effect } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { LegacyPgDeltaEngineError } from "./legacy-pgdelta-engine.service.ts"; +import type { + LegacyPgDeltaNextDiagnostic, + LegacyPgDeltaNextOperation, +} from "./legacy-pgdelta-next-adapter.service.ts"; + +const coverageDiagnosticCodes = new Set(["unmodeled_kind", "unresolved_security_label"]); + +const operationConsequence: Record = { + diff: "Changes to these objects are omitted from the generated database diff.", + declarativeExport: "These objects are omitted from the exported declarative schema.", + declarativePlan: "Changes to these objects are omitted from the declarative migration plan.", + snapshotCapture: "These objects are omitted from the captured database snapshot.", +}; + +const operationAction: Record = { + diff: "emit the database diff", + declarativeExport: "export the declarative schema", + declarativePlan: "emit the declarative migration plan", + snapshotCapture: "capture the database snapshot", +}; + +export interface LegacyPgDeltaNextDiagnosticReport { + readonly diagnostics: ReadonlyArray; + readonly blocking: ReadonlyArray; + readonly coverage: ReadonlyArray; + readonly unmodeledKinds: ReadonlyArray; +} + +function diagnosticKind(diagnostic: LegacyPgDeltaNextDiagnostic): string | undefined { + if (diagnostic.code !== "unmodeled_kind") return undefined; + const kind = diagnostic.context?.kind; + if (typeof kind !== "string") return undefined; + const normalized = kind.trim().replaceAll(/\s+/gu, " "); + return normalized.length === 0 ? undefined : normalized; +} + +export function legacyPgDeltaNextDiagnosticReport( + diagnostics: readonly LegacyPgDeltaNextDiagnostic[], + strictCoverage: boolean, +): LegacyPgDeltaNextDiagnosticReport { + const coverage = diagnostics.filter((diagnostic) => coverageDiagnosticCodes.has(diagnostic.code)); + const blocking = diagnostics.filter( + (diagnostic) => + diagnostic.severity === "error" || + (strictCoverage && coverageDiagnosticCodes.has(diagnostic.code)), + ); + const unmodeledKinds = [ + ...new Set(diagnostics.map(diagnosticKind).filter((kind) => kind !== undefined)), + ].sort((left, right) => left.localeCompare(right)); + + return { diagnostics: [...diagnostics], blocking, coverage, unmodeledKinds }; +} + +export function legacyPgDeltaNextDiagnosticMessage( + diagnostic: LegacyPgDeltaNextDiagnostic, +): string { + const subject = + diagnostic.subject === undefined || diagnostic.subject === "unknown" + ? "" + : ` subject=${diagnostic.subject}`; + return `pg-delta next diagnostic: origin=${diagnostic.origin} code=${diagnostic.code}${subject} message=${diagnostic.message}`; +} + +function legacyPgDeltaNextUnmodeledKindsMessage( + operation: LegacyPgDeltaNextOperation, + kinds: readonly string[], + strictCoverage: boolean, +): string { + const policy = strictCoverage + ? "Strict coverage is enabled, so the operation will stop." + : operationConsequence[operation]; + const summary = + kinds.length === 0 + ? "pg-delta found schema objects it does not manage." + : `pg-delta does not manage these PostgreSQL object kinds: ${kinds.join(", ")}.`; + return `${summary} ${policy}`; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +export function legacyPgDeltaNextFeedbackInvitation(kinds: readonly string[]): string | undefined { + if (kinds.length === 0) return undefined; + const problem = `pg-delta does not manage these PostgreSQL object kinds: ${kinds.join(", ")}`; + const solution = "Add pg-delta support for these PostgreSQL object kinds."; + return [ + "Request pg-delta support:", + ` supabase issue feature --problem ${shellQuote(problem)} --proposed-solution ${shellQuote(solution)}`, + ].join("\n"); +} + +function legacyPgDeltaNextBlockingDiagnosticMessage( + operation: LegacyPgDeltaNextOperation, + blockedByCoverage: boolean, +): string { + const reason = blockedByCoverage + ? "strict coverage rejected unmanaged schema objects" + : "pg-delta reported an error"; + return `pg-delta next refused to ${operationAction[operation]}: ${reason}`; +} + +/** Render actionable diagnostics, route internal detail to debug, and enforce coverage policy. */ +export const legacyReportPgDeltaNextDiagnostics = Effect.fnUntraced(function* ( + operation: LegacyPgDeltaNextOperation, + diagnostics: readonly LegacyPgDeltaNextDiagnostic[], + strictCoverage: boolean, + showFeedback = true, + verboseDiagnostics = false, +) { + const output = yield* Output; + const debug = yield* LegacyDebugLogger; + const report = legacyPgDeltaNextDiagnosticReport(diagnostics, strictCoverage); + + for (const diagnostic of report.diagnostics) { + const message = legacyPgDeltaNextDiagnosticMessage(diagnostic); + const renderDetail = + verboseDiagnostics || + diagnostic.severity === "error" || + (strictCoverage && coverageDiagnosticCodes.has(diagnostic.code)); + if (!renderDetail) { + yield* debug.debug(message); + continue; + } + if (diagnostic.severity === "error") { + yield* output.error(message); + } else if (diagnostic.severity === "warning") { + yield* output.warn(message); + } else { + yield* output.info(message); + } + } + + const unmodeledCount = report.diagnostics.filter( + (diagnostic) => diagnostic.code === "unmodeled_kind", + ).length; + if (unmodeledCount > 0) { + yield* output.warn( + legacyPgDeltaNextUnmodeledKindsMessage(operation, report.unmodeledKinds, strictCoverage), + ); + } + + const feedback = showFeedback + ? legacyPgDeltaNextFeedbackInvitation(report.unmodeledKinds) + : undefined; + if (feedback !== undefined) yield* output.info(feedback); + + if (report.blocking.length > 0) { + return yield* Effect.fail( + new LegacyPgDeltaEngineError({ + message: legacyPgDeltaNextBlockingDiagnosticMessage( + operation, + strictCoverage && report.coverage.length > 0, + ), + cause: report.blocking, + }), + ); + } +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.unit.test.ts new file mode 100644 index 0000000000..849b1a9452 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.unit.test.ts @@ -0,0 +1,253 @@ +import { Effect, Exit, Layer } from "effect"; +import { it } from "@effect/vitest"; +import { describe, expect } from "vitest"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import type { LegacyPgDeltaNextDiagnostic } from "./legacy-pgdelta-next-adapter.service.ts"; +import { + legacyPgDeltaNextDiagnosticMessage, + legacyPgDeltaNextDiagnosticReport, + legacyPgDeltaNextFeedbackInvitation, + legacyReportPgDeltaNextDiagnostics, +} from "./legacy-pgdelta-next-diagnostics.ts"; + +const unmodeled = ( + kind: unknown, + overrides: Partial = {}, +): LegacyPgDeltaNextDiagnostic => ({ + origin: "desired", + code: "unmodeled_kind", + severity: "warning", + subject: "object:public.unsupported", + message: "object kind is not modeled", + context: { kind }, + ...overrides, +}); + +const debugLayer = (messages: string[]) => + Layer.succeed(LegacyDebugLogger, { + debug: (message) => Effect.sync(() => messages.push(message)), + http: () => Effect.void, + }); + +describe("pg-delta next diagnostic coverage policy", () => { + it("summarizes unmodeled kinds and routes nonfatal diagnostic detail to debug", () => { + const out = mockOutput(); + const debugMessages: string[] = []; + return Effect.gen(function* () { + yield* legacyReportPgDeltaNextDiagnostics( + "diff", + [ + unmodeled("text search configuration"), + unmodeled("statistics object"), + { + origin: "source", + code: "dangling_edge", + severity: "warning", + subject: "role:postgres", + message: "edge references a fact not in the base", + }, + { + origin: "declarativeLoad", + code: "invalid_routine_body", + severity: "warning", + message: "routine body failed validation", + }, + { + origin: "snapshot", + code: "unresolved_security_label", + severity: "warning", + message: "provider was not resolved", + }, + ], + false, + ); + + expect(out.messages.filter(({ type }) => type === "warn")).toHaveLength(1); + expect(out.messages).toContainEqual({ + type: "warn", + message: + "pg-delta does not manage these PostgreSQL object kinds: statistics object, text search configuration. Changes to these objects are omitted from the generated database diff.", + }); + expect(out.messages.some(({ message }) => message.includes("dangling_edge"))).toBe(false); + expect(out.messages.some(({ message }) => message.includes("invalid_routine_body"))).toBe( + false, + ); + expect( + out.messages.some(({ message }) => message.includes("unresolved_security_label")), + ).toBe(false); + expect(debugMessages).toHaveLength(5); + expect(debugMessages).toContain( + "pg-delta next diagnostic: origin=source code=dangling_edge subject=role:postgres message=edge references a fact not in the base", + ); + const invitations = out.messages.filter(({ message }) => + message.startsWith("Request pg-delta support:"), + ); + expect(invitations).toHaveLength(1); + expect(invitations[0]?.message).toContain("statistics object, text search configuration"); + }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }); + + it("renders coverage diagnostics and then fails in strict mode", () => { + const out = mockOutput(); + const debugMessages: string[] = []; + return Effect.gen(function* () { + const exit = yield* legacyReportPgDeltaNextDiagnostics( + "declarativePlan", + [unmodeled("text search configuration")], + true, + ).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(out.messages).toContainEqual({ + type: "warn", + message: + "pg-delta next diagnostic: origin=desired code=unmodeled_kind subject=object:public.unsupported message=object kind is not modeled", + }); + expect(out.messages).toContainEqual({ + type: "warn", + message: + "pg-delta does not manage these PostgreSQL object kinds: text search configuration. Strict coverage is enabled, so the operation will stop.", + }); + expect(debugMessages).toEqual([]); + expect(out.messages.some(({ message }) => message.includes("supabase issue feature"))).toBe( + true, + ); + }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }); + + it("can suppress a repeated feedback invitation without suppressing warnings", () => { + const out = mockOutput(); + const debugMessages: string[] = []; + return Effect.gen(function* () { + yield* legacyReportPgDeltaNextDiagnostics( + "declarativePlan", + [unmodeled("text search configuration")], + false, + false, + ); + + expect(out.messages.some(({ message }) => message.includes("supabase issue feature"))).toBe( + false, + ); + expect(out.messages.some(({ type }) => type === "warn")).toBe(true); + }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }); + + it("always renders and fails error diagnostics", () => { + const out = mockOutput(); + const debugMessages: string[] = []; + return Effect.gen(function* () { + const exit = yield* legacyReportPgDeltaNextDiagnostics( + "declarativeExport", + [ + { + origin: "export", + code: "extraction_failed", + severity: "error", + message: "catalog query failed", + }, + ], + false, + ).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(out.messages).toContainEqual({ + type: "error", + message: + "pg-delta next diagnostic: origin=export code=extraction_failed message=catalog query failed", + }); + }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }); + + it("renders every diagnostic with full detail when pg-delta debug is enabled", () => { + const out = mockOutput(); + const debugMessages: string[] = []; + return Effect.gen(function* () { + yield* legacyReportPgDeltaNextDiagnostics( + "diff", + [ + { + origin: "source", + code: "dangling_edge", + severity: "warning", + subject: "role:postgres", + message: "edge references a fact not in the base", + }, + { + origin: "declarativeLoad", + code: "invalid_routine_body", + severity: "info", + message: "routine body failed validation", + }, + ], + false, + true, + true, + ); + + expect(out.messages).toContainEqual({ + type: "warn", + message: + "pg-delta next diagnostic: origin=source code=dangling_edge subject=role:postgres message=edge references a fact not in the base", + }); + expect(out.messages).toContainEqual({ + type: "info", + message: + "pg-delta next diagnostic: origin=declarativeLoad code=invalid_routine_body message=routine body failed validation", + }); + expect(debugMessages).toEqual([]); + }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }); + + it("classifies both coverage codes and aggregates arbitrary kinds safely", () => { + const report = legacyPgDeltaNextDiagnosticReport( + [ + unmodeled("z future kind"), + unmodeled("a future kind"), + unmodeled("a future kind"), + unmodeled("line\nbreak"), + unmodeled(undefined), + unmodeled(" "), + { + origin: "snapshot", + code: "unresolved_security_label", + severity: "info", + message: "provider was not resolved", + context: { kind: 42 }, + }, + ], + true, + ); + + expect(report.coverage).toHaveLength(7); + expect(report.blocking).toHaveLength(7); + expect(report.unmodeledKinds).toEqual(["a future kind", "line break", "z future kind"]); + }); + + it("omits an unknown subject and keeps feedback free of diagnostic details", () => { + expect( + legacyPgDeltaNextDiagnosticMessage({ + origin: "source", + code: "unmodeled_kind", + severity: "warning", + subject: "unknown", + message: "private diagnostic message", + context: { kind: "operator class" }, + }), + ).not.toContain("subject="); + + const invitation = legacyPgDeltaNextFeedbackInvitation(["operator class"]); + expect(invitation).toContain("operator class"); + expect(invitation).not.toContain("private diagnostic message"); + expect(invitation).not.toContain("subject"); + expect(invitation).not.toContain("public."); + }); + + it("shell-quotes future kind names without making feedback kind-specific", () => { + expect(legacyPgDeltaNextFeedbackInvitation(["user's future kind"])).toContain( + `user'"'"'s future kind`, + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts new file mode 100644 index 0000000000..63ca24a5e9 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts @@ -0,0 +1,29 @@ +import { Effect, Layer } from "effect"; + +import { + LegacyPgDeltaNextShadow, + type LegacyPgDeltaNextShadowDatabases, +} from "./legacy-pgdelta-next-shadow.service.ts"; +import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; + +/** + * Scoped next-engine shadow orchestration over the narrow Go `db __shadow` + * seam. Go creates independent migrated and declarative clusters; declarative + * SQL remains wholly owned by the TypeScript pg-delta next adapter. + */ +export const legacyPgDeltaNextShadowLayer = Layer.effect( + LegacyPgDeltaNextShadow, + Effect.gen(function* () { + const seam = yield* LegacyDeclarativeSeam; + + return LegacyPgDeltaNextShadow.of({ + provision: ({ schema, projectRef }) => + Effect.gen(function* () { + return (yield* seam.provisionNextShadow({ + schema, + ...(projectRef !== undefined ? { projectRef } : {}), + })) satisfies LegacyPgDeltaNextShadowDatabases; + }), + }); + }), +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.service.ts new file mode 100644 index 0000000000..24505e8725 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.service.ts @@ -0,0 +1,31 @@ +import { Context, type Effect, type Scope } from "effect"; + +import type { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; + +/** The two live databases needed to plan with the bundled pg-delta next engine. */ +export interface LegacyPgDeltaNextShadowDatabases { + /** Platform baseline with the project's local migrations applied. */ + readonly migrationsUrl: string; + /** Independent platform baseline owned by `planSchemaFiles` while loading desired SQL. */ + readonly declarativeUrl: string; +} + +interface LegacyPgDeltaNextShadowShape { + /** + * Provisions both next-engine shadow containers and owns them for the current + * Effect scope. Both containers are removed when that scope closes. + */ + readonly provision: (opts: { + readonly schema: ReadonlyArray; + readonly projectRef?: string; + }) => Effect.Effect< + LegacyPgDeltaNextShadowDatabases, + LegacyDeclarativeShadowDbError, + Scope.Scope + >; +} + +export class LegacyPgDeltaNextShadow extends Context.Service< + LegacyPgDeltaNextShadow, + LegacyPgDeltaNextShadowShape +>()("supabase/legacy/PgDeltaNextShadow") {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.unit.test.ts new file mode 100644 index 0000000000..97ff6dba2e --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.unit.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { legacyPgDeltaNextShadowLayer } from "./legacy-pgdelta-next-shadow.layer.ts"; +import { LegacyPgDeltaNextShadow } from "./legacy-pgdelta-next-shadow.service.ts"; +import { legacyParseNextShadowProtocol } from "./legacy-pgdelta.seam.layer.ts"; +import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; + +function setup() { + const state = { + provisionCalls: [] as object[], + legacyMethodCalls: [] as string[], + }; + const seamLayer = Layer.succeed( + LegacyDeclarativeSeam, + LegacyDeclarativeSeam.of({ + exportCatalog: () => Effect.die("exportCatalog not used"), + ensureLocalDatabaseStarted: () => Effect.die("ensureLocalDatabaseStarted not used"), + ensureLocalPostgresImageCurrent: () => Effect.die("ensureLocalPostgresImageCurrent not used"), + provisionShadow: () => Effect.die("provisionShadow not used"), + provisionNextShadow: (input) => + Effect.sync(() => { + state.provisionCalls.push(input); + return { + migrationsUrl: "postgresql://postgres:secret@localhost:55432/postgres", + declarativeUrl: "postgresql://postgres:secret@localhost:55433/postgres", + }; + }), + removeShadowContainer: () => Effect.die("removeShadowContainer not used"), + }), + ); + + return { + state, + layer: legacyPgDeltaNextShadowLayer.pipe(Layer.provide(seamLayer)), + }; +} + +describe("LegacyPgDeltaNextShadow", () => { + it("validates the dual-shadow JSON protocol structurally", () => { + expect( + legacyParseNextShadowProtocol( + JSON.stringify({ + migrations: { + containerId: "migrations-container", + url: "postgresql://postgres@localhost:55432/postgres", + }, + declarative: { + containerId: "declarative-container", + url: "postgresql://postgres@localhost:55433/postgres", + }, + }), + ), + ).toEqual({ + migrations: { + containerId: "migrations-container", + url: "postgresql://postgres@localhost:55432/postgres", + }, + declarative: { + containerId: "declarative-container", + url: "postgresql://postgres@localhost:55433/postgres", + }, + }); + + expect(() => legacyParseNextShadowProtocol("not json")).toThrow(); + expect(() => legacyParseNextShadowProtocol('{"migrations":{}}')).toThrow(); + expect(() => + legacyParseNextShadowProtocol( + JSON.stringify({ + migrations: { containerId: "same", url: "postgresql://localhost/postgres" }, + declarative: { containerId: "same", url: "postgresql://localhost/postgres" }, + }), + ), + ).toThrow("next-shadow containers must be distinct"); + }); + + it.effect("delegates to the isolated next-shadow seam and exposes both postgres URLs", () => { + const { layer, state } = setup(); + + return Effect.gen(function* () { + const databases = yield* Effect.scoped( + Effect.gen(function* () { + const shadow = yield* LegacyPgDeltaNextShadow; + return yield* shadow.provision({ + schema: ["public", "extensions"], + projectRef: "linked-project", + }); + }), + ); + + expect(databases).toEqual({ + migrationsUrl: "postgresql://postgres:secret@localhost:55432/postgres", + declarativeUrl: "postgresql://postgres:secret@localhost:55433/postgres", + }); + expect(Object.keys(databases)).toEqual(["migrationsUrl", "declarativeUrl"]); + expect(state.provisionCalls).toEqual([ + { + schema: ["public", "extensions"], + projectRef: "linked-project", + }, + ]); + expect(state.legacyMethodCalls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts new file mode 100644 index 0000000000..7de8a4176c --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts @@ -0,0 +1,798 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, test } from "vitest"; + +import { describeDockerLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; + +const COMMAND_TIMEOUT_MS = 280_000; +const SCENARIO_TIMEOUT_MS = 900_000; +const NEXT_ENV = { + PGDELTA_DEBUG: "1", + SUPABASE_USE_PG_DELTA_NEXT: "true", +}; + +const initialDesiredSchema = `create type public.account_state as enum ('pending', 'active'); + +create table public.disposable_note ( + id bigint generated by default as identity primary key, + body text not null +); + +create view public.auth_user_emails as +select id, email +from auth.users; +`; + +const editedDesiredSchema = `create type public.account_state as enum ('pending', 'review', 'active'); + +create view public.auth_user_emails as +select id, email +from auth.users; + +create table public.review_queue ( + id bigint primary key, + state public.account_state not null default 'review' +); +`; + +function commandFailure(result: { stdout: string; stderr: string }): string { + return `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`; +} + +function migrationFiles(projectDir: string): ReadonlyArray { + const migrationsDir = path.join(projectDir, "supabase", "migrations"); + return existsSync(migrationsDir) + ? readdirSync(migrationsDir) + .filter((file) => file.endsWith(".sql")) + .sort() + : []; +} + +function debugBundleDirectories(projectDir: string): ReadonlyArray { + const debugDir = path.join(projectDir, "supabase", ".temp", "pgdelta", "v2", "debug"); + if (!existsSync(debugDir)) return []; + return readdirSync(debugDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(debugDir, entry.name)) + .sort(); +} + +function requireDebugBundle(projectDir: string, operation: "declarativePlan" | "diff"): string { + const bundle = debugBundleDirectories(projectDir) + .filter((dir) => path.basename(dir).endsWith(`-${operation}`)) + .at(-1); + expect(bundle, `missing ${operation} debug bundle`).toBeDefined(); + if (bundle === undefined) throw new Error(`missing ${operation} debug bundle`); + return bundle; +} + +function assertJsonFile(file: string): unknown { + expect(existsSync(file), `missing ${file}`).toBe(true); + return JSON.parse(readFileSync(file, "utf8")); +} + +function localDatabaseUrl(config: string): string { + const dbSection = config.match(/\[db\][\s\S]*?\nport\s*=\s*(\d+)/u); + expect(dbSection?.[1], "db.port missing from generated config.toml").toBeDefined(); + return `postgresql://postgres:postgres@127.0.0.1:${dbSection?.[1]}/postgres?sslmode=disable`; +} + +function projectContainerIds(config: string): ReadonlyArray { + const projectId = config.match(/^project_id\s*=\s*"([^"]+)"/mu)?.[1]; + expect(projectId, "project_id missing from generated config.toml").toBeDefined(); + if (projectId === undefined) throw new Error("project_id missing from generated config.toml"); + const output = execFileSync( + "docker", + ["ps", "-aq", "--filter", `label=com.supabase.cli.project=${projectId}`], + { encoding: "utf8" }, + ); + return output.split(/\r?\n/u).filter(Boolean).sort(); +} + +function findSqlContaining(root: string, needle: string): string { + const match = readdirSync(root, { recursive: true }) + .filter((entry): entry is string => typeof entry === "string" && entry.endsWith(".sql")) + .map((entry) => path.join(root, entry)) + .find((file) => readFileSync(file, "utf8").includes(needle)); + expect(match, `no SQL file under ${root} contains ${needle}`).toBeDefined(); + if (match === undefined) throw new Error(`no SQL file under ${root} contains ${needle}`); + return match; +} + +function findExtensionDeclaration(root: string, extension: string): string { + const escaped = extension.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const declaration = new RegExp( + `\\bCREATE\\s+EXTENSION(?:\\s+IF\\s+NOT\\s+EXISTS)?\\s+(?:"${escaped}"|${escaped})(?=\\s|;)`, + "iu", + ); + const match = readdirSync(root, { recursive: true }) + .filter((entry): entry is string => typeof entry === "string" && entry.endsWith(".sql")) + .map((entry) => path.join(root, entry)) + .find((file) => declaration.test(readFileSync(file, "utf8"))); + expect(match, `no SQL file under ${root} declares extension ${extension}`).toBeDefined(); + if (match === undefined) throw new Error(`no SQL file under ${root} declares ${extension}`); + return match; +} + +describeDockerLive("pg-delta next local convergence (live)", () => { + let projectDir = ""; + let desiredSchemaPath = ""; + let databaseUrl = ""; + + beforeAll(async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-pgdelta-next-live-")); + + const init = await runSupabaseLive(["init"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(init.exitCode, commandFailure(init)).toBe(0); + + const configPath = path.join(projectDir, "supabase", "config.toml"); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("schema_paths = []"); + expect(config).toContain("[experimental.pgdelta]\nenabled = true"); + writeFileSync( + configPath, + config + .replace("schema_paths = []", 'schema_paths = ["./schemas/*.sql"]') + .replace( + '# declarative_schema_path = "./database"', + 'declarative_schema_path = "./schemas"', + ), + ); + databaseUrl = localDatabaseUrl(config); + + const schemasDir = path.join(projectDir, "supabase", "schemas"); + mkdirSync(schemasDir, { recursive: true }); + desiredSchemaPath = path.join(schemasDir, "public.sql"); + writeFileSync(desiredSchemaPath, initialDesiredSchema); + + const start = await runSupabaseLive( + [ + "start", + "--exclude", + "studio", + "--exclude", + "logflare", + "--exclude", + "vector", + "--exclude", + "gotrue", + "--exclude", + "realtime", + "--exclude", + "storage-api", + ], + { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(start.exitCode, commandFailure(start)).toBe(0); + }, COMMAND_TIMEOUT_MS); + + afterAll(async () => { + if (projectDir.length === 0) return; + await runSupabaseLive(["stop", "--no-backup"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + }, COMMAND_TIMEOUT_MS); + + test( + "converges declarative state across empty, destructive, enum, URL, and migrations refs", + { timeout: SCENARIO_TIMEOUT_MS }, + async () => { + expect(migrationFiles(projectDir)).toEqual([]); + + const initialDiff = await runSupabaseLive( + ["db", "diff", "--local", "--use-pg-delta", "-f", "initial_declarative"], + { cwd: projectDir, env: NEXT_ENV, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(initialDiff.exitCode, commandFailure(initialDiff)).toBe(0); + + const initialMigrations = migrationFiles(projectDir); + expect(initialMigrations.length).toBeGreaterThan(0); + const initialSql = initialMigrations + .map((file) => readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8")) + .join("\n"); + expect(initialSql).toContain("account_state"); + expect(initialSql).toContain("disposable_note"); + expect(initialSql).toContain("auth_user_emails"); + expect(initialSql).toContain("auth.users"); + expect(initialSql).not.toMatch( + /CREATE\s+(?:SCHEMA|TABLE)\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(?:auth|storage|realtime)["']?/iu, + ); + + const declarativeBundle = requireDebugBundle(projectDir, "declarativePlan"); + expect(assertJsonFile(path.join(declarativeBundle, "metadata.json"))).toMatchObject({ + version: 1, + generation: "v2", + implementation: "next", + operation: "declarativePlan", + cacheReusable: false, + files: ["diagnostics.json", "plan.json"], + }); + assertJsonFile(path.join(declarativeBundle, "plan.json")); + expect(Array.isArray(assertJsonFile(path.join(declarativeBundle, "diagnostics.json")))).toBe( + true, + ); + + const firstReset = await runSupabaseLive(["db", "reset", "--local", "--no-seed"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(firstReset.exitCode, commandFailure(firstReset)).toBe(0); + + const emptyAfterInitial = await runSupabaseLive(["db", "diff", "--local", "--use-pg-delta"], { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(emptyAfterInitial.exitCode, commandFailure(emptyAfterInitial)).toBe(0); + expect(emptyAfterInitial.stderr).toContain("No schema changes found"); + + writeFileSync(desiredSchemaPath, editedDesiredSchema); + const beforeEdit = new Set(migrationFiles(projectDir)); + const editedDiff = await runSupabaseLive( + ["db", "diff", "--local", "--use-pg-delta", "-f", "enum_and_drop"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(editedDiff.exitCode, commandFailure(editedDiff)).toBe(0); + expect(editedDiff.stderr).toContain("Found drop statements in schema diff"); + + const editedMigrations = migrationFiles(projectDir).filter((file) => !beforeEdit.has(file)); + expect(editedMigrations.length).toBeGreaterThan(1); + const editedMigrationSql = editedMigrations.map((file) => + readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8"), + ); + const editedSql = editedMigrationSql.join("\n"); + expect(editedSql).toMatch(/ALTER\s+TYPE[\s\S]*account_state[\s\S]*ADD\s+VALUE/iu); + expect(editedSql).toMatch(/DROP\s+TABLE[\s\S]*disposable_note/iu); + expect(editedSql).toContain("review_queue"); + + const enumPush = await runSupabaseLive(["db", "push", "--local"], { + cwd: projectDir, + env: { SUPABASE_YES: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(enumPush.exitCode, commandFailure(enumPush)).toBe(0); + + const emptyAfterEdit = await runSupabaseLive(["db", "diff", "--local", "--use-pg-delta"], { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(emptyAfterEdit.exitCode, commandFailure(emptyAfterEdit)).toBe(0); + expect(emptyAfterEdit.stderr).toContain("No schema changes found"); + + const explicit = await runSupabaseLive( + ["db", "diff", "--from", "migrations", "--to", databaseUrl], + { cwd: projectDir, env: NEXT_ENV, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(explicit.exitCode, commandFailure(explicit)).toBe(0); + expect(explicit.stdout.trim()).toBe(""); + + const diffBundle = requireDebugBundle(projectDir, "diff"); + expect(assertJsonFile(path.join(diffBundle, "metadata.json"))).toMatchObject({ + version: 1, + generation: "v2", + implementation: "next", + operation: "diff", + cacheReusable: false, + files: ["desired-snapshot.json", "diagnostics.json", "plan.json", "source-snapshot.json"], + }); + const sourceSnapshot = readFileSync(path.join(diffBundle, "source-snapshot.json"), "utf8"); + const desiredSnapshot = readFileSync(path.join(diffBundle, "desired-snapshot.json"), "utf8"); + expect(sourceSnapshot).toContain("account_state"); + expect(desiredSnapshot).toContain("account_state"); + JSON.parse(sourceSnapshot); + JSON.parse(desiredSnapshot); + assertJsonFile(path.join(diffBundle, "plan.json")); + expect(Array.isArray(assertJsonFile(path.join(diffBundle, "diagnostics.json")))).toBe(true); + + const generated = await runSupabaseLive( + ["db", "schema", "declarative", "generate", "--local", "--overwrite"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(generated.exitCode, commandFailure(generated)).toBe(0); + + const exportedFiles = readdirSync(path.join(projectDir, "supabase", "schemas"), { + recursive: true, + }) + .filter((entry): entry is string => typeof entry === "string" && entry.endsWith(".sql")) + .map((entry) => path.join(projectDir, "supabase", "schemas", entry)) + .sort(); + expect(exportedFiles.length).toBeGreaterThan(0); + expect(existsSync(path.join(projectDir, "supabase", "schemas", ".pgdelta-export.json"))).toBe( + true, + ); + + const migrationsBeforeGeneratedSync = migrationFiles(projectDir); + const emptyGeneratedSync = await runSupabaseLive( + ["db", "schema", "declarative", "sync", "--no-apply"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(emptyGeneratedSync.exitCode, commandFailure(emptyGeneratedSync)).toBe(0); + expect(emptyGeneratedSync.stderr).toContain("No schema changes found"); + expect(migrationFiles(projectDir)).toEqual(migrationsBeforeGeneratedSync); + + const editedExport = exportedFiles[0]; + expect(editedExport).toBeDefined(); + if (editedExport === undefined) throw new Error("declarative export produced no SQL files"); + writeFileSync( + editedExport, + `${readFileSync(editedExport, "utf8")}\ncreate table public.phase6_synced (id bigint primary key);\n`, + ); + + const migrationsBeforeApplySync = new Set(migrationFiles(projectDir)); + const appliedSync = await runSupabaseLive( + ["db", "schema", "declarative", "sync", "--apply", "--name", "phase6_sync"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(appliedSync.exitCode, commandFailure(appliedSync)).toBe(0); + expect(appliedSync.stderr).toContain("Migration applied successfully"); + const appliedSyncMigrations = migrationFiles(projectDir).filter( + (file) => !migrationsBeforeApplySync.has(file), + ); + expect(appliedSyncMigrations.length).toBeGreaterThan(0); + expect( + appliedSyncMigrations + .map((file) => + readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8"), + ) + .join("\n"), + ).toContain("phase6_synced"); + + const emptyAppliedSync = await runSupabaseLive( + ["db", "schema", "declarative", "sync", "--no-apply"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(emptyAppliedSync.exitCode, commandFailure(emptyAppliedSync)).toBe(0); + expect(emptyAppliedSync.stderr).toContain("No schema changes found"); + + const dbOnlyChange = await runSupabaseLive( + ["db", "query", "--local", "create table public.phase6_pulled (id bigint primary key)"], + { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(dbOnlyChange.exitCode, commandFailure(dbOnlyChange)).toBe(0); + + const configPath = path.join(projectDir, "supabase", "config.toml"); + const pullConfig = readFileSync(configPath, "utf8") + .replace('schema_paths = ["./schemas/*.sql"]', "schema_paths = []") + .replace('declarative_schema_path = "./schemas"', 'declarative_schema_path = "./database"'); + writeFileSync(configPath, pullConfig); + renameSync( + path.join(projectDir, "supabase", "schemas"), + path.join(projectDir, "supabase", ".phase6-exported-schemas"), + ); + + const migrationsBeforePull = new Set(migrationFiles(projectDir)); + const pulled = await runSupabaseLive( + ["db", "pull", "phase6_pull", "--db-url", databaseUrl, "--diff-engine", "pg-delta"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true", SUPABASE_YES: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(pulled.exitCode, commandFailure(pulled)).toBe(0); + const pulledMigrations = migrationFiles(projectDir).filter( + (file) => !migrationsBeforePull.has(file), + ); + expect(pulledMigrations.length).toBeGreaterThan(0); + expect( + pulledMigrations + .map((file) => + readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8"), + ) + .join("\n"), + ).toContain("phase6_pulled"); + + const removePulledTable = await runSupabaseLive( + ["db", "query", "--local", "drop table public.phase6_pulled"], + { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(removePulledTable.exitCode, commandFailure(removePulledTable)).toBe(0); + + const pulledVersion = pulledMigrations[0]?.split("_", 1)[0]; + expect(pulledVersion).toMatch(/^\d{14}$/u); + if (pulledVersion === undefined) throw new Error("db pull produced no migration version"); + const markPulledReverted = await runSupabaseLive( + ["migration", "repair", "--local", "--status", "reverted", pulledVersion], + { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(markPulledReverted.exitCode, commandFailure(markPulledReverted)).toBe(0); + + const pullPush = await runSupabaseLive(["db", "push", "--local"], { + cwd: projectDir, + env: { SUPABASE_YES: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(pullPush.exitCode, commandFailure(pullPush)).toBe(0); + + const emptyPull = await runSupabaseLive( + ["db", "pull", "phase6_pull_empty", "--db-url", databaseUrl, "--diff-engine", "pg-delta"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true", SUPABASE_YES: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(emptyPull.exitCode, commandFailure(emptyPull)).toBe(1); + expect(emptyPull.stderr).toContain("No schema changes found"); + }, + ); + + test( + "keeps the legacy edge-runtime implementation available behind the opt-out", + { timeout: SCENARIO_TIMEOUT_MS }, + async (context) => { + const legacy = await runSupabaseLive( + ["db", "diff", "--from", "migrations", "--to", databaseUrl], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "false" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + const output = `${legacy.stdout}\n${legacy.stderr}`; + if ( + legacy.exitCode !== 0 && + /(?:No such image|manifest unknown|pull access denied|edge-runtime: (?:not found|command not found))/iu.test( + output, + ) + ) { + context.skip("legacy edge-runtime image is concretely unavailable on this Docker host"); + } + expect(legacy.exitCode, commandFailure(legacy)).toBe(0); + }, + ); +}); + +describeDockerLive("pg-delta next declarative extension baseline (live)", () => { + let projectDir = ""; + let config = ""; + + beforeAll(async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-pgdelta-next-extensions-live-")); + + const init = await runSupabaseLive(["init"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(init.exitCode, commandFailure(init)).toBe(0); + + const configPath = path.join(projectDir, "supabase", "config.toml"); + const generatedConfig = readFileSync(configPath, "utf8"); + expect(generatedConfig).toContain("major_version = 17"); + expect(generatedConfig).not.toContain("[experimental.webhooks]"); + config = `${generatedConfig + .replace("schema_paths = []", 'schema_paths = ["./schemas/*.sql"]') + .replace( + '# declarative_schema_path = "./database"', + 'declarative_schema_path = "./schemas"', + )}\n[experimental.webhooks]\nenabled = true\n`; + writeFileSync(configPath, config); + + const start = await runSupabaseLive( + [ + "start", + "--exclude", + "studio", + "--exclude", + "logflare", + "--exclude", + "vector", + "--exclude", + "gotrue", + "--exclude", + "realtime", + "--exclude", + "storage-api", + ], + { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(start.exitCode, commandFailure(start)).toBe(0); + }, COMMAND_TIMEOUT_MS); + + afterAll(async () => { + if (projectDir.length === 0) return; + await runSupabaseLive(["stop", "--no-backup"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + }, COMMAND_TIMEOUT_MS); + + test( + "loads exported user-managed extensions and plans their removal by file deletion", + { timeout: SCENARIO_TIMEOUT_MS }, + async () => { + const generated = await runSupabaseLive( + ["db", "schema", "declarative", "generate", "--local", "--overwrite"], + { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(generated.exitCode, commandFailure(generated)).toBe(0); + + const schemasDir = path.join(projectDir, "supabase", "schemas"); + findExtensionDeclaration(schemasDir, "pg_net"); + const pgcryptoFile = findExtensionDeclaration(schemasDir, "pgcrypto"); + findExtensionDeclaration(schemasDir, "uuid-ossp"); + // The directory itself is the complete desired-state contract. A missing + // manifest must not preserve an extension omitted from the SQL files. + await rm(path.join(schemasDir, ".pgdelta-export.json")); + + const containersBeforeEmpty = projectContainerIds(config); + const migrationsBeforeEmpty = migrationFiles(projectDir); + const empty = await runSupabaseLive(["db", "schema", "declarative", "sync", "--no-apply"], { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(empty.exitCode, commandFailure(empty)).toBe(0); + expect(empty.stderr).toContain("No schema changes found"); + expect(migrationFiles(projectDir)).toEqual(migrationsBeforeEmpty); + expect(projectContainerIds(config)).toEqual(containersBeforeEmpty); + + const pgcryptoSql = readFileSync(pgcryptoFile, "utf8"); + const migrationsBeforeRemoval = new Set(migrationFiles(projectDir)); + await rm(pgcryptoFile); + try { + const containersBeforeRemoval = projectContainerIds(config); + const removal = await runSupabaseLive( + ["db", "schema", "declarative", "sync", "--no-apply", "--name", "drop_pgcrypto"], + { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(removal.exitCode, commandFailure(removal)).toBe(0); + expect(projectContainerIds(config)).toEqual(containersBeforeRemoval); + + const removalMigrations = migrationFiles(projectDir).filter( + (file) => !migrationsBeforeRemoval.has(file), + ); + expect(removalMigrations.length).toBeGreaterThan(0); + const removalSql = removalMigrations + .map((file) => + readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8"), + ) + .join("\n"); + expect(removalSql).toMatch(/DROP\s+EXTENSION(?:\s+IF\s+EXISTS)?\s+"?pgcrypto"?/iu); + } finally { + writeFileSync(pgcryptoFile, pgcryptoSql); + await Promise.all( + migrationFiles(projectDir) + .filter((file) => !migrationsBeforeRemoval.has(file)) + .map((file) => rm(path.join(projectDir, "supabase", "migrations", file))), + ); + } + }, + ); +}); + +describeDockerLive("pg-delta next isolated cron shadows (live)", () => { + const jobName = "pgdelta_cli_inactive"; + const initialSchedule = "0 0 * * *"; + const changedSchedule = "15 3 * * *"; + let projectDir = ""; + let config = ""; + + beforeAll(async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-pgdelta-next-cron-live-")); + + const init = await runSupabaseLive(["init"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(init.exitCode, commandFailure(init)).toBe(0); + + const configPath = path.join(projectDir, "supabase", "config.toml"); + config = readFileSync(configPath, "utf8") + .replace("schema_paths = []", 'schema_paths = ["./schemas/*.sql"]') + .replace('# declarative_schema_path = "./database"', 'declarative_schema_path = "./schemas"'); + writeFileSync(configPath, config); + + const migrationsDir = path.join(projectDir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + writeFileSync( + path.join(migrationsDir, "20260806000000_cron_inactive.sql"), + `create extension if not exists pg_cron; + +create table public.pgdelta_cron_execution_sentinel ( + executed_at timestamptz not null default now() +); + +select cron.schedule( + '${jobName}', + '${initialSchedule}', + 'insert into public.pgdelta_cron_execution_sentinel default values' +); + +select cron.alter_job( + (select jobid from cron.job where jobname = '${jobName}'), + active := false +); +`, + ); + + const start = await runSupabaseLive( + [ + "start", + "--exclude", + "studio", + "--exclude", + "logflare", + "--exclude", + "vector", + "--exclude", + "gotrue", + "--exclude", + "realtime", + "--exclude", + "storage-api", + ], + { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(start.exitCode, commandFailure(start)).toBe(0); + }, COMMAND_TIMEOUT_MS); + + afterAll(async () => { + if (projectDir.length === 0) return; + await runSupabaseLive(["stop", "--no-backup"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + }, COMMAND_TIMEOUT_MS); + + test( + "keeps an inactive named job converged and replaces only its changed schedule", + { timeout: SCENARIO_TIMEOUT_MS }, + async () => { + const generated = await runSupabaseLive( + ["db", "schema", "declarative", "generate", "--local", "--overwrite"], + { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(generated.exitCode, commandFailure(generated)).toBe(0); + + const schemasDir = path.join(projectDir, "supabase", "schemas"); + const cronFile = findSqlContaining(schemasDir, `cron.schedule_in_database('${jobName}'`); + const containersBeforeEmpty = projectContainerIds(config); + const empty = await runSupabaseLive(["db", "schema", "declarative", "sync", "--no-apply"], { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(empty.exitCode, commandFailure(empty)).toBe(0); + expect(empty.stderr).toContain("No schema changes found"); + expect(projectContainerIds(config)).toEqual(containersBeforeEmpty); + + const emptyBundle = requireDebugBundle(projectDir, "declarativePlan"); + expect(assertJsonFile(path.join(emptyBundle, "plan.json"))).toMatchObject({ + deltas: [], + actions: [], + source: { fingerprint: expect.any(String) }, + target: { fingerprint: expect.any(String) }, + }); + + const exportedCron = readFileSync(cronFile, "utf8"); + expect(exportedCron).toContain(`'${initialSchedule}'`); + writeFileSync(cronFile, exportedCron.replace(`'${initialSchedule}'`, `'${changedSchedule}'`)); + + const migrationsBeforeApply = new Set(migrationFiles(projectDir)); + const containersBeforeApply = projectContainerIds(config); + const applied = await runSupabaseLive( + ["db", "schema", "declarative", "sync", "--apply", "--name", "cron_schedule"], + { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(applied.exitCode, commandFailure(applied)).toBe(0); + expect(applied.stderr).toContain("Migration applied successfully"); + expect(projectContainerIds(config)).toEqual(containersBeforeApply); + + const scheduleMigrations = migrationFiles(projectDir).filter( + (file) => !migrationsBeforeApply.has(file), + ); + expect(scheduleMigrations.length).toBeGreaterThan(0); + const scheduleSql = scheduleMigrations + .map((file) => readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8")) + .join("\n"); + expect(scheduleSql.match(/cron\.unschedule/gu)).toHaveLength(1); + expect(scheduleSql.match(/cron\.schedule_in_database/gu)).toHaveLength(1); + expect(scheduleSql).toContain(`'${changedSchedule}'`); + expect(scheduleSql).not.toMatch( + /\b(?:create|alter|drop)\s+(?:table|schema|function|view|extension|role)\b/iu, + ); + + const job = await runSupabaseLive( + [ + "db", + "query", + "--local", + "-o", + "json", + `select schedule, active from cron.job where jobname = '${jobName}'`, + ], + { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(job.exitCode, commandFailure(job)).toBe(0); + expect(JSON.parse(job.stdout)).toEqual([{ schedule: changedSchedule, active: false }]); + + const executions = await runSupabaseLive( + [ + "db", + "query", + "--local", + "-o", + "json", + "select count(*)::int as executions from public.pgdelta_cron_execution_sentinel", + ], + { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(executions.exitCode, commandFailure(executions)).toBe(0); + expect(JSON.parse(executions.stdout)).toEqual([{ executions: 0 }]); + + const containersBeforeFinal = projectContainerIds(config); + const finalSync = await runSupabaseLive( + ["db", "schema", "declarative", "sync", "--no-apply"], + { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(finalSync.exitCode, commandFailure(finalSync)).toBe(0); + expect(finalSync.stderr).toContain("No schema changes found"); + expect(projectContainerIds(config)).toEqual(containersBeforeFinal); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 9e97f7105e..0e29d3f144 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -1,9 +1,9 @@ -import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Scope, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import { LegacyNetworkIdFlag, LegacyProfileFlag } from "../../../../shared/legacy/global-flags.ts"; -import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; +import { type BinaryResolution, resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { containerCliExitCode, spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts"; @@ -14,7 +14,11 @@ import { localDbContainerId, } from "../../../shared/legacy-docker-ids.ts"; import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; -import { LegacyDeclarativeSeam, type LegacyShadowSource } from "./legacy-pgdelta.seam.service.ts"; +import { + LegacyDeclarativeSeam, + type LegacyNextShadowSource, + type LegacyShadowSource, +} from "./legacy-pgdelta.seam.service.ts"; import { legacyInjectPostgresPassword } from "./legacy-pgdelta.seam.url.ts"; /** @@ -28,8 +32,7 @@ import { legacyInjectPostgresPassword } from "./legacy-pgdelta.seam.url.ts"; * doc comment in `legacy-pgdelta.seam.service.ts` for why those two modes still * need this hidden Go command while `"migrations"` no longer does). */ -export const legacyDeclarativeSeamLayer = Layer.effect( - LegacyDeclarativeSeam, +const makeLegacyDeclarativeSeam = (resolved: BinaryResolution) => Effect.gen(function* () { const cliConfig = yield* LegacyCliConfig; const networkId = yield* LegacyNetworkIdFlag; @@ -44,7 +47,19 @@ export const legacyDeclarativeSeamLayer = Layer.effect( const spawner = yield* ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const resolved = resolveBinary(); + const removeShadowContainer = (container: string) => + Effect.gen(function* () { + if (container.length === 0) return; + // Best-effort and volume-aware, matching Go's DockerRemove. Each next + // shadow registers this independently so one cleanup defect cannot + // prevent the sibling container from being removed. + yield* containerCliExitCode(spawner, ["rm", "-f", "-v", container], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + extendEnv: true, + }).pipe(Effect.ignore); + }); return LegacyDeclarativeSeam.of({ exportCatalog: ({ mode, noCache, projectRef }) => @@ -359,7 +374,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( ); }), ), - provisionShadow: ({ mode, targetLocal, usePgDelta, schema, projectRef }) => + provisionShadow: ({ mode, schema, projectRef }) => Effect.scoped( Effect.gen(function* () { if (!("found" in resolved)) { @@ -375,8 +390,6 @@ export const legacyDeclarativeSeamLayer = Layer.effect( "__shadow", "--mode", mode, - ...(targetLocal ? ["--target-local"] : []), - ...(usePgDelta ? ["--use-pg-delta"] : []), ...(schema.length > 0 ? ["--schema", schema.join(",")] : []), ...(Option.isSome(networkId) ? ["--network-id", networkId.value] : []), // Linked path only: pass the resolved ref so the hidden `db __shadow` @@ -426,9 +439,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( bytes.set(chunk, offset); offset += chunk.length; } - // stdout is three newline-separated lines: container id, source URL, - // and an optional target-override URL (empty unless the local-target - // declarative branch redirected the target to a second shadow db). + // stdout is two newline-separated lines: container id and source URL. // The URLs arrive WITHOUT a password — the Go seam prints them via // ToPostgresURLWithoutPassword so it never logs a credential to stdout // (CWE-312). The shadow uses the local Postgres password, so we re-inject @@ -441,7 +452,6 @@ export const legacyDeclarativeSeamLayer = Layer.effect( const lines = new TextDecoder().decode(bytes).split(/\r?\n/u); const container = (lines[0] ?? "").trim(); const sourceUrl = (lines[1] ?? "").trim(); - const targetOverride = (lines[2] ?? "").trim(); if (container.length === 0 || sourceUrl.length === 0) { return yield* Effect.fail(failure()); } @@ -458,32 +468,129 @@ export const legacyDeclarativeSeamLayer = Layer.effect( return { container, sourceUrl: legacyInjectPostgresPassword(sourceUrl, password), - targetUrlOverride: - targetOverride.length > 0 - ? legacyInjectPostgresPassword(targetOverride, password) - : undefined, } satisfies LegacyShadowSource; }), ), - removeShadowContainer: (container) => + provisionNextShadow: ({ schema, projectRef }) => Effect.gen(function* () { - if (container.length === 0) return; - // Remove the shadow left running by provisionShadow. Best-effort — a - // failure here must never mask the diff result. `-v` removes the - // Postgres anonymous data volume too, matching Go's `DockerRemove` - // (`RemoveOptions{RemoveVolumes: true, Force: true}`, - // `internal/utils/docker.go:330`); without it every shadow leaves a - // dangling volume behind. - yield* containerCliExitCode(spawner, ["rm", "-f", "-v", container], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - extendEnv: true, - }).pipe(Effect.ignore); + if (!("found" in resolved)) { + return yield* Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: + "Could not find the supabase-go binary required to provision the shadow databases.", + }), + ); + } + + // Keep the process in a nested scope. Until `ack\n`, Go owns both + // containers, so parsing/password failures and interruption close the + // child and let its deferred cleanup run. The caller scope receives + // both Docker finalizers before ownership is acknowledged. + const ownerScope = yield* Effect.scope; + return yield* Effect.scoped( + Effect.gen(function* () { + const args = [ + "db", + "__shadow", + "--mode", + "pgdelta-next", + ...(schema.length > 0 ? ["--schema", schema.join(",")] : []), + ...(Option.isSome(networkId) ? ["--network-id", networkId.value] : []), + ...(projectRef !== undefined ? ["--project-ref", projectRef] : []), + ...profileArgs, + ]; + const command = ChildProcess.make(resolved.found, args, { + cwd: cliConfig.workdir, + stdin: "pipe", + stdout: "pipe", + stderr: "inherit", + extendEnv: true, + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + detached: false, + }); + const handle = yield* spawner.spawn(command).pipe( + Effect.mapError( + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to run the shadow-database provisioner (supabase-go).", + }), + ), + ); + // `runHead` returns as soon as the newline-delimited JSON object is + // emitted; waiting for stdout EOF would deadlock because Go waits + // for the acknowledgment before exiting. + const line = yield* handle.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runHead, + Effect.mapError(() => failure()), + ); + if (Option.isNone(line)) { + return yield* Effect.fail(failure()); + } + const protocol = yield* Effect.try({ + try: () => legacyParseNextShadowProtocol(line.value), + catch: () => failure(), + }); + const password = yield* legacyReadDbToml( + fs, + path, + cliConfig.workdir, + projectRef, + ).pipe( + Effect.map((toml) => toml.password), + Effect.mapError( + () => + new LegacyDeclarativeShadowDbError({ + message: + "failed to read the local database password from config.toml to connect to the shadow databases.", + }), + ), + ); + const databases = yield* Effect.try({ + try: () => + ({ + migrationsUrl: legacyInjectPostgresPassword(protocol.migrations.url, password), + declarativeUrl: legacyInjectPostgresPassword( + protocol.declarative.url, + password, + ), + }) satisfies LegacyNextShadowSource, + catch: () => failure(), + }); + + yield* Scope.addFinalizer( + ownerScope, + removeShadowContainer(protocol.migrations.containerId).pipe(Effect.ignoreCause), + ); + yield* Scope.addFinalizer( + ownerScope, + removeShadowContainer(protocol.declarative.containerId).pipe(Effect.ignoreCause), + ); + yield* Stream.make("ack\n").pipe( + Stream.encodeText, + Stream.run(handle.stdin), + Effect.mapError(() => failure()), + ); + const exitCode = yield* handle.exitCode.pipe(Effect.mapError(() => failure())); + if (exitCode !== 0) { + return yield* Effect.fail(failure(exitCode)); + } + return databases; + }), + ); }), + removeShadowContainer, }); - }), -); + }); + +export function makeLegacyDeclarativeSeamLayer(options: { readonly binary?: string } = {}) { + const resolved: BinaryResolution = + options.binary === undefined ? resolveBinary() : { found: options.binary }; + return Layer.effect(LegacyDeclarativeSeam, makeLegacyDeclarativeSeam(resolved)); +} + +export const legacyDeclarativeSeamLayer = makeLegacyDeclarativeSeamLayer(); // Intentionally NOT `LegacyGoChildExitError` (contrast the now-removed `db __db-bootstrap` // seam, fixed under CLI-1879): this seam's failure is a TS-authored domain summary over noisy @@ -535,3 +642,40 @@ export function legacyResolveContainerInspectImageName(stdout: string): string { function isJsonRecord(value: unknown): value is { readonly [key: string]: unknown } { return typeof value === "object" && value !== null; } + +interface LegacyNextShadowProtocolDatabase { + readonly containerId: string; + readonly url: string; +} + +interface LegacyNextShadowProtocol { + readonly migrations: LegacyNextShadowProtocolDatabase; + readonly declarative: LegacyNextShadowProtocolDatabase; +} + +/** Strict structural validation for the Go next-shadow ownership protocol. */ +export function legacyParseNextShadowProtocol(line: string): LegacyNextShadowProtocol { + const parsed: unknown = JSON.parse(line); + if (!isJsonRecord(parsed)) throw new Error("invalid next-shadow protocol"); + const migrations = parseNextShadowProtocolDatabase(parsed["migrations"]); + const declarative = parseNextShadowProtocolDatabase(parsed["declarative"]); + if (migrations.containerId === declarative.containerId) { + throw new Error("next-shadow containers must be distinct"); + } + return { migrations, declarative }; +} + +function parseNextShadowProtocolDatabase(value: unknown): LegacyNextShadowProtocolDatabase { + if (!isJsonRecord(value)) throw new Error("invalid next-shadow database"); + const containerId = value["containerId"]; + const url = value["url"]; + if ( + typeof containerId !== "string" || + containerId.trim().length === 0 || + typeof url !== "string" || + url.trim().length === 0 + ) { + throw new Error("invalid next-shadow database"); + } + return { containerId: containerId.trim(), url: url.trim() }; +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts index b6b0251a10..004d8ade19 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts @@ -1,9 +1,239 @@ -import { describe, expect, it } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Sink, Stream } from "effect"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { LegacyNetworkIdFlag, LegacyProfileFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyIsMissingContainerInspectError, legacyResolveContainerInspectImageName, + makeLegacyDeclarativeSeamLayer, } from "./legacy-pgdelta.seam.layer.ts"; +import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; + +const protocol = JSON.stringify({ + migrations: { + containerId: "migrations-container", + url: "postgresql://postgres@localhost:55432/postgres", + }, + declarative: { + containerId: "declarative-container", + url: "postgresql://postgres@localhost:55433/postgres", + }, +}); + +function setup( + options: { + readonly stdout?: string; + readonly interruptOnAck?: boolean; + readonly cleanupDefectContainer?: string; + readonly workdir?: string; + } = {}, +) { + const state = { + commands: [] as Array<{ + readonly command: string; + readonly args: ReadonlyArray; + readonly stdin: unknown; + readonly stdout: unknown; + readonly stderr: unknown; + }>, + stdin: "", + childScopeClosed: 0, + cleanupAttempts: [] as string[], + }; + const spawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (command._tag !== "StandardCommand") return Effect.die("unexpected pipeline"); + state.commands.push({ + command: command.command, + args: [...command.args], + stdin: command.options.stdin, + stdout: command.options.stdout, + stderr: command.options.stderr, + }); + const isProvisioner = command.command === "/fake/supabase-go"; + if (!isProvisioner) { + const container = command.args.at(-1) ?? ""; + state.cleanupAttempts.push(container); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(2), + exitCode: + container === options.cleanupDefectContainer + ? Effect.die("cleanup defect") + : Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + } + const handle = ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.sync(() => { + if (state.stdin !== "ack\n") throw new Error("exit awaited before ack"); + return ChildProcessSpawner.ExitCode(0); + }), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.forEach((chunk: Uint8Array) => + Effect.sync(() => { + state.stdin += new TextDecoder().decode(chunk); + }).pipe(Effect.andThen(options.interruptOnAck === true ? Effect.interrupt : Effect.void)), + ), + // Never terminate stdout: reading the full stream would deadlock before ack. + stdout: Stream.make(new TextEncoder().encode(`${options.stdout ?? protocol}\n`)).pipe( + Stream.concat(Stream.never), + ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + return Effect.acquireRelease(Effect.succeed(handle), () => + Effect.sync(() => { + state.childScopeClosed += 1; + }), + ); + }), + ); + const config = Layer.succeed(LegacyCliConfig, { + profile: "supabase", + apiUrl: "https://api.supabase.com", + projectHost: "supabase.co", + poolerHost: "pooler.supabase.com", + dashboardUrl: "https://supabase.com/dashboard", + accessToken: Option.none(), + projectId: Option.none(), + workdir: options.workdir ?? resolve(process.cwd(), "../.."), + userAgent: "test", + }); + const dependencies = Layer.mergeAll( + BunFileSystem.layer, + BunPath.layer, + spawner, + config, + Layer.succeed(LegacyNetworkIdFlag, Option.some("test-network")), + Layer.succeed(LegacyProfileFlag, "snap"), + ); + return { + state, + layer: makeLegacyDeclarativeSeamLayer({ binary: "/fake/supabase-go" }).pipe( + Layer.provide(dependencies), + ), + }; +} + +describe("LegacyDeclarativeSeam next shadow protocol", () => { + it.effect("acks only after acquisition and cleans both containers on caller failure", () => { + const { layer, state } = setup({ cleanupDefectContainer: "declarative-container" }); + return Effect.gen(function* () { + const exit = yield* Effect.scoped( + Effect.gen(function* () { + const seam = yield* LegacyDeclarativeSeam; + const databases = yield* seam.provisionNextShadow({ + schema: ["public", "extensions"], + projectRef: "linked-project", + }); + expect(state.stdin).toBe("ack\n"); + expect(databases).toEqual({ + migrationsUrl: "postgresql://postgres:postgres@localhost:55432/postgres", + declarativeUrl: "postgresql://postgres:postgres@localhost:55433/postgres", + }); + return yield* Effect.fail("caller failed"); + }), + ).pipe(Effect.exit); + + expect(exit._tag).toBe("Failure"); + expect(state.cleanupAttempts).toEqual(["declarative-container", "migrations-container"]); + expect(state.childScopeClosed).toBe(1); + expect(state.commands[0]).toEqual({ + command: "/fake/supabase-go", + args: [ + "db", + "__shadow", + "--mode", + "pgdelta-next", + "--schema", + "public,extensions", + "--network-id", + "test-network", + "--project-ref", + "linked-project", + "--profile", + "snap", + ], + stdin: "pipe", + stdout: "pipe", + stderr: "inherit", + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("has both cleanup finalizers installed when the ack write is interrupted", () => { + const { layer, state } = setup({ interruptOnAck: true }); + return Effect.gen(function* () { + yield* Effect.scoped( + Effect.gen(function* () { + const seam = yield* LegacyDeclarativeSeam; + yield* seam.provisionNextShadow({ schema: [] }); + }), + ).pipe(Effect.exit); + expect(state.stdin).toBe("ack\n"); + expect(state.cleanupAttempts).toEqual(["declarative-container", "migrations-container"]); + expect(state.childScopeClosed).toBe(1); + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not ack malformed output and leaves cleanup with Go", () => { + const { layer, state } = setup({ stdout: '{"migrations":{}}' }); + return Effect.gen(function* () { + yield* Effect.scoped( + Effect.gen(function* () { + const seam = yield* LegacyDeclarativeSeam; + yield* seam.provisionNextShadow({ schema: [] }); + }), + ).pipe(Effect.exit); + expect(state.stdin).toBe(""); + expect(state.cleanupAttempts).toEqual([]); + expect(state.childScopeClosed).toBe(1); + }).pipe(Effect.provide(layer)); + }); + + it.effect("does not ack when the database password cannot be read", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-next-shadow-")); + mkdirSync(join(workdir, "supabase")); + writeFileSync(join(workdir, "supabase", "config.toml"), "[db\ninvalid"); + const { layer, state } = setup({ workdir }); + return Effect.gen(function* () { + yield* Effect.scoped( + Effect.gen(function* () { + const seam = yield* LegacyDeclarativeSeam; + yield* seam.provisionNextShadow({ schema: [] }); + }), + ).pipe(Effect.exit); + expect(state.stdin).toBe(""); + expect(state.cleanupAttempts).toEqual([]); + expect(state.childScopeClosed).toBe(1); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true }))), + ); + }); +}); describe("legacyIsMissingContainerInspectError", () => { it("matches Docker and Podman missing-container stderr", () => { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts index 4f5409c3a6..51ff36aab1 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts @@ -1,4 +1,4 @@ -import { Context, type Effect } from "effect"; +import { Context, type Effect, type Scope } from "effect"; import type { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; @@ -35,12 +35,14 @@ export interface LegacyShadowSource { readonly container: string; /** The diff source Postgres URL (the provisioned shadow). */ readonly sourceUrl: string; - /** - * When set, replaces the diff target with a second shadow database - * (`contrib_regression` with declarative schemas applied). Mirrors Go's - * local-target declarative branch, where the user's local DB is not diffed. - */ - readonly targetUrlOverride: string | undefined; +} + +/** The independently hosted databases used by the pg-delta next planner. */ +export interface LegacyNextShadowSource { + /** Platform baseline with local configuration and migrations applied. */ + readonly migrationsUrl: string; + /** Platform baseline with local configuration, ready for declarative SQL. */ + readonly declarativeUrl: string; } interface LegacyDeclarativeSeamShape { @@ -97,14 +99,12 @@ interface LegacyDeclarativeSeamShape { * Provisions a live shadow database via the bundled Go binary's hidden * `db __shadow` command and returns it running (the container is NOT removed — * the caller must call `removeShadowContainer` when the diff completes). This - * is the diff "source" that both the migra and pg-delta engines run against in - * `db diff` / `db pull`, mirroring Go's `DiffDatabase` (`differ(shadow, target)`). + * is the migration-state source that both the migra and pg-delta engines run + * against in `db diff` / `db pull`. * Go's shadow-provisioning progress is teed to stderr. */ readonly provisionShadow: (opts: { readonly mode: LegacyShadowMode; - readonly targetLocal: boolean; - readonly usePgDelta: boolean; readonly schema: ReadonlyArray; /** * Resolved linked project ref, passed ONLY on the `--linked` path so the @@ -114,6 +114,16 @@ interface LegacyDeclarativeSeamShape { */ readonly projectRef?: string; }) => Effect.Effect; + /** + * Provisions the two isolated pg-delta next shadows through the Go seam's + * JSON/ack ownership protocol. Both containers are owned by the current + * Effect scope before the child is acknowledged, and are independently + * removed when that scope closes. + */ + readonly provisionNextShadow: (opts: { + readonly schema: ReadonlyArray; + readonly projectRef?: string; + }) => Effect.Effect; /** * Removes a shadow database container left running by `provisionShadow` * (`docker rm -f `). Best-effort: a failure to remove is swallowed so it diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts index 2503e5ccc2..e184be77be 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts @@ -1,8 +1,16 @@ import { Effect, type FileSystem, type Path } from "effect"; import { legacyBold } from "../../../shared/legacy-colors.ts"; -import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; import type { LegacyDeclarativeOutput } from "../../../shared/legacy-pgdelta.ts"; +import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; +import type { + LegacyPgDeltaDeclarativeExportResult, + LegacyPgDeltaExportManifest, +} from "./legacy-pgdelta-engine.service.ts"; + +const EXPORT_MANIFEST_FILE = ".pgdelta-export.json"; + +type LegacyDeclarativeWriteOutput = LegacyDeclarativeOutput | LegacyPgDeltaDeclarativeExportResult; /** * Go's `declarative.Generate` / `pull.go`'s written-to line, printed by all three @@ -31,7 +39,7 @@ export const legacyWriteDeclarativeSchemas = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, declarativeDir: string, - output: LegacyDeclarativeOutput, + output: LegacyDeclarativeWriteOutput, ) { yield* fs.remove(declarativeDir, { recursive: true }).pipe( Effect.catchTag("PlatformError", (error) => @@ -47,18 +55,37 @@ export const legacyWriteDeclarativeSchemas = Effect.fnUntraced(function* ( ); yield* fs.makeDirectory(declarativeDir, { recursive: true }); + const writtenFiles: Array = []; for (const file of output.files) { - const rel = path.normalize(file.path); + const name = "name" in file ? file.name : file.path; + const rel = path.normalize(name); if (rel.startsWith("..") || path.isAbsolute(rel)) { return yield* Effect.fail( new LegacyDeclarativeWriteError({ - message: `unsafe declarative export path: ${file.path}`, + message: `unsafe declarative export path: ${name}`, }), ); } const targetPath = path.join(declarativeDir, rel); yield* fs.makeDirectory(path.dirname(targetPath), { recursive: true }); yield* fs.writeFileString(targetPath, file.sql); + writtenFiles.push(name.split("\\").join("/")); + } + + const manifest = "manifest" in output ? output.manifest : undefined; + if (manifest !== undefined) { + const serialized: LegacyPgDeltaExportManifest & { + readonly formatVersion: 1; + readonly files: ReadonlyArray; + } = { + formatVersion: 1, + ...manifest, + files: [...writtenFiles].sort(), + }; + yield* fs.writeFileString( + path.join(declarativeDir, EXPORT_MANIFEST_FILE), + `${JSON.stringify(serialized, null, 2)}\n`, + ); } }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts index 3e619d6322..93c9ff5ec5 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts @@ -7,14 +7,18 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Path } from "effect"; import { legacyBold } from "../../../shared/legacy-colors.ts"; -import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; import type { LegacyDeclarativeOutput } from "../../../shared/legacy-pgdelta.ts"; +import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; +import type { LegacyPgDeltaDeclarativeExportResult } from "./legacy-pgdelta-engine.service.ts"; import { legacyDeclarativeSchemaWrittenLine, legacyWriteDeclarativeSchemas, } from "./legacy-pgdelta.write.ts"; -const write = (declarativeDir: string, output: LegacyDeclarativeOutput) => +const write = ( + declarativeDir: string, + output: LegacyDeclarativeOutput | LegacyPgDeltaDeclarativeExportResult, +) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -41,6 +45,32 @@ describe("legacyWriteDeclarativeSchemas", () => { expect(existsSync(join(declDir, "stale.sql"))).toBe(false); expect(readFileSync(join(declDir, "public.sql"), "utf8")).toBe("create table a();"); expect(readFileSync(join(declDir, "auth", "roles.sql"), "utf8")).toBe("create role app;"); + expect(existsSync(join(declDir, ".pgdelta-export.json"))).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("writes the next export manifest with the generated file list", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-decl-write-")); + const declDir = join(dir, "supabase", "database"); + return write(declDir, { + files: [ + { name: "schemas/z.sql", sql: "select 'z';" }, + { name: "schemas/a.sql", sql: "select 'a';" }, + ], + manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, + }).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(JSON.parse(readFileSync(join(declDir, ".pgdelta-export.json"), "utf8"))).toEqual({ + formatVersion: 1, + redactSecrets: true, + scope: "database", + profile: "supabase", + files: ["schemas/a.sql", "schemas/z.sql"], + }); rmSync(dir, { recursive: true, force: true }); }), ), diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts index 506dde00a6..8bec68ec27 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -262,6 +262,7 @@ interface SetupOpts { readonly running?: boolean; readonly runningFails?: boolean; readonly configContents?: string; + readonly projectEnvContents?: string; readonly skipConfig?: boolean; readonly workdir?: string; readonly cwd?: string; @@ -281,6 +282,9 @@ function setup(opts: SetupOpts = {}) { const workdir = opts.workdir ?? tempRoot.current; if (opts.skipConfig !== true) { writeConfig(workdir, opts.configContents ?? 'project_id = "test"\n'); + if (opts.projectEnvContents !== undefined) { + writeFileSync(join(workdir, "supabase", ".env"), opts.projectEnvContents); + } } const out = mockOutput({ format: opts.format ?? "text" }); const telemetry = mockLegacyTelemetryStateTracked(); @@ -462,10 +466,11 @@ describe("legacy db start", () => { ); it.live( - "caches the migrations catalog after a fresh-volume setup when pg-delta is enabled", + "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"}', }); @@ -487,10 +492,11 @@ describe("legacy db start", () => { ); it.live( - "warns without failing db start when the migrations-catalog export fails on a fresh volume", + "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", }); diff --git a/apps/cli/src/legacy/commands/link/link.handler.ts b/apps/cli/src/legacy/commands/link/link.handler.ts index f1cca7c3e5..eca1beae99 100644 --- a/apps/cli/src/legacy/commands/link/link.handler.ts +++ b/apps/cli/src/legacy/commands/link/link.handler.ts @@ -1,4 +1,4 @@ -import type { ApiClient } from "@supabase/api/effect"; +import { isSupabaseApiResponseSchemaError, type ApiClient } from "@supabase/api/effect"; import { Effect, FileSystem, Option, Path } from "effect"; import type { PlatformError } from "effect/PlatformError"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; @@ -18,7 +18,10 @@ import { } from "../../../shared/telemetry/event-catalog.ts"; import { legacyDashboardUrl } from "../../shared/legacy-profile.ts"; import { legacyMapTenantApiKeysError } from "../../shared/legacy-get-tenant-api-keys.ts"; -import { sanitizeLegacyErrorBody } from "../../shared/legacy-http-errors.ts"; +import { + LegacyApiResponseSchemaError, + sanitizeLegacyErrorBody, +} from "../../shared/legacy-http-errors.ts"; import { legacyLinkServicesCore } from "../../shared/legacy-link-services-core.ts"; import { legacyExtractServiceKeys } from "../../shared/legacy-tenant-keys.ts"; import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; @@ -42,8 +45,16 @@ const classifyProjectError = ( cause: unknown, ): Effect.Effect< Option.Option, - LegacyLinkProjectStatusError | LegacyLinkProjectStatusNetworkError + LegacyApiResponseSchemaError | LegacyLinkProjectStatusError | LegacyLinkProjectStatusNetworkError > => { + if (isSupabaseApiResponseSchemaError(cause)) { + return Effect.fail( + new LegacyApiResponseSchemaError({ + operationId: cause.operationId, + message: cause.message, + }), + ); + } if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { const status = cause.response.status; if (status === 404) { diff --git a/apps/cli/src/legacy/commands/link/link.integration.test.ts b/apps/cli/src/legacy/commands/link/link.integration.test.ts index 870adb758c..cfe975662f 100644 --- a/apps/cli/src/legacy/commands/link/link.integration.test.ts +++ b/apps/cli/src/legacy/commands/link/link.integration.test.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { SupabaseApiResponseSchemaError } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; @@ -353,6 +354,26 @@ describe("legacy link integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("surfaces project response schema failures separately from network failures", () => { + const { layer } = setup({ + project: { + fail: new SupabaseApiResponseSchemaError( + "v1GetProject", + new Error("created_at is not RFC3339"), + ), + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyLink(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyApiResponseSchemaError"); + expect(json).not.toContain("LegacyLinkProjectStatusNetworkError"); + } + }).pipe(Effect.provide(layer)); + }); + it.live("fails with auth error when api-keys returns non-200", () => { const { layer } = setup({ apiKeys: { fail: legacyStatusCodeFailure(401) } }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index aae4dd9bc9..be344f1b53 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -2382,13 +2382,14 @@ content_path = "./templates/custom_notice.html" ); it.live( - "caches the migrations catalog after a fresh-volume setup when pg-delta is enabled", + "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"); @@ -2409,11 +2410,12 @@ content_path = "./templates/custom_notice.html" it.live( "warns without failing supabase start when the migrations-catalog export fails on a fresh volume", () => { - const { layer, out } = setup({ + 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); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts index 33d2ccd90a..bf76b3ca54 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -31,14 +31,17 @@ * `STORAGE_S3_REGION`, no JWKS) — built locally, not reused. * - `initAuthJob` (`start.go:319-332`) — ditto, a minimal env distinct from * `gotrue.service.ts`'s full container builder. - * 2. **`ApplyApiPrivileges`** (`start.go:414-435`) — tri-state on + * 2. **`ApplyDatabaseWebhooks`** — installs `pg_net` only when + * `experimental.webhooks.enabled` is true. The platform webhook helpers/event + * trigger remain available for migrations that manage the extension explicitly. + * 3. **`ApplyApiPrivileges`** (`start.go:414-435`) — tri-state on * `api.auto_expose_new_tables`: `true` is a no-op (keep the bundled initial-schema * grants); unset/`false` execs {@link LEGACY_START_REVOKE_API_PRIVILEGES_SQL} * (Go's inline `RevokeDefaultDataApiPrivilegesSql` constant, `start.go:405-412`) * via a temp file, same as the schema SQL above. - * 3. **Vault upsert** (`start.go:390-393`) — `legacyUpsertVaultSecrets`, run BEFORE + * 4. **Vault upsert** (`start.go:390-393`) — `legacyUpsertVaultSecrets`, run BEFORE * the custom-roles seed "so roles.sql can reference them" (Go's own comment). - * 4. **Custom-roles seed** (`start.go:394-398` + `pkg/migration/seed.go:84-97`) — + * 5. **Custom-roles seed** (`start.go:394-398` + `pkg/migration/seed.go:84-97`) — * prints "Seeding globals from roles.sql..." UNCONDITIONALLY, BEFORE checking * whether `supabase/roles.sql` even exists (Go's `SeedGlobals` prints first, * then attempts the read), then execs the file via `legacyExecSqlFile` only when @@ -46,7 +49,7 @@ * os.ErrNotExist)` check, reproduced here as an existence check ahead of the read * rather than a caught not-found error — see the call site's own comment for why); * any other read/exec error propagates. - * 5. **`apply.MigrateAndSeed`** (`start.go:368`, via the already-ported + * 6. **`apply.MigrateAndSeed`** (`start.go:368`, via the already-ported * `legacyMigrateAndSeed`) with the caller-supplied {@link * LegacyStartSetupLocalDatabaseInput.version} — `""` (every pending migration) for * `db start`'s own call, matching `SetupLocalDatabase`'s call in the `start` @@ -56,7 +59,7 @@ * `--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}. - * 6. **`pgcache.TryCacheMigrationsCatalog`** (`start.go:371-379`) — a best-effort + * 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 @@ -112,6 +115,7 @@ import { Clock, Data, Effect, type FileSystem, Option, type Path } from "effect" import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; +import { legacyResolvePgDeltaImplementation } from "../legacy-pgdelta-next-flag.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; @@ -163,6 +167,9 @@ alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated, service_role; `; +const LEGACY_START_ENABLE_DATABASE_WEBHOOKS_SQL = + "create extension if not exists pg_net schema extensions;"; + /** * A SQL exec (schema/globals/API-privileges) or one-shot service-migration Docker * job failed, or the scratch temp directory/file could not be created. The Docker @@ -740,6 +747,22 @@ export const legacyApplyApiPrivileges = Effect.fnUntraced(function* ( ); }); +/** Installs pg_net only for the explicit Database Webhooks feature opt-in. */ +const legacyApplyDatabaseWebhooks = Effect.fnUntraced(function* ( + input: LegacyStartSetupLocalDatabaseInput, + tmpDir: string, +) { + if (input.config.experimental.webhooks?.enabled !== true) return; + yield* legacyExecSqlConstant( + input.session, + input.fs, + input.path, + tmpDir, + "enable-database-webhooks.sql", + LEGACY_START_ENABLE_DATABASE_WEBHOOKS_SQL, + ); +}); + /** * Port of Go's `initCurrentBranch` (`start.go:233-241`): writes * `supabase/.branches/_current_branch` = `"main"` (Go's `CurrBranchPath`, @@ -833,7 +856,7 @@ export const legacyStartSetupLocalDatabase = ( warnOnUnresolvedEnv: false, }); - // SetupDatabase: initSchema -> ApplyApiPrivileges (start.go:383-389). + // SetupDatabase: initSchema -> ApplyDatabaseWebhooks -> ApplyApiPrivileges. yield* Effect.scoped( Effect.gen(function* () { const tmpDir = yield* fs @@ -847,6 +870,7 @@ export const legacyStartSetupLocalDatabase = ( ), ); yield* legacyStartInitSchema(spawner, input, tmpDir); + yield* legacyApplyDatabaseWebhooks(input, tmpDir); yield* legacyApplyApiPrivileges( session, fs, @@ -933,6 +957,9 @@ export const legacyStartSetupLocalDatabase = ( input.version.length === 0 && (toml.pgDelta.enabled || legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA"))); + const pgDeltaImplementation = legacyResolvePgDeltaImplementation( + toml.envLookup("SUPABASE_USE_PG_DELTA_NEXT"), + ); const pgDeltaCtx: LegacyPgDeltaContext = { projectId: input.projectId, cwd: workdir, @@ -952,7 +979,8 @@ export const legacyStartSetupLocalDatabase = ( Effect.gen(function* () { yield* legacyApplyProjectEnv(input.projectEnvValues ?? {}); yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { - enabled: cacheEnabled, + // The catalog is an alpha.33-only artifact with no next-engine consumer. + enabled: cacheEnabled && pgDeltaImplementation === "legacy", targetUrl: input.dbUrl, conn: { host: hostDbUrl.hostname, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index f7d6fdc8c0..efb102ba2f 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -47,6 +47,7 @@ const SCHEMA_13_FINGERPRINT = const SCHEMA_14_FINGERPRINT_SUFFIX = "CREATE SCHEMA IF NOT EXISTS graphql"; const REVOKE_PRIVILEGES_FINGERPRINT = "revoke execute on functions from anon, authenticated, service_role"; +const PG_NET_CREATE_FINGERPRINT = "create extension if not exists pg_net schema extensions"; function fakeSession() { const calls: Array<{ kind: "exec" | "query"; sql: string; params?: ReadonlyArray }> = []; @@ -582,6 +583,42 @@ describe("legacyStartSetupLocalDatabase", () => { ); }); + describe("Database Webhooks", () => { + it.effect("does not install pg_net merely because Edge Runtime is enabled", () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ edge_runtime: { enabled: true } }); + return run(baseInput(workdir, session, { majorVersion: 14, config }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(PG_NET_CREATE_FINGERPRINT))).toBe(false); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("installs pg_net when Database Webhooks is enabled without Edge Runtime", () => { + const workdir = makeWorkdir(); + writeConfigToml(workdir, "[experimental.webhooks]\nenabled = true\n"); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + edge_runtime: { enabled: false }, + experimental: { webhooks: { enabled: true } }, + }); + return run(baseInput(workdir, session, { majorVersion: 14, config }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.filter((sql) => sql.includes(PG_NET_CREATE_FINGERPRINT))).toHaveLength(1); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + }); + describe("vault upsert + custom-roles seed", () => { it.effect("upserts vault secrets before seeding supabase/roles.sql", () => { const workdir = makeWorkdir(); @@ -651,42 +688,53 @@ describe("legacyStartSetupLocalDatabase", () => { ); }); - it.effect( - "caches the migrations catalog after MigrateAndSeed when [experimental.pgdelta] 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(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 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( "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\n"); + 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(); @@ -724,7 +772,7 @@ describe("legacyStartSetupLocalDatabase", () => { mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync( join(workdir, "supabase", ".env"), - "PGDELTA_NPM_REGISTRY=https://registry.example.com/supabase\n", + "PGDELTA_NPM_REGISTRY=https://registry.example.com/supabase\nSUPABASE_USE_PG_DELTA_NEXT=false\n", ); const { session } = fakeSession(); const out = mockOutput(); @@ -763,6 +811,7 @@ describe("legacyStartSetupLocalDatabase", () => { () => { 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(); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts index 3b88393d98..1476001a4b 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts @@ -86,6 +86,11 @@ describe("legacyBuildPostgresStartContainerSpec", () => { ); expect(script).not.toContain(LEGACY_POSTGRES_DEFAULT_ROOT_KEY); expect(script).not.toContain("pgsodium_root.key"); + expect(LEGACY_START_DB_WEBHOOK_SQL).not.toContain("CREATE EXTENSION IF NOT EXISTS pg_net"); + expect(LEGACY_START_DB_WEBHOOK_SQL).toContain( + "CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access()", + ); + expect(LEGACY_START_DB_WEBHOOK_SQL).toContain("CREATE EVENT TRIGGER issue_pg_net_access"); expect(spec.tmpfs).toBeUndefined(); expect(spec.secretFiles).toEqual([ { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/templates/db-initial-schema-14.sql.ts b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-initial-schema-14.sql.ts index 8199aaad59..b2b506eb09 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/templates/db-initial-schema-14.sql.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-initial-schema-14.sql.ts @@ -79,20 +79,6 @@ CREATE SCHEMA IF NOT EXISTS graphql_public; ALTER SCHEMA graphql_public OWNER TO supabase_admin; --- --- Name: pg_net; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions; - - --- --- Name: EXTENSION pg_net; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pg_net IS 'Async HTTP'; - - -- -- Name: pgbouncer; Type: SCHEMA; Schema: -; Owner: pgbouncer -- diff --git a/apps/cli/src/legacy/shared/db-bootstrap/templates/db-webhook.sql.ts b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-webhook.sql.ts index 5aa85e84a5..d71eeff247 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/templates/db-webhook.sql.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-webhook.sql.ts @@ -7,9 +7,6 @@ */ export const LEGACY_START_DB_WEBHOOK_SQL = `BEGIN; --- Create pg_net extension -CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; - -- Create supabase_functions schema CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; diff --git a/apps/cli/src/legacy/shared/legacy-db-config.service.ts b/apps/cli/src/legacy/shared/legacy-db-config.service.ts index c21f8c42db..0ea2f10f88 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.service.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.service.ts @@ -8,6 +8,7 @@ import type { import type { LegacyProfileLoadError } from "./legacy-profile-load.ts"; import type { LegacyProjectRefReadError } from "./legacy-temp-paths.ts"; import type { LegacyDbConnectError } from "./legacy-db-connection.errors.ts"; +import type { LegacyApiResponseSchemaError } from "./legacy-http-errors.ts"; import type { LegacyDbConfigConnectTempRoleError, LegacyDbConfigIpv6Error, @@ -42,6 +43,7 @@ export type LegacyDbConfigError = | LegacyDbConfigConnectTempRoleError | LegacyDbConfigPoolerLoginError | LegacyDbConnectError + | LegacyApiResponseSchemaError // The `--linked` path resolves the access token lazily via // `LegacyPlatformApiFactory.make` (only when minting a temp login role), so the // auth-required / invalid-token / api-config errors surface from the resolver diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index dc2ac7b869..5d3d36d0c3 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -556,8 +556,8 @@ const DEFAULT_SUPABASE_ENV = "development"; * `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 every pg-delta edge-runtime invocation — diff, declarative - * export/sync, and the push/pull/dump migrations-catalog cache). Go's + * `process.env` for legacy-opt-out pg-delta edge-runtime invocations). The bundled + * next implementation never consults it. 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 @@ -1092,10 +1092,11 @@ const readDbTomlCore = Effect.fnUntraced(function* ( .readFileString(poolerUrlPath) .pipe(Effect.map(nonEmptyString), Effect.orElseSucceed(Option.none)); - // Go: `config.go:700-709` — the pg-delta npm version is read from + // Go: `config.go:700-709` — 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`). + // 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())), diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts index 0af60b46c9..367dcc7a7b 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -13,7 +13,10 @@ import { Effect } from "effect"; import { LEGACY_SUGGEST_ENV_VAR, LEGACY_SUGGEST_LOCAL_STACK } from "./legacy-connect-errors.ts"; import type { LegacyDbConnectError, LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import { type LegacyPgConnInput, LegacyDbConnection } from "./legacy-db-connection.service.ts"; -import { legacyDbConnectionSqlPgLayer } from "./legacy-db-connection.sql-pg.layer.ts"; +import { + legacyAcquirePgPool, + legacyDbConnectionSqlPgLayer, +} from "./legacy-db-connection.sql-pg.layer.ts"; const SUGGESTION_CONTEXT = { dashboardUrl: "https://supabase.com/dashboard", @@ -335,3 +338,37 @@ describe("legacyDbConnectionSqlPgLayer exec failures", () => { }), ); }); + +describe("legacyAcquirePgPool", () => { + it.live("returns the winning raw pool and ends it when the caller scope closes", () => + Effect.gen(function* () { + const server = yield* Effect.promise(() => + fakeQueryServer(() => Buffer.concat([commandComplete("SELECT 1"), READY_FOR_QUERY])), + ); + yield* Effect.gen(function* () { + let acquired: import("pg").Pool | undefined; + + yield* Effect.gen(function* () { + const pool = yield* legacyAcquirePgPool( + { + host: "127.0.0.1", + port: server.port, + user: "postgres", + password: SENTINEL_PASSWORD, + database: "postgres", + sslmode: "disable", + }, + { isLocal: true, dnsResolver: "native" }, + ); + acquired = pool; + expect(pool.ending).toBe(false); + expect(pool.ended).toBe(false); + yield* Effect.tryPromise(() => pool.query("select 1")); + }).pipe(Effect.scoped); + + expect(acquired?.ending).toBe(true); + expect(acquired?.ended).toBe(true); + }).pipe(Effect.ensuring(Effect.sync(server.close))); + }), + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index a05da9fd73..1ecdc5bd8d 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -574,15 +574,32 @@ export const legacyAcquireProbedPool =

( return pool; }); +/** Map a driver connect failure to the credential-free Go-compatible error. */ +const legacyToConnectError = ( + cfg: LegacyPgConnInput, + isLocal: boolean, + error: unknown, +): LegacyDbConnectError => { + const suggestion = + cfg.suggestionContext === undefined + ? undefined + : legacyConnectSuggestion(error, { ...cfg.suggestionContext, isLocal }); + return new LegacyDbConnectError({ + message: `failed to connect to postgres: ${legacyConnectFailureMessage(cfg, error)}`, + ...(suggestion === undefined ? {} : { suggestion }), + }); +}; + /** - * Default `LegacyDbConnection` layer, backed by `@effect/sql-pg` (pure-JS `pg` - * driver, no native addon — bundles under `bun build --compile`). Each - * `connect` builds a scoped single-client connection that closes on scope exit. + * Acquire the winning raw pool through the full Go-compatible connection attempt + * chain. The pool finalizer is owned by the caller's scope; both the legacy session + * adapter and direct-pool consumers use this one acquisition core so their DNS, + * TLS, fallback, and role behavior cannot drift apart. */ -const connect = ( +const acquirePgPoolConnection = ( cfg: LegacyPgConnInput, { isLocal, dnsResolver }: LegacyDbConnectOptions, -): Effect.Effect => +) => Effect.gen(function* () { // pgconn dials the primary host then each HA fallback in order // (`config.go:326-362`); `cfg.fallbacks` carries the extras parsed from a @@ -616,8 +633,8 @@ const connect = ( // `AfterConnect` hook only on the remote path (`ConnectByConfigStream`, // `connect.go:342-362`), not `ConnectLocalPostgres`, so gate on `!isLocal`. const stepDownRequired = !isLocal && needsRoleStepDown(cfg.user); - // Build the primary connection over a self-managed `pg.Pool` (via - // `PgClient.fromPool`) rather than `PgClient.make`, so we control two pool + // Build the primary connection over a self-managed `pg.Pool` rather than + // `PgClient.make`, so we control two pool // behaviors `PgClient.make` does not expose: `idleTimeoutMillis: 0` (never reap // the single pooled connection — see `legacyBuildPoolConfig`; the fix for the // `db pull` step-down loss) and the per-connection role step-down `verify` hook @@ -626,12 +643,12 @@ const connect = ( // the pool on scope exit AND on every failure/timeout (the leak `PgClient.make` // has). `probe` (below) runs each attempt in a forked scope so a failed fallback // attempt's pool closes immediately, before the next host is dialed. - const makeClient = ( + const makePool = ( dialHost: string, port: number, sslOption: boolean | ConnectionOptions | undefined, - ) => { - const acquire = legacyAcquireProbedPool( + ) => + legacyAcquireProbedPool( () => new Pg.Pool( legacyBuildPoolConfig( @@ -645,8 +662,6 @@ const connect = ( ), connectTimeoutSeconds, ); - return PgClient.fromPool({ acquire }).pipe(Effect.provide(Reactivity.layer)); - }; // Go's `ConnectByUrl` calls `SetConnectSuggestion(err)` on every connect failure // (`connect.go:187`), mapping the driver error to an actionable hint that replaces @@ -657,17 +672,6 @@ const connect = ( // to postgres:` prefix plus the `host=… user=… database=…` identity and the // underlying driver cause — not the bare `SqlError` toString, which drops all // of that detail. - const toConnectError = (error: unknown) => { - const suggestion = - cfg.suggestionContext === undefined - ? undefined - : legacyConnectSuggestion(error, { ...cfg.suggestionContext, isLocal }); - return new LegacyDbConnectError({ - message: `failed to connect to postgres: ${legacyConnectFailureMessage(cfg, error)}`, - ...(suggestion === undefined ? {} : { suggestion }), - }); - }; - // Load the `sslrootcert` CA bundle (pgconn reads it into `RootCAs` at parse // time; a missing/unreadable file aborts). Skipped for local connections, which // never use TLS. pgconn builds TLS per fallback host, so the CA must be loaded @@ -723,7 +727,7 @@ const connect = ( const attempts = dialTargets.flatMap(({ dialHost, port, servername }) => legacySslConfigsFor(cfg.sslmode, isLocal, servername, caCert, dialHost, clientCert).map( (ssl) => ({ - client: makeClient(dialHost, port, ssl), + pool: makePool(dialHost, port, ssl), // pgconn only short-circuits the fallback chain on an auth error when the // failed attempt used TLS (`pgconn.go:182`, gated on `fc.TLSConfig != nil`); // a TLS config is any non-plaintext `ssl` value. @@ -756,9 +760,8 @@ const connect = ( // session and closes with it. const sessionScope = yield* Scope.Scope; const attemptScope = yield* Scope.fork(sessionScope); - return yield* attempt.client.pipe( - Effect.tap((candidate) => candidate`select 1`), - Effect.map((candidate) => ({ candidate, rawConfig: attempt.rawConfig })), + return yield* attempt.pool.pipe( + Effect.map((pool) => ({ pool, rawConfig: attempt.rawConfig })), Scope.provide(attemptScope), Effect.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : Scope.close(attemptScope, exit), @@ -766,7 +769,7 @@ const connect = ( ); }); const lastIndex = attempts.length - 1; - const { candidate: client, rawConfig: winningRawConfig } = yield* attempts + const { pool, rawConfig: winningRawConfig } = yield* attempts .slice(0, lastIndex) .reduceRight( (next, attempt) => @@ -777,26 +780,58 @@ const connect = ( ), probe(attempts[lastIndex]!), ) - .pipe(Effect.mapError(toConnectError)); + .pipe(Effect.mapError((error) => legacyToConnectError(cfg, isLocal, error))); // Step down from the temp/privileged login role before any further SQL — but // only for remote connections: Go installs this hook in `ConnectByConfigStream`, // not `ConnectLocalPostgres`, so a local `--db-url` using `supabase_admin`/ - // `cli_login_*` must not run it. The pool's `"connect"` hook already ran this on - // the physical connection (and on any silent redial); this explicit one-shot is - // the fail-fast path — the hook swallows errors, so a real role-privilege problem - // only surfaces here, as `LegacyDbConnectError: failed to set session role: ...` - // (Go parity). `max: 1` + `idleTimeoutMillis: 0` keep the stepped-down connection + // `cli_login_*` must not run it. The pool's `verify` hook already ran this on + // the physical connection (and runs it on any silent redial); this explicit + // one-shot preserves the fail-fast `LegacyDbConnectError: failed to set session + // role: ...` path. `max: 1` + `idleTimeoutMillis: 0` keep the stepped-down connection // alive so the session-scoped role persists for every later `exec`/`query`. if (stepDownRequired) { - yield* client.unsafe(SET_SESSION_ROLE).pipe( - Effect.asVoid, - Effect.mapError( - (error) => new LegacyDbConnectError({ message: `failed to set session role: ${error}` }), - ), - ); + yield* Effect.tryPromise({ + try: () => pool.query(SET_SESSION_ROLE), + catch: (error) => + new LegacyDbConnectError({ message: `failed to set session role: ${error}` }), + }); } + return { pool, winningRawConfig, stepDownRequired }; + }); + +/** + * Acquire a live `pg.Pool` using the same scoped lifecycle and connection parity + * as `LegacyDbConnection.connect`. The caller owns the surrounding scope; closing + * it ends the winning pool, while every losing fallback attempt is closed before + * the next target is tried. + */ +export const legacyAcquirePgPool = ( + cfg: LegacyPgConnInput, + options: LegacyDbConnectOptions, +): Effect.Effect => + acquirePgPoolConnection(cfg, options).pipe(Effect.map(({ pool }) => pool)); + +/** + * Default `LegacyDbConnection` layer, backed by `@effect/sql-pg` (pure-JS `pg` + * driver, no native addon — bundles under `bun build --compile`). Each + * `connect` builds a scoped single-client connection that closes on scope exit. + */ +const connect = ( + cfg: LegacyPgConnInput, + options: LegacyDbConnectOptions, +): Effect.Effect => + Effect.gen(function* () { + const { pool, winningRawConfig, stepDownRequired } = yield* acquirePgPoolConnection( + cfg, + options, + ); + const client = yield* PgClient.fromPool({ acquire: Effect.succeed(pool) }).pipe( + Effect.provide(Reactivity.layer), + Effect.mapError((error) => legacyToConnectError(cfg, options.isLocal, error)), + ); + // `inspect report` runs ~14 `COPY (...) TO STDOUT` statements. node-postgres' // COPY protocol needs the raw client (which `@effect/sql-pg` does not surface), // so the session opens ONE dedicated raw connection against the winning dial @@ -826,7 +861,7 @@ const connect = ( const fresh = new Pg.Client(winningRawConfig); yield* Effect.tryPromise({ try: () => fresh.connect(), - catch: toConnectError, + catch: (error) => legacyToConnectError(cfg, options.isLocal, error), }); if (stepDownRequired) { yield* Effect.tryPromise({ diff --git a/apps/cli/src/legacy/shared/legacy-db-push-core.ts b/apps/cli/src/legacy/shared/legacy-db-push-core.ts index be8759fda7..0920c9dba5 100644 --- a/apps/cli/src/legacy/shared/legacy-db-push-core.ts +++ b/apps/cli/src/legacy/shared/legacy-db-push-core.ts @@ -9,6 +9,7 @@ import { } from "./legacy-pgdelta.cache.ts"; import { type LegacyPgDeltaContext } from "./legacy-pgdelta.ts"; import { legacyParseBoolEnv } from "./legacy-diff-engine.ts"; +import { legacyResolvePgDeltaImplementation } from "./legacy-pgdelta-next-flag.ts"; import { LEGACY_ERR_MISSING_LOCAL, LEGACY_ERR_MISSING_REMOTE, @@ -318,6 +319,9 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush const cacheEnabled = toml.pgDelta.enabled || legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")); + const pgDeltaImplementation = legacyResolvePgDeltaImplementation( + toml.envLookup("SUPABASE_USE_PG_DELTA_NEXT"), + ); const pgDeltaCtx: LegacyPgDeltaContext = { // Go's `flags.LoadConfig` seeds `Config.ProjectId = ProjectRef` before // `Config.Load` runs, so an absent config.toml `project_id` retains the @@ -341,7 +345,10 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush denoVersion: toml.denoVersion, }; yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { - enabled: cacheEnabled, + // 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, diff --git a/apps/cli/src/legacy/shared/legacy-diff-engine.ts b/apps/cli/src/legacy/shared/legacy-diff-engine.ts index 12079b9ab8..16c65e0190 100644 --- a/apps/cli/src/legacy/shared/legacy-diff-engine.ts +++ b/apps/cli/src/legacy/shared/legacy-diff-engine.ts @@ -3,6 +3,9 @@ // byte-identical to the Go CLI. No Effect / service dependencies — unit-tested // directly. +export const legacySchemaPathsTransitionWarning = + "WARNING: [db.migrations].schema_paths no longer changes the migrations baseline used by db diff or migration-style db pull. These commands always compare local migrations with the selected database. Use `supabase db schema declarative sync` to compare declarative schema files.\n"; + /** * Whether pg-delta is the active default engine. Mirrors Go's `shouldUsePgDelta` * (`db.go:375-376`): `utils.IsPgDeltaEnabled() || usePgDelta || viper.GetBool("EXPERIMENTAL_PG_DELTA")`. diff --git a/apps/cli/src/legacy/shared/legacy-http-errors.ts b/apps/cli/src/legacy/shared/legacy-http-errors.ts index 7639e83f99..80a21bddef 100644 --- a/apps/cli/src/legacy/shared/legacy-http-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-http-errors.ts @@ -1,5 +1,5 @@ -import type { SupabaseApiError } from "@supabase/api/effect"; -import { Effect } from "effect"; +import { isSupabaseApiResponseSchemaError, type SupabaseApiError } from "@supabase/api/effect"; +import { Data, Effect } from "effect"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; // HttpClientError reasons that indicate the server returned an actual response (vs a transport @@ -55,6 +55,12 @@ export type StatusErrorFactory = new (args: { readonly message: string; }) => E; +/** A 2xx Management API response that violates the generated response contract. */ +export class LegacyApiResponseSchemaError extends Data.TaggedError("LegacyApiResponseSchemaError")<{ + readonly operationId: string; + readonly message: string; +}> {} + /** * Build an error mapper that classifies a `SupabaseApiError` into either a typed network * error or a typed unexpected-status error. Pulled out of individual command families so @@ -69,9 +75,17 @@ export function mapLegacyHttpError(opts: { readonly statusError: StatusErrorFactory; readonly networkMessage: (cause: string) => string; readonly statusMessage: (status: number, body: string) => string; -}): (cause: SupabaseApiError) => Effect.Effect { +}): (cause: SupabaseApiError) => Effect.Effect { return (cause) => Effect.gen(function* () { + if (isSupabaseApiResponseSchemaError(cause)) { + return yield* Effect.fail( + new LegacyApiResponseSchemaError({ + operationId: cause.operationId, + message: cause.message, + }), + ); + } if (HttpClientError.isHttpClientError(cause)) { if (RESPONSE_ERROR_TAGS.has(cause.reason._tag) && cause.response !== undefined) { const status = cause.response.status; @@ -92,7 +106,7 @@ export function mapLegacyHttpError(opts: { new opts.networkError({ message: opts.networkMessage(description) }), ); } - // SchemaError or HttpBodyError — treat as transport-level network error. + // Input SchemaError or HttpBodyError — retain their historical mapping. return yield* Effect.fail( new opts.networkError({ message: opts.networkMessage(String(cause)) }), ); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 96d545f10e..a97a2b078f 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -51,6 +51,8 @@ const REINDEX_CONCURRENTLY_PATTERN = /^REINDEX(?:\s|\().*\sCONCURRENTLY(?:\s|$)/ const VACUUM_PATTERN = /^VACUUM(?:\s|\(|$)/u; const ALTER_SYSTEM_PATTERN = /^ALTER\s+SYSTEM(?:\s|$)/u; const CLUSTER_PATTERN = /^CLUSTER(?:\s|$)/u; +const TRANSACTION_CONTROL_PATTERN = + /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ROLLBACK|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; /** * Strips a leading BOM, whitespace, and SQL line (`--`) and block comments from the @@ -97,6 +99,10 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => { ); }; +/** Whether the statement owns a transaction boundary that must not be nested. */ +export const legacyHasTransactionControl = (sql: string): boolean => + TRANSACTION_CONTROL_PATTERN.test(legacyTrimLeadingSqlComments(sql).toUpperCase()); + /** A buffered statement awaiting the next batch flush; `version` is the history insert. */ type LegacyBatchItem = | { readonly kind: "exec"; readonly sql: string } @@ -199,6 +205,31 @@ const execMigrationBatch = ( return new Error(`${errMessage(e)}\n${msg.join("\n")}`); }; + // A file with authored transaction boundaries owns those semantics. Execute + // the statements exactly as written, clean up a failed authored transaction, + // and only send the history insert after every statement has succeeded. + if (statements.some(legacyHasTransactionControl)) { + const authored = Effect.gen(function* () { + for (const [index, statement] of statements.entries()) { + yield* session + .exec(statement) + .pipe(Effect.mapError((cause) => atStatement(cause, index, statement))); + } + if (version.length > 0) { + yield* session + .query(INSERT_MIGRATION_VERSION, [version, name, statements]) + .pipe( + Effect.mapError((cause) => + atStatement(cause, statements.length, INSERT_MIGRATION_VERSION), + ), + ); + } + }); + return yield* authored.pipe( + Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore)), + ); + } + // `executed` is the global statement index of the next statement to run, so the // error context stays accurate across flushed batches and standalone statements // (Go threads the same counter through `ExecBatch`). diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 749fb9b70b..4ca6249394 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -9,6 +9,7 @@ import { mockOutput } from "../../../tests/helpers/mocks.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyApplyMigrationFile, + legacyHasTransactionControl, legacyIsPipelineIncompatible, legacyMarkError, legacySeedGlobals, @@ -193,6 +194,61 @@ describe("legacyApplyMigrationFile", () => { ), ); }); + + it.effect("preserves authored transaction boundaries and records history afterwards", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_authored.sql"); + writeFileSync(file, "BEGIN;\nSET LOCAL check_function_bodies = off;\nCOMMIT;"); + const { session, calls } = fakeSession(); + return run(session, file).pipe( + Effect.tap(() => + Effect.sync(() => { + const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); + // One BEGIN/COMMIT belongs to history-table setup; the other pair is + // exactly the authored boundary, with no nested migration wrapper. + expect(execs.filter((sql) => sql === "BEGIN")).toHaveLength(2); + expect(execs.filter((sql) => sql === "COMMIT")).toHaveLength(2); + expect(execs).toContain("SET LOCAL check_function_bodies = off"); + const history = calls.filter((call) => call.kind === "query"); + expect(history).toHaveLength(1); + expect(history[0]?.params?.[0]).toBe("20240101120000"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("does not record history when an authored transaction fails", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_authored.sql"); + writeFileSync(file, "BEGIN;\nCREATE TABLE broken (;\nCOMMIT;"); + const { session, calls } = fakeSession({ failOn: "CREATE TABLE broken" }); + return run(session, file).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + expect(calls.some((call) => call.kind === "query")).toBe(false); + expect(calls.some((call) => call.kind === "exec" && call.sql === "ROLLBACK")).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); +}); + +describe("legacyHasTransactionControl", () => { + it("recognizes authored boundaries after comments without matching routine bodies", () => { + expect(legacyHasTransactionControl("-- authored\nBEGIN")).toBe(true); + expect(legacyHasTransactionControl("START TRANSACTION ISOLATION LEVEL SERIALIZABLE")).toBe( + true, + ); + expect( + legacyHasTransactionControl( + "CREATE FUNCTION f() RETURNS void AS $$ BEGIN END $$ LANGUAGE plpgsql", + ), + ).toBe(false); + }); }); describe("migration failure rendering (Go ExecBatch parity)", () => { diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.ts new file mode 100644 index 0000000000..39f4729dfc --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.ts @@ -0,0 +1,22 @@ +export type LegacyPgDeltaImplementation = "next" | "legacy"; + +/** + * 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/legacy/shared/legacy-pgdelta-next-flag.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.unit.test.ts new file mode 100644 index 0000000000..1768456b35 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.unit.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { legacyResolvePgDeltaImplementation } from "./legacy-pgdelta-next-flag.ts"; + +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/legacy/shared/legacy-pgdelta.cache.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts index d9f292dd25..39d1ded01a 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts @@ -34,7 +34,7 @@ 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 to `setupInputsToken` — everything `start.SetupDatabase` consumes. */ +/** 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; @@ -581,8 +581,6 @@ const exportViaShadowCatalog = ( const seam = yield* LegacyDeclarativeSeam; const shadow = yield* seam.provisionShadow({ mode: "diff", - targetLocal: false, - usePgDelta: false, schema: [], ...(provisionParams.projectRef !== undefined ? { projectRef: provisionParams.projectRef } diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts index b1f28744aa..4e177f5682 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts @@ -176,6 +176,39 @@ describe("legacyDiffPgDelta", () => { 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", () => { diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.ts index d181e4deb0..71872ccf74 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.ts @@ -21,6 +21,7 @@ import { LegacyDeclarativeParseOutputError, LegacyPgDeltaDiffParseError, } from "../commands/db/shared/legacy-pgdelta.errors.ts"; +import type { LegacyPgDeltaTransactionMode } from "../commands/db/shared/legacy-pgdelta-engine.service.ts"; const PG_DELTA_NPM_REGISTRY_ENV = "PGDELTA_NPM_REGISTRY"; @@ -47,14 +48,18 @@ export interface LegacyDeclarativeOutput { interface LegacyPgDeltaPlanFile { readonly order: number; readonly name: string; - readonly transactionMode: string; + readonly transactionMode: LegacyPgDeltaTransactionMode; readonly sql: string; } /** The pg-delta diff envelope. Mirrors Go's `PgDeltaDiffOutput`. */ interface LegacyPgDeltaDiffOutput { readonly version: number; - readonly files: ReadonlyArray; + readonly files: ReadonlyArray< + Omit & { + readonly transactionMode: string; + } + >; } /** @@ -69,7 +74,7 @@ interface LegacyPgDeltaDiffResult { } /** - * Ambient inputs shared by every pg-delta invocation: the project id (for the + * 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). */ @@ -230,7 +235,19 @@ export const legacyDiffPgDelta = Effect.fnUntraced(function* ( }:\n${result.stderr}`, }), }); - const files = envelope.files ?? []; + 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"); diff --git a/apps/cli/src/shared/init/project-init.templates.ts b/apps/cli/src/shared/init/project-init.templates.ts index 2c6a823579..7acc2f30a0 100644 --- a/apps/cli/src/shared/init/project-init.templates.ts +++ b/apps/cli/src/shared/init/project-init.templates.ts @@ -412,6 +412,7 @@ enabled = true # declarative_schema_path = "./database" # JSON string passed through to pg-delta SQL formatting. # format_options = "{\\"keywordCase\\":\\"upper\\",\\"indent\\":2,\\"maxWidth\\":80,\\"commaStyle\\":\\"trailing\\"}" +# Set to "null" to disable formatting while retaining plan compaction. `; export const INIT_GITIGNORE_TEMPLATE = `# Supabase diff --git a/apps/cli/tests/fixtures/compiled-libpg-query.ts b/apps/cli/tests/fixtures/compiled-libpg-query.ts new file mode 100644 index 0000000000..f367f9e377 --- /dev/null +++ b/apps/cli/tests/fixtures/compiled-libpg-query.ts @@ -0,0 +1,21 @@ +import { validateSqlSyntax } from "@supabase/pg-topo"; +import "@supabase/pg-delta/core"; + +const embeddedParser = Bun.embeddedFiles.find((file) => file.type === "application/wasm"); + +if (!embeddedParser) { + throw new Error("libpg-query.wasm was not embedded in the executable"); +} + +const wasmBytes = new Uint8Array(await embeddedParser.arrayBuffer()); +if ( + wasmBytes[0] !== 0x00 || + wasmBytes[1] !== 0x61 || + wasmBytes[2] !== 0x73 || + wasmBytes[3] !== 0x6d +) { + throw new Error("the embedded libpg-query asset is not WebAssembly"); +} + +await validateSqlSyntax("select 1"); +console.log("libpg-query.wasm loaded"); diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 89d8092ba3..327cceacb3 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; import { describe } from "vitest"; import { runSupabase } from "./cli.ts"; @@ -39,6 +40,23 @@ export { */ export const describeLive = describe.skipIf(!isLiveConfigured()); +function hasDockerDaemon(): boolean { + try { + execSync("docker info", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * `describe` for local-stack live tests that only require a real Docker daemon. + * Unlike `describeLive`, this gate does not require platform credentials or a + * Management API. The synchronous `docker info` probe is read-only and runs once + * when this helper module is collected. + */ +export const describeDockerLive = describe.skipIf(!hasDockerDaemon()); + /** * `describe` for project-scoped live suites: runs only when the live env is * configured AND a project ref is available. On a control-plane-only stack diff --git a/packages/api/src/effect.ts b/packages/api/src/effect.ts index 0cb0a4d4fe..64f1459292 100644 --- a/packages/api/src/effect.ts +++ b/packages/api/src/effect.ts @@ -12,7 +12,11 @@ import { } from "./generated/effect-client.ts"; export type { SupabaseApiError, SupabaseApiRetryOptions } from "./internal/client.ts"; -export { SupabaseApiConfigError } from "./internal/client.ts"; +export { + isSupabaseApiResponseSchemaError, + SupabaseApiConfigError, + SupabaseApiResponseSchemaError, +} from "./internal/client.ts"; export type { SupabaseApiClientOptions, SupabaseApiConfig } from "./internal/client.ts"; export { apiConfigLayer, DEFAULT_SUPABASE_API_URL } from "./config/api-config.layer.ts"; export { ApiConfig } from "./config/api-config.service.ts"; diff --git a/packages/api/src/internal/client.ts b/packages/api/src/internal/client.ts index 24dc02ed8b..74b71e83e2 100644 --- a/packages/api/src/internal/client.ts +++ b/packages/api/src/internal/client.ts @@ -47,7 +47,27 @@ export interface SupabaseApiClientOptions { export type SupabaseApiError = | HttpBody.HttpBodyError | HttpClientError.HttpClientError - | SchemaError; + | SchemaError + | SupabaseApiResponseSchemaError; + +/** A successful HTTP response whose JSON body violates the generated output schema. */ +export class SupabaseApiResponseSchemaError extends Error { + readonly _tag = "SupabaseApiResponseSchemaError"; + + constructor( + readonly operationId: OperationId, + override readonly cause: unknown, + ) { + super(`Response schema validation failed for ${operationId}: ${String(cause)}`); + this.name = "SupabaseApiResponseSchemaError"; + } +} + +export function isSupabaseApiResponseSchemaError( + cause: unknown, +): cause is SupabaseApiResponseSchemaError { + return cause instanceof SupabaseApiResponseSchemaError; +} export interface SupabaseApiClientShape { readonly execute: ( @@ -479,7 +499,13 @@ function decodeJsonResponse( definition: OperationDefinition, response: HttpClientResponse.HttpClientResponse, ): Effect.Effect, SupabaseApiError> { - return HttpClientResponse.schemaBodyJson(definition.outputSchema)(response); + return HttpClientResponse.schemaBodyJson(definition.outputSchema)(response).pipe( + Effect.mapError((cause) => + Schema.isSchemaError(cause) + ? new SupabaseApiResponseSchemaError(definition.id, cause) + : cause, + ), + ); } function decodeTextResponse( diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts index 9887879346..c61652bcb8 100644 --- a/packages/api/src/internal/client.unit.test.ts +++ b/packages/api/src/internal/client.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -8,7 +8,7 @@ import * as UrlParams from "effect/unstable/http/UrlParams"; import * as Schema from "effect/Schema"; import { operationDefinitions } from "../generated/contracts.ts"; -import { makeSupabaseApiClient } from "./client.ts"; +import { makeSupabaseApiClient, SupabaseApiResponseSchemaError } from "./client.ts"; const textDecoder = new TextDecoder(); @@ -155,6 +155,78 @@ const config = { } as const; describe("makeSupabaseApiClient", () => { + test.each(["2026-08-07T10:11:12Z", "2026-08-07T10:11:12+00:00", "2026-08-07T12:41:12+02:30"])( + "accepts RFC3339 response timestamp %s", + async (createdAt) => { + const result = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: createdAt, + status: "ACTIVE_HEALTHY", + }), + ), + ), + ), + ), + ); + + expect(result.created_at).toBe(createdAt); + }, + ); + + test("wraps output schema failures separately from input and transport errors", async () => { + const exit = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + created_at: "malformed", + }), + ), + ), + ), + Effect.exit, + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = exit.cause.reasons[0]; + expect(failure !== undefined && Cause.isFailReason(failure)).toBe(true); + if (failure !== undefined && Cause.isFailReason(failure)) { + expect(failure.error).toBeInstanceOf(SupabaseApiResponseSchemaError); + if (failure.error instanceof SupabaseApiResponseSchemaError) { + expect(failure.error.operationId).toBe("v1CreateAProject"); + } + } + } + }); test("retries transport errors for POST requests", async () => { let attempts = 0; diff --git a/patches/@libpg-query__parser@17.6.10.patch b/patches/@libpg-query__parser@17.6.10.patch new file mode 100644 index 0000000000..191d73ed04 --- /dev/null +++ b/patches/@libpg-query__parser@17.6.10.patch @@ -0,0 +1,17 @@ +diff --git a/wasm/index.js b/wasm/index.js +index 00caf4f1591549e445b97c5deeed95a9d8dabd8b..ce4a88226d12687644ca76805e08d11a6696b00e 100644 +--- a/wasm/index.js ++++ b/wasm/index.js +@@ -65,10 +65,11 @@ export function formatSqlError(error, query, options = {}) { + } + // @ts-ignore + import PgQueryModule from './libpg-query.js'; ++import libPgQueryWasmPath from './libpg-query.wasm' with { type: 'file' }; + // @ts-ignore + import { pg_query } from '../proto.js'; + let wasmModule; +-const initPromise = PgQueryModule().then((module) => { ++const initPromise = PgQueryModule({ locateFile: () => libPgQueryWasmPath }).then((module) => { + wasmModule = module; + }); + function ensureLoaded() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4624e456dd..a593864a86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,10 @@ catalogs: overrides: '@effect/platform-node-shared': 4.0.0-beta.103 + '@launchql/protobufjs>@types/node': 24.10.4 + +patchedDependencies: + '@libpg-query/parser@17.6.10': ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3 importers: @@ -150,6 +154,12 @@ importers: '@supabase/config': specifier: workspace:* version: link:../../packages/config + '@supabase/pg-delta': + specifier: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@2f1d6b677bb44485f0a6874caf288f2c77896f86 + version: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@2f1d6b677bb44485f0a6874caf288f2c77896f86(@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@2f1d6b677bb44485f0a6874caf288f2c77896f86) + '@supabase/pg-topo': + specifier: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@2f1d6b677bb44485f0a6874caf288f2c77896f86 + version: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@2f1d6b677bb44485f0a6874caf288f2c77896f86 '@supabase/process-compose': specifier: workspace:* version: link:../../packages/process-compose @@ -712,10 +722,18 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -1192,6 +1210,13 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@launchql/protobufjs@7.2.6': + resolution: {integrity: sha512-vwi1nG2/heVFsIMHQU1KxTjUp5c757CTtRAZn/jutApCkFlle1iv8tzM/DHlSZJKDldxaYqnNYTg0pTyp8Bbtg==} + engines: {node: '>=12.0.0'} + + '@libpg-query/parser@17.6.10': + resolution: {integrity: sha512-AT/IM9H24/u70HvBhzkYlSBlYQWhJK3Z4CTmTnd3PnMqHU7Ib3o5pk2TEik6IblWsU64D+4GGURn94v2iSRe1A==} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -2208,6 +2233,15 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} + '@pgsql/quotes@17.1.0': + resolution: {integrity: sha512-J/H+LcrENBpYgL45WW6aTjb5Yk4tX4+AmB2/k8KZa+Zh3wiCtqmNIag+HZz5HmWaF6EZK9ZGC95NBD1fs+rUvg==} + + '@pgsql/traverse@17.2.6': + resolution: {integrity: sha512-BLOE9DUcvd3y3Ogf56mmpTONPylnMuFCo9PvHQA9SXavcRPhRtvIZ/sRO2ja+bUWK/3KTLJ1Hb61CbbdPkcHoA==} + + '@pgsql/types@17.6.2': + resolution: {integrity: sha512-1UtbELdbqNdyOShhrVfSz3a1gDi0s9XXiQemx+6QqtsrXe62a6zOGU+vjb2GRfG5jeEokI1zBBcfD42enRv0Rw==} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2229,6 +2263,36 @@ packages: '@posthog/types@1.398.0': resolution: {integrity: sha512-sJMkl4k+u8yS/0fjHsKqE9xTdsAh30a2WvgChiptellnVoE0e8QJKFgqOMD2sk8FaEArPdeFklAhXvmENAt3Sg==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@radix-ui/number@1.1.3': resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} @@ -2775,6 +2839,21 @@ packages: resolution: {integrity: sha512-RW/OCsd6MO592zU8ifzP8/f8XzxxIdpb+Up5XaOtE26Fw+3zTp475WX7+GuuktiD1WF8pFUDe6khUPbMp77RCw==} engines: {node: '>=22.0.0'} + '@supabase/pg-delta@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@2f1d6b677bb44485f0a6874caf288f2c77896f86': + resolution: {integrity: sha512-jEEZhv8uh2vFPIoMeRd4GK1qiTNZh+LiIpY1bjd+oFVlacnb+UIrjS85JdDWStniRT03+NPOUkpjOtGiTspsIg==, tarball: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@2f1d6b677bb44485f0a6874caf288f2c77896f86} + version: 1.0.0-alpha.33 + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + '@supabase/pg-topo': ^1.0.0-alpha.3 + peerDependenciesMeta: + '@supabase/pg-topo': + optional: true + + '@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@2f1d6b677bb44485f0a6874caf288f2c77896f86': + resolution: {integrity: sha512-HoqxATDYB2WygDOV1XQBN/hM/hcfvVUrc7F+R691j6CD89cHumR/nRiRJ+0bh9siZb7Fx81Bp9BAPRfzEkEydA==, tarball: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@2f1d6b677bb44485f0a6874caf288f2c77896f86} + version: 1.0.0-alpha.5 + '@supabase/phoenix@0.4.5': resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} @@ -2948,6 +3027,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.10.4': + resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} + '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} @@ -3602,6 +3684,10 @@ packages: caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + case@1.6.3: + resolution: {integrity: sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ==} + engines: {node: '>= 0.8.0'} + caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} @@ -3899,6 +3985,10 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -5070,6 +5160,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -5444,6 +5537,9 @@ packages: nerf-dart@1.0.0: resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} + nested-obj@0.2.2: + resolution: {integrity: sha512-M1etu+T6Ai9Bo06L3K3nWD0ytZWltggBGsrxJlOGvMNGlCA4fokUVlbPKoWzsiiRX+PXq6Cb1xFEn4chiyC7MQ==} + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -5839,6 +5935,9 @@ packages: peerDependencies: pg: '>=8.0' + pg-proto-parser@1.30.6: + resolution: {integrity: sha512-2XwPyl9oz5Pest4ebaovRTTJN8MXaa/XvqMQzKq127fFcl4I1POUgV/FtzHzg/p8FjtO5yHsipeW/kAumzNxxw==} + pg-protocol@1.15.0: resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} @@ -5862,6 +5961,9 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + pgsql-deparser@17.18.5: + resolution: {integrity: sha512-C23etz+aWjp5d09SQwrByisCIV0Zy1dPI0IdBPBaRiMRrDQ2MH8O9txvqpxPyWiXEGRU+MMvZqk48UHxWWbODg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5906,6 +6008,12 @@ packages: resolution: {integrity: sha512-IkmRFE+Xk2xsT2Jikwd40eY2E9yRplA+0OHqeRBZql2Y1b/SY9XUK9wtEMrL9ZzdlYLKma5vNJPkCNx91ov+zg==} hasBin: true + plpgsql-deparser@0.7.13: + resolution: {integrity: sha512-vigoLMQL4NdMx4FjP6Q1IEIiThL+mt483ETFtcBoFJJOMxLg8h29k/NMi79XMahqDu3QvQiQnQ8JNK4hPC74Tw==} + + plpgsql-parser@0.5.16: + resolution: {integrity: sha512-zMHt7xLNW//88KzoKSDyhbDvQeEISzllZKYLl5VcpUlKy/v/EA2SnRkBQz2L6Rv+cOMNv/mzckOpyBUdUMjPdA==} + postcss@8.5.10: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} @@ -6471,6 +6579,9 @@ packages: streamx@2.28.0: resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + strfy-js@3.2.2: + resolution: {integrity: sha512-hUgJ5k2PR1ivhq4uObxnin5j6GcOr0Y0N1lzi3z6SRhxNqu4rzpDfyoC2ToUAyM8yXNXM0zs6f4KIiqj8NqheQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -6740,6 +6851,9 @@ packages: resolution: {integrity: sha512-60m9IVGbavD6jholbxt0jVBXZkEB/HsMZq7Tyaghseve2/Sf0zQRAIfWsD34sde+DKP2tBxJS2wP88ZM0D1FhA==} engines: {node: '>=14'} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -7276,6 +7390,18 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -7288,6 +7414,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -7669,6 +7800,26 @@ snapshots: '@keyv/serialize@1.1.1': {} + '@launchql/protobufjs@7.2.6': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 24.10.4 + long: 5.3.2 + + '@libpg-query/parser@17.6.10(patch_hash=ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3)': + dependencies: + '@launchql/protobufjs': 7.2.6 + '@pgsql/types': 17.6.2 + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 @@ -8352,6 +8503,17 @@ snapshots: '@parcel/watcher-win32-arm64': 2.6.0 '@parcel/watcher-win32-x64': 2.6.0 + '@pgsql/quotes@17.1.0': {} + + '@pgsql/traverse@17.2.6': + dependencies: + '@pgsql/types': 17.6.2 + pg-proto-parser: 1.30.6 + transitivePeerDependencies: + - supports-color + + '@pgsql/types@17.6.2': {} + '@pinojs/redact@0.4.0': {} '@pnpm/config.env-replace@1.1.0': {} @@ -8372,6 +8534,28 @@ snapshots: '@posthog/types@1.398.0': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@radix-ui/number@1.1.3': {} '@radix-ui/primitive@1.1.7': {} @@ -8909,6 +9093,24 @@ snapshots: dependencies: tslib: 2.8.1 + '@supabase/pg-delta@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@2f1d6b677bb44485f0a6874caf288f2c77896f86(@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@2f1d6b677bb44485f0a6874caf288f2c77896f86)': + dependencies: + debug: 4.4.3(supports-color@7.2.0) + pg: 8.22.0 + pg-connection-string: 2.14.0 + optionalDependencies: + '@supabase/pg-topo': https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@2f1d6b677bb44485f0a6874caf288f2c77896f86 + transitivePeerDependencies: + - pg-native + - supports-color + + '@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@2f1d6b677bb44485f0a6874caf288f2c77896f86': + dependencies: + '@pgsql/traverse': 17.2.6 + plpgsql-parser: 0.5.16 + transitivePeerDependencies: + - supports-color + '@supabase/phoenix@0.4.5': {} '@supabase/postgrest-js@2.111.0': @@ -9070,6 +9272,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@24.10.4': + dependencies: + undici-types: 7.16.0 + '@types/node@26.1.1': dependencies: undici-types: 8.3.0 @@ -9721,6 +9927,8 @@ snapshots: caniuse-lite@1.0.30001806: {} + case@1.6.3: {} + caseless@0.12.0: {} ccount@2.0.1: {} @@ -9983,6 +10191,8 @@ snapshots: deep-extend@0.6.0: {} + deepmerge@4.3.1: {} + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -11319,6 +11529,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + longest-streak@3.1.0: {} lowdb@1.0.0: @@ -11920,6 +12132,8 @@ snapshots: nerf-dart@1.0.0: {} + nested-obj@0.2.2: {} + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -12410,6 +12624,20 @@ snapshots: dependencies: pg: 8.22.0 + pg-proto-parser@1.30.6: + dependencies: + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@launchql/protobufjs': 7.2.6 + case: 1.6.3 + deepmerge: 4.3.1 + nested-obj: 0.2.2 + strfy-js: 3.2.2 + transitivePeerDependencies: + - supports-color + pg-protocol@1.15.0: {} pg-types@2.2.0: @@ -12444,6 +12672,11 @@ snapshots: dependencies: split2: 4.2.0 + pgsql-deparser@17.18.5: + dependencies: + '@pgsql/quotes': 17.1.0 + '@pgsql/types': 17.6.2 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -12488,6 +12721,21 @@ snapshots: pkg-pr-new@0.0.82: {} + plpgsql-deparser@0.7.13: + dependencies: + '@pgsql/types': 17.6.2 + pgsql-deparser: 17.18.5 + + plpgsql-parser@0.5.16: + dependencies: + '@libpg-query/parser': 17.6.10(patch_hash=ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3) + '@pgsql/traverse': 17.2.6 + '@pgsql/types': 17.6.2 + pgsql-deparser: 17.18.5 + plpgsql-deparser: 0.7.13 + transitivePeerDependencies: + - supports-color + postcss@8.5.10: dependencies: nanoid: 3.3.16 @@ -13204,6 +13452,10 @@ snapshots: - bare-abort-controller - react-native-b4a + strfy-js@3.2.2: + dependencies: + minimatch: 10.2.5 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -13467,6 +13719,8 @@ snapshots: unbash@4.0.4: {} + undici-types@7.16.0: {} + undici-types@8.3.0: {} undici@6.28.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9a8ff42897..1a003a6606 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: allowBuilds: '@parcel/watcher': true + '@launchql/protobufjs': true "@swc/core": true esbuild: true msgpackr-extract: true @@ -37,6 +38,8 @@ blockExoticSubdeps: true overrides: "@effect/platform-node-shared": "4.0.0-beta.103" + # pg-topo's parser chain otherwise resolves bleeding-edge Node globals that conflict with Bun's web types. + "@launchql/protobufjs>@types/node": "24.10.4" minimumReleaseAge: 10200 minimumReleaseAgeExclude: @@ -46,6 +49,8 @@ minimumReleaseAgeExclude: - "@effect/platform-node-shared@4.0.0-beta.103" - "@effect/sql-pg@4.0.0-beta.103" - "@effect/vitest@4.0.0-beta.103" + - "@supabase/pg-delta@1.0.0-alpha.33" + - "@supabase/pg-topo@1.0.0-alpha.5" - "effect@4.0.0-beta.103" supportedArchitectures: @@ -62,3 +67,6 @@ supportedArchitectures: - darwin - linux - win32 + +patchedDependencies: + '@libpg-query/parser@17.6.10': patches/@libpg-query__parser@17.6.10.patch