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
27 changes: 27 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ 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.
Expand Down Expand Up @@ -103,6 +118,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.
Expand Down Expand Up @@ -335,6 +356,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.
Expand Down
143 changes: 116 additions & 27 deletions app_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +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()
Expand Down Expand Up @@ -527,41 +555,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)
}

if err := ext.Register(a); err != nil {
return fmt.Errorf("failed to register extension %s: %w", ext.Name(), err)
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()),
)
// 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 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()),
)

// 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.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()),
)

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
Expand Down Expand Up @@ -1454,6 +1534,7 @@ type forgeYAMLConfig struct {
} `yaml:"build"`
Database struct {
DisableMigrations bool `yaml:"disable_migrations"`
CentralMigrations bool `yaml:"central_migrations"`
} `yaml:"database"`
}

Expand Down Expand Up @@ -1551,5 +1632,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
}
Loading
Loading