Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .repos/effect
Submodule effect updated 749 files
2 changes: 1 addition & 1 deletion .repos/lalph
Submodule lalph updated 825 files
2 changes: 1 addition & 1 deletion .repos/t3code
Submodule t3code updated 735 files
49 changes: 49 additions & 0 deletions apps/cli-go/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,52 @@ go test ./... -race -v -count=1 -failfast
## API client

The Supabase API client is generated from OpenAPI spec. See [our guide](api/README.md) for updating the client and types.

## Testing local pg-delta builds

To exercise unpublished `@supabase/pg-delta` changes inside CLI edge-runtime scripts (`db pull`, `db diff`, `db push`, etc.), publish a local build via Verdaccio in [pg-toolbelt](https://github.com/supabase/pg-toolbelt) and point the CLI at that registry.

### 1. Start Verdaccio (pg-toolbelt)

```sh
cd pg-toolbelt
bun run verdaccio:start
```

Verdaccio listens on `http://localhost:4873`. `@supabase/*` packages you publish locally are served from local storage; other `@supabase/*` dependencies (for example `@supabase/pg-topo`) are proxied to npmjs.

### 2. Publish a local pg-delta build

After changing `packages/pg-delta`:

```sh
bun run pg-delta:publish-local \
--write-version-to=/path/to/test-project/supabase/.temp/pgdelta-version
```

This publishes a fresh `0.0.0-local.<timestamp>` version and restores `package.json` afterward. The version file tells the CLI which npm version to request (`EffectivePgDeltaNpmVersion`).

Re-run whenever you change pg-delta source.

### 3. Run the CLI against the local registry

Set `PGDELTA_NPM_REGISTRY` to a URL reachable **from inside the edge-runtime Docker container**:

```sh
# Docker Desktop (macOS / Windows)
export PGDELTA_NPM_REGISTRY=http://host.docker.internal:4873

# Linux (Docker 20.10+)
export PGDELTA_NPM_REGISTRY=http://host.docker.internal:4873
# or: export PGDELTA_NPM_REGISTRY=http://172.17.0.1:4873
```

Then run any pg-delta-backed command, for example:

```sh
supabase db pull --db-url "$DATABASE_URL" --diff-engine pg-delta
```

When set, the CLI injects a scoped `.npmrc` and forwards `NPM_CONFIG_REGISTRY` into the edge-runtime container (`PgDeltaNpmRegistryOption` in `internal/utils/pgdelta_local.go`).

Unset `PGDELTA_NPM_REGISTRY` to return to the npmjs version pinned in config / `supabase/.temp/pgdelta-version`.
9 changes: 8 additions & 1 deletion apps/cli-go/cmd/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ var (
if usePgDeltaDiff {
pullDiffer = diff.DiffPgDelta
}
useDeclarativePgDelta := shouldUsePgDelta()
useDeclarativePgDelta := shouldUseDeclarativePgDeltaPull(usePgDeltaDiff)
return pull.Run(cmd.Context(), schema, flags.DbConfig, name, useDeclarativePgDelta, usePgDeltaDiff, pullDiffer, afero.NewOsFs())
},
PostRun: func(cmd *cobra.Command, args []string) {
Expand Down Expand Up @@ -358,6 +358,13 @@ func shouldUsePgDelta() bool {
return utils.IsPgDeltaEnabled() || usePgDelta || viper.GetBool("EXPERIMENTAL_PG_DELTA")
}

func shouldUseDeclarativePgDeltaPull(usePgDeltaDiff bool) bool {
if usePgDeltaDiff {
return false
}
return shouldUsePgDelta() || usePgDelta
}

func init() {
// Build branch command
dbBranchCmd.AddCommand(dbBranchCreateCmd)
Expand Down
29 changes: 29 additions & 0 deletions apps/cli-go/cmd/db_pull_routing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package cmd

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestShouldUseDeclarativePgDeltaPull(t *testing.T) {
t.Run("migration pg-delta wins over experimental config", func(t *testing.T) {
usePgDelta = false
t.Cleanup(func() { usePgDelta = false })
assert.False(t, shouldUseDeclarativePgDeltaPull(true))
})

t.Run("experimental config without diff-engine uses declarative", func(t *testing.T) {
usePgDelta = false
t.Cleanup(func() { usePgDelta = false })
// Simulate config enabled via shouldUsePgDelta's IsPgDeltaEnabled path indirectly:
// when neither flag nor config is set, declarative is off.
assert.False(t, shouldUseDeclarativePgDeltaPull(false))
})

t.Run("use-pg-delta flag forces declarative", func(t *testing.T) {
usePgDelta = true
t.Cleanup(func() { usePgDelta = false })
assert.True(t, shouldUseDeclarativePgDeltaPull(false))
})
}
28 changes: 26 additions & 2 deletions apps/cli-go/docs/supabase/db/pull.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ Requires your local project to be linked to a remote database by running `supaba

Optionally, a new row can be inserted into the migration history table to reflect the current state of the remote database.

If no entries exist in the migration history table, `pg_dump` will be used to capture all contents of the remote schemas you have created. Otherwise, this command will only diff schema changes against the remote database, similar to running `db diff --linked`.
If no entries exist in the migration history table, the default diff engine uses `pg_dump` to capture all contents of the remote schemas you have created. Otherwise, this command will only diff schema changes against the remote database, similar to running `db diff --linked`.

Pass `--diff-engine pg-delta` to keep the migration-file `db pull` workflow while using pg-delta for the shadow diff step. Pass `--use-pg-delta` to switch to the declarative pg-delta export workflow instead.
Pass `--diff-engine pg-delta` to keep the migration-file `db pull` workflow while using pg-delta for the shadow diff step. On initial pull, pg-delta replaces `pg_dump` and produces the full migration from the shadow diff alone. Pass `--use-pg-delta` to switch to the declarative pg-delta export workflow instead.

When `[experimental.pgdelta] enabled = true` is set in `config.toml`, `db pull` defaults to the declarative export path. Explicit `--diff-engine pg-delta` still selects the migration-file workflow.

When pulling from a remote database with `--db-url`, prefer a direct connection (`db.<project-ref>.supabase.co:5432`) over the connection pooler so pg-delta can introspect the full catalog reliably.

## Debugging empty pg-delta pulls

If `db pull --diff-engine pg-delta` reports `No schema changes found` but you expect schema output, set `PGDELTA_DEBUG=1` before running the command. Unlike `--debug`, this keeps SSL enabled for remote Supabase connections.

```sh
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/<timestamp>/`:

- `source-catalog.json` — shadow database baseline pg-delta extracted
- `target-catalog.json` — remote database pg-delta extracted
- `pgdelta-stderr.txt` — pg-delta script diagnostics (statement count, schemas)
- `connection.txt` — redacted connection metadata
- `error.txt` — error summary

Catalog files are not written during normal `db pull` runs. The `.temp/pgdelta` directory is also used by migration catalog caching (`db push`, local `db start`) when `[experimental.pgdelta] enabled = true`.

For TLS tracing without disabling SSL, use `SUPABASE_SSL_DEBUG=true` alongside `PGDELTA_DEBUG=1`.
32 changes: 24 additions & 8 deletions apps/cli-go/internal/db/declarative/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ const (

// DebugBundle collects diagnostic artifacts when a declarative operation fails.
type DebugBundle struct {
ID string // timestamp-based unique ID (e.g. "20240414-044403")
SourceRef string // path to source catalog
TargetRef string // path to target catalog
MigrationSQL string // generated migration (if available)
Error error // the error that occurred
Migrations []string // list of local migration files
ID string // timestamp-based unique ID (e.g. "20240414-044403")
SourceRef string // path to source catalog
TargetRef string // path to target catalog
SourceCatalog string // inline source catalog JSON (optional)
TargetCatalog string // inline target catalog JSON (optional)
MigrationSQL string // generated migration (if available)
PgDeltaStderr string // edge-runtime stderr from pg-delta scripts
ConnectionInfo string // redacted connection metadata
Error error // the error that occurred
Migrations []string // list of local migration files
}

// SaveDebugBundle writes diagnostic artifacts to .temp/pgdelta/debug/<ID>/ and
Expand All @@ -38,14 +42,18 @@ func SaveDebugBundle(bundle DebugBundle, fsys afero.Fs) (string, error) {
}

// Copy source catalog if available
if len(bundle.SourceRef) > 0 {
if len(bundle.SourceCatalog) > 0 {
_ = utils.WriteFile(filepath.Join(debugDir, "source-catalog.json"), []byte(bundle.SourceCatalog), fsys)
} else if len(bundle.SourceRef) > 0 {
if data, err := afero.ReadFile(fsys, bundle.SourceRef); err == nil {
_ = utils.WriteFile(filepath.Join(debugDir, "source-catalog.json"), data, fsys)
}
}

// Copy target catalog if available
if len(bundle.TargetRef) > 0 {
if len(bundle.TargetCatalog) > 0 {
_ = utils.WriteFile(filepath.Join(debugDir, "target-catalog.json"), []byte(bundle.TargetCatalog), fsys)
} else if len(bundle.TargetRef) > 0 {
if data, err := afero.ReadFile(fsys, bundle.TargetRef); err == nil {
_ = utils.WriteFile(filepath.Join(debugDir, "target-catalog.json"), data, fsys)
}
Expand All @@ -61,6 +69,14 @@ func SaveDebugBundle(bundle DebugBundle, fsys afero.Fs) (string, error) {
_ = utils.WriteFile(filepath.Join(debugDir, "error.txt"), []byte(bundle.Error.Error()), fsys)
}

if len(bundle.PgDeltaStderr) > 0 {
_ = utils.WriteFile(filepath.Join(debugDir, "pgdelta-stderr.txt"), []byte(bundle.PgDeltaStderr), fsys)
}

if len(bundle.ConnectionInfo) > 0 {
_ = utils.WriteFile(filepath.Join(debugDir, "connection.txt"), []byte(bundle.ConnectionInfo), fsys)
}

// Copy migration files
if len(bundle.Migrations) > 0 {
migrationsDir := filepath.Join(debugDir, "migrations")
Expand Down
44 changes: 34 additions & 10 deletions apps/cli-go/internal/db/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ import (
type DiffFunc func(context.Context, pgconn.Config, pgconn.Config, []string, ...func(*pgx.ConnConfig)) (string, error)

func Run(ctx context.Context, schema []string, file string, config pgconn.Config, differ DiffFunc, usePgDelta bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (err error) {
out, err := DiffDatabase(ctx, schema, config, os.Stderr, fsys, differ, usePgDelta, options...)
result, err := DiffDatabase(ctx, schema, config, os.Stderr, fsys, differ, usePgDelta, options...)
if err != nil {
return err
}
out := result.SQL
branch := utils.GetGitBranch(fsys)
fmt.Fprintln(os.Stderr, "Finished "+utils.Aqua("supabase db diff")+" on branch "+utils.Aqua(branch)+".\n")
if err := SaveDiff(out, file, fsys); err != nil {
Expand Down Expand Up @@ -161,18 +162,18 @@ func MigrateShadowDatabase(ctx context.Context, container string, fsys afero.Fs,
return migration.ApplyMigrations(ctx, migrations, conn, afero.NewIOFS(fsys))
}

func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w io.Writer, fsys afero.Fs, differ DiffFunc, usePgDelta bool, options ...func(*pgx.ConnConfig)) (string, error) {
func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w io.Writer, fsys afero.Fs, differ DiffFunc, usePgDelta bool, options ...func(*pgx.ConnConfig)) (DatabaseDiff, error) {
fmt.Fprintln(w, "Creating shadow database...")
shadow, err := CreateShadowDatabase(ctx, utils.Config.Db.ShadowPort)
if err != nil {
return "", err
return DatabaseDiff{}, err
}
defer utils.DockerRemove(shadow)
if err := start.WaitForHealthyService(ctx, utils.Config.Db.HealthTimeout, shadow); err != nil {
return "", err
return DatabaseDiff{}, err
}
if err := MigrateShadowDatabase(ctx, shadow, fsys, options...); err != nil {
return "", err
return DatabaseDiff{}, err
}
shadowConfig := pgconn.Config{
Host: utils.Config.Hostname,
Expand All @@ -189,20 +190,20 @@ func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w
declDir := utils.GetDeclarativeDir()
if exists, _ := afero.DirExists(fsys, declDir); exists {
if err := pgdelta.ApplyDeclarative(ctx, config, fsys); err != nil {
return "", err
return DatabaseDiff{}, err
}
} else {
if err := migrateBaseDatabase(ctx, config, declared, fsys, options...); err != nil {
return "", err
return DatabaseDiff{}, err
}
}
} else {
if err := migrateBaseDatabase(ctx, config, declared, fsys, options...); err != nil {
return "", err
return DatabaseDiff{}, err
}
}
} else if err != nil {
return "", err
return DatabaseDiff{}, err
}
}
// Load all user defined schemas
Expand All @@ -211,7 +212,30 @@ func DiffDatabase(ctx context.Context, schema []string, config pgconn.Config, w
} else {
fmt.Fprintln(w, "Diffing schemas...")
}
return differ(ctx, shadowConfig, config, schema, options...)
var debugCapture *PgDeltaDebugCapture
if IsPgDeltaDebugEnabled() && usePgDelta {
if snapshot, exportErr := exportCatalogPgDelta(ctx, utils.ToPostgresURL(shadowConfig), "postgres", options...); exportErr == nil {
debugCapture = &PgDeltaDebugCapture{SourceCatalog: snapshot}
} else {
fmt.Fprintf(w, "Warning: failed to export shadow pg-delta catalog: %v\n", exportErr)
}
}
if IsPgDeltaDebugEnabled() && usePgDelta {
result, err := DiffPgDeltaRefDetailed(ctx, utils.ToPostgresURL(shadowConfig), utils.ToPostgresURL(config), schema, pgDeltaFormatOptions(), options...)
if err != nil {
return DatabaseDiff{}, err
}
if debugCapture == nil {
debugCapture = &PgDeltaDebugCapture{}
}
debugCapture.Stderr = result.Stderr
return DatabaseDiff{SQL: result.SQL, Debug: debugCapture}, nil
}
output, err := differ(ctx, shadowConfig, config, schema, options...)
if err != nil {
return DatabaseDiff{}, err
}
return DatabaseDiff{SQL: output, Debug: debugCapture}, nil
}

func migrateBaseDatabase(ctx context.Context, config pgconn.Config, migrations []string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error {
Expand Down
16 changes: 8 additions & 8 deletions apps/cli-go/internal/db/diff/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,9 @@ func TestDiffDatabase(t *testing.T) {
Get("/v" + utils.Docker.ClientVersion() + "/images/" + utils.GetRegistryImageUrl(utils.Config.Db.Image) + "/json").
ReplyError(errNetwork)
// Run test
diff, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false)
result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false)
// Check error
assert.Empty(t, diff)
assert.Empty(t, result)
assert.ErrorIs(t, err, errNetwork)
assert.Empty(t, apitest.ListUnmatchedRequests())
})
Expand Down Expand Up @@ -234,9 +234,9 @@ func TestDiffDatabase(t *testing.T) {
Delete("/v" + utils.Docker.ClientVersion() + "/containers/test-shadow-db").
Reply(http.StatusOK)
// Run test
diff, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false)
result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false)
// Check error
assert.Empty(t, diff)
assert.Empty(t, result)
assert.ErrorContains(t, err, "test-shadow-db container is not running: exited")
assert.Empty(t, apitest.ListUnmatchedRequests())
})
Expand Down Expand Up @@ -266,9 +266,9 @@ func TestDiffDatabase(t *testing.T) {
conn.Query(utils.GlobalsSql).
ReplyError(pgerrcode.DuplicateSchema, `schema "public" already exists`)
// Run test
diff, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false, conn.Intercept)
result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false, conn.Intercept)
// Check error
assert.Empty(t, diff)
assert.Empty(t, result)
assert.ErrorContains(t, err, `ERROR: schema "public" already exists (SQLSTATE 42P06)
At statement: 0
create schema public`)
Expand Down Expand Up @@ -321,7 +321,7 @@ create schema public`)
Query(migration.INSERT_MIGRATION_VERSION, "0", "test", []string{sql}).
Reply("INSERT 0 1")
// Run test
diff, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false, func(cc *pgx.ConnConfig) {
result, err := DiffDatabase(context.Background(), []string{"public"}, dbConfig, io.Discard, fsys, DiffSchemaMigra, false, func(cc *pgx.ConnConfig) {
if cc.Host == dbConfig.Host {
// Fake a SSL error when connecting to target database
cc.LookupFunc = func(ctx context.Context, host string) (addrs []string, err error) {
Expand All @@ -333,7 +333,7 @@ create schema public`)
}
})
// Check error
assert.Empty(t, diff)
assert.Empty(t, result)
assert.ErrorContains(t, err, "error diffing schema")
assert.Empty(t, apitest.ListUnmatchedRequests())
})
Expand Down
Loading
Loading