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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion apps/cli-go/cmd/db_schema_declarative.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Comment thread
jgoux marked this conversation as resolved.
}
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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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"))
}
Expand Down Expand Up @@ -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()))

Expand Down
70 changes: 70 additions & 0 deletions apps/cli-go/cmd/db_schema_declarative_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()))
Expand Down
18 changes: 15 additions & 3 deletions apps/cli-go/internal/db/declarative/declarative.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions apps/cli-go/internal/db/declarative/declarative_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function mockSeam(paths: Record<LegacyCatalogMode, string>) {
},
execInherit: () => Effect.succeed(0),
ensureLocalDatabaseStarted: () => Effect.void,
ensureLocalPostgresImageCurrent: () => Effect.void,
provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"),
removeShadowContainer: () => Effect.void,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -84,10 +85,12 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* (
path: Path.Path,
workdir: string,
linkedRef: Option.Option<string>,
beforeLocalTarget: Effect.Effect<void, LegacyDeclarativeShadowDbError> = 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);
}
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -206,6 +208,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec
path,
cliConfig.workdir,
linkedRef,
(yield* LegacyDeclarativeSeam).ensureLocalPostgresImageCurrent(),
);
overwrite = true;
}
Expand Down
Loading
Loading