From a87eeed2d96adbca8136a99e1b0df5d55b581df2 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 17 Jun 2026 10:32:28 -0500 Subject: [PATCH 1/5] feat(app): opt-in CentralMigrations split-phase startup (Register-all -> migrate -> Start-all) --- app.go | 16 +++ app_impl.go | 121 +++++++++++++++----- central_migrations_test.go | 223 +++++++++++++++++++++++++++++++++++++ lifecycle_helpers_test.go | 3 +- 4 files changed, 335 insertions(+), 28 deletions(-) create mode 100644 central_migrations_test.go diff --git a/app.go b/app.go index 0b977afc..2061b9a4 100644 --- a/app.go +++ b/app.go @@ -44,6 +44,10 @@ type App interface { // Configuration queries MigrationsDisabled() bool + + // CentralMigrationsEnabled reports whether the single-pass migration + // lifecycle is enabled. + CentralMigrationsEnabled() bool } // AppConfig configures the application. @@ -103,6 +107,12 @@ type AppConfig struct { // Database / Migration DisableMigrations bool // When true, skip auto-migrations on serve (default: false). Also settable via .forge.yaml database.disable_migrations. + + // CentralMigrations runs all MigratableExtension migrations as a single + // ordered pass (Register-all -> migrate -> Start-all) instead of letting + // each extension migrate independently. Default: false. Also settable via + // .forge.yaml database.central_migrations. + CentralMigrations bool } // DefaultAppConfig returns a default application configuration. @@ -335,6 +345,12 @@ func WithDisableMigrations() AppOption { return func(c *AppConfig) { c.DisableMigrations = true } } +// WithCentralMigrations enables the single-pass, dependency-ordered migration +// lifecycle (Register-all -> migrate -> Start-all). +func WithCentralMigrations() AppOption { + return func(c *AppConfig) { c.CentralMigrations = true } +} + // WithPprof enables pprof profiling endpoints. // Endpoints are registered at /_/debug/pprof by default. // Only enable in development or staging — never in production. diff --git a/app_impl.go b/app_impl.go index 8aecf661..3db4a5ec 100644 --- a/app_impl.go +++ b/app_impl.go @@ -483,6 +483,12 @@ func (a *app) MigrationsDisabled() bool { return a.config.DisableMigrations } +// CentralMigrationsEnabled reports whether the single-pass migration lifecycle +// is enabled via config or .forge.yaml. +func (a *app) CentralMigrationsEnabled() bool { + return a.config.CentralMigrations +} + // StartTime returns the application start time. func (a *app) StartTime() time.Time { a.mu.RLock() @@ -527,41 +533,93 @@ func (a *app) Start(ctx context.Context) error { return err } - // Process each extension's FULL lifecycle in dependency order - // This ensures dependencies are fully ready (Register + Start) before dependents begin - for _, name := range order { - ext, ok := extMap[name] - if !ok { - continue // Dependency might not be registered (optional) - } + if a.config.CentralMigrations { + // Central-migrations path: split Register-all / migrate / Start-all. - // Phase 1: Register extension's services - a.logger.Info("registering extension", - F("extension", ext.Name()), - F("version", ext.Version()), - ) + // Phase 1: Register ALL extensions in dependency order. Each + // MigratableExtension contributes its migration groups to the shared + // registry here, but runs nothing yet. + for _, name := range order { + ext, ok := extMap[name] + if !ok { + continue // Dependency might not be registered (optional) + } + + a.logger.Info("registering extension", + F("extension", ext.Name()), + F("version", ext.Version()), + ) + + if err := ext.Register(a); err != nil { + return fmt.Errorf("failed to register extension %s: %w", ext.Name(), err) + } + } - if err := ext.Register(a); err != nil { - return fmt.Errorf("failed to register extension %s: %w", ext.Name(), err) + // Phase 2: Run the single ordered migration pass before any Start, so + // schema exists for extensions that seed/query during Start. The grove + // MigrationRegistry registered a PhaseAfterRegister hook during Register. + if err := a.lifecycleManager.ExecuteHooks(ctx, PhaseAfterRegister, a); err != nil { + return fmt.Errorf("central migration phase failed: %w", err) } - // Phase 2: Start the extension (services auto-start on Resolve) - a.logger.Info("starting extension", - F("extension", ext.Name()), - ) + // Phase 3: Start ALL extensions in dependency order. + for _, name := range order { + ext, ok := extMap[name] + if !ok { + continue // Dependency might not be registered (optional) + } + + a.logger.Info("starting extension", + F("extension", ext.Name()), + ) + + if err := ext.Start(ctx); err != nil { + return fmt.Errorf("failed to start extension %s: %w", ext.Name(), err) + } - if err := ext.Start(ctx); err != nil { - return fmt.Errorf("failed to start extension %s: %w", ext.Name(), err) + a.logger.Info("extension ready", + F("extension", ext.Name()), + ) } + } else { + // Default path: existing interleaved Register+Start loop (UNCHANGED). + // This ensures dependencies are fully ready (Register + Start) before dependents begin. + for _, name := range order { + ext, ok := extMap[name] + if !ok { + continue // Dependency might not be registered (optional) + } - a.logger.Info("extension ready", - F("extension", ext.Name()), - ) - } + // Phase 1: Register extension's services + a.logger.Info("registering extension", + F("extension", ext.Name()), + F("version", ext.Version()), + ) + + if err := ext.Register(a); err != nil { + return fmt.Errorf("failed to register extension %s: %w", ext.Name(), err) + } + + // Phase 2: Start the extension (services auto-start on Resolve) + a.logger.Info("starting extension", + F("extension", ext.Name()), + ) - // Execute after register hooks (all extensions now registered and started) - if err := a.lifecycleManager.ExecuteHooks(ctx, PhaseAfterRegister, a); err != nil { - return fmt.Errorf("after register hooks failed: %w", err) + if err := ext.Start(ctx); err != nil { + return fmt.Errorf("failed to start extension %s: %w", ext.Name(), err) + } + + a.logger.Info("extension ready", + F("extension", ext.Name()), + ) + } + + // Execute after register hooks (all extensions now registered and started). + // In the central path this hook already fired between Register-all and Start-all, + // so we only fire it here in the default (non-central) path to prevent double-fire. + if err := a.lifecycleManager.ExecuteHooks(ctx, PhaseAfterRegister, a); err != nil { + return fmt.Errorf("after register hooks failed: %w", err) + } } // Apply global middleware from extensions @@ -1454,6 +1512,7 @@ type forgeYAMLConfig struct { } `yaml:"build"` Database struct { DisableMigrations bool `yaml:"disable_migrations"` + CentralMigrations bool `yaml:"central_migrations"` } `yaml:"database"` } @@ -1551,5 +1610,13 @@ func loadForgeYAMLConfig(config AppConfig, logger Logger) AppConfig { } } + // Set CentralMigrations from .forge.yaml if not already set programmatically. + if !config.CentralMigrations && forgeConfig.Database.CentralMigrations { + config.CentralMigrations = true + if logger != nil { + logger.Info("central migrations enabled via .forge.yaml", F("path", forgeConfigPath)) + } + } + return config } diff --git a/central_migrations_test.go b/central_migrations_test.go new file mode 100644 index 00000000..77be9f9f --- /dev/null +++ b/central_migrations_test.go @@ -0,0 +1,223 @@ +package forge + +import ( + "context" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/xraph/forge/internal/logger" +) + +// recordingExtension records Register and Start calls into a shared log. +type recordingExtension struct { + *BaseExtension + log *[]string + mu *sync.Mutex +} + +func newRecordingExtension(name string, log *[]string, mu *sync.Mutex) *recordingExtension { + base := NewBaseExtension(name, "1.0.0", "Recording extension "+name) + return &recordingExtension{ + BaseExtension: base, + log: log, + mu: mu, + } +} + +func (e *recordingExtension) Register(app App) error { + e.mu.Lock() + *e.log = append(*e.log, e.Name()+".register") + e.mu.Unlock() + return e.BaseExtension.Register(app) +} + +func (e *recordingExtension) Start(ctx context.Context) error { + e.mu.Lock() + *e.log = append(*e.log, e.Name()+".start") + e.mu.Unlock() + e.MarkStarted() + return nil +} + +func (e *recordingExtension) Stop(ctx context.Context) error { + e.MarkStopped() + return nil +} + +func (e *recordingExtension) Health(ctx context.Context) error { + return nil +} + +// TestCentralMigrations_SplitPhaseOrder verifies that with CentralMigrations=true, +// all Register calls happen first, then the PhaseAfterRegister hook fires exactly once, +// then all Start calls happen. +func TestCentralMigrations_SplitPhaseOrder(t *testing.T) { + var log []string + var mu sync.Mutex + + extA := newRecordingExtension("extA", &log, &mu) + extB := newRecordingExtension("extB", &log, &mu) + + testLogger := logger.NewTestLogger() + app := NewApp(AppConfig{ + Name: "test-central", + Logger: testLogger, + Extensions: []Extension{extA, extB}, + CentralMigrations: true, + }) + + // Register a PhaseAfterRegister probe hook + err := app.RegisterHookFn(PhaseAfterRegister, "test-probe", func(ctx context.Context, a App) error { + mu.Lock() + log = append(log, "hook") + mu.Unlock() + return nil + }) + assert.NoError(t, err) + + ctx := context.Background() + err = app.Start(ctx) + assert.NoError(t, err) + defer app.Stop(ctx) //nolint:errcheck + + // Find partition boundaries + hookIdx := -1 + for i, entry := range log { + if entry == "hook" { + hookIdx = i + break + } + } + + assert.NotEqual(t, -1, hookIdx, "PhaseAfterRegister hook should have fired") + + // Count hook occurrences — must be exactly 1 + hookCount := 0 + for _, entry := range log { + if entry == "hook" { + hookCount++ + } + } + assert.Equal(t, 1, hookCount, "PhaseAfterRegister hook must fire exactly once") + + // All *.register entries must appear before the hook + for i, entry := range log[:hookIdx] { + _ = i + assert.False(t, strings.HasSuffix(entry, ".start"), + "No .start should appear before the hook; got %v at index %d (full log: %v)", entry, i, log) + } + + // All *.start entries must appear after the hook + for i, entry := range log[hookIdx+1:] { + _ = i + assert.False(t, strings.HasSuffix(entry, ".register"), + "No .register should appear after the hook; got %v (full log: %v)", entry, log) + } + + // Both extensions must have registered and started + assert.Contains(t, log, "extA.register") + assert.Contains(t, log, "extB.register") + assert.Contains(t, log, "extA.start") + assert.Contains(t, log, "extB.start") +} + +// TestCentralMigrations_DefaultInterleaved verifies that without CentralMigrations, +// the legacy interleaved Register+Start order is preserved: each extension's .register +// is immediately followed by its own .start before the next extension registers. +func TestCentralMigrations_DefaultInterleaved(t *testing.T) { + var log []string + var mu sync.Mutex + + extA := newRecordingExtension("extA", &log, &mu) + extB := newRecordingExtension("extB", &log, &mu) + + testLogger := logger.NewTestLogger() + app := NewApp(AppConfig{ + Name: "test-default", + Logger: testLogger, + Extensions: []Extension{extA, extB}, + // CentralMigrations deliberately omitted (false) + }) + + // Register a PhaseAfterRegister probe hook + err := app.RegisterHookFn(PhaseAfterRegister, "test-probe", func(ctx context.Context, a App) error { + mu.Lock() + log = append(log, "hook") + mu.Unlock() + return nil + }) + assert.NoError(t, err) + + ctx := context.Background() + err = app.Start(ctx) + assert.NoError(t, err) + defer app.Stop(ctx) //nolint:errcheck + + // Hook must fire exactly once + hookCount := 0 + for _, entry := range log { + if entry == "hook" { + hookCount++ + } + } + assert.Equal(t, 1, hookCount, "PhaseAfterRegister hook must fire exactly once in default mode") + + // Legacy interleaving: a *.start should appear before the second extension's *.register. + // Find the index of the second .register (whichever extension is second in order). + secondRegisterIdx := -1 + registerCount := 0 + for i, entry := range log { + if strings.HasSuffix(entry, ".register") { + registerCount++ + if registerCount == 2 { + secondRegisterIdx = i + break + } + } + } + + if secondRegisterIdx > 0 { + // There must be at least one .start before the second .register + startBeforeSecondRegister := false + for _, entry := range log[:secondRegisterIdx] { + if strings.HasSuffix(entry, ".start") { + startBeforeSecondRegister = true + break + } + } + assert.True(t, startBeforeSecondRegister, + "In default mode, first extension's .start should appear before second extension's .register (interleaved). log: %v", log) + } + + // Both extensions must have registered and started + assert.Contains(t, log, "extA.register") + assert.Contains(t, log, "extB.register") + assert.Contains(t, log, "extA.start") + assert.Contains(t, log, "extB.start") +} + +// TestCentralMigrationsEnabled verifies the accessor method. +func TestCentralMigrationsEnabled(t *testing.T) { + t.Run("FalseByDefault", func(t *testing.T) { + testLogger := logger.NewTestLogger() + app := NewApp(AppConfig{Logger: testLogger}) + assert.False(t, app.CentralMigrationsEnabled()) + }) + + t.Run("TrueWhenSet", func(t *testing.T) { + testLogger := logger.NewTestLogger() + app := NewApp(AppConfig{Logger: testLogger, CentralMigrations: true}) + assert.True(t, app.CentralMigrationsEnabled()) + }) + + t.Run("TrueViaOption", func(t *testing.T) { + testLogger := logger.NewTestLogger() + config := DefaultAppConfig() + config.Logger = testLogger + WithCentralMigrations()(&config) + app := NewApp(config) + assert.True(t, app.CentralMigrationsEnabled()) + }) +} diff --git a/lifecycle_helpers_test.go b/lifecycle_helpers_test.go index fed77511..0009ab05 100644 --- a/lifecycle_helpers_test.go +++ b/lifecycle_helpers_test.go @@ -173,4 +173,5 @@ func (m *mockAppForLifecycle) StartTime() time.Time { return func (m *mockAppForLifecycle) Uptime() time.Duration { return 0 } func (m *mockAppForLifecycle) Extensions() []Extension { return nil } func (m *mockAppForLifecycle) GetExtension(_ string) (Extension, error) { return nil, nil } -func (m *mockAppForLifecycle) MigrationsDisabled() bool { return false } +func (m *mockAppForLifecycle) MigrationsDisabled() bool { return false } +func (m *mockAppForLifecycle) CentralMigrationsEnabled() bool { return false } From d11e40cc329fc09129cc8be7dbbdb51dce65651a Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 17 Jun 2026 10:35:57 -0500 Subject: [PATCH 2/5] style(app): gofmt central migrations files --- central_migrations_test.go | 6 +++--- lifecycle_helpers_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/central_migrations_test.go b/central_migrations_test.go index 77be9f9f..4ab5305d 100644 --- a/central_migrations_test.go +++ b/central_migrations_test.go @@ -62,9 +62,9 @@ func TestCentralMigrations_SplitPhaseOrder(t *testing.T) { testLogger := logger.NewTestLogger() app := NewApp(AppConfig{ - Name: "test-central", - Logger: testLogger, - Extensions: []Extension{extA, extB}, + Name: "test-central", + Logger: testLogger, + Extensions: []Extension{extA, extB}, CentralMigrations: true, }) diff --git a/lifecycle_helpers_test.go b/lifecycle_helpers_test.go index 0009ab05..fe6fe3f4 100644 --- a/lifecycle_helpers_test.go +++ b/lifecycle_helpers_test.go @@ -173,5 +173,5 @@ func (m *mockAppForLifecycle) StartTime() time.Time { return func (m *mockAppForLifecycle) Uptime() time.Duration { return 0 } func (m *mockAppForLifecycle) Extensions() []Extension { return nil } func (m *mockAppForLifecycle) GetExtension(_ string) (Extension, error) { return nil, nil } -func (m *mockAppForLifecycle) MigrationsDisabled() bool { return false } -func (m *mockAppForLifecycle) CentralMigrationsEnabled() bool { return false } +func (m *mockAppForLifecycle) MigrationsDisabled() bool { return false } +func (m *mockAppForLifecycle) CentralMigrationsEnabled() bool { return false } From 1d6db7b021b5b70e0418958ae2244b5745ad1bf3 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 17 Jun 2026 11:10:47 -0500 Subject: [PATCH 3/5] feat(app): CentralMigrator interface + CLI central-mode migrate routing --- app.go | 11 ++ app_impl.go | 22 ++++ central_migrator.go | 11 ++ central_migrator_test.go | 152 ++++++++++++++++++++++++++++ cli/app_runner_commands.go | 162 ++++++++++++++++++++++++------ cli/app_runner_test.go | 5 +- extensions/webrtc/test_helpers.go | 3 + lifecycle_helpers_test.go | 6 +- 8 files changed, 341 insertions(+), 31 deletions(-) create mode 100644 central_migrator.go create mode 100644 central_migrator_test.go diff --git a/app.go b/app.go index 2061b9a4..e46fa344 100644 --- a/app.go +++ b/app.go @@ -45,9 +45,20 @@ type App interface { // Configuration queries MigrationsDisabled() bool + // SetMigrationsDisabled overrides the DisableMigrations config flag at + // runtime. CLI commands call this before app.Start() to prevent the + // PhaseAfterRegister forward-migration hook from running when rolling back + // or inspecting status. + SetMigrationsDisabled(v bool) + // CentralMigrationsEnabled reports whether the single-pass migration // lifecycle is enabled. CentralMigrationsEnabled() bool + + // CentralMigrator resolves the CentralMigrator registered in the DI + // container (ok=true) or returns nil, false when nothing has been + // contributed (e.g. no grove extension or CentralMigrations is off). + CentralMigrator() (CentralMigrator, bool) } // AppConfig configures the application. diff --git a/app_impl.go b/app_impl.go index 3db4a5ec..0a8952f0 100644 --- a/app_impl.go +++ b/app_impl.go @@ -483,12 +483,34 @@ func (a *app) MigrationsDisabled() bool { return a.config.DisableMigrations } +// SetMigrationsDisabled overrides the DisableMigrations config flag at runtime. +func (a *app) SetMigrationsDisabled(v bool) { + a.config.DisableMigrations = v +} + // CentralMigrationsEnabled reports whether the single-pass migration lifecycle // is enabled via config or .forge.yaml. func (a *app) CentralMigrationsEnabled() bool { return a.config.CentralMigrations } +// CentralMigrator resolves the CentralMigrator registered in the DI container. +// Returns nil, false when the container is nil or no CentralMigrator has been +// contributed (e.g. CentralMigrations is disabled or grove is not registered). +func (a *app) CentralMigrator() (CentralMigrator, bool) { + if a.container == nil { + return nil, false + } + if !vessel.HasType[CentralMigrator](a.container) { + return nil, false + } + cm, err := vessel.Inject[CentralMigrator](a.container) + if err != nil { + return nil, false + } + return cm, true +} + // StartTime returns the application start time. func (a *app) StartTime() time.Time { a.mu.RLock() diff --git a/central_migrator.go b/central_migrator.go new file mode 100644 index 00000000..a3b5af3b --- /dev/null +++ b/central_migrator.go @@ -0,0 +1,11 @@ +package forge + +import "context" + +// CentralMigrator runs all extension migrations as one ordered set per database. +// The grove MigrationRegistry implements it; it is resolved from the DI container. +type CentralMigrator interface { + RunAll(ctx context.Context) (*MigrationResult, error) + RollbackAll(ctx context.Context) (*MigrationResult, error) + StatusAll(ctx context.Context) ([]*MigrationGroupInfo, error) +} diff --git a/central_migrator_test.go b/central_migrator_test.go new file mode 100644 index 00000000..12f1cfa4 --- /dev/null +++ b/central_migrator_test.go @@ -0,0 +1,152 @@ +package forge + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xraph/forge/internal/logger" + "github.com/xraph/vessel" +) + +// fakeCentralMigrator is a recording fake that satisfies the CentralMigrator interface. +type fakeCentralMigrator struct { + runAllCalled bool + rollbackAllCalled bool + statusAllCalled bool + + runAllResult *MigrationResult + rollbackAllResult *MigrationResult + statusAllResult []*MigrationGroupInfo +} + +func newFakeCentralMigrator() *fakeCentralMigrator { + return &fakeCentralMigrator{ + runAllResult: &MigrationResult{Applied: 2, Names: []string{"001_init", "002_users"}}, + rollbackAllResult: &MigrationResult{RolledBack: 1, Names: []string{"002_users"}}, + statusAllResult: []*MigrationGroupInfo{ + { + Name: "core", + Applied: []*MigrationInfo{ + {Version: "001", Name: "init", AppliedAt: "2024-01-01T00:00:00Z"}, + }, + Pending: []*MigrationInfo{ + {Version: "002", Name: "users"}, + }, + }, + }, + } +} + +func (f *fakeCentralMigrator) RunAll(_ context.Context) (*MigrationResult, error) { + f.runAllCalled = true + return f.runAllResult, nil +} + +func (f *fakeCentralMigrator) RollbackAll(_ context.Context) (*MigrationResult, error) { + f.rollbackAllCalled = true + return f.rollbackAllResult, nil +} + +func (f *fakeCentralMigrator) StatusAll(_ context.Context) ([]*MigrationGroupInfo, error) { + f.statusAllCalled = true + return f.statusAllResult, nil +} + +// Compile-time assertion: fakeCentralMigrator satisfies CentralMigrator. +var _ CentralMigrator = (*fakeCentralMigrator)(nil) + +// TestApp_CentralMigratorResolves verifies that CentralMigrator() returns the +// implementation when one is registered in the container, and ok=false otherwise. +func TestApp_CentralMigratorResolves(t *testing.T) { + t.Run("ReturnsFalseWhenNothingRegistered", func(t *testing.T) { + testLogger := logger.NewTestLogger() + a := NewApp(AppConfig{Logger: testLogger}) + cm, ok := a.CentralMigrator() + assert.False(t, ok, "CentralMigrator() should return false when nothing is registered") + assert.Nil(t, cm) + }) + + t.Run("ReturnsTrueWhenRegistered", func(t *testing.T) { + testLogger := logger.NewTestLogger() + a := NewApp(AppConfig{Logger: testLogger, CentralMigrations: true}) + + fake := newFakeCentralMigrator() + err := vessel.ProvideValue[CentralMigrator](a.Container(), fake) + require.NoError(t, err, "ProvideValue should succeed") + + cm, ok := a.CentralMigrator() + assert.True(t, ok, "CentralMigrator() should return true when registered") + assert.NotNil(t, cm) + + // Verify it's the same instance we registered. + result, err := cm.RunAll(context.Background()) + require.NoError(t, err) + assert.Equal(t, 2, result.Applied) + assert.True(t, fake.runAllCalled) + }) +} + +// TestApp_SetMigrationsDisabled verifies the runtime setter toggles the config flag. +func TestApp_SetMigrationsDisabled(t *testing.T) { + testLogger := logger.NewTestLogger() + a := NewApp(AppConfig{Logger: testLogger}) + + assert.False(t, a.MigrationsDisabled(), "should be false by default") + + a.SetMigrationsDisabled(true) + assert.True(t, a.MigrationsDisabled(), "should be true after SetMigrationsDisabled(true)") + + a.SetMigrationsDisabled(false) + assert.False(t, a.MigrationsDisabled(), "should be false after SetMigrationsDisabled(false)") +} + +// TestApp_SetMigrationsDisabledViaConfig verifies the initial config flag is respected. +func TestApp_SetMigrationsDisabledViaConfig(t *testing.T) { + testLogger := logger.NewTestLogger() + a := NewApp(AppConfig{Logger: testLogger, DisableMigrations: true}) + assert.True(t, a.MigrationsDisabled()) + + // Override at runtime. + a.SetMigrationsDisabled(false) + assert.False(t, a.MigrationsDisabled()) +} + +// TestApp_CentralMigratorFakeCallsThrough verifies each method on the fake +// records the call and returns the expected canned result. +func TestApp_CentralMigratorFakeCallsThrough(t *testing.T) { + testLogger := logger.NewTestLogger() + a := NewApp(AppConfig{Logger: testLogger}) + + fake := newFakeCentralMigrator() + require.NoError(t, vessel.ProvideValue[CentralMigrator](a.Container(), fake)) + + cm, ok := a.CentralMigrator() + require.True(t, ok) + + ctx := context.Background() + + // RunAll + r, err := cm.RunAll(ctx) + require.NoError(t, err) + assert.True(t, fake.runAllCalled) + assert.Equal(t, 2, r.Applied) + assert.Equal(t, []string{"001_init", "002_users"}, r.Names) + + // RollbackAll + rb, err := cm.RollbackAll(ctx) + require.NoError(t, err) + assert.True(t, fake.rollbackAllCalled) + assert.Equal(t, 1, rb.RolledBack) + assert.Equal(t, []string{"002_users"}, rb.Names) + + // StatusAll + groups, err := cm.StatusAll(ctx) + require.NoError(t, err) + assert.True(t, fake.statusAllCalled) + require.Len(t, groups, 1) + assert.Equal(t, "core", groups[0].Name) + require.Len(t, groups[0].Applied, 1) + require.Len(t, groups[0].Pending, 1) +} diff --git a/cli/app_runner_commands.go b/cli/app_runner_commands.go index 12fa9f59..3531e70a 100644 --- a/cli/app_runner_commands.go +++ b/cli/app_runner_commands.go @@ -113,11 +113,40 @@ func buildMigrateUpCommand() Command { } // Bootstrap: Start the app to initialize extensions without HTTP server. + // For central mode we do NOT suppress migrations — the PhaseAfterRegister + // forward hook is what applies migrations (schema must be up before Start-all). if err := app.Start(ctx.Context()); err != nil { return fmt.Errorf("failed to start app for migrations: %w", err) } defer app.Stop(ctx.Context()) //nolint:errcheck // best-effort cleanup + if app.CentralMigrationsEnabled() { + cm, ok := app.CentralMigrator() + if !ok { + ctx.Info("CentralMigrations is enabled but no CentralMigrator was contributed to the container (is grove registered?)") + return nil + } + + groups, err := cm.StatusAll(ctx.Context()) + if err != nil { + return fmt.Errorf("failed to get migration status: %w", err) + } + + totalApplied := 0 + totalPending := 0 + for _, g := range groups { + totalApplied += len(g.Applied) + totalPending += len(g.Pending) + } + if totalPending == 0 { + ctx.Info(fmt.Sprintf("No pending migrations (%d already applied)", totalApplied)) + } else { + ctx.Success(fmt.Sprintf("%d migration(s) applied, %d pending", totalApplied, totalPending)) + } + return nil + } + + // Per-extension path (unchanged). migratables := collectMigratableExtensions(app) if len(migratables) == 0 { ctx.Info("No extensions with migrations found") @@ -160,12 +189,54 @@ func buildMigrateDownCommand() Command { return NewError("app not available", ExitError) } + if app.CentralMigrationsEnabled() { + // Suppress the PhaseAfterRegister forward-migration hook so + // bootstrapping does not apply pending migrations before rollback. + app.SetMigrationsDisabled(true) + } + // Bootstrap app. if err := app.Start(ctx.Context()); err != nil { return fmt.Errorf("failed to start app for rollback: %w", err) } defer app.Stop(ctx.Context()) //nolint:errcheck // best-effort cleanup + if app.CentralMigrationsEnabled() { + cm, ok := app.CentralMigrator() + if !ok { + ctx.Info("CentralMigrations is enabled but no CentralMigrator was contributed to the container (is grove registered?)") + return nil + } + + // Confirmation prompt unless --force is set. + force := ctx.Bool("force") + if !force { + ok, err := ctx.Confirm("Are you sure you want to rollback?") + if err != nil { + return fmt.Errorf("confirmation failed: %w", err) + } + if !ok { + ctx.Info("Rollback cancelled") + return nil + } + } + + result, err := cm.RollbackAll(ctx.Context()) + if err != nil { + return fmt.Errorf("central rollback failed: %w", err) + } + if result.RolledBack == 0 { + ctx.Info("Nothing to rollback") + } else { + ctx.Success(fmt.Sprintf("Rolled back %d migration(s)", result.RolledBack)) + for _, name := range result.Names { + ctx.Println(fmt.Sprintf(" %s %s", Yellow("↩"), name)) + } + } + return nil + } + + // Per-extension path (unchanged). migratables := collectMigratableExtensions(app) if len(migratables) == 0 { ctx.Info("No extensions with migrations found") @@ -210,6 +281,40 @@ func buildMigrateDownCommand() Command { ) } +// renderMigrationGroups renders a slice of MigrationGroupInfo to the command +// context using the standard Version/Name/Status/Applied-At table format. +// It is shared by the per-extension and central status paths. +func renderMigrationGroups(ctx CommandContext, groupLabel string, groups []*forge.MigrationGroupInfo) { + ctx.Println("") + ctx.Println(fmt.Sprintf("%s:", Bold(groupLabel))) + + for _, g := range groups { + ctx.Println(fmt.Sprintf(" Group: %s", Bold(g.Name))) + + table := ctx.Table() + table.SetHeader([]string{"Version", "Name", "Status", "Applied At"}) + + for _, mig := range g.Applied { + table.AppendRow([]string{ + mig.Version, + mig.Name, + Green("applied"), + mig.AppliedAt, + }) + } + for _, mig := range g.Pending { + table.AppendRow([]string{ + mig.Version, + mig.Name, + Yellow("pending"), + "", + }) + } + + table.Render() + } +} + // buildMigrateStatusCommand creates the "migrate status" command. func buildMigrateStatusCommand() Command { return NewCommand("status", "Show migration status", func(ctx CommandContext) error { @@ -218,12 +323,40 @@ func buildMigrateStatusCommand() Command { return NewError("app not available", ExitError) } + if app.CentralMigrationsEnabled() { + // Suppress the PhaseAfterRegister forward-migration hook so + // bootstrapping does not apply pending migrations when showing status. + app.SetMigrationsDisabled(true) + } + // Bootstrap app. if err := app.Start(ctx.Context()); err != nil { return fmt.Errorf("failed to start app: %w", err) } defer app.Stop(ctx.Context()) //nolint:errcheck // best-effort cleanup + if app.CentralMigrationsEnabled() { + cm, ok := app.CentralMigrator() + if !ok { + ctx.Info("CentralMigrations is enabled but no CentralMigrator was contributed to the container (is grove registered?)") + return nil + } + + groups, err := cm.StatusAll(ctx.Context()) + if err != nil { + return fmt.Errorf("failed to get central migration status: %w", err) + } + + if len(groups) == 0 { + ctx.Info("No migrations registered") + return nil + } + + renderMigrationGroups(ctx, "Migrations", groups) + return nil + } + + // Per-extension path (unchanged). migratables := collectMigratableExtensions(app) if len(migratables) == 0 { ctx.Info("No extensions with migrations found") @@ -243,34 +376,7 @@ func buildMigrateStatusCommand() Command { continue } - ctx.Println("") - ctx.Println(fmt.Sprintf("%s Migrations (%s %s):", Bold(ext.Name()), ext.Name(), ext.Version())) - - for _, g := range groups { - ctx.Println(fmt.Sprintf(" Group: %s", Bold(g.Name))) - - table := ctx.Table() - table.SetHeader([]string{"Version", "Name", "Status", "Applied At"}) - - for _, mig := range g.Applied { - table.AppendRow([]string{ - mig.Version, - mig.Name, - Green("applied"), - mig.AppliedAt, - }) - } - for _, mig := range g.Pending { - table.AppendRow([]string{ - mig.Version, - mig.Name, - Yellow("pending"), - "", - }) - } - - table.Render() - } + renderMigrationGroups(ctx, fmt.Sprintf("%s Migrations (%s %s)", Bold(ext.Name()), ext.Name(), ext.Version()), groups) } return nil }) diff --git a/cli/app_runner_test.go b/cli/app_runner_test.go index f498b15a..069059b3 100644 --- a/cli/app_runner_test.go +++ b/cli/app_runner_test.go @@ -471,7 +471,10 @@ func (a *mockApp) GetExtension(name string) (forge.Extension, error) { } return nil, nil } -func (a *mockApp) MigrationsDisabled() bool { return false } +func (a *mockApp) MigrationsDisabled() bool { return false } +func (a *mockApp) SetMigrationsDisabled(_ bool) {} +func (a *mockApp) CentralMigrationsEnabled() bool { return false } +func (a *mockApp) CentralMigrator() (forge.CentralMigrator, bool) { return nil, false } // plainExt is a minimal extension that does NOT implement MigratableExtension. type plainExt struct { diff --git a/extensions/webrtc/test_helpers.go b/extensions/webrtc/test_helpers.go index af9bfddf..27731c04 100644 --- a/extensions/webrtc/test_helpers.go +++ b/extensions/webrtc/test_helpers.go @@ -51,3 +51,6 @@ func (m *mockApp) Uptime() time.Duration { ret func (m *mockApp) Extensions() []forge.Extension { return nil } func (m *mockApp) HealthManager() forge.HealthManager { return nil } func (m *mockApp) MigrationsDisabled() bool { return false } +func (m *mockApp) SetMigrationsDisabled(_ bool) {} +func (m *mockApp) CentralMigrationsEnabled() bool { return false } +func (m *mockApp) CentralMigrator() (forge.CentralMigrator, bool) { return nil, false } diff --git a/lifecycle_helpers_test.go b/lifecycle_helpers_test.go index fe6fe3f4..f959fee8 100644 --- a/lifecycle_helpers_test.go +++ b/lifecycle_helpers_test.go @@ -173,5 +173,7 @@ func (m *mockAppForLifecycle) StartTime() time.Time { return func (m *mockAppForLifecycle) Uptime() time.Duration { return 0 } func (m *mockAppForLifecycle) Extensions() []Extension { return nil } func (m *mockAppForLifecycle) GetExtension(_ string) (Extension, error) { return nil, nil } -func (m *mockAppForLifecycle) MigrationsDisabled() bool { return false } -func (m *mockAppForLifecycle) CentralMigrationsEnabled() bool { return false } +func (m *mockAppForLifecycle) MigrationsDisabled() bool { return false } +func (m *mockAppForLifecycle) SetMigrationsDisabled(_ bool) {} +func (m *mockAppForLifecycle) CentralMigrationsEnabled() bool { return false } +func (m *mockAppForLifecycle) CentralMigrator() (CentralMigrator, bool) { return nil, false } From 32bd21bb2b5a296341662379b6db821293db9c14 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 17 Jun 2026 11:19:05 -0500 Subject: [PATCH 4/5] fix(cli): keep default status output unchanged; test central migrate routing --- cli/app_runner_commands.go | 26 +++-- cli/app_runner_test.go | 217 +++++++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 12 deletions(-) diff --git a/cli/app_runner_commands.go b/cli/app_runner_commands.go index 3531e70a..d8deeec3 100644 --- a/cli/app_runner_commands.go +++ b/cli/app_runner_commands.go @@ -123,7 +123,7 @@ func buildMigrateUpCommand() Command { if app.CentralMigrationsEnabled() { cm, ok := app.CentralMigrator() if !ok { - ctx.Info("CentralMigrations is enabled but no CentralMigrator was contributed to the container (is grove registered?)") + ctx.Info("migrations applied during startup; no CentralMigrator registered for status reporting") return nil } @@ -139,9 +139,9 @@ func buildMigrateUpCommand() Command { totalPending += len(g.Pending) } if totalPending == 0 { - ctx.Info(fmt.Sprintf("No pending migrations (%d already applied)", totalApplied)) + ctx.Success(fmt.Sprintf("All migrations applied (%d total)", totalApplied)) } else { - ctx.Success(fmt.Sprintf("%d migration(s) applied, %d pending", totalApplied, totalPending)) + ctx.Info(fmt.Sprintf("%d migration(s) applied, %d still pending", totalApplied, totalPending)) } return nil } @@ -281,13 +281,11 @@ func buildMigrateDownCommand() Command { ) } -// renderMigrationGroups renders a slice of MigrationGroupInfo to the command -// context using the standard Version/Name/Status/Applied-At table format. -// It is shared by the per-extension and central status paths. -func renderMigrationGroups(ctx CommandContext, groupLabel string, groups []*forge.MigrationGroupInfo) { - ctx.Println("") - ctx.Println(fmt.Sprintf("%s:", Bold(groupLabel))) - +// renderMigrationGroups renders only the per-group section (group header + +// Version/Name/Status/Applied-At table) for each group in the slice. +// Callers are responsible for printing their own top-level section header +// before calling this helper. +func renderMigrationGroups(ctx CommandContext, groups []*forge.MigrationGroupInfo) { for _, g := range groups { ctx.Println(fmt.Sprintf(" Group: %s", Bold(g.Name))) @@ -352,7 +350,9 @@ func buildMigrateStatusCommand() Command { return nil } - renderMigrationGroups(ctx, "Migrations", groups) + ctx.Println("") + ctx.Println(fmt.Sprintf("%s:", Bold("Central migrations"))) + renderMigrationGroups(ctx, groups) return nil } @@ -376,7 +376,9 @@ func buildMigrateStatusCommand() Command { continue } - renderMigrationGroups(ctx, fmt.Sprintf("%s Migrations (%s %s)", Bold(ext.Name()), ext.Name(), ext.Version()), groups) + ctx.Println("") + ctx.Println(fmt.Sprintf("%s Migrations (%s %s):", Bold(ext.Name()), ext.Name(), ext.Version())) + renderMigrationGroups(ctx, groups) } return nil }) diff --git a/cli/app_runner_test.go b/cli/app_runner_test.go index 069059b3..bcee8d0a 100644 --- a/cli/app_runner_test.go +++ b/cli/app_runner_test.go @@ -1,7 +1,9 @@ package cli import ( + "bytes" "context" + "sync" "testing" "time" @@ -528,3 +530,218 @@ func (e *commandProviderExt) Stop(_ context.Context) error { return nil } func (e *commandProviderExt) Health(_ context.Context) error { return nil } func (e *commandProviderExt) Dependencies() []string { return nil } func (e *commandProviderExt) CLICommands() []any { return e.commands } + +// --- Central-mode mock types --- + +// callEvent records the name of a method call for ordering assertions. +type callEvent struct { + method string +} + +// centralMockApp is a mockApp variant with CentralMigrationsEnabled == true. +// It records the order of SetMigrationsDisabled and Start calls so tests can +// verify suppression happens before startup. +type centralMockApp struct { + mockApp + mu sync.Mutex + events []callEvent + fakeCM forge.CentralMigrator + hasCM bool +} + +func (a *centralMockApp) CentralMigrationsEnabled() bool { return true } + +func (a *centralMockApp) SetMigrationsDisabled(v bool) { + a.mu.Lock() + defer a.mu.Unlock() + if v { + a.events = append(a.events, callEvent{"SetMigrationsDisabled"}) + } +} + +func (a *centralMockApp) Start(_ context.Context) error { + a.mu.Lock() + defer a.mu.Unlock() + a.events = append(a.events, callEvent{"Start"}) + return a.startErr +} + +func (a *centralMockApp) CentralMigrator() (forge.CentralMigrator, bool) { + return a.fakeCM, a.hasCM +} + +// fakeCentralMigrator records which high-level method was called. +type fakeCentralMigrator struct { + mu sync.Mutex + calledRunAll bool + calledRollback bool + calledStatus bool + rollbackResult *forge.MigrationResult + statusGroups []*forge.MigrationGroupInfo +} + +func (f *fakeCentralMigrator) RunAll(_ context.Context) (*forge.MigrationResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calledRunAll = true + return &forge.MigrationResult{}, nil +} + +func (f *fakeCentralMigrator) RollbackAll(_ context.Context) (*forge.MigrationResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calledRollback = true + if f.rollbackResult != nil { + return f.rollbackResult, nil + } + return &forge.MigrationResult{RolledBack: 1, Names: []string{"001_init"}}, nil +} + +func (f *fakeCentralMigrator) StatusAll(_ context.Context) ([]*forge.MigrationGroupInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calledStatus = true + if f.statusGroups != nil { + return f.statusGroups, nil + } + return []*forge.MigrationGroupInfo{ + { + Name: "default", + Applied: []*forge.MigrationInfo{{Version: "001", Name: "init", AppliedAt: "2026-01-01"}}, + Pending: nil, + }, + }, nil +} + +// newCentralCLI builds a CLI wired to a centralMockApp so commands can +// invoke the migrate subcommands via c.Run(args). +func newCentralCLI(app *centralMockApp, out *bytes.Buffer) CLI { + c := New(Config{ + Name: "testapp", + App: app, + }) + if out != nil { + c.SetOutput(out) + } + migrateCmd := buildMigrateCommand() + _ = c.AddCommand(migrateCmd) + return c +} + +// --- Central routing tests --- + +// TestCentralMigrateDown_SuppressionBeforeStart verifies that when central +// migrations are enabled, SetMigrationsDisabled(true) is recorded BEFORE +// Start in the call sequence for "migrate down". +func TestCentralMigrateDown_SuppressionBeforeStart(t *testing.T) { + fakeCM := &fakeCentralMigrator{} + app := ¢ralMockApp{ + mockApp: mockApp{name: "test-central", logger: forge.NewNoopLogger()}, + fakeCM: fakeCM, + hasCM: true, + } + + var out bytes.Buffer + c := newCentralCLI(app, &out) + + // Use --force to skip the interactive confirmation prompt. + err := c.Run([]string{"testapp", "migrate", "down", "--force"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + app.mu.Lock() + events := make([]callEvent, len(app.events)) + copy(events, app.events) + app.mu.Unlock() + + // Must have at least SetMigrationsDisabled and Start recorded. + if len(events) < 2 { + t.Fatalf("expected at least 2 call events, got %d: %v", len(events), events) + } + + // SetMigrationsDisabled must come before Start. + suppressIdx := -1 + startIdx := -1 + for i, ev := range events { + if ev.method == "SetMigrationsDisabled" && suppressIdx == -1 { + suppressIdx = i + } + if ev.method == "Start" && startIdx == -1 { + startIdx = i + } + } + if suppressIdx == -1 { + t.Error("SetMigrationsDisabled(true) was never called") + } + if startIdx == -1 { + t.Error("Start was never called") + } + if suppressIdx != -1 && startIdx != -1 && suppressIdx >= startIdx { + t.Errorf("expected SetMigrationsDisabled before Start, got indices suppress=%d start=%d", suppressIdx, startIdx) + } + + // RollbackAll must have been called on the central migrator. + fakeCM.mu.Lock() + rolledBack := fakeCM.calledRollback + fakeCM.mu.Unlock() + if !rolledBack { + t.Error("expected fakeCentralMigrator.RollbackAll to be called") + } +} + +// TestCentralMigrateStatus_SuppressionBeforeStart verifies that for "migrate +// status" in central mode, suppression happens before Start and StatusAll is +// called (not per-extension MigrationStatus). +func TestCentralMigrateStatus_SuppressionBeforeStart(t *testing.T) { + fakeCM := &fakeCentralMigrator{} + app := ¢ralMockApp{ + mockApp: mockApp{name: "test-central", logger: forge.NewNoopLogger()}, + fakeCM: fakeCM, + hasCM: true, + // Also register a migratable extension so we can confirm it is NOT used. + // (extensions field is on the embedded mockApp) + } + app.mockApp.extensions = []forge.Extension{&migratableExt{name: "some-ext"}} + + var out bytes.Buffer + c := newCentralCLI(app, &out) + + err := c.Run([]string{"testapp", "migrate", "status"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + app.mu.Lock() + events := make([]callEvent, len(app.events)) + copy(events, app.events) + app.mu.Unlock() + + suppressIdx := -1 + startIdx := -1 + for i, ev := range events { + if ev.method == "SetMigrationsDisabled" && suppressIdx == -1 { + suppressIdx = i + } + if ev.method == "Start" && startIdx == -1 { + startIdx = i + } + } + if suppressIdx == -1 { + t.Error("SetMigrationsDisabled(true) was never called for status") + } + if startIdx == -1 { + t.Error("Start was never called for status") + } + if suppressIdx != -1 && startIdx != -1 && suppressIdx >= startIdx { + t.Errorf("suppression must precede Start: suppress=%d start=%d", suppressIdx, startIdx) + } + + // StatusAll must have been called. + fakeCM.mu.Lock() + statusCalled := fakeCM.calledStatus + fakeCM.mu.Unlock() + if !statusCalled { + t.Error("expected fakeCentralMigrator.StatusAll to be called") + } +} From 80440995819e5abda616e263a30c6acdc9bb4aac Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 17 Jun 2026 11:42:00 -0500 Subject: [PATCH 5/5] refactor: refactored migrations to avoid process locks --- central_migrator_test.go | 2 +- cli/app_runner_test.go | 28 ++++++++++++++-------------- extensions/webrtc/test_helpers.go | 4 ++-- lifecycle_helpers_test.go | 8 ++++---- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/central_migrator_test.go b/central_migrator_test.go index 12f1cfa4..253cdf29 100644 --- a/central_migrator_test.go +++ b/central_migrator_test.go @@ -23,7 +23,7 @@ type fakeCentralMigrator struct { func newFakeCentralMigrator() *fakeCentralMigrator { return &fakeCentralMigrator{ - runAllResult: &MigrationResult{Applied: 2, Names: []string{"001_init", "002_users"}}, + runAllResult: &MigrationResult{Applied: 2, Names: []string{"001_init", "002_users"}}, rollbackAllResult: &MigrationResult{RolledBack: 1, Names: []string{"002_users"}}, statusAllResult: []*MigrationGroupInfo{ { diff --git a/cli/app_runner_test.go b/cli/app_runner_test.go index bcee8d0a..07f90688 100644 --- a/cli/app_runner_test.go +++ b/cli/app_runner_test.go @@ -473,10 +473,10 @@ func (a *mockApp) GetExtension(name string) (forge.Extension, error) { } return nil, nil } -func (a *mockApp) MigrationsDisabled() bool { return false } -func (a *mockApp) SetMigrationsDisabled(_ bool) {} -func (a *mockApp) CentralMigrationsEnabled() bool { return false } -func (a *mockApp) CentralMigrator() (forge.CentralMigrator, bool) { return nil, false } +func (a *mockApp) MigrationsDisabled() bool { return false } +func (a *mockApp) SetMigrationsDisabled(_ bool) {} +func (a *mockApp) CentralMigrationsEnabled() bool { return false } +func (a *mockApp) CentralMigrator() (forge.CentralMigrator, bool) { return nil, false } // plainExt is a minimal extension that does NOT implement MigratableExtension. type plainExt struct { @@ -543,10 +543,10 @@ type callEvent struct { // verify suppression happens before startup. type centralMockApp struct { mockApp - mu sync.Mutex - events []callEvent - fakeCM forge.CentralMigrator - hasCM bool + mu sync.Mutex + events []callEvent + fakeCM forge.CentralMigrator + hasCM bool } func (a *centralMockApp) CentralMigrationsEnabled() bool { return true } @@ -572,12 +572,12 @@ func (a *centralMockApp) CentralMigrator() (forge.CentralMigrator, bool) { // fakeCentralMigrator records which high-level method was called. type fakeCentralMigrator struct { - mu sync.Mutex - calledRunAll bool - calledRollback bool - calledStatus bool - rollbackResult *forge.MigrationResult - statusGroups []*forge.MigrationGroupInfo + mu sync.Mutex + calledRunAll bool + calledRollback bool + calledStatus bool + rollbackResult *forge.MigrationResult + statusGroups []*forge.MigrationGroupInfo } func (f *fakeCentralMigrator) RunAll(_ context.Context) (*forge.MigrationResult, error) { diff --git a/extensions/webrtc/test_helpers.go b/extensions/webrtc/test_helpers.go index 27731c04..954b9fbf 100644 --- a/extensions/webrtc/test_helpers.go +++ b/extensions/webrtc/test_helpers.go @@ -52,5 +52,5 @@ func (m *mockApp) Extensions() []forge.Extension { ret func (m *mockApp) HealthManager() forge.HealthManager { return nil } func (m *mockApp) MigrationsDisabled() bool { return false } func (m *mockApp) SetMigrationsDisabled(_ bool) {} -func (m *mockApp) CentralMigrationsEnabled() bool { return false } -func (m *mockApp) CentralMigrator() (forge.CentralMigrator, bool) { return nil, false } +func (m *mockApp) CentralMigrationsEnabled() bool { return false } +func (m *mockApp) CentralMigrator() (forge.CentralMigrator, bool) { return nil, false } diff --git a/lifecycle_helpers_test.go b/lifecycle_helpers_test.go index f959fee8..13df62b6 100644 --- a/lifecycle_helpers_test.go +++ b/lifecycle_helpers_test.go @@ -173,7 +173,7 @@ func (m *mockAppForLifecycle) StartTime() time.Time { return func (m *mockAppForLifecycle) Uptime() time.Duration { return 0 } func (m *mockAppForLifecycle) Extensions() []Extension { return nil } func (m *mockAppForLifecycle) GetExtension(_ string) (Extension, error) { return nil, nil } -func (m *mockAppForLifecycle) MigrationsDisabled() bool { return false } -func (m *mockAppForLifecycle) SetMigrationsDisabled(_ bool) {} -func (m *mockAppForLifecycle) CentralMigrationsEnabled() bool { return false } -func (m *mockAppForLifecycle) CentralMigrator() (CentralMigrator, bool) { return nil, false } +func (m *mockAppForLifecycle) MigrationsDisabled() bool { return false } +func (m *mockAppForLifecycle) SetMigrationsDisabled(_ bool) {} +func (m *mockAppForLifecycle) CentralMigrationsEnabled() bool { return false } +func (m *mockAppForLifecycle) CentralMigrator() (CentralMigrator, bool) { return nil, false }