diff --git a/internal/dev_server/db/sqlite.go b/internal/dev_server/db/sqlite.go index 2e847ff2..5bd66ec7 100644 --- a/internal/dev_server/db/sqlite.go +++ b/internal/dev_server/db/sqlite.go @@ -6,6 +6,7 @@ import ( "encoding/json" "io" "os" + "strings" _ "github.com/mattn/go-sqlite3" "github.com/pkg/errors" @@ -47,12 +48,15 @@ func (s *Sqlite) GetDevProject(ctx context.Context, key string) (*model.Project, var flagStateData string row := s.database.QueryRowContext(ctx, ` - SELECT key, source_environment_key, context, last_sync_time, flag_state - FROM projects + SELECT key, source_environment_key, context, last_sync_time, flag_state, payload_version + FROM projects WHERE key = ? `, key) - if err := row.Scan(&project.Key, &project.SourceEnvironmentKey, &contextData, &project.LastSyncTime, &flagStateData); err != nil { + if err := row.Scan( + &project.Key, &project.SourceEnvironmentKey, &contextData, + &project.LastSyncTime, &flagStateData, &project.PayloadVersion, + ); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, model.NewErrNotFound("project", key) } @@ -200,14 +204,15 @@ SELECT 1 FROM projects WHERE key = ? return } _, err = tx.Exec(` -INSERT INTO projects (key, source_environment_key, context, last_sync_time, flag_state) -VALUES (?, ?, ?, ?, ?) +INSERT INTO projects (key, source_environment_key, context, last_sync_time, flag_state, payload_version) +VALUES (?, ?, ?, ?, ?, ?) `, project.Key, project.SourceEnvironmentKey, project.Context.JSONString(), project.LastSyncTime, string(flagsStateJson), + project.PayloadVersion, ) if err != nil { return @@ -341,6 +346,20 @@ func (s *Sqlite) UpsertOverride(ctx context.Context, override model.Override) (m return override, nil } +func (s *Sqlite) IncrementProjectPayloadVersion(ctx context.Context, projectKey string) (int, error) { + row := s.database.QueryRowContext(ctx, ` + UPDATE projects + SET payload_version = payload_version + 1 + WHERE key = ? + RETURNING payload_version + `, projectKey) + var version int + if err := row.Scan(&version); err != nil { + return 0, errors.Wrap(err, "unable to increment payload version") + } + return version, nil +} + func (s *Sqlite) DeactivateOverride(ctx context.Context, projectKey, flagKey string) (int, error) { row := s.database.QueryRowContext(ctx, ` UPDATE overrides @@ -373,12 +392,12 @@ func (s *Sqlite) RestoreBackup(ctx context.Context, stream io.Reader) (string, e } err = os.Rename(filepath, s.dbPath) if err != nil { - //panic because this would really leave the app in an invalid state + // panic because this would really leave the app in an invalid state panic(err) } s.database, err = sql.Open("sqlite3", s.dbPath) if err != nil { - //panic because this would really leave the app in an invalid state + // panic because this would really leave the app in an invalid state panic(err) } @@ -445,12 +464,20 @@ func (s *Sqlite) runMigrations(ctx context.Context) error { source_environment_key text NOT NULL, context text NOT NULL, last_sync_time timestamp NOT NULL, - flag_state TEXT NOT NULL + flag_state TEXT NOT NULL, + payload_version INTEGER NOT NULL DEFAULT 1 )`) if err != nil { return err } + // Migration: add payload_version to existing databases that predate this column. + _, err = tx.Exec(`ALTER TABLE projects ADD COLUMN payload_version INTEGER NOT NULL DEFAULT 1`) + if err != nil && !strings.Contains(err.Error(), "duplicate column name") { + return err + } + err = nil + _, err = tx.Exec(` CREATE TABLE IF NOT EXISTS overrides ( project_key text NOT NULL, diff --git a/internal/dev_server/db/sqlite_test.go b/internal/dev_server/db/sqlite_test.go index a15ef72c..3eb3cb46 100644 --- a/internal/dev_server/db/sqlite_test.go +++ b/internal/dev_server/db/sqlite_test.go @@ -36,6 +36,7 @@ func TestDBFunctions(t *testing.T) { SourceEnvironmentKey: "env-1", Context: ldContext, LastSyncTime: now, + PayloadVersion: 1, AllFlagsState: model.FlagsState{ "flag-1": model.FlagState{Value: ldvalue.Bool(true), Version: 2}, "flag-2": model.FlagState{Value: ldvalue.String("cool"), Version: 2}, @@ -71,6 +72,7 @@ func TestDBFunctions(t *testing.T) { SourceEnvironmentKey: "env-2", Context: ldContext, LastSyncTime: now, + PayloadVersion: 1, AllFlagsState: model.FlagsState{ "flag-1": model.FlagState{Value: ldvalue.Int(123), Version: 2}, "flag-2": model.FlagState{Value: ldvalue.Float64(99.99), Version: 2}, @@ -97,6 +99,7 @@ func TestDBFunctions(t *testing.T) { SourceEnvironmentKey: "env-3", Context: ldContext, LastSyncTime: now, + PayloadVersion: 1, AllFlagsState: model.FlagsState{ "flag-1": model.FlagState{Value: ldvalue.Int(123), Version: 2}, "flag-2": model.FlagState{Value: ldvalue.Float64(99.99), Version: 2}, @@ -169,6 +172,7 @@ func TestDBFunctions(t *testing.T) { assert.Equal(t, expected.SourceEnvironmentKey, p.SourceEnvironmentKey) assert.Equal(t, expected.Context, p.Context) assert.True(t, expected.LastSyncTime.Equal(p.LastSyncTime)) + assert.Equal(t, expected.PayloadVersion, p.PayloadVersion) }) t.Run("GetAvailableVariations returns variations", func(t *testing.T) { @@ -364,6 +368,25 @@ func TestDBFunctions(t *testing.T) { assert.True(t, found) }) + t.Run("IncrementProjectPayloadVersion increments and returns new version", func(t *testing.T) { + proj, err := store.GetDevProject(ctx, projects[0].Key) + require.NoError(t, err) + initialVersion := proj.PayloadVersion + + newVersion, err := store.IncrementProjectPayloadVersion(ctx, projects[0].Key) + require.NoError(t, err) + assert.Equal(t, initialVersion+1, newVersion) + + proj, err = store.GetDevProject(ctx, projects[0].Key) + require.NoError(t, err) + assert.Equal(t, initialVersion+1, proj.PayloadVersion) + + // Calling again should increment once more + newVersion2, err := store.IncrementProjectPayloadVersion(ctx, projects[0].Key) + require.NoError(t, err) + assert.Equal(t, initialVersion+2, newVersion2) + }) + t.Run("UpdateProject deletes overrides for flags that are no longer in the project", func(t *testing.T) { project := projects[2] diff --git a/internal/dev_server/model/import_project.go b/internal/dev_server/model/import_project.go index 2b6133ad..d5360a05 100644 --- a/internal/dev_server/model/import_project.go +++ b/internal/dev_server/model/import_project.go @@ -53,6 +53,7 @@ func ImportProject(ctx context.Context, projectKey string, importData ImportData Context: importData.Context, AllFlagsState: importData.FlagsState, AvailableVariations: []FlagVariation{}, + PayloadVersion: 1, } // Convert available variations if present diff --git a/internal/dev_server/model/mocks/store.go b/internal/dev_server/model/mocks/store.go index 5487ece9..dd037c2e 100644 --- a/internal/dev_server/model/mocks/store.go +++ b/internal/dev_server/model/mocks/store.go @@ -148,6 +148,21 @@ func (mr *MockStoreMockRecorder) GetOverridesForProject(ctx, projectKey any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOverridesForProject", reflect.TypeOf((*MockStore)(nil).GetOverridesForProject), ctx, projectKey) } +// IncrementProjectPayloadVersion mocks base method. +func (m *MockStore) IncrementProjectPayloadVersion(ctx context.Context, projectKey string) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementProjectPayloadVersion", ctx, projectKey) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IncrementProjectPayloadVersion indicates an expected call of IncrementProjectPayloadVersion. +func (mr *MockStoreMockRecorder) IncrementProjectPayloadVersion(ctx, projectKey any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementProjectPayloadVersion", reflect.TypeOf((*MockStore)(nil).IncrementProjectPayloadVersion), ctx, projectKey) +} + // InsertProject mocks base method. func (m *MockStore) InsertProject(ctx context.Context, project model.Project) error { m.ctrl.T.Helper() diff --git a/internal/dev_server/model/override.go b/internal/dev_server/model/override.go index 36359bbd..cfc97c34 100644 --- a/internal/dev_server/model/override.go +++ b/internal/dev_server/model/override.go @@ -3,6 +3,8 @@ package model import ( "context" + "github.com/pkg/errors" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" ) @@ -58,6 +60,11 @@ func UpsertOverride(ctx context.Context, projectKey, flagKey string, value ldval return Override{}, err } + _, err = store.IncrementProjectPayloadVersion(ctx, projectKey) + if err != nil { + return Override{}, errors.Wrap(err, "unable to increment payload version") + } + GetObserversFromContext(ctx).Notify(OverrideEvent{ FlagKey: flagKey, ProjectKey: projectKey, @@ -76,6 +83,12 @@ func DeleteOverride(ctx context.Context, projectKey, flagKey string) error { if err != nil { return err } + + _, err = store.IncrementProjectPayloadVersion(ctx, projectKey) + if err != nil { + return errors.Wrap(err, "unable to increment payload version") + } + override := Override{ ProjectKey: projectKey, FlagKey: flagKey, diff --git a/internal/dev_server/model/override_test.go b/internal/dev_server/model/override_test.go index 57e58ddd..b9198336 100644 --- a/internal/dev_server/model/override_test.go +++ b/internal/dev_server/model/override_test.go @@ -73,6 +73,7 @@ func TestUpsertOverride(t *testing.T) { t.Run("override is applied, observers are notified", func(t *testing.T) { store.EXPECT().GetDevProject(gomock.Any(), projKey).Return(project, nil) store.EXPECT().UpsertOverride(gomock.Any(), override).Return(override, nil) + store.EXPECT().IncrementProjectPayloadVersion(gomock.Any(), projKey).Return(1, nil) observer. EXPECT(). Handle(model.OverrideEvent{ @@ -128,6 +129,7 @@ func TestDeleteOverride(t *testing.T) { t.Run("override is applied, observers are notified", func(t *testing.T) { store.EXPECT().GetDevProject(gomock.Any(), projKey).Return(project, nil) store.EXPECT().DeactivateOverride(gomock.Any(), projKey, flagKey).Return(2, nil) + store.EXPECT().IncrementProjectPayloadVersion(gomock.Any(), projKey).Return(1, nil) observer. EXPECT(). Handle(model.OverrideEvent{ @@ -198,11 +200,13 @@ func TestDeleteOverrides(t *testing.T) { // Expectations for first override store.EXPECT().GetDevProject(gomock.Any(), projKey).Return(project, nil) store.EXPECT().DeactivateOverride(gomock.Any(), projKey, flagKey).Return(2, nil) + store.EXPECT().IncrementProjectPayloadVersion(gomock.Any(), projKey).Return(1, nil) observer.EXPECT().Handle(gomock.Any()) // Expectations for second override store.EXPECT().GetDevProject(gomock.Any(), projKey).Return(project, nil) store.EXPECT().DeactivateOverride(gomock.Any(), projKey, "flag2").Return(2, nil) + store.EXPECT().IncrementProjectPayloadVersion(gomock.Any(), projKey).Return(2, nil) observer.EXPECT().Handle(gomock.Any()) err := model.DeleteOverrides(ctx, projKey) diff --git a/internal/dev_server/model/project.go b/internal/dev_server/model/project.go index f796498f..ad3e899d 100644 --- a/internal/dev_server/model/project.go +++ b/internal/dev_server/model/project.go @@ -18,6 +18,7 @@ type Project struct { LastSyncTime time.Time AllFlagsState FlagsState AvailableVariations []FlagVariation + PayloadVersion int } // CreateProject creates a project and adds it to the database. @@ -25,6 +26,7 @@ func CreateProject(ctx context.Context, projectKey, sourceEnvironmentKey string, project := Project{ Key: projectKey, SourceEnvironmentKey: sourceEnvironmentKey, + PayloadVersion: 1, } if ldCtx == nil { @@ -87,6 +89,12 @@ func UpdateProject(ctx context.Context, projectKey string, context *ldcontext.Co return Project{}, errors.New("Project not updated") } + newPayloadVersion, err := store.IncrementProjectPayloadVersion(ctx, projectKey) + if err != nil { + return Project{}, errors.Wrap(err, "unable to increment payload version") + } + project.PayloadVersion = newPayloadVersion + allFlagsWithOverrides, err := project.GetFlagStateWithOverridesForProject(ctx) if err != nil { return Project{}, errors.Wrapf(err, "unable to get overrides for project, %s", projectKey) diff --git a/internal/dev_server/model/project_test.go b/internal/dev_server/model/project_test.go index eb47455c..4a6d4a15 100644 --- a/internal/dev_server/model/project_test.go +++ b/internal/dev_server/model/project_test.go @@ -183,6 +183,7 @@ func TestUpdateProject(t *testing.T) { sdk.EXPECT().GetAllFlagsState(gomock.Any(), gomock.Any(), "sdkKey").Return(allFlagsState, nil) api.EXPECT().GetAllFlags(gomock.Any(), proj.Key).Return(allFlags, nil) store.EXPECT().UpdateProject(gomock.Any(), gomock.Any()).Return(true, nil) + store.EXPECT().IncrementProjectPayloadVersion(gomock.Any(), proj.Key).Return(2, nil) store.EXPECT().GetOverridesForProject(gomock.Any(), proj.Key).Return(model.Overrides{}, nil) observer. EXPECT(). @@ -193,7 +194,9 @@ func TestUpdateProject(t *testing.T) { project, err := model.UpdateProject(ctx, proj.Key, nil, nil) require.Nil(t, err) - assert.Equal(t, proj, project) + expectedProj := proj + expectedProj.PayloadVersion = 2 + assert.Equal(t, expectedProj, project) }) } diff --git a/internal/dev_server/model/store.go b/internal/dev_server/model/store.go index 8c3d9284..7a3b3883 100644 --- a/internal/dev_server/model/store.go +++ b/internal/dev_server/model/store.go @@ -28,6 +28,8 @@ type Store interface { UpsertOverride(ctx context.Context, override Override) (Override, error) GetOverridesForProject(ctx context.Context, projectKey string) (Overrides, error) GetAvailableVariationsForProject(ctx context.Context, projectKey string) (map[string][]Variation, error) + // IncrementProjectPayloadVersion atomically increments the payload version for the project and returns the new version. + IncrementProjectPayloadVersion(ctx context.Context, projectKey string) (int, error) CreateBackup(ctx context.Context) (io.ReadCloser, int64, error) RestoreBackup(ctx context.Context, stream io.Reader) (string, error) diff --git a/internal/dev_server/model/sync_test.go b/internal/dev_server/model/sync_test.go index 643e3653..ed508135 100644 --- a/internal/dev_server/model/sync_test.go +++ b/internal/dev_server/model/sync_test.go @@ -153,8 +153,9 @@ func TestInitialSync(t *testing.T) { sdk.EXPECT().GetAllFlagsState(gomock.Any(), gomock.Any(), sdkKey).Return(allFlagsState, nil) api.EXPECT().GetAllFlags(gomock.Any(), projKey).Return(allFlags, nil) store.EXPECT().InsertProject(gomock.Any(), gomock.Any()).Return(nil) - store.EXPECT().UpsertOverride(gomock.Any(), override).Return(override, nil) store.EXPECT().GetDevProject(gomock.Any(), projKey).Return(&proj, nil) + store.EXPECT().UpsertOverride(gomock.Any(), override).Return(override, nil) + store.EXPECT().IncrementProjectPayloadVersion(gomock.Any(), projKey).Return(1, nil) input := model.InitialProjectSettings{ Enabled: true,