diff --git a/apps/cli-go/cmd/db_schema_declarative.go b/apps/cli-go/cmd/db_schema_declarative.go index 45f3f6aaaa..bcc689ca0b 100644 --- a/apps/cli-go/cmd/db_schema_declarative.go +++ b/apps/cli-go/cmd/db_schema_declarative.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/containerd/errdefs" + "github.com/docker/docker/api/types/container" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" @@ -157,6 +159,39 @@ func ensureLocalDatabaseStarted(ctx context.Context, local bool, isRunning func( return nil } +type inspectContainerFunc func(context.Context, string) (container.InspectResponse, error) + +func dockerImageTag(image string) string { + image = strings.TrimSpace(image) + index := strings.LastIndexByte(image, ':') + if index < 0 || index == len(image)-1 { + return "" + } + return image[index+1:] +} + +func ensureLocalPostgresImageCurrent(ctx context.Context, inspect inspectContainerFunc) error { + resp, err := inspect(ctx, utils.DbId) + if err != nil { + if errdefs.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed to inspect local Postgres container: %w", err) + } + if resp.Config == nil || len(strings.TrimSpace(resp.Config.Image)) == 0 { + return nil + } + actual := strings.TrimSpace(resp.Config.Image) + expected := strings.TrimSpace(utils.GetRegistryImageUrl(utils.Config.Db.Image)) + actualTag := dockerImageTag(actual) + expectedTag := dockerImageTag(expected) + if len(actualTag) == 0 || len(expectedTag) == 0 || actualTag == expectedTag { + return nil + } + utils.CmdSuggestion = fmt.Sprintf("Run %s, then %s before syncing declarative schemas.", utils.Aqua("supabase stop --all --no-backup"), utils.Aqua("supabase start")) + return fmt.Errorf("local Postgres container image is stale: running %s but expected %s", actual, expected) +} + // hasExplicitTargetFlag returns true if the user explicitly set --local, --linked, or --db-url. func hasExplicitTargetFlag(cmd *cobra.Command) bool { return cmd.Flags().Changed("local") || cmd.Flags().Changed("linked") || cmd.Flags().Changed("db-url") @@ -208,6 +243,11 @@ func runDeclarativeGenerate(cmd *cobra.Command, args []string) error { // When an explicit target flag is provided, use the direct path. if hasExplicitTargetFlag(cmd) { + if cmd.Flags().Changed("local") { + if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { + return err + } + } if err := ensureLocalDatabaseStarted(ctx, declarativeLocal, utils.AssertSupabaseDbIsRunning, func(ctx context.Context) error { return start.Run(ctx, "", fsys) }); err != nil { @@ -267,6 +307,9 @@ func runDeclarativeGenerate(cmd *cobra.Command, args []string) error { switch choice.Index { case 0: // Local database + if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { + return err + } if err := ensureLocalDatabaseStarted(ctx, true, utils.AssertSupabaseDbIsRunning, func(ctx context.Context) error { return start.Run(ctx, "", fsys) }); err != nil { @@ -309,6 +352,9 @@ func runDeclarativeGenerate(cmd *cobra.Command, args []string) error { } } else { // No migrations — generate from local DB + if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { + return err + } if err := ensureLocalDatabaseStarted(ctx, true, utils.AssertSupabaseDbIsRunning, func(ctx context.Context) error { return start.Run(ctx, "", fsys) }); err != nil { @@ -325,9 +371,10 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { ctx := cmd.Context() fsys := afero.NewOsFs() console := utils.NewConsole() + declarativeFilesExist := hasDeclarativeFiles(fsys) // Step 1: Check if declarative dir has files - if !hasDeclarativeFiles(fsys) { + if !declarativeFilesExist { if !isTTY() && !viper.GetBool("YES") { return fmt.Errorf("no declarative schema found. Run %s first", utils.Aqua("supabase db schema declarative generate")) } @@ -416,6 +463,9 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { } if shouldApply { + if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { + return err + } if applyErr := applyMigrationToLocal(ctx, path, fsys); applyErr != nil { fmt.Fprintln(os.Stderr, utils.Red("Migration failed to apply: "+applyErr.Error())) diff --git a/apps/cli-go/cmd/db_schema_declarative_test.go b/apps/cli-go/cmd/db_schema_declarative_test.go index a799ad0fb8..738e204111 100644 --- a/apps/cli-go/cmd/db_schema_declarative_test.go +++ b/apps/cli-go/cmd/db_schema_declarative_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "testing" + "github.com/containerd/errdefs" + "github.com/docker/docker/api/types/container" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -194,6 +196,74 @@ func TestEnsureLocalDatabaseStarted(t *testing.T) { }) } +func TestDockerImageTag(t *testing.T) { + testCases := map[string]string{ + "public.ecr.aws/supabase/postgres:17.6.1.138": "17.6.1.138", + "localhost:5000/supabase/postgres:17.6.1.138": "17.6.1.138", + "supabase/postgres": "", + } + for image, expected := range testCases { + t.Run(image, func(t *testing.T) { + assert.Equal(t, expected, dockerImageTag(image)) + }) + } +} + +func TestEnsureLocalPostgresImageCurrent(t *testing.T) { + originalImage := utils.Config.Db.Image + originalSuggestion := utils.CmdSuggestion + t.Cleanup(func() { + utils.Config.Db.Image = originalImage + utils.CmdSuggestion = originalSuggestion + }) + utils.Config.Db.Image = "supabase/postgres:17.6.1.138" + expectedImage := utils.GetRegistryImageUrl(utils.Config.Db.Image) + + t.Run("passes when no local container exists", func(t *testing.T) { + utils.CmdSuggestion = "" + err := ensureLocalPostgresImageCurrent(context.Background(), func(context.Context, string) (container.InspectResponse, error) { + return container.InspectResponse{}, errdefs.ErrNotFound + }) + + assert.NoError(t, err) + assert.Empty(t, utils.CmdSuggestion) + }) + + t.Run("passes when local container image matches expected postgres image", func(t *testing.T) { + utils.CmdSuggestion = "" + err := ensureLocalPostgresImageCurrent(context.Background(), func(_ context.Context, containerID string) (container.InspectResponse, error) { + assert.Equal(t, utils.DbId, containerID) + return container.InspectResponse{Config: &container.Config{Image: expectedImage}}, nil + }) + + assert.NoError(t, err) + assert.Empty(t, utils.CmdSuggestion) + }) + + t.Run("passes when registry differs but postgres tag matches", func(t *testing.T) { + utils.CmdSuggestion = "" + err := ensureLocalPostgresImageCurrent(context.Background(), func(context.Context, string) (container.InspectResponse, error) { + return container.InspectResponse{Config: &container.Config{Image: "docker.io/supabase/postgres:17.6.1.138"}}, nil + }) + + assert.NoError(t, err) + assert.Empty(t, utils.CmdSuggestion) + }) + + t.Run("fails when local container image is stale", func(t *testing.T) { + utils.CmdSuggestion = "" + err := ensureLocalPostgresImageCurrent(context.Background(), func(context.Context, string) (container.InspectResponse, error) { + return container.InspectResponse{Config: &container.Config{Image: "public.ecr.aws/supabase/postgres:17.6.1.106"}}, nil + }) + + assert.ErrorContains(t, err, "local Postgres container image is stale") + assert.ErrorContains(t, err, "17.6.1.106") + assert.ErrorContains(t, err, "17.6.1.138") + assert.Contains(t, utils.CmdSuggestion, "supabase stop --all --no-backup") + assert.Contains(t, utils.CmdSuggestion, "supabase start") + }) +} + func TestHasDeclarativeFiles(t *testing.T) { t.Run("returns false when dir does not exist", func(t *testing.T) { assert.False(t, hasDeclarativeFiles(mockFsys())) diff --git a/apps/cli-go/internal/db/declarative/declarative.go b/apps/cli-go/internal/db/declarative/declarative.go index 9b17efa078..c79df6289f 100644 --- a/apps/cli-go/internal/db/declarative/declarative.go +++ b/apps/cli-go/internal/db/declarative/declarative.go @@ -363,8 +363,8 @@ func getGenerateBaselineCatalogRef(ctx context.Context, noCache bool, fsys afero // getMigrationsCatalogRef returns a catalog reference representing local // migrations applied to a shadow database. // -// A migration-content hash keys the cache so it is reused only when local -// migration state is unchanged. +// A migration-content hash plus setup-input token keys the cache so it is reused +// only when both local migration state and platform baseline inputs are unchanged. func getMigrationsCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, prefix string, options ...func(*pgx.ConnConfig)) (string, error) { migrations, err := migration.ListLocalMigrations(utils.MigrationsDir, afero.NewIOFS(fsys)) if err != nil { @@ -390,7 +390,7 @@ func getMigrationsCatalogRef(ctx context.Context, noCache bool, fsys afero.Fs, p } } } - hash, err := pgcache.HashMigrations(fsys) + hash, err := migrationsCatalogCacheKey(fsys) if err != nil { return "", err } @@ -762,6 +762,18 @@ func declarativeCatalogCacheKey(fsys afero.Fs) (string, error) { return setup + "-" + schemaHash, nil } +func migrationsCatalogCacheKey(fsys afero.Fs) (string, error) { + migrationsHash, err := hashMigrations(fsys) + if err != nil { + return "", err + } + setup, err := setupInputsToken(fsys) + if err != nil { + return "", err + } + return setup + "-" + migrationsHash, 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 fa17a12a2b..cbd67d29de 100644 --- a/apps/cli-go/internal/db/declarative/declarative_test.go +++ b/apps/cli-go/internal/db/declarative/declarative_test.go @@ -241,22 +241,27 @@ func TestGetMigrationsCatalogRefUsesCache(t *testing.T) { fsys := afero.NewMemMapFs() p := filepath.Join(utils.MigrationsDir, "20240101000000_first.sql") require.NoError(t, afero.WriteFile(fsys, p, []byte("create table a();"), 0644)) - hash, err := hashMigrations(fsys) + legacyHash, err := hashMigrations(fsys) require.NoError(t, err) + stalePath := filepath.Join(utils.TempDir, "pgdelta", "catalog-local-migrations-"+legacyHash+"-1000.json") + require.NoError(t, afero.WriteFile(fsys, stalePath, []byte(`{"version":"stale"}`), 0644)) + hash, err := migrationsCatalogCacheKey(fsys) + require.NoError(t, err) cachePath := filepath.Join(utils.TempDir, "pgdelta", "catalog-local-migrations-"+hash+"-1000.json") require.NoError(t, afero.WriteFile(fsys, cachePath, []byte(`{"version":1}`), 0644)) ref, err := getMigrationsCatalogRef(t.Context(), false, fsys, "local") require.NoError(t, err) assert.Equal(t, cachePath, ref) + assert.NotEqual(t, stalePath, ref) } func TestGetMigrationsCatalogRefUsesProjectPrefix(t *testing.T) { fsys := afero.NewMemMapFs() p := filepath.Join(utils.MigrationsDir, "20240101000000_first.sql") require.NoError(t, afero.WriteFile(fsys, p, []byte("create table a();"), 0644)) - hash, err := hashMigrations(fsys) + hash, err := migrationsCatalogCacheKey(fsys) require.NoError(t, err) cachePath := filepath.Join(utils.TempDir, "pgdelta", "catalog-testproject-migrations-"+hash+"-1000.json") diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index bb52810856..bc2d759e52 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -63,6 +63,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, + ensureLocalPostgresImageCurrent: () => Effect.void, provisionShadow: ({ mode, targetLocal, usePgDelta, projectRef }) => { provisionCalls.push({ mode, targetLocal, usePgDelta, projectRef }); return Effect.succeed({ diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index f7ee1b0396..794b61ab5e 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -81,6 +81,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { exportCatalog: () => Effect.succeed("supabase/.temp/pgdelta/x.json"), execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, + ensureLocalPostgresImageCurrent: () => Effect.void, provisionShadow: ({ mode, usePgDelta, targetLocal, projectRef }) => { provisionCalls.push({ mode, usePgDelta, targetLocal, projectRef }); return Effect.succeed({ diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index ff49d1be91..19da790379 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -29,6 +29,7 @@ function mockSeam(paths: Record) { }, execInherit: () => Effect.succeed(0), ensureLocalDatabaseStarted: () => Effect.void, + ensureLocalPostgresImageCurrent: () => Effect.void, provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), removeShadowContainer: () => Effect.void, }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts index 167520a3d4..7e6cbab323 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts @@ -19,6 +19,7 @@ import { LegacyDeclarativeApplyError, LegacyDeclarativeInvalidDbUrlError, } from "./declarative.errors.ts"; +import type { LegacyDeclarativeShadowDbError } from "../../shared/legacy-pgdelta.errors.ts"; import { LegacyDeclarativeSeam } from "../../shared/legacy-pgdelta.seam.service.ts"; /** @@ -84,10 +85,12 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( path: Path.Path, workdir: string, linkedRef: Option.Option, + beforeLocalTarget: Effect.Effect = Effect.void, ) { if (!hasMigrations) { // No migrations → generate from local. Go runs ensureLocalDatabaseStarted first // (db_schema_declarative.go:291), starting a stopped stack. + yield* beforeLocalTarget; yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted(); return legacyLocalUrl(local); } @@ -149,6 +152,7 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( // "Local database" choice: Go runs ensureLocalDatabaseStarted before the reset // prompt (db_schema_declarative.go:249), starting a stopped stack. + yield* beforeLocalTarget; yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted(); let shouldReset = flags.reset; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts index 5e090a8633..fbcb89a721 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts @@ -132,14 +132,16 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec let targetUrl: string; let overwrite: boolean; if (hasExplicitTarget) { + const seam = yield* LegacyDeclarativeSeam; if (Option.isSome(flags.local)) { // Target selection keys off flag presence (Go's `Changed`), but the // auto-start gates on the boolean VALUE: Go passes `declarativeLocal` to // `ensureLocalDatabaseStarted` (`db_schema_declarative.go:190`), which // short-circuits `if !local { return nil }` (`:127-128`). So `--local=false` // selects the local target but must NOT start a stopped stack. + yield* seam.ensureLocalPostgresImageCurrent(); if (Option.getOrElse(flags.local, () => false)) { - yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted(); + yield* seam.ensureLocalDatabaseStarted(); } targetUrl = legacyLocalUrl(local); } else { @@ -206,6 +208,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec path, cliConfig.workdir, linkedRef, + (yield* LegacyDeclarativeSeam).ensureLocalPostgresImageCurrent(), ); overwrite = true; } diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index b49cf92592..938ceab885 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -58,6 +58,7 @@ interface SetupOpts { networkId?: Option.Option; projectId?: Option.Option; exportFailsForMode?: LegacyCatalogMode; + staleLocalImage?: boolean; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -71,6 +72,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const seamCalls: LegacyCatalogMode[] = []; const seamExportCalls: Array<{ mode: LegacyCatalogMode; projectRef?: string }> = []; const execInheritCalls: ReadonlyArray[] = []; + const localPostgresImageChecks: Array = []; let ensureStartedCalls = 0; const seam = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: ({ mode, projectRef }) => { @@ -88,6 +90,20 @@ function setup(workdir: string, opts: SetupOpts = {}) { Effect.sync(() => { ensureStartedCalls += 1; }), + ensureLocalPostgresImageCurrent: () => + Effect.sync(() => { + localPostgresImageChecks.push(true); + }).pipe( + Effect.flatMap(() => + opts.staleLocalImage === true + ? Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: "local Postgres container image is stale", + }), + ) + : Effect.void, + ), + ), provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), removeShadowContainer: () => Effect.void, }); @@ -148,6 +164,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { edgeCalls, resolverCalls, proxyCalls, + localPostgresImageChecks, get ensureStartedCalls() { return ensureStartedCalls; }, @@ -228,6 +245,23 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("explicit --local checks the local Postgres image before generating", () => { + const s = setup(tmp.current, { experimental: true, staleLocalImage: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeShadowDbError", + message: "local Postgres container image is stale", + }); + expect(s.localPostgresImageChecks).toHaveLength(1); + expect(s.ensureStartedCalls).toBe(0); + expect(s.edgeCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("honors --yes to overwrite existing declarative files without prompting", () => { // Pre-seed the declarative dir so the overwrite branch is reached. With --yes, // Go's confirmOverwrite returns true immediately (Console.PromptYesNo); the @@ -416,6 +450,7 @@ describe("legacy db schema declarative generate integration", () => { expect(s.seamCalls).toContain("baseline"); // ... but did NOT auto-start (value is false). expect(s.ensureStartedCalls).toBe(0); + expect(s.localPostgresImageChecks).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); @@ -561,6 +596,27 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("smart mode: local target checks the local Postgres image before generating", () => { + mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: true, + staleLocalImage: true, + promptSelectResponses: ["local"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDbSchemaDeclarativeGenerate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeShadowDbError", + message: "local Postgres container image is stale", + }); + expect(s.localPostgresImageChecks).toHaveLength(1); + expect(s.edgeCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect( "smart mode: caches the linked project even when the user picks local (Go PostRun)", () => { diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 8213bd9df9..d70db8625c 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -109,7 +109,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara pgDeltaEnabled: toml.pgDelta.enabled, configPath: path.join("supabase", "config.toml"), }); - // `path.resolve` (not `path.join`) so an absolute `declarative_schema_path` is // used as-is, matching Go's `config.resolve` (which only prefixes the workdir onto // a relative path). `path.join(workdir, abs)` would mangle the absolute path. @@ -131,6 +130,8 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara schema: flags.schema, noCache: flags.noCache, }; + const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent(); + const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); // Go's `saveApplyDebugBundle`: warn (rather than masking the apply error) and // treat the bundle path as empty when the debug directory cannot be created, so @@ -148,7 +149,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara ); // Step 1: declarative files must exist; in a TTY, offer to generate them. - if (!(yield* declarativeDirHasFiles(fs, declarativeDir))) { + if (!declarativeFilesExist) { const noFiles = new LegacyDeclarativeNonInteractiveError({ message: "no declarative schema found. Run supabase db schema declarative generate first", }); @@ -207,6 +208,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara path, cliConfig.workdir, linkedRef, + ensureLocalPostgresImageCurrent, ); const generated = yield* legacyGenerateDeclarativeOutput(run, targetUrl); yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, generated); @@ -307,6 +309,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara if (!shouldApply) return; // Step 8: apply the migration to the local database (native). + yield* ensureLocalPostgresImageCurrent; const applyExit = yield* applyMigrationToLocal( { port: toml.port, password: toml.password, dnsResolver }, migrationPath, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index 0420d274f0..aa2718ac62 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -24,10 +24,24 @@ import { LegacyEdgeRuntimeScript, } from "../../../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { LegacyDeclarativeShadowDbError } from "../../../shared/legacy-pgdelta.errors.ts"; import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; import { legacyDbSchemaDeclarativeSync } from "./sync.handler.ts"; +const EXPORT_JSON = JSON.stringify({ + version: 1, + mode: "declarative", + files: [ + { + path: "schemas/public/tables/players.sql", + order: 0, + statements: 1, + sql: "create table players ();", + }, + ], +}); + interface SetupOpts { experimental?: boolean; yes?: boolean; @@ -40,6 +54,8 @@ interface SetupOpts { promptTextResponses?: ReadonlyArray; networkId?: string; projectId?: Option.Option; + staleLocalImage?: boolean; + exportJson?: string; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -51,6 +67,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); const execInheritCalls: ReadonlyArray[] = []; + const localPostgresImageChecks: Array = []; const seam = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: ({ mode }) => Effect.succeed(`supabase/.temp/pgdelta/${mode}.json`), execInherit: (args) => @@ -59,12 +76,33 @@ function setup(workdir: string, opts: SetupOpts = {}) { return opts.resetExitCode ?? 0; }), ensureLocalDatabaseStarted: () => Effect.void, + ensureLocalPostgresImageCurrent: () => + Effect.sync(() => { + localPostgresImageChecks.push(true); + }).pipe( + Effect.flatMap(() => + opts.staleLocalImage === true + ? Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: "local Postgres container image is stale", + }), + ) + : Effect.void, + ), + ), provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), removeShadowContainer: () => Effect.void, }); const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (_opts: LegacyEdgeRuntimeRunOpts) => - Effect.succeed({ stdout: opts.diffSql ?? "", stderr: "" }), + run: (runOpts: LegacyEdgeRuntimeRunOpts) => + Effect.succeed({ + stdout: + opts.exportJson !== undefined && + runOpts.errPrefix === "error exporting declarative schema" + ? opts.exportJson + : (opts.diffSql ?? ""), + stderr: "", + }), }); const dbExec: string[] = []; const dbConn = Layer.succeed(LegacyDbConnection, { @@ -123,7 +161,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false) }), BunServices.layer, ); - return { layer, out, execInheritCalls, dbExec, cache }; + return { layer, out, execInheritCalls, dbExec, cache, localPostgresImageChecks }; } const flags = ( @@ -205,6 +243,59 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("non-interactive default dry-run does not check the local Postgres image", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + experimental: true, + staleLocalImage: true, + diffSql: "ALTER TABLE a ADD COLUMN b int;\n", + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags()); + const migrations = readdirSync(join(tmp.current, "supabase", "migrations")); + expect(migrations).toHaveLength(1); + expect(s.localPostgresImageChecks).toEqual([]); + expect(s.dbExec).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--apply checks the local Postgres image before applying", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + experimental: true, + staleLocalImage: true, + diffSql: "ALTER TABLE a ADD COLUMN b int;\n", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeShadowDbError", + message: "local Postgres container image is stale", + }); + expect(s.localPostgresImageChecks).toHaveLength(1); + expect(s.dbExec).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--no-apply skips the local Postgres image check", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + experimental: true, + staleLocalImage: true, + diffSql: "ALTER TABLE a ADD COLUMN b int;\n", + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + const migrations = readdirSync(join(tmp.current, "supabase", "migrations")); + expect(migrations).toHaveLength(1); + expect(s.localPostgresImageChecks).toEqual([]); + expect(s.dbExec).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("--yes bypasses the bootstrap prompt when no declarative files exist", () => { // Without --yes + non-TTY this fails at the "no declarative schema found" gate // (prior test). With --yes, Go's PromptYesNo auto-confirms, so the bootstrap is @@ -239,6 +330,90 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("bootstrap linked target does not run the local Postgres image check", () => { + // The stale-image guard only matters once bootstrap chooses a local source. A + // linked/custom bootstrap can build fresh catalogs and skip local apply, so it + // must reach the target prompt before any local-container inspection. + mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: true, + staleLocalImage: true, + projectId: Option.some("abcdefghijklmnopqrst"), + promptConfirmResponses: [true], // generate a new one? yes + promptSelectResponses: ["linked"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })), + ); + expect(s.localPostgresImageChecks).toEqual([]); + expect(JSON.stringify(exit)).not.toContain("local Postgres container image is stale"); + expect((s.out.promptSelectCalls[0]?.options ?? []).map((o) => o.value)).toEqual([ + "local", + "linked", + "custom", + ]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("bootstrap linked target checks the local Postgres image before apply", () => { + mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: true, + staleLocalImage: true, + projectId: Option.some("abcdefghijklmnopqrst"), + diffSql: "ALTER TABLE a ADD COLUMN b int;\n", + exportJson: EXPORT_JSON, + promptConfirmResponses: [true], // generate a new one? yes + promptSelectResponses: ["linked"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync( + flags({ + noCache: true, + apply: Option.some(true), + name: Option.some("bootstrap_apply"), + }), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeShadowDbError", + message: "local Postgres container image is stale", + }); + expect(s.localPostgresImageChecks).toHaveLength(1); + expect(s.dbExec).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("bootstrap local target checks the local Postgres image", () => { + mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: true, + staleLocalImage: true, + promptConfirmResponses: [true], // generate a new one? yes + promptSelectResponses: ["local"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeShadowDbError", + message: "local Postgres container image is stale", + }); + expect(s.localPostgresImageChecks).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("bootstrap: an unreadable migrations path is treated as no migrations", () => { // Go's delegated hasMigrationFiles returns false on ANY ListLocalMigrations error // (db_schema_declarative.go:164-169), flowing into the no-migrations local generate. diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 30c03b827f..634c4c7b56 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -6,7 +6,9 @@ import { LegacyNetworkIdFlag, LegacyProfileFlag } from "../../../../shared/legac import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { containerCliExitCode, spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts"; import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; import { legacyResolveLocalProjectId, localDbContainerId, @@ -265,6 +267,116 @@ export const legacyDeclarativeSeamLayer = Layer.effect( } }), ), + ensureLocalPostgresImageCurrent: () => + Effect.scoped( + Effect.gen(function* () { + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir).pipe( + Effect.mapError( + (error) => + new LegacyDeclarativeShadowDbError({ + message: `failed to read config for local Postgres image check: ${error.message}`, + }), + ), + ); + const image = yield* legacyResolveDbImage( + fs, + path, + cliConfig.workdir, + toml.majorVersion, + Option.getOrUndefined(toml.orioledbVersion), + ); + const tomlProjectId = toml.projectId; + const projectId = legacyResolveLocalProjectId( + Option.getOrUndefined(cliConfig.projectId), + Option.getOrUndefined(tomlProjectId), + cliConfig.workdir, + ); + const containerId = localDbContainerId(projectId); + const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + extendEnv: true, + }).pipe( + Effect.mapError( + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + }), + ), + ); + const stdoutChunks: Array = []; + const stderrChunks: Array = []; + yield* Stream.runForEach(child.stdout, (chunk) => + Effect.sync(() => { + stdoutChunks.push(chunk); + }), + ).pipe( + Effect.mapError( + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + }), + ), + ); + yield* Stream.runForEach(child.stderr, (chunk) => + Effect.sync(() => { + stderrChunks.push(chunk); + }), + ).pipe( + Effect.mapError( + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + }), + ), + ); + const inspectExit = yield* child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + }), + ), + ); + const decodeChunks = (chunks: ReadonlyArray): string => { + const total = chunks.reduce((size, chunk) => size + chunk.length, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return new TextDecoder().decode(bytes).trim(); + }; + const stderr = decodeChunks(stderrChunks); + const stdout = decodeChunks(stdoutChunks); + if (inspectExit !== 0) { + if (legacyIsMissingContainerInspectError(stderr)) return; + return yield* Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: + stderr.length > 0 + ? `failed to inspect local Postgres container: ${stderr}` + : "failed to inspect local Postgres container.", + }), + ); + } + const actual = legacyResolveContainerInspectImageName(stdout); + const expected = legacyGetRegistryImageUrl(image).trim(); + const actualTag = dockerImageTag(actual); + const expectedTag = dockerImageTag(expected); + if (actualTag.length === 0 || expectedTag.length === 0 || actualTag === expectedTag) { + return; + } + return yield* Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: `local Postgres container image is stale: running ${actual} but expected ${expected}. Run supabase stop --all --no-backup, then supabase start before syncing declarative schemas.`, + }), + ); + }), + ), provisionShadow: ({ mode, targetLocal, usePgDelta, schema, projectRef }) => Effect.scoped( Effect.gen(function* () { @@ -398,3 +510,39 @@ const failure = (exitCode?: number) => ? "failed to provision the shadow database." : `failed to provision the shadow database: exit ${exitCode}`, }); + +function dockerImageTag(image: string): string { + const trimmed = image.trim(); + const index = trimmed.lastIndexOf(":"); + if (index < 0 || index === trimmed.length - 1) return ""; + return trimmed.slice(index + 1); +} + +export function legacyIsMissingContainerInspectError(stderr: string): boolean { + return stderr.toLowerCase().includes("no such container"); +} + +export function legacyResolveContainerInspectImageName(stdout: string): string { + const trimmed = stdout.trim(); + if (trimmed.length === 0) return ""; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return trimmed; + } + const inspect = Array.isArray(parsed) ? parsed[0] : parsed; + if (!isJsonRecord(inspect)) return ""; + const imageName = inspect["ImageName"]; + if (typeof imageName === "string" && imageName.trim().length > 0) { + return imageName.trim(); + } + const config = inspect["Config"]; + if (!isJsonRecord(config)) return ""; + const configImage = config["Image"]; + return typeof configImage === "string" && configImage.trim().length > 0 ? configImage.trim() : ""; +} + +function isJsonRecord(value: unknown): value is { readonly [key: string]: unknown } { + return typeof value === "object" && value !== null; +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts new file mode 100644 index 0000000000..b6b0251a10 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyIsMissingContainerInspectError, + legacyResolveContainerInspectImageName, +} from "./legacy-pgdelta.seam.layer.ts"; + +describe("legacyIsMissingContainerInspectError", () => { + it("matches Docker and Podman missing-container stderr", () => { + expect(legacyIsMissingContainerInspectError("Error: No such container: supabase_db_test")).toBe( + true, + ); + expect(legacyIsMissingContainerInspectError("Error: no such container: supabase_db_test")).toBe( + true, + ); + }); + + it("does not match unrelated inspect failures", () => { + expect(legacyIsMissingContainerInspectError("Cannot connect to the Docker daemon")).toBe(false); + }); +}); + +describe("legacyResolveContainerInspectImageName", () => { + it("reads Docker's config image from inspect JSON", () => { + expect( + legacyResolveContainerInspectImageName( + JSON.stringify([{ Config: { Image: "public.ecr.aws/supabase/postgres:17.4.1.056" } }]), + ), + ).toBe("public.ecr.aws/supabase/postgres:17.4.1.056"); + }); + + it("prefers Podman's image name from inspect JSON", () => { + expect( + legacyResolveContainerInspectImageName( + JSON.stringify([ + { + Image: "sha256:0123456789", + ImageName: "public.ecr.aws/supabase/postgres:17.4.1.056", + }, + ]), + ), + ).toBe("public.ecr.aws/supabase/postgres:17.4.1.056"); + }); + + it("keeps raw formatter output as a compatibility fallback", () => { + expect(legacyResolveContainerInspectImageName("supabase/postgres:15.1.0")).toBe( + "supabase/postgres:15.1.0", + ); + }); + + it("returns empty when JSON inspect output has no image-name field", () => { + expect(legacyResolveContainerInspectImageName(JSON.stringify([{ Image: "sha256:0123" }]))).toBe( + "", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts index de657d0af7..16593f5f75 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts @@ -73,6 +73,15 @@ interface LegacyDeclarativeSeamShape { * of failing to connect, matching Go. */ readonly ensureLocalDatabaseStarted: () => Effect.Effect; + /** + * Checks the running local Postgres container image tag against the currently + * resolved Postgres image. A missing container is accepted: catalog cache keys + * self-invalidate on setup inputs, and local-apply paths will start/connect later. + */ + readonly ensureLocalPostgresImageCurrent: () => Effect.Effect< + void, + LegacyDeclarativeShadowDbError + >; /** * Provisions a live shadow database via the bundled Go binary's hidden * `db __shadow` command and returns it running (the container is NOT removed —