mergeful-0.1.0.0: src/Data/Mergeful/Collection.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
-- | A way to synchronise a single item with safe merge conflicts.
--
-- The setup is as follows:
--
-- * A central server is set up to synchronise with
-- * Each client synchronises with the central server, but never with eachother
--
--
--
-- = A client should operate as follows:
--
-- The client starts with an 'initialClientStore'.
--
-- * The client produces a 'SyncRequest' with 'makeSyncRequest'.
-- * The client sends that request to the central server and gets a 'SyncResponse'.
-- * The client then updates its local store with 'mergeSyncResponseIgnoreProblems'.
--
--
-- = The central server should operate as follows:
--
-- The server starts with an 'initialServerStore'.
--
-- * The server accepts a 'SyncRequest'.
-- * The server performs operations according to the functionality of 'processServerSync'.
-- * The server respons with a 'SyncResponse'.
--
--
-- WARNING:
-- This whole approach can break down if a server resets its server times
-- or if a client syncs with two different servers using the same server times.
module Data.Mergeful.Collection
( initialClientStore
, clientStoreSize
, clientStoreClientIdSet
, clientStoreUndeletedSyncIdSet
, clientStoreSyncIdSet
, clientStoreItems
, addItemToClientStore
, markItemDeletedInClientStore
, changeItemInClientStore
, deleteItemFromClientStore
, initialSyncRequest
, makeSyncRequest
, mergeSyncResponseIgnoreProblems
, mergeSyncResponseFromServer
-- * Custom merging
, ItemMergeStrategy(..)
, mergeSyncResponseUsingStrategy
-- * Server side
, initialServerStore
, processServerSync
-- * Types, for reference
, ClientStore(..)
, SyncRequest(..)
, SyncResponse(..)
, emptySyncResponse
, ServerStore(..)
, ClientId(..)
-- * Utility functions for implementing client-side merging
, mergeAddedItems
, mergeSyncedButChangedItems
, mergeDeletedItems
-- * Utility functions for implementing server-side responding
, addToSyncResponse
) where
import GHC.Generics (Generic)
import Data.Aeson
import Data.List
import Data.Map (Map)
import qualified Data.Map as M
import Data.Maybe
import Data.Set (Set)
import qualified Data.Set as S
import Data.Text (Text)
import Data.Validity
import Data.Validity.Containers ()
import Data.Word
import Control.Applicative
import Control.Monad
import Data.Mergeful.Item
import Data.Mergeful.Timed
-- | A Client-side identifier for items.
--
-- These only need to be unique at the client.
newtype ClientId =
ClientId
{ unClientId :: Word64
}
deriving (Show, Eq, Ord, Enum, Bounded, Generic, ToJSON, ToJSONKey, FromJSON, FromJSONKey)
instance Validity ClientId
data ClientStore i a =
ClientStore
{ clientStoreAddedItems :: Map ClientId a
-- ^ These items are new locally but have not been synced to the server yet.
, clientStoreSyncedItems :: Map i (Timed a)
-- ^ These items have been synced at their respective 'ServerTime's.
, clientStoreSyncedButChangedItems :: Map i (Timed a)
-- ^ These items have been synced at their respective 'ServerTime's
-- but modified locally since then.
, clientStoreDeletedItems :: Map i ServerTime
-- ^ These items have been deleted locally after they were synced
-- but the server has not been notified of that yet.
}
deriving (Show, Eq, Generic)
instance (Validity i, Show i, Ord i, Validity a) => Validity (ClientStore i a) where
validate cs@ClientStore {..} =
mconcat
[ genericValidate cs
, declare "There are no duplicate IDs" $
distinct $
concat
[ M.keys clientStoreSyncedItems
, M.keys clientStoreSyncedButChangedItems
, M.keys clientStoreDeletedItems
]
]
instance (Ord i, FromJSONKey i, FromJSON a) => FromJSON (ClientStore i a) where
parseJSON =
withObject "ClientStore" $ \o ->
ClientStore <$> o .:? "added" .!= M.empty <*> o .:? "synced" .!= M.empty <*>
o .:? "changed" .!= M.empty <*>
o .:? "deleted" .!= M.empty
instance (ToJSONKey i, ToJSON a) => ToJSON (ClientStore i a) where
toJSON ClientStore {..} =
object $
catMaybes
[ jNull "added" clientStoreAddedItems
, jNull "synced" clientStoreSyncedItems
, jNull "changed" clientStoreSyncedButChangedItems
, jNull "deleted" clientStoreDeletedItems
]
-- | A client store to start with.
--
-- This store contains no items.
initialClientStore :: ClientStore i a
initialClientStore =
ClientStore
{ clientStoreAddedItems = M.empty
, clientStoreSyncedItems = M.empty
, clientStoreSyncedButChangedItems = M.empty
, clientStoreDeletedItems = M.empty
}
-- | The number of items in a client store
--
-- This does not count the deleted items, so that they really look deleted..
clientStoreSize :: ClientStore i a -> Word
clientStoreSize ClientStore {..} =
fromIntegral $
sum
[ M.size clientStoreAddedItems
, M.size clientStoreSyncedItems
, M.size clientStoreSyncedButChangedItems
]
-- | The set of client ids.
--
-- These are only the client ids of the added items that have not been synced yet.
clientStoreClientIdSet :: ClientStore i a -> Set ClientId
clientStoreClientIdSet ClientStore {..} = M.keysSet clientStoreAddedItems
-- | The set of server ids.
--
-- This does not include the ids of items that have been marked as deleted.
clientStoreUndeletedSyncIdSet :: Ord i => ClientStore i a -> Set i
clientStoreUndeletedSyncIdSet ClientStore {..} =
S.unions [M.keysSet clientStoreSyncedItems, M.keysSet clientStoreSyncedButChangedItems]
-- | The set of server ids.
--
-- This includes the ids of items that have been marked as deleted.
clientStoreSyncIdSet :: Ord i => ClientStore i a -> Set i
clientStoreSyncIdSet ClientStore {..} =
S.unions
[ M.keysSet clientStoreSyncedItems
, M.keysSet clientStoreSyncedButChangedItems
, M.keysSet clientStoreDeletedItems
]
-- | The set of items in the client store
--
-- This map does not include items that have been marked as deleted.
clientStoreItems :: Ord i => ClientStore i a -> Map (Either ClientId i) a
clientStoreItems ClientStore {..} =
M.unions
[ M.mapKeys Left clientStoreAddedItems
, M.mapKeys Right $ M.map timedValue clientStoreSyncedItems
, M.mapKeys Right $ M.map timedValue clientStoreSyncedButChangedItems
]
-- | Add an item to a client store as an added item.
--
-- This will take care of the uniqueness constraint of the 'ClientId's in the map.
addItemToClientStore :: a -> ClientStore i a -> ClientStore i a
addItemToClientStore a cs =
let oldAddedItems = clientStoreAddedItems cs
newAddedItems =
let newKey =
ClientId $
if M.null oldAddedItems
then 0
else let (ClientId k, _) = M.findMax oldAddedItems
in succ k
in M.insert newKey a oldAddedItems
in cs {clientStoreAddedItems = newAddedItems}
-- | Mark an item deleted in a client store.
--
-- This function will not delete the item, but mark it as deleted instead.
markItemDeletedInClientStore :: Ord i => i -> ClientStore i a -> ClientStore i a
markItemDeletedInClientStore u cs =
let oldSyncedItems = clientStoreSyncedItems cs
oldChangedItems = clientStoreSyncedButChangedItems cs
oldDeletedItems = clientStoreDeletedItems cs
mItem = M.lookup u oldSyncedItems <|> M.lookup u oldChangedItems
in case mItem of
Nothing -> cs
Just t ->
let newSyncedItems = M.delete u oldSyncedItems
newChangedItems = M.delete u oldChangedItems
newDeletedItems = M.insert u (timedTime t) oldDeletedItems
in cs
{ clientStoreSyncedItems = newSyncedItems
, clientStoreSyncedButChangedItems = newChangedItems
, clientStoreDeletedItems = newDeletedItems
}
-- | Replace the given item with a new value.
--
-- This function will correctly mark the item as changed, if it exist.
--
-- It will not add an item to the store with the given id, because the
-- server may not have been the origin of that id.
changeItemInClientStore :: Ord i => i -> a -> ClientStore i a -> ClientStore i a
changeItemInClientStore i a cs =
case M.lookup i (clientStoreSyncedItems cs) of
Just t ->
cs
{ clientStoreSyncedItems = M.delete i (clientStoreSyncedItems cs)
, clientStoreSyncedButChangedItems =
M.insert i (t {timedValue = a}) (clientStoreSyncedButChangedItems cs)
}
Nothing ->
case M.lookup i (clientStoreSyncedButChangedItems cs) of
Nothing -> cs
Just _ ->
cs
{ clientStoreSyncedButChangedItems =
M.adjust (\t -> t {timedValue = a}) i (clientStoreSyncedButChangedItems cs)
}
-- | Delete an unsynced item from a client store.
--
-- This function will immediately delete the item, because it has never been synced.
deleteItemFromClientStore :: ClientId -> ClientStore i a -> ClientStore i a
deleteItemFromClientStore i cs = cs {clientStoreAddedItems = M.delete i (clientStoreAddedItems cs)}
newtype ServerStore i a =
ServerStore
{ serverStoreItems :: Map i (Timed a)
-- ^ A map of items, named using an 'i', together with the 'ServerTime' at which
-- they were last synced.
}
deriving (Show, Eq, Generic, FromJSON, ToJSON)
instance (Validity i, Show i, Ord i, Validity a) => Validity (ServerStore i a)
-- | A server store to start with
--
-- This store contains no items.
initialServerStore :: ServerStore i a
initialServerStore = ServerStore {serverStoreItems = M.empty}
data SyncRequest i a =
SyncRequest
{ syncRequestNewItems :: Map ClientId a
-- ^ These items are new locally but have not been synced to the server yet.
, syncRequestKnownItems :: Map i ServerTime
-- ^ These items have been synced at their respective 'ServerTime's.
, syncRequestKnownButChangedItems :: Map i (Timed a)
-- ^ These items have been synced at their respective 'ServerTime's
-- but modified locally since then.
, syncRequestDeletedItems :: Map i ServerTime
-- ^ These items have been deleted locally after they were synced
-- but the server has not been notified of that yet.
}
deriving (Show, Eq, Generic)
instance (Validity i, Show i, Ord i, Validity a) => Validity (SyncRequest i a) where
validate sr@SyncRequest {..} =
mconcat
[ genericValidate sr
, declare "There are no duplicate IDs" $
distinct $
concat
[ M.keys syncRequestKnownItems
, M.keys syncRequestKnownButChangedItems
, M.keys syncRequestDeletedItems
]
]
instance (Ord i, FromJSONKey i, FromJSON a) => FromJSON (SyncRequest i a) where
parseJSON =
withObject "SyncRequest" $ \o ->
SyncRequest <$> o .:? "new" .!= M.empty <*> o .:? "synced" .!= M.empty <*>
o .:? "changed" .!= M.empty <*>
o .:? "deleted" .!= M.empty
instance (ToJSONKey i, ToJSON a) => ToJSON (SyncRequest i a) where
toJSON SyncRequest {..} =
object $
catMaybes
[ jNull "new" syncRequestNewItems
, jNull "synced" syncRequestKnownItems
, jNull "changed" syncRequestKnownButChangedItems
, jNull "deleted" syncRequestDeletedItems
]
-- | An intial 'SyncRequest' to start with.
--
-- It just asks the server to send over whatever it knows.
initialSyncRequest :: SyncRequest i a
initialSyncRequest =
SyncRequest
{ syncRequestNewItems = M.empty
, syncRequestKnownItems = M.empty
, syncRequestKnownButChangedItems = M.empty
, syncRequestDeletedItems = M.empty
}
data SyncResponse i a =
SyncResponse
{ syncResponseClientAdded :: Map ClientId (i, ServerTime)
-- ^ The client added these items and server has succesfully been made aware of that.
--
-- The client needs to update their server times
, syncResponseClientChanged :: Map i ServerTime
-- ^ The client changed these items and server has succesfully been made aware of that.
--
-- The client needs to update their server times
, syncResponseClientDeleted :: Set i
-- ^ The client deleted these items and server has succesfully been made aware of that.
--
-- The client can delete them from its deleted items
, syncResponseServerAdded :: Map i (Timed a)
-- ^ These items have been added on the server side
--
-- The client should add them too.
, syncResponseServerChanged :: Map i (Timed a)
-- ^ These items have been modified on the server side.
--
-- The client should modify them too.
, syncResponseServerDeleted :: Set i
-- ^ These items were deleted on the server side
--
-- The client should delete them too
, syncResponseConflicts :: Map i (Timed a)
-- ^ These are conflicts where the server and the client both have an item, but it is different.
--
-- The server kept its part of each, the client can either take whatever the server gave them
-- or deal with the conflicts somehow, and then try to re-sync.
, syncResponseConflictsClientDeleted :: Map i (Timed a)
-- ^ These are conflicts where the server has an item but the client does not.
--
-- The server kept its item, the client can either take whatever the server gave them
-- or deal with the conflicts somehow, and then try to re-sync.
, syncResponseConflictsServerDeleted :: Set i
-- ^ These are conflicts where the server has no item but the client has a modified item.
--
-- The server left its item deleted, the client can either delete its items too
-- or deal with the conflicts somehow, and then try to re-sync.
}
deriving (Show, Eq, Generic)
instance (Validity i, Show i, Ord i, Validity a) => Validity (SyncResponse i a) where
validate sr@SyncResponse {..} =
mconcat
[ genericValidate sr
, declare "There are no duplicate IDs" $
distinct $
concat
[ map (\(_, (i, _)) -> i) $ M.toList syncResponseClientAdded
, M.keys syncResponseClientChanged
, S.toList syncResponseClientDeleted
, M.keys syncResponseServerAdded
, M.keys syncResponseServerChanged
, S.toList syncResponseServerDeleted
, M.keys syncResponseConflicts
, M.keys syncResponseConflictsClientDeleted
, S.toList syncResponseConflictsServerDeleted
]
]
instance (Ord i, FromJSON i, FromJSONKey i, FromJSON a) => FromJSON (SyncResponse i a) where
parseJSON =
withObject "SyncResponse" $ \o ->
SyncResponse <$> o .:? "client-added" .!= M.empty <*> o .:? "client-changed" .!= M.empty <*>
o .:? "client-deleted" .!= S.empty <*>
o .:? "server-added" .!= M.empty <*>
o .:? "server-changed" .!= M.empty <*>
o .:? "server-deleted" .!= S.empty <*>
o .:? "conflict" .!= M.empty <*>
o .:? "conflict-client-deleted" .!= M.empty <*>
o .:? "conflict-server-deleted" .!= S.empty
instance (ToJSON i, ToJSONKey i, ToJSON a) => ToJSON (SyncResponse i a) where
toJSON SyncResponse {..} =
object $
catMaybes
[ jNull "client-added" syncResponseClientAdded
, jNull "client-changed" syncResponseClientChanged
, jNull "client-deleted" syncResponseClientDeleted
, jNull "server-added" syncResponseServerAdded
, jNull "server-changed" syncResponseServerChanged
, jNull "server-deleted" syncResponseServerDeleted
, jNull "conflict" syncResponseConflicts
, jNull "conflict-client-deleted" syncResponseConflictsClientDeleted
, jNull "conflict-server-deleted" syncResponseConflictsServerDeleted
]
emptySyncResponse :: SyncResponse i a
emptySyncResponse =
SyncResponse
{ syncResponseClientAdded = M.empty
, syncResponseClientChanged = M.empty
, syncResponseClientDeleted = S.empty
, syncResponseServerAdded = M.empty
, syncResponseServerChanged = M.empty
, syncResponseServerDeleted = S.empty
, syncResponseConflicts = M.empty
, syncResponseConflictsClientDeleted = M.empty
, syncResponseConflictsServerDeleted = S.empty
}
jNull :: (Foldable f, ToJSON (f b)) => Text -> f b -> Maybe (Text, Value)
jNull n s =
if null s
then Nothing
else Just $ n .= s
-- | Produce an 'SyncRequest' from a 'ClientStore'.
--
-- Send this to the server for synchronisation.
makeSyncRequest :: ClientStore i a -> SyncRequest i a
makeSyncRequest ClientStore {..} =
SyncRequest
{ syncRequestNewItems = clientStoreAddedItems
, syncRequestKnownItems = M.map timedTime clientStoreSyncedItems
, syncRequestKnownButChangedItems = clientStoreSyncedButChangedItems
, syncRequestDeletedItems = clientStoreDeletedItems
}
-- | Merge an 'SyncResponse' into the current 'ClientStore'.
--
-- This function ignores any problems that may occur.
-- In the case of a conclict, it will just not update the client item.
-- The next sync request will then produce a conflict again.
--
-- Pro: No data will be lost
--
-- __Con: Clients will diverge when conflicts occur.__
mergeSyncResponseIgnoreProblems :: Ord i => ClientStore i a -> SyncResponse i a -> ClientStore i a
mergeSyncResponseIgnoreProblems cs SyncResponse {..} =
let (addedItemsLeftovers, newSyncedItems) =
mergeAddedItems (clientStoreAddedItems cs) syncResponseClientAdded
(syncedButNotChangedLeftovers, newModifiedItems) =
mergeSyncedButChangedItems (clientStoreSyncedButChangedItems cs) syncResponseClientChanged
deletedItemsLeftovers =
mergeDeletedItems (clientStoreDeletedItems cs) syncResponseClientDeleted
synced =
M.unions
[ newSyncedItems
, syncResponseServerAdded
, syncResponseServerChanged
, newModifiedItems
, clientStoreSyncedItems cs
]
in ClientStore
{ clientStoreAddedItems = addedItemsLeftovers
, clientStoreSyncedButChangedItems = syncedButNotChangedLeftovers `M.difference` synced
, clientStoreDeletedItems = deletedItemsLeftovers `M.difference` synced
, clientStoreSyncedItems =
synced `M.difference` M.fromSet (const ()) syncResponseServerDeleted
}
-- | Merge an 'SyncResponse' into the current 'ClientStore' with the given merge strategy.
--
-- In order for clients to converge on the same collection correctly, this function must be:
--
-- * Associative
-- * Idempotent
-- * The same on all clients
--
-- This function ignores mismatches.
mergeSyncResponseUsingStrategy ::
Ord i => ItemMergeStrategy a -> ClientStore i a -> SyncResponse i a -> ClientStore i a
mergeSyncResponseUsingStrategy ItemMergeStrategy {..} cs SyncResponse {..} =
let (addedItemsLeftovers, newSyncedItems) =
mergeAddedItems (clientStoreAddedItems cs) syncResponseClientAdded
(syncedButNotChangedLeftovers, newModifiedItems) =
mergeSyncedButChangedItems (clientStoreSyncedButChangedItems cs) syncResponseClientChanged
deletedItemsLeftovers =
mergeDeletedItems (clientStoreDeletedItems cs) syncResponseClientDeleted
synced =
M.unions
[ newSyncedItems
, syncResponseServerAdded
, syncResponseServerChanged
-- Merge the synced but changed (the only ones that could have caused a conflict)
-- with the ones that the response indicated were a conflict.
, M.intersectionWith
itemMergeStrategyMergeChangeConflict
(M.map timedValue $ clientStoreSyncedButChangedItems cs)
syncResponseConflicts
-- Of the items that the server changed but the client deleted,
-- keep the ones that the strategy wants to keep.
, M.mapMaybe id $
M.intersectionWith
(\_ t -> itemMergeStrategyMergeClientDeletedConflict t)
(clientStoreDeletedItems cs)
syncResponseConflictsClientDeleted
, newModifiedItems
, clientStoreSyncedItems cs
]
-- | The synced but changed items that were not acknowledged as changed,
-- minus the ones that the strategy decided to delete.
newSyncedButChangedItems =
syncedButNotChangedLeftovers `M.difference`
M.fromSet (const ()) syncResponseConflictsServerDeleted
in ClientStore
{ clientStoreAddedItems = addedItemsLeftovers
, clientStoreSyncedButChangedItems = newSyncedButChangedItems `M.difference` synced
, clientStoreDeletedItems = deletedItemsLeftovers `M.difference` synced
, clientStoreSyncedItems =
synced `M.difference` M.fromSet (const ()) syncResponseServerDeleted
}
-- | Merge an 'SyncResponse' into the current 'ClientStore' by taking whatever the server gave the client.
--
-- Pro: Clients will converge on the same value.
--
-- __Con: Conflicting updates will be lost.__
mergeSyncResponseFromServer :: Ord i => ClientStore i a -> SyncResponse i a -> ClientStore i a
mergeSyncResponseFromServer =
mergeSyncResponseUsingStrategy
ItemMergeStrategy
{ itemMergeStrategyMergeChangeConflict = \_ serverItem -> serverItem
, itemMergeStrategyMergeClientDeletedConflict = \serverItem -> Just serverItem
, itemMergeStrategyMergeServerDeletedConflict = \_ -> Nothing
}
-- | Merge the local added items with the ones that the server has acknowledged as added.
mergeAddedItems ::
forall i a. Ord i
=> Map ClientId a
-> Map ClientId (i, ServerTime)
-> (Map ClientId a, Map i (Timed a))
mergeAddedItems local added = M.foldlWithKey go (M.empty, M.empty) local
where
go :: (Map ClientId a, Map i (Timed a)) -> ClientId -> a -> (Map ClientId a, Map i (Timed a))
go (as, m) i a =
case M.lookup i added of
Nothing -> (M.insert i a as, m)
Just (k, st) -> (as, M.insert k (Timed {timedValue = a, timedTime = st}) m)
-- | Merge the local synced but changed items with the ones that the server has acknowledged as changed.
mergeSyncedButChangedItems ::
forall i a. Ord i
=> Map i (Timed a)
-> Map i ServerTime
-> (Map i (Timed a), Map i (Timed a))
mergeSyncedButChangedItems local changed = M.foldlWithKey go (M.empty, M.empty) local
where
go :: (Map i (Timed a), Map i (Timed a)) -> i -> Timed a -> (Map i (Timed a), Map i (Timed a))
go (m1, m2) k t =
case M.lookup k changed of
Nothing -> (M.insert k t m1, m2)
Just st' -> (m1, M.insert k (t {timedTime = st'}) m2)
-- | Merge the local deleted items with the ones that the server has acknowledged as deleted.
mergeDeletedItems :: Ord i => Map i b -> Set i -> Map i b
mergeDeletedItems m s = m `M.difference` M.fromSet (const ()) s
data Identifier i
= OnlyServer i
| BothServerAndClient i ClientId
deriving (Show, Eq, Ord, Generic)
instance Validity i => Validity (Identifier i)
-- | Serve an 'SyncRequest' using the current 'ServerStore', producing an 'SyncResponse' and a new 'ServerStore'.
processServerSync ::
forall i a m. (Ord i, Monad m)
=> m i -- ^ The action that is guaranteed to generate unique identifiers
-> ServerStore i a
-> SyncRequest i a
-> m (SyncResponse i a, ServerStore i a)
processServerSync genId ServerStore {..} sr@SyncRequest {..}
-- Make tuples of requests for all of the items that only had a client identifier.
= do
let unidentifedPairs :: Map ClientId (ServerItem a, ItemSyncRequest a)
unidentifedPairs = M.map (\a -> (ServerEmpty, ItemSyncRequestNew a)) syncRequestNewItems
-- Make tuples of results for each of the unidentifier tuples.
unidentifedResults :: Map ClientId (ItemSyncResponse a, ServerItem a)
unidentifedResults = M.map (uncurry processServerItemSync) unidentifedPairs
generatedResults <- generateIdentifiersFor genId unidentifedResults
-- Gather the items that had a server identifier already.
let clientIdentifiedSyncRequests :: Map i (ItemSyncRequest a)
clientIdentifiedSyncRequests = identifiedItemSyncRequests sr
-- Make 'ServerItem's for each of the items on the server side
serverIdentifiedItems :: Map i (ServerItem a)
serverIdentifiedItems = M.map ServerFull serverStoreItems
-- Match up client items with server items by their id.
thesePairs :: Map i (These (ServerItem a) (ItemSyncRequest a))
thesePairs = unionTheseMaps serverIdentifiedItems clientIdentifiedSyncRequests
-- Make tuples of server 'ServerItem's and 'ItemSyncRequest's for each of the items with an id
requestPairs :: Map i (ServerItem a, ItemSyncRequest a)
requestPairs = M.map (fromThese ServerEmpty ItemSyncRequestPoll) thesePairs
-- Make tuples of results for each of the tuplus that had a server identifier.
identifiedResults :: Map i (ItemSyncResponse a, ServerItem a)
identifiedResults = M.map (uncurry processServerItemSync) requestPairs
-- Put together the results together
let allResults :: Map (Identifier i) (ItemSyncResponse a, ServerItem a)
allResults =
M.union
(M.mapKeys OnlyServer identifiedResults)
(M.mapKeys (uncurry BothServerAndClient) generatedResults)
pure $ produceSyncResults allResults
identifiedItemSyncRequests :: Ord i => SyncRequest i a -> Map i (ItemSyncRequest a)
identifiedItemSyncRequests SyncRequest {..} =
M.unions
[ M.map ItemSyncRequestKnown syncRequestKnownItems
, M.map ItemSyncRequestKnownButChanged syncRequestKnownButChangedItems
, M.map ItemSyncRequestDeletedLocally syncRequestDeletedItems
]
generateIdentifiersFor ::
(Ord i, Monad m)
=> m i
-> Map ClientId (ItemSyncResponse a, ServerItem a)
-> m (Map (i, ClientId) (ItemSyncResponse a, ServerItem a))
generateIdentifiersFor genId unidentifedResults =
fmap M.fromList $
forM (M.toList unidentifedResults) $ \(int, r) -> do
uuid <- genId
pure ((uuid, int), r)
produceSyncResults ::
forall i a. Ord i
=> Map (Identifier i) (ItemSyncResponse a, ServerItem a)
-> (SyncResponse i a, ServerStore i a)
produceSyncResults allResults
-- Produce a sync response
=
let resp :: SyncResponse i a
resp =
M.foldlWithKey
(\sr cid (isr, _) -> addToSyncResponse sr cid isr)
emptySyncResponse
allResults
-- Produce a new server store
newStore :: Map i (Timed a)
newStore =
M.mapMaybe
(\case
ServerEmpty -> Nothing
ServerFull t -> Just t) $
M.map snd $
M.mapKeys
(\case
OnlyServer i -> i
BothServerAndClient i _ -> i)
allResults
-- return them both.
in (resp, ServerStore newStore)
-- | Given an incomplete 'SyncResponse', an id, possibly a client ID too, and
-- an 'ItemSyncResponse', produce a less incomplete 'SyncResponse'.
addToSyncResponse ::
Ord i => SyncResponse i a -> Identifier i -> ItemSyncResponse a -> SyncResponse i a
addToSyncResponse sr cid isr =
case cid of
BothServerAndClient i int ->
case isr of
ItemSyncResponseClientAdded st ->
sr {syncResponseClientAdded = M.insert int (i, st) $ syncResponseClientAdded sr}
_ -> error "should not happen"
OnlyServer i ->
case isr of
ItemSyncResponseInSyncEmpty -> sr
ItemSyncResponseInSyncFull -> sr
ItemSyncResponseClientAdded _ -> sr -- Should not happen.
ItemSyncResponseClientChanged st ->
sr {syncResponseClientChanged = M.insert i st $ syncResponseClientChanged sr}
ItemSyncResponseClientDeleted ->
sr {syncResponseClientDeleted = S.insert i $ syncResponseClientDeleted sr}
ItemSyncResponseServerAdded t ->
sr {syncResponseServerAdded = M.insert i t $ syncResponseServerAdded sr}
ItemSyncResponseServerChanged t ->
sr {syncResponseServerChanged = M.insert i t $ syncResponseServerChanged sr}
ItemSyncResponseServerDeleted ->
sr {syncResponseServerDeleted = S.insert i $ syncResponseServerDeleted sr}
ItemSyncResponseConflict a ->
sr {syncResponseConflicts = M.insert i a $ syncResponseConflicts sr}
ItemSyncResponseConflictClientDeleted a ->
sr
{ syncResponseConflictsClientDeleted =
M.insert i a $ syncResponseConflictsClientDeleted sr
}
ItemSyncResponseConflictServerDeleted ->
sr
{ syncResponseConflictsServerDeleted =
S.insert i $ syncResponseConflictsServerDeleted sr
}
unionTheseMaps :: Ord k => Map k a -> Map k b -> Map k (These a b)
unionTheseMaps m1 m2 = M.unionWith go (M.map This m1) (M.map That m2)
where
go (This a) (That b) = These a b
go _ _ = error "should not happen."
distinct :: Eq a => [a] -> Bool
distinct ls = nub ls == ls
-- Inlined because holy smokes, `these` has a _lot_ of dependencies.
data These a b
= This a
| That b
| These a b
deriving (Show, Eq, Generic)
fromThese :: a -> b -> These a b -> (a, b)
fromThese a b t =
case t of
This a' -> (a', b)
That b' -> (a, b')
These a' b' -> (a', b')