diff --git a/model/thread.go b/model/thread.go new file mode 100644 index 0000000000000..76707c5f19002 --- /dev/null +++ b/model/thread.go @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/json" +) + +type Thread struct { + PostId string `json:"id"` + ReplyCount int64 `json:"reply_count"` + LastReplyAt int64 `json:"last_reply_at"` + Participants StringArray `json:"participants"` +} + +func (o *Thread) ToJson() string { + b, _ := json.Marshal(o) + return string(b) +} + +func (o *Thread) Etag() string { + return Etag(o.PostId, o.LastReplyAt) +} diff --git a/model/utils.go b/model/utils.go index 47dd8c36b880a..3aed19da1576c 100644 --- a/model/utils.go +++ b/model/utils.go @@ -36,6 +36,26 @@ type StringInterface map[string]interface{} type StringMap map[string]string type StringArray []string +func (sa StringArray) Remove(input string) StringArray { + for index := range sa { + if sa[index] == input { + ret := make(StringArray, 0, len(sa)-1) + ret = append(ret, sa[:index]...) + return append(ret, sa[index+1:]...) + } + } + return sa +} + +func (sa StringArray) Contains(input string) bool { + for index := range sa { + if sa[index] == input { + return true + } + } + + return false +} func (sa StringArray) Equals(input StringArray) bool { if len(sa) != len(input) { diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index ab2a101b91326..cf12ab949903a 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -45,6 +45,7 @@ type OpenTracingLayer struct { SystemStore store.SystemStore TeamStore store.TeamStore TermsOfServiceStore store.TermsOfServiceStore + ThreadStore store.ThreadStore TokenStore store.TokenStore UploadSessionStore store.UploadSessionStore UserStore store.UserStore @@ -161,6 +162,10 @@ func (s *OpenTracingLayer) TermsOfService() store.TermsOfServiceStore { return s.TermsOfServiceStore } +func (s *OpenTracingLayer) Thread() store.ThreadStore { + return s.ThreadStore +} + func (s *OpenTracingLayer) Token() store.TokenStore { return s.TokenStore } @@ -320,6 +325,11 @@ type OpenTracingLayerTermsOfServiceStore struct { Root *OpenTracingLayer } +type OpenTracingLayerThreadStore struct { + store.ThreadStore + Root *OpenTracingLayer +} + type OpenTracingLayerTokenStore struct { store.TokenStore Root *OpenTracingLayer @@ -7584,6 +7594,96 @@ func (s *OpenTracingLayerTermsOfServiceStore) Save(termsOfService *model.TermsOf return result, err } +func (s *OpenTracingLayerThreadStore) Delete(postId string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.Delete") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.ThreadStore.Delete(postId) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + +func (s *OpenTracingLayerThreadStore) Get(id string) (*model.Thread, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.Get") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ThreadStore.Get(id) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerThreadStore) Save(thread *model.Thread) (*model.Thread, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.Save") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ThreadStore.Save(thread) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + +func (s *OpenTracingLayerThreadStore) SaveMultiple(thread []*model.Thread) ([]*model.Thread, int, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.SaveMultiple") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, resultVar1, err := s.ThreadStore.SaveMultiple(thread) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, resultVar1, err +} + +func (s *OpenTracingLayerThreadStore) Update(thread *model.Thread) (*model.Thread, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.Update") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ThreadStore.Update(thread) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerTokenStore) Cleanup() { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "TokenStore.Cleanup") @@ -9730,6 +9830,7 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer { newStore.SystemStore = &OpenTracingLayerSystemStore{SystemStore: childStore.System(), Root: &newStore} newStore.TeamStore = &OpenTracingLayerTeamStore{TeamStore: childStore.Team(), Root: &newStore} newStore.TermsOfServiceStore = &OpenTracingLayerTermsOfServiceStore{TermsOfServiceStore: childStore.TermsOfService(), Root: &newStore} + newStore.ThreadStore = &OpenTracingLayerThreadStore{ThreadStore: childStore.Thread(), Root: &newStore} newStore.TokenStore = &OpenTracingLayerTokenStore{TokenStore: childStore.Token(), Root: &newStore} newStore.UploadSessionStore = &OpenTracingLayerUploadSessionStore{UploadSessionStore: childStore.UploadSession(), Root: &newStore} newStore.UserStore = &OpenTracingLayerUserStore{UserStore: childStore.User(), Root: &newStore} diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index af57bb5f1898a..c896d5763e405 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -47,6 +47,7 @@ type RetryLayer struct { SystemStore store.SystemStore TeamStore store.TeamStore TermsOfServiceStore store.TermsOfServiceStore + ThreadStore store.ThreadStore TokenStore store.TokenStore UploadSessionStore store.UploadSessionStore UserStore store.UserStore @@ -163,6 +164,10 @@ func (s *RetryLayer) TermsOfService() store.TermsOfServiceStore { return s.TermsOfServiceStore } +func (s *RetryLayer) Thread() store.ThreadStore { + return s.ThreadStore +} + func (s *RetryLayer) Token() store.TokenStore { return s.TokenStore } @@ -322,6 +327,11 @@ type RetryLayerTermsOfServiceStore struct { Root *RetryLayer } +type RetryLayerThreadStore struct { + store.ThreadStore + Root *RetryLayer +} + type RetryLayerTokenStore struct { store.TokenStore Root *RetryLayer @@ -7192,6 +7202,106 @@ func (s *RetryLayerTermsOfServiceStore) Save(termsOfService *model.TermsOfServic } +func (s *RetryLayerThreadStore) Delete(postId string) error { + + tries := 0 + for { + err := s.ThreadStore.Delete(postId) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + } + +} + +func (s *RetryLayerThreadStore) Get(id string) (*model.Thread, error) { + + tries := 0 + for { + result, err := s.ThreadStore.Get(id) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + } + +} + +func (s *RetryLayerThreadStore) Save(thread *model.Thread) (*model.Thread, error) { + + tries := 0 + for { + result, err := s.ThreadStore.Save(thread) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + } + +} + +func (s *RetryLayerThreadStore) SaveMultiple(thread []*model.Thread) ([]*model.Thread, int, error) { + + tries := 0 + for { + result, resultVar1, err := s.ThreadStore.SaveMultiple(thread) + if err == nil { + return result, resultVar1, nil + } + if !isRepeatableError(err) { + return result, resultVar1, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, resultVar1, err + } + } + +} + +func (s *RetryLayerThreadStore) Update(thread *model.Thread) (*model.Thread, error) { + + tries := 0 + for { + result, err := s.ThreadStore.Update(thread) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + } + +} + func (s *RetryLayerTokenStore) Cleanup() { s.TokenStore.Cleanup() @@ -8644,6 +8754,7 @@ func New(childStore store.Store) *RetryLayer { newStore.SystemStore = &RetryLayerSystemStore{SystemStore: childStore.System(), Root: &newStore} newStore.TeamStore = &RetryLayerTeamStore{TeamStore: childStore.Team(), Root: &newStore} newStore.TermsOfServiceStore = &RetryLayerTermsOfServiceStore{TermsOfServiceStore: childStore.TermsOfService(), Root: &newStore} + newStore.ThreadStore = &RetryLayerThreadStore{ThreadStore: childStore.Thread(), Root: &newStore} newStore.TokenStore = &RetryLayerTokenStore{TokenStore: childStore.Token(), Root: &newStore} newStore.UploadSessionStore = &RetryLayerUploadSessionStore{UploadSessionStore: childStore.UploadSession(), Root: &newStore} newStore.UserStore = &RetryLayerUserStore{UserStore: childStore.User(), Root: &newStore} diff --git a/store/retrylayer/retrylayer_test.go b/store/retrylayer/retrylayer_test.go index 5224057637d37..d48b7f67197d4 100644 --- a/store/retrylayer/retrylayer_test.go +++ b/store/retrylayer/retrylayer_test.go @@ -33,6 +33,7 @@ func genStore() *mocks.Store { mock.On("OAuth").Return(&mocks.OAuthStore{}) mock.On("Plugin").Return(&mocks.PluginStore{}) mock.On("Post").Return(&mocks.PostStore{}) + mock.On("Thread").Return(&mocks.ThreadStore{}) mock.On("Preference").Return(&mocks.PreferenceStore{}) mock.On("ProductNotices").Return(&mocks.ProductNoticesStore{}) mock.On("Reaction").Return(&mocks.ReactionStore{}) diff --git a/store/sqlstore/post_store.go b/store/sqlstore/post_store.go index a737fdfb0d8ad..c4a6a8ce5b948 100644 --- a/store/sqlstore/post_store.go +++ b/store/sqlstore/post_store.go @@ -11,15 +11,15 @@ import ( "strings" "sync" - "github.com/mattermost/mattermost-server/v5/store/searchlayer" - sq "github.com/Masterminds/squirrel" + "github.com/mattermost/gorp" "github.com/pkg/errors" "github.com/mattermost/mattermost-server/v5/einterfaces" "github.com/mattermost/mattermost-server/v5/mlog" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/store" + "github.com/mattermost/mattermost-server/v5/store/searchlayer" "github.com/mattermost/mattermost-server/v5/utils" ) @@ -159,18 +159,34 @@ func (s *SqlPostStore) SaveMultiple(posts []*model.Post) ([]*model.Post, int, er return nil, -1, errors.Wrap(err, "post_tosql") } - if _, err := s.GetMaster().Exec(query, args...); err != nil { + transaction, err := s.GetMaster().Begin() + if err != nil { + return posts, -1, errors.Wrap(err, "begin_transaction") + } + + defer finalizeTransaction(transaction) + + if _, err = transaction.Exec(query, args...); err != nil { return nil, -1, errors.Wrap(err, "failed to save Post") } + if err = s.updateThreadsFromPosts(transaction, posts); err != nil { + mlog.Error("Error updating posts, thread update failed", mlog.Err(err)) + } + + if err = transaction.Commit(); err != nil { + // don't need to rollback here since the transaction is already closed + return posts, -1, errors.Wrap(err, "commit_transaction") + } + for channelId, count := range channelNewPosts { - if _, err := s.GetMaster().Exec("UPDATE Channels SET LastPostAt = GREATEST(:LastPostAt, LastPostAt), TotalMsgCount = TotalMsgCount + :Count WHERE Id = :ChannelId", map[string]interface{}{"LastPostAt": maxDateNewPosts[channelId], "ChannelId": channelId, "Count": count}); err != nil { + if _, err = s.GetMaster().Exec("UPDATE Channels SET LastPostAt = GREATEST(:LastPostAt, LastPostAt), TotalMsgCount = TotalMsgCount + :Count WHERE Id = :ChannelId", map[string]interface{}{"LastPostAt": maxDateNewPosts[channelId], "ChannelId": channelId, "Count": count}); err != nil { mlog.Error("Error updating Channel LastPostAt.", mlog.Err(err)) } } for rootId := range rootIds { - if _, err := s.GetMaster().Exec("UPDATE Posts SET UpdateAt = :UpdateAt WHERE Id = :RootId", map[string]interface{}{"UpdateAt": maxDateRootIds[rootId], "RootId": rootId}); err != nil { + if _, err = s.GetMaster().Exec("UPDATE Posts SET UpdateAt = :UpdateAt WHERE Id = :RootId", map[string]interface{}{"UpdateAt": maxDateRootIds[rootId], "RootId": rootId}); err != nil { mlog.Error("Error updating Post UpdateAt.", mlog.Err(err)) } } @@ -265,6 +281,7 @@ func (s *SqlPostStore) Update(newPost *model.Post, oldPost *model.Post) (*model. if len(newPost.RootId) > 0 { s.GetMaster().Exec("UPDATE Posts SET UpdateAt = :UpdateAt WHERE Id = :RootId AND UpdateAt < :UpdateAt", map[string]interface{}{"UpdateAt": time, "RootId": newPost.RootId}) + s.GetMaster().Exec("UPDATE Threads SET LastReplyAt = :UpdateAt WHERE PostId = :RootId", map[string]interface{}{"UpdateAt": time, "RootId": newPost.RootId}) } // mark the old post as deleted @@ -296,6 +313,9 @@ func (s *SqlPostStore) OverwriteMultiple(posts []*model.Post) ([]*model.Post, in return nil, idx, errors.Wrap(err, "failed to update Post") } + if len(post.RootId) > 0 { + tx.Exec("UPDATE Threads SET LastReplyAt = :UpdateAt WHERE PostId = :RootId", map[string]interface{}{"UpdateAt": updateAt, "RootId": post.Id}) + } } err = tx.Commit() if err != nil { @@ -495,19 +515,49 @@ func (s *SqlPostStore) Delete(postId string, time int64, deleteByID string) erro return errors.Wrap(err, "failed to update Posts") } - return nil + return s.cleanupThreads(post.Id, post.RootId, post.UserId) } func (s *SqlPostStore) permanentDelete(postId string) error { - _, err := s.GetMaster().Exec("DELETE FROM Posts WHERE Id = :Id OR RootId = :RootId", map[string]interface{}{"Id": postId, "RootId": postId}) - if err != nil { + var post model.Post + err := s.GetReplica().SelectOne(&post, "SELECT * FROM Posts WHERE Id = :Id AND DeleteAt = 0", map[string]interface{}{"Id": postId}) + if err != nil && err != sql.ErrNoRows { + if err != sql.ErrNoRows { + return errors.Wrapf(err, "failed to get Post with id=%s", postId) + } + + if err = s.cleanupThreads(post.Id, post.RootId, post.UserId); err != nil { + return errors.Wrapf(err, "failed to cleanup threads for Post with id=%s", postId) + } + } + + if _, err = s.GetMaster().Exec("DELETE FROM Posts WHERE Id = :Id OR RootId = :RootId", map[string]interface{}{"Id": postId, "RootId": postId}); err != nil { return errors.Wrapf(err, "failed to delete Post with id=%s", postId) } + return nil } +type postIds struct { + Id string + RootId string + UserId string +} + func (s *SqlPostStore) permanentDeleteAllCommentByUser(userId string) error { - _, err := s.GetMaster().Exec("DELETE FROM Posts WHERE UserId = :UserId AND RootId != ''", map[string]interface{}{"UserId": userId}) + results := []postIds{} + _, err := s.GetMaster().Select(&results, "Select Id, RootId FROM Posts WHERE UserId = :UserId AND RootId != ''", map[string]interface{}{"UserId": userId}) + if err != nil { + return errors.Wrapf(err, "failed to fetch Posts with userId=%s", userId) + } + + for _, ids := range results { + if err = s.cleanupThreads(ids.Id, ids.RootId, userId); err != nil { + return err + } + } + + _, err = s.GetMaster().Exec("DELETE FROM Posts WHERE UserId = :UserId AND RootId != ''", map[string]interface{}{"UserId": userId}) if err != nil { return errors.Wrapf(err, "failed to delete Posts with userId=%s", userId) } @@ -551,6 +601,18 @@ func (s *SqlPostStore) PermanentDeleteByUser(userId string) error { } func (s *SqlPostStore) PermanentDeleteByChannel(channelId string) error { + results := []postIds{} + _, err := s.GetMaster().Select(&results, "SELECT Id, RootId, UserId FROM Posts WHERE ChannelId = :ChannelId", map[string]interface{}{"ChannelId": channelId}) + if err != nil { + return errors.Wrapf(err, "failed to fetch Posts with channelId=%s", channelId) + } + + for _, ids := range results { + if err = s.cleanupThreads(ids.Id, ids.RootId, ids.UserId); err != nil { + return err + } + } + if _, err := s.GetMaster().Exec("DELETE FROM Posts WHERE ChannelId = :ChannelId", map[string]interface{}{"ChannelId": channelId}); err != nil { return errors.Wrapf(err, "failed to delete Posts with channelId=%s", channelId) } @@ -1858,3 +1920,90 @@ func (s *SqlPostStore) GetOldestEntityCreationTime() (int64, error) { } return oldest, nil } + +func (s *SqlPostStore) cleanupThreads(postId, rootId, userId string) error { + if len(rootId) > 0 { + thread, err := s.Thread().Get(rootId) + if err != nil { + if err != sql.ErrNoRows { + return errors.Wrap(err, "failed to get a thread") + } + } + if thread != nil { + thread.ReplyCount -= 1 + thread.Participants = thread.Participants.Remove(userId) + if _, err = s.Thread().Update(thread); err != nil { + return errors.Wrap(err, "failed to update thread") + } + } + } + _, err := s.GetMaster().Exec("DELETE FROM Threads WHERE PostId = :Id", map[string]interface{}{"Id": postId}) + if err != nil { + return errors.Wrap(err, "failed to update Threads") + } + return nil +} + +func (s *SqlPostStore) updateThreadsFromPosts(transaction *gorp.Transaction, posts []*model.Post) error { + postsByRoot := map[string][]*model.Post{} + var rootIds []string + for _, post := range posts { + // skip if post is not a part of a thread + if len(post.RootId) == 0 { + continue + } + rootIds = append(rootIds, post.RootId) + postsByRoot[post.RootId] = append(postsByRoot[post.RootId], post) + } + if len(rootIds) == 0 { + return nil + } + now := model.GetMillis() + threadsByRootsSql, threadsByRootsArgs, _ := s.getQueryBuilder().Select("*").From("Threads").Where(sq.Eq{"PostId": rootIds}).ToSql() + var threadsByRoots []*model.Thread + if _, err := transaction.Select(&threadsByRoots, threadsByRootsSql, threadsByRootsArgs...); err != nil { + return err + } + + threadByRoot := map[string]*model.Thread{} + for _, thread := range threadsByRoots { + threadByRoot[thread.PostId] = thread + } + + for rootId, posts := range postsByRoot { + if thread, found := threadByRoot[rootId]; !found { + // calculate participants + var participants model.StringArray + if _, err := transaction.Select(&participants, "SELECT DISTINCT UserId FROM Posts WHERE RootId=:RootId", map[string]interface{}{"RootId": rootId}); err != nil { + return err + } + // calculate reply count + count, err := transaction.SelectInt("SELECT COUNT(Id) FROM Posts WHERE RootId=:RootId", map[string]interface{}{"RootId": rootId}) + if err != nil { + return err + } + // no metadata entry, create one + if err := transaction.Insert(&model.Thread{ + PostId: rootId, + ReplyCount: count, + LastReplyAt: now, + Participants: participants, + }); err != nil { + return err + } + } else { + // metadata exists, update it + thread.LastReplyAt = now + for _, post := range posts { + thread.ReplyCount += 1 + if !thread.Participants.Contains(post.UserId) { + thread.Participants = append(thread.Participants, post.UserId) + } + } + if _, err := transaction.Update(thread); err != nil { + return err + } + } + } + return nil +} diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 21aadd05ce26c..bd24ce38ab537 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -75,6 +75,7 @@ type SqlStore interface { Team() store.TeamStore Channel() store.ChannelStore Post() store.PostStore + Thread() store.ThreadStore User() store.UserStore Bot() store.BotStore Audit() store.AuditStore diff --git a/store/sqlstore/supplier.go b/store/sqlstore/supplier.go index 8304d70a6130c..921a7fbfb1a0d 100644 --- a/store/sqlstore/supplier.go +++ b/store/sqlstore/supplier.go @@ -72,6 +72,7 @@ type SqlSupplierStores struct { team store.TeamStore channel store.ChannelStore post store.PostStore + thread store.ThreadStore user store.UserStore bot store.BotStore audit store.AuditStore @@ -160,6 +161,7 @@ func NewSqlSupplier(settings model.SqlSettings, metrics einterfaces.MetricsInter supplier.stores.status = newSqlStatusStore(supplier) supplier.stores.fileInfo = newSqlFileInfoStore(supplier, metrics) supplier.stores.uploadSession = newSqlUploadSessionStore(supplier) + supplier.stores.thread = newSqlThreadStore(supplier) supplier.stores.job = newSqlJobStore(supplier) supplier.stores.userAccessToken = newSqlUserAccessTokenStore(supplier) supplier.stores.channelMemberHistory = newSqlChannelMemberHistoryStore(supplier) @@ -189,6 +191,7 @@ func NewSqlSupplier(settings model.SqlSettings, metrics einterfaces.MetricsInter supplier.stores.team.(*SqlTeamStore).createIndexesIfNotExists() supplier.stores.channel.(*SqlChannelStore).createIndexesIfNotExists() supplier.stores.post.(*SqlPostStore).createIndexesIfNotExists() + supplier.stores.thread.(*SqlThreadStore).createIndexesIfNotExists() supplier.stores.user.(*SqlUserStore).createIndexesIfNotExists() supplier.stores.bot.(*SqlBotStore).createIndexesIfNotExists() supplier.stores.audit.(*SqlAuditStore).createIndexesIfNotExists() @@ -1163,6 +1166,10 @@ func (ss *SqlSupplier) Plugin() store.PluginStore { return ss.stores.plugin } +func (ss *SqlSupplier) Thread() store.ThreadStore { + return ss.stores.thread +} + func (ss *SqlSupplier) Role() store.RoleStore { return ss.stores.role } diff --git a/store/sqlstore/thread_store.go b/store/sqlstore/thread_store.go new file mode 100644 index 0000000000000..3254076a7a112 --- /dev/null +++ b/store/sqlstore/thread_store.go @@ -0,0 +1,108 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/store" + "github.com/pkg/errors" + + sq "github.com/Masterminds/squirrel" +) + +type SqlThreadStore struct { + SqlStore +} + +func (s *SqlThreadStore) ClearCaches() { +} + +func newSqlThreadStore(sqlStore SqlStore) store.ThreadStore { + s := &SqlThreadStore{ + SqlStore: sqlStore, + } + + for _, db := range sqlStore.GetAllConns() { + table := db.AddTableWithName(model.Thread{}, "Threads").SetKeys(false, "PostId") + table.ColMap("PostId").SetMaxSize(26) + table.ColMap("Participants").SetMaxSize(0) + } + + return s +} + +func threadSliceColumns() []string { + return []string{"PostId", "LastReplyAt", "ReplyCount", "Participants"} +} + +func threadToSlice(thread *model.Thread) []interface{} { + return []interface{}{ + thread.PostId, + thread.LastReplyAt, + thread.ReplyCount, + thread.Participants, + } +} + +func (s *SqlThreadStore) createIndexesIfNotExists() { + s.CreateIndexIfNotExists("idx_threads_last_reply_at", "Threads", "LastReplyAt") + s.CreateIndexIfNotExists("idx_threads_post_id", "Threads", "PostId") +} + +func (s *SqlThreadStore) SaveMultiple(threads []*model.Thread) ([]*model.Thread, int, error) { + builder := s.getQueryBuilder().Insert("Threads").Columns(threadSliceColumns()...) + for _, thread := range threads { + builder = builder.Values(threadToSlice(thread)...) + } + query, args, err := builder.ToSql() + if err != nil { + return nil, -1, errors.Wrap(err, "thread_tosql") + } + + if _, err := s.GetMaster().Exec(query, args...); err != nil { + return nil, -1, errors.Wrap(err, "failed to save Post") + } + + return threads, -1, nil +} + +func (s *SqlThreadStore) Save(thread *model.Thread) (*model.Thread, error) { + threads, _, err := s.SaveMultiple([]*model.Thread{thread}) + if err != nil { + return nil, err + } + return threads[0], nil +} + +func (s *SqlThreadStore) Update(thread *model.Thread) (*model.Thread, error) { + if _, err := s.GetMaster().Update(thread); err != nil { + return nil, errors.Wrapf(err, "failed to update thread with id=%s", thread.PostId) + } + + return thread, nil +} + +func (s *SqlThreadStore) Get(id string) (*model.Thread, error) { + var thread model.Thread + query, args, _ := s.getQueryBuilder().Select("*").From("Threads").Where(sq.Eq{"PostId": id}).ToSql() + err := s.GetReplica().SelectOne(&thread, query, args...) + if err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("Thread", id) + } + + return nil, errors.Wrapf(err, "failed to get thread with id=%s", id) + } + return &thread, nil +} + +func (s *SqlThreadStore) Delete(threadId string) error { + query, args, _ := s.getQueryBuilder().Delete("Threads").Where(sq.Eq{"PostId": threadId}).ToSql() + if _, err := s.GetMaster().Exec(query, args...); err != nil { + return errors.Wrap(err, "failed to update threads") + } + + return nil +} diff --git a/store/sqlstore/thread_store_test.go b/store/sqlstore/thread_store_test.go new file mode 100644 index 0000000000000..82b399b3cbd69 --- /dev/null +++ b/store/sqlstore/thread_store_test.go @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v5/store/storetest" +) + +func TestThreadStore(t *testing.T) { + StoreTestWithSqlSupplier(t, storetest.TestThreadStore) +} diff --git a/store/store.go b/store/store.go index 20d81f60d3239..81e050da901c2 100644 --- a/store/store.go +++ b/store/store.go @@ -24,6 +24,7 @@ type Store interface { Team() TeamStore Channel() ChannelStore Post() PostStore + Thread() ThreadStore User() UserStore Bot() BotStore Audit() AuditStore @@ -245,6 +246,13 @@ type ChannelMemberHistoryStore interface { GetUsersInChannelDuring(startTime int64, endTime int64, channelId string) ([]*model.ChannelMemberHistoryResult, error) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) } +type ThreadStore interface { + SaveMultiple(thread []*model.Thread) ([]*model.Thread, int, error) + Save(thread *model.Thread) (*model.Thread, error) + Update(thread *model.Thread) (*model.Thread, error) + Get(id string) (*model.Thread, error) + Delete(postId string) error +} type PostStore interface { SaveMultiple(posts []*model.Post) ([]*model.Post, int, error) diff --git a/store/storetest/mocks/SqlStore.go b/store/storetest/mocks/SqlStore.go index db6c129331324..629308fbcc432 100644 --- a/store/storetest/mocks/SqlStore.go +++ b/store/storetest/mocks/SqlStore.go @@ -782,6 +782,22 @@ func (_m *SqlStore) TermsOfService() store.TermsOfServiceStore { return r0 } +// Thread provides a mock function with given fields: +func (_m *SqlStore) Thread() store.ThreadStore { + ret := _m.Called() + + var r0 store.ThreadStore + if rf, ok := ret.Get(0).(func() store.ThreadStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.ThreadStore) + } + } + + return r0 +} + // Token provides a mock function with given fields: func (_m *SqlStore) Token() store.TokenStore { ret := _m.Called() diff --git a/store/storetest/mocks/Store.go b/store/storetest/mocks/Store.go index cdfb63221eee7..45ed48e2a9ad2 100644 --- a/store/storetest/mocks/Store.go +++ b/store/storetest/mocks/Store.go @@ -549,6 +549,22 @@ func (_m *Store) TermsOfService() store.TermsOfServiceStore { return r0 } +// Thread provides a mock function with given fields: +func (_m *Store) Thread() store.ThreadStore { + ret := _m.Called() + + var r0 store.ThreadStore + if rf, ok := ret.Get(0).(func() store.ThreadStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.ThreadStore) + } + } + + return r0 +} + // Token provides a mock function with given fields: func (_m *Store) Token() store.TokenStore { ret := _m.Called() diff --git a/store/storetest/mocks/ThreadStore.go b/store/storetest/mocks/ThreadStore.go new file mode 100644 index 0000000000000..5050c688305cf --- /dev/null +++ b/store/storetest/mocks/ThreadStore.go @@ -0,0 +1,128 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost-server/v5/model" + mock "github.com/stretchr/testify/mock" +) + +// ThreadStore is an autogenerated mock type for the ThreadStore type +type ThreadStore struct { + mock.Mock +} + +// Delete provides a mock function with given fields: postId +func (_m *ThreadStore) Delete(postId string) error { + ret := _m.Called(postId) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(postId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Get provides a mock function with given fields: id +func (_m *ThreadStore) Get(id string) (*model.Thread, error) { + ret := _m.Called(id) + + var r0 *model.Thread + if rf, ok := ret.Get(0).(func(string) *model.Thread); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Thread) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Save provides a mock function with given fields: thread +func (_m *ThreadStore) Save(thread *model.Thread) (*model.Thread, error) { + ret := _m.Called(thread) + + var r0 *model.Thread + if rf, ok := ret.Get(0).(func(*model.Thread) *model.Thread); ok { + r0 = rf(thread) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Thread) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.Thread) error); ok { + r1 = rf(thread) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SaveMultiple provides a mock function with given fields: thread +func (_m *ThreadStore) SaveMultiple(thread []*model.Thread) ([]*model.Thread, int, error) { + ret := _m.Called(thread) + + var r0 []*model.Thread + if rf, ok := ret.Get(0).(func([]*model.Thread) []*model.Thread); ok { + r0 = rf(thread) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Thread) + } + } + + var r1 int + if rf, ok := ret.Get(1).(func([]*model.Thread) int); ok { + r1 = rf(thread) + } else { + r1 = ret.Get(1).(int) + } + + var r2 error + if rf, ok := ret.Get(2).(func([]*model.Thread) error); ok { + r2 = rf(thread) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// Update provides a mock function with given fields: thread +func (_m *ThreadStore) Update(thread *model.Thread) (*model.Thread, error) { + ret := _m.Called(thread) + + var r0 *model.Thread + if rf, ok := ret.Get(0).(func(*model.Thread) *model.Thread); ok { + r0 = rf(thread) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Thread) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.Thread) error); ok { + r1 = rf(thread) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/store/storetest/store.go b/store/storetest/store.go index 43beb2d0d88e7..1889d707c81d9 100644 --- a/store/storetest/store.go +++ b/store/storetest/store.go @@ -33,6 +33,7 @@ type Store struct { LicenseStore mocks.LicenseStore TokenStore mocks.TokenStore EmojiStore mocks.EmojiStore + ThreadStore mocks.ThreadStore StatusStore mocks.StatusStore FileInfoStore mocks.FileInfoStore UploadSessionStore mocks.UploadSessionStore @@ -72,6 +73,7 @@ func (s *Store) Preference() store.PreferenceStore { return &s.P func (s *Store) License() store.LicenseStore { return &s.LicenseStore } func (s *Store) Token() store.TokenStore { return &s.TokenStore } func (s *Store) Emoji() store.EmojiStore { return &s.EmojiStore } +func (s *Store) Thread() store.ThreadStore { return &s.ThreadStore } func (s *Store) Status() store.StatusStore { return &s.StatusStore } func (s *Store) FileInfo() store.FileInfoStore { return &s.FileInfoStore } func (s *Store) UploadSession() store.UploadSessionStore { return &s.UploadSessionStore } @@ -133,6 +135,7 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.PluginStore, &s.RoleStore, &s.SchemeStore, + &s.ThreadStore, &s.ProductNoticesStore, ) } diff --git a/store/storetest/thread_store.go b/store/storetest/thread_store.go new file mode 100644 index 0000000000000..62edf5e2c195d --- /dev/null +++ b/store/storetest/thread_store.go @@ -0,0 +1,200 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package storetest + +import ( + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/store" + "github.com/stretchr/testify/require" + "testing" +) + +func TestThreadStore(t *testing.T, ss store.Store, s SqlSupplier) { + t.Run("ThreadStorePopulation", func(t *testing.T) { testThreadStorePopulation(t, ss) }) +} + +func testThreadStorePopulation(t *testing.T, ss store.Store) { + makeSomePosts := func() []*model.Post { + o1 := model.Post{} + o1.ChannelId = model.NewId() + o1.UserId = model.NewId() + o1.RootId = model.NewId() + o1.Message = "zz" + model.NewId() + "b" + + o2 := model.Post{} + o2.ChannelId = model.NewId() + o2.UserId = model.NewId() + o2.RootId = o1.RootId + o2.Message = "zz" + model.NewId() + "b" + + o3 := model.Post{} + o3.ChannelId = model.NewId() + o3.UserId = model.NewId() + o3.RootId = model.NewId() + o3.Message = "zz" + model.NewId() + "b" + + o4 := model.Post{} + o4.ChannelId = model.NewId() + o4.UserId = model.NewId() + o4.Message = "zz" + model.NewId() + "b" + + newPosts, errIdx, err := ss.Post().SaveMultiple([]*model.Post{&o1, &o2, &o3, &o4}) + require.Nil(t, err, "couldn't save item") + require.Equal(t, -1, errIdx) + require.Len(t, newPosts, 4) + require.Equal(t, int64(2), newPosts[0].ReplyCount) + require.Equal(t, int64(2), newPosts[1].ReplyCount) + require.Equal(t, int64(1), newPosts[2].ReplyCount) + require.Equal(t, int64(0), newPosts[3].ReplyCount) + return newPosts + } + t.Run("Save replies creates a thread", func(t *testing.T) { + newPosts := makeSomePosts() + thread, err := ss.Thread().Get(newPosts[0].RootId) + require.Nil(t, err, "couldn't get thread") + require.NotNil(t, thread) + require.Equal(t, int64(2), thread.ReplyCount) + require.ElementsMatch(t, model.StringArray{newPosts[0].UserId, newPosts[1].UserId}, thread.Participants) + + o5 := model.Post{} + o5.ChannelId = model.NewId() + o5.UserId = model.NewId() + o5.RootId = newPosts[0].RootId + o5.Message = "zz" + model.NewId() + "b" + + _, _, err = ss.Post().SaveMultiple([]*model.Post{&o5}) + require.Nil(t, err, "couldn't save item") + + thread, err = ss.Thread().Get(newPosts[0].RootId) + require.Nil(t, err, "couldn't get thread") + require.NotNil(t, thread) + require.Equal(t, int64(3), thread.ReplyCount) + require.ElementsMatch(t, model.StringArray{newPosts[0].UserId, newPosts[1].UserId, o5.UserId}, thread.Participants) + }) + + t.Run("Delete a reply updates count on a thread", func(t *testing.T) { + newPosts := makeSomePosts() + thread, err := ss.Thread().Get(newPosts[0].RootId) + require.Nil(t, err, "couldn't get thread") + require.NotNil(t, thread) + require.Equal(t, int64(2), thread.ReplyCount) + require.ElementsMatch(t, model.StringArray{newPosts[0].UserId, newPosts[1].UserId}, thread.Participants) + + err = ss.Post().Delete(newPosts[1].Id, 1234, model.NewId()) + require.Nil(t, err, "couldn't delete post") + + thread, err = ss.Thread().Get(newPosts[0].RootId) + require.Nil(t, err, "couldn't get thread") + require.NotNil(t, thread) + require.Equal(t, int64(1), thread.ReplyCount) + require.ElementsMatch(t, model.StringArray{newPosts[0].UserId}, thread.Participants) + }) + + t.Run("Update reply should update the UpdateAt of the thread", func(t *testing.T) { + rootPost := model.Post{} + rootPost.RootId = model.NewId() + rootPost.ChannelId = model.NewId() + rootPost.UserId = model.NewId() + rootPost.Message = "zz" + model.NewId() + "b" + + replyPost := model.Post{} + replyPost.ChannelId = rootPost.ChannelId + replyPost.UserId = model.NewId() + replyPost.Message = "zz" + model.NewId() + "b" + replyPost.RootId = rootPost.RootId + + newPosts, _, err := ss.Post().SaveMultiple([]*model.Post{&rootPost, &replyPost}) + require.Nil(t, err) + + thread1, err := ss.Thread().Get(newPosts[0].RootId) + require.Nil(t, err) + + rrootPost, err := ss.Post().GetSingle(rootPost.Id) + require.Nil(t, err) + require.Equal(t, rrootPost.UpdateAt, rootPost.UpdateAt) + + replyPost2 := model.Post{} + replyPost2.ChannelId = rootPost.ChannelId + replyPost2.UserId = model.NewId() + replyPost2.Message = "zz" + model.NewId() + "b" + replyPost2.RootId = rootPost.Id + + replyPost3 := model.Post{} + replyPost3.ChannelId = rootPost.ChannelId + replyPost3.UserId = model.NewId() + replyPost3.Message = "zz" + model.NewId() + "b" + replyPost3.RootId = rootPost.Id + + _, _, err = ss.Post().SaveMultiple([]*model.Post{&replyPost2, &replyPost3}) + require.Nil(t, err) + + rrootPost2, err := ss.Post().GetSingle(rootPost.Id) + require.Nil(t, err) + require.Greater(t, rrootPost2.UpdateAt, rrootPost.UpdateAt) + + thread2, err := ss.Thread().Get(rootPost.Id) + require.Nil(t, err) + require.Greater(t, thread2.LastReplyAt, thread1.LastReplyAt) + }) + + t.Run("Deleting reply should update the thread", func(t *testing.T) { + rootPost := model.Post{} + rootPost.RootId = model.NewId() + rootPost.ChannelId = model.NewId() + rootPost.UserId = model.NewId() + rootPost.Message = "zz" + model.NewId() + "b" + + replyPost := model.Post{} + replyPost.ChannelId = rootPost.ChannelId + replyPost.UserId = model.NewId() + replyPost.Message = "zz" + model.NewId() + "b" + replyPost.RootId = rootPost.RootId + + newPosts, _, err := ss.Post().SaveMultiple([]*model.Post{&rootPost, &replyPost}) + require.Nil(t, err) + + thread1, err := ss.Thread().Get(newPosts[0].RootId) + require.Nil(t, err) + require.EqualValues(t, thread1.ReplyCount, 2) + require.Len(t, thread1.Participants, 2) + + err = ss.Post().Delete(replyPost.Id, 123, model.NewId()) + require.Nil(t, err) + + thread2, err := ss.Thread().Get(rootPost.RootId) + require.Nil(t, err) + require.EqualValues(t, thread2.ReplyCount, 1) + require.Len(t, thread2.Participants, 1) + }) + + t.Run("Deleting root post should delete the thread", func(t *testing.T) { + rootPost := model.Post{} + rootPost.ChannelId = model.NewId() + rootPost.UserId = model.NewId() + rootPost.Message = "zz" + model.NewId() + "b" + + newPosts1, _, err := ss.Post().SaveMultiple([]*model.Post{&rootPost}) + require.Nil(t, err) + + replyPost := model.Post{} + replyPost.ChannelId = rootPost.ChannelId + replyPost.UserId = model.NewId() + replyPost.Message = "zz" + model.NewId() + "b" + replyPost.RootId = newPosts1[0].Id + + _, _, err = ss.Post().SaveMultiple([]*model.Post{&replyPost}) + require.Nil(t, err) + + thread1, err := ss.Thread().Get(newPosts1[0].Id) + require.Nil(t, err) + require.EqualValues(t, thread1.ReplyCount, 1) + require.Len(t, thread1.Participants, 1) + + err = ss.Post().Delete(rootPost.Id, 123, model.NewId()) + require.Nil(t, err) + + thread2, _ := ss.Thread().Get(rootPost.Id) + require.Nil(t, thread2) + }) +} diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 9344c1a610ab2..da0cde93eccea 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -45,6 +45,7 @@ type TimerLayer struct { SystemStore store.SystemStore TeamStore store.TeamStore TermsOfServiceStore store.TermsOfServiceStore + ThreadStore store.ThreadStore TokenStore store.TokenStore UploadSessionStore store.UploadSessionStore UserStore store.UserStore @@ -161,6 +162,10 @@ func (s *TimerLayer) TermsOfService() store.TermsOfServiceStore { return s.TermsOfServiceStore } +func (s *TimerLayer) Thread() store.ThreadStore { + return s.ThreadStore +} + func (s *TimerLayer) Token() store.TokenStore { return s.TokenStore } @@ -320,6 +325,11 @@ type TimerLayerTermsOfServiceStore struct { Root *TimerLayer } +type TimerLayerThreadStore struct { + store.ThreadStore + Root *TimerLayer +} + type TimerLayerTokenStore struct { store.TokenStore Root *TimerLayer @@ -6846,6 +6856,86 @@ func (s *TimerLayerTermsOfServiceStore) Save(termsOfService *model.TermsOfServic return result, err } +func (s *TimerLayerThreadStore) Delete(postId string) error { + start := timemodule.Now() + + err := s.ThreadStore.Delete(postId) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ThreadStore.Delete", success, elapsed) + } + return err +} + +func (s *TimerLayerThreadStore) Get(id string) (*model.Thread, error) { + start := timemodule.Now() + + result, err := s.ThreadStore.Get(id) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ThreadStore.Get", success, elapsed) + } + return result, err +} + +func (s *TimerLayerThreadStore) Save(thread *model.Thread) (*model.Thread, error) { + start := timemodule.Now() + + result, err := s.ThreadStore.Save(thread) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ThreadStore.Save", success, elapsed) + } + return result, err +} + +func (s *TimerLayerThreadStore) SaveMultiple(thread []*model.Thread) ([]*model.Thread, int, error) { + start := timemodule.Now() + + result, resultVar1, err := s.ThreadStore.SaveMultiple(thread) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ThreadStore.SaveMultiple", success, elapsed) + } + return result, resultVar1, err +} + +func (s *TimerLayerThreadStore) Update(thread *model.Thread) (*model.Thread, error) { + start := timemodule.Now() + + result, err := s.ThreadStore.Update(thread) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ThreadStore.Update", success, elapsed) + } + return result, err +} + func (s *TimerLayerTokenStore) Cleanup() { start := timemodule.Now() @@ -8800,6 +8890,7 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay newStore.SystemStore = &TimerLayerSystemStore{SystemStore: childStore.System(), Root: &newStore} newStore.TeamStore = &TimerLayerTeamStore{TeamStore: childStore.Team(), Root: &newStore} newStore.TermsOfServiceStore = &TimerLayerTermsOfServiceStore{TermsOfServiceStore: childStore.TermsOfService(), Root: &newStore} + newStore.ThreadStore = &TimerLayerThreadStore{ThreadStore: childStore.Thread(), Root: &newStore} newStore.TokenStore = &TimerLayerTokenStore{TokenStore: childStore.Token(), Root: &newStore} newStore.UploadSessionStore = &TimerLayerUploadSessionStore{UploadSessionStore: childStore.UploadSession(), Root: &newStore} newStore.UserStore = &TimerLayerUserStore{UserStore: childStore.User(), Root: &newStore}