{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} -- | A way to synchronise items without merge conflicts. -- -- This concept has a few requirements: -- -- * Items must be immutable. -- * Items must allow for a centrally unique identifier. -- * Identifiers for items must be generatable in such a way that they are certainly unique. -- -- Should mutation be a requirement, then it can be build such that it entails deleting the old version and creating a new version that is the modification of the old version. -- -- -- There are a few obvious candidates for identifiers: -- -- * incremental identifiers -- * universally unique identifiers (recommended). -- -- -- -- The typical 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 central server should operate as follows: -- -- * The server accepts a 'SyncRequest'. -- * The server performs operations according to the functionality of 'processServerSync'. -- * The server respons with a 'SyncResponse'. -- -- -- A client should operate as follows: -- -- * 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 'mergeSyncResponse'. module Data.Mergeless.Collection ( ClientId(..) , ClientStore(..) , emptyClientStore , storeSize , addItemToClientStore , deleteUnsyncedFromClientStore , deleteSyncedFromClientStore , SyncRequest(..) , SyncResponse(..) , emptySyncResponse -- * Client-side Synchronisation , makeSyncRequest , mergeSyncResponse , addRemotelyAddedItems , addAddedItems , deleteItemsToBeDeletedLocally , deleteLocalUndeletedItems -- * Server-side Synchronisation -- ** General synchronisation , ServerSyncProcessor(..) , processServerSyncCustom -- ** Synchronisation with a simple central store , ServerStore(..) , emptyServerStore , processServerSync ) where import GHC.Generics (Generic) import Data.Validity import Data.Validity.Containers () import Data.Aeson import Data.List import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import qualified Data.Set as S import Data.Set (Set) import Data.Word import Control.DeepSeq import Control.Monad.State.Strict {-# ANN module ("HLint: ignore Use lambda-case" :: String) #-} -- | 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 instance NFData ClientId -- | A client-side store of items with Id's of type @i@ and values of type @a@ data ClientStore i a = ClientStore { clientStoreAdded :: !(Map ClientId a) , clientStoreSynced :: !(Map i a) , clientStoreDeleted :: !(Set i) } deriving (Show, Eq, Ord, Generic) instance (NFData i, NFData a) => NFData (ClientStore i a) instance (Validity i, Validity a, Show i, Show a, Ord i, Ord a) => Validity (ClientStore i a) where validate cs@ClientStore {..} = mconcat [ genericValidate cs , declare "the store items have distinct ids" $ distinct $ M.keys clientStoreSynced ++ S.toList clientStoreDeleted ] instance (Ord i, FromJSON i, FromJSONKey i, FromJSON a) => FromJSON (ClientStore i a) where parseJSON = withObject "ClientStore" $ \o -> ClientStore <$> o .:? "added" .!= M.empty <*> o .:? "synced" .!= M.empty <*> o .:? "deleted" .!= S.empty instance (Ord i, ToJSON i, ToJSONKey i, ToJSON a) => ToJSON (ClientStore i a) where toJSON ClientStore {..} = object ["added" .= clientStoreAdded, "synced" .= clientStoreSynced, "deleted" .= clientStoreDeleted] -- | The store with no items. emptyClientStore :: ClientStore i a emptyClientStore = ClientStore {clientStoreAdded = M.empty, clientStoreSynced = M.empty, clientStoreDeleted = S.empty} -- | The number of items in a store -- -- This does not count the deleted items, so that those really look deleted. storeSize :: ClientStore i a -> Int storeSize ClientStore {..} = M.size clientStoreAdded + M.size clientStoreSynced clientStoreIds :: Ord i => ClientStore i a -> Set i clientStoreIds ClientStore {..} = M.keysSet clientStoreSynced `S.union` clientStoreDeleted -- | 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 :: (Ord i, Ord a) => a -> ClientStore i a -> ClientStore i a addItemToClientStore a cs = let oldAddedItems = clientStoreAdded cs newAddedItems = let newKey = findFreeSpot oldAddedItems in M.insert newKey a oldAddedItems in cs {clientStoreAdded = newAddedItems} -- | Find a free client id to use -- -- You shouldn't need this function, 'addItemToClientStore' takes care of this. findFreeSpot :: Map ClientId a -> ClientId findFreeSpot m = if M.null m then ClientId 0 else let (i, _) = M.findMax m in go (next i) where go i = if M.member i m then go (next i) else i next (ClientId w) | w == maxBound = ClientId 0 | otherwise = ClientId $ succ w deleteUnsyncedFromClientStore :: (Ord i, Ord a) => ClientId -> ClientStore i a -> ClientStore i a deleteUnsyncedFromClientStore cid cs = cs {clientStoreAdded = M.delete cid $ clientStoreAdded cs} deleteSyncedFromClientStore :: (Ord i, Ord a) => i -> ClientStore i a -> ClientStore i a deleteSyncedFromClientStore i cs = let syncedBefore = clientStoreSynced cs in case M.lookup i syncedBefore of Nothing -> cs Just _ -> cs { clientStoreSynced = M.delete i syncedBefore , clientStoreDeleted = S.insert i $ clientStoreDeleted cs } -- | A synchronisation request for items with identifiers of type @i@ and values of type @a@ data SyncRequest i a = SyncRequest { syncRequestAdded :: !(Map ClientId a) , syncRequestSynced :: !(Set i) , syncRequestDeleted :: !(Set i) } deriving (Show, Eq, Ord, Generic) instance (NFData i, NFData a) => NFData (SyncRequest i a) instance (Validity i, Validity a, Ord i, Ord a) => Validity (SyncRequest i a) where validate sr@SyncRequest {..} = mconcat [ genericValidate sr , declare "the sync request items have distinct ids" $ distinct $ S.toList syncRequestSynced ++ S.toList syncRequestDeleted ] instance (FromJSON i, FromJSON a, Ord i, Ord a) => FromJSON (SyncRequest i a) where parseJSON = withObject "SyncRequest" $ \o -> SyncRequest <$> o .: "added" <*> o .: "synced" <*> o .: "undeleted" instance (ToJSON i, ToJSON a) => ToJSON (SyncRequest i a) where toJSON SyncRequest {..} = object [ "added" .= syncRequestAdded , "synced" .= syncRequestSynced , "undeleted" .= syncRequestDeleted ] -- | Produce a synchronisation request for a client-side store. -- -- This request can then be sent to a central store for synchronisation. makeSyncRequest :: (Ord i, Ord a) => ClientStore i a -> SyncRequest i a makeSyncRequest ClientStore {..} = SyncRequest { syncRequestAdded = clientStoreAdded , syncRequestSynced = M.keysSet clientStoreSynced , syncRequestDeleted = clientStoreDeleted } -- | A synchronisation response for items with identifiers of type @i@ and values of type @a@ data SyncResponse i a = SyncResponse { syncResponseClientAdded :: !(Map ClientId i) , syncResponseClientDeleted :: !(Set i) , syncResponseServerAdded :: !(Map i a) , syncResponseServerDeleted :: !(Set i) } deriving (Show, Eq, Ord, Generic) instance (NFData i, NFData a) => NFData (SyncResponse i a) instance (Validity i, Validity a, Show i, Show a, Ord i, Ord a) => Validity (SyncResponse i a) where validate sr@SyncResponse {..} = mconcat [ genericValidate sr , declare "the sync response items have distinct uuids" $ distinct $ concat [ M.elems syncResponseClientAdded , S.toList syncResponseClientDeleted , M.keys syncResponseServerAdded , S.toList syncResponseServerDeleted ] ] instance (Ord i, FromJSON i, FromJSONKey i, Ord a, FromJSON a) => FromJSON (SyncResponse i a) where parseJSON = withObject "SyncResponse" $ \o -> SyncResponse <$> o .: "client-added" <*> o .: "client-deleted" <*> o .: "server-added" <*> o .: "server-deleted" instance (ToJSON i, ToJSONKey i, ToJSON a) => ToJSON (SyncResponse i a) where toJSON SyncResponse {..} = object [ "client-added" .= syncResponseClientAdded , "client-deleted" .= syncResponseClientDeleted , "server-added" .= syncResponseServerAdded , "server-deleted" .= syncResponseServerDeleted ] emptySyncResponse :: SyncResponse i ia emptySyncResponse = SyncResponse { syncResponseClientAdded = M.empty , syncResponseClientDeleted = S.empty , syncResponseServerAdded = M.empty , syncResponseServerDeleted = S.empty } -- | Merge a synchronisation response back into a client-side store. mergeSyncResponse :: forall i a. (Ord i, Ord a) => ClientStore i a -> SyncResponse i a -> ClientStore i a mergeSyncResponse s SyncResponse {..} = addRemotelyAddedItems syncResponseServerAdded . addAddedItems syncResponseClientAdded . deleteItemsToBeDeletedLocally syncResponseServerDeleted . deleteLocalUndeletedItems syncResponseClientDeleted $ s addRemotelyAddedItems :: (Ord i, Ord a) => Map i a -> ClientStore i a -> ClientStore i a addRemotelyAddedItems m cs = cs {clientStoreSynced = M.union (clientStoreSynced cs) (m `diffSet` clientStoreIds cs)} addAddedItems :: forall i a. (Ord i, Ord a) => Map ClientId i -> ClientStore i a -> ClientStore i a addAddedItems addedItems cs = let oldAdded = clientStoreAdded cs oldSynced = clientStoreSynced cs go :: (Map ClientId a, Map i a) -> ClientId -> i -> (Map ClientId a, Map i a) go (added, synced) cid i = case M.lookup cid added of Nothing -> (added, synced) Just a -> (M.delete cid added, M.insert i a synced) (newAdded, newSynced) = M.foldlWithKey go (oldAdded, oldSynced) addedItems in cs {clientStoreAdded = newAdded, clientStoreSynced = newSynced} deleteItemsToBeDeletedLocally :: (Ord i, Ord a) => Set i -> ClientStore i a -> ClientStore i a deleteItemsToBeDeletedLocally toBeDeletedLocally cs = cs {clientStoreSynced = clientStoreSynced cs `diffSet` toBeDeletedLocally} deleteLocalUndeletedItems :: (Ord i, Ord a) => Set i -> ClientStore i a -> ClientStore i a deleteLocalUndeletedItems cd cs = cs {clientStoreDeleted = clientStoreDeleted cs `S.difference` cd} -- | A record of the basic operations that are necessary to build a synchronisation processor. data ServerSyncProcessor i a m = ServerSyncProcessor { serverSyncProcessorDeleteMany :: Set i -> m (Set i) -- ^ Delete the items with an identifier in the given set, return the set that was indeed deleted or did not exist. -- In particular, return the identifiers of the items that the client should forget about. , serverSyncProcessorQueryNoLongerSynced :: Set i -> m (Set i) -- ^ Query the identifiers of the items that are in the given set but not in the store. , serverSyncProcessorQueryNewRemote :: Set i -> m (Map i a) -- ^ Query the items that are in store, but not in the given set. , serverSyncProcessorInsertMany :: Map ClientId a -> m (Map ClientId i) -- ^ Insert a set of items into the store. } deriving (Generic) -- | Process a server-side synchronisation request using a custom synchronisation processor -- -- WARNING: The identifier generation function must produce newly unique identifiers such that each new item gets a unique identifier. -- -- You can use this function with deterministically-random identifiers or incrementing identifiers. processServerSyncCustom :: forall i a m. (Ord i, Ord a, Monad m) => ServerSyncProcessor i a m -> SyncRequest i a -> m (SyncResponse i a) processServerSyncCustom ServerSyncProcessor {..} SyncRequest {..} = do deletedFromClient <- deleteUndeleted -- First we delete the items that were deleted locally but not yet remotely. -- Then we find the items that have been deleted remotely but not locally deletedRemotely <- syncItemsToBeDeletedLocally -- Then we find the items that have appeared remotely but aren't known locally newRemoteItems <- syncNewRemoteItems -- Then we add the items that should be added. newLocalItems <- syncAddedItems pure SyncResponse { syncResponseClientAdded = newLocalItems , syncResponseClientDeleted = deletedFromClient , syncResponseServerAdded = newRemoteItems , syncResponseServerDeleted = deletedRemotely } where deleteUndeleted :: m (Set i) deleteUndeleted = serverSyncProcessorDeleteMany syncRequestDeleted syncItemsToBeDeletedLocally :: m (Set i) syncItemsToBeDeletedLocally = serverSyncProcessorQueryNoLongerSynced syncRequestSynced syncNewRemoteItems :: m (Map i a) syncNewRemoteItems = serverSyncProcessorQueryNewRemote syncRequestSynced syncAddedItems :: m (Map ClientId i) syncAddedItems = serverSyncProcessorInsertMany syncRequestAdded -- | A central store of items with identifiers of type @i@ and values of type @a@ newtype ServerStore i a = ServerStore { serverStoreItems :: Map i a } deriving (Show, Eq, Ord, Generic, FromJSON, ToJSON) instance (NFData i, NFData a) => NFData (ServerStore i a) instance (Validity i, Validity a, Show i, Show a, Ord i, Ord a) => Validity (ServerStore i a) -- | An empty central store to start with emptyServerStore :: ServerStore i a emptyServerStore = ServerStore {serverStoreItems = M.empty} -- | Process a server-side synchronisation request using @getCurrentTime@ -- -- see 'processSyncCustom' processServerSync :: forall m i a. (Ord i, Ord a, Monad m) => m i -> ServerStore i a -> SyncRequest i a -> m (SyncResponse i a, ServerStore i a) processServerSync genUuid cs sr = flip runStateT cs $ processServerSyncCustom ServerSyncProcessor { serverSyncProcessorDeleteMany = deleteMany , serverSyncProcessorQueryNoLongerSynced = queryNoLongerSynced , serverSyncProcessorQueryNewRemote = queryNewRemote , serverSyncProcessorInsertMany = insertMany } sr where deleteMany :: Set i -> StateT (ServerStore i a) m (Set i) deleteMany s = do modC (`diffSet` s) pure s queryNoLongerSynced :: Set i -> StateT (ServerStore i a) m (Set i) queryNoLongerSynced s = query ((s `S.difference`) . M.keysSet) queryNewRemote :: Set i -> StateT (ServerStore i a) m (Map i a) queryNewRemote s = query (`diffSet` s) query :: (Map i a -> b) -> StateT (ServerStore i a) m b query func = gets $ func . serverStoreItems insertMany :: Map ClientId a -> StateT (ServerStore i a) m (Map ClientId i) insertMany = traverse $ \a -> do u <- lift genUuid ins u a pure u ins :: i -> a -> StateT (ServerStore i a) m () ins i val = modC $ M.insert i val modC :: (Map i a -> Map i a) -> StateT (ServerStore i a) m () modC func = modify (\(ServerStore m) -> ServerStore $ func m) diffSet :: Ord i => Map i a -> Set i -> Map i a diffSet m s = m `M.difference` toMap s toMap :: Set i -> Map i () toMap = M.fromSet (const ()) distinct :: Ord a => [a] -> Bool distinct ls = sort ls == S.toAscList (S.fromList ls)