From 5ffbf4b1e944bbc373ec937374fb18dc3afd7cee Mon Sep 17 00:00:00 2001 From: pjpj Date: Mon, 22 Jun 2026 18:31:11 +0800 Subject: [PATCH 1/6] fix(cli): detect stale local postgres before declarative sync --- apps/cli-go/cmd/db_schema_declarative.go | 39 +++++++++++ apps/cli-go/cmd/db_schema_declarative_test.go | 70 +++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/apps/cli-go/cmd/db_schema_declarative.go b/apps/cli-go/cmd/db_schema_declarative.go index 45f3f6aaaa..277c6db552 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") @@ -326,6 +361,10 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { fsys := afero.NewOsFs() console := utils.NewConsole() + if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { + return err + } + // Step 1: Check if declarative dir has files if !hasDeclarativeFiles(fsys) { if !isTTY() && !viper.GetBool("YES") { 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())) From 2483ce8405a37547021a71b3cdbd769f256928a1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 25 Jun 2026 11:47:45 +0200 Subject: [PATCH 2/6] fix(cli): harden declarative sync stale image guard --- apps/cli-go/cmd/db_schema_declarative.go | 13 +- apps/cli-go/cmd/db_schema_declarative_test.go | 8 ++ .../internal/db/declarative/declarative.go | 18 ++- .../db/declarative/declarative_test.go | 9 +- .../commands/db/diff/diff.integration.test.ts | 1 + .../commands/db/pull/pull.integration.test.ts | 1 + ...eclarative.orchestrate.integration.test.ts | 1 + .../generate/generate.integration.test.ts | 1 + .../schema/declarative/sync/sync.handler.ts | 17 ++- .../declarative/sync/sync.integration.test.ts | 49 ++++++- .../db/shared/legacy-pgdelta.seam.layer.ts | 122 ++++++++++++++++++ .../db/shared/legacy-pgdelta.seam.service.ts | 9 ++ 12 files changed, 238 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/cmd/db_schema_declarative.go b/apps/cli-go/cmd/db_schema_declarative.go index 277c6db552..4a04a0e8f9 100644 --- a/apps/cli-go/cmd/db_schema_declarative.go +++ b/apps/cli-go/cmd/db_schema_declarative.go @@ -192,6 +192,10 @@ func ensureLocalPostgresImageCurrent(ctx context.Context, inspect inspectContain return fmt.Errorf("local Postgres container image is stale: running %s but expected %s", actual, expected) } +func shouldEnsureLocalPostgresImageCurrent(noCache, noApply, declarativeFilesExist bool) bool { + return !noCache || !noApply || !declarativeFilesExist +} + // 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") @@ -360,13 +364,16 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { ctx := cmd.Context() fsys := afero.NewOsFs() console := utils.NewConsole() + declarativeFilesExist := hasDeclarativeFiles(fsys) - if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { - return err + if shouldEnsureLocalPostgresImageCurrent(declarativeNoCache, declarativeNoApply, declarativeFilesExist) { + if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { + return err + } } // 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")) } diff --git a/apps/cli-go/cmd/db_schema_declarative_test.go b/apps/cli-go/cmd/db_schema_declarative_test.go index 738e204111..7f293d3835 100644 --- a/apps/cli-go/cmd/db_schema_declarative_test.go +++ b/apps/cli-go/cmd/db_schema_declarative_test.go @@ -264,6 +264,14 @@ func TestEnsureLocalPostgresImageCurrent(t *testing.T) { }) } +func TestShouldEnsureLocalPostgresImageCurrent(t *testing.T) { + assert.False(t, shouldEnsureLocalPostgresImageCurrent(true, true, true), "--no-cache --no-apply builds fresh catalogs and skips local apply") + assert.True(t, shouldEnsureLocalPostgresImageCurrent(true, true, false), "bootstrap may generate from the local database") + assert.True(t, shouldEnsureLocalPostgresImageCurrent(false, true, true), "cache-enabled dry run can reuse local catalog snapshots") + assert.True(t, shouldEnsureLocalPostgresImageCurrent(true, false, true), "apply path can touch the local database") + assert.True(t, shouldEnsureLocalPostgresImageCurrent(false, false, true), "default sync can reuse caches and apply locally") +} + 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/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index b49cf92592..c609577c50 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 @@ -88,6 +88,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Effect.sync(() => { ensureStartedCalls += 1; }), + 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/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 8213bd9df9..76314d7333 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,12 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara schema: flags.schema, noCache: flags.noCache, }; + const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); + if ( + shouldEnsureLocalPostgresImageCurrent(flags.noCache, flags.noApply, declarativeFilesExist) + ) { + yield* seam.ensureLocalPostgresImageCurrent(); + } // 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 +153,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", }); @@ -423,6 +428,14 @@ const declarativeDirHasFiles = Effect.fnUntraced(function* ( return entries.length > 0; }); +function shouldEnsureLocalPostgresImageCurrent( + noCache: boolean, + noApply: Option.Option, + declarativeFilesExist: boolean, +): boolean { + return !(noCache && Option.getOrElse(noApply, () => false) && declarativeFilesExist); +} + /** Connects to the local database and applies the single migration file (Go's `applyMigrationToLocal`). */ const applyMigrationToLocal = ( local: { port: number; password: string; dnsResolver: "native" | "https" }, 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..6116cb9889 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,6 +24,7 @@ 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"; @@ -40,6 +41,7 @@ interface SetupOpts { promptTextResponses?: ReadonlyArray; networkId?: string; projectId?: Option.Option; + staleLocalImage?: boolean; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -51,6 +53,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,6 +62,20 @@ 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, }); @@ -123,7 +140,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 +222,36 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("checks the local Postgres image before diffing on the shipped sync path", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { experimental: true, staleLocalImage: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyDbSchemaDeclarativeSync(flags({ 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("--no-cache --no-apply skips the local Postgres image check", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + experimental: true, + staleLocalImage: true, + diffSql: "", + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })); + expect(s.localPostgresImageChecks).toEqual([]); + expect(s.out.rawChunks.some((c) => c.text.includes("No schema changes found"))).toBe(true); + }).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 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..9e6bf3059e 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,119 @@ 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, "--format", "{{.Config.Image}}"], + { + 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); + if (inspectExit !== 0) { + if (stderr.includes("No such container")) 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 = decodeChunks(stdoutChunks); + 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 +513,10 @@ 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); +} 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 — From b24e45886bc6fbe7864cd34b44cbccf1b565a5e1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 25 Jun 2026 12:39:51 +0200 Subject: [PATCH 3/6] fix(cli): defer declarative sync local image guard --- apps/cli-go/cmd/db_schema_declarative.go | 17 +++++-- apps/cli-go/cmd/db_schema_declarative_test.go | 9 ++-- .../declarative/declarative.smart-target.ts | 4 ++ .../schema/declarative/sync/sync.handler.ts | 7 +-- .../declarative/sync/sync.integration.test.ts | 51 +++++++++++++++++++ .../db/shared/legacy-pgdelta.seam.layer.ts | 6 ++- .../legacy-pgdelta.seam.layer.unit.test.ts | 18 +++++++ 7 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts diff --git a/apps/cli-go/cmd/db_schema_declarative.go b/apps/cli-go/cmd/db_schema_declarative.go index 4a04a0e8f9..9dde457fbe 100644 --- a/apps/cli-go/cmd/db_schema_declarative.go +++ b/apps/cli-go/cmd/db_schema_declarative.go @@ -192,8 +192,8 @@ func ensureLocalPostgresImageCurrent(ctx context.Context, inspect inspectContain return fmt.Errorf("local Postgres container image is stale: running %s but expected %s", actual, expected) } -func shouldEnsureLocalPostgresImageCurrent(noCache, noApply, declarativeFilesExist bool) bool { - return !noCache || !noApply || !declarativeFilesExist +func shouldEnsureLocalPostgresImageCurrent(noCache, noApply bool) bool { + return !noCache || !noApply } // hasExplicitTargetFlag returns true if the user explicitly set --local, --linked, or --db-url. @@ -247,6 +247,11 @@ func runDeclarativeGenerate(cmd *cobra.Command, args []string) error { // When an explicit target flag is provided, use the direct path. if hasExplicitTargetFlag(cmd) { + if declarativeLocal { + 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 { @@ -306,6 +311,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 { @@ -348,6 +356,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 { @@ -366,7 +377,7 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { console := utils.NewConsole() declarativeFilesExist := hasDeclarativeFiles(fsys) - if shouldEnsureLocalPostgresImageCurrent(declarativeNoCache, declarativeNoApply, declarativeFilesExist) { + if declarativeFilesExist && shouldEnsureLocalPostgresImageCurrent(declarativeNoCache, declarativeNoApply) { if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { return err } diff --git a/apps/cli-go/cmd/db_schema_declarative_test.go b/apps/cli-go/cmd/db_schema_declarative_test.go index 7f293d3835..964ecaf66a 100644 --- a/apps/cli-go/cmd/db_schema_declarative_test.go +++ b/apps/cli-go/cmd/db_schema_declarative_test.go @@ -265,11 +265,10 @@ func TestEnsureLocalPostgresImageCurrent(t *testing.T) { } func TestShouldEnsureLocalPostgresImageCurrent(t *testing.T) { - assert.False(t, shouldEnsureLocalPostgresImageCurrent(true, true, true), "--no-cache --no-apply builds fresh catalogs and skips local apply") - assert.True(t, shouldEnsureLocalPostgresImageCurrent(true, true, false), "bootstrap may generate from the local database") - assert.True(t, shouldEnsureLocalPostgresImageCurrent(false, true, true), "cache-enabled dry run can reuse local catalog snapshots") - assert.True(t, shouldEnsureLocalPostgresImageCurrent(true, false, true), "apply path can touch the local database") - assert.True(t, shouldEnsureLocalPostgresImageCurrent(false, false, true), "default sync can reuse caches and apply locally") + assert.False(t, shouldEnsureLocalPostgresImageCurrent(true, true), "--no-cache --no-apply builds fresh catalogs and skips local apply") + assert.True(t, shouldEnsureLocalPostgresImageCurrent(false, true), "cache-enabled dry run can reuse local catalog snapshots") + assert.True(t, shouldEnsureLocalPostgresImageCurrent(true, false), "apply path can touch the local database") + assert.True(t, shouldEnsureLocalPostgresImageCurrent(false, false), "default sync can reuse caches and apply locally") } func TestHasDeclarativeFiles(t *testing.T) { 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/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 76314d7333..fed700b2ee 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 @@ -132,7 +132,8 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara }; const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); if ( - shouldEnsureLocalPostgresImageCurrent(flags.noCache, flags.noApply, declarativeFilesExist) + declarativeFilesExist && + shouldEnsureLocalPostgresImageCurrent(flags.noCache, flags.noApply) ) { yield* seam.ensureLocalPostgresImageCurrent(); } @@ -212,6 +213,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara path, cliConfig.workdir, linkedRef, + seam.ensureLocalPostgresImageCurrent(), ); const generated = yield* legacyGenerateDeclarativeOutput(run, targetUrl); yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, generated); @@ -431,9 +433,8 @@ const declarativeDirHasFiles = Effect.fnUntraced(function* ( function shouldEnsureLocalPostgresImageCurrent( noCache: boolean, noApply: Option.Option, - declarativeFilesExist: boolean, ): boolean { - return !(noCache && Option.getOrElse(noApply, () => false) && declarativeFilesExist); + return !(noCache && Option.getOrElse(noApply, () => false)); } /** Connects to the local database and applies the single migration file (Go's `applyMigrationToLocal`). */ 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 6116cb9889..4a7a698c0e 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 @@ -286,6 +286,57 @@ 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 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 9e6bf3059e..a5e8a58c9b 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 @@ -356,7 +356,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( }; const stderr = decodeChunks(stderrChunks); if (inspectExit !== 0) { - if (stderr.includes("No such container")) return; + if (legacyIsMissingContainerInspectError(stderr)) return; return yield* Effect.fail( new LegacyDeclarativeShadowDbError({ message: @@ -520,3 +520,7 @@ function dockerImageTag(image: string): string { 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"); +} 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..ebea5c4ac3 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { legacyIsMissingContainerInspectError } 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); + }); +}); From bee148c014090642cf3ae23bf63aa5a114ce2987 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 25 Jun 2026 13:01:49 +0200 Subject: [PATCH 4/6] fix(cli): guard declarative local generation --- .../declarative/generate/generate.handler.ts | 5 +- .../generate/generate.integration.test.ts | 56 +++++++++++++++++- .../schema/declarative/sync/sync.handler.ts | 15 ++++- .../declarative/sync/sync.integration.test.ts | 58 ++++++++++++++++++- .../db/shared/legacy-pgdelta.seam.layer.ts | 44 ++++++++++---- .../legacy-pgdelta.seam.layer.unit.test.ts | 40 ++++++++++++- 6 files changed, 200 insertions(+), 18 deletions(-) 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..e92bc9cb11 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,6 +132,7 @@ 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 @@ -139,7 +140,8 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec // short-circuits `if !local { return nil }` (`:127-128`). So `--local=false` // selects the local target but must NOT start a stopped stack. if (Option.getOrElse(flags.local, () => false)) { - yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted(); + yield* seam.ensureLocalPostgresImageCurrent(); + 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 c609577c50..4b6e942c27 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,7 +90,20 @@ function setup(workdir: string, opts: SetupOpts = {}) { Effect.sync(() => { ensureStartedCalls += 1; }), - ensureLocalPostgresImageCurrent: () => 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, }); @@ -149,6 +164,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { edgeCalls, resolverCalls, proxyCalls, + localPostgresImageChecks, get ensureStartedCalls() { return ensureStartedCalls; }, @@ -229,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 @@ -562,6 +595,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 fed700b2ee..4ae3734358 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 @@ -130,12 +130,20 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara schema: flags.schema, noCache: flags.noCache, }; + let localPostgresImageChecked = false; + const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent().pipe( + Effect.tap(() => + Effect.sync(() => { + localPostgresImageChecked = true; + }), + ), + ); const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); if ( declarativeFilesExist && shouldEnsureLocalPostgresImageCurrent(flags.noCache, flags.noApply) ) { - yield* seam.ensureLocalPostgresImageCurrent(); + yield* ensureLocalPostgresImageCurrent; } // Go's `saveApplyDebugBundle`: warn (rather than masking the apply error) and @@ -213,7 +221,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara path, cliConfig.workdir, linkedRef, - seam.ensureLocalPostgresImageCurrent(), + ensureLocalPostgresImageCurrent, ); const generated = yield* legacyGenerateDeclarativeOutput(run, targetUrl); yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, generated); @@ -314,6 +322,9 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara if (!shouldApply) return; // Step 8: apply the migration to the local database (native). + if (!localPostgresImageChecked) { + 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 4a7a698c0e..67059888f7 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 @@ -29,6 +29,19 @@ import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.servi 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; @@ -42,6 +55,7 @@ interface SetupOpts { networkId?: string; projectId?: Option.Option; staleLocalImage?: boolean; + exportJson?: string; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -80,8 +94,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { 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, { @@ -314,6 +335,39 @@ describe("legacy db schema declarative sync integration", () => { }).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;"); 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 a5e8a58c9b..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 @@ -292,16 +292,12 @@ export const legacyDeclarativeSeamLayer = Layer.effect( cliConfig.workdir, ); const containerId = localDbContainerId(projectId); - const child = yield* spawnContainerCli( - spawner, - ["container", "inspect", containerId, "--format", "{{.Config.Image}}"], - { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - extendEnv: true, - }, - ).pipe( + const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + extendEnv: true, + }).pipe( Effect.mapError( () => new LegacyDeclarativeShadowDbError({ @@ -355,6 +351,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( 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( @@ -366,7 +363,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( }), ); } - const actual = decodeChunks(stdoutChunks); + const actual = legacyResolveContainerInspectImageName(stdout); const expected = legacyGetRegistryImageUrl(image).trim(); const actualTag = dockerImageTag(actual); const expectedTag = dockerImageTag(expected); @@ -524,3 +521,28 @@ function dockerImageTag(image: string): string { 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 index ebea5c4ac3..b6b0251a10 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.unit.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { legacyIsMissingContainerInspectError } from "./legacy-pgdelta.seam.layer.ts"; +import { + legacyIsMissingContainerInspectError, + legacyResolveContainerInspectImageName, +} from "./legacy-pgdelta.seam.layer.ts"; describe("legacyIsMissingContainerInspectError", () => { it("matches Docker and Podman missing-container stderr", () => { @@ -16,3 +19,38 @@ describe("legacyIsMissingContainerInspectError", () => { 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( + "", + ); + }); +}); From ff19713dee9f4f3ec073f01468c2f0f2d4a7dac6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 25 Jun 2026 13:18:01 +0200 Subject: [PATCH 5/6] fix(cli): refine declarative image guard paths --- apps/cli-go/cmd/db_schema_declarative.go | 15 +++++++++++---- apps/cli-go/cmd/db_schema_declarative_test.go | 6 ++---- .../declarative/generate/generate.handler.ts | 2 +- .../generate/generate.integration.test.ts | 1 + .../db/schema/declarative/sync/sync.handler.ts | 12 +++--------- .../declarative/sync/sync.integration.test.ts | 10 ++++------ 6 files changed, 22 insertions(+), 24 deletions(-) diff --git a/apps/cli-go/cmd/db_schema_declarative.go b/apps/cli-go/cmd/db_schema_declarative.go index 9dde457fbe..d8170ff95d 100644 --- a/apps/cli-go/cmd/db_schema_declarative.go +++ b/apps/cli-go/cmd/db_schema_declarative.go @@ -192,8 +192,8 @@ func ensureLocalPostgresImageCurrent(ctx context.Context, inspect inspectContain return fmt.Errorf("local Postgres container image is stale: running %s but expected %s", actual, expected) } -func shouldEnsureLocalPostgresImageCurrent(noCache, noApply bool) bool { - return !noCache || !noApply +func shouldEnsureLocalPostgresImageCurrent(noApply bool) bool { + return !noApply } // hasExplicitTargetFlag returns true if the user explicitly set --local, --linked, or --db-url. @@ -247,7 +247,7 @@ func runDeclarativeGenerate(cmd *cobra.Command, args []string) error { // When an explicit target flag is provided, use the direct path. if hasExplicitTargetFlag(cmd) { - if declarativeLocal { + if cmd.Flags().Changed("local") { if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { return err } @@ -376,11 +376,13 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { fsys := afero.NewOsFs() console := utils.NewConsole() declarativeFilesExist := hasDeclarativeFiles(fsys) + localPostgresImageChecked := false - if declarativeFilesExist && shouldEnsureLocalPostgresImageCurrent(declarativeNoCache, declarativeNoApply) { + if declarativeFilesExist && shouldEnsureLocalPostgresImageCurrent(declarativeNoApply) { if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { return err } + localPostgresImageChecked = true } // Step 1: Check if declarative dir has files @@ -473,6 +475,11 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { } if shouldApply { + if !localPostgresImageChecked { + 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 964ecaf66a..c268a7d227 100644 --- a/apps/cli-go/cmd/db_schema_declarative_test.go +++ b/apps/cli-go/cmd/db_schema_declarative_test.go @@ -265,10 +265,8 @@ func TestEnsureLocalPostgresImageCurrent(t *testing.T) { } func TestShouldEnsureLocalPostgresImageCurrent(t *testing.T) { - assert.False(t, shouldEnsureLocalPostgresImageCurrent(true, true), "--no-cache --no-apply builds fresh catalogs and skips local apply") - assert.True(t, shouldEnsureLocalPostgresImageCurrent(false, true), "cache-enabled dry run can reuse local catalog snapshots") - assert.True(t, shouldEnsureLocalPostgresImageCurrent(true, false), "apply path can touch the local database") - assert.True(t, shouldEnsureLocalPostgresImageCurrent(false, false), "default sync can reuse caches and apply locally") + assert.False(t, shouldEnsureLocalPostgresImageCurrent(true), "--no-apply writes a migration without touching local Postgres") + assert.True(t, shouldEnsureLocalPostgresImageCurrent(false), "apply paths can touch the local database") } func TestHasDeclarativeFiles(t *testing.T) { 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 e92bc9cb11..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 @@ -139,8 +139,8 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec // `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* seam.ensureLocalPostgresImageCurrent(); yield* seam.ensureLocalDatabaseStarted(); } targetUrl = legacyLocalUrl(local); 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 4b6e942c27..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 @@ -450,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)); }); 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 4ae3734358..d320b74a9e 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 @@ -139,10 +139,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara ), ); const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); - if ( - declarativeFilesExist && - shouldEnsureLocalPostgresImageCurrent(flags.noCache, flags.noApply) - ) { + if (declarativeFilesExist && shouldEnsureLocalPostgresImageCurrent(flags.noApply)) { yield* ensureLocalPostgresImageCurrent; } @@ -441,11 +438,8 @@ const declarativeDirHasFiles = Effect.fnUntraced(function* ( return entries.length > 0; }); -function shouldEnsureLocalPostgresImageCurrent( - noCache: boolean, - noApply: Option.Option, -): boolean { - return !(noCache && Option.getOrElse(noApply, () => false)); +function shouldEnsureLocalPostgresImageCurrent(noApply: Option.Option): boolean { + return !Option.getOrElse(noApply, () => false); } /** Connects to the local database and applies the single migration file (Go's `applyMigrationToLocal`). */ 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 67059888f7..e700a54855 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 @@ -243,13 +243,11 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("checks the local Postgres image before diffing on the shipped sync path", () => { + it.effect("checks the local Postgres image before diffing when apply may run", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, staleLocalImage: true }); return Effect.gen(function* () { - const exit = yield* Effect.exit( - legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })), - ); + const exit = yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags())); expect(Exit.isFailure(exit)).toBe(true); expect(failError(exit)).toMatchObject({ _tag: "LegacyDeclarativeShadowDbError", @@ -259,7 +257,7 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("--no-cache --no-apply skips the local Postgres image check", () => { + it.effect("--no-apply skips the local Postgres image check", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, @@ -267,7 +265,7 @@ describe("legacy db schema declarative sync integration", () => { diffSql: "", }); return Effect.gen(function* () { - yield* legacyDbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })); + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); expect(s.localPostgresImageChecks).toEqual([]); expect(s.out.rawChunks.some((c) => c.text.includes("No schema changes found"))).toBe(true); }).pipe(Effect.provide(s.layer)); From e2c9b6a6bd29d2fa0e146ec3f210d2d2b65f49e6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 25 Jun 2026 13:37:32 +0200 Subject: [PATCH 6/6] fix(cli): defer declarative sync image check --- apps/cli-go/cmd/db_schema_declarative.go | 18 ++-------- apps/cli-go/cmd/db_schema_declarative_test.go | 5 --- .../schema/declarative/sync/sync.handler.ts | 20 ++--------- .../declarative/sync/sync.integration.test.ts | 35 ++++++++++++++++--- 4 files changed, 34 insertions(+), 44 deletions(-) diff --git a/apps/cli-go/cmd/db_schema_declarative.go b/apps/cli-go/cmd/db_schema_declarative.go index d8170ff95d..bcc689ca0b 100644 --- a/apps/cli-go/cmd/db_schema_declarative.go +++ b/apps/cli-go/cmd/db_schema_declarative.go @@ -192,10 +192,6 @@ func ensureLocalPostgresImageCurrent(ctx context.Context, inspect inspectContain return fmt.Errorf("local Postgres container image is stale: running %s but expected %s", actual, expected) } -func shouldEnsureLocalPostgresImageCurrent(noApply bool) bool { - return !noApply -} - // 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") @@ -376,14 +372,6 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { fsys := afero.NewOsFs() console := utils.NewConsole() declarativeFilesExist := hasDeclarativeFiles(fsys) - localPostgresImageChecked := false - - if declarativeFilesExist && shouldEnsureLocalPostgresImageCurrent(declarativeNoApply) { - if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { - return err - } - localPostgresImageChecked = true - } // Step 1: Check if declarative dir has files if !declarativeFilesExist { @@ -475,10 +463,8 @@ func runDeclarativeSync(cmd *cobra.Command, args []string) error { } if shouldApply { - if !localPostgresImageChecked { - if err := ensureLocalPostgresImageCurrent(ctx, utils.Docker.ContainerInspect); err != nil { - return err - } + 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 c268a7d227..738e204111 100644 --- a/apps/cli-go/cmd/db_schema_declarative_test.go +++ b/apps/cli-go/cmd/db_schema_declarative_test.go @@ -264,11 +264,6 @@ func TestEnsureLocalPostgresImageCurrent(t *testing.T) { }) } -func TestShouldEnsureLocalPostgresImageCurrent(t *testing.T) { - assert.False(t, shouldEnsureLocalPostgresImageCurrent(true), "--no-apply writes a migration without touching local Postgres") - assert.True(t, shouldEnsureLocalPostgresImageCurrent(false), "apply paths can touch the local database") -} - 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/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index d320b74a9e..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 @@ -130,18 +130,8 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara schema: flags.schema, noCache: flags.noCache, }; - let localPostgresImageChecked = false; - const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent().pipe( - Effect.tap(() => - Effect.sync(() => { - localPostgresImageChecked = true; - }), - ), - ); + const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent(); const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); - if (declarativeFilesExist && shouldEnsureLocalPostgresImageCurrent(flags.noApply)) { - yield* ensureLocalPostgresImageCurrent; - } // 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 @@ -319,9 +309,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara if (!shouldApply) return; // Step 8: apply the migration to the local database (native). - if (!localPostgresImageChecked) { - yield* ensureLocalPostgresImageCurrent; - } + yield* ensureLocalPostgresImageCurrent; const applyExit = yield* applyMigrationToLocal( { port: toml.port, password: toml.password, dnsResolver }, migrationPath, @@ -438,10 +426,6 @@ const declarativeDirHasFiles = Effect.fnUntraced(function* ( return entries.length > 0; }); -function shouldEnsureLocalPostgresImageCurrent(noApply: Option.Option): boolean { - return !Option.getOrElse(noApply, () => false); -} - /** Connects to the local database and applies the single migration file (Go's `applyMigrationToLocal`). */ const applyMigrationToLocal = ( local: { port: number; password: string; dnsResolver: "native" | "https" }, 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 e700a54855..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 @@ -243,17 +243,40 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("checks the local Postgres image before diffing when apply may run", () => { + 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 }); + 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())); + 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)); }); @@ -262,12 +285,14 @@ describe("legacy db schema declarative sync integration", () => { const s = setup(tmp.current, { experimental: true, staleLocalImage: true, - diffSql: "", + 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.out.rawChunks.some((c) => c.text.includes("No schema changes found"))).toBe(true); + expect(s.dbExec).toEqual([]); }).pipe(Effect.provide(s.layer)); });