Skip to content

Commit 1d6db7b

Browse files
committed
feat(app): CentralMigrator interface + CLI central-mode migrate routing
1 parent d11e40c commit 1d6db7b

8 files changed

Lines changed: 341 additions & 31 deletions

File tree

app.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,20 @@ type App interface {
4545
// Configuration queries
4646
MigrationsDisabled() bool
4747

48+
// SetMigrationsDisabled overrides the DisableMigrations config flag at
49+
// runtime. CLI commands call this before app.Start() to prevent the
50+
// PhaseAfterRegister forward-migration hook from running when rolling back
51+
// or inspecting status.
52+
SetMigrationsDisabled(v bool)
53+
4854
// CentralMigrationsEnabled reports whether the single-pass migration
4955
// lifecycle is enabled.
5056
CentralMigrationsEnabled() bool
57+
58+
// CentralMigrator resolves the CentralMigrator registered in the DI
59+
// container (ok=true) or returns nil, false when nothing has been
60+
// contributed (e.g. no grove extension or CentralMigrations is off).
61+
CentralMigrator() (CentralMigrator, bool)
5162
}
5263

5364
// AppConfig configures the application.

app_impl.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,12 +483,34 @@ func (a *app) MigrationsDisabled() bool {
483483
return a.config.DisableMigrations
484484
}
485485

486+
// SetMigrationsDisabled overrides the DisableMigrations config flag at runtime.
487+
func (a *app) SetMigrationsDisabled(v bool) {
488+
a.config.DisableMigrations = v
489+
}
490+
486491
// CentralMigrationsEnabled reports whether the single-pass migration lifecycle
487492
// is enabled via config or .forge.yaml.
488493
func (a *app) CentralMigrationsEnabled() bool {
489494
return a.config.CentralMigrations
490495
}
491496

497+
// CentralMigrator resolves the CentralMigrator registered in the DI container.
498+
// Returns nil, false when the container is nil or no CentralMigrator has been
499+
// contributed (e.g. CentralMigrations is disabled or grove is not registered).
500+
func (a *app) CentralMigrator() (CentralMigrator, bool) {
501+
if a.container == nil {
502+
return nil, false
503+
}
504+
if !vessel.HasType[CentralMigrator](a.container) {
505+
return nil, false
506+
}
507+
cm, err := vessel.Inject[CentralMigrator](a.container)
508+
if err != nil {
509+
return nil, false
510+
}
511+
return cm, true
512+
}
513+
492514
// StartTime returns the application start time.
493515
func (a *app) StartTime() time.Time {
494516
a.mu.RLock()

central_migrator.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package forge
2+
3+
import "context"
4+
5+
// CentralMigrator runs all extension migrations as one ordered set per database.
6+
// The grove MigrationRegistry implements it; it is resolved from the DI container.
7+
type CentralMigrator interface {
8+
RunAll(ctx context.Context) (*MigrationResult, error)
9+
RollbackAll(ctx context.Context) (*MigrationResult, error)
10+
StatusAll(ctx context.Context) ([]*MigrationGroupInfo, error)
11+
}

central_migrator_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package forge
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
"github.com/xraph/forge/internal/logger"
10+
"github.com/xraph/vessel"
11+
)
12+
13+
// fakeCentralMigrator is a recording fake that satisfies the CentralMigrator interface.
14+
type fakeCentralMigrator struct {
15+
runAllCalled bool
16+
rollbackAllCalled bool
17+
statusAllCalled bool
18+
19+
runAllResult *MigrationResult
20+
rollbackAllResult *MigrationResult
21+
statusAllResult []*MigrationGroupInfo
22+
}
23+
24+
func newFakeCentralMigrator() *fakeCentralMigrator {
25+
return &fakeCentralMigrator{
26+
runAllResult: &MigrationResult{Applied: 2, Names: []string{"001_init", "002_users"}},
27+
rollbackAllResult: &MigrationResult{RolledBack: 1, Names: []string{"002_users"}},
28+
statusAllResult: []*MigrationGroupInfo{
29+
{
30+
Name: "core",
31+
Applied: []*MigrationInfo{
32+
{Version: "001", Name: "init", AppliedAt: "2024-01-01T00:00:00Z"},
33+
},
34+
Pending: []*MigrationInfo{
35+
{Version: "002", Name: "users"},
36+
},
37+
},
38+
},
39+
}
40+
}
41+
42+
func (f *fakeCentralMigrator) RunAll(_ context.Context) (*MigrationResult, error) {
43+
f.runAllCalled = true
44+
return f.runAllResult, nil
45+
}
46+
47+
func (f *fakeCentralMigrator) RollbackAll(_ context.Context) (*MigrationResult, error) {
48+
f.rollbackAllCalled = true
49+
return f.rollbackAllResult, nil
50+
}
51+
52+
func (f *fakeCentralMigrator) StatusAll(_ context.Context) ([]*MigrationGroupInfo, error) {
53+
f.statusAllCalled = true
54+
return f.statusAllResult, nil
55+
}
56+
57+
// Compile-time assertion: fakeCentralMigrator satisfies CentralMigrator.
58+
var _ CentralMigrator = (*fakeCentralMigrator)(nil)
59+
60+
// TestApp_CentralMigratorResolves verifies that CentralMigrator() returns the
61+
// implementation when one is registered in the container, and ok=false otherwise.
62+
func TestApp_CentralMigratorResolves(t *testing.T) {
63+
t.Run("ReturnsFalseWhenNothingRegistered", func(t *testing.T) {
64+
testLogger := logger.NewTestLogger()
65+
a := NewApp(AppConfig{Logger: testLogger})
66+
cm, ok := a.CentralMigrator()
67+
assert.False(t, ok, "CentralMigrator() should return false when nothing is registered")
68+
assert.Nil(t, cm)
69+
})
70+
71+
t.Run("ReturnsTrueWhenRegistered", func(t *testing.T) {
72+
testLogger := logger.NewTestLogger()
73+
a := NewApp(AppConfig{Logger: testLogger, CentralMigrations: true})
74+
75+
fake := newFakeCentralMigrator()
76+
err := vessel.ProvideValue[CentralMigrator](a.Container(), fake)
77+
require.NoError(t, err, "ProvideValue should succeed")
78+
79+
cm, ok := a.CentralMigrator()
80+
assert.True(t, ok, "CentralMigrator() should return true when registered")
81+
assert.NotNil(t, cm)
82+
83+
// Verify it's the same instance we registered.
84+
result, err := cm.RunAll(context.Background())
85+
require.NoError(t, err)
86+
assert.Equal(t, 2, result.Applied)
87+
assert.True(t, fake.runAllCalled)
88+
})
89+
}
90+
91+
// TestApp_SetMigrationsDisabled verifies the runtime setter toggles the config flag.
92+
func TestApp_SetMigrationsDisabled(t *testing.T) {
93+
testLogger := logger.NewTestLogger()
94+
a := NewApp(AppConfig{Logger: testLogger})
95+
96+
assert.False(t, a.MigrationsDisabled(), "should be false by default")
97+
98+
a.SetMigrationsDisabled(true)
99+
assert.True(t, a.MigrationsDisabled(), "should be true after SetMigrationsDisabled(true)")
100+
101+
a.SetMigrationsDisabled(false)
102+
assert.False(t, a.MigrationsDisabled(), "should be false after SetMigrationsDisabled(false)")
103+
}
104+
105+
// TestApp_SetMigrationsDisabledViaConfig verifies the initial config flag is respected.
106+
func TestApp_SetMigrationsDisabledViaConfig(t *testing.T) {
107+
testLogger := logger.NewTestLogger()
108+
a := NewApp(AppConfig{Logger: testLogger, DisableMigrations: true})
109+
assert.True(t, a.MigrationsDisabled())
110+
111+
// Override at runtime.
112+
a.SetMigrationsDisabled(false)
113+
assert.False(t, a.MigrationsDisabled())
114+
}
115+
116+
// TestApp_CentralMigratorFakeCallsThrough verifies each method on the fake
117+
// records the call and returns the expected canned result.
118+
func TestApp_CentralMigratorFakeCallsThrough(t *testing.T) {
119+
testLogger := logger.NewTestLogger()
120+
a := NewApp(AppConfig{Logger: testLogger})
121+
122+
fake := newFakeCentralMigrator()
123+
require.NoError(t, vessel.ProvideValue[CentralMigrator](a.Container(), fake))
124+
125+
cm, ok := a.CentralMigrator()
126+
require.True(t, ok)
127+
128+
ctx := context.Background()
129+
130+
// RunAll
131+
r, err := cm.RunAll(ctx)
132+
require.NoError(t, err)
133+
assert.True(t, fake.runAllCalled)
134+
assert.Equal(t, 2, r.Applied)
135+
assert.Equal(t, []string{"001_init", "002_users"}, r.Names)
136+
137+
// RollbackAll
138+
rb, err := cm.RollbackAll(ctx)
139+
require.NoError(t, err)
140+
assert.True(t, fake.rollbackAllCalled)
141+
assert.Equal(t, 1, rb.RolledBack)
142+
assert.Equal(t, []string{"002_users"}, rb.Names)
143+
144+
// StatusAll
145+
groups, err := cm.StatusAll(ctx)
146+
require.NoError(t, err)
147+
assert.True(t, fake.statusAllCalled)
148+
require.Len(t, groups, 1)
149+
assert.Equal(t, "core", groups[0].Name)
150+
require.Len(t, groups[0].Applied, 1)
151+
require.Len(t, groups[0].Pending, 1)
152+
}

0 commit comments

Comments
 (0)