Skip to content

Commit 23a9e68

Browse files
committed
refactor: split preferences into user and org scopes
Split the monolithic Preferences service into UserPreferences (env-level) and OrgPreferences (org-level). User preferences (theme, active org, role) persist across org switches. Org preferences (account, workspace) are scoped to each organization. - Add theme preference (auto/dark/light) with auto-detection default - Rename config.yaml to preferences.yaml at both scopes - Absorb active.yaml into user-level preferences.yaml - Use typed domain IDs (OrganizationID, AccountID, WorkspaceID) in config - Remove dead preference fields (Services, Email, DatadogAPIKey, etc.) - Extract migration logic into migrate.go with detect-and-dispatch pattern - Add migration tests covering all prior disk layouts (V1/V2/V3) - Replace map[string]interface{} with map[string]any
1 parent 90aca09 commit 23a9e68

21 files changed

Lines changed: 1024 additions & 654 deletions

File tree

‎internal/app/app.go‎

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ type Model struct {
7070
authService auth.Auth
7171
syncer powersync.Syncer
7272
services api.APIServices
73-
prefs preferences.Preferences
73+
userPrefs preferences.UserPreferences
74+
orgPrefs preferences.OrgPreferences
7475

7576
// Runtime (created after account selection / onboarding)
7677
sessionCancel context.CancelFunc
@@ -108,7 +109,8 @@ func New(
108109
version string,
109110
services api.APIServices,
110111
authService auth.Auth,
111-
prefs preferences.Preferences,
112+
userPrefs preferences.UserPreferences,
113+
orgPrefs preferences.OrgPreferences,
112114
storage sqlite.Storage,
113115
syncer powersync.Syncer,
114116
scope log.Scope,
@@ -122,8 +124,11 @@ func New(
122124
if authService == nil {
123125
panic("authService is nil")
124126
}
125-
if prefs == nil {
126-
panic("prefs is nil")
127+
if userPrefs == nil {
128+
panic("userPrefs is nil")
129+
}
130+
if orgPrefs == nil {
131+
panic("orgPrefs is nil")
127132
}
128133
if storage == nil {
129134
panic("storage is nil")
@@ -144,11 +149,12 @@ func New(
144149
authService: authService,
145150
syncer: syncer,
146151
services: services,
147-
prefs: prefs,
152+
userPrefs: userPrefs,
153+
orgPrefs: orgPrefs,
148154
statusBar: statusbar.New(theme, syncer, cfg.APIEndpoint),
149155
toast: toast.New(theme),
150156
keyBar: keybar.New(theme, scope),
151-
onboarding: onboarding.New(ctx, theme, services, prefs, authService, syncer, scope),
157+
onboarding: onboarding.New(ctx, theme, services, userPrefs, orgPrefs, authService, syncer, scope),
152158
state: stateOnboarding,
153159
}
154160
}
@@ -259,10 +265,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
259265
}
260266

261267
case onboardingmsg.OrgSelected:
262-
return m, m.activateOrg(msg.Org.ID.String(), msg)
268+
return m, m.activateOrg(msg.Org.ID, msg)
263269

264270
case onboardingmsg.OrgCreated:
265-
return m, m.activateOrg(msg.Org.ID.String(), msg)
271+
return m, m.activateOrg(msg.Org.ID, msg)
266272

267273
case onboardingmsg.AccountSelected:
268274
// Open database and start syncer when account is selected
@@ -708,21 +714,21 @@ func (m *Model) shutdown() {
708714
}
709715
}
710716

711-
// activateOrg sets the active org, reloads config/prefs/storage for the new
717+
// activateOrg sets the active org, reloads org prefs/storage for the new
712718
// org's isolated data directory, and forwards the message to onboarding.
713-
func (m *Model) activateOrg(orgID string, msg tea.Msg) tea.Cmd {
719+
func (m *Model) activateOrg(orgID domain.OrganizationID, msg tea.Msg) tea.Cmd {
714720
env := m.cfg.Environment()
715721
m.scope.Info("setting active org", "org_id", orgID)
716-
_ = config.SetActiveOrgID(env, orgID)
722+
_ = m.userPrefs.SetActiveOrgID(orgID)
717723

718-
cfg, err := config.Load(env, orgID)
724+
cfg, err := config.LoadOrgPreferences(env, orgID)
719725
if err != nil {
720726
m.scope.Error("failed to reload config for org", "error", err)
721727
} else {
722-
m.prefs = preferences.NewService(cfg, m.scope)
728+
m.orgPrefs = preferences.NewOrgService(cfg, m.scope)
723729
m.storage = sqlite.NewStorageService(cfg)
724730
if m.onboarding != nil {
725-
m.onboarding.SetPreferences(m.prefs)
731+
m.onboarding.SetOrgPreferences(m.orgPrefs)
726732
}
727733
}
728734

@@ -744,7 +750,7 @@ func (m *Model) switchOrganization() tea.Cmd {
744750
// re-enters onboarding. The saved org auto-selects, then prompts for account.
745751
func (m *Model) switchAccount() tea.Cmd {
746752
m.scope.Info("switching account")
747-
_ = m.prefs.ClearDefaultAccountID()
753+
_ = m.orgPrefs.ClearDefaultAccountID()
748754
return m.restartOnboarding()
749755
}
750756

@@ -763,7 +769,7 @@ func (m *Model) restartOnboarding() tea.Cmd {
763769
m.statusBar = statusbar.New(m.theme, m.syncer, m.cfg.APIEndpoint)
764770
m.windowTitle = ""
765771

766-
m.onboarding = onboarding.New(m.ctx, m.theme, m.services, m.prefs, m.authService, m.syncer, m.scope)
772+
m.onboarding = onboarding.New(m.ctx, m.theme, m.services, m.userPrefs, m.orgPrefs, m.authService, m.syncer, m.scope)
767773
m.state = stateOnboarding
768774
m.updateLayout()
769775

‎internal/app/onboarding/accounts/create.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ type CreateModel struct {
2929
ctx context.Context
3030
theme styles.Theme
3131
services api.APIServices
32-
prefs preferences.Preferences
32+
prefs preferences.OrgPreferences
3333
scope log.Scope
3434
org domain.Organization
3535

@@ -46,7 +46,7 @@ func NewCreate(
4646
theme styles.Theme,
4747
org domain.Organization,
4848
services api.APIServices,
49-
prefs preferences.Preferences,
49+
prefs preferences.OrgPreferences,
5050
scope log.Scope,
5151
) *CreateModel {
5252
if ctx == nil {

‎internal/app/onboarding/accounts/select.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ type SelectModel struct {
2323
ctx context.Context
2424
theme styles.Theme
2525
services api.APIServices
26-
prefs preferences.Preferences
26+
prefs preferences.OrgPreferences
2727
scope log.Scope
2828
org domain.Organization
2929

@@ -39,7 +39,7 @@ func NewSelect(
3939
theme styles.Theme,
4040
org domain.Organization,
4141
services api.APIServices,
42-
prefs preferences.Preferences,
42+
prefs preferences.OrgPreferences,
4343
scope log.Scope,
4444
) *SelectModel {
4545
if ctx == nil {

‎internal/app/onboarding/onboarding.go‎

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,14 @@ import (
2929
// Model is the onboarding orchestrator.
3030
type Model struct {
3131
// Dependencies available from start
32-
ctx context.Context
33-
theme styles.Theme
34-
services api.APIServices
35-
prefs preferences.Preferences
36-
auth iauth.Auth
37-
syncer powersync.Syncer
38-
scope log.Scope
32+
ctx context.Context
33+
theme styles.Theme
34+
services api.APIServices
35+
userPrefs preferences.UserPreferences
36+
orgPrefs preferences.OrgPreferences
37+
auth iauth.Auth
38+
syncer powersync.Syncer
39+
scope log.Scope
3940

4041
// Accumulated state from step completions
4142
user *iauth.User
@@ -56,16 +57,20 @@ func New(
5657
ctx context.Context,
5758
theme styles.Theme,
5859
services api.APIServices,
59-
prefs preferences.Preferences,
60+
userPrefs preferences.UserPreferences,
61+
orgPrefs preferences.OrgPreferences,
6062
authService iauth.Auth,
6163
syncer powersync.Syncer,
6264
scope log.Scope,
6365
) *Model {
6466
if ctx == nil {
6567
panic("ctx is nil")
6668
}
67-
if prefs == nil {
68-
panic("prefs is nil")
69+
if userPrefs == nil {
70+
panic("userPrefs is nil")
71+
}
72+
if orgPrefs == nil {
73+
panic("orgPrefs is nil")
6974
}
7075
if authService == nil {
7176
panic("authService is nil")
@@ -77,20 +82,21 @@ func New(
7782
scope = scope.Child("onboarding")
7883

7984
return &Model{
80-
ctx: ctx,
81-
theme: theme,
82-
services: services,
83-
prefs: prefs,
84-
auth: authService,
85-
syncer: syncer,
86-
scope: scope,
85+
ctx: ctx,
86+
theme: theme,
87+
services: services,
88+
userPrefs: userPrefs,
89+
orgPrefs: orgPrefs,
90+
auth: authService,
91+
syncer: syncer,
92+
scope: scope,
8793
}
8894
}
8995

90-
// SetPreferences replaces the preferences used by subsequent onboarding steps.
91-
// Called by the app when switching to a different org-scoped config.
92-
func (m *Model) SetPreferences(prefs preferences.Preferences) {
93-
m.prefs = prefs
96+
// SetOrgPreferences replaces the org preferences used by subsequent onboarding steps.
97+
// Called by the app when switching to a different organization.
98+
func (m *Model) SetOrgPreferences(prefs preferences.OrgPreferences) {
99+
m.orgPrefs = prefs
94100
}
95101

96102
// Init starts the onboarding flow with auth check.
@@ -105,7 +111,7 @@ func (m *Model) Init() tea.Cmd {
105111
// for selection instead of auto-selecting.
106112
func (m *Model) StartFromOrgSelect() tea.Cmd {
107113
m.scope.Info("onboarding started from org select")
108-
return m.setStep(organizations.NewSelect(m.ctx, m.theme, m.services, m.prefs, m.auth, m.scope))
114+
return m.setStep(organizations.NewSelect(m.ctx, m.theme, m.services, m.userPrefs, m.auth, m.scope))
109115
}
110116

111117
// Update handles messages and orchestrates step transitions.
@@ -125,32 +131,32 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd {
125131
if msg.NeedsAuth {
126132
return m.setStep(auth.NewAuthenticate(m.ctx, m.theme, m.auth, m.scope))
127133
}
128-
return m.setStep(role.New(m.theme, m.prefs, m.scope))
134+
return m.setStep(role.New(m.theme, m.userPrefs, m.scope))
129135

130136
case msgs.Authenticated:
131137
m.scope.Info("user authenticated", "user_id", msg.User.ID)
132138
m.user = &msg.User
133-
return m.setStep(role.New(m.theme, m.prefs, m.scope))
139+
return m.setStep(role.New(m.theme, m.userPrefs, m.scope))
134140

135141
// Role messages
136142
case msgs.RoleSelected:
137143
m.scope.Info("role selected", slog.String("role", msg.Role))
138-
return m.setStep(organizations.NewSelect(m.ctx, m.theme, m.services, m.prefs, m.auth, m.scope))
144+
return m.setStep(organizations.NewSelect(m.ctx, m.theme, m.services, m.userPrefs, m.auth, m.scope))
139145

140146
// Organization messages
141147
case msgs.OrgSelected:
142148
m.scope.Info("organization selected", slog.String("org_id", msg.Org.ID.String()))
143149
m.org = &msg.Org
144-
return m.setStep(accounts.NewSelect(m.ctx, m.theme, msg.Org, m.services, m.prefs, m.scope))
150+
return m.setStep(accounts.NewSelect(m.ctx, m.theme, msg.Org, m.services, m.orgPrefs, m.scope))
145151

146152
case msgs.NoOrgs:
147153
m.scope.Debug("no organizations found")
148-
return m.setStep(organizations.NewCreate(m.ctx, m.theme, m.services, m.prefs, m.scope))
154+
return m.setStep(organizations.NewCreate(m.ctx, m.theme, m.services, m.userPrefs, m.scope))
149155

150156
case msgs.OrgCreated:
151157
m.scope.Info("organization created", slog.String("org_id", msg.Org.ID.String()))
152158
m.org = &msg.Org
153-
return m.setStep(accounts.NewSelect(m.ctx, m.theme, msg.Org, m.services, m.prefs, m.scope))
159+
return m.setStep(accounts.NewSelect(m.ctx, m.theme, msg.Org, m.services, m.orgPrefs, m.scope))
154160

155161
// Account messages
156162
case msgs.AccountSelected:
@@ -161,7 +167,7 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd {
161167

162168
case msgs.NoAccounts:
163169
m.scope.Debug("no accounts found")
164-
return m.setStep(accounts.NewCreate(m.ctx, m.theme, msg.Org, m.services, m.prefs, m.scope))
170+
return m.setStep(accounts.NewCreate(m.ctx, m.theme, msg.Org, m.services, m.orgPrefs, m.scope))
165171

166172
case msgs.AccountCreated:
167173
m.scope.Info("account created", slog.String("account_id", msg.Account.ID.String()))
@@ -172,7 +178,7 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd {
172178
// Datadog messages
173179
case msgs.DatadogReady:
174180
m.scope.Debug("datadog ready")
175-
return m.setStep(workspaces.NewSelect(m.ctx, m.theme, *m.account, m.services, m.prefs, m.scope))
181+
return m.setStep(workspaces.NewSelect(m.ctx, m.theme, *m.account, m.services, m.orgPrefs, m.scope))
176182

177183
case msgs.DatadogNeeded:
178184
m.scope.Debug("datadog setup needed")
@@ -194,7 +200,7 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd {
194200

195201
case msgs.DatadogDiscoveryComplete:
196202
m.scope.Info("datadog discovery complete")
197-
return m.setStep(workspaces.NewSelect(m.ctx, m.theme, *m.account, m.services, m.prefs, m.scope))
203+
return m.setStep(workspaces.NewSelect(m.ctx, m.theme, *m.account, m.services, m.orgPrefs, m.scope))
198204

199205
// Workspace messages
200206
case msgs.WorkspaceSelected:

‎internal/app/onboarding/organizations/create.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type CreateModel struct {
2828
ctx context.Context
2929
theme styles.Theme
3030
services api.APIServices
31-
prefs preferences.Preferences
31+
prefs preferences.UserPreferences
3232
scope log.Scope
3333

3434
input *input.Model
@@ -43,7 +43,7 @@ func NewCreate(
4343
ctx context.Context,
4444
theme styles.Theme,
4545
services api.APIServices,
46-
prefs preferences.Preferences,
46+
prefs preferences.UserPreferences,
4747
scope log.Scope,
4848
) *CreateModel {
4949
if ctx == nil {
@@ -82,7 +82,7 @@ func (m *CreateModel) Update(msg tea.Msg) tea.Cmd {
8282
m.err = msg.err
8383
return appmsg.ErrorCmd("Failed to create organization", msg.err, false)
8484
}
85-
_ = m.prefs.SetDefaultOrgID(msg.result.Organization.ID)
85+
_ = m.prefs.SetActiveOrgID(msg.result.Organization.ID)
8686
org := *msg.result.Organization
8787
m.scope.Info("organization created", "id", org.ID, "name", org.Name)
8888
return func() tea.Msg { return msgs.OrgCreated{Org: org} }

‎internal/app/onboarding/organizations/select.go‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ type SelectModel struct {
3030
ctx context.Context
3131
theme styles.Theme
3232
services api.APIServices
33-
prefs preferences.Preferences
33+
prefs preferences.UserPreferences
3434
auth auth.Auth
3535
scope log.Scope
3636

@@ -48,7 +48,7 @@ func NewSelect(
4848
ctx context.Context,
4949
theme styles.Theme,
5050
services api.APIServices,
51-
prefs preferences.Preferences,
51+
prefs preferences.UserPreferences,
5252
authService auth.Auth,
5353
scope log.Scope,
5454
) *SelectModel {
@@ -123,7 +123,7 @@ func (m *SelectModel) Update(msg tea.Msg) tea.Cmd {
123123
return func() tea.Msg { return msgs.NoOrgs{} }
124124
}
125125

126-
prefID := m.prefs.GetDefaultOrgID()
126+
prefID := m.prefs.GetActiveOrgID()
127127
if prefID != "" {
128128
for _, org := range m.orgs {
129129
if org.ID == prefID {
@@ -135,7 +135,7 @@ func (m *SelectModel) Update(msg tea.Msg) tea.Cmd {
135135

136136
if len(m.orgs) == 1 {
137137
m.scope.Debug("auto-selected organization (only one)")
138-
_ = m.prefs.SetDefaultOrgID(m.orgs[0].ID)
138+
_ = m.prefs.SetActiveOrgID(m.orgs[0].ID)
139139
return m.selectOrg(m.orgs[0])
140140
}
141141

@@ -158,7 +158,7 @@ func (m *SelectModel) Update(msg tea.Msg) tea.Cmd {
158158
case "enter":
159159
if item := m.list.SelectedItem(); item != nil {
160160
if org, ok := item.(domain.Organization); ok {
161-
_ = m.prefs.SetDefaultOrgID(org.ID)
161+
_ = m.prefs.SetActiveOrgID(org.ID)
162162
return m.selectOrg(org)
163163
}
164164
}

‎internal/app/onboarding/role/select.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,15 @@ type savedRoleMsg struct {
2020
// Model handles role selection.
2121
type Model struct {
2222
theme styles.Theme
23-
prefs preferences.Preferences
23+
prefs preferences.UserPreferences
2424
scope log.Scope
2525
selected int
2626
width int
2727
height int
2828
}
2929

3030
// New creates a new role selection step.
31-
func New(theme styles.Theme, prefs preferences.Preferences, scope log.Scope) *Model {
31+
func New(theme styles.Theme, prefs preferences.UserPreferences, scope log.Scope) *Model {
3232
if prefs == nil {
3333
panic("prefs is nil")
3434
}

0 commit comments

Comments
 (0)