diff options
author | Norfair <> | 2020-02-12 16:39:00 (GMT) |
---|---|---|
committer | hdiff <hdiff@hdiff.luite.com> | 2020-02-12 16:39:00 (GMT) |
commit | 1480c0435ef15e742a711dde194b0b793941c07a (patch) | |
tree | 090561a832dd2c3de7cfc2311dfb28d92616ce08 | |
parent | ee2f4f2c04f835a5ca0d21ebe4cfa63abae1291e (diff) |
version 0.2.0.00.2.0.0
-rw-r--r-- | ChangeLog.md | 4 | ||||
-rw-r--r-- | mergeless.cabal | 16 | ||||
-rw-r--r-- | src/Data/Mergeless.hs | 501 | ||||
-rw-r--r-- | src/Data/Mergeless/Collection.hs | 435 | ||||
-rw-r--r-- | src/Data/Mergeless/Item.hs | 148 |
5 files changed, 599 insertions, 505 deletions
diff --git a/ChangeLog.md b/ChangeLog.md index 1acfce1..c97ebfe 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,3 +1,7 @@ # Changelog for mergeless ## Unreleased changes + +## [0.2.0.0] - 2020-02-12 + +* Got rid of the UTCTimes. The client should take care of that. diff --git a/mergeless.cabal b/mergeless.cabal index 2a630c3..9c593ba 100644 --- a/mergeless.cabal +++ b/mergeless.cabal @@ -1,11 +1,13 @@ --- This file has been generated from package.yaml by hpack version 0.28.2. +cabal-version: 1.12 + +-- This file has been generated from package.yaml by hpack version 0.33.0. -- -- see: https://github.com/sol/hpack -- --- hash: 749159cfb0e485429b445319f97126579e191fed759cadc0903f884e813159a7 +-- hash: 7b7549a7b1a7d5ca5767941adb11f773af2e6727a37c8fdceb9367c0147b4d91 name: mergeless -version: 0.1.0.0 +version: 0.2.0.0 description: Please see the README on GitHub at <https://github.com/NorfairKing/mergeless#readme> homepage: https://github.com/NorfairKing/mergeless#readme bug-reports: https://github.com/NorfairKing/mergeless/issues @@ -14,10 +16,9 @@ maintainer: syd.kerckhove@gmail.com copyright: Copyright: (c) 2018 Tom Sydney Kerckhove license: MIT build-type: Simple -cabal-version: >= 1.10 extra-source-files: - ChangeLog.md README.md + ChangeLog.md source-repository head type: git @@ -26,6 +27,8 @@ source-repository head library exposed-modules: Data.Mergeless + Data.Mergeless.Collection + Data.Mergeless.Item other-modules: Paths_mergeless hs-source-dirs: @@ -34,9 +37,8 @@ library aeson , base >=4.7 && <5 , containers + , deepseq , mtl - , time , validity , validity-containers - , validity-time default-language: Haskell2010 diff --git a/src/Data/Mergeless.hs b/src/Data/Mergeless.hs index 53f6763..2f3ae3b 100644 --- a/src/Data/Mergeless.hs +++ b/src/Data/Mergeless.hs @@ -1,500 +1,5 @@ -{-# LANGUAGE DeriveGeneric #-} -{-# LANGUAGE FlexibleContexts #-} -{-# LANGUAGE GeneralizedNewtypeDeriving #-} -{-# LANGUAGE LambdaCase #-} -{-# 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 'processSync'. --- * 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 - ( Added(..) - , Synced(..) - , StoreItem(..) - , Store(..) - , emptyStore - , storeSize - , addItemToStore - , deleteUnsynced - , deleteSynced - , SyncRequest(..) - , SyncResponse(..) - -- * Client-side Synchronisation - , makeSyncRequest - , mergeSyncResponse - -- * Server-side Synchronisation - -- ** General synchronisation - , SyncProcessor(..) - , processSyncCustom - -- ** Synchronisation with a simple central store - , CentralStore(..) - , CentralItem(..) - , processSyncWith - , processSync - ) where - -import Control.Applicative -import Control.Monad.IO.Class -import Control.Monad.State.Strict -import Data.Aeson -import Data.List -import Data.Map.Strict (Map) -import qualified Data.Map.Strict as M -import Data.Maybe -import qualified Data.Set as S -import Data.Set (Set) -import Data.Time -import Data.Validity -import Data.Validity.Containers () -import Data.Validity.Time () -import GHC.Generics (Generic) - -{-# ANN module ("HLint: ignore Use lambda-case" :: String) #-} - --- | A local item of type @a@ that has been added but not synchronised yet -data Added a = Added - { addedValue :: !a - , addedCreated :: !UTCTime - } deriving (Show, Eq, Ord, Generic) - -instance Validity a => Validity (Added a) - -instance FromJSON a => FromJSON (Added a) where - parseJSON = - withObject "Added" $ \o -> Added <$> o .: "value" <*> o .: "added" - -instance ToJSON a => ToJSON (Added a) where - toJSON Added {..} = object ["value" .= addedValue, "added" .= addedCreated] - --- | A local item of type @a@ with an identifier of type @a@ that has been synchronised -data Synced i a = Synced - { syncedUuid :: i - , syncedValue :: !a - , syncedCreated :: !UTCTime - , syncedSynced :: !UTCTime - } deriving (Show, Eq, Ord, Generic) - -instance (Validity i, Validity a) => Validity (Synced i a) - -instance (FromJSON i, FromJSON a) => FromJSON (Synced i a) where - parseJSON = - withObject "Synced" $ \o -> - Synced <$> o .: "id" <*> o .: "value" <*> o .: "created" <*> - o .: "synced" - -instance (ToJSON i, ToJSON a) => ToJSON (Synced i a) where - toJSON Synced {..} = - object - [ "id" .= syncedUuid - , "value" .= syncedValue - , "created" .= syncedCreated - , "synced" .= syncedSynced - ] - --- | A store item with an Id of type @i@ and a value of type @a@ -data StoreItem i a - = UnsyncedItem !(Added a) -- ^ A local item that has not been synchronised to the central store yet - | SyncedItem !(Synced i a) -- ^ A local item that has been synchronised to the central store already - | UndeletedItem !i -- ^ An item that has been synchronised to the central store, was subsequently deleted locally but this deletion has not been synchronised to the central store yet. - deriving (Show, Eq, Ord, Generic) - -instance (Validity i, Validity a) => Validity (StoreItem i a) - -instance (FromJSON i, FromJSON a) => FromJSON (StoreItem i a) where - parseJSON v = - (SyncedItem <$> parseJSON v) <|> (UnsyncedItem <$> parseJSON v) <|> - (UndeletedItem <$> parseJSON v) - -instance (ToJSON i, ToJSON a) => ToJSON (StoreItem i a) where - toJSON (UnsyncedItem a) = toJSON a - toJSON (SyncedItem a) = toJSON a - toJSON (UndeletedItem a) = toJSON a - --- | A client-side store of items with Id's of type @i@ and values of type @a@ -newtype Store i a = Store - { storeItems :: Set (StoreItem i a) - } deriving (Show, Eq, Ord, Generic, FromJSON, ToJSON) - -instance (Validity i, Validity a, Ord i, Ord a) => Validity (Store i a) where - validate Store {..} = - mconcat - [ annotate storeItems "storeItems" - , declare "the store items have distinct uuids" $ - distinct $ - flip mapMaybe (S.toList storeItems) $ \case - UnsyncedItem _ -> Nothing - SyncedItem Synced {..} -> Just syncedUuid - UndeletedItem u -> Just u - ] - --- | The store with no items. -emptyStore :: Store i a -emptyStore = Store S.empty - --- | The number of items in a store -storeSize :: Store i a -> Int -storeSize (Store s) = - S.size $ - S.filter - (\si -> - case si of - UndeletedItem _ -> False - _ -> True) - s - --- | Add a new (unsynced) item to the store -addItemToStore :: (Ord i, Ord a) => Added a -> Store i a -> Store i a -addItemToStore a (Store s) = Store $ S.insert (UnsyncedItem a) s - -deleteUnsynced :: (Ord i, Ord a) => Added a -> Store i a -> Store i a -deleteUnsynced a (Store s) = - Store $ - flip mapSetMaybe s $ \si -> - case si of - UnsyncedItem i -> - if i == a - then Nothing - else Just si - SyncedItem _ -> Just si - UndeletedItem _ -> Just si - -deleteSynced :: (Ord i, Ord a) => Synced i a -> Store i a -> Store i a -deleteSynced a (Store s) = - Store $ - flip mapSetMaybe s $ \si -> - case si of - UnsyncedItem _ -> Just si - SyncedItem i -> - if i == a - then Just $ UndeletedItem $ syncedUuid i - else Just si - UndeletedItem _ -> Just si - --- | A synchronisation request for items with identifiers of type @i@ and values of type @a@ -data SyncRequest i a = SyncRequest - { syncRequestAddedItems :: !(Set (Added a)) - , syncRequestSyncedItems :: !(Set i) - , syncRequestUndeletedItems :: !(Set i) - } deriving (Show, Eq, Ord, Generic) - -instance (Validity i, Validity a, Ord i, Ord a) => - Validity (SyncRequest i a) where - validate SyncRequest {..} = - mconcat - [ annotate syncRequestAddedItems "syncRequestAddedItems" - , annotate syncRequestSyncedItems "syncRequestSyncedItems" - , annotate syncRequestUndeletedItems "syncRequestUndeletedItems" - , declare "the sync request items have distinct ids" $ - distinct $ - S.toList syncRequestSyncedItems ++ - S.toList syncRequestUndeletedItems - ] - -instance (FromJSON i, FromJSON a, Ord i, Ord a) => - FromJSON (SyncRequest i a) where - parseJSON = - withObject "SyncRequest" $ \o -> - SyncRequest <$> o .: "unsynced" <*> o .: "synced" <*> - o .: "undeleted" - -instance (ToJSON i, ToJSON a) => ToJSON (SyncRequest i a) where - toJSON SyncRequest {..} = - object - [ "unsynced" .= syncRequestAddedItems - , "synced" .= syncRequestSyncedItems - , "undeleted" .= syncRequestUndeletedItems - ] - --- | A synchronisation response for items with identifiers of type @i@ and values of type @a@ -data SyncResponse i a = SyncResponse - { syncResponseAddedItems :: !(Set (Synced i a)) - , syncResponseNewRemoteItems :: !(Set (Synced i a)) - , syncResponseItemsToBeDeletedLocally :: !(Set i) - } deriving (Show, Eq, Ord, Generic) - -instance (Validity i, Validity a, Ord i, Ord a) => - Validity (SyncResponse i a) where - validate SyncResponse {..} = - mconcat - [ annotate syncResponseAddedItems "syncResponseAddedItems" - , annotate syncResponseNewRemoteItems "syncResponseNewRemoteItems" - , annotate - syncResponseItemsToBeDeletedLocally - "syncResponseItemsToBeDeletedLocally" - , declare "the sync response items have distinct uuids" $ - distinct $ - map - syncedUuid - (S.toList syncResponseAddedItems ++ - S.toList syncResponseNewRemoteItems) ++ - S.toList syncResponseItemsToBeDeletedLocally - ] - -instance (FromJSON i, FromJSON a, Ord i, Ord a) => - FromJSON (SyncResponse i a) where - parseJSON = - withObject "SyncResponse" $ \o -> - SyncResponse <$> o .: "added" <*> o .: "new" <*> o .: "deleted" - -instance (ToJSON i, ToJSON a) => ToJSON (SyncResponse i a) where - toJSON SyncResponse {..} = - object - [ "added" .= syncResponseAddedItems - , "new" .= syncResponseNewRemoteItems - , "deleted" .= syncResponseItemsToBeDeletedLocally - ] - --- | 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) => Store i a -> SyncRequest i a -makeSyncRequest Store {..} = - SyncRequest - { syncRequestAddedItems = - flip mapSetMaybe storeItems $ \case - UnsyncedItem a -> Just a - _ -> Nothing - , syncRequestSyncedItems = - flip mapSetMaybe storeItems $ \case - SyncedItem i -> Just $ syncedUuid i - _ -> Nothing - , syncRequestUndeletedItems = - flip mapSetMaybe storeItems $ \case - UndeletedItem uuid -> Just uuid - _ -> Nothing - } - --- | Merge a synchronisation response back into a client-side store. -mergeSyncResponse :: - (Ord i, Ord a) => Store i a -> SyncResponse i a -> Store i a -mergeSyncResponse s SyncResponse {..} = - let withNewOwnItems = - flip mapSetMaybe (storeItems s) $ \si -> - case si of - UnsyncedItem Added {..} -> - case find - (\Synced {..} -> - syncedCreated == addedCreated && - syncedValue == addedValue) - syncResponseAddedItems of - Nothing -> Just si -- If it wasn't added (for whatever reason), just leave it as unsynced - Just ii -> Just $ SyncedItem ii -- If it was added, then it becomes synced - SyncedItem ii -> - case find - (== syncedUuid ii) - syncResponseItemsToBeDeletedLocally of - Nothing -> Just si -- If it wasn't deleted, don't delete it. - Just _ -> Nothing -- If it was deleted, delete it here. - UndeletedItem _ -> Nothing -- Delete all locally deleted items after sync - in Store - { storeItems = - S.fromList . - nubBy - (\i1 i2 -> - case (i1, i2) of - (UnsyncedItem _, _) -> False - (_, UnsyncedItem _) -> False - (SyncedItem s1, SyncedItem s2) -> - syncedUuid s1 == syncedUuid s2 - (SyncedItem s1, UndeletedItem u2) -> - syncedUuid s1 == u2 - (UndeletedItem u1, SyncedItem s2) -> - u1 == syncedUuid s2 - (UndeletedItem u1, UndeletedItem u2) -> u1 == u2) . - S.toList $ - S.map SyncedItem syncResponseNewRemoteItems `S.union` - withNewOwnItems - } - --- | A record of the basic operations that are necessary to build a synchronisation processor. -data SyncProcessor i a m = SyncProcessor - { syncProcessorDeleteMany :: Set i -> m () -- ^ Delete the items with an identifier in the given set. - , syncProcessorQuerySynced :: Set i -> m (Set i) -- ^ Query the identifiers of the items that are in store, of the given set. - , syncProcessorQueryNewRemote :: Set i -> m (Set (Synced i a)) -- ^ Query the items that are in store, but not in the given set. - , syncProcessorInsertMany :: Set (Synced i a) -> m () -- ^ Insert a set of items into the stor. - } 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. -processSyncCustom :: - forall i a m. (Ord i, Ord a, Monad m) - => m i - -> UTCTime - -> SyncProcessor i a m - -> SyncRequest i a - -> m (SyncResponse i a) -processSyncCustom genUuid now SyncProcessor {..} SyncRequest {..} = do - 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 - { syncResponseNewRemoteItems = newRemoteItems - , syncResponseAddedItems = newLocalItems - , syncResponseItemsToBeDeletedLocally = deletedRemotely - } - where - deleteUndeleted :: m () - deleteUndeleted = syncProcessorDeleteMany syncRequestUndeletedItems - syncItemsToBeDeletedLocally :: m (Set i) - syncItemsToBeDeletedLocally = do - foundItems <- syncProcessorQuerySynced syncRequestSyncedItems - pure $ syncRequestSyncedItems `S.difference` foundItems - syncNewRemoteItems :: m (Set (Synced i a)) - syncNewRemoteItems = syncProcessorQueryNewRemote syncRequestSyncedItems - syncAddedItems :: m (Set (Synced i a)) - syncAddedItems = do - is <- - fmap S.fromList $ - forM (S.toList syncRequestAddedItems) $ \Added {..} -> do - uuid <- genUuid - pure - Synced - { syncedUuid = uuid - , syncedCreated = addedCreated - , syncedSynced = now - , syncedValue = addedValue - } - syncProcessorInsertMany is - pure is - --- | An item in a central store with a value of type @a@ -data CentralItem a = CentralItem - { centralValue :: !a - , centralSynced :: !UTCTime - , centralCreated :: !UTCTime - } deriving (Show, Eq, Ord, Generic) - -instance Validity a => Validity (CentralItem a) - -instance FromJSON a => FromJSON (CentralItem a) - -instance ToJSON a => ToJSON (CentralItem a) - --- | A central store of items with identifiers of type @i@ and values of type @a@ -newtype CentralStore i a = CentralStore - { centralStoreItems :: Map i (CentralItem a) - } deriving (Show, Eq, Ord, Generic, FromJSON, ToJSON) - -instance (Validity i, Validity a, Ord i, Ord a) => - Validity (CentralStore i a) - --- | Process a server-side synchronisation request using @getCurrentTime@ --- --- see 'processSyncCustom' -processSync :: - (Ord i, Ord a, MonadIO m) - => m i - -> CentralStore i a - -> SyncRequest i a - -> m (SyncResponse i a, CentralStore i a) -processSync genId cs sr = do - now <- liftIO getCurrentTime - processSyncWith genId now cs sr - --- | Process a server-side synchronisation request using a time of syncing, and an identifier generation function. --- --- see 'processSyncCustom' -processSyncWith :: - forall i a m. (Ord i, Ord a, Monad m) - => m i - -> UTCTime - -> CentralStore i a - -> SyncRequest i a - -> m (SyncResponse i a, CentralStore i a) -processSyncWith genUuid now cs sr = - flip runStateT cs $ - processSyncCustom - (lift genUuid) - now - SyncProcessor - { syncProcessorDeleteMany = deleteMany - , syncProcessorQuerySynced = querySynced - , syncProcessorQueryNewRemote = queryNewRemote - , syncProcessorInsertMany = insertMany - } - sr - where - deleteMany :: Set i -> StateT (CentralStore i a) m () - deleteMany s = modC (`M.withoutKeys` s) - querySynced :: Set i -> StateT (CentralStore i a) m (Set i) - querySynced s = M.keysSet <$> query (`M.restrictKeys` s) - queryNewRemote :: Set i -> StateT (CentralStore i a) m (Set (Synced i a)) - queryNewRemote s = do - m <- query (`M.withoutKeys` s) - pure $ - S.fromList $ - flip map (M.toList m) $ \(i, CentralItem {..}) -> - Synced - { syncedUuid = i - , syncedCreated = centralCreated - , syncedSynced = centralSynced - , syncedValue = centralValue - } - query :: (Map i (CentralItem a) -> b) -> StateT (CentralStore i a) m b - query func = gets $ func . centralStoreItems - insertMany :: Set (Synced i a) -> StateT (CentralStore i a) m () - insertMany s = - forM_ (S.toList s) $ \Synced {..} -> - ins - syncedUuid - CentralItem - { centralValue = syncedValue - , centralCreated = syncedCreated - , centralSynced = syncedSynced - } - ins :: i -> CentralItem a -> StateT (CentralStore i a) m () - ins i val = modC $ M.insert i val - modC :: - (Map i (CentralItem a) -> Map i (CentralItem a)) - -> StateT (CentralStore i a) m () - modC func = modify (\(CentralStore m) -> CentralStore $ func m) - -mapSetMaybe :: Ord b => (a -> Maybe b) -> Set a -> Set b -mapSetMaybe func = S.map fromJust . S.filter isJust . S.map func + ( module Data.Mergeless.Collection + ) where -distinct :: Ord a => [a] -> Bool -distinct ls = sort ls == S.toAscList (S.fromList ls) +import Data.Mergeless.Collection diff --git a/src/Data/Mergeless/Collection.hs b/src/Data/Mergeless/Collection.hs new file mode 100644 index 0000000..7937eaf --- /dev/null +++ b/src/Data/Mergeless/Collection.hs @@ -0,0 +1,435 @@ +{-# 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) diff --git a/src/Data/Mergeless/Item.hs b/src/Data/Mergeless/Item.hs new file mode 100644 index 0000000..bda97c0 --- /dev/null +++ b/src/Data/Mergeless/Item.hs @@ -0,0 +1,148 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Data.Mergeless.Item where + +import Control.DeepSeq +import Data.Aeson +import Data.Validity +import Data.Validity.Containers () +import GHC.Generics (Generic) + +{-# ANN module ("HLint: ignore Use lambda-case" :: String) #-} + +data ClientItem a + = ClientEmpty + | ClientAdded !a + | ClientSynced !a + | ClientDeleted + deriving (Show, Eq, Ord, Generic) + +instance Validity a => Validity (ClientItem a) + +instance NFData a => NFData (ClientItem a) + +instance FromJSON a => FromJSON (ClientItem a) + +instance ToJSON a => ToJSON (ClientItem a) + +-- | A synchronisation request for items with identifiers of type @i@ and values of type @a@ +data ItemSyncRequest a + = ItemSyncRequestPoll + | ItemSyncRequestNew a + | ItemSyncRequestKnown + | ItemSyncRequestDeleted + deriving (Show, Eq, Ord, Generic) + +instance Validity a => Validity (ItemSyncRequest a) + +instance NFData a => NFData (ItemSyncRequest a) + +instance FromJSON a => FromJSON (ItemSyncRequest a) + +instance ToJSON a => ToJSON (ItemSyncRequest a) + +makeItemSyncRequest :: ClientItem a -> ItemSyncRequest a +makeItemSyncRequest ci = + case ci of + ClientEmpty -> ItemSyncRequestPoll + ClientAdded a -> ItemSyncRequestNew a + ClientSynced _ -> ItemSyncRequestKnown + ClientDeleted -> ItemSyncRequestDeleted + +-- | A synchronisation response for items with identifiers of type @i@ and values of type @a@ +data ItemSyncResponse a + = ItemSyncResponseInSyncEmpty + | ItemSyncResponseInSyncFull + | ItemSyncResponseClientAdded + | ItemSyncResponseClientDeleted + | ItemSyncResponseServerAdded !a + | ItemSyncResponseServerDeleted + deriving (Show, Eq, Ord, Generic) + +instance Validity a => Validity (ItemSyncResponse a) + +instance NFData a => NFData (ItemSyncResponse a) + +instance FromJSON a => FromJSON (ItemSyncResponse a) + +instance ToJSON a => ToJSON (ItemSyncResponse a) + +-- | Merge a synchronisation response back into a client-side store. +mergeItemSyncResponse :: ClientItem a -> ItemSyncResponse a -> ClientItem a +mergeItemSyncResponse ci sr = + let mismatch = ci + in case ci of + ClientEmpty -> + case sr of + ItemSyncResponseInSyncEmpty -> ClientEmpty + ItemSyncResponseServerAdded s -> ClientSynced s + _ -> mismatch + ClientAdded a -> + case sr of + ItemSyncResponseClientAdded -> ClientSynced a + ItemSyncResponseServerAdded s -> ClientSynced s + -- For completeness sake. + -- This can only happen if two clients make the item at the same time. + -- In practice, with named items in a collection, this will never happen. + _ -> mismatch + ClientSynced _ -> + case sr of + ItemSyncResponseInSyncFull -> ci -- No change + ItemSyncResponseServerDeleted -> ClientEmpty + _ -> mismatch + ClientDeleted -> + case sr of + ItemSyncResponseClientDeleted -> ClientEmpty + _ -> mismatch + +-- | An item in a central store with a value of type @a@ +data ServerItem a + = ServerItemEmpty + | ServerItemFull !a + deriving (Show, Eq, Ord, Generic) + +instance Validity a => Validity (ServerItem a) + +instance NFData a => NFData (ServerItem a) + +instance FromJSON a => FromJSON (ServerItem a) + +instance ToJSON a => ToJSON (ServerItem a) + +processServerItemSync :: ServerItem a -> ItemSyncRequest a -> (ItemSyncResponse a, ServerItem a) +processServerItemSync si sr = + case si of + ServerItemEmpty -> + case sr of + ItemSyncRequestPoll + -- Both the client and the server think the item is empty, fine. + -> (ItemSyncResponseInSyncEmpty, si) + ItemSyncRequestNew a + -- The client has a new item and the server has space for it, add it. + -> (ItemSyncResponseClientAdded, ServerItemFull a) + ItemSyncRequestKnown + -- The client has an item that the server doesn't, so the server must have + -- deleted it when another client asked to do that. + -- Leave it deleted. + -> (ItemSyncResponseServerDeleted, si) + ItemSyncRequestDeleted + -- The server has deleted an item but the current client hasn't been made aware of that + -- AND this server also deleted that item in the meantime. + -- Just leave it deleted. + -> (ItemSyncResponseClientDeleted, si) + ServerItemFull s -> + case sr of + ItemSyncRequestPoll + -- The server has an item that the client doesn't, send it to the client. + -> (ItemSyncResponseServerAdded s, si) + ItemSyncRequestNew _ + -- The client wants to add an item that the server already has. + -- That means that another client has added that same item in the meantime. + -- This wouldn't happen if the items were named. + -- In this case, for completeness sake, + -> (ItemSyncResponseServerAdded s, si) + ItemSyncRequestKnown -> (ItemSyncResponseInSyncFull, si) + ItemSyncRequestDeleted -> (ItemSyncResponseClientDeleted, ServerItemEmpty) |