From 1baf8ea1066352a6b3d4497a1087fef1e25fcc95 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 6 Aug 2026 09:04:30 +0200 Subject: [PATCH 1/7] feat(cli): bundle pg-delta next engine --- apps/cli-go/CONTRIBUTING.md | 56 +- apps/cli-go/cmd/db.go | 12 +- apps/cli-go/docs/supabase/db/diff.md | 4 + apps/cli-go/docs/supabase/db/pull.md | 15 +- .../db/schema-declarative-generate.md | 4 + .../supabase/db/schema-declarative-sync.md | 4 + apps/cli-go/internal/db/diff/shadow.go | 85 +++ apps/cli-go/internal/db/diff/shadow_test.go | 116 ++++ apps/cli/docs/go-cli-porting-status.md | 14 +- apps/cli/package.json | 2 + .../legacy/commands/bootstrap/SIDE_EFFECTS.md | 36 +- .../legacy/commands/db/diff/SIDE_EFFECTS.md | 64 +- .../legacy/commands/db/diff/diff.handler.ts | 212 ++++-- .../commands/db/diff/diff.integration.test.ts | 278 ++++++-- .../legacy/commands/db/diff/diff.layers.ts | 19 +- .../legacy/commands/db/pull/SIDE_EFFECTS.md | 45 +- .../legacy/commands/db/pull/pull.handler.ts | 228 ++++--- .../commands/db/pull/pull.integration.test.ts | 190 +++++- .../legacy/commands/db/pull/pull.layers.ts | 18 +- .../legacy/commands/db/push/SIDE_EFFECTS.md | 65 +- .../commands/db/push/push.integration.test.ts | 48 +- ...eclarative.orchestrate.integration.test.ts | 134 +++- .../declarative/declarative.orchestrate.ts | 102 ++- .../declarative/declarative.smart-target.ts | 64 +- .../declarative/generate/SIDE_EFFECTS.md | 66 +- .../declarative/generate/generate.handler.ts | 28 +- .../generate/generate.integration.test.ts | 52 +- .../declarative/generate/generate.layers.ts | 20 +- .../schema/declarative/sync/SIDE_EFFECTS.md | 68 +- .../schema/declarative/sync/sync.handler.ts | 63 +- .../declarative/sync/sync.integration.test.ts | 78 ++- .../db/schema/declarative/sync/sync.layers.ts | 13 + .../db/shared/legacy-pgdelta-engine.layer.ts | 67 ++ .../legacy-pgdelta-engine.layer.unit.test.ts | 196 ++++++ .../legacy-pgdelta-engine.legacy.layer.ts | 183 ++++++ .../legacy-pgdelta-engine.next.layer.ts | 368 +++++++++++ .../legacy-pgdelta-engine.next.unit.test.ts | 44 ++ .../shared/legacy-pgdelta-engine.service.ts | 135 ++++ .../db/shared/legacy-pgdelta-files.ts | 168 +++++ .../shared/legacy-pgdelta-migrations.write.ts | 12 +- .../legacy-pgdelta-next-adapter.layer.ts | 615 ++++++++++++++++++ .../legacy-pgdelta-next-adapter.service.ts | 189 ++++++ .../legacy-pgdelta-next-adapter.unit.test.ts | 520 +++++++++++++++ .../shared/legacy-pgdelta-next-artifacts.ts | 81 +++ ...legacy-pgdelta-next-artifacts.unit.test.ts | 74 +++ .../shared/legacy-pgdelta-next-diagnostics.ts | 30 + ...gacy-pgdelta-next-diagnostics.unit.test.ts | 58 ++ .../db/shared/legacy-pgdelta-next-flag.ts | 22 + .../legacy-pgdelta-next-flag.unit.test.ts | 23 + .../legacy-pgdelta-next-shadow.layer.ts | 54 ++ .../legacy-pgdelta-next-shadow.service.ts | 32 + .../legacy-pgdelta-next-shadow.unit.test.ts | 154 +++++ .../shared/legacy-pgdelta-next.live.test.ts | 448 +++++++++++++ .../db/shared/legacy-pgdelta.seam.layer.ts | 6 +- .../db/shared/legacy-pgdelta.seam.service.ts | 13 +- .../commands/db/shared/legacy-pgdelta.ts | 2 +- .../db/shared/legacy-pgdelta.write.ts | 33 +- .../shared/legacy-pgdelta.write.unit.test.ts | 32 +- .../shared/legacy-db-config.toml-read.ts | 34 +- ...y-db-connection.sql-pg.integration.test.ts | 39 +- .../legacy-db-connection.sql-pg.layer.ts | 117 ++-- .../src/legacy/shared/legacy-db-push-core.ts | 9 +- apps/cli/src/legacy/shared/legacy-seed-ops.ts | 3 + apps/cli/tests/helpers/live.ts | 18 + pnpm-lock.yaml | 251 +++++++ pnpm-workspace.yaml | 2 + 66 files changed, 5696 insertions(+), 539 deletions(-) create mode 100644 apps/cli-go/internal/db/diff/shadow_test.go create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-flag.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-flag.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.service.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts 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 66df4311cc..fedb35b30c 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -247,6 +247,16 @@ 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 + } + fmt.Println(nextShadow.Container) + fmt.Println(utils.ToPostgresURLWithoutPassword(nextShadow.Migrated)) + fmt.Println(utils.ToPostgresURLWithoutPassword(nextShadow.Scratch)) + return nil + } var src diff.ShadowSource var err error switch shadowMode { @@ -680,7 +690,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.StringVar(&shadowMode, "mode", "diff", "Shadow mode: diff (baseline + migrations), declarative (bare shadow), or pgdelta-next (migrated + empty scratch).") shadowFlags.BoolVar(&shadowTargetLocal, "target-local", false, "Whether the diff target is the local database (enables the declarative-schema branch).") shadowFlags.BoolVar(&shadowUsePgDelta, "use-pg-delta", false, "Whether pg-delta is the active diff engine (selects the declarative-apply path).") shadowFlags.StringSliceVarP(&shadowSchema, "schema", "s", []string{}, "Comma separated list of schema to include.") diff --git a/apps/cli-go/docs/supabase/db/diff.md b/apps/cli-go/docs/supabase/db/diff.md index 0c0cf05a4d..497d371a95 100644 --- a/apps/cli-go/docs/supabase/db/diff.md +++ b/apps/cli-go/docs/supabase/db/diff.md @@ -10,8 +10,12 @@ By default, all schemas in the target database are diffed. Use the `--schema pub 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. +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 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 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: - Changes to publication diff --git a/apps/cli-go/docs/supabase/db/pull.md b/apps/cli-go/docs/supabase/db/pull.md index e10c0679f7..50c01be1ac 100644 --- a/apps/cli-go/docs/supabase/db/pull.md +++ b/apps/cli-go/docs/supabase/db/pull.md @@ -12,6 +12,8 @@ If no entries exist in the migration history table, the default diff engine uses Pass `--diff-engine pg-delta` to keep the migration-file `db pull` workflow while using pg-delta for the shadow diff step. On initial pull, pg-delta replaces `pg_dump` and produces the full migration from the shadow diff alone. Pass `--declarative` to switch to the declarative pg-delta export workflow instead. +Pg-delta 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. @@ -28,7 +30,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 +47,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..d82cae431a 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,8 @@ 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. +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. + +`--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..a6cf5e5729 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,8 @@ 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. +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. + +`--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/diff/shadow.go b/apps/cli-go/internal/db/diff/shadow.go index 2ebd13591f..eba3fb2358 100644 --- a/apps/cli-go/internal/db/diff/shadow.go +++ b/apps/cli-go/internal/db/diff/shadow.go @@ -2,9 +2,11 @@ package diff import ( "context" + "time" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" + "github.com/pkg/errors" "github.com/spf13/afero" "github.com/supabase/cli/internal/db/start" "github.com/supabase/cli/internal/pgdelta" @@ -28,6 +30,89 @@ type ShadowSource struct { TargetOverride *pgconn.Config } +// PgDeltaNextShadow is a provisioned shadow container exposing both database +// states needed by the native pg-delta engine. Migrated contains the platform +// baseline plus local migrations. Scratch is an empty sibling database owned +// by pg-delta's declarative planner while it loads the desired schema files. +type PgDeltaNextShadow struct { + // Container is left running for the caller, which MUST remove it after use. + Container string + Migrated pgconn.Config + Scratch pgconn.Config +} + +type pgDeltaNextShadowDependencies struct { + 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 + createScratch func(context.Context, ...func(*pgx.ConnConfig)) error + remove func(string) +} + +const createPgDeltaNextScratch = "CREATE DATABASE pgdelta_declarative TEMPLATE template0" + +// createPgDeltaNextScratchDatabase creates the empty same-cluster database that +// planSchemaFiles owns. Using template0 guarantees it does not inherit the +// platform baseline or local migrations from postgres. +func createPgDeltaNextScratchDatabase(ctx context.Context, options ...func(*pgx.ConnConfig)) error { + conn, err := ConnectShadowDatabase(ctx, 10*time.Second, options...) + if err != nil { + return err + } + defer conn.Close(context.Background()) + if _, err := conn.Exec(ctx, createPgDeltaNextScratch); err != nil { + return errors.Wrap(err, "failed to create pg-delta declarative scratch database") + } + return nil +} + +// PreparePgDeltaNextShadow provisions the migrated target and an empty live +// sibling database used by the native pg-delta declarative planner. It never +// loads or applies the legacy declarative schemas. On failure, the container 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{ + create: CreateShadowDatabase, + wait: start.WaitForHealthyService, + migrate: MigrateShadowDatabase, + createScratch: createPgDeltaNextScratchDatabase, + remove: utils.DockerRemove, + }, options...) +} + +func preparePgDeltaNextShadow(ctx context.Context, fsys afero.Fs, dependencies pgDeltaNextShadowDependencies, options ...func(*pgx.ConnConfig)) (PgDeltaNextShadow, error) { + shadow, err := dependencies.create(ctx, utils.Config.Db.ShadowPort) + if err != nil { + return PgDeltaNextShadow{}, err + } + ok := false + defer func() { + if !ok { + dependencies.remove(shadow) + } + }() + if err := dependencies.wait(ctx, utils.Config.Db.HealthTimeout, shadow); err != nil { + return PgDeltaNextShadow{}, err + } + if err := dependencies.migrate(ctx, shadow, fsys, options...); err != nil { + return PgDeltaNextShadow{}, err + } + if err := dependencies.createScratch(ctx, options...); err != nil { + return PgDeltaNextShadow{}, err + } + migrated := pgconn.Config{ + Host: utils.Config.Hostname, + Port: utils.Config.Db.ShadowPort, + User: "postgres", + Password: utils.Config.Db.Password, + Database: "postgres", + } + scratch := migrated + scratch.Database = "pgdelta_declarative" + ok = true + return PgDeltaNextShadow{Container: shadow, Migrated: migrated, Scratch: scratch}, nil +} + // 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 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..6cb1d400bd --- /dev/null +++ b/apps/cli-go/internal/db/diff/shadow_test.go @@ -0,0 +1,116 @@ +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.ShadowPort = 6543 + utils.Config.Db.Password = "secret" + utils.Config.Db.HealthTimeout = 7 * time.Second + + var waitedContainer string + var migratedContainer string + var scratchCreated bool + var removedContainer string + dependencies := pgDeltaNextShadowDependencies{ + create: func(_ context.Context, port uint16) (string, error) { + assert.Equal(t, uint16(6543), port) + return "shadow-container", nil + }, + wait: func(_ context.Context, timeout time.Duration, containers ...string) error { + assert.Equal(t, 7*time.Second, timeout) + require.Len(t, containers, 1) + waitedContainer = containers[0] + return nil + }, + migrate: func(_ context.Context, container string, _ afero.Fs, _ ...func(*pgx.ConnConfig)) error { + migratedContainer = container + return nil + }, + createScratch: func(_ context.Context, _ ...func(*pgx.ConnConfig)) error { + scratchCreated = true + return nil + }, + remove: func(container string) { removedContainer = container }, + } + + result, err := preparePgDeltaNextShadow(context.Background(), afero.NewMemMapFs(), dependencies) + + require.NoError(t, err) + assert.Equal(t, "shadow-container", result.Container) + assert.Equal(t, "shadow-container", waitedContainer) + assert.Equal(t, "shadow-container", migratedContainer) + assert.True(t, scratchCreated) + assert.Empty(t, removedContainer) + assert.Equal(t, "shadow-host", result.Migrated.Host) + assert.Equal(t, uint16(6543), result.Migrated.Port) + assert.Equal(t, "postgres", result.Migrated.User) + assert.Equal(t, "secret", result.Migrated.Password) + assert.Equal(t, "postgres", result.Migrated.Database) + assert.Equal(t, result.Migrated.Host, result.Scratch.Host) + assert.Equal(t, result.Migrated.Port, result.Scratch.Port) + assert.Equal(t, result.Migrated.User, result.Scratch.User) + assert.Equal(t, result.Migrated.Password, result.Scratch.Password) + assert.Equal(t, "pgdelta_declarative", result.Scratch.Database) +} + +func TestPreparePgDeltaNextShadowRemovesContainerAfterFailure(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + wantErr := errors.New("migration failed") + var removedContainer string + dependencies := pgDeltaNextShadowDependencies{ + create: func(context.Context, uint16) (string, error) { + return "failed-shadow", nil + }, + wait: func(context.Context, time.Duration, ...string) error { return nil }, + migrate: func(context.Context, string, afero.Fs, ...func(*pgx.ConnConfig)) error { + return wantErr + }, + createScratch: func(context.Context, ...func(*pgx.ConnConfig)) error { return nil }, + remove: func(container string) { removedContainer = container }, + } + + result, err := preparePgDeltaNextShadow(context.Background(), afero.NewMemMapFs(), dependencies) + + assert.ErrorIs(t, err, wantErr) + assert.Empty(t, result.Container) + assert.Equal(t, "failed-shadow", removedContainer) +} + +func TestPreparePgDeltaNextShadowRemovesContainerAfterScratchFailure(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + wantErr := errors.New("scratch creation failed") + var removedContainer string + dependencies := pgDeltaNextShadowDependencies{ + create: func(context.Context, uint16) (string, error) { + return "failed-scratch-shadow", nil + }, + wait: func(context.Context, time.Duration, ...string) error { return nil }, + migrate: func(context.Context, string, afero.Fs, ...func(*pgx.ConnConfig)) error { + return nil + }, + createScratch: func(context.Context, ...func(*pgx.ConnConfig)) error { return wantErr }, + remove: func(container string) { removedContainer = container }, + } + + result, err := preparePgDeltaNextShadow(context.Background(), afero.NewMemMapFs(), dependencies) + + assert.ErrorIs(t, err, wantErr) + assert.Empty(t, result.Container) + assert.Equal(t, "failed-scratch-shadow", removedContainer) +} diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 5f461d0990..9757fa7956 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. | +| `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 a Go-seam-provisioned live shadow (`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 Go. | | `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. | +| `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` | Remote path native (drop user schemas, vault upsert, MigrateAndSeed, `--version`/`--last`, `--sql-paths` seed override). Local path native: running check, recreate + migrate + seed via the hidden Go `db __db-bootstrap` seam, storage-gated bucket seeding (reuses `seed buckets`), git-branch `Finished…` line. Only the niche `--experimental` remote schema-files path still delegates to the Go binary (telemetry-disabled). 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` | Native TS port. Validates config, checks "already running" (prints Go's line), else delegates the container bootstrap (create + health + initial schema/roles/migrations/seed + `_current_branch`) to the hidden Go `db __db-bootstrap --mode start` seam. No status table / `cli_stack_started` (those are `supabase start`). `--from-backup` supported. | | `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). | @@ -298,10 +298,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 | +| `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 | | `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) | @@ -314,8 +314,8 @@ Legend: | `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | | `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | | `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | -| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | -| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | +| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) — bundled in-process pg-delta/pg-topo by default; legacy catalog/edge-runtime opt-out | +| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) — bundled in-process export by default; writes `.pgdelta-export.json`; legacy catalog/edge-runtime opt-out | Flag divergences from the Go reference: diff --git a/apps/cli/package.json b/apps/cli/package.json index 7f80212f3d..10dd167e55 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@951daa9d9b333f5c69c38eb664d8a17847c635eb", + "@supabase/pg-topo": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb", "@supabase/process-compose": "workspace:*", "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", 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/db/diff/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md index a019c95594..620e7e2649 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -2,8 +2,29 @@ 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 refuses to emit a diff when extraction reports an error or a + strict coverage gap (`unmodeled_kind` or `unresolved_security_label`). The error + identifies the diagnostic origin, code, subject, and message; when debug capture + is enabled, the bundle is saved before the refusal. +- SQL text and file segmentation may differ from the legacy renderer. Applicable + output and convergence (a subsequent diff is empty) are the compatibility contract. ## Files Read @@ -14,21 +35,25 @@ the native pg-delta or migra engine (both run inside Docker via edge-runtime). T | `/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) | +| `/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). +- 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. @@ -43,14 +68,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 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 61fb818dbc..98fd04c383 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"; @@ -26,8 +29,23 @@ import { legacyGetMigrationPath, } from "../../../shared/legacy-migration-file.ts"; import { legacyDiffMigra } from "../shared/legacy-migra.ts"; +import { + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseEndpoint, + type LegacyPgDeltaEndpoint, + type LegacyPgDeltaExportManifest, + type LegacyPgDeltaSqlFile, +} from "../shared/legacy-pgdelta-engine.service.ts"; +import { + LegacyLoadPgDeltaSqlFiles, + LegacyLoadPgDeltaSqlPaths, + LegacyReadPgDeltaExportManifest, +} 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"; @@ -85,6 +103,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; @@ -193,17 +212,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(), @@ -217,39 +243,49 @@ 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); + return { + kind: "database", + ref: legacyToPostgresURL(resolved.conn), + connection: resolved.conn, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + } satisfies LegacyPgDeltaDatabaseEndpoint; } case "migrations": - return yield* seam.exportCatalog({ - mode: "migrations", - noCache: false, - // Pass the linked ref only if one resolved earlier in the cascade, - // so the `__catalog` child merges the same remote override Go's - // in-process migrations catalog sees (`explicit.go:88-126`). Absent - // otherwise → base config, matching Go's resolution order. + 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(), }); // Explicit-mode output: `--output` file (Go's `writeOutput`) or stdout // (Go's `fmt.Print`, no trailing newline — pg-delta ends each statement `;\n`). @@ -378,47 +414,93 @@ 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"); + let declarativeFiles: ReadonlyArray | undefined; + let declarativeManifest: LegacyPgDeltaExportManifest | undefined; + if (pgDelta.implementation === "next" && resolved.isLocal) { + if (cfg.migrationSchemaPaths !== undefined && cfg.migrationSchemaPaths.length > 0) { + declarativeFiles = yield* LegacyLoadPgDeltaSqlPaths( + fs, + path, + cliConfig.workdir, + cfg.migrationSchemaPaths, + ); + } else { + const declarativeDirSetting = legacyResolveDeclarativeDir(path, cfg.pgDelta); + const declarativeDir = path.isAbsolute(declarativeDirSetting) + ? declarativeDirSetting + : path.join(cliConfig.workdir, declarativeDirSetting); + const hasDeclarativeDir = cfg.pgDelta.enabled + ? yield* fs.exists(declarativeDir).pipe(Effect.orElseSucceed(() => false)) + : false; + if (hasDeclarativeDir) { + const loaded = yield* LegacyLoadPgDeltaSqlFiles(fs, path, declarativeDir); + if (loaded.length > 0) { + declarativeFiles = loaded; + declarativeManifest = yield* LegacyReadPgDeltaExportManifest( + fs, + path, + declarativeDir, + ); + } + } else { + const schemasDir = path.join(cliConfig.workdir, "supabase", "schemas"); + const hasSchemasDir = yield* fs + .exists(schemasDir) + .pipe(Effect.orElseSucceed(() => false)); + if (hasSchemasDir) { + const loaded = yield* LegacyLoadPgDeltaSqlFiles(fs, path, schemasDir); + if (loaded.length > 0) declarativeFiles = loaded; + } + } + } + } + const result = yield* pgDelta.diffDatabase({ + context: ctx, + target: { + kind: "database", + ref: targetUrl, + connection: resolved.conn, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }, + targetLocal: resolved.isLocal, + schema: flags.schema, + formatOptions, + ...(connType === "linked" && linkedRef !== undefined ? { projectRef: linkedRef } : {}), + ...(declarativeFiles !== undefined ? { declarativeFiles } : {}), + ...(declarativeManifest !== undefined ? { declarativeManifest } : {}), + debug: legacyIsPgDeltaDebugEnabled(), + }); + return { sql: result.sql, files: result.files }; + }) + : yield* Effect.gen(function* () { + const shadow = yield* seam.provisionShadow({ + mode: "diff", + targetLocal: resolved.isLocal, + usePgDelta: false, + 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: shadow.targetUrlOverride ?? 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 @@ -458,7 +540,13 @@ 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, + })), }).pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); for (const unit of writtenUnits) writtenFiles.push(unit.path); } else { 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 f1a531c7a9..a00db0b094 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 @@ -28,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"; @@ -37,9 +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 }>; + // Exact suffixes returned by the next renderer, parallel to `diffFiles`. + readonly diffSuffixes?: ReadonlyArray; + readonly pgDeltaImplementation?: "legacy" | "next"; readonly targetOverride?: string; readonly oom?: boolean; // edge-runtime OOMs; the bash fallback returns `diffSql` readonly delegateStdout?: string; // stdout returned by a captured Go-delegate run @@ -60,14 +67,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { 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"), execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, @@ -85,6 +86,51 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); + 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, + transactional: true, + })) + : sql.length > 0 + ? [{ sequence: 1, name: "schema_changes", sql, transactional: true }] + : []; + 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 +140,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 +199,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry.layer, cache.layer, seam, + pgDeltaEngine, edge, docker, dbConnection, @@ -206,8 +232,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry, provisionCalls, removedContainers, - exportCalls, - exportCatalogCalls, + explicitDiffCalls, + databaseDiffCalls, edgeCalls, resolverCalls, proxyCalls, @@ -267,12 +293,107 @@ describe("legacy db diff", () => { const s = setup(tmp.current, { diffSql: "create table p ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), schema: ["public"] })); - expect(s.provisionCalls).toEqual([{ mode: "diff", targetLocal: true, usePgDelta: true }]); + expect(s.provisionCalls).toEqual([]); + expect(s.databaseDiffCalls).toHaveLength(1); + expect(s.databaseDiffCalls[0]).toMatchObject({ + targetLocal: true, + schema: ["public"], + 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 gives configured schema_paths precedence", () => { + 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]?.declarativeFiles).toEqual([ + { name: "supabase/configured.sql", sql: "create table configured ();\n" }, + ]); + expect(s.databaseDiffCalls[0]?.declarativeManifest).toBeUndefined(); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("next local diff loads the enabled declarative directory and manifest", () => { + const declarativeDir = join(tmp.current, "supabase", "database"); + mkdirSync(declarativeDir, { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[experimental.pgdelta]", "enabled = true", ""].join("\n"), + ); + writeFileSync(join(declarativeDir, "public.sql"), "create table public.t ();\n"); + writeFileSync( + join(declarativeDir, ".pgdelta-export.json"), + JSON.stringify({ formatVersion: 1, redactSecrets: true, scope: "database" }), + ); + 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]?.declarativeFiles).toEqual([ + { name: "public.sql", sql: "create table public.t ();\n" }, + ]); + expect(s.databaseDiffCalls[0]?.declarativeManifest).toEqual({ + redactSecrets: true, + scope: "database", + }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("next local diff falls back to supabase/schemas", () => { + const schemasDir = join(tmp.current, "supabase", "schemas"); + mkdirSync(schemasDir, { recursive: true }); + writeFileSync(join(schemasDir, "fallback.sql"), "create table fallback ();\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]?.declarativeFiles).toEqual([ + { name: "fallback.sql", sql: "create table fallback ();\n" }, + ]); + expect(s.databaseDiffCalls[0]?.declarativeManifest).toBeUndefined(); + }).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 +422,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)); }); @@ -498,6 +618,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`. @@ -590,10 +728,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* () { @@ -652,11 +834,12 @@ describe("legacy db diff", () => { }, ); - it.effect("explicit --from migrations resolves a shadow catalog via the seam", () => { + 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(["migrations"]); + expect(s.explicitDiffCalls[0]?.source).toEqual({ kind: "migrations" }); + expect(s.edgeCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); @@ -672,8 +855,10 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("linked"), to: Option.some("migrations") })); - const migrations = s.exportCatalogCalls.find((c) => c.mode === "migrations"); - expect(migrations?.projectRef).toBe("abcdefghijklmnopqrst"); + expect(s.explicitDiffCalls[0]?.desired).toEqual({ + kind: "migrations", + projectRef: "abcdefghijklmnopqrst", + }); }).pipe(Effect.provide(s.layer)); }, ); @@ -688,8 +873,7 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("linked") })); - const migrations = s.exportCatalogCalls.find((c) => c.mode === "migrations"); - expect(migrations?.projectRef).toBeUndefined(); + expect(s.explicitDiffCalls[0]?.source).toEqual({ kind: "migrations" }); }).pipe(Effect.provide(s.layer)); }); @@ -711,8 +895,10 @@ describe("legacy db diff", () => { linked: Option.some(true), }), ); - const migrations = s.exportCatalogCalls.find((c) => c.mode === "migrations"); - 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..a5c49e8a65 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -10,7 +10,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 +24,36 @@ 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 refuses migration or declarative output when extraction + reports an error or a strict coverage gap (`unmodeled_kind` or + `unresolved_security_label`). The refusal names the diagnostic, and debug + artifacts are saved first 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. + ## 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 +61,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 +95,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 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 c510d15bca..baa9830f33 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -58,14 +58,20 @@ 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 type { LegacyPgDeltaContext } from "../shared/legacy-pgdelta.ts"; import { - type LegacyPgDeltaContext, - legacyDeclarativeExportPgDelta, - legacyDiffPgDelta, - legacyExportCatalogPgDelta, - legacyIsPgDeltaDebugEnabled, -} from "../shared/legacy-pgdelta.ts"; + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseEndpoint, + type LegacyPgDeltaExportManifest, + type LegacyPgDeltaSqlFile, +} from "../shared/legacy-pgdelta-engine.service.ts"; +import { + LegacyLoadPgDeltaSqlFiles, + LegacyLoadPgDeltaSqlPaths, + LegacyReadPgDeltaExportManifest, +} from "../shared/legacy-pgdelta-files.ts"; +import { legacyIsPgDeltaDebugEnabled } from "../shared/legacy-pgdelta.ts"; import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; @@ -160,6 +166,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 +311,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 +346,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 +425,17 @@ 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(), + noCache: false, }), - ).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + ); yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, exported).pipe( Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), ); @@ -472,7 +484,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 +633,87 @@ 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, - }; + yield* output.raw( + diffSchema.length > 0 + ? `Diffing schemas: ${diffSchema.join(",")}\n` + : "Diffing schemas...\n", + "stderr", + ); + let declarativeFiles: ReadonlyArray | undefined; + let declarativeManifest: LegacyPgDeltaExportManifest | undefined; + if (usePgDeltaDiff && pgDeltaEngine.implementation === "next" && resolved.isLocal) { + if (toml.migrationSchemaPaths !== undefined && toml.migrationSchemaPaths.length > 0) { + declarativeFiles = yield* LegacyLoadPgDeltaSqlPaths( + fs, + path, + cliConfig.workdir, + toml.migrationSchemaPaths, + ); + } else { + const declarativeDirSetting = legacyResolveDeclarativeDir(path, toml.pgDelta); + const declarativeDir = path.isAbsolute(declarativeDirSetting) + ? declarativeDirSetting + : path.join(cliConfig.workdir, declarativeDirSetting); + const hasDeclarativeDir = toml.pgDelta.enabled + ? yield* fs.exists(declarativeDir).pipe(Effect.orElseSucceed(() => false)) + : false; + if (hasDeclarativeDir) { + const loaded = yield* LegacyLoadPgDeltaSqlFiles(fs, path, declarativeDir); + if (loaded.length > 0) { + declarativeFiles = loaded; + declarativeManifest = yield* LegacyReadPgDeltaExportManifest( + fs, + path, + declarativeDir, + ); + } + } else { + const schemasDir = path.join(cliConfig.workdir, "supabase", "schemas"); + if (yield* fs.exists(schemasDir).pipe(Effect.orElseSucceed(() => false))) { + const loaded = yield* LegacyLoadPgDeltaSqlFiles(fs, path, schemasDir); + if (loaded.length > 0) declarativeFiles = loaded; } - const sql = yield* legacyDiffMigra(ctx, { + } + } + } + + const diffOutcome = usePgDeltaDiff + ? yield* withPoolerFallback(targetEndpoint, (target) => + pgDeltaEngine.diffDatabase({ + context: ctx, + target, + targetLocal: resolved.isLocal, + schema: diffSchema, + formatOptions, + projectRef: connType === "linked" ? linkedRef : undefined, + debug: legacyIsPgDeltaDebugEnabled(), + ...(declarativeFiles !== undefined ? { declarativeFiles } : {}), + ...(declarativeManifest !== undefined ? { declarativeManifest } : {}), + }), + ) + : yield* Effect.gen(function* () { + const shadow = yield* seam.provisionShadow({ + mode: "diff", + targetLocal: resolved.isLocal, + usePgDelta: false, + schema: diffSchema, + projectRef: connType === "linked" ? linkedRef : undefined, + }); + return yield* legacyDiffMigra(ctx, { source: shadow.sourceUrl, - target: targetRef, + target: shadow.targetUrlOverride ?? 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 +725,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 +754,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 +790,11 @@ 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, + })), }).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 f22dd4e230..292e640e8f 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 @@ -134,6 +140,116 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); + const engineCalls: Array<{ + operation: "diff" | "export"; + targetRef: string; + projectRef?: string; + targetLocal?: 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, + targetLocal: input.targetLocal, + }); + 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"); + } + return { + sequence: index + 1, + name, + sql, + transactional: transactionMode !== "none", + }; + }); + 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, + }); + 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) => { @@ -260,6 +376,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry.layer, cache.layer, seam, + pgDeltaEngine, edge, docker, dbConnection, @@ -303,6 +420,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { execLog, poolerFallbackCalls, dumpCalls, + engineCalls, get edgeRunCount() { return edgeRunCount; }, @@ -366,6 +484,9 @@ 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.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 @@ -536,6 +657,8 @@ describe("legacy db pull", () => { const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true) })); + expect(s.engineCalls[0]?.operation).toBe("export"); + 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`). @@ -550,7 +673,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)); }); @@ -667,7 +800,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)); }); @@ -934,6 +1068,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, { @@ -1471,7 +1639,7 @@ 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)); }); @@ -1598,10 +1766,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)); }); @@ -1624,7 +1792,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, ); @@ -1642,7 +1810,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`, ); @@ -1665,7 +1833,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)); }); @@ -1685,7 +1853,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.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 2b581df3b0..0828abc62a 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 @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -10,6 +10,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 { type LegacyCatalogMode, LegacyDeclarativeSeam, @@ -73,9 +78,67 @@ const ctx = (declarativeDir: string): LegacyDeclarativeRunContext => ({ declarativeDir, schema: [], noCache: false, + debug: false, + dnsResolver: "native", }); +const engineLayer = ( + seam: Layer.Layer, + edge: Layer.Layer, +) => + legacyPgDeltaLegacyEngineLayer.pipe( + Layer.provide(Layer.mergeAll(seam, edge, probe, 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", + }); + }, + }), + ); + return legacyDiffDeclarativeToMigrations({ ...ctx(declDir), debug: true, noCache: true }).pipe( + Effect.tap(() => + 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); + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(Layer.mergeAll(engine, BunServices.layer)), + ); + }); + it.effect("provisions migrations + declarative catalogs via the seam and diffs them", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); const declDir = join(dir, "supabase", "database"); @@ -100,7 +163,15 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, BunServices.layer)), + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + engineLayer(seam.layer, edge.layer), + BunServices.layer, + ), + ), ); }); @@ -123,12 +194,48 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, BunServices.layer)), + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + engineLayer(seam.layer, edge.layer), + BunServices.layer, + ), + ), ); }); }); describe("legacyGenerateDeclarativeOutput", () => { + it.effect("propagates debug and no-cache to the selected engine", () => { + const calls: Array<{ readonly debug: boolean; readonly noCache: 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 }); + return Effect.succeed({ files: [] }); + }, + planDeclarativeSchema: () => Effect.die("planDeclarativeSchema not used"), + }), + ); + return legacyGenerateDeclarativeOutput( + { ...ctx("/proj/supabase/database"), debug: true, noCache: 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 }]))), + Effect.provide(engine), + ); + }); + it.effect("diffs the baseline catalog against the live DB and returns files", () => { const seam = mockSeam({ migrations: "m", @@ -141,14 +248,15 @@ describe("legacyGenerateDeclarativeOutput", () => { files: [{ path: "public.sql", order: 0, statements: 1, sql: "create table a();" }], }; const edge = mockEdge(JSON.stringify(payload)); - return legacyGenerateDeclarativeOutput( - ctx("/proj/supabase/database"), - "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10", - ).pipe( + return legacyGenerateDeclarativeOutput(ctx("/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( @@ -156,7 +264,15 @@ describe("legacyGenerateDeclarativeOutput", () => { ); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, BunServices.layer)), + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + engineLayer(seam.layer, edge.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 954dc76ec6..522c38e7d9 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,101 +1,91 @@ -import { Effect, FileSystem } from "effect"; +import { Effect, FileSystem, Path } from "effect"; +import { legacyFindDropStatements } from "../../../../shared/legacy-sql-split.ts"; +import { + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseEndpoint, + type LegacyPgDeltaRenderedFile, +} from "../../shared/legacy-pgdelta-engine.service.ts"; import { - type LegacyPgDeltaContext, - legacyDeclarativeExportPgDelta, - legacyDiffPgDelta, -} from "../../shared/legacy-pgdelta.ts"; + LegacyLoadPgDeltaSqlFiles, + LegacyReadPgDeltaExportManifest, +} from "../../shared/legacy-pgdelta-files.ts"; +import type { LegacyPgDeltaContext } from "../../shared/legacy-pgdelta.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 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; } -/** - * Computes the diff between local migrations state and the declarative schema. - * Mirrors Go's `DiffDeclarativeToMigrations` (`declarative.go:170`): the - * migrations catalog (source) and declarative catalog (target) are provisioned - * via the Go seam (shadow DB + `SetupDatabase` + migrate / apply), then diffed - * natively with pg-delta. - */ +const declarativeError = (message: string) => new LegacyDeclarativeDiffError({ message }); + export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( run: LegacyDeclarativeRunContext, ) { const fs = yield* FileSystem.FileSystem; - const seam = yield* LegacyDeclarativeSeam; - + const path = yield* Path.Path; + 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* seam.exportCatalog({ mode: "migrations", noCache: run.noCache }); - 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, + files, + noCache: run.noCache, + ...(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), } 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, + noCache: run.noCache, + ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), + target, }); }); 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 2a01c96912..4b6b0b8a7c 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; @@ -125,7 +144,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") { @@ -151,7 +170,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 @@ -194,5 +218,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/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index bd009a4af3..139257200a 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,18 +1,39 @@ # `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 refuses an export when extraction reports an error or a + strict coverage gap (`unmodeled_kind` or `unresolved_security_label`). The + refusal names the diagnostic, and debug artifacts are saved first 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. ## 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 @@ -20,15 +41,17 @@ pg-delta catalog (source) against the target database's catalog (target). | 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) | +| `/supabase/database/.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 | -| `supabase-go db reset --local` | 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 | +| `supabase-go db reset --local` | smart-mode Local choice when reset is confirmed (or `--reset`) | ## Environment Variables @@ -36,8 +59,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 +74,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 +99,10 @@ 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. +- 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.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts index def35f12a7..dae9e1e656 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, @@ -32,9 +38,9 @@ import { legacyWriteDeclarativeSchemas } from "../../../shared/legacy-pgdelta.wr 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")( @@ -46,6 +52,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/ @@ -138,13 +146,15 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec declarativeDir, schema: flags.schema, noCache: flags.noCache, + debug: legacyIsPgDeltaDebugEnabled(), + 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; @@ -158,9 +168,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 { @@ -216,7 +226,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec linkedProjectRef = linkedRef.value; } } - targetUrl = yield* legacyResolveSmartTargetUrl( + target = yield* legacyResolveSmartTargetEndpoint( flags, local, hasMigrations, @@ -229,7 +239,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/ @@ -265,7 +275,7 @@ 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) { + if (!flags.noCache && engine.implementation === "legacy") { 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 b8c4e2733d..44e74e8202 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 @@ -26,6 +26,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, @@ -61,6 +63,7 @@ interface SetupOpts { projectId?: Option.Option; exportFailsForMode?: LegacyCatalogMode; staleLocalImage?: boolean; + engineImplementation?: "legacy" | "next"; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -138,12 +141,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, BunServices.layer)), + ); const layer = Layer.mergeAll( out.layer, telemetry.layer, cache.layer, seam, edge, + engine, resolver, proxy, mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), @@ -155,10 +185,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), Layer.succeed(LegacyDnsResolverFlag, "native"), // 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, BunServices.layer, ); return { @@ -940,4 +967,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 6f2429fe57..5eaade6ef5 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. */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); @@ -42,6 +46,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, @@ -49,6 +62,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 ba4ee2562b..8c98dddee1 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,39 +3,61 @@ 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 refuses to emit a migration when extraction or declarative + loading reports an error or a strict coverage gap (`unmodeled_kind` or + `unresolved_security_label`). The refusal names the diagnostic, and debug + artifacts are saved first when capture is enabled. +- 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. + ## 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 | shadow-DB migrations catalog (Go seam) | -| `/supabase/.temp/pgdelta/*.json` | JSON | catalog cache (read/written by 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: catalog source | +| `/supabase/database/.pgdelta-export.json` | JSON | default-engine export policy, when present | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out only: catalog cache | ## Files Written -| Path | Format | When | -| ------------------------------------------------------ | ------ | ----------------------------- | -| `/supabase/migrations/_.sql` | SQL | when schema changes are found | -| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | catalog cache (Go seam) | +| Path | Format | When | +| ------------------------------------------------------------------ | ------ | ------------------------------------------------- | +| `/supabase/migrations/_[_].sql` | SQL | changes; default engine may emit ordered segments | +| `/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 migrations --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply migrations → catalog | always | -| `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 | always | +| `supabase-go db __shadow` / declarative shadow seam — platform baseline plus migrations and clean declarative target | default engine | +| `supabase-go db schema declarative __catalog` migrations/declarative catalog seams | legacy opt-out only | +| Edge-runtime container running the pg-delta diff Deno script | legacy opt-out only | | `supabase-go db reset --local [--network-id ]` (seam) — only on the failed-apply recovery path; `db reset` is still Go-proxied (`wrapped`), so the reset itself shells out to the bundled binary | 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 | @@ -48,7 +70,7 @@ 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`) | The pg-delta gate and the mutex check are both raised before any side effects run, @@ -62,8 +84,8 @@ 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. @@ -79,6 +101,6 @@ are mutually exclusive. `supabase/.temp/pgdelta/debug/` and, in a TTY, a reset-and-reapply is offered (the reset itself runs the bundled `supabase-go db reset --local`, since `db reset` is still `wrapped`). -- **Architecture:** the shadow-database platform baseline (migrations / declarative - catalogs) is provisioned by the bundled `supabase-go` via the hidden - `db schema declarative __catalog` seam; the diff is native pg-delta. +- **Architecture:** the bundled `supabase-go` provisions the two shadow databases; + the default engine applies declarative SQL and plans/renders the migration + in-process. The opt-out preserves the hidden legacy catalog seams and Deno script. 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 e7f946362b..8f1d21a7d6 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 @@ -27,7 +27,10 @@ import { legacyListLocalMigrations, legacyPgDeltaTempPath, } 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, @@ -92,6 +95,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara const networkId = yield* LegacyNetworkIdFlag; 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 +156,8 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara declarativeDir, schema: flags.schema, noCache: flags.noCache, + debug: legacyIsPgDeltaDebugEnabled(), + dnsResolver, }; const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent(); const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); @@ -225,7 +231,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 +241,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 +257,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 @@ -308,11 +314,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) { @@ -346,7 +369,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)) { @@ -460,10 +483,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; @@ -486,11 +509,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 a5acb0655e..684745a8d3 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 @@ -26,6 +26,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 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"; @@ -59,6 +64,8 @@ interface SetupOpts { projectId?: Option.Option; staleLocalImage?: boolean; exportJson?: string; + engineImplementation?: "legacy" | "next"; + renderedFiles?: ReadonlyArray; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -169,12 +176,46 @@ 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: () => + Effect.succeed({ + changes: nextFiles.length > 0, + sql: opts.diffSql ?? nextFiles.map((file) => file.sql).join("\n"), + files: nextFiles, + sourceRef: "migrations", + targetRef: "declarative", + }), + }), + ) + : legacyPgDeltaLegacyEngineLayer.pipe( + Layer.provide(Layer.mergeAll(seam, edge, sslProbe, 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") }), @@ -189,10 +230,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), Layer.succeed(LegacyDnsResolverFlag, "native"), // 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, BunServices.layer, ); return { @@ -896,4 +934,36 @@ describe("legacy db schema declarative sync integration", () => { ]); }).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;", + transactional: true, + }, + { + sequence: 2, + name: "non_transactional", + suffix: "_2", + sql: "ALTER TYPE mood ADD VALUE 'fine';", + transactional: false, + }, + ], + }); + 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 0eb4fc8592..49929b862b 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 @@ -40,12 +43,22 @@ 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, 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..df5494aa71 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts @@ -0,0 +1,67 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; + +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 "./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 + | 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..56a8fbcea9 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts @@ -0,0 +1,196 @@ +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 { 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"), + execInherit: () => Effect.die("exec not needed"), + ensureLocalDatabaseStarted: () => Effect.die("local start not needed"), + ensureLocalPostgresImageCurrent: () => Effect.die("image check not needed"), + provisionShadow: () => Effect.die("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"), + }), +); + +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, + }) + .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..3505e73c36 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts @@ -0,0 +1,183 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; + +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, +} from "./legacy-pgdelta-engine.service.ts"; +import { + legacyDeclarativeExportPgDelta, + legacyDiffPgDelta, + legacyExportCatalogPgDelta, +} from "./legacy-pgdelta.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: string; + 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, + transactional: file.transactionMode !== "non-transactional", + })), + ...(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 provideRuntime = ( + operation: Effect.Effect< + Success, + Error, + LegacyEdgeRuntimeScript | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path + >, + ) => + operation.pipe( + Effect.provideService(LegacyEdgeRuntimeScript, edgeRuntime), + Effect.provideService(LegacyPgDeltaSslProbe, sslProbe), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + + const endpointRef = (endpoint: LegacyPgDeltaEndpoint, noCache: boolean) => + endpoint.kind === "database" + ? Effect.succeed(endpoint.ref) + : seam.exportCatalog({ + mode: "migrations", + noCache, + ...(endpoint.projectRef !== undefined ? { projectRef: endpoint.projectRef } : {}), + }); + + return LegacyPgDeltaEngine.of({ + implementation: "legacy", + diffExplicit: (input) => + Effect.gen(function* () { + const sourceRef = yield* endpointRef(input.source, false); + const targetRef = yield* endpointRef(input.desired, false); + 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", + targetLocal: input.targetLocal, + usePgDelta: true, + 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: shadow.targetUrlOverride ?? 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* seam.exportCatalog({ + mode: "migrations", + noCache: input.noCache, + }); + 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..3250ab029d --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts @@ -0,0 +1,368 @@ +import { Clock, Effect, FileSystem, Layer, Path } from "effect"; + +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 { + legacyPgDeltaNextBlockingDiagnostic, + legacyPgDeltaNextBlockingDiagnosticMessage, +} from "./legacy-pgdelta-next-diagnostics.ts"; + +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 transactional: boolean; + readonly actionCount: number; + }>; + 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, + transactional: file.transactional, + actionCount: file.actionCount, + })), + ...(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 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 rejectBlockingDiagnostic = ( + operation: LegacyPgDeltaNextOperation, + diagnostics: Parameters[0], + ) => { + const blocking = legacyPgDeltaNextBlockingDiagnostic(diagnostics); + return blocking === undefined + ? Effect.void + : Effect.fail( + new LegacyPgDeltaEngineError({ + message: legacyPgDeltaNextBlockingDiagnosticMessage(operation, blocking), + cause: blocking, + }), + ); + }; + + return LegacyPgDeltaEngine.of({ + implementation: "next", + diffExplicit: (input) => + Effect.scoped( + Effect.gen(function* () { + let shadow: { readonly migrationsUrl: string; readonly scratchUrl: 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, + }); + const debugDirectory = + result.debug !== undefined + ? yield* saveDebugArtifacts(input.context.cwd, "diff", { + ...result.debug, + diagnostics: result.diagnostics, + }) + : undefined; + yield* rejectBlockingDiagnostic("diff", result.diagnostics); + 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); + const scratch = parseLegacyConnectionString(shadow.scratchUrl); + if (migrations === undefined || scratch === 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", + }); + if (input.declarativeFiles !== undefined) { + const scratchPool = yield* legacyAcquirePgPool(scratch, { + isLocal: true, + dnsResolver: "native", + }); + const result = yield* adapter.planDeclarativeSchema({ + targetPool: migrationsPool, + shadowPool: scratchPool, + files: input.declarativeFiles, + allowDrops: true, + debug: input.debug, + reorder: true, + seedAssumedSchemas: true, + schema: input.schema, + ...(input.declarativeManifest !== undefined + ? { manifest: input.declarativeManifest } + : {}), + }); + const debugDirectory = + result.debug !== undefined + ? yield* saveDebugArtifacts(input.context.cwd, "declarativePlan", { + ...result.debug, + diagnostics: result.diagnostics, + }) + : undefined; + yield* rejectBlockingDiagnostic("declarativePlan", result.diagnostics); + return normalizeNextDiff(result, debugDirectory); + } + const desiredPool = yield* acquireDatabase(input.target); + const result = yield* adapter.diff({ + sourcePool: migrationsPool, + desiredPool, + allowDrops: true, + debug: input.debug, + schema: input.schema, + }); + const debugDirectory = + result.debug !== undefined + ? yield* saveDebugArtifacts(input.context.cwd, "diff", { + ...result.debug, + diagnostics: result.diagnostics, + }) + : undefined; + yield* rejectBlockingDiagnostic("diff", result.diagnostics); + 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* rejectBlockingDiagnostic("declarativeExport", result.diagnostics); + 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 scratch = parseLegacyConnectionString(shadow.scratchUrl); + if (migrations === undefined || scratch === 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, scratchPool] = yield* Effect.all( + [ + legacyAcquirePgPool(migrations, { isLocal: true, dnsResolver: "native" }), + legacyAcquirePgPool(scratch, { isLocal: true, dnsResolver: "native" }), + ], + { concurrency: 2 }, + ); + const result = yield* adapter.planDeclarativeSchema({ + targetPool: migrationsPool, + shadowPool: scratchPool, + files: input.files, + allowDrops: true, + debug: input.debug, + reorder: true, + seedAssumedSchemas: true, + schema: input.schema, + ...(input.manifest !== undefined ? { manifest: input.manifest } : {}), + }); + const debugDirectory = + result.debug !== undefined + ? yield* saveDebugArtifacts(input.context.cwd, "declarativePlan", { + ...result.debug, + diagnostics: result.diagnostics, + }) + : undefined; + yield* rejectBlockingDiagnostic("declarativePlan", result.diagnostics); + 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.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..a5163f7263 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts @@ -0,0 +1,135 @@ +import { Context, Data, type Effect } from "effect"; + +import type { + LegacyDbConnectOptions, + LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import type { LegacyPgDeltaContext } from "./legacy-pgdelta.ts"; +import type { LegacyPgDeltaImplementation } from "./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 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 transactional: boolean; + readonly actionCount?: number; +} + +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 debug?: LegacyPgDeltaDebugArtifacts; +} + +interface LegacyPgDeltaCommonInput { + readonly context: LegacyPgDeltaContext; + readonly schema: ReadonlyArray; + readonly formatOptions: string; + readonly projectRef?: string; + readonly debug: boolean; +} + +export interface LegacyPgDeltaExplicitDiffInput extends LegacyPgDeltaCommonInput { + readonly source: LegacyPgDeltaEndpoint; + readonly desired: LegacyPgDeltaEndpoint; +} + +export interface LegacyPgDeltaDatabaseDiffInput extends LegacyPgDeltaCommonInput { + readonly target: LegacyPgDeltaDatabaseEndpoint; + readonly targetLocal: boolean; + /** Present when the local desired state is declarative SQL rather than the live DB. */ + readonly declarativeFiles?: ReadonlyArray; + readonly declarativeManifest?: LegacyPgDeltaExportManifest; +} + +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; +} + +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..d24e0562f4 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts @@ -0,0 +1,168 @@ +import { Data, Effect, type FileSystem, Option, type Path } from "effect"; + +import type { + LegacyPgDeltaExportManifest, + LegacyPgDeltaSqlFile, +} from "./legacy-pgdelta-engine.service.ts"; +import { legacyResolveSqlGlobFiles } from "../../../shared/legacy-seed-ops.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; +}); + +/** Loads `[db.migrations].schema_paths` in configured pattern/application order. */ +export const LegacyLoadPgDeltaSqlPaths = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + patterns: ReadonlyArray, +) { + const resolved = yield* legacyResolveSqlGlobFiles(fs, path, patterns, workdir); + if (resolved.files.length === 0) { + return yield* Effect.fail( + filesError( + Option.isSome(resolved.warning) + ? resolved.warning.value + : "no declarative schema files matched schema_paths", + ), + ); + } + const files: Array = []; + for (const file of resolved.files) { + const full = path.isAbsolute(file) ? file : path.join(workdir, file); + const sql = yield* fs + .readFileString(full) + .pipe( + Effect.mapError((error) => + filesError(`failed to read declarative schema file: ${error.message}`), + ), + ); + files.push({ name: file.split("\\").join("/"), 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..35d9de726c 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 @@ -58,7 +58,11 @@ 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; + }>; }, ): Effect.Effect, LegacyPgDeltaMigrationWriteError> => Effect.gen(function* () { @@ -67,7 +71,11 @@ export const legacyWritePgDeltaMigrations = ( 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..119cca4f9c --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts @@ -0,0 +1,615 @@ +import { Effect, Layer } from "effect"; +import type { Pool } from "pg"; +import { serializeSnapshot, encodeId } from "@supabase/pg-delta/core"; +import { buildSchemaExport, planSchemaFiles, renderPlanFiles } 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 { Policy } from "@supabase/pg-delta/policy"; +import 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"; + +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 encodeSubject: (subject: Subject) => string; +} + +function legacyPgDeltaNextMessage(operation: LegacyPgDeltaNextOperation, cause: unknown): string { + const detail = cause instanceof Error ? cause.message : String(cause); + const label = + operation === "declarativeExport" + ? "Declarative schema export" + : operation === "declarativePlan" + ? "Declarative schema planning" + : operation === "snapshotCapture" + ? "Snapshot capture" + : "Database diff"; + return `${label} failed: ${detail}`; +} + +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, + transactional: file.transactional, + 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 }; +} + +function legacyPgDeltaNextFormatOptions(raw: string | undefined): SqlFormatOptions | undefined { + if (raw === undefined || raw.trim().length === 0) return undefined; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; + 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 { + ...(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 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 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 diagnostics = [ + ...legacyNormalizePgDeltaNextDiagnostics( + source.diagnostics, + "source", + libraries.encodeSubject, + ), + ...legacyNormalizePgDeltaNextDiagnostics( + desired.diagnostics, + "desired", + libraries.encodeSubject, + ), + ]; + return { + changes: rendered.changes, + sql: rendered.files.map((file) => file.contents).join("\n\n"), + files: legacyNormalizePgDeltaNextRenderedFiles(rendered.files), + 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 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, + }); + return { + changes: rendered.changes, + sql: rendered.files.map((file) => file.contents).join("\n\n"), + files: legacyNormalizePgDeltaNextRenderedFiles(rendered.files), + diagnostics: [ + ...legacyNormalizePgDeltaNextDiagnostics( + result.loadDiagnostics, + "declarativeLoad", + libraries.encodeSubject, + ), + ...legacyNormalizePgDeltaNextDiagnostics( + result.targetDiagnostics, + "declarativeTarget", + libraries.encodeSubject, + ), + ], + skipped: result.skipped.map((skipped) => ({ + file: skipped.file, + statement: skipped.stmt, + })), + ...(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, + 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..80b6cbb130 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts @@ -0,0 +1,189 @@ +import type { Pool } from "pg"; +import { Context, Data, type Effect } from "effect"; + +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 transactional: boolean; + 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[]; +} + +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; + /** 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 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..3ad7d9d9a2 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts @@ -0,0 +1,520 @@ +import { it } from "@effect/vitest"; +import { Effect } from "effect"; +import { Pool } from "pg"; +import { describe, expect } from "vitest"; + +import { + legacyPgDeltaNextAdapterLayer, + legacyPgDeltaNextAdapterLayerFromLibraries, + legacyFilterPgDeltaNextPlatformParameterAclDiagnostics, + legacyPgDeltaNextProfile, + legacyPgDeltaNextUserOwnedParameterAcls, + 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 }; + }>, + renderAllowDrops: [] as 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.renderAllowDrops.push(options.allowDrops); + if (!state.renderChanges) return { changes: false, files: [] }; + return { + changes: true, + files: [ + { + suffix: "_1", + contents: `begin ${generatedPlan.source};\n`, + transactional: true, + actionCount: 2, + }, + { + suffix: "_2", + contents: `alter ${generatedPlan.desired};\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); + }, + encodeSubject: (subject) => `subject:${subject.id}`, + }; + + return { + state, + layer: legacyPgDeltaNextAdapterLayerFromLibraries(libraries), + }; +} + +describe("LegacyPgDeltaNextAdapter", () => { + 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"], + }); + + 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(result.files).toEqual([ + { + sequence: 1, + suffix: "_1", + sql: "begin source-facts;\n", + transactional: true, + actionCount: 2, + }, + { + sequence: 2, + suffix: "_2", + sql: "alter desired-facts;\n", + transactional: false, + actionCount: 1, + }, + ]); + expect(result.sql).toBe("begin source-facts;\n\n\nalter desired-facts;\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.renderAllowDrops).toEqual([false]); + 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"); + + const planned = yield* adapter.planDeclarativeSchema({ + targetPool, + shadowPool, + files: exported.files, + allowDrops: true, + debug: true, + isolatedShadow: true, + seedAssumedSchemas: true, + }); + 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.debug).toEqual({ + plan: JSON.stringify({ source: "target-facts", desired: "loaded-files" }), + }); + expect(state.renderAllowDrops).toEqual([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", + 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)); + }); +}); 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..d949442dea --- /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 "./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..3330ed35fd --- /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 "./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..8eef84318d --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.ts @@ -0,0 +1,30 @@ +import type { + LegacyPgDeltaNextDiagnostic, + LegacyPgDeltaNextOperation, +} from "./legacy-pgdelta-next-adapter.service.ts"; + +const coverageDiagnosticCodes = new Set(["unmodeled_kind", "unresolved_security_label"]); + +export function legacyPgDeltaNextBlockingDiagnostic( + diagnostics: readonly LegacyPgDeltaNextDiagnostic[], +): LegacyPgDeltaNextDiagnostic | undefined { + return diagnostics.find( + (diagnostic) => diagnostic.severity === "error" || coverageDiagnosticCodes.has(diagnostic.code), + ); +} + +export function legacyPgDeltaNextBlockingDiagnosticMessage( + operation: LegacyPgDeltaNextOperation, + diagnostic: LegacyPgDeltaNextDiagnostic, +): string { + const action = + operation === "declarativeExport" + ? "export the declarative schema" + : operation === "declarativePlan" + ? "emit the declarative migration plan" + : operation === "snapshotCapture" + ? "capture the database snapshot" + : "emit the database diff"; + const subject = diagnostic.subject ?? "unknown"; + return `pg-delta next refused to ${action}: origin=${diagnostic.origin} code=${diagnostic.code} subject=${subject} message=${diagnostic.message}`; +} 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..f0cfe77a79 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyPgDeltaNextBlockingDiagnostic, + legacyPgDeltaNextBlockingDiagnosticMessage, +} from "./legacy-pgdelta-next-diagnostics.ts"; + +describe("pg-delta next diagnostic coverage policy", () => { + it("blocks errors and strict coverage gaps while allowing ordinary warnings", () => { + expect( + legacyPgDeltaNextBlockingDiagnostic([ + { + origin: "source", + code: "unsupported_extension", + severity: "warning", + message: "extension is managed externally", + }, + ]), + ).toBeUndefined(); + + expect( + legacyPgDeltaNextBlockingDiagnostic([ + { + origin: "desired", + code: "unmodeled_kind", + severity: "warning", + subject: "object:public.unsupported", + message: "object kind is not modeled", + }, + ]), + ).toMatchObject({ code: "unmodeled_kind" }); + + expect( + legacyPgDeltaNextBlockingDiagnostic([ + { + origin: "export", + code: "extraction_failed", + severity: "error", + message: "catalog query failed", + }, + ]), + ).toMatchObject({ code: "extraction_failed" }); + }); + + it("renders the refused action and complete diagnostic identity", () => { + expect( + legacyPgDeltaNextBlockingDiagnosticMessage("declarativePlan", { + origin: "declarativeLoad", + code: "unresolved_security_label", + severity: "info", + subject: "table:public.accounts", + message: "security label provider was not resolved", + }), + ).toBe( + "pg-delta next refused to emit the declarative migration plan: origin=declarativeLoad code=unresolved_security_label subject=table:public.accounts message=security label provider was not resolved", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-flag.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-flag.ts new file mode 100644 index 0000000000..39f4729dfc --- /dev/null +++ b/apps/cli/src/legacy/commands/db/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/commands/db/shared/legacy-pgdelta-next-flag.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-flag.unit.test.ts new file mode 100644 index 0000000000..1768456b35 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/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/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..aaf8ae08c1 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts @@ -0,0 +1,54 @@ +import { Effect, Layer } from "effect"; + +import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; +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 the migrated target and a dedicated empty same-cluster + * scratch database; declarative SQL remains wholly owned by the TypeScript + * pg-delta next adapter and its `planSchemaFiles` operation. + */ +export const legacyPgDeltaNextShadowLayer = Layer.effect( + LegacyPgDeltaNextShadow, + Effect.gen(function* () { + const seam = yield* LegacyDeclarativeSeam; + + return LegacyPgDeltaNextShadow.of({ + provision: ({ schema, projectRef }) => + Effect.gen(function* () { + // Register cleanup immediately after Go returns the container. URL + // validation happens only after acquireRelease has installed the + // finalizer, so even malformed seam output cannot leak the shadow. + const shadow = yield* Effect.acquireRelease( + seam.provisionShadow({ + mode: "pgdelta-next", + targetLocal: false, + usePgDelta: false, + schema, + ...(projectRef !== undefined ? { projectRef } : {}), + }), + ({ container }) => seam.removeShadowContainer(container).pipe(Effect.ignoreCause), + ); + + if (shadow.targetUrlOverride === undefined) { + return yield* Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: + "failed to provision the pg-delta next shadow database: missing declarative scratch URL.", + }), + ); + } + + return { + migrationsUrl: shadow.sourceUrl, + scratchUrl: shadow.targetUrlOverride, + } 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..1c34cc8fc8 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.service.ts @@ -0,0 +1,32 @@ +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; + /** Empty same-cluster database owned by `planSchemaFiles` while loading desired SQL. */ + readonly scratchUrl: string; +} + +interface LegacyPgDeltaNextShadowShape { + /** + * Provisions the next-engine shadow container and owns it for the current + * Effect scope. The container is removed when that scope closes, including + * when URL validation or the caller fails. + */ + 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..ef201d34d3 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.unit.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Layer } from "effect"; + +import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; +import { legacyPgDeltaNextShadowLayer } from "./legacy-pgdelta-next-shadow.layer.ts"; +import { LegacyPgDeltaNextShadow } from "./legacy-pgdelta-next-shadow.service.ts"; +import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; + +class PrimaryFailure extends Data.TaggedError("PrimaryFailure")<{ + readonly message: string; +}> {} + +function setup( + opts: { + readonly sourceUrl?: string; + readonly scratchUrl?: string; + readonly cleanupDefect?: boolean; + } = {}, +) { + const state = { + provisionCalls: [] as object[], + removedContainers: [] as string[], + legacyMethodCalls: [] as string[], + }; + const seamLayer = Layer.succeed( + LegacyDeclarativeSeam, + LegacyDeclarativeSeam.of({ + exportCatalog: () => + Effect.sync(() => { + state.legacyMethodCalls.push("exportCatalog"); + return "catalog.json"; + }), + execInherit: () => + Effect.sync(() => { + state.legacyMethodCalls.push("execInherit"); + return 0; + }), + ensureLocalDatabaseStarted: () => + Effect.sync(() => { + state.legacyMethodCalls.push("ensureLocalDatabaseStarted"); + }), + ensureLocalPostgresImageCurrent: () => + Effect.sync(() => { + state.legacyMethodCalls.push("ensureLocalPostgresImageCurrent"); + }), + provisionShadow: (input) => + Effect.sync(() => { + state.provisionCalls.push(input); + return { + container: "next-shadow-container", + sourceUrl: opts.sourceUrl ?? "postgresql://postgres@localhost:55432/postgres", + targetUrlOverride: opts.scratchUrl, + }; + }), + removeShadowContainer: (container) => + Effect.gen(function* () { + state.removedContainers.push(container); + if (opts.cleanupDefect === true) { + return yield* Effect.die("cleanup failed"); + } + }), + }), + ); + + return { + state, + layer: legacyPgDeltaNextShadowLayer.pipe(Layer.provide(seamLayer)), + }; +} + +describe("LegacyPgDeltaNextShadow", () => { + it.effect("provisions the exact next mode and exposes the migrated and scratch URLs", () => { + const { layer, state } = setup({ + scratchUrl: "postgresql://postgres@localhost:55432/pgdelta_declarative", + }); + + return Effect.gen(function* () { + const databases = yield* Effect.scoped( + Effect.gen(function* () { + const shadow = yield* LegacyPgDeltaNextShadow; + const acquired = yield* shadow.provision({ + schema: ["public", "extensions"], + projectRef: "linked-project", + }); + expect(state.removedContainers).toEqual([]); + return acquired; + }), + ); + + expect(databases).toEqual({ + migrationsUrl: "postgresql://postgres@localhost:55432/postgres", + scratchUrl: "postgresql://postgres@localhost:55432/pgdelta_declarative", + }); + expect(Object.keys(databases)).toEqual(["migrationsUrl", "scratchUrl"]); + expect(state.provisionCalls).toEqual([ + { + mode: "pgdelta-next", + targetLocal: false, + usePgDelta: false, + schema: ["public", "extensions"], + projectRef: "linked-project", + }, + ]); + expect(state.removedContainers).toEqual(["next-shadow-container"]); + expect(state.legacyMethodCalls).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("cleans up when the caller fails and never lets cleanup mask that failure", () => { + const { layer, state } = setup({ + scratchUrl: "postgresql://postgres@localhost:55432/pgdelta_declarative", + cleanupDefect: true, + }); + const primary = new PrimaryFailure({ message: "caller failed" }); + + return Effect.gen(function* () { + const error = yield* Effect.scoped( + Effect.gen(function* () { + const shadow = yield* LegacyPgDeltaNextShadow; + yield* shadow.provision({ schema: [] }); + return yield* Effect.fail(primary); + }), + ).pipe(Effect.flip); + + expect(error).toEqual(primary); + expect(state.removedContainers).toEqual(["next-shadow-container"]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("cleans up and fails when the declarative scratch URL is missing", () => { + const { layer, state } = setup(); + + return Effect.gen(function* () { + const error = yield* Effect.scoped( + Effect.gen(function* () { + const shadow = yield* LegacyPgDeltaNextShadow; + return yield* shadow.provision({ schema: ["public"] }); + }), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyDeclarativeShadowDbError); + expect(error.message).toContain("missing declarative scratch URL"); + expect(state.removedContainers).toEqual(["next-shadow-container"]); + expect(state.provisionCalls).toEqual([ + { + mode: "pgdelta-next", + targetLocal: false, + usePgDelta: false, + schema: ["public"], + }, + ]); + }).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..c936e1a649 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts @@ -0,0 +1,448 @@ +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`; +} + +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); + }, + ); +}); 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 6a52434c89..c6d8c2b9a8 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 @@ -447,8 +447,10 @@ export const legacyDeclarativeSeamLayer = Layer.effect( 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). + // and an optional second-database URL. Legacy diff uses the third URL + // only when its local-target declarative branch redirects the target; + // `pgdelta-next` always returns its empty same-cluster declarative + // scratch database there. That next mode never asks Go to apply SQL. // 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 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 16593f5f75..8fc4958144 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 @@ -11,8 +11,12 @@ export type LegacyCatalogMode = "baseline" | "migrations" | "declarative"; * `db pull` diff source), plus the local-target declarative branch. * - `declarative`: a bare shadow with no baseline/migrations (the `db pull * --declarative` empty export source). + * - `pgdelta-next`: platform baseline + local migrations in `postgres`, plus + * an empty same-cluster `pgdelta_declarative` scratch database. Declarative + * SQL is deliberately not applied by Go in this mode; the TypeScript next + * engine loads it later through `planSchemaFiles`. */ -type LegacyShadowMode = "diff" | "declarative"; +type LegacyShadowMode = "diff" | "declarative" | "pgdelta-next"; /** A live shadow database left running for the caller to diff against and remove. */ export interface LegacyShadowSource { @@ -21,9 +25,10 @@ export interface LegacyShadowSource { /** 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. + * Optional second live database. For legacy diff it replaces the target with + * `contrib_regression` after Go applies declarative schemas. For + * `pgdelta-next` it is the empty declarative scratch database; TypeScript + * loads the declarative files later through `planSchemaFiles`. */ readonly targetUrlOverride: string | undefined; } diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts index 93a4504acf..bf2fb2c9bc 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts @@ -69,7 +69,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). */ 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 53dfc3fd0b..3b49c856dd 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 @@ -2,8 +2,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 { + LegacyPgDeltaDeclarativeExportResult, + LegacyPgDeltaExportManifest, +} from "./legacy-pgdelta-engine.service.ts"; import type { LegacyDeclarativeOutput } from "./legacy-pgdelta.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 * declarative write paths (`generate`, `pull --declarative`, `sync`'s bootstrap). @@ -32,7 +40,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) => @@ -48,18 +56,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 ba2684ca1c..1004f0ba20 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 @@ -8,13 +8,17 @@ import { Cause, Effect, Exit, FileSystem, Path } from "effect"; import { legacyBold } from "../../../shared/legacy-colors.ts"; import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; +import type { LegacyPgDeltaDeclarativeExportResult } from "./legacy-pgdelta-engine.service.ts"; import type { LegacyDeclarativeOutput } from "./legacy-pgdelta.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/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 74582272eb..1ad855de06 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 @@ -98,6 +98,8 @@ export interface LegacyDbTomlValues { readonly baseline: LegacyBaselineTomlConfig; /** `[db.migrations] enabled` (default true) — gates `up`/`down` migration apply. */ readonly migrationsEnabled: boolean; + /** `[db.migrations] schema_paths`, resolved relative to `supabase/`. */ + readonly migrationSchemaPaths?: ReadonlyArray; /** `[db.seed]` enabled + supabase-prefixed `sql_paths` globs — used by `down`. */ readonly seed: LegacyDbSeedTomlConfig; /** `[db.vault]` secrets (name → resolved value) — upserted by `up`/`down`. */ @@ -283,6 +285,7 @@ const LEGACY_ENV_OVERRIDABLE_KEYS: ReadonlyArray = [ "db.shadow_port", "db.major_version", "db.migrations.enabled", + "db.migrations.schema_paths", "db.seed.enabled", "db.seed.sql_paths", "auth.enabled", @@ -536,8 +539,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 @@ -1059,10 +1062,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())), @@ -1815,6 +1819,27 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ? undefined : envOverride("SUPABASE_DB_MIGRATIONS_ENABLED"), ); + const rawMigrationSchemaPaths = migrationsRaw?.["schema_paths"]; + const migrationSchemaPathsOverride = remoteOverrideKeys.has("db.migrations.schema_paths") + ? undefined + : envOverride("SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"); + const splitMigrationSchemaPaths = (value: string): ReadonlyArray => { + const expanded = legacyExpandEnv(value, lookup); + return expanded.length === 0 ? [] : expanded.split(","); + }; + const migrationSchemaPathPatterns = + migrationSchemaPathsOverride !== undefined + ? splitMigrationSchemaPaths(migrationSchemaPathsOverride) + : Array.isArray(rawMigrationSchemaPaths) + ? rawMigrationSchemaPaths + .filter((pattern): pattern is string => typeof pattern === "string") + .map((pattern) => legacyExpandEnv(pattern, lookup)) + : typeof rawMigrationSchemaPaths === "string" + ? splitMigrationSchemaPaths(rawMigrationSchemaPaths) + : []; + const migrationSchemaPaths = migrationSchemaPathPatterns.map((pattern) => + path.isAbsolute(pattern) || pattern.length === 0 ? pattern : path.join("supabase", pattern), + ); // `[db.seed]` — Go defaults enabled true, sql_paths ["seed.sql"]; relative // patterns are supabase-prefixed (`config.go:801-806`). `db.seed.enabled` is @@ -1953,6 +1978,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( vaultNames, }, migrationsEnabled, + migrationSchemaPaths, seed: { enabled: seedEnabled, sqlPaths: seedSqlPaths }, vault, appliedRemote, 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 31b855bdd4..07e2788004 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 "../commands/db/shared/legacy-pgdelta.cache.ts"; import { type LegacyPgDeltaContext } from "../commands/db/shared/legacy-pgdelta.ts"; import { legacyParseBoolEnv } from "../commands/db/shared/legacy-diff-engine.ts"; +import { legacyResolvePgDeltaImplementation } from "../commands/db/shared/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-seed-ops.ts b/apps/cli/src/legacy/shared/legacy-seed-ops.ts index bbea4d4fcc..3419a30df4 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-ops.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-ops.ts @@ -109,6 +109,9 @@ const legacyGlobSeedFiles = Effect.fnUntraced(function* ( } satisfies LegacyGlobResult; }); +/** Shared Go-compatible SQL glob expansion for migration/declarative consumers. */ +export const legacyResolveSqlGlobFiles = legacyGlobSeedFiles; + const toSlash = (p: string): string => p.replaceAll("\\", "/"); /** Splits a forward-slashed path into its directory prefix and final element. */ 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/pnpm-lock.yaml b/pnpm-lock.yaml index b6e5837da1..733db9db68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,7 @@ catalogs: overrides: '@effect/platform-node-shared': 4.0.0-beta.97 + '@launchql/protobufjs>@types/node': 24.10.4 importers: @@ -150,6 +151,12 @@ importers: '@supabase/config': specifier: workspace:* version: link:../../packages/config + '@supabase/pg-delta': + specifier: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@951daa9d9b333f5c69c38eb664d8a17847c635eb + version: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@951daa9d9b333f5c69c38eb664d8a17847c635eb(@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb) + '@supabase/pg-topo': + specifier: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb + version: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb '@supabase/process-compose': specifier: workspace:* version: link:../../packages/process-compose @@ -715,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'} @@ -1186,6 +1201,13 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@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==} @@ -2298,6 +2320,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==} @@ -2319,6 +2350,36 @@ packages: '@posthog/types@1.397.1': resolution: {integrity: sha512-W/LpWbKVaaUnfZKuFuHa+Dg03D+fC87cM+PQbG+59JcSPW8F0JcBtSoXmpfrqbpuxUToMo+gktutrUkAb/KQBw==} + '@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.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -2861,6 +2922,21 @@ packages: resolution: {integrity: sha512-megYmexlYEoR/0qlsr4Snh9wtzAodO7MAri3NMevZrXzNvQRKlvmTcSBoKGLQEPDakgDZMqbMdf9DwoZz6qfoA==} engines: {node: '>=22.0.0'} + '@supabase/pg-delta@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@951daa9d9b333f5c69c38eb664d8a17847c635eb': + resolution: {integrity: sha512-eWhb8JyODx870aSr2xKr3i81yBBnblAqLjdFqW0MGr6pxDzX/PbJkQGx700u/CESycO6HfW8+2MrDQUE3em53w==, tarball: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@951daa9d9b333f5c69c38eb664d8a17847c635eb} + 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@951daa9d9b333f5c69c38eb664d8a17847c635eb': + resolution: {integrity: sha512-HoqxATDYB2WygDOV1XQBN/hM/hcfvVUrc7F+R691j6CD89cHumR/nRiRJ+0bh9siZb7Fx81Bp9BAPRfzEkEydA==, tarball: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb} + version: 1.0.0-alpha.5 + '@supabase/phoenix@0.4.5': resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} @@ -3035,6 +3111,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==} @@ -3691,6 +3770,10 @@ packages: caniuse-lite@1.0.30001805: resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} + case@1.6.3: + resolution: {integrity: sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ==} + engines: {node: '>= 0.8.0'} + caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} @@ -3987,6 +4070,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==} @@ -5172,6 +5259,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==} @@ -5550,6 +5640,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: @@ -5948,6 +6041,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==} @@ -5971,6 +6067,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==} @@ -6015,6 +6114,12 @@ packages: resolution: {integrity: sha512-u9mdErTewKSMsr+ceCt8VcNuNP0ro5AXiPXhUVApuEyqr2Zlvt+DdCFBcm+yGWN8mhOdZJ27meIDbnoZgfzpOw==} 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} @@ -6577,6 +6682,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'} @@ -6842,6 +6950,9 @@ packages: resolution: {integrity: sha512-8gwNZ29+0/3zmXw7ToIHZtg6wK37xnniRUdBt7B27xZxaxfgR5tGMaGHT0t0dLtBV9fXE7zurh0s6Z1DHVjfWg==} 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==} @@ -7383,6 +7494,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 @@ -7395,6 +7518,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 @@ -7771,6 +7899,26 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@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': + dependencies: + '@launchql/protobufjs': 7.2.6 + '@pgsql/types': 17.6.2 + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 @@ -8515,6 +8663,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': {} @@ -8535,6 +8694,28 @@ snapshots: '@posthog/types@1.397.1': {} + '@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.2': {} '@radix-ui/primitive@1.1.6': {} @@ -9070,6 +9251,24 @@ snapshots: dependencies: tslib: 2.8.1 + '@supabase/pg-delta@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@951daa9d9b333f5c69c38eb664d8a17847c635eb(@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb)': + 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@951daa9d9b333f5c69c38eb664d8a17847c635eb + transitivePeerDependencies: + - pg-native + - supports-color + + '@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb': + dependencies: + '@pgsql/traverse': 17.2.6 + plpgsql-parser: 0.5.16 + transitivePeerDependencies: + - supports-color + '@supabase/phoenix@0.4.5': {} '@supabase/postgrest-js@2.110.7': @@ -9233,6 +9432,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 @@ -9888,6 +10091,8 @@ snapshots: caniuse-lite@1.0.30001805: {} + case@1.6.3: {} + caseless@0.12.0: {} ccount@2.0.1: {} @@ -10152,6 +10357,8 @@ snapshots: deep-extend@0.6.0: {} + deepmerge@4.3.1: {} + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -11494,6 +11701,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: @@ -12099,6 +12308,8 @@ snapshots: nerf-dart@1.0.0: {} + nested-obj@0.2.2: {} + next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: react: 19.2.7 @@ -12610,6 +12821,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: @@ -12644,6 +12869,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: {} @@ -12688,6 +12918,21 @@ snapshots: pkg-pr-new@0.0.75: {} + 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 + '@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 @@ -13407,6 +13652,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 @@ -13668,6 +13917,8 @@ snapshots: unbash@4.0.2: {} + undici-types@7.16.0: {} + undici-types@8.3.0: {} undici@6.27.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5cc2f4607d..0318f5d07e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -37,6 +37,8 @@ blockExoticSubdeps: true overrides: "@effect/platform-node-shared": "4.0.0-beta.97" + # 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 From 4b697a2e5e0d5090ece6b319e6a1498db91afbcf Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 6 Aug 2026 09:22:53 +0200 Subject: [PATCH 2/7] fix(cli): allow pg-topo parser build script --- pnpm-workspace.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index acb32acc7f..178ee04367 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 From c52cf535abd2f8cab0b00027117ebad02b0f27a4 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 6 Aug 2026 16:24:14 +0200 Subject: [PATCH 3/7] fix(cli): embed libpg-query wasm in compiled binary --- .../scripts/build-binary.integration.test.ts | 53 +++++++++++++++++++ .../tests/fixtures/compiled-libpg-query.ts | 21 ++++++++ patches/@libpg-query__parser@17.6.10.patch | 17 ++++++ pnpm-lock.yaml | 7 ++- pnpm-workspace.yaml | 3 ++ 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 apps/cli/scripts/build-binary.integration.test.ts create mode 100644 apps/cli/tests/fixtures/compiled-libpg-query.ts create mode 100644 patches/@libpg-query__parser@17.6.10.patch 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/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/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 dd27a38bf0..7cba076346 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ 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: .: @@ -7815,7 +7818,7 @@ snapshots: '@types/node': 24.10.4 long: 5.3.2 - '@libpg-query/parser@17.6.10': + '@libpg-query/parser@17.6.10(patch_hash=ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3)': dependencies: '@launchql/protobufjs': 7.2.6 '@pgsql/types': 17.6.2 @@ -12728,7 +12731,7 @@ snapshots: plpgsql-parser@0.5.16: dependencies: - '@libpg-query/parser': 17.6.10 + '@libpg-query/parser': 17.6.10(patch_hash=ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3) '@pgsql/traverse': 17.2.6 '@pgsql/types': 17.6.2 pgsql-deparser: 17.18.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 178ee04367..1a003a6606 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -67,3 +67,6 @@ supportedArchitectures: - darwin - linux - win32 + +patchedDependencies: + '@libpg-query/parser@17.6.10': patches/@libpg-query__parser@17.6.10.patch From 1f82bf9883e4307d45ac1c0ab23477c966539f89 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 7 Aug 2026 17:16:14 +0200 Subject: [PATCH 4/7] feat(cli): isolate pg-delta next shadow databases --- apps/cli-go/cmd/db.go | 93 ++++- apps/cli-go/cmd/db_shadow_test.go | 112 ++++++ .../internal/db/declarative/declarative.go | 3 + .../db/declarative/declarative_test.go | 22 ++ apps/cli-go/internal/db/diff/diff.go | 41 +++ apps/cli-go/internal/db/diff/diff_test.go | 80 ++++ apps/cli-go/internal/db/diff/shadow.go | 155 +++++--- apps/cli-go/internal/db/diff/shadow_test.go | 199 ++++++---- apps/cli-go/internal/db/reset/reset.go | 3 + apps/cli-go/internal/db/start/start.go | 45 ++- apps/cli-go/internal/db/start/start_test.go | 81 ++++ .../internal/db/start/templates/webhook.sql | 3 - apps/cli-go/internal/utils/edgeruntime.go | 8 +- .../utils/templates/initial_schemas/14.sql | 14 - apps/cli/package.json | 4 +- .../commands/db/diff/diff.integration.test.ts | 1 + .../commands/db/pull/pull.integration.test.ts | 1 + ...eclarative.orchestrate.integration.test.ts | 1 + .../generate/generate.integration.test.ts | 1 + .../declarative/sync/sync.integration.test.ts | 1 + .../legacy-pgdelta-engine.layer.unit.test.ts | 1 + .../legacy-pgdelta-engine.next.layer.ts | 49 ++- ...acy-pgdelta-engine.next.layer.unit.test.ts | 12 + .../legacy-pgdelta-next-adapter.layer.ts | 12 +- .../legacy-pgdelta-next-adapter.unit.test.ts | 57 +++ .../legacy-pgdelta-next-shadow.layer.ts | 37 +- .../legacy-pgdelta-next-shadow.service.ts | 9 +- .../legacy-pgdelta-next-shadow.unit.test.ts | 156 +++----- .../shared/legacy-pgdelta-next.live.test.ts | 347 ++++++++++++++++++ .../db/shared/legacy-pgdelta.cache.ts | 3 + .../shared/legacy-pgdelta.cache.unit.test.ts | 6 +- .../db/shared/legacy-pgdelta.seam.layer.ts | 201 ++++++++-- .../legacy-pgdelta.seam.layer.unit.test.ts | 232 +++++++++++- .../db/shared/legacy-pgdelta.seam.service.ts | 30 +- .../src/legacy/commands/start/lib/db-setup.ts | 34 +- .../commands/start/lib/db-setup.unit.test.ts | 37 ++ .../services/postgres.service.unit.test.ts | 5 + .../templates/db-initial-schema-14.sql.ts | 14 - .../start/templates/db-webhook.sql.ts | 3 - pnpm-lock.yaml | 22 +- 40 files changed, 1752 insertions(+), 383 deletions(-) create mode 100644 apps/cli-go/cmd/db_shadow_test.go create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.unit.test.ts diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index fedb35b30c..be733771e6 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -1,9 +1,12 @@ package cmd import ( + "bufio" "context" + "encoding/json" "errors" "fmt" + "io" "os" "path" "path/filepath" @@ -31,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, @@ -208,13 +283,12 @@ var ( 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 three 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 @@ -252,10 +326,7 @@ var ( if err != nil { return err } - fmt.Println(nextShadow.Container) - fmt.Println(utils.ToPostgresURLWithoutPassword(nextShadow.Migrated)) - fmt.Println(utils.ToPostgresURLWithoutPassword(nextShadow.Scratch)) - return nil + return handoffPgDeltaNextShadow(cmd.Context(), nextShadow, os.Stdin, os.Stdout, utils.DockerRemove) } var src diff.ShadowSource var err error 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/internal/db/declarative/declarative.go b/apps/cli-go/internal/db/declarative/declarative.go index b84087bf9f..881db395a5 100644 --- a/apps/cli-go/internal/db/declarative/declarative.go +++ b/apps/cli-go/internal/db/declarative/declarative.go @@ -678,6 +678,7 @@ func baselineVersionToken() string { // // - the Postgres image (initSchema content); // - the service toggles that gate initSchema — auth/storage/realtime; +// - experimental.webhooks.enabled (conditional pg_net installation); // - api.auto_expose_new_tables (ApplyApiPrivileges default ACLs); // - vault secret names (UpsertVaultSecrets); // - supabase/roles.sql (SeedGlobals). @@ -691,6 +692,8 @@ func setupInputsToken(fsys afero.Fs) (string, error) { // initSchema conditionally provisions these service schemas. fmt.Fprintf(h, "auth=%t storage=%t realtime=%t\n", utils.Config.Auth.Enabled, utils.Config.Storage.Enabled, utils.Config.Realtime.Enabled) + webhooksEnabled := utils.Config.Experimental.Webhooks != nil && utils.Config.Experimental.Webhooks.Enabled + fmt.Fprintf(h, "database_webhooks=%t\n", webhooksEnabled) // api.auto_expose_new_tables drives ApplyApiPrivileges (default ACLs). Key on the // effective value, not the raw tri-state: as of the 2026-05-30 flip an unset flag // resolves to the same revoke-by-default baseline as explicit false (see diff --git a/apps/cli-go/internal/db/declarative/declarative_test.go b/apps/cli-go/internal/db/declarative/declarative_test.go index cbd67d29de..50a33bff02 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,27 @@ func TestBaselineCatalogKeyVariesWithServiceToggles(t *testing.T) { assert.NotEqual(t, on, off, "toggling a service must change the baseline cache key") } +func TestBaselineCatalogKeyVariesWithDatabaseWebhooks(t *testing.T) { + originalConfig := utils.Config + t.Cleanup(func() { utils.Config = originalConfig }) + fSys := afero.NewMemMapFs() + + disabled := config.NewConfig() + utils.Config = disabled + disabledKey, err := baselineCatalogKey(fSys) + require.NoError(t, err) + + 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) + + assert.NotEqual(t, disabledKey, enabledKey, "Database Webhooks must change the baseline cache key") +} + func TestDeclarativeCatalogCacheKeyVariesWithSetupInputs(t *testing.T) { // The declarative target is built on the platform baseline, so its cache key // must change when setup inputs change even if the declarative SQL does not. diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index 32fabb2c21..b06ac78284 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -192,6 +192,47 @@ func SetupShadowDatabase(ctx context.Context, container string, fsys afero.Fs, o return setupShadowConn(ctx, conn, container, fsys) } +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, options ...func(*pgx.ConnConfig)) error { migrations, err := migration.ListLocalMigrations(utils.MigrationsDir, afero.NewIOFS(fsys)) if err != nil { diff --git a/apps/cli-go/internal/db/diff/diff_test.go b/apps/cli-go/internal/db/diff/diff_test.go index aff3242699..7e92f23ef8 100644 --- a/apps/cli-go/internal/db/diff/diff_test.go +++ b/apps/cli-go/internal/db/diff/diff_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "testing" + stdfs "testing/fstest" "time" "github.com/docker/docker/api/types" @@ -404,6 +405,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("provisions PG17 without activating user-managed extensions", 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 diff --git a/apps/cli-go/internal/db/diff/shadow.go b/apps/cli-go/internal/db/diff/shadow.go index eba3fb2358..aa32c91e74 100644 --- a/apps/cli-go/internal/db/diff/shadow.go +++ b/apps/cli-go/internal/db/diff/shadow.go @@ -2,11 +2,12 @@ package diff import ( "context" + "fmt" + "math" "time" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" - "github.com/pkg/errors" "github.com/spf13/afero" "github.com/supabase/cli/internal/db/start" "github.com/supabase/cli/internal/pgdelta" @@ -30,87 +31,135 @@ type ShadowSource struct { TargetOverride *pgconn.Config } -// PgDeltaNextShadow is a provisioned shadow container exposing both database -// states needed by the native pg-delta engine. Migrated contains the platform -// baseline plus local migrations. Scratch is an empty sibling database owned -// by pg-delta's declarative planner while it loads the desired schema files. -type PgDeltaNextShadow struct { - // Container is left running for the caller, which MUST remove it after use. +// 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 - Migrated pgconn.Config - Scratch pgconn.Config + Config pgconn.Config } -type pgDeltaNextShadowDependencies struct { - 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 - createScratch func(context.Context, ...func(*pgx.ConnConfig)) error - remove func(string) +// 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 } -const createPgDeltaNextScratch = "CREATE DATABASE pgdelta_declarative TEMPLATE template0" - -// createPgDeltaNextScratchDatabase creates the empty same-cluster database that -// planSchemaFiles owns. Using template0 guarantees it does not inherit the -// platform baseline or local migrations from postgres. -func createPgDeltaNextScratchDatabase(ctx context.Context, options ...func(*pgx.ConnConfig)) error { - conn, err := ConnectShadowDatabase(ctx, 10*time.Second, options...) - if err != nil { - return err - } - defer conn.Close(context.Background()) - if _, err := conn.Exec(ctx, createPgDeltaNextScratch); err != nil { - return errors.Wrap(err, "failed to create pg-delta declarative scratch database") - } - return nil +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 the migrated target and an empty live -// sibling database used by the native pg-delta declarative planner. It never -// loads or applies the legacy declarative schemas. On failure, the container is -// removed best-effort without replacing the provisioning error. +// 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{ - create: CreateShadowDatabase, - wait: start.WaitForHealthyService, - migrate: MigrateShadowDatabase, - createScratch: createPgDeltaNextScratchDatabase, - remove: utils.DockerRemove, + freePort: utils.GetFreeHostPort, + create: CreateShadowDatabase, + wait: start.WaitForHealthyService, + migrate: MigrateShadowDatabase, + setup: SetupPgDeltaNextDeclarativeShadowDatabase, + remove: utils.DockerRemove, }, options...) } func preparePgDeltaNextShadow(ctx context.Context, fsys afero.Fs, dependencies pgDeltaNextShadowDependencies, options ...func(*pgx.ConnConfig)) (PgDeltaNextShadow, error) { - shadow, err := dependencies.create(ctx, utils.Config.Db.ShadowPort) - if err != nil { - return PgDeltaNextShadow{}, err - } + var containers []string ok := false defer func() { if !ok { - dependencies.remove(shadow) + for _, container := range containers { + dependencies.remove(container) + } } }() - if err := dependencies.wait(ctx, utils.Config.Db.HealthTimeout, shadow); err != nil { + + 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, shadow, fsys, options...); err != nil { + if err := dependencies.migrate(ctx, migrationsContainer, fsys, append(options, withShadowPort(migrationsPort))...); err != nil { return PgDeltaNextShadow{}, err } - if err := dependencies.createScratch(ctx, options...); err != nil { + + declarativePort, err := allocatePgDeltaNextPort(dependencies.freePort, migrationsPort) + if err != nil { return PgDeltaNextShadow{}, err } - migrated := pgconn.Config{ + 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: utils.Config.Db.ShadowPort, + Port: port, User: "postgres", Password: utils.Config.Db.Password, Database: "postgres", } - scratch := migrated - scratch.Database = "pgdelta_declarative" - ok = true - return PgDeltaNextShadow{Container: shadow, Migrated: migrated, Scratch: scratch}, nil } // PrepareShadowSource provisions the shadow database that DiffDatabase diffs diff --git a/apps/cli-go/internal/db/diff/shadow_test.go b/apps/cli-go/internal/db/diff/shadow_test.go index 6cb1d400bd..9d293c70dc 100644 --- a/apps/cli-go/internal/db/diff/shadow_test.go +++ b/apps/cli-go/internal/db/diff/shadow_test.go @@ -17,100 +17,165 @@ func TestPreparePgDeltaNextShadow(t *testing.T) { originalConfig := utils.Config t.Cleanup(func() { utils.Config = originalConfig }) utils.Config.Hostname = "shadow-host" - utils.Config.Db.ShadowPort = 6543 utils.Config.Db.Password = "secret" utils.Config.Db.HealthTimeout = 7 * time.Second - var waitedContainer string - var migratedContainer string - var scratchCreated bool - var removedContainer string + 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) { - assert.Equal(t, uint16(6543), port) - return "shadow-container", nil + 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) - waitedContainer = containers[0] + waitedContainers = append(waitedContainers, containers[0]) return nil }, - migrate: func(_ context.Context, container string, _ afero.Fs, _ ...func(*pgx.ConnConfig)) error { - migratedContainer = container + 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 }, - createScratch: func(_ context.Context, _ ...func(*pgx.ConnConfig)) error { - scratchCreated = true + 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) { removedContainer = container }, + remove: func(container string) { removedContainers = append(removedContainers, container) }, } result, err := preparePgDeltaNextShadow(context.Background(), afero.NewMemMapFs(), dependencies) require.NoError(t, err) - assert.Equal(t, "shadow-container", result.Container) - assert.Equal(t, "shadow-container", waitedContainer) - assert.Equal(t, "shadow-container", migratedContainer) - assert.True(t, scratchCreated) - assert.Empty(t, removedContainer) - assert.Equal(t, "shadow-host", result.Migrated.Host) - assert.Equal(t, uint16(6543), result.Migrated.Port) - assert.Equal(t, "postgres", result.Migrated.User) - assert.Equal(t, "secret", result.Migrated.Password) - assert.Equal(t, "postgres", result.Migrated.Database) - assert.Equal(t, result.Migrated.Host, result.Scratch.Host) - assert.Equal(t, result.Migrated.Port, result.Scratch.Port) - assert.Equal(t, result.Migrated.User, result.Scratch.User) - assert.Equal(t, result.Migrated.Password, result.Scratch.Password) - assert.Equal(t, "pgdelta_declarative", result.Scratch.Database) + 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 TestPreparePgDeltaNextShadowRemovesContainerAfterFailure(t *testing.T) { - originalConfig := utils.Config - t.Cleanup(func() { utils.Config = originalConfig }) - wantErr := errors.New("migration failed") - var removedContainer string - dependencies := pgDeltaNextShadowDependencies{ - create: func(context.Context, uint16) (string, error) { - return "failed-shadow", nil - }, - wait: func(context.Context, time.Duration, ...string) error { return nil }, - migrate: func(context.Context, string, afero.Fs, ...func(*pgx.ConnConfig)) error { - return wantErr - }, - createScratch: func(context.Context, ...func(*pgx.ConnConfig)) error { return nil }, - remove: func(container string) { removedContainer = container }, +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"}}, } - result, err := preparePgDeltaNextShadow(context.Background(), afero.NewMemMapFs(), dependencies) + 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) }, + } - assert.ErrorIs(t, err, wantErr) - assert.Empty(t, result.Container) - assert.Equal(t, "failed-shadow", removedContainer) -} + result, err := preparePgDeltaNextShadow(context.Background(), afero.NewMemMapFs(), dependencies) -func TestPreparePgDeltaNextShadowRemovesContainerAfterScratchFailure(t *testing.T) { - originalConfig := utils.Config - t.Cleanup(func() { utils.Config = originalConfig }) - wantErr := errors.New("scratch creation failed") - var removedContainer string - dependencies := pgDeltaNextShadowDependencies{ - create: func(context.Context, uint16) (string, error) { - return "failed-scratch-shadow", nil - }, - wait: func(context.Context, time.Duration, ...string) error { return nil }, - migrate: func(context.Context, string, afero.Fs, ...func(*pgx.ConnConfig)) error { - return nil - }, - createScratch: func(context.Context, ...func(*pgx.ConnConfig)) error { return wantErr }, - remove: func(container string) { removedContainer = container }, + assert.ErrorIs(t, err, wantErr) + assert.Empty(t, result) + assert.Equal(t, tt.wantRemoved, removed) + }) } +} - result, err := preparePgDeltaNextShadow(context.Background(), afero.NewMemMapFs(), dependencies) +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) - assert.ErrorIs(t, err, wantErr) - assert.Empty(t, result.Container) - assert.Equal(t, "failed-scratch-shadow", removedContainer) + 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/reset/reset.go b/apps/cli-go/internal/db/reset/reset.go index 7cfe42ff27..eb4709b71c 100644 --- a/apps/cli-go/internal/db/reset/reset.go +++ b/apps/cli-go/internal/db/reset/reset.go @@ -182,6 +182,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/start/start.go b/apps/cli-go/internal/db/start/start.go index 6cd411791c..13845b1b90 100644 --- a/apps/cli-go/internal/db/start/start.go +++ b/apps/cli-go/internal/db/start/start.go @@ -380,10 +380,36 @@ 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 +} + +// 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 + } +} + +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.activateUserExtensions { + if err := ApplyDatabaseWebhooks(ctx, conn); err != nil { + return err + } + } if err := ApplyApiPrivileges(ctx, conn); err != nil { return err } @@ -398,6 +424,23 @@ 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 + } + 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..ae513d7013 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,6 +21,7 @@ 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" ) @@ -379,6 +381,85 @@ 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) + conn.Query("create extension if not exists pg_net schema extensions").Reply("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) + conn.Query("create extension if not exists pg_net schema extensions"). + Reply("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/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/package.json b/apps/cli/package.json index 44603081f8..b8171b32f9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -55,8 +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@951daa9d9b333f5c69c38eb664d8a17847c635eb", - "@supabase/pg-topo": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb", + "@supabase/pg-delta": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@ad62ae432865f67bb359a8183a2b3279fa9ebccb", + "@supabase/pg-topo": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@ad62ae432865f67bb359a8183a2b3279fa9ebccb", "@supabase/process-compose": "workspace:*", "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", 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 f58d482dcc..04a283fd31 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 @@ -80,6 +80,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { targetUrlOverride: opts.targetOverride, }); }, + provisionNextShadow: () => Effect.die("provisionNextShadow not used"), removeShadowContainer: (container) => Effect.sync(() => { removedContainers.push(container); 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 292e640e8f..dbbdea699a 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 @@ -134,6 +134,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { targetUrlOverride: opts.shadowTargetOverride, }); }, + provisionNextShadow: () => Effect.die("provisionNextShadow not used"), removeShadowContainer: (container) => Effect.sync(() => { removedContainers.push(container); 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 0828abc62a..2230914cfa 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 @@ -36,6 +36,7 @@ function mockSeam(paths: Record) { ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), + provisionNextShadow: () => Effect.die("provisionNextShadow not used in declarative tests"), removeShadowContainer: () => Effect.void, }); return { layer, calls }; 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 44e74e8202..e3c0eaa162 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 @@ -110,6 +110,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[] = []; 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 684745a8d3..3597b17a80 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 @@ -109,6 +109,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 edge = Layer.succeed(LegacyEdgeRuntimeScript, { 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 index 56a8fbcea9..fbda732eed 100644 --- 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 @@ -52,6 +52,7 @@ const unusedLegacyRuntime = Layer.mergeAll( 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 --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 index 3250ab029d..766a453dfa 100644 --- 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 @@ -26,6 +26,12 @@ import { legacyPgDeltaNextBlockingDiagnosticMessage, } 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; @@ -165,7 +171,9 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( diffExplicit: (input) => Effect.scoped( Effect.gen(function* () { - let shadow: { readonly migrationsUrl: string; readonly scratchUrl: string } | undefined; + let shadow: + | { readonly migrationsUrl: string; readonly declarativeUrl: string } + | undefined; const migrationsEndpoint = input.source.kind === "migrations" ? input.source @@ -230,8 +238,8 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), }); const migrations = parseLegacyConnectionString(shadow.migrationsUrl); - const scratch = parseLegacyConnectionString(shadow.scratchUrl); - if (migrations === undefined || scratch === undefined) { + 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", @@ -239,23 +247,22 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( }), ); } - const migrationsPool = yield* legacyAcquirePgPool(migrations, { - isLocal: true, - dnsResolver: "native", - }); if (input.declarativeFiles !== undefined) { - const scratchPool = yield* legacyAcquirePgPool(scratch, { - isLocal: true, - dnsResolver: "native", - }); + 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: scratchPool, + shadowPool: declarativePool, files: input.declarativeFiles, allowDrops: true, debug: input.debug, reorder: true, - seedAssumedSchemas: true, + ...legacyPgDeltaNextIsolatedShadowPlanOptions, schema: input.schema, ...(input.declarativeManifest !== undefined ? { manifest: input.declarativeManifest } @@ -271,6 +278,10 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( yield* rejectBlockingDiagnostic("declarativePlan", result.diagnostics); return normalizeNextDiff(result, debugDirectory); } + const migrationsPool = yield* legacyAcquirePgPool(migrations, { + isLocal: true, + dnsResolver: "native", + }); const desiredPool = yield* acquireDatabase(input.target); const result = yield* adapter.diff({ sourcePool: migrationsPool, @@ -321,8 +332,8 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( Effect.gen(function* () { const shadow = yield* shadowService.provision({ schema: input.schema }); const migrations = parseLegacyConnectionString(shadow.migrationsUrl); - const scratch = parseLegacyConnectionString(shadow.scratchUrl); - if (migrations === undefined || scratch === undefined) { + 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", @@ -330,21 +341,21 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( }), ); } - const [migrationsPool, scratchPool] = yield* Effect.all( + const [migrationsPool, declarativePool] = yield* Effect.all( [ legacyAcquirePgPool(migrations, { isLocal: true, dnsResolver: "native" }), - legacyAcquirePgPool(scratch, { isLocal: true, dnsResolver: "native" }), + legacyAcquirePgPool(declarative, { isLocal: true, dnsResolver: "native" }), ], { concurrency: 2 }, ); const result = yield* adapter.planDeclarativeSchema({ targetPool: migrationsPool, - shadowPool: scratchPool, + shadowPool: declarativePool, files: input.files, allowDrops: true, debug: input.debug, reorder: true, - seedAssumedSchemas: true, + ...legacyPgDeltaNextIsolatedShadowPlanOptions, schema: input.schema, ...(input.manifest !== undefined ? { manifest: input.manifest } : {}), }); 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-next-adapter.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts index 119cca4f9c..dd59eee4a8 100644 --- 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 @@ -1,7 +1,12 @@ import { Effect, Layer } from "effect"; import type { Pool } from "pg"; import { serializeSnapshot, encodeId } from "@supabase/pg-delta/core"; -import { buildSchemaExport, planSchemaFiles, renderPlanFiles } from "@supabase/pg-delta/frontends"; +import { + buildSchemaExport, + planSchemaFiles, + renderPlanFiles, + ShadowLoadError, +} from "@supabase/pg-delta/frontends"; import { type IntegrationProfile, resolveProfile, @@ -121,6 +126,8 @@ export interface LegacyPgDeltaNextLibraries diagnostic.message) : []; const label = operation === "declarativeExport" ? "Declarative schema export" @@ -129,7 +136,8 @@ function legacyPgDeltaNextMessage(operation: LegacyPgDeltaNextOperation, cause: : operation === "snapshotCapture" ? "Snapshot capture" : "Database diff"; - return `${label} failed: ${detail}`; + const renderedDiagnostics = diagnostics.map((diagnostic) => ` - ${diagnostic}`).join("\n"); + return `${label} failed: ${detail}${renderedDiagnostics === "" ? "" : `\n${renderedDiagnostics}`}`; } function legacyTryPgDeltaNext( 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 index 3ad7d9d9a2..5c9d1c51c2 100644 --- 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 @@ -1,4 +1,5 @@ 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"; @@ -517,4 +518,60 @@ describe("LegacyPgDeltaNextAdapter", () => { 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", + 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-shadow.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts index aaf8ae08c1..63ca24a5e9 100644 --- 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 @@ -1,6 +1,5 @@ import { Effect, Layer } from "effect"; -import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; import { LegacyPgDeltaNextShadow, type LegacyPgDeltaNextShadowDatabases, @@ -9,9 +8,8 @@ import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; /** * Scoped next-engine shadow orchestration over the narrow Go `db __shadow` - * seam. Go creates the migrated target and a dedicated empty same-cluster - * scratch database; declarative SQL remains wholly owned by the TypeScript - * pg-delta next adapter and its `planSchemaFiles` operation. + * 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, @@ -21,33 +19,10 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( return LegacyPgDeltaNextShadow.of({ provision: ({ schema, projectRef }) => Effect.gen(function* () { - // Register cleanup immediately after Go returns the container. URL - // validation happens only after acquireRelease has installed the - // finalizer, so even malformed seam output cannot leak the shadow. - const shadow = yield* Effect.acquireRelease( - seam.provisionShadow({ - mode: "pgdelta-next", - targetLocal: false, - usePgDelta: false, - schema, - ...(projectRef !== undefined ? { projectRef } : {}), - }), - ({ container }) => seam.removeShadowContainer(container).pipe(Effect.ignoreCause), - ); - - if (shadow.targetUrlOverride === undefined) { - return yield* Effect.fail( - new LegacyDeclarativeShadowDbError({ - message: - "failed to provision the pg-delta next shadow database: missing declarative scratch URL.", - }), - ); - } - - return { - migrationsUrl: shadow.sourceUrl, - scratchUrl: shadow.targetUrlOverride, - } satisfies LegacyPgDeltaNextShadowDatabases; + 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 index 1c34cc8fc8..24505e8725 100644 --- 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 @@ -6,15 +6,14 @@ import type { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts" export interface LegacyPgDeltaNextShadowDatabases { /** Platform baseline with the project's local migrations applied. */ readonly migrationsUrl: string; - /** Empty same-cluster database owned by `planSchemaFiles` while loading desired SQL. */ - readonly scratchUrl: string; + /** Independent platform baseline owned by `planSchemaFiles` while loading desired SQL. */ + readonly declarativeUrl: string; } interface LegacyPgDeltaNextShadowShape { /** - * Provisions the next-engine shadow container and owns it for the current - * Effect scope. The container is removed when that scope closes, including - * when URL validation or the caller fails. + * 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; 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 index ef201d34d3..b3c18940a5 100644 --- 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 @@ -1,64 +1,33 @@ import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Layer } from "effect"; +import { Effect, Layer } from "effect"; -import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; 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"; -class PrimaryFailure extends Data.TaggedError("PrimaryFailure")<{ - readonly message: string; -}> {} - -function setup( - opts: { - readonly sourceUrl?: string; - readonly scratchUrl?: string; - readonly cleanupDefect?: boolean; - } = {}, -) { +function setup() { const state = { provisionCalls: [] as object[], - removedContainers: [] as string[], legacyMethodCalls: [] as string[], }; const seamLayer = Layer.succeed( LegacyDeclarativeSeam, LegacyDeclarativeSeam.of({ - exportCatalog: () => - Effect.sync(() => { - state.legacyMethodCalls.push("exportCatalog"); - return "catalog.json"; - }), - execInherit: () => - Effect.sync(() => { - state.legacyMethodCalls.push("execInherit"); - return 0; - }), - ensureLocalDatabaseStarted: () => - Effect.sync(() => { - state.legacyMethodCalls.push("ensureLocalDatabaseStarted"); - }), - ensureLocalPostgresImageCurrent: () => - Effect.sync(() => { - state.legacyMethodCalls.push("ensureLocalPostgresImageCurrent"); - }), - provisionShadow: (input) => + exportCatalog: () => Effect.die("exportCatalog not used"), + execInherit: () => Effect.die("execInherit 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 { - container: "next-shadow-container", - sourceUrl: opts.sourceUrl ?? "postgresql://postgres@localhost:55432/postgres", - targetUrlOverride: opts.scratchUrl, + migrationsUrl: "postgresql://postgres:secret@localhost:55432/postgres", + declarativeUrl: "postgresql://postgres:secret@localhost:55433/postgres", }; }), - removeShadowContainer: (container) => - Effect.gen(function* () { - state.removedContainers.push(container); - if (opts.cleanupDefect === true) { - return yield* Effect.die("cleanup failed"); - } - }), + removeShadowContainer: () => Effect.die("removeShadowContainer not used"), }), ); @@ -69,86 +38,69 @@ function setup( } describe("LegacyPgDeltaNextShadow", () => { - it.effect("provisions the exact next mode and exposes the migrated and scratch URLs", () => { - const { layer, state } = setup({ - scratchUrl: "postgresql://postgres@localhost:55432/pgdelta_declarative", + 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; - const acquired = yield* shadow.provision({ + return yield* shadow.provision({ schema: ["public", "extensions"], projectRef: "linked-project", }); - expect(state.removedContainers).toEqual([]); - return acquired; }), ); expect(databases).toEqual({ - migrationsUrl: "postgresql://postgres@localhost:55432/postgres", - scratchUrl: "postgresql://postgres@localhost:55432/pgdelta_declarative", + migrationsUrl: "postgresql://postgres:secret@localhost:55432/postgres", + declarativeUrl: "postgresql://postgres:secret@localhost:55433/postgres", }); - expect(Object.keys(databases)).toEqual(["migrationsUrl", "scratchUrl"]); + expect(Object.keys(databases)).toEqual(["migrationsUrl", "declarativeUrl"]); expect(state.provisionCalls).toEqual([ { - mode: "pgdelta-next", - targetLocal: false, - usePgDelta: false, schema: ["public", "extensions"], projectRef: "linked-project", }, ]); - expect(state.removedContainers).toEqual(["next-shadow-container"]); expect(state.legacyMethodCalls).toEqual([]); }).pipe(Effect.provide(layer)); }); - - it.effect("cleans up when the caller fails and never lets cleanup mask that failure", () => { - const { layer, state } = setup({ - scratchUrl: "postgresql://postgres@localhost:55432/pgdelta_declarative", - cleanupDefect: true, - }); - const primary = new PrimaryFailure({ message: "caller failed" }); - - return Effect.gen(function* () { - const error = yield* Effect.scoped( - Effect.gen(function* () { - const shadow = yield* LegacyPgDeltaNextShadow; - yield* shadow.provision({ schema: [] }); - return yield* Effect.fail(primary); - }), - ).pipe(Effect.flip); - - expect(error).toEqual(primary); - expect(state.removedContainers).toEqual(["next-shadow-container"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("cleans up and fails when the declarative scratch URL is missing", () => { - const { layer, state } = setup(); - - return Effect.gen(function* () { - const error = yield* Effect.scoped( - Effect.gen(function* () { - const shadow = yield* LegacyPgDeltaNextShadow; - return yield* shadow.provision({ schema: ["public"] }); - }), - ).pipe(Effect.flip); - - expect(error).toBeInstanceOf(LegacyDeclarativeShadowDbError); - expect(error.message).toContain("missing declarative scratch URL"); - expect(state.removedContainers).toEqual(["next-shadow-container"]); - expect(state.provisionCalls).toEqual([ - { - mode: "pgdelta-next", - targetLocal: false, - usePgDelta: false, - schema: ["public"], - }, - ]); - }).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 index c936e1a649..8a0f7aafc1 100644 --- 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 @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, @@ -86,6 +87,43 @@ function localDatabaseUrl(config: string): string { 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 = ""; @@ -446,3 +484,312 @@ describeDockerLive("pg-delta next local convergence (live)", () => { }, ); }); + +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"); + + 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.cache.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts index f06b687000..55120a704a 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.ts @@ -31,6 +31,8 @@ export interface LegacySetupInputs { readonly authEnabled: boolean; readonly storageEnabled: boolean; readonly realtimeEnabled: boolean; + /** Effective `experimental.webhooks.enabled` (absent → false). */ + readonly webhooksEnabled: boolean; /** Effective `api.auto_expose_new_tables` (unset and false both → false). */ readonly autoExpose: boolean; /** `[db.vault]` secret names (sorted before hashing). */ @@ -90,6 +92,7 @@ export function legacySetupInputsToken(inputs: LegacySetupInputs): string { payload += `auth=${boolToken(inputs.authEnabled)} storage=${boolToken( inputs.storageEnabled, )} realtime=${boolToken(inputs.realtimeEnabled)}\n`; + payload += `database_webhooks=${boolToken(inputs.webhooksEnabled)}\n`; payload += `auto_expose_new_tables=${boolToken(inputs.autoExpose)}\n`; for (const name of [...inputs.vaultNames].sort()) payload += `vault=${name}\n`; payload += inputs.rolesSql; diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts index 83535b91c0..4a6df34501 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.cache.unit.test.ts @@ -34,6 +34,7 @@ const BASE: LegacySetupInputs = { authEnabled: true, storageEnabled: true, realtimeEnabled: true, + webhooksEnabled: false, autoExpose: false, vaultNames: [], rolesSql: "", @@ -66,7 +67,7 @@ describe("legacyBaselineVersionToken", () => { describe("legacySetupInputsToken", () => { it("byte-matches the Go hash input sequence", () => { const expected = sha12( - "17.6.1.135\nauth=true storage=true realtime=true\nauto_expose_new_tables=false\n", + "17.6.1.135\nauth=true storage=true realtime=true\ndatabase_webhooks=false\nauto_expose_new_tables=false\n", ); expect(legacySetupInputsToken(BASE)).toBe(expected); }); @@ -78,7 +79,7 @@ describe("legacySetupInputsToken", () => { rolesSql: "create role app;", }); const expected = sha12( - "17.6.1.135\nauth=true storage=true realtime=true\nauto_expose_new_tables=false\n" + + "17.6.1.135\nauth=true storage=true realtime=true\ndatabase_webhooks=false\nauto_expose_new_tables=false\n" + "vault=a_secret\nvault=b_secret\ncreate role app;", ); expect(token).toBe(expected); @@ -87,6 +88,7 @@ describe("legacySetupInputsToken", () => { it("self-invalidates when any baseline input changes", () => { const baseToken = legacySetupInputsToken(BASE); expect(legacySetupInputsToken({ ...BASE, authEnabled: false })).not.toBe(baseToken); + expect(legacySetupInputsToken({ ...BASE, webhooksEnabled: true })).not.toBe(baseToken); expect(legacySetupInputsToken({ ...BASE, autoExpose: true })).not.toBe(baseToken); expect(legacySetupInputsToken({ ...BASE, vaultNames: ["x"] })).not.toBe(baseToken); expect(legacySetupInputsToken({ ...BASE, rolesSql: "x" })).not.toBe(baseToken); 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 c6d8c2b9a8..d9e87dda80 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"; /** @@ -23,8 +27,7 @@ import { legacyInjectPostgresPassword } from "./legacy-pgdelta.seam.url.ts"; * (the catalog path) and stderr inherited (shadow-DB progress / image pulls). * The Go binary is resolved exactly like `LegacyGoProxy` (`resolveBinary`). */ -export const legacyDeclarativeSeamLayer = Layer.effect( - LegacyDeclarativeSeam, +const makeLegacyDeclarativeSeam = (resolved: BinaryResolution) => Effect.gen(function* () { const cliConfig = yield* LegacyCliConfig; const networkId = yield* LegacyNetworkIdFlag; @@ -39,7 +42,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 }) => @@ -448,9 +463,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( } // stdout is three newline-separated lines: container id, source URL, // and an optional second-database URL. Legacy diff uses the third URL - // only when its local-target declarative branch redirects the target; - // `pgdelta-next` always returns its empty same-cluster declarative - // scratch database there. That next mode never asks Go to apply SQL. + // only when its local-target declarative branch redirects the target. // 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 @@ -487,25 +500,126 @@ export const legacyDeclarativeSeamLayer = Layer.effect( } 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 `legacy-db-bootstrap.seam.layer.ts`, // fixed under CLI-1879): this seam's failure is a TS-authored domain summary over noisy @@ -557,3 +671,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 02413bcdd7..847b063dec 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"; @@ -11,12 +11,8 @@ export type LegacyCatalogMode = "baseline" | "migrations" | "declarative"; * `db pull` diff source), plus the local-target declarative branch. * - `declarative`: a bare shadow with no baseline/migrations (the `db pull * --declarative` empty export source). - * - `pgdelta-next`: platform baseline + local migrations in `postgres`, plus - * an empty same-cluster `pgdelta_declarative` scratch database. Declarative - * SQL is deliberately not applied by Go in this mode; the TypeScript next - * engine loads it later through `planSchemaFiles`. */ -type LegacyShadowMode = "diff" | "declarative" | "pgdelta-next"; +type LegacyShadowMode = "diff" | "declarative"; /** A live shadow database left running for the caller to diff against and remove. */ export interface LegacyShadowSource { @@ -26,13 +22,19 @@ export interface LegacyShadowSource { readonly sourceUrl: string; /** * Optional second live database. For legacy diff it replaces the target with - * `contrib_regression` after Go applies declarative schemas. For - * `pgdelta-next` it is the empty declarative scratch database; TypeScript - * loads the declarative files later through `planSchemaFiles`. + * `contrib_regression` after Go applies declarative schemas. */ 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 { /** * Provisions the shadow-database platform baseline (and, for @@ -116,6 +118,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/start/lib/db-setup.ts b/apps/cli/src/legacy/commands/start/lib/db-setup.ts index c9722c8cdd..a4bca9c0bb 100644 --- a/apps/cli/src/legacy/commands/start/lib/db-setup.ts +++ b/apps/cli/src/legacy/commands/start/lib/db-setup.ts @@ -31,14 +31,18 @@ * `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 are always present, so user migrations can still create or drop 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 +50,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 `version: ""` — every pending migration, matching * `SetupLocalDatabase`'s own call in the `start` context. * @@ -117,6 +121,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 @@ -517,6 +524,22 @@ const legacyStartApplyApiPrivileges = Effect.fnUntraced(function* ( ); }); +/** Installs pg_net only for the explicit Database Webhooks feature opt-in. */ +const legacyStartApplyDatabaseWebhooks = 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`, @@ -580,7 +603,7 @@ export const legacyStartSetupLocalDatabase = ( const toml = yield* legacyCheckDbToml(fs, path, workdir); - // SetupDatabase: initSchema -> ApplyApiPrivileges (start.go:383-389). + // SetupDatabase: initSchema -> ApplyDatabaseWebhooks -> ApplyApiPrivileges. yield* Effect.scoped( Effect.gen(function* () { const tmpDir = yield* fs @@ -594,6 +617,7 @@ export const legacyStartSetupLocalDatabase = ( ), ); yield* legacyStartInitSchema(input, tmpDir); + yield* legacyStartApplyDatabaseWebhooks(input, tmpDir); yield* legacyStartApplyApiPrivileges(input, tmpDir, toml.baseline.apiAutoExposeNewTables); }), ); diff --git a/apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts b/apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts index 8f2decfc6f..45675c1d40 100644 --- a/apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/lib/db-setup.unit.test.ts @@ -43,6 +43,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 }> = []; @@ -429,6 +430,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(); diff --git a/apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts index b651045860..988123e113 100644 --- a/apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/postgres.service.unit.test.ts @@ -85,6 +85,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/commands/start/templates/db-initial-schema-14.sql.ts b/apps/cli/src/legacy/commands/start/templates/db-initial-schema-14.sql.ts index 8199aaad59..b2b506eb09 100644 --- a/apps/cli/src/legacy/commands/start/templates/db-initial-schema-14.sql.ts +++ b/apps/cli/src/legacy/commands/start/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/commands/start/templates/db-webhook.sql.ts b/apps/cli/src/legacy/commands/start/templates/db-webhook.sql.ts index 5aa85e84a5..d71eeff247 100644 --- a/apps/cli/src/legacy/commands/start/templates/db-webhook.sql.ts +++ b/apps/cli/src/legacy/commands/start/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/pnpm-lock.yaml b/pnpm-lock.yaml index 7cba076346..69321df5f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,11 +155,11 @@ importers: specifier: workspace:* version: link:../../packages/config '@supabase/pg-delta': - specifier: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@951daa9d9b333f5c69c38eb664d8a17847c635eb - version: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@951daa9d9b333f5c69c38eb664d8a17847c635eb(@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb) + specifier: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@ad62ae432865f67bb359a8183a2b3279fa9ebccb + version: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@ad62ae432865f67bb359a8183a2b3279fa9ebccb(@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@ad62ae432865f67bb359a8183a2b3279fa9ebccb) '@supabase/pg-topo': - specifier: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb - version: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb + specifier: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@ad62ae432865f67bb359a8183a2b3279fa9ebccb + version: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@ad62ae432865f67bb359a8183a2b3279fa9ebccb '@supabase/process-compose': specifier: workspace:* version: link:../../packages/process-compose @@ -2842,8 +2842,8 @@ 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@951daa9d9b333f5c69c38eb664d8a17847c635eb': - resolution: {integrity: sha512-eWhb8JyODx870aSr2xKr3i81yBBnblAqLjdFqW0MGr6pxDzX/PbJkQGx700u/CESycO6HfW8+2MrDQUE3em53w==, tarball: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@951daa9d9b333f5c69c38eb664d8a17847c635eb} + '@supabase/pg-delta@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@ad62ae432865f67bb359a8183a2b3279fa9ebccb': + resolution: {integrity: sha512-zD/OOjOZaOIaMMjm7VbhlZa23hqeQFDYaWT3mnfT7/6/JgSEp5rge4gwTY+cjy+Q7ObLdwqJe+wDO7ARujJMbA==, tarball: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@ad62ae432865f67bb359a8183a2b3279fa9ebccb} version: 1.0.0-alpha.33 engines: {node: '>=20.0.0'} hasBin: true @@ -2853,8 +2853,8 @@ packages: '@supabase/pg-topo': optional: true - '@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb': - resolution: {integrity: sha512-HoqxATDYB2WygDOV1XQBN/hM/hcfvVUrc7F+R691j6CD89cHumR/nRiRJ+0bh9siZb7Fx81Bp9BAPRfzEkEydA==, tarball: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb} + '@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@ad62ae432865f67bb359a8183a2b3279fa9ebccb': + resolution: {integrity: sha512-HoqxATDYB2WygDOV1XQBN/hM/hcfvVUrc7F+R691j6CD89cHumR/nRiRJ+0bh9siZb7Fx81Bp9BAPRfzEkEydA==, tarball: https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@ad62ae432865f67bb359a8183a2b3279fa9ebccb} version: 1.0.0-alpha.5 '@supabase/phoenix@0.4.5': @@ -9096,18 +9096,18 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/pg-delta@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@951daa9d9b333f5c69c38eb664d8a17847c635eb(@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb)': + '@supabase/pg-delta@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-delta@ad62ae432865f67bb359a8183a2b3279fa9ebccb(@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@ad62ae432865f67bb359a8183a2b3279fa9ebccb)': 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@951daa9d9b333f5c69c38eb664d8a17847c635eb + '@supabase/pg-topo': https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@ad62ae432865f67bb359a8183a2b3279fa9ebccb transitivePeerDependencies: - pg-native - supports-color - '@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@951daa9d9b333f5c69c38eb664d8a17847c635eb': + '@supabase/pg-topo@https://pkg.pr.new/supabase/pg-toolbelt/@supabase/pg-topo@ad62ae432865f67bb359a8183a2b3279fa9ebccb': dependencies: '@pgsql/traverse': 17.2.6 plpgsql-parser: 0.5.16 From 5bffecbfba560944f6b4f0364d6eb2784d87b389 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 7 Aug 2026 19:05:35 +0200 Subject: [PATCH 5/7] fix(cli): correct diff and migration execution contracts --- apps/cli-go/cmd/db.go | 19 +- apps/cli-go/docs/supabase/db/diff.md | 2 + apps/cli-go/docs/supabase/db/pull.md | 2 + .../db/schema-declarative-generate.md | 2 + .../supabase/db/schema-declarative-sync.md | 2 + apps/cli-go/internal/db/diff/diff.go | 83 +----- apps/cli-go/internal/db/diff/diff_test.go | 248 ++++++------------ apps/cli-go/internal/db/diff/pgdelta.go | 29 +- .../internal/db/diff/pgdelta_migrations.go | 8 + .../db/diff/pgdelta_migrations_test.go | 11 + apps/cli-go/internal/db/diff/pgdelta_test.go | 6 + apps/cli-go/internal/db/diff/shadow.go | 43 +-- .../cli-go/internal/testing/helper/history.go | 16 +- .../internal/testing/helper/privileges.go | 8 +- apps/cli-go/pkg/migration/apply_test.go | 28 +- apps/cli-go/pkg/migration/drop_test.go | 16 +- apps/cli-go/pkg/migration/file.go | 74 +++++- apps/cli-go/pkg/migration/file_test.go | 128 ++++++++- apps/cli-go/pkg/migration/history.go | 22 +- apps/cli-go/pkg/migration/seed_test.go | 24 +- .../create/create.integration.test.ts | 33 ++- .../legacy/commands/db/diff/SIDE_EFFECTS.md | 27 +- .../legacy/commands/db/diff/diff.handler.ts | 64 +---- .../commands/db/diff/diff.integration.test.ts | 114 ++++---- .../legacy/commands/db/pull/SIDE_EFFECTS.md | 5 + .../legacy/commands/db/pull/pull.handler.ts | 62 +---- .../commands/db/pull/pull.integration.test.ts | 34 ++- .../schema/declarative/sync/SIDE_EFFECTS.md | 7 + .../declarative/sync/sync.integration.test.ts | 4 +- .../commands/db/shared/legacy-diff-engine.ts | 3 + .../legacy-pgdelta-engine.legacy.layer.ts | 9 +- .../legacy-pgdelta-engine.next.layer.ts | 38 +-- .../shared/legacy-pgdelta-engine.service.ts | 8 +- .../db/shared/legacy-pgdelta-files.ts | 35 +-- .../shared/legacy-pgdelta-migrations.write.ts | 11 + .../legacy-pgdelta-next-adapter.layer.ts | 2 +- .../legacy-pgdelta-next-adapter.service.ts | 4 +- .../legacy-pgdelta-next-adapter.unit.test.ts | 4 +- .../shared/legacy-pgdelta-next.live.test.ts | 3 + .../shared/legacy-pgdelta.integration.test.ts | 33 +++ .../db/shared/legacy-pgdelta.seam.layer.ts | 13 +- .../db/shared/legacy-pgdelta.seam.service.ts | 11 +- .../commands/db/shared/legacy-pgdelta.ts | 23 +- .../src/legacy/commands/link/link.handler.ts | 17 +- .../commands/link/link.integration.test.ts | 21 ++ .../legacy/shared/legacy-db-config.service.ts | 2 + .../src/legacy/shared/legacy-http-errors.ts | 22 +- .../legacy/shared/legacy-migration-apply.ts | 31 +++ .../legacy-migration-apply.unit.test.ts | 56 ++++ apps/cli/src/legacy/shared/legacy-seed-ops.ts | 3 - packages/api/scripts/generate.ts | 12 + packages/api/scripts/generate.unit.test.ts | 24 +- packages/api/src/effect.ts | 6 +- packages/api/src/generated/contracts.ts | 232 ++++++++-------- packages/api/src/internal/client.ts | 30 ++- packages/api/src/internal/client.unit.test.ts | 76 +++++- 56 files changed, 1046 insertions(+), 804 deletions(-) diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index be733771e6..ec9cb971ca 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -276,16 +276,14 @@ 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 throwaway shadow databases, then leave them running // so the TS caller can run the differ itself and remove the containers - // afterwards. Legacy modes print three newline-separated lines. pgdelta-next + // 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 @@ -334,7 +332,7 @@ var ( 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) } @@ -343,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 }, } @@ -762,8 +755,6 @@ func init() { // Build hidden shadow-provisioning seam command shadowFlags := dbShadowCmd.Flags() shadowFlags.StringVar(&shadowMode, "mode", "diff", "Shadow mode: diff (baseline + migrations), declarative (bare shadow), or pgdelta-next (migrated + empty scratch).") - shadowFlags.BoolVar(&shadowTargetLocal, "target-local", false, "Whether the diff target is the local database (enables the declarative-schema branch).") - shadowFlags.BoolVar(&shadowUsePgDelta, "use-pg-delta", false, "Whether pg-delta is the active diff engine (selects the declarative-apply path).") shadowFlags.StringSliceVarP(&shadowSchema, "schema", "s", []string{}, "Comma separated list of schema to include.") shadowFlags.StringVar(&shadowProjectRef, "project-ref", "", "Linked project ref, so the shadow merges the matching [remotes.] config override.") dbCmd.AddCommand(dbShadowCmd) diff --git a/apps/cli-go/docs/supabase/db/diff.md b/apps/cli-go/docs/supabase/db/diff.md index 497d371a95..0307f41ade 100644 --- a/apps/cli-go/docs/supabase/db/diff.md +++ b/apps/cli-go/docs/supabase/db/diff.md @@ -6,6 +6,8 @@ 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. +Normal diff mode always compares that migrations shadow with the selected live database. Declarative files under `supabase/database/` and `[db.migrations].schema_paths` do not replace the target. Use `supabase db schema declarative sync` to compare the complete declarative desired state. + 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. diff --git a/apps/cli-go/docs/supabase/db/pull.md b/apps/cli-go/docs/supabase/db/pull.md index 50c01be1ac..5128f06133 100644 --- a/apps/cli-go/docs/supabase/db/pull.md +++ b/apps/cli-go/docs/supabase/db/pull.md @@ -12,6 +12,8 @@ If no entries exist in the migration history table, the default diff engine uses 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. +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 target; use `db schema declarative sync` for declarative comparison. + 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. 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 d82cae431a..4d82a4b8cf 100644 --- a/apps/cli-go/docs/supabase/db/schema-declarative-generate.md +++ b/apps/cli-go/docs/supabase/db/schema-declarative-generate.md @@ -4,6 +4,8 @@ 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. +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. `--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. 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 a6cf5e5729..867c36076a 100644 --- a/apps/cli-go/docs/supabase/db/schema-declarative-sync.md +++ b/apps/cli-go/docs/supabase/db/schema-declarative-sync.md @@ -4,6 +4,8 @@ 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. +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. `--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//`. diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index b06ac78284..1906f76c41 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+`) @@ -250,16 +179,16 @@ func MigrateShadowDatabase(ctx context.Context, container string, fsys afero.Fs, } func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w io.Writer, fsys afero.Fs, differ DiffFunc, usePgDelta bool, options ...func(*pgx.ConnConfig)) (DatabaseDiff, error) { + 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, ",")) diff --git a/apps/cli-go/internal/db/diff/diff_test.go b/apps/cli-go/internal/db/diff/diff_test.go index 7e92f23ef8..53ee1a9a53 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" @@ -38,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 @@ -174,7 +101,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 @@ -212,19 +139,21 @@ 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") 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. @@ -233,7 +162,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}}, @@ -252,11 +181,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) @@ -302,20 +227,32 @@ 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") 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 @@ -352,8 +289,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 @@ -377,10 +318,18 @@ 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") helper.MockApiPrivilegesRevoke(conn). Query(CREATE_TEMPLATE). Reply("CREATE DATABASE") @@ -396,8 +345,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 @@ -491,6 +444,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() @@ -501,10 +456,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()) }) @@ -561,8 +518,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 @@ -615,20 +576,32 @@ 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") 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 { @@ -652,70 +625,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 aa32c91e74..a71a4b2c9e 100644 --- a/apps/cli-go/internal/db/diff/shadow.go +++ b/apps/cli-go/internal/db/diff/shadow.go @@ -10,7 +10,6 @@ import ( "github.com/jackc/pgx/v4" "github.com/spf13/afero" "github.com/supabase/cli/internal/db/start" - "github.com/supabase/cli/internal/pgdelta" "github.com/supabase/cli/internal/utils" ) @@ -24,11 +23,6 @@ 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. @@ -164,11 +158,8 @@ func pgDeltaNextShadowConfig(port uint16) pgconn.Config { // PrepareShadowSource provisions the shadow database that DiffDatabase diffs // against, but returns it running instead of diffing + removing, so a native -// caller can run the differ itself. targetLocal mirrors -// utils.IsLocalDatabase(config) — the only target-derived input the shadow prep -// needs. usePgDelta selects the declarative-apply engine for the local-declared -// branch, matching DiffDatabase. On error the shadow container is removed. -func PrepareShadowSource(ctx context.Context, schema []string, targetLocal bool, usePgDelta bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (ShadowSource, error) { +// 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 @@ -192,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/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/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 83c07f53c7..b2a0ec48bb 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' @@ -142,22 +151,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 { @@ -178,7 +232,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/src/legacy/commands/branches/create/create.integration.test.ts b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts index d9b7029288..ec677a8341 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: "not-a-timestamp" }, + }); + 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 be1efe65c5..7760be4b2a 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -28,16 +28,15 @@ bundled Go binary. ## 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-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 | +| 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 @@ -91,8 +90,9 @@ bundled Go binary. 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` @@ -108,6 +108,9 @@ 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). +- Normal mode always compares the migrations shadow with the selected live + database. Declarative files and `schema_paths` never replace that target; use + `supabase db schema declarative sync` for declarative comparison. ### `--use-pg-schema` is deprecated (CLI-1960) — keep-in-Go exception 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 bd7ba9b769..fc2b833bb0 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -6,10 +6,7 @@ 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, - legacyResolveDeclarativeDir, -} from "../../../shared/legacy-db-config.toml-read.ts"; +import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; @@ -22,6 +19,7 @@ import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state. import { legacyParseBoolEnv, legacyResolveDiffEngine, + legacySchemaPathsTransitionWarning, legacyShouldUsePgDelta, } from "../shared/legacy-diff-engine.ts"; import { @@ -33,14 +31,7 @@ import { LegacyPgDeltaEngine, type LegacyPgDeltaDatabaseEndpoint, type LegacyPgDeltaEndpoint, - type LegacyPgDeltaExportManifest, - type LegacyPgDeltaSqlFile, } from "../shared/legacy-pgdelta-engine.service.ts"; -import { - LegacyLoadPgDeltaSqlFiles, - LegacyLoadPgDeltaSqlPaths, - LegacyReadPgDeltaExportManifest, -} from "../shared/legacy-pgdelta-files.ts"; import { legacyWritePgDeltaMigrations } from "../shared/legacy-pgdelta-migrations.write.ts"; import { legacyIsPgDeltaDebugEnabled, @@ -411,6 +402,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy denoVersion: cfg.denoVersion, }; const formatOptions = Option.getOrElse(cfg.pgDelta.formatOptions, () => ""); + if (cfg.migrationSchemaPaths !== undefined && cfg.migrationSchemaPaths.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. @@ -437,46 +431,6 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // precedes the call because the high-level boundary intentionally exposes // no partially-provisioned resource to the handler. yield* output.raw(diffingMessage, "stderr"); - let declarativeFiles: ReadonlyArray | undefined; - let declarativeManifest: LegacyPgDeltaExportManifest | undefined; - if (pgDelta.implementation === "next" && resolved.isLocal) { - if (cfg.migrationSchemaPaths !== undefined && cfg.migrationSchemaPaths.length > 0) { - declarativeFiles = yield* LegacyLoadPgDeltaSqlPaths( - fs, - path, - cliConfig.workdir, - cfg.migrationSchemaPaths, - ); - } else { - const declarativeDirSetting = legacyResolveDeclarativeDir(path, cfg.pgDelta); - const declarativeDir = path.isAbsolute(declarativeDirSetting) - ? declarativeDirSetting - : path.join(cliConfig.workdir, declarativeDirSetting); - const hasDeclarativeDir = cfg.pgDelta.enabled - ? yield* fs.exists(declarativeDir).pipe(Effect.orElseSucceed(() => false)) - : false; - if (hasDeclarativeDir) { - const loaded = yield* LegacyLoadPgDeltaSqlFiles(fs, path, declarativeDir); - if (loaded.length > 0) { - declarativeFiles = loaded; - declarativeManifest = yield* LegacyReadPgDeltaExportManifest( - fs, - path, - declarativeDir, - ); - } - } else { - const schemasDir = path.join(cliConfig.workdir, "supabase", "schemas"); - const hasSchemasDir = yield* fs - .exists(schemasDir) - .pipe(Effect.orElseSucceed(() => false)); - if (hasSchemasDir) { - const loaded = yield* LegacyLoadPgDeltaSqlFiles(fs, path, schemasDir); - if (loaded.length > 0) declarativeFiles = loaded; - } - } - } - } const result = yield* pgDelta.diffDatabase({ context: ctx, target: { @@ -485,12 +439,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy connection: resolved.conn, connectOptions: { isLocal: resolved.isLocal, dnsResolver }, }, - targetLocal: resolved.isLocal, schema: flags.schema, formatOptions, ...(connType === "linked" && linkedRef !== undefined ? { projectRef: linkedRef } : {}), - ...(declarativeFiles !== undefined ? { declarativeFiles } : {}), - ...(declarativeManifest !== undefined ? { declarativeManifest } : {}), debug: legacyIsPgDeltaDebugEnabled(), }); return { sql: result.sql, files: result.files }; @@ -498,8 +449,6 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy : yield* Effect.gen(function* () { const shadow = yield* seam.provisionShadow({ mode: "diff", - targetLocal: resolved.isLocal, - usePgDelta: false, schema: flags.schema, ...(connType === "linked" && linkedRef !== undefined ? { projectRef: linkedRef } : {}), }); @@ -507,7 +456,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy yield* output.raw(diffingMessage, "stderr"); const sql = yield* legacyDiffMigra(ctx, { source: shadow.sourceUrl, - target: shadow.targetUrlOverride ?? targetUrl, + target: targetUrl, schema: flags.schema, connectOptions: { isLocal: resolved.isLocal, dnsResolver }, }); @@ -559,6 +508,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy ? 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); 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 04a283fd31..0d079d186e 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 @@ -47,7 +47,6 @@ interface SetupOpts { // Exact suffixes returned by the next renderer, parallel to `diffFiles`. readonly diffSuffixes?: ReadonlyArray; readonly pgDeltaImplementation?: "legacy" | "next"; - readonly targetOverride?: string; readonly oom?: boolean; // edge-runtime OOMs; the bash fallback returns `diffSql` readonly delegateStdout?: string; // stdout returned by a captured Go-delegate run readonly networkId?: string; // --network-id value forwarded to docker runs @@ -62,8 +61,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const provisionCalls: Array<{ mode: string; - targetLocal: boolean; - usePgDelta: boolean; projectRef?: string; }> = []; const removedContainers: string[] = []; @@ -72,12 +69,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { execInherit: () => Effect.succeed(0), 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"), @@ -100,10 +96,17 @@ function setup(workdir: string, opts: SetupOpts = {}) { ? { suffix: opts.diffSuffixes[index] } : {}), sql: file.sql, - transactional: true, + transactionMode: "transactional" as const, })) : sql.length > 0 - ? [{ sequence: 1, name: "schema_changes", sql, transactional: true }] + ? [ + { + sequence: 1, + name: "schema_changes", + sql, + transactionMode: "transactional" as const, + }, + ] : []; return { changes: files.length > 0, @@ -280,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..."); @@ -297,7 +300,6 @@ describe("legacy db diff", () => { expect(s.provisionCalls).toEqual([]); expect(s.databaseDiffCalls).toHaveLength(1); expect(s.databaseDiffCalls[0]).toMatchObject({ - targetLocal: true, schema: ["public"], target: { kind: "database", @@ -319,7 +321,7 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("next local diff gives configured schema_paths precedence", () => { + 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"), @@ -343,55 +345,10 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); - expect(s.databaseDiffCalls[0]?.declarativeFiles).toEqual([ - { name: "supabase/configured.sql", sql: "create table configured ();\n" }, - ]); - expect(s.databaseDiffCalls[0]?.declarativeManifest).toBeUndefined(); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("next local diff loads the enabled declarative directory and manifest", () => { - const declarativeDir = join(tmp.current, "supabase", "database"); - mkdirSync(declarativeDir, { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - ["[experimental.pgdelta]", "enabled = true", ""].join("\n"), - ); - writeFileSync(join(declarativeDir, "public.sql"), "create table public.t ();\n"); - writeFileSync( - join(declarativeDir, ".pgdelta-export.json"), - JSON.stringify({ formatVersion: 1, redactSecrets: true, scope: "database" }), - ); - 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]?.declarativeFiles).toEqual([ - { name: "public.sql", sql: "create table public.t ();\n" }, - ]); - expect(s.databaseDiffCalls[0]?.declarativeManifest).toEqual({ - redactSecrets: true, - scope: "database", - }); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect("next local diff falls back to supabase/schemas", () => { - const schemasDir = join(tmp.current, "supabase", "schemas"); - mkdirSync(schemasDir, { recursive: true }); - writeFileSync(join(schemasDir, "fallback.sql"), "create table fallback ();\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]?.declarativeFiles).toEqual([ - { name: "fallback.sql", sql: "create table fallback ();\n" }, - ]); - expect(s.databaseDiffCalls[0]?.declarativeManifest).toBeUndefined(); + 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 target"); + expect(stdout(s.out)).toBe("create table result ();\n\n"); }).pipe(Effect.provide(s.layer)); }); @@ -450,7 +407,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)); @@ -464,16 +420,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"); @@ -609,16 +562,37 @@ 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 target"); 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)); }); 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 a5c49e8a65..fb28ffca76 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,8 @@ 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 target. 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 @@ -120,6 +122,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 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 baa9830f33..def9a3cf3e 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"; @@ -63,14 +64,7 @@ import type { LegacyPgDeltaContext } from "../shared/legacy-pgdelta.ts"; import { LegacyPgDeltaEngine, type LegacyPgDeltaDatabaseEndpoint, - type LegacyPgDeltaExportManifest, - type LegacyPgDeltaSqlFile, } from "../shared/legacy-pgdelta-engine.service.ts"; -import { - LegacyLoadPgDeltaSqlFiles, - LegacyLoadPgDeltaSqlPaths, - LegacyReadPgDeltaExportManifest, -} from "../shared/legacy-pgdelta-files.ts"; import { legacyIsPgDeltaDebugEnabled } from "../shared/legacy-pgdelta.ts"; import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; @@ -477,6 +471,14 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy return; } + if ( + !delegatesExperimentalPull && + toml.migrationSchemaPaths !== undefined && + toml.migrationSchemaPaths.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` @@ -639,69 +641,26 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy : "Diffing schemas...\n", "stderr", ); - let declarativeFiles: ReadonlyArray | undefined; - let declarativeManifest: LegacyPgDeltaExportManifest | undefined; - if (usePgDeltaDiff && pgDeltaEngine.implementation === "next" && resolved.isLocal) { - if (toml.migrationSchemaPaths !== undefined && toml.migrationSchemaPaths.length > 0) { - declarativeFiles = yield* LegacyLoadPgDeltaSqlPaths( - fs, - path, - cliConfig.workdir, - toml.migrationSchemaPaths, - ); - } else { - const declarativeDirSetting = legacyResolveDeclarativeDir(path, toml.pgDelta); - const declarativeDir = path.isAbsolute(declarativeDirSetting) - ? declarativeDirSetting - : path.join(cliConfig.workdir, declarativeDirSetting); - const hasDeclarativeDir = toml.pgDelta.enabled - ? yield* fs.exists(declarativeDir).pipe(Effect.orElseSucceed(() => false)) - : false; - if (hasDeclarativeDir) { - const loaded = yield* LegacyLoadPgDeltaSqlFiles(fs, path, declarativeDir); - if (loaded.length > 0) { - declarativeFiles = loaded; - declarativeManifest = yield* LegacyReadPgDeltaExportManifest( - fs, - path, - declarativeDir, - ); - } - } else { - const schemasDir = path.join(cliConfig.workdir, "supabase", "schemas"); - if (yield* fs.exists(schemasDir).pipe(Effect.orElseSucceed(() => false))) { - const loaded = yield* LegacyLoadPgDeltaSqlFiles(fs, path, schemasDir); - if (loaded.length > 0) declarativeFiles = loaded; - } - } - } - } - const diffOutcome = usePgDeltaDiff ? yield* withPoolerFallback(targetEndpoint, (target) => pgDeltaEngine.diffDatabase({ context: ctx, target, - targetLocal: resolved.isLocal, schema: diffSchema, formatOptions, projectRef: connType === "linked" ? linkedRef : undefined, debug: legacyIsPgDeltaDebugEnabled(), - ...(declarativeFiles !== undefined ? { declarativeFiles } : {}), - ...(declarativeManifest !== undefined ? { declarativeManifest } : {}), }), ) : yield* Effect.gen(function* () { const shadow = yield* seam.provisionShadow({ mode: "diff", - targetLocal: resolved.isLocal, - usePgDelta: false, schema: diffSchema, projectRef: connType === "linked" ? linkedRef : undefined, }); return yield* legacyDiffMigra(ctx, { source: shadow.sourceUrl, - target: shadow.targetUrlOverride ?? targetUrl, + target: targetUrl, schema: diffSchema, connectOptions: { isLocal: resolved.isLocal, dnsResolver }, }).pipe( @@ -794,6 +753,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy 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 dbbdea699a..af8d4f24e5 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 @@ -77,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 @@ -116,8 +115,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const provisionCalls: Array<{ mode: string; - usePgDelta: boolean; - targetLocal: boolean; projectRef?: string; }> = []; const removedContainers: string[] = []; @@ -126,12 +123,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { execInherit: () => Effect.succeed(0), 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"), @@ -145,7 +141,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { operation: "diff" | "export"; targetRef: string; projectRef?: string; - targetLocal?: boolean; }> = []; let engineDiffCount = 0; const pgDeltaEngine = Layer.succeed( @@ -158,7 +153,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { operation: "diff", targetRef: input.target.ref, projectRef: input.projectRef, - targetLocal: input.targetLocal, }); engineDiffCount += 1; if (opts.edgeFailFirstWith !== undefined && engineDiffCount === 1) { @@ -203,11 +197,14 @@ function setup(workdir: string, opts: SetupOpts = {}) { 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, - transactional: transactionMode !== "none", + transactionMode, }; }); return Effect.succeed({ @@ -630,8 +627,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", @@ -639,8 +640,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 target"); // 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"); @@ -835,7 +837,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")); @@ -1644,20 +1646,16 @@ describe("legacy db pull", () => { }).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"); 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 8c98dddee1..56bea69784 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 @@ -93,6 +93,13 @@ are mutually exclusive. ## 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. +- Projects upgrading from the legacy workflow should regenerate declarations or + add declarations for every extension they intend to retain before syncing. + Review the existing drop-statement warning before applying destructive changes. - `--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. 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 3597b17a80..2adfb3bcac 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 @@ -947,14 +947,14 @@ describe("legacy db schema declarative sync integration", () => { name: "transactional", suffix: "_1", sql: "ALTER TABLE a ADD COLUMN b int;", - transactional: true, + transactionMode: "transactional", }, { sequence: 2, name: "non_transactional", suffix: "_2", sql: "ALTER TYPE mood ADD VALUE 'fine';", - transactional: false, + transactionMode: "none", }, ], }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-diff-engine.ts b/apps/cli/src/legacy/commands/db/shared/legacy-diff-engine.ts index 12079b9ab8..ef1528387a 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-diff-engine.ts +++ b/apps/cli/src/legacy/commands/db/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 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.\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/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts index 3505e73c36..6ad91f2e12 100644 --- 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 @@ -8,6 +8,7 @@ import { LegacyPgDeltaEngineError, type LegacyPgDeltaDiffResult, type LegacyPgDeltaEndpoint, + type LegacyPgDeltaTransactionMode, } from "./legacy-pgdelta-engine.service.ts"; import { legacyDeclarativeExportPgDelta, @@ -26,7 +27,7 @@ function normalizeDiff( readonly files: ReadonlyArray<{ readonly order: number; readonly name: string; - readonly transactionMode: string; + readonly transactionMode: LegacyPgDeltaTransactionMode; readonly sql: string; }>; }, @@ -39,7 +40,7 @@ function normalizeDiff( sequence: file.order, name: file.name, sql: file.sql, - transactional: file.transactionMode !== "non-transactional", + transactionMode: file.transactionMode, })), ...(debug ? { debug: { stderr: result.stderr } } : {}), }; @@ -98,8 +99,6 @@ export const legacyPgDeltaLegacyEngineLayer = Layer.effect( Effect.gen(function* () { const shadow = yield* seam.provisionShadow({ mode: "diff", - targetLocal: input.targetLocal, - usePgDelta: true, schema: input.schema, ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), }); @@ -114,7 +113,7 @@ export const legacyPgDeltaLegacyEngineLayer = Layer.effect( return yield* provideRuntime( legacyDiffPgDelta(input.context, { sourceRef: shadow.sourceUrl, - targetRef: shadow.targetUrlOverride ?? input.target.ref, + targetRef: input.target.ref, schema: input.schema, formatOptions: input.formatOptions, }), 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 index 766a453dfa..886aa020df 100644 --- 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 @@ -62,7 +62,7 @@ function normalizeNextDiff( readonly sequence: number; readonly suffix: string | null; readonly sql: string; - readonly transactional: boolean; + readonly transactionMode: "transactional" | "none"; readonly actionCount: number; }>; readonly debug?: { @@ -81,7 +81,7 @@ function normalizeNextDiff( name: `segment_${file.sequence}`, suffix: file.suffix, sql: file.sql, - transactional: file.transactional, + transactionMode: file.transactionMode, actionCount: file.actionCount, })), ...(result.debug !== undefined @@ -238,8 +238,7 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), }); const migrations = parseLegacyConnectionString(shadow.migrationsUrl); - const declarative = parseLegacyConnectionString(shadow.declarativeUrl); - if (migrations === undefined || declarative === undefined) { + if (migrations === undefined) { return yield* Effect.fail( new LegacyPgDeltaEngineError({ message: "failed to parse pg-delta next shadow database URL", @@ -247,37 +246,6 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( }), ); } - if (input.declarativeFiles !== undefined) { - 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.declarativeFiles, - allowDrops: true, - debug: input.debug, - reorder: true, - ...legacyPgDeltaNextIsolatedShadowPlanOptions, - schema: input.schema, - ...(input.declarativeManifest !== undefined - ? { manifest: input.declarativeManifest } - : {}), - }); - const debugDirectory = - result.debug !== undefined - ? yield* saveDebugArtifacts(input.context.cwd, "declarativePlan", { - ...result.debug, - diagnostics: result.diagnostics, - }) - : undefined; - yield* rejectBlockingDiagnostic("declarativePlan", result.diagnostics); - return normalizeNextDiff(result, debugDirectory); - } const migrationsPool = yield* legacyAcquirePgPool(migrations, { isLocal: true, dnsResolver: "native", 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 index a5163f7263..a88bddbf32 100644 --- 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 @@ -37,6 +37,8 @@ export interface LegacyPgDeltaExportManifest { readonly files?: ReadonlyArray; } +export type LegacyPgDeltaTransactionMode = "transactional" | "none"; + export interface LegacyPgDeltaRenderedFile { readonly sequence: number; /** Legacy semantic unit name. */ @@ -44,7 +46,7 @@ export interface LegacyPgDeltaRenderedFile { /** Next renderer's exact filename suffix (`null`, `_1`, `_2`, ...). */ readonly suffix?: string | null; readonly sql: string; - readonly transactional: boolean; + readonly transactionMode: LegacyPgDeltaTransactionMode; readonly actionCount?: number; } @@ -79,10 +81,6 @@ export interface LegacyPgDeltaExplicitDiffInput extends LegacyPgDeltaCommonInput export interface LegacyPgDeltaDatabaseDiffInput extends LegacyPgDeltaCommonInput { readonly target: LegacyPgDeltaDatabaseEndpoint; - readonly targetLocal: boolean; - /** Present when the local desired state is declarative SQL rather than the live DB. */ - readonly declarativeFiles?: ReadonlyArray; - readonly declarativeManifest?: LegacyPgDeltaExportManifest; } interface LegacyPgDeltaDeclarativeExportInput extends LegacyPgDeltaCommonInput { 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 index d24e0562f4..368a8cf20e 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts @@ -1,10 +1,9 @@ -import { Data, Effect, type FileSystem, Option, type Path } from "effect"; +import { Data, Effect, type FileSystem, type Path } from "effect"; import type { LegacyPgDeltaExportManifest, LegacyPgDeltaSqlFile, } from "./legacy-pgdelta-engine.service.ts"; -import { legacyResolveSqlGlobFiles } from "../../../shared/legacy-seed-ops.ts"; const EXPORT_MANIFEST_FILE = ".pgdelta-export.json"; @@ -134,35 +133,3 @@ export const LegacyLoadPgDeltaSqlFiles = Effect.fnUntraced(function* ( } return files; }); - -/** Loads `[db.migrations].schema_paths` in configured pattern/application order. */ -export const LegacyLoadPgDeltaSqlPaths = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - patterns: ReadonlyArray, -) { - const resolved = yield* legacyResolveSqlGlobFiles(fs, path, patterns, workdir); - if (resolved.files.length === 0) { - return yield* Effect.fail( - filesError( - Option.isSome(resolved.warning) - ? resolved.warning.value - : "no declarative schema files matched schema_paths", - ), - ); - } - const files: Array = []; - for (const file of resolved.files) { - const full = path.isAbsolute(file) ? file : path.join(workdir, file); - const sql = yield* fs - .readFileString(full) - .pipe( - Effect.mapError((error) => - filesError(`failed to read declarative schema file: ${error.message}`), - ), - ); - files.push({ name: file.split("\\").join("/"), 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 35d9de726c..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 { @@ -62,11 +63,21 @@ export const legacyWritePgDeltaMigrations = ( 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) => { 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 index dd59eee4a8..395dfcbd0b 100644 --- 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 @@ -270,7 +270,7 @@ function legacyNormalizePgDeltaNextRenderedFiles( sequence: index + 1, suffix: file.suffix, sql: file.contents, - transactional: file.transactional, + transactionMode: file.transactional ? "transactional" : "none", actionCount: file.actionCount, })); } 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 index 80b6cbb130..0c4e235bcf 100644 --- 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 @@ -1,6 +1,8 @@ import type { Pool } from "pg"; import { Context, Data, type Effect } from "effect"; +import type { LegacyPgDeltaTransactionMode } from "./legacy-pgdelta-engine.service.ts"; + export type LegacyPgDeltaNextOperation = | "diff" | "declarativeExport" @@ -28,7 +30,7 @@ export interface LegacyPgDeltaNextRenderedFile { readonly sequence: number; readonly suffix: string | null; readonly sql: string; - readonly transactional: boolean; + readonly transactionMode: LegacyPgDeltaTransactionMode; readonly actionCount: number; } 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 index 5c9d1c51c2..a276e2fec6 100644 --- 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 @@ -302,14 +302,14 @@ describe("LegacyPgDeltaNextAdapter", () => { sequence: 1, suffix: "_1", sql: "begin source-facts;\n", - transactional: true, + transactionMode: "transactional", actionCount: 2, }, { sequence: 2, suffix: "_2", sql: "alter desired-facts;\n", - transactional: false, + transactionMode: "none", actionCount: 1, }, ]); 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 index 8a0f7aafc1..7de8a4176c 100644 --- 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 @@ -558,6 +558,9 @@ describeDockerLive("pg-delta next declarative extension baseline (live)", () => 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); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts index c64f9f624a..63a7d1169f 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/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/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index d9e87dda80..5d9ed36c47 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 @@ -394,7 +394,7 @@ const makeLegacyDeclarativeSeam = (resolved: BinaryResolution) => ); }), ), - provisionShadow: ({ mode, targetLocal, usePgDelta, schema, projectRef }) => + provisionShadow: ({ mode, schema, projectRef }) => Effect.scoped( Effect.gen(function* () { if (!("found" in resolved)) { @@ -410,8 +410,6 @@ const makeLegacyDeclarativeSeam = (resolved: BinaryResolution) => "__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` @@ -461,9 +459,7 @@ const makeLegacyDeclarativeSeam = (resolved: BinaryResolution) => bytes.set(chunk, offset); offset += chunk.length; } - // stdout is three newline-separated lines: container id, source URL, - // and an optional second-database URL. Legacy diff uses the third URL - // only when its local-target declarative branch redirects the target. + // 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 @@ -476,7 +472,6 @@ const makeLegacyDeclarativeSeam = (resolved: BinaryResolution) => 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()); } @@ -493,10 +488,6 @@ const makeLegacyDeclarativeSeam = (resolved: BinaryResolution) => return { container, sourceUrl: legacyInjectPostgresPassword(sourceUrl, password), - targetUrlOverride: - targetOverride.length > 0 - ? legacyInjectPostgresPassword(targetOverride, password) - : undefined, } satisfies LegacyShadowSource; }), ), diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts index 847b063dec..0f89404d99 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 @@ -20,11 +20,6 @@ export interface LegacyShadowSource { readonly container: string; /** The diff source Postgres URL (the provisioned shadow). */ readonly sourceUrl: string; - /** - * Optional second live database. For legacy diff it replaces the target with - * `contrib_regression` after Go applies declarative schemas. - */ - readonly targetUrlOverride: string | undefined; } /** The independently hosted databases used by the pg-delta next planner. */ @@ -101,14 +96,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 diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts index bf2fb2c9bc..cf341f5384 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts @@ -21,6 +21,7 @@ import { LegacyDeclarativeParseOutputError, LegacyPgDeltaDiffParseError, } from "./legacy-pgdelta.errors.ts"; +import type { LegacyPgDeltaTransactionMode } from "./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; + } + >; } /** @@ -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/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/shared/legacy-db-config.service.ts b/apps/cli/src/legacy/shared/legacy-db-config.service.ts index 2b28e4397e..f95597fbce 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.service.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.service.ts @@ -7,6 +7,7 @@ import type { } from "../config/legacy-project-ref.errors.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, @@ -41,6 +42,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-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 a6b3bef1e9..c99b8e5919 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -46,6 +46,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 @@ -92,6 +94,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 } @@ -194,6 +200,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-seed-ops.ts b/apps/cli/src/legacy/shared/legacy-seed-ops.ts index 3419a30df4..bbea4d4fcc 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-ops.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-ops.ts @@ -109,9 +109,6 @@ const legacyGlobSeedFiles = Effect.fnUntraced(function* ( } satisfies LegacyGlobResult; }); -/** Shared Go-compatible SQL glob expansion for migration/declarative consumers. */ -export const legacyResolveSqlGlobFiles = legacyGlobSeedFiles; - const toSlash = (p: string): string => p.replaceAll("\\", "/"); /** Splits a forward-slashed path into its directory prefix and final element. */ diff --git a/packages/api/scripts/generate.ts b/packages/api/scripts/generate.ts index f6ff09526b..6f5b277949 100644 --- a/packages/api/scripts/generate.ts +++ b/packages/api/scripts/generate.ts @@ -215,6 +215,14 @@ function identifier(value: string): string { const UUID_PATTERN = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; +// The Management API's checked-in OpenAPI currently carries a Z-only pattern +// alongside `format: "date-time"`. OpenAPI date-time is RFC3339, whose time +// offset may be either Z or a numeric `+/-HH:MM` offset. Normalize every such +// node so generated response contracts accept the complete wire format while +// retaining strict calendar/time validation. +const RFC3339_DATE_TIME_PATTERN = + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$"; + // Keys that we want to strip from a schema node because they describe // documentation / example values rather than the value's shape. JSON Schema's // `default` is a primitive (or array/object) literal used for documentation — @@ -290,6 +298,10 @@ export function sanitizeOpenApiSchema( sanitized.pattern = UUID_PATTERN; } + if (sanitized.type === "string" && sanitized.format === "date-time") { + sanitized.pattern = RFC3339_DATE_TIME_PATTERN; + } + return sanitized; } diff --git a/packages/api/scripts/generate.unit.test.ts b/packages/api/scripts/generate.unit.test.ts index 5b4dbe720e..4c6038b210 100644 --- a/packages/api/scripts/generate.unit.test.ts +++ b/packages/api/scripts/generate.unit.test.ts @@ -28,11 +28,31 @@ describe("generate", () => { expect(renderOpenApiSchema({ type: "string", format: "email", nullable: true })).toBe( 'Schema.Union([Schema.String.annotate({ "format": "email" }), Schema.Null])', ); - expect(renderOpenApiSchema({ type: "string", format: "date-time", nullable: true })).toBe( - 'Schema.Union([Schema.String.annotate({ "format": "date-time" }), Schema.Null])', + expect(renderOpenApiSchema({ type: "string", format: "date-time", nullable: true })).toEqual( + expect.stringContaining('Schema.String.annotate({ "format": "date-time" }).check'), ); }); + test("normalizes date-time schemas to strict RFC3339 timestamps with numeric offsets", () => { + const sanitized = sanitizeOpenApiSchema({ + type: "string", + format: "date-time", + pattern: "Z-only-pattern-from-upstream", + }); + expect(typeof sanitized.pattern).toBe("string"); + if (typeof sanitized.pattern !== "string") { + throw new Error("Expected sanitized date-time pattern"); + } + const dateTime = new RegExp(sanitized.pattern); + + expect(dateTime.test("2026-08-07T10:11:12Z")).toBe(true); + expect(dateTime.test("2026-08-07T10:11:12+00:00")).toBe(true); + expect(dateTime.test("2026-08-07T12:41:12+02:30")).toBe(true); + expect(dateTime.test("2026-08-07 10:11:12Z")).toBe(false); + expect(dateTime.test("2026-08-07T10:11:12+25:00")).toBe(false); + expect(dateTime.test("not-a-timestamp")).toBe(false); + }); + test("accepts booleans for string-encoded boolean query parameters", () => { expect( normalizeQueryParameterSchema( 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/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 589f6895ea..7458f460f7 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -81,11 +81,11 @@ export const ApiKeyResponse = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -96,11 +96,11 @@ export const ApiKeyResponse = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -197,32 +197,32 @@ export const BranchResponse = Schema.Struct({ created_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), updated_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), review_requested_at: Schema.optionalKey( Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -232,11 +232,11 @@ export const BranchResponse = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -1112,32 +1112,32 @@ export const V1CreateABranchOutput = Schema.Struct({ created_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), updated_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), review_requested_at: Schema.optionalKey( Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -1147,11 +1147,11 @@ export const V1CreateABranchOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -1553,21 +1553,21 @@ export const V1CreateLegacySigningKeyOutput = Schema.Struct({ created_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), updated_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), }); @@ -1664,11 +1664,11 @@ export const V1CreateProjectApiKeyOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -1679,11 +1679,11 @@ export const V1CreateProjectApiKeyOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -1880,21 +1880,21 @@ export const V1CreateProjectSigningKeyOutput = Schema.Struct({ created_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), updated_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), }); @@ -1957,11 +1957,11 @@ export const V1CreateRestorePointOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -2258,11 +2258,11 @@ export const V1DeleteProjectApiKeyOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -2273,11 +2273,11 @@ export const V1DeleteProjectApiKeyOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -2613,32 +2613,32 @@ export const V1GetABranchOutput = Schema.Struct({ created_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), updated_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), review_requested_at: Schema.optionalKey( Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -2648,11 +2648,11 @@ export const V1GetABranchOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -3680,11 +3680,11 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -3881,11 +3881,11 @@ export const V1GetBackupScheduleOutput = Schema.Struct({ }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), }); @@ -4183,21 +4183,21 @@ export const V1GetLegacySigningKeyOutput = Schema.Struct({ created_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), updated_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), }); @@ -4235,11 +4235,11 @@ export const V1GetNetworkRestrictionsOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -4247,11 +4247,11 @@ export const V1GetNetworkRestrictionsOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -5020,11 +5020,11 @@ export const V1GetProjectApiKeyOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -5035,11 +5035,11 @@ export const V1GetProjectApiKeyOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -5193,11 +5193,11 @@ export const V1GetProjectLogsInput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -5205,11 +5205,11 @@ export const V1GetProjectLogsInput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -5251,11 +5251,11 @@ export const V1GetProjectLogsAllInput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -5263,11 +5263,11 @@ export const V1GetProjectLogsAllInput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -5426,21 +5426,21 @@ export const V1GetProjectSigningKeyOutput = Schema.Struct({ created_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), updated_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), }); @@ -5474,21 +5474,21 @@ export const V1GetProjectSigningKeysOutput = Schema.Struct({ created_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), updated_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), }), @@ -5560,11 +5560,11 @@ export const V1GetProjectUsageApiCountOutput = Schema.Struct({ timestamp: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), total_auth_requests: Schema.Number.check( @@ -5829,11 +5829,11 @@ export const V1GetRestorePointOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -7057,11 +7057,11 @@ export const V1PatchNetworkRestrictionsOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -7069,11 +7069,11 @@ export const V1PatchNetworkRestrictionsOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -7218,21 +7218,21 @@ export const V1RemoveProjectSigningKeyOutput = Schema.Struct({ created_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), updated_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), }); @@ -7564,32 +7564,32 @@ export const V1UpdateABranchConfigOutput = Schema.Struct({ created_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), updated_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), review_requested_at: Schema.optionalKey( Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -7599,11 +7599,11 @@ export const V1UpdateABranchConfigOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -8365,11 +8365,11 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -9118,11 +9118,11 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -9228,11 +9228,11 @@ export const V1UpdateBackupScheduleOutput = Schema.Struct({ }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), }); @@ -9497,11 +9497,11 @@ export const V1UpdateNetworkRestrictionsOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -9509,11 +9509,11 @@ export const V1UpdateNetworkRestrictionsOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), ), @@ -10149,11 +10149,11 @@ export const V1UpdateProjectApiKeyOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -10164,11 +10164,11 @@ export const V1UpdateProjectApiKeyOutput = Schema.Struct({ Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), Schema.Null, @@ -10227,21 +10227,21 @@ export const V1UpdateProjectSigningKeyOutput = Schema.Struct({ created_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), updated_at: Schema.String.annotate({ format: "date-time" }).check( Schema.isPattern( new RegExp( - "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", ), ).annotate({ expected: - "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "a string matching the RegExp ^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", }), ), }); 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 76515ed47b..952086ef62 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; From 958d36ceb2e3ffde77851dc8f745492c6685c6ac Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 7 Aug 2026 20:33:26 +0200 Subject: [PATCH 6/7] test(cli): update transactional Go mocks --- apps/cli-go/internal/db/push/push_test.go | 18 +++++-- apps/cli-go/internal/db/reset/reset_test.go | 54 +++++++++++++++---- apps/cli-go/internal/db/start/start_test.go | 4 +- .../internal/migration/apply/apply_test.go | 12 ++++- .../internal/migration/down/down_test.go | 36 ++++++++++--- .../internal/migration/squash/squash_test.go | 16 +++++- .../legacy/branch/switch_/switch__test.go | 26 +++++++-- 7 files changed, 133 insertions(+), 33 deletions(-) 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_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_test.go b/apps/cli-go/internal/db/start/start_test.go index f65d4bea45..04de8b93f6 100644 --- a/apps/cli-go/internal/db/start/start_test.go +++ b/apps/cli-go/internal/db/start/start_test.go @@ -25,8 +25,8 @@ import ( "github.com/supabase/cli/pkg/pgtest" ) -func mockTransactionalStatement(conn *pgtest.MockConn, statement, reply string) *pgtest.MockConn { - return conn.Query("BEGIN"). +func mockTransactionalStatement(conn *pgtest.MockConn, statement, reply string) { + conn.Query("BEGIN"). Reply("BEGIN"). Query(statement). Reply(reply). 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_test.go b/apps/cli-go/internal/migration/squash/squash_test.go index 91d13066a5..bc44bb9648 100644 --- a/apps/cli-go/internal/migration/squash/squash_test.go +++ b/apps/cli-go/internal/migration/squash/squash_test.go @@ -88,14 +88,22 @@ func TestSquashCommand(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", "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", @@ -317,10 +325,14 @@ func TestSquashMigrations(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", "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/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;"). From d4861957ee293c0a032283a30bd6e3d97112a01e Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 7 Aug 2026 20:40:10 +0200 Subject: [PATCH 7/7] chore(cli): remove unused diff helper --- apps/cli-go/internal/db/diff/diff.go | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index 1906f76c41..f43581b092 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -227,18 +227,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)) -}