diff --git a/apps/cli-go/internal/db/declarative/declarative.go b/apps/cli-go/internal/db/declarative/declarative.go index ecd464f48d..853779691e 100644 --- a/apps/cli-go/internal/db/declarative/declarative.go +++ b/apps/cli-go/internal/db/declarative/declarative.go @@ -31,10 +31,16 @@ 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. 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" @@ -58,11 +64,19 @@ 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 // 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,14 +137,10 @@ 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 - } - hash, err := hashDeclarativeSchemas(fsys) + // The reused baseline shadow already has the platform baseline + // provisioned (getGenerateBaselineCatalogRef), so apply declarative + // schemas directly on top of it without setting it up again. + hash, err := declarativeCatalogCacheKey(fsys) if err != nil { return err } @@ -172,7 +182,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 } @@ -294,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 @@ -308,6 +321,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,21 +370,31 @@ 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. - if !noCache && len(migrations) == 0 { - baselinePath := filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, baselineVersionToken())) - if ok, err := afero.Exists(fsys, baselinePath); err != nil { + // 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 - } 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 { @@ -381,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 } @@ -439,9 +484,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 { @@ -628,6 +673,94 @@ func baselineVersionToken() string { return catalogPrefixRegexp.ReplaceAllString(image, "-") } +// setupInputsToken hashes every project input that start.SetupDatabase consumes +// and that therefore shapes the platform baseline: +// +// - the Postgres image (initSchema content); +// - the service toggles that gate initSchema — auth/storage/realtime; +// - api.auto_expose_new_tables (ApplyApiPrivileges default ACLs); +// - vault secret names (UpsertVaultSecrets); +// - supabase/roles.sql (SeedGlobals). +// +// Every catalog produced in this flow is "platform baseline + {nothing | migrations +// | declarative}", so each cache folds this token into its key and self-invalidates +// when setup changes instead of reusing a snapshot from a different baseline. +func setupInputsToken(fsys afero.Fs) (string, error) { + h := sha256.New() + fmt.Fprintln(h, baselineVersionToken()) + // initSchema conditionally provisions these service schemas. + fmt.Fprintf(h, "auth=%t storage=%t realtime=%t\n", + utils.Config.Auth.Enabled, utils.Config.Storage.Enabled, utils.Config.Realtime.Enabled) + // api.auto_expose_new_tables drives ApplyApiPrivileges (default ACLs). + 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 hex.EncodeToString(h.Sum(nil))[:12], nil +} + +// baselineCatalogKey derives the cache key for the platform baseline catalog. +// +// Keying only by image would let a stale baseline — produced by a pre-platform- +// baseline CLI, a different image, or different service/api/vault/roles config — be +// reused as the no-migration diff source, leaking spurious objects into generated +// migrations until .temp/pgdelta is cleared. The image token stays as a human- +// readable prefix; old bare-baseline files keyed by the token alone no longer +// match, so they are never reused. +func baselineCatalogKey(fsys afero.Fs) (string, error) { + token, err := setupInputsToken(fsys) + if err != nil { + return "", err + } + return baselineVersionToken() + "-" + token, nil +} + +// baselineCatalogPath returns the on-disk path of the platform baseline catalog +// for the current project inputs. Both the generate writer and the no-migration +// sync reader resolve the path through this helper so they always agree. +func baselineCatalogPath(fsys afero.Fs) (string, error) { + key, err := baselineCatalogKey(fsys) + if err != nil { + return "", err + } + return filepath.Join(pgDeltaTempPath(), fmt.Sprintf(baselineCatalogName, key)), nil +} + +// declarativeCatalogCacheKey keys the warmed declarative target catalog by both the +// declarative SQL files and the setup inputs. The target is the platform baseline +// plus the declarative schema, so a change to either must invalidate it; otherwise +// sync could pair a freshly keyed source baseline with a target warmed under a +// different setup, emitting platform/config-only differences as user migrations. +func declarativeCatalogCacheKey(fsys afero.Fs) (string, error) { + schemaHash, err := hashDeclarativeSchemas(fsys) + if err != nil { + return "", err + } + setup, err := setupInputsToken(fsys) + if err != nil { + return "", err + } + return setup + "-" + schemaHash, nil +} + func 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 new file mode 100644 index 0000000000..3aaf09dca6 --- /dev/null +++ b/apps/cli-go/internal/db/declarative/declarative_flow_test.go @@ -0,0 +1,165 @@ +package declarative + +import ( + "context" + "encoding/json" + "sort" + "strings" + "testing" + + "github.com/jackc/pgconn" + "github.com/jackc/pgx/v4" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/db/diff" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/config" +) + +// catalogObjects models a pg-delta catalog snapshot as the set of object names +// present in a shadow database, so the full generate -> sync flow can be +// exercised without the real pg-delta runtime while still proving that platform +// objects cancel out of the generated diff. +type catalogObjects struct { + Objects []string `json:"objects"` +} + +func marshalCatalog(objects []string) string { + sorted := append([]string(nil), objects...) + sort.Strings(sorted) + out, _ := json.Marshal(catalogObjects{Objects: sorted}) + return string(out) +} + +func readCatalogObjects(t *testing.T, fsys afero.Fs, path string) []string { + t.Helper() + raw, err := afero.ReadFile(fsys, path) + require.NoError(t, err) + var parsed catalogObjects + require.NoError(t, json.Unmarshal(raw, &parsed)) + return parsed.Objects +} + +// TestGenerateThenSyncWithNoMigrationsCancelsPlatformObjects exercises the full +// generate -> sync (no local migrations) flow end to end through the public +// command functions. The bug it guards: generate writes the baseline catalog +// (catalog-baseline-.json) that sync reuses as its diff source when +// there are no local migrations. If that baseline is captured from a bare image +// instead of the platform baseline, platform-managed objects (auth/storage/ +// realtime) leak into the generated migration even though the user only declared +// a single table. The Docker and pg-delta seams are stubbed (the established +// cli-go pattern) so the test runs in the standard `go test ./...` CI job. +func TestGenerateThenSyncWithNoMigrationsCancelsPlatformObjects(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("[db]\n"), 0644)) + + originalPgDelta := utils.Config.Experimental.PgDelta + originalImage := utils.Config.Db.Image + originalCreateShadow := createShadow + originalSetupShadow := setupShadowDatabase + originalExportCatalog := exportCatalog + originalApplyDeclarative := applyDeclarative + originalExportRef := declarativeExportRef + originalDiffRef := diffPgDeltaRef + t.Cleanup(func() { + utils.Config.Experimental.PgDelta = originalPgDelta + utils.Config.Db.Image = originalImage + createShadow = originalCreateShadow + setupShadowDatabase = originalSetupShadow + exportCatalog = originalExportCatalog + applyDeclarative = originalApplyDeclarative + declarativeExportRef = originalExportRef + diffPgDeltaRef = originalDiffRef + }) + + utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} + utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" + + shadowConfig := pgconn.Config{Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"} + // Model the evolving shadow state: platform baseline provisioning adds the + // auth/storage/realtime schemas, declarative apply adds the user's table. + platformReady := false + declarativeApplied := false + platformObjects := []string{"auth", "realtime", "storage"} + const userObject = "public.profiles" + + createShadow = func(_ context.Context) (string, pgconn.Config, error) { + return "test-shadow-container", shadowConfig, nil + } + setupShadowDatabase = func(_ context.Context, _ string, _ afero.Fs, _ ...func(*pgx.ConnConfig)) error { + platformReady = true + return nil + } + applyDeclarative = func(_ context.Context, _ pgconn.Config, _ afero.Fs) error { + declarativeApplied = true + return nil + } + exportCatalog = func(_ context.Context, _ string, role string, _ ...func(*pgx.ConnConfig)) (string, error) { + assert.Equal(t, "postgres", role) + var objects []string + if platformReady { + objects = append(objects, platformObjects...) + } + if declarativeApplied { + objects = append(objects, userObject) + } + return marshalCatalog(objects), nil + } + // generate exports declarative files from the live database; emit a single + // table that depends on auth so WriteDeclarativeSchemas + hashing have content. + declarativeExportRef = func(_ context.Context, _, _ string, _ []string, _ string, _ ...func(*pgx.ConnConfig)) (diff.DeclarativeOutput, error) { + return diff.DeclarativeOutput{ + Files: []diff.DeclarativeFile{ + {Path: "schemas/public/tables/profiles.sql", SQL: "create table public.profiles (id uuid primary key references auth.users(id));"}, + }, + }, nil + } + // Stand in for the pg-delta diff: emit DDL for objects present in the target + // catalog but missing from the source catalog. Platform objects that exist in + // both sides must not appear. + diffPgDeltaRef = func(_ context.Context, sourceRef, targetRef string, _ []string, _ string, _ ...func(*pgx.ConnConfig)) (string, error) { + source := readCatalogObjects(t, fsys, sourceRef) + target := readCatalogObjects(t, fsys, targetRef) + inSource := make(map[string]bool, len(source)) + for _, obj := range source { + inSource[obj] = true + } + var added []string + for _, obj := range target { + if !inSource[obj] { + added = append(added, obj) + } + } + sort.Strings(added) + var sb strings.Builder + for _, obj := range added { + sb.WriteString("create " + obj + ";\n") + } + return sb.String(), nil + } + + liveConfig := pgconn.Config{Host: "db.test.supabase.co", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres"} + + // 1. generate writes declarative files and warms the baseline + declarative caches. + require.NoError(t, Generate(t.Context(), nil, liveConfig, true, false, fsys)) + + // The baseline catalog reused by sync must represent the platform baseline, + // not a bare image. + baselinePath, err := baselineCatalogPath(fsys) + require.NoError(t, err) + assert.ElementsMatch(t, platformObjects, readCatalogObjects(t, fsys, baselinePath), + "baseline catalog must capture the platform baseline (auth/storage/realtime)") + + // 2. sync with no local migrations diffs the warmed declarative catalog against + // the baseline. Platform objects exist on both sides, so only the user's table + // should surface in the generated migration. + result, err := DiffDeclarativeToMigrations(t.Context(), nil, false, fsys) + require.NoError(t, err) + assert.Equal(t, baselinePath, result.SourceRef, "no-migration sync must source from the platform baseline catalog") + assert.Contains(t, result.DiffSQL, "public.profiles", "the user's declared table should be generated") + for _, platform := range platformObjects { + assert.NotContains(t, result.DiffSQL, "create "+platform+";", + "platform object %q must cancel out instead of leaking into the migration", platform) + } +} diff --git a/apps/cli-go/internal/db/declarative/declarative_test.go b/apps/cli-go/internal/db/declarative/declarative_test.go index f5c7da857f..75829ea4f0 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") @@ -278,6 +279,53 @@ 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, err := baselineCatalogPath(fsys) + require.NoError(t, err) + assert.Equal(t, cachePath, ref.ref) + cached, err := afero.ReadFile(fsys, cachePath) + require.NoError(t, err) + assert.JSONEq(t, `{"version":1}`, string(cached)) +} + func TestHashDeclarativeSchemasChangesWithContent(t *testing.T) { fsys := afero.NewMemMapFs() p1 := filepath.Join(utils.GetDeclarativeDir(), "schemas", "public", "tables", "a.sql") @@ -331,6 +379,138 @@ 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 TestBaselineCatalogKeyVariesWithServiceToggles(t *testing.T) { + // initSchema conditionally provisions auth/storage/realtime schemas, so toggling + // a service must invalidate the baseline cache even on the same image. + originalImage := utils.Config.Db.Image + originalStorage := utils.Config.Storage.Enabled + t.Cleanup(func() { + utils.Config.Db.Image = originalImage + utils.Config.Storage.Enabled = originalStorage + }) + utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" + + fsys := afero.NewMemMapFs() + utils.Config.Storage.Enabled = true + on, err := baselineCatalogKey(fsys) + require.NoError(t, err) + utils.Config.Storage.Enabled = false + off, err := baselineCatalogKey(fsys) + require.NoError(t, err) + assert.NotEqual(t, on, off, "toggling a service must change the baseline cache key") +} + +func TestDeclarativeCatalogCacheKeyVariesWithSetupInputs(t *testing.T) { + // The declarative target is built on the platform baseline, so its cache key + // must change when setup inputs change even if the declarative SQL does not. + originalImage := utils.Config.Db.Image + originalStorage := utils.Config.Storage.Enabled + t.Cleanup(func() { + utils.Config.Db.Image = originalImage + utils.Config.Storage.Enabled = originalStorage + }) + utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" + + fsys := afero.NewMemMapFs() + p := filepath.Join(utils.GetDeclarativeDir(), "schemas", "public", "tables", "a.sql") + require.NoError(t, afero.WriteFile(fsys, p, []byte("create table a();"), 0644)) + + utils.Config.Storage.Enabled = true + on, err := declarativeCatalogCacheKey(fsys) + require.NoError(t, err) + utils.Config.Storage.Enabled = false + off, err := declarativeCatalogCacheKey(fsys) + require.NoError(t, err) + assert.NotEqual(t, on, off, "setup input changes must invalidate the warmed declarative catalog") +} + +func TestGetMigrationsCatalogRefZeroMigrationsIgnoresMigrationsHashCache(t *testing.T) { + // With no local migrations, the source must come from the setup-keyed baseline, + // not the migrations-hash cache (which is not setup-aware and could otherwise + // surface an empty-migrations snapshot from a different platform setup). + originalImage := utils.Config.Db.Image + t.Cleanup(func() { utils.Config.Db.Image = originalImage }) + utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" + + fsys := afero.NewMemMapFs() + require.NoError(t, fsys.MkdirAll(pgDeltaTempPath(), 0755)) + + // A stale empty-migrations catalog in the migrations-hash cache. + emptyHash, err := pgcache.HashMigrations(fsys) + require.NoError(t, err) + stale := filepath.Join(pgDeltaTempPath(), "catalog-local-migrations-"+emptyHash+"-1000.json") + require.NoError(t, afero.WriteFile(fsys, stale, []byte(`{"objects":["stale"]}`), 0644)) + + // A baseline catalog for the current setup key. + baselinePath, err := baselineCatalogPath(fsys) + require.NoError(t, err) + require.NoError(t, afero.WriteFile(fsys, baselinePath, []byte(`{"objects":[]}`), 0644)) + + ref, err := getMigrationsCatalogRef(t.Context(), false, fsys, "local") + require.NoError(t, err) + assert.Equal(t, baselinePath, ref, "zero-migration source must be the setup-keyed baseline") + assert.NotEqual(t, stale, ref, "the non-setup-aware migrations-hash cache must not be reused") +} + +func TestBaselineCatalogPathIgnoresLegacyBareBaseline(t *testing.T) { + // A baseline written by a pre-fix CLI is keyed by the image token alone and + // holds a bare-image catalog. The input-hashed key must not collide with it, so + // no-migration sync never reuses the stale snapshot. + originalImage := utils.Config.Db.Image + t.Cleanup(func() { utils.Config.Db.Image = originalImage }) + utils.Config.Db.Image = "public.ecr.aws/supabase/postgres:15.8.1.049" + + fsys := afero.NewMemMapFs() + require.NoError(t, fsys.MkdirAll(pgDeltaTempPath(), 0755)) + legacy := filepath.Join(pgDeltaTempPath(), "catalog-baseline-"+baselineVersionToken()+".json") + require.NoError(t, afero.WriteFile(fsys, legacy, []byte(`{"objects":[]}`), 0644)) + + current, err := baselineCatalogPath(fsys) + require.NoError(t, err) + assert.NotEqual(t, legacy, current, "input-hashed key must not collide with the legacy bare-baseline filename") + exists, err := afero.Exists(fsys, current) + require.NoError(t, err) + assert.False(t, exists, "stale bare baseline must not satisfy the current cache key") +} + func TestBaselineVersionToken(t *testing.T) { originalImage := utils.Config.Db.Image originalMajor := utils.Config.Db.MajorVersion @@ -351,7 +531,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 @@ -384,7 +565,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) } @@ -466,9 +647,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,11 +678,11 @@ 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") - hash, err := hashDeclarativeSchemas(fsys) + hash, err := declarativeCatalogCacheKey(fsys) require.NoError(t, err) cachePath, ok, err := resolveDeclarativeCatalogPath(fsys, hash, "local") require.NoError(t, err)