From 210d0e5e8a88315762ac43aa8f521f570686833a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 10:30:10 +0000 Subject: [PATCH 1/4] fix(cli): provision platform baseline in declarative baseline catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baseline catalog (catalog-baseline-.json) was exported from a bare shadow database before any platform setup ran. That same catalog is reused by getMigrationsCatalogRef as the diff source when there are zero local migrations. After the declarative target started provisioning the Supabase platform baseline (auth/storage/realtime), the no-migrations sync diffed an empty-image source against a platform-baseline + declarative target, so platform-managed objects surfaced as spurious additions/drops in generated migrations. Provision the platform baseline in getGenerateBaselineCatalogRef before exporting, so the baseline catalog consistently represents "platform baseline, no user migrations" — identical to MigrateShadowDatabase with zero migrations and in parity with the declarative target. The now-redundant second setup call in Generate's cache-warm path is removed since the reused shadow already has the baseline. RED before fix: expected: []string{"setup", "export"} actual : []string{"export"} --- .../internal/db/declarative/declarative.go | 46 +++++++++++------ .../db/declarative/declarative_test.go | 51 +++++++++++++++++-- 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/apps/cli-go/internal/db/declarative/declarative.go b/apps/cli-go/internal/db/declarative/declarative.go index ecd464f48d..173356894e 100644 --- a/apps/cli-go/internal/db/declarative/declarative.go +++ b/apps/cli-go/internal/db/declarative/declarative.go @@ -31,10 +31,13 @@ const ( // pgDeltaTempDir namespaces pg-delta artifacts under .temp to make ownership // and cleanup intent explicit. pgDeltaTempDir = "pgdelta" - // baselineCatalogName caches the catalog of an empty shadow database. + // baselineCatalogName caches the catalog of a shadow database with the Supabase + // platform baseline (auth/storage/realtime) provisioned but no user migrations + // applied — equivalent to diff.MigrateShadowDatabase with zero migrations. // - // It is used as the "source" baseline when generating declarative files from - // a real database target. + // It is used as the "source" baseline both when generating declarative files + // from a real database target and when syncing with no local migrations, so it + // must stay in parity with the declarative target's platform baseline. baselineCatalogName = "catalog-baseline-%s.json" // declarativeCatalogName stores catalogs keyed by declarative-content hash. declarativeCatalogName = "catalog-%s-declarative-%s-%d.json" @@ -63,6 +66,10 @@ var ( // Supabase-managed dependencies (auth.sessions, auth.jwt(), ...) resolve. It is // a package var so tests can inject a no-op without a real shadow database. setupShadowDatabase = diff.SetupShadowDatabase + // createShadow provisions a healthy shadow database container. It is a package + // var so tests can exercise the baseline/migrations/declarative paths without a + // real Docker daemon. + createShadow = createShadowContainer // generateBaselineCatalogRefResolver allows Generate to reuse a freshly // provisioned baseline shadow for declarative cache warmup. generateBaselineCatalogRefResolver = getGenerateBaselineCatalogRef @@ -123,13 +130,9 @@ func Generate(ctx context.Context, schema []string, config pgconn.Config, overwr // can reuse it without provisioning another shadow database. if !noCache { if baseline.shadow != nil { - // The baseline catalog was already exported from the empty image - // baseline above. Set up the platform baseline on the reused shadow - // before applying declarative schemas so Supabase-managed dependencies - // (auth.sessions, auth.jwt(), ...) resolve during cache warmup. - if err := setupShadowDatabase(ctx, baseline.shadow.container, fsys, options...); err != nil { - return err - } + // The reused baseline shadow already has the platform baseline + // provisioned (getGenerateBaselineCatalogRef), so apply declarative + // schemas directly on top of it without setting it up again. hash, err := hashDeclarativeSchemas(fsys) if err != nil { return err @@ -308,6 +311,18 @@ func getGenerateBaselineCatalogRef(ctx context.Context, noCache bool, fsys afero container: shadowID, config: config, } + // Provision the Supabase platform baseline before exporting so the baseline + // catalog represents "platform baseline, no user migrations" — the same + // semantics as diff.MigrateShadowDatabase with zero migrations. This baseline is + // reused as the diff source by both Generate (against the live database) and + // sync-with-no-migrations (getMigrationsCatalogRef). Its starting point must + // match the declarative target, which also sets up the platform baseline; + // otherwise platform objects (auth/storage/realtime) surface as spurious + // additions in generated migrations. + if err := setupShadowDatabase(ctx, shadow.container, fsys, options...); err != nil { + shadow.cleanup() + return generateBaselineCatalogRef{}, err + } snapshot, err := exportCatalog(ctx, utils.ToPostgresURL(config), "postgres", options...) if err != nil { shadow.cleanup() @@ -345,8 +360,9 @@ func getMigrationsCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, p if err != nil { return "", err } - // For sync with no local migrations, reuse an existing baseline - // snapshot instead of provisioning a fresh shadow database. + // For sync with no local migrations, the migrations catalog is just the + // platform baseline. Reuse an existing baseline snapshot (which now also + // represents the platform baseline) instead of provisioning a fresh shadow. if !noCache && len(migrations) == 0 { baselinePath := filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, baselineVersionToken())) if ok, err := afero.Exists(fsys, baselinePath); err != nil { @@ -439,9 +455,9 @@ func writeDeclarativeCatalogFromConfig(ctx context.Context, config pgconn.Config return path, nil } -// createShadow provisions and health-checks the temporary Postgres container -// used by declarative conversion and diff operations. -func createShadow(ctx context.Context) (string, pgconn.Config, error) { +// createShadowContainer provisions and health-checks the temporary Postgres +// container used by declarative conversion and diff operations. +func createShadowContainer(ctx context.Context) (string, pgconn.Config, error) { fmt.Fprintln(os.Stderr, "Creating shadow database...") shadow, err := diff.CreateShadowDatabase(ctx, utils.Config.Db.ShadowPort) if err != nil { diff --git a/apps/cli-go/internal/db/declarative/declarative_test.go b/apps/cli-go/internal/db/declarative/declarative_test.go index f5c7da857f..65c6f8dcb8 100644 --- a/apps/cli-go/internal/db/declarative/declarative_test.go +++ b/apps/cli-go/internal/db/declarative/declarative_test.go @@ -278,6 +278,52 @@ func TestGetMigrationsCatalogRefUsesBaselineWhenNoMigrations(t *testing.T) { assert.Equal(t, baselinePath, ref) } +func TestGetGenerateBaselineCatalogRefSetsUpPlatformBaseline(t *testing.T) { + // The baseline catalog is reused as the diff source for sync-with-no-migrations + // (getMigrationsCatalogRef). Since the declarative target now provisions the + // Supabase platform baseline, the baseline catalog must represent the same + // platform baseline (not the empty image) so platform objects cancel out of the + // diff instead of surfacing as spurious additions. Assert setup runs before the + // catalog is exported. + fsys := afero.NewMemMapFs() + require.NoError(t, fsys.MkdirAll(filepath.Join(utils.TempDir, "pgdelta"), 0755)) + + originalCreateShadow := createShadow + originalSetupShadow := setupShadowDatabase + originalExportCatalog := exportCatalog + t.Cleanup(func() { + createShadow = originalCreateShadow + setupShadowDatabase = originalSetupShadow + exportCatalog = originalExportCatalog + }) + + shadowConfig := pgconn.Config{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"} + createShadow = func(_ context.Context) (string, pgconn.Config, error) { + return "test-shadow-container", shadowConfig, nil + } + var order []string + setupShadowDatabase = func(_ context.Context, container string, _ afero.Fs, _ ...func(*pgx.ConnConfig)) error { + assert.Equal(t, "test-shadow-container", container) + order = append(order, "setup") + return nil + } + exportCatalog = func(_ context.Context, _ string, role string, _ ...func(*pgx.ConnConfig)) (string, error) { + assert.Equal(t, "postgres", role) + order = append(order, "export") + return `{"version":1}`, nil + } + + ref, err := getGenerateBaselineCatalogRef(t.Context(), false, fsys) + require.NoError(t, err) + assert.Equal(t, []string{"setup", "export"}, order, "platform baseline must be provisioned before the baseline catalog is exported") + + cachePath := filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, baselineVersionToken())) + assert.Equal(t, cachePath, ref.ref) + cached, err := afero.ReadFile(fsys, cachePath) + require.NoError(t, err) + assert.JSONEq(t, `{"version":1}`, string(cached)) +} + func TestHashDeclarativeSchemasChangesWithContent(t *testing.T) { fsys := afero.NewMemMapFs() p1 := filepath.Join(utils.GetDeclarativeDir(), "schemas", "public", "tables", "a.sql") @@ -466,9 +512,8 @@ func TestGenerateReusesBaselineShadowForDeclarativeWarmup(t *testing.T) { }, nil } setupCalled := false - setupShadowDatabase = func(_ context.Context, container string, _ afero.Fs, _ ...func(*pgx.ConnConfig)) error { + setupShadowDatabase = func(_ context.Context, _ string, _ afero.Fs, _ ...func(*pgx.ConnConfig)) error { setupCalled = true - assert.Equal(t, shadowContainer, container) return nil } declarativeExportRef = func(_ context.Context, sourceRef, _ string, _ []string, _ string, _ ...func(*pgx.ConnConfig)) (diff.DeclarativeOutput, error) { @@ -498,7 +543,7 @@ func TestGenerateReusesBaselineShadowForDeclarativeWarmup(t *testing.T) { err := Generate(t.Context(), nil, pgconn.Config{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"}, true, false, fsys) require.NoError(t, err) - assert.True(t, setupCalled, "generate should set up the platform baseline on the reused shadow before applying declarative schema") + assert.False(t, setupCalled, "generate must not re-run platform setup on the reused shadow; the baseline resolver already provisioned it") assert.True(t, applyCalled, "generate should apply declarative schema using reused baseline shadow") assert.False(t, fallbackCalled, "fallback declarative resolver should not run when baseline shadow is reusable") From 9f63a3c80fd0dbbbc67e47b2c7ab8322d9e6fb02 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 10:46:59 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test(cli):=20cover=20generate=E2=86=92sync?= =?UTF-8?q?=20platform=20baseline=20cancellation=20end-to-end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a full-flow test driving Generate -> DiffDeclarativeToMigrations with no local migrations through the public command functions. It models pg-delta catalog content by shadow setup/apply state and stubs the Docker/pg-delta seams (the established cli-go pattern), so it runs in the standard `go test ./...` CI job. The test pins the regression fixed in the previous commit: generate must write the baseline catalog from the platform baseline, and a no-migration sync must reuse it as the diff source so platform-managed objects (auth/storage/realtime) cancel out instead of leaking into the generated migration. To diff through the public path without the real pg-delta runtime, diff.DiffPgDeltaRef is now an injectable package var (diffPgDeltaRef), matching the existing exportCatalog / declarativeExportRef / applyDeclarative seams. RED (pre-fix wiring) — generated migration leaked platform objects: "create auth;\ncreate public.profiles;\ncreate realtime;\ncreate storage;\n" should not contain "create auth;" GREEN (with fix) — only "create public.profiles;" is generated. --- .../internal/db/declarative/declarative.go | 6 +- .../db/declarative/declarative_flow_test.go | 166 ++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 apps/cli-go/internal/db/declarative/declarative_flow_test.go diff --git a/apps/cli-go/internal/db/declarative/declarative.go b/apps/cli-go/internal/db/declarative/declarative.go index 173356894e..24f62e2030 100644 --- a/apps/cli-go/internal/db/declarative/declarative.go +++ b/apps/cli-go/internal/db/declarative/declarative.go @@ -61,6 +61,10 @@ var ( exportCatalog = diff.ExportCatalogPgDelta applyDeclarative = pgdelta.ApplyDeclarative declarativeExportRef = diff.DeclarativeExportPgDeltaRef + // diffPgDeltaRef diffs a source catalog against a target catalog. It is a + // package var so tests can exercise the full generate -> sync flow without the + // real pg-delta runtime. + diffPgDeltaRef = diff.DiffPgDeltaRef // setupShadowDatabase provisions the Supabase platform baseline (auth/storage/ // realtime) on a shadow database before declarative schemas are applied, so // Supabase-managed dependencies (auth.sessions, auth.jwt(), ...) resolve. It is @@ -175,7 +179,7 @@ func DiffDeclarativeToMigrations(ctx context.Context, schema []string, noCache b if err != nil { return nil, err } - out, err := diff.DiffPgDeltaRef(ctx, sourceRef, targetRef, schema, pgDeltaFormatOptions(), options...) + out, err := diffPgDeltaRef(ctx, sourceRef, targetRef, schema, pgDeltaFormatOptions(), options...) if err != nil { return nil, err } diff --git a/apps/cli-go/internal/db/declarative/declarative_flow_test.go b/apps/cli-go/internal/db/declarative/declarative_flow_test.go new file mode 100644 index 0000000000..43ea2cda38 --- /dev/null +++ b/apps/cli-go/internal/db/declarative/declarative_flow_test.go @@ -0,0 +1,166 @@ +package declarative + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/jackc/pgconn" + "github.com/jackc/pgx/v4" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/db/diff" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/config" +) + +// catalogObjects models a pg-delta catalog snapshot as the set of object names +// present in a shadow database, so the full generate -> sync flow can be +// exercised without the real pg-delta runtime while still proving that platform +// objects cancel out of the generated diff. +type catalogObjects struct { + Objects []string `json:"objects"` +} + +func marshalCatalog(objects []string) string { + sorted := append([]string(nil), objects...) + sort.Strings(sorted) + out, _ := json.Marshal(catalogObjects{Objects: sorted}) + return string(out) +} + +func readCatalogObjects(t *testing.T, fsys afero.Fs, path string) []string { + t.Helper() + raw, err := afero.ReadFile(fsys, path) + require.NoError(t, err) + var parsed catalogObjects + require.NoError(t, json.Unmarshal(raw, &parsed)) + return parsed.Objects +} + +// TestGenerateThenSyncWithNoMigrationsCancelsPlatformObjects exercises the full +// generate -> sync (no local migrations) flow end to end through the public +// command functions. The bug it guards: generate writes the baseline catalog +// (catalog-baseline-.json) that sync reuses as its diff source when +// there are no local migrations. If that baseline is captured from a bare image +// instead of the platform baseline, platform-managed objects (auth/storage/ +// realtime) leak into the generated migration even though the user only declared +// a single table. The Docker and pg-delta seams are stubbed (the established +// cli-go pattern) so the test runs in the standard `go test ./...` CI job. +func TestGenerateThenSyncWithNoMigrationsCancelsPlatformObjects(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("[db]\n"), 0644)) + + originalPgDelta := utils.Config.Experimental.PgDelta + originalImage := utils.Config.Db.Image + originalCreateShadow := createShadow + originalSetupShadow := setupShadowDatabase + originalExportCatalog := exportCatalog + originalApplyDeclarative := applyDeclarative + originalExportRef := declarativeExportRef + originalDiffRef := diffPgDeltaRef + t.Cleanup(func() { + utils.Config.Experimental.PgDelta = originalPgDelta + utils.Config.Db.Image = originalImage + createShadow = originalCreateShadow + setupShadowDatabase = originalSetupShadow + exportCatalog = originalExportCatalog + applyDeclarative = originalApplyDeclarative + declarativeExportRef = originalExportRef + diffPgDeltaRef = originalDiffRef + }) + + utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} + utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" + + shadowConfig := pgconn.Config{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"} + // Model the evolving shadow state: platform baseline provisioning adds the + // auth/storage/realtime schemas, declarative apply adds the user's table. + platformReady := false + declarativeApplied := false + platformObjects := []string{"auth", "realtime", "storage"} + const userObject = "public.profiles" + + createShadow = func(_ context.Context) (string, pgconn.Config, error) { + return "test-shadow-container", shadowConfig, nil + } + setupShadowDatabase = func(_ context.Context, _ string, _ afero.Fs, _ ...func(*pgx.ConnConfig)) error { + platformReady = true + return nil + } + applyDeclarative = func(_ context.Context, _ pgconn.Config, _ afero.Fs) error { + declarativeApplied = true + return nil + } + exportCatalog = func(_ context.Context, _ string, role string, _ ...func(*pgx.ConnConfig)) (string, error) { + assert.Equal(t, "postgres", role) + var objects []string + if platformReady { + objects = append(objects, platformObjects...) + } + if declarativeApplied { + objects = append(objects, userObject) + } + return marshalCatalog(objects), nil + } + // generate exports declarative files from the live database; emit a single + // table that depends on auth so WriteDeclarativeSchemas + hashing have content. + declarativeExportRef = func(_ context.Context, _, _ string, _ []string, _ string, _ ...func(*pgx.ConnConfig)) (diff.DeclarativeOutput, error) { + return diff.DeclarativeOutput{ + Files: []diff.DeclarativeFile{ + {Path: "schemas/public/tables/profiles.sql", SQL: "create table public.profiles (id uuid primary key references auth.users(id));"}, + }, + }, nil + } + // Stand in for the pg-delta diff: emit DDL for objects present in the target + // catalog but missing from the source catalog. Platform objects that exist in + // both sides must not appear. + diffPgDeltaRef = func(_ context.Context, sourceRef, targetRef string, _ []string, _ string, _ ...func(*pgx.ConnConfig)) (string, error) { + source := readCatalogObjects(t, fsys, sourceRef) + target := readCatalogObjects(t, fsys, targetRef) + inSource := make(map[string]bool, len(source)) + for _, obj := range source { + inSource[obj] = true + } + var added []string + for _, obj := range target { + if !inSource[obj] { + added = append(added, obj) + } + } + sort.Strings(added) + var sb strings.Builder + for _, obj := range added { + sb.WriteString("create " + obj + ";\n") + } + return sb.String(), nil + } + + liveConfig := pgconn.Config{Host: "db.test.supabase.co", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"} + + // 1. generate writes declarative files and warms the baseline + declarative caches. + require.NoError(t, Generate(t.Context(), nil, liveConfig, true, false, fsys)) + + // The baseline catalog reused by sync must represent the platform baseline, + // not a bare image. + baselinePath := filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, baselineVersionToken())) + assert.ElementsMatch(t, platformObjects, readCatalogObjects(t, fsys, baselinePath), + "baseline catalog must capture the platform baseline (auth/storage/realtime)") + + // 2. sync with no local migrations diffs the warmed declarative catalog against + // the baseline. Platform objects exist on both sides, so only the user's table + // should surface in the generated migration. + result, err := DiffDeclarativeToMigrations(t.Context(), nil, false, fsys) + require.NoError(t, err) + assert.Equal(t, baselinePath, result.SourceRef, "no-migration sync must source from the platform baseline catalog") + assert.Contains(t, result.DiffSQL, "public.profiles", "the user's declared table should be generated") + for _, platform := range platformObjects { + assert.NotContains(t, result.DiffSQL, "create "+platform+";", + "platform object %q must cancel out instead of leaking into the migration", platform) + } +} From 3dfa1ca491fbc0ffd9d39ad4e2ed1b9712d6c70a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 11:48:52 +0000 Subject: [PATCH 3/4] fix(cli): key declarative baseline catalog by setup inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform baseline catalog is produced by SetupDatabase (initSchema + ApplyApiPrivileges + vault secrets + supabase/roles.sql), so its contents are a function of the Postgres image AND the project inputs setup consumes — not the image alone. The cache key was still only the image version token, which meant: - A bare baseline written by a pre-fix CLI (keyed by the same token) was reused as the no-migration sync source, re-leaking platform objects until .temp/pgdelta was cleared. - Changing auto_expose_new_tables, vault secrets, or roles.sql after a generate left sync reusing a baseline built from the old config. Key the baseline catalog by a hash of the image token plus those setup inputs (baselineCatalogKey), resolved through a single baselineCatalogPath helper used by both the generate writer and the no-migration sync reader. Old bare-baseline filenames no longer match, so they are never reused, and config/roles changes self-invalidate the cache. Addresses Codex review feedback on #5521. --- .../internal/db/declarative/declarative.go | 68 +++++++++++++++++- .../db/declarative/declarative_flow_test.go | 5 +- .../db/declarative/declarative_test.go | 72 +++++++++++++++++-- 3 files changed, 134 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/internal/db/declarative/declarative.go b/apps/cli-go/internal/db/declarative/declarative.go index 24f62e2030..b47c4e5329 100644 --- a/apps/cli-go/internal/db/declarative/declarative.go +++ b/apps/cli-go/internal/db/declarative/declarative.go @@ -37,7 +37,10 @@ const ( // // It is used as the "source" baseline both when generating declarative files // from a real database target and when syncing with no local migrations, so it - // must stay in parity with the declarative target's platform baseline. + // must stay in parity with the declarative target's platform baseline. The "%s" + // is a key (see baselineCatalogKey) derived from the image plus every setup + // input that shapes the baseline, so config/roles changes self-invalidate the + // cache rather than reusing a stale snapshot. baselineCatalogName = "catalog-baseline-%s.json" // declarativeCatalogName stores catalogs keyed by declarative-content hash. declarativeCatalogName = "catalog-%s-declarative-%s-%d.json" @@ -301,7 +304,10 @@ func updateDeclarativeSchemaPathsConfig(fsys afero.Fs) error { } func getGenerateBaselineCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (generateBaselineCatalogRef, error) { - cachePath := filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, baselineVersionToken())) + cachePath, err := baselineCatalogPath(fsys) + if err != nil { + return generateBaselineCatalogRef{}, err + } if !noCache { if ok, err := afero.Exists(fsys, cachePath); err == nil && ok { return generateBaselineCatalogRef{ref: cachePath}, nil @@ -368,7 +374,10 @@ func getMigrationsCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, p // platform baseline. Reuse an existing baseline snapshot (which now also // represents the platform baseline) instead of provisioning a fresh shadow. if !noCache && len(migrations) == 0 { - baselinePath := filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, baselineVersionToken())) + baselinePath, err := baselineCatalogPath(fsys) + if err != nil { + return "", err + } if ok, err := afero.Exists(fsys, baselinePath); err != nil { return "", err } else if ok { @@ -648,6 +657,59 @@ func baselineVersionToken() string { return catalogPrefixRegexp.ReplaceAllString(image, "-") } +// baselineCatalogKey derives the cache key for the platform baseline catalog. +// +// The baseline is the result of diff.SetupShadowDatabase, i.e. start.SetupDatabase: +// initSchema + ApplyApiPrivileges + vault secrets + supabase/roles.sql. Its catalog +// is therefore a function of the Postgres image AND the project inputs that setup +// consumes, not the image alone. Keying only by image would let a stale baseline — +// produced by a pre-platform-baseline CLI, a different image, or a different +// api/vault/roles config — be reused as the no-migration diff source, leaking +// spurious objects into generated migrations until .temp/pgdelta is cleared. Fold +// those inputs into the key so the cache self-invalidates. The image token stays as +// a human-readable prefix; old bare-baseline files keyed by the token alone no +// longer match, so they are never reused. +func baselineCatalogKey(fsys afero.Fs) (string, error) { + token := baselineVersionToken() + h := sha256.New() + fmt.Fprintln(h, token) + // api.auto_expose_new_tables drives ApplyApiPrivileges (default ACLs). + if v := utils.Config.Api.AutoExposeNewTables; v != nil { + fmt.Fprintf(h, "auto_expose_new_tables=%t\n", *v) + } else { + fmt.Fprintln(h, "auto_expose_new_tables=unset") + } + // Vault secrets are created during setup; key on their names. + names := make([]string, 0, len(utils.Config.Db.Vault)) + for name := range utils.Config.Db.Vault { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + fmt.Fprintf(h, "vault=%s\n", name) + } + // supabase/roles.sql is seeded into the baseline. + roles, err := afero.ReadFile(fsys, utils.CustomRolesPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + if _, err := h.Write(roles); err != nil { + return "", err + } + return token + "-" + hex.EncodeToString(h.Sum(nil))[:12], nil +} + +// baselineCatalogPath returns the on-disk path of the platform baseline catalog +// for the current project inputs. Both the generate writer and the no-migration +// sync reader resolve the path through this helper so they always agree. +func baselineCatalogPath(fsys afero.Fs) (string, error) { + key, err := baselineCatalogKey(fsys) + if err != nil { + return "", err + } + return filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, key)), nil +} + func sanitizedCatalogPrefix(prefix string) string { prefix = strings.TrimSpace(prefix) if len(prefix) == 0 { diff --git a/apps/cli-go/internal/db/declarative/declarative_flow_test.go b/apps/cli-go/internal/db/declarative/declarative_flow_test.go index 43ea2cda38..3aaf09dca6 100644 --- a/apps/cli-go/internal/db/declarative/declarative_flow_test.go +++ b/apps/cli-go/internal/db/declarative/declarative_flow_test.go @@ -3,8 +3,6 @@ package declarative import ( "context" "encoding/json" - "fmt" - "path/filepath" "sort" "strings" "testing" @@ -148,7 +146,8 @@ func TestGenerateThenSyncWithNoMigrationsCancelsPlatformObjects(t *testing.T) { // The baseline catalog reused by sync must represent the platform baseline, // not a bare image. - baselinePath := filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, baselineVersionToken())) + baselinePath, err := baselineCatalogPath(fsys) + require.NoError(t, err) assert.ElementsMatch(t, platformObjects, readCatalogObjects(t, fsys, baselinePath), "baseline catalog must capture the platform baseline (auth/storage/realtime)") diff --git a/apps/cli-go/internal/db/declarative/declarative_test.go b/apps/cli-go/internal/db/declarative/declarative_test.go index 65c6f8dcb8..c758b91781 100644 --- a/apps/cli-go/internal/db/declarative/declarative_test.go +++ b/apps/cli-go/internal/db/declarative/declarative_test.go @@ -4,8 +4,8 @@ import ( "context" "crypto/sha256" "encoding/hex" - "fmt" "path/filepath" + "strings" "testing" "github.com/jackc/pgconn" @@ -270,7 +270,8 @@ func TestGetMigrationsCatalogRefUsesProjectPrefix(t *testing.T) { func TestGetMigrationsCatalogRefUsesBaselineWhenNoMigrations(t *testing.T) { fsys := afero.NewMemMapFs() require.NoError(t, fsys.MkdirAll(filepath.Join(utils.TempDir, "pgdelta"), 0755)) - baselinePath := filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, baselineVersionToken())) + baselinePath, err := baselineCatalogPath(fsys) + require.NoError(t, err) require.NoError(t, afero.WriteFile(fsys, baselinePath, []byte(`{"version":1}`), 0644)) ref, err := getMigrationsCatalogRef(t.Context(), false, fsys, "local") @@ -317,7 +318,8 @@ func TestGetGenerateBaselineCatalogRefSetsUpPlatformBaseline(t *testing.T) { require.NoError(t, err) assert.Equal(t, []string{"setup", "export"}, order, "platform baseline must be provisioned before the baseline catalog is exported") - cachePath := filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, baselineVersionToken())) + cachePath, err := baselineCatalogPath(fsys) + require.NoError(t, err) assert.Equal(t, cachePath, ref.ref) cached, err := afero.ReadFile(fsys, cachePath) require.NoError(t, err) @@ -377,6 +379,65 @@ func TestCleanupOldDeclarativeCatalogsKeepsLatestTwo(t *testing.T) { assert.True(t, ok) } +func TestBaselineCatalogKeyVariesWithSetupInputs(t *testing.T) { + // The baseline is produced by SetupDatabase, so its cache key must change when + // any setup input changes; otherwise a stale baseline is reused as the diff + // source and platform/config changes leak into generated migrations. + originalImage := utils.Config.Db.Image + originalExpose := utils.Config.Api.AutoExposeNewTables + originalVault := utils.Config.Db.Vault + t.Cleanup(func() { + utils.Config.Db.Image = originalImage + utils.Config.Api.AutoExposeNewTables = originalExpose + utils.Config.Db.Vault = originalVault + }) + utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" + utils.Config.Api.AutoExposeNewTables = nil + utils.Config.Db.Vault = nil + + fsys := afero.NewMemMapFs() + base, err := baselineCatalogKey(fsys) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(base, baselineVersionToken()+"-"), "image token should remain a readable prefix") + + require.NoError(t, afero.WriteFile(fsys, utils.CustomRolesPath, []byte("create role app;"), 0644)) + withRoles, err := baselineCatalogKey(fsys) + require.NoError(t, err) + assert.NotEqual(t, base, withRoles, "roles.sql content must change the key") + + expose := false + utils.Config.Api.AutoExposeNewTables = &expose + withApi, err := baselineCatalogKey(fsys) + require.NoError(t, err) + assert.NotEqual(t, withRoles, withApi, "auto_expose_new_tables must change the key") + + utils.Config.Db.Vault = map[string]config.Secret{"KEY": {}} + withVault, err := baselineCatalogKey(fsys) + require.NoError(t, err) + assert.NotEqual(t, withApi, withVault, "vault secrets must change the key") +} + +func TestBaselineCatalogPathIgnoresLegacyBareBaseline(t *testing.T) { + // A baseline written by a pre-fix CLI is keyed by the image token alone and + // holds a bare-image catalog. The input-hashed key must not collide with it, so + // no-migration sync never reuses the stale snapshot. + originalImage := utils.Config.Db.Image + t.Cleanup(func() { utils.Config.Db.Image = originalImage }) + utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" + + fsys := afero.NewMemMapFs() + require.NoError(t, fsys.MkdirAll(pgDeltaTempPath(), 0755)) + legacy := filepath.Join(pgDeltaTempPath(), "catalog-baseline-"+baselineVersionToken()+".json") + require.NoError(t, afero.WriteFile(fsys, legacy, []byte(`{"objects":[]}`), 0644)) + + current, err := baselineCatalogPath(fsys) + require.NoError(t, err) + assert.NotEqual(t, legacy, current, "input-hashed key must not collide with the legacy bare-baseline filename") + exists, err := afero.Exists(fsys, current) + require.NoError(t, err) + assert.False(t, exists, "stale bare baseline must not satisfy the current cache key") +} + func TestBaselineVersionToken(t *testing.T) { originalImage := utils.Config.Db.Image originalMajor := utils.Config.Db.MajorVersion @@ -397,7 +458,8 @@ func TestGenerateWarmsDeclarativeCatalogCache(t *testing.T) { fsys := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("[db]\n"), 0644)) require.NoError(t, fsys.MkdirAll(filepath.Join(utils.TempDir, "pgdelta"), 0755)) - baselinePath := filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, baselineVersionToken())) + baselinePath, err := baselineCatalogPath(fsys) + require.NoError(t, err) require.NoError(t, afero.WriteFile(fsys, baselinePath, []byte(`{"version":1}`), 0644)) originalPgDelta := utils.Config.Experimental.PgDelta @@ -430,7 +492,7 @@ func TestGenerateWarmsDeclarativeCatalogCache(t *testing.T) { return filepath.Join(utils.TempDir, "pgdelta", "catalog-local-declarative-hash-1000.json"), nil } - err := Generate(t.Context(), nil, pgconn.Config{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"}, true, false, fsys) + err = Generate(t.Context(), nil, pgconn.Config{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"}, true, false, fsys) require.NoError(t, err) assert.True(t, called) } From d264376b3166b11f9c9695ea57b7bcb994fc4ab0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 12:01:15 +0000 Subject: [PATCH 4/4] fix(cli): make all declarative catalog caches setup-input aware The previous commit keyed only the baseline catalog by setup inputs, which left its sibling caches inconsistent and able to pair a freshly keyed source with a stale snapshot from a different platform setup: - baselineCatalogKey ignored the auth/storage/realtime service toggles that gate initSchema, so toggling a service on the same image reused a stale baseline. - The zero-migration branch of getMigrationsCatalogRef fell through to the migrations-hash cache (pgcache), which is not setup-aware, so an old empty-migrations snapshot could be reused as the no-migration sync source. - The warmed declarative target catalog was keyed only by the declarative SQL hash, so a setup change with unchanged SQL paired a fresh source baseline with a stale target. Introduce a single setupInputsToken (image + service toggles + api + vault + roles.sql) and thread it through all three caches: the baseline key, a new declarativeCatalogCacheKey for the warmed target, and the zero-migration path now reads/writes the setup-keyed baseline instead of the migrations-hash cache. Every catalog in the flow is "platform baseline + {nothing | migrations | declarative}", so each cache now self-invalidates on setup changes. Addresses follow-up Codex review feedback on #5521. --- .../internal/db/declarative/declarative.go | 105 +++++++++++++----- .../db/declarative/declarative_test.go | 75 ++++++++++++- 2 files changed, 152 insertions(+), 28 deletions(-) diff --git a/apps/cli-go/internal/db/declarative/declarative.go b/apps/cli-go/internal/db/declarative/declarative.go index b47c4e5329..853779691e 100644 --- a/apps/cli-go/internal/db/declarative/declarative.go +++ b/apps/cli-go/internal/db/declarative/declarative.go @@ -140,7 +140,7 @@ func Generate(ctx context.Context, schema []string, config pgconn.Config, overwr // The reused baseline shadow already has the platform baseline // provisioned (getGenerateBaselineCatalogRef), so apply declarative // schemas directly on top of it without setting it up again. - hash, err := hashDeclarativeSchemas(fsys) + hash, err := declarativeCatalogCacheKey(fsys) if err != nil { return err } @@ -370,25 +370,31 @@ func getMigrationsCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, p if err != nil { return "", err } - // For sync with no local migrations, the migrations catalog is just the - // platform baseline. Reuse an existing baseline snapshot (which now also - // represents the platform baseline) instead of provisioning a fresh shadow. - if !noCache && len(migrations) == 0 { - baselinePath, err := baselineCatalogPath(fsys) + // With no local migrations, the migrations catalog is exactly the platform + // baseline, so it is cached under the setup-keyed baseline path rather than the + // migrations-hash cache. The migrations-hash cache is not setup-aware, so an + // older empty-migrations snapshot from a different platform setup must not be + // reused as the no-migration sync source. + zeroMigrations := len(migrations) == 0 + var baselinePath string + if zeroMigrations { + baselinePath, err = baselineCatalogPath(fsys) if err != nil { return "", err } - if ok, err := afero.Exists(fsys, baselinePath); err != nil { - return "", err - } else if ok { - return baselinePath, nil + if !noCache { + if ok, err := afero.Exists(fsys, baselinePath); err != nil { + return "", err + } else if ok { + return baselinePath, nil + } } } hash, err := pgcache.HashMigrations(fsys) if err != nil { return "", err } - if !noCache { + if !noCache && !zeroMigrations { if cachePath, ok, err := pgcache.ResolveMigrationCatalogPath(fsys, hash, prefix); err != nil { return "", err } else if ok { @@ -410,13 +416,23 @@ func getMigrationsCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, p if noCache { return writeTempCatalog(fsys, noCacheMigrationsCatalogPath, snapshot) } + if zeroMigrations { + // MigrateShadowDatabase with zero migrations == the platform baseline. + if err := ensureTempDir(fsys); err != nil { + return "", err + } + if err := utils.WriteFile(baselinePath, []byte(snapshot), fsys); err != nil { + return "", err + } + return baselinePath, nil + } return pgcache.WriteMigrationCatalogSnapshot(fsys, prefix, hash, snapshot) } // getDeclarativeCatalogRef applies local declarative files to a shadow database // and exports the resulting catalog for diffing. func getDeclarativeCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) (string, error) { - hash, err := hashDeclarativeSchemas(fsys) + hash, err := declarativeCatalogCacheKey(fsys) if err != nil { return "", err } @@ -657,22 +673,24 @@ func baselineVersionToken() string { return catalogPrefixRegexp.ReplaceAllString(image, "-") } -// baselineCatalogKey derives the cache key for the platform baseline catalog. +// setupInputsToken hashes every project input that start.SetupDatabase consumes +// and that therefore shapes the platform baseline: // -// The baseline is the result of diff.SetupShadowDatabase, i.e. start.SetupDatabase: -// initSchema + ApplyApiPrivileges + vault secrets + supabase/roles.sql. Its catalog -// is therefore a function of the Postgres image AND the project inputs that setup -// consumes, not the image alone. Keying only by image would let a stale baseline — -// produced by a pre-platform-baseline CLI, a different image, or a different -// api/vault/roles config — be reused as the no-migration diff source, leaking -// spurious objects into generated migrations until .temp/pgdelta is cleared. Fold -// those inputs into the key so the cache self-invalidates. The image token stays as -// a human-readable prefix; old bare-baseline files keyed by the token alone no -// longer match, so they are never reused. -func baselineCatalogKey(fsys afero.Fs) (string, error) { - token := baselineVersionToken() +// - the Postgres image (initSchema content); +// - the service toggles that gate initSchema — auth/storage/realtime; +// - api.auto_expose_new_tables (ApplyApiPrivileges default ACLs); +// - vault secret names (UpsertVaultSecrets); +// - supabase/roles.sql (SeedGlobals). +// +// Every catalog produced in this flow is "platform baseline + {nothing | migrations +// | declarative}", so each cache folds this token into its key and self-invalidates +// when setup changes instead of reusing a snapshot from a different baseline. +func setupInputsToken(fsys afero.Fs) (string, error) { h := sha256.New() - fmt.Fprintln(h, token) + fmt.Fprintln(h, baselineVersionToken()) + // initSchema conditionally provisions these service schemas. + fmt.Fprintf(h, "auth=%t storage=%t realtime=%t\n", + utils.Config.Auth.Enabled, utils.Config.Storage.Enabled, utils.Config.Realtime.Enabled) // api.auto_expose_new_tables drives ApplyApiPrivileges (default ACLs). if v := utils.Config.Api.AutoExposeNewTables; v != nil { fmt.Fprintf(h, "auto_expose_new_tables=%t\n", *v) @@ -696,7 +714,23 @@ func baselineCatalogKey(fsys afero.Fs) (string, error) { if _, err := h.Write(roles); err != nil { return "", err } - return token + "-" + hex.EncodeToString(h.Sum(nil))[:12], nil + return hex.EncodeToString(h.Sum(nil))[:12], nil +} + +// baselineCatalogKey derives the cache key for the platform baseline catalog. +// +// Keying only by image would let a stale baseline — produced by a pre-platform- +// baseline CLI, a different image, or different service/api/vault/roles config — be +// reused as the no-migration diff source, leaking spurious objects into generated +// migrations until .temp/pgdelta is cleared. The image token stays as a human- +// readable prefix; old bare-baseline files keyed by the token alone no longer +// match, so they are never reused. +func baselineCatalogKey(fsys afero.Fs) (string, error) { + token, err := setupInputsToken(fsys) + if err != nil { + return "", err + } + return baselineVersionToken() + "-" + token, nil } // baselineCatalogPath returns the on-disk path of the platform baseline catalog @@ -710,6 +744,23 @@ func baselineCatalogPath(fsys afero.Fs) (string, error) { return filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, key)), nil } +// declarativeCatalogCacheKey keys the warmed declarative target catalog by both the +// declarative SQL files and the setup inputs. The target is the platform baseline +// plus the declarative schema, so a change to either must invalidate it; otherwise +// sync could pair a freshly keyed source baseline with a target warmed under a +// different setup, emitting platform/config-only differences as user migrations. +func declarativeCatalogCacheKey(fsys afero.Fs) (string, error) { + schemaHash, err := hashDeclarativeSchemas(fsys) + if err != nil { + return "", err + } + setup, err := setupInputsToken(fsys) + if err != nil { + return "", err + } + return setup + "-" + schemaHash, nil +} + func sanitizedCatalogPrefix(prefix string) string { prefix = strings.TrimSpace(prefix) if len(prefix) == 0 { diff --git a/apps/cli-go/internal/db/declarative/declarative_test.go b/apps/cli-go/internal/db/declarative/declarative_test.go index c758b91781..75829ea4f0 100644 --- a/apps/cli-go/internal/db/declarative/declarative_test.go +++ b/apps/cli-go/internal/db/declarative/declarative_test.go @@ -417,6 +417,79 @@ func TestBaselineCatalogKeyVariesWithSetupInputs(t *testing.T) { assert.NotEqual(t, withApi, withVault, "vault secrets must change the key") } +func TestBaselineCatalogKeyVariesWithServiceToggles(t *testing.T) { + // initSchema conditionally provisions auth/storage/realtime schemas, so toggling + // a service must invalidate the baseline cache even on the same image. + originalImage := utils.Config.Db.Image + originalStorage := utils.Config.Storage.Enabled + t.Cleanup(func() { + utils.Config.Db.Image = originalImage + utils.Config.Storage.Enabled = originalStorage + }) + utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" + + fsys := afero.NewMemMapFs() + utils.Config.Storage.Enabled = true + on, err := baselineCatalogKey(fsys) + require.NoError(t, err) + utils.Config.Storage.Enabled = false + off, err := baselineCatalogKey(fsys) + require.NoError(t, err) + assert.NotEqual(t, on, off, "toggling a service must change the baseline cache key") +} + +func TestDeclarativeCatalogCacheKeyVariesWithSetupInputs(t *testing.T) { + // The declarative target is built on the platform baseline, so its cache key + // must change when setup inputs change even if the declarative SQL does not. + originalImage := utils.Config.Db.Image + originalStorage := utils.Config.Storage.Enabled + t.Cleanup(func() { + utils.Config.Db.Image = originalImage + utils.Config.Storage.Enabled = originalStorage + }) + utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" + + fsys := afero.NewMemMapFs() + p := filepath.Join(utils.GetDeclarativeDir(), "schemas", "public", "tables", "a.sql") + require.NoError(t, afero.WriteFile(fsys, p, []byte("create table a();"), 0644)) + + utils.Config.Storage.Enabled = true + on, err := declarativeCatalogCacheKey(fsys) + require.NoError(t, err) + utils.Config.Storage.Enabled = false + off, err := declarativeCatalogCacheKey(fsys) + require.NoError(t, err) + assert.NotEqual(t, on, off, "setup input changes must invalidate the warmed declarative catalog") +} + +func TestGetMigrationsCatalogRefZeroMigrationsIgnoresMigrationsHashCache(t *testing.T) { + // With no local migrations, the source must come from the setup-keyed baseline, + // not the migrations-hash cache (which is not setup-aware and could otherwise + // surface an empty-migrations snapshot from a different platform setup). + originalImage := utils.Config.Db.Image + t.Cleanup(func() { utils.Config.Db.Image = originalImage }) + utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" + + fsys := afero.NewMemMapFs() + require.NoError(t, fsys.MkdirAll(pgDeltaTempPath(), 0755)) + + // A stale empty-migrations catalog in the migrations-hash cache. + emptyHash, err := pgcache.HashMigrations(fsys) + require.NoError(t, err) + stale := filepath.Join(pgDeltaTempPath(), "catalog-local-migrations-"+emptyHash+"-1000.json") + require.NoError(t, afero.WriteFile(fsys, stale, []byte(`{"objects":["stale"]}`), 0644)) + + // A baseline catalog for the current setup key. + baselinePath, err := baselineCatalogPath(fsys) + require.NoError(t, err) + require.NoError(t, afero.WriteFile(fsys, baselinePath, []byte(`{"objects":[]}`), 0644)) + + ref, err := getMigrationsCatalogRef(t.Context(), false, fsys, "local") + require.NoError(t, err) + assert.Equal(t, baselinePath, ref, "zero-migration source must be the setup-keyed baseline") + assert.NotEqual(t, stale, ref, "the non-setup-aware migrations-hash cache must not be reused") +} + func TestBaselineCatalogPathIgnoresLegacyBareBaseline(t *testing.T) { // A baseline written by a pre-fix CLI is keyed by the image token alone and // holds a bare-image catalog. The input-hashed key must not collide with it, so @@ -609,7 +682,7 @@ func TestGenerateReusesBaselineShadowForDeclarativeWarmup(t *testing.T) { assert.True(t, applyCalled, "generate should apply declarative schema using reused baseline shadow") assert.False(t, fallbackCalled, "fallback declarative resolver should not run when baseline shadow is reusable") - hash, err := hashDeclarativeSchemas(fsys) + hash, err := declarativeCatalogCacheKey(fsys) require.NoError(t, err) cachePath, ok, err := resolveDeclarativeCatalogPath(fsys, hash, "local") require.NoError(t, err)