packages feed

mergeful (empty) → 0.0.0.0

raw patch · 9 files changed

+1694/−0 lines, 9 filesdep +aesondep +basedep +containerssetup-changed

Dependencies added: aeson, base, containers, mtl, text, time, validity, validity-containers, validity-time

Files

+ ChangeLog.md view
@@ -0,0 +1,3 @@+# Changelog for mergeful++## Unreleased changes
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2018-2019 Tom Sydney Kerckhove++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,1 @@+# mergeful
+ Setup.hs view
@@ -0,0 +1,3 @@+import Distribution.Simple++main = defaultMain
+ mergeful.cabal view
@@ -0,0 +1,48 @@+cabal-version: 1.12++-- This file has been generated from package.yaml by hpack version 0.31.1.+--+-- see: https://github.com/sol/hpack+--+-- hash: 1779bd15e9a78b9227b2f291e7c301be723b2ae4bb160e6f540c68cf51852fac++name:           mergeful+version:        0.0.0.0+description:    Please see the README on GitHub at <https://github.com/NorfairKing/mergeful#readme>+homepage:       https://github.com/NorfairKing/mergeful#readme+bug-reports:    https://github.com/NorfairKing/mergeful/issues+author:         Tom Sydney Kerckhove+maintainer:     syd@cs-syd.eu+copyright:      Copyright: (c) 2019 Tom Sydney Kerckhove+license:        MIT+license-file:   LICENSE+build-type:     Simple+extra-source-files:+    README.md+    ChangeLog.md++source-repository head+  type: git+  location: https://github.com/NorfairKing/mergeful++library+  exposed-modules:+      Data.Mergeful+      Data.Mergeful.Item+      Data.Mergeful.Timed+      Data.Mergeful.Value+  other-modules:+      Paths_mergeful+  hs-source-dirs:+      src+  build-depends:+      aeson+    , base >=4.7 && <5+    , containers+    , mtl+    , text+    , time+    , validity+    , validity-containers+    , validity-time+  default-language: Haskell2010
+ src/Data/Mergeful.hs view
@@ -0,0 +1,707 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# 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+  ( initialClientStore+  , 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, 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+    }++-- | 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 u a cs =+  case M.lookup u (clientStoreSyncedItems cs) of+    Just _ ->+      cs+        { clientStoreSyncedItems = M.delete u (clientStoreSyncedItems cs)+        , clientStoreSyncedButChangedItems =+            M.adjust (\t -> t {timedValue = a}) u (clientStoreSyncedButChangedItems cs)+        }+    Nothing ->+      case M.lookup u (clientStoreSyncedButChangedItems cs) of+        Nothing -> cs+        Just _ ->+          cs+            { clientStoreSyncedButChangedItems =+                M.adjust (\t -> t {timedValue = a}) u (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, 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, 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, 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+          (\si ->+             case si of+               ServerEmpty -> Nothing+               ServerFull t -> Just t) $+        M.map snd $+        M.mapKeys+          (\cid ->+             case cid of+               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')
+ src/Data/Mergeful/Item.hs view
@@ -0,0 +1,515 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++-- | A way to synchronise an item with safe merge conflicts.+--+-- The item is "zero or one" value.+-- One could say that @Item a = Maybe a@ but there are so such types here.+-- This methaphor just serves as explanation+--+--+-- 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 clients starts with an 'initialClientItem'.+--+-- * The client produces a 'ItemSyncRequest' with 'makeItemSyncRequest'.+-- * The client sends that request to the central server and gets a 'ItemSyncResponse'.+-- * The client then updates its local store with 'mergeItemSyncResponseRaw' or 'mergeItemSyncResponseIgnoreProblems'.+--+--+-- = The central server should operate as follows:+--+-- The server starts with an 'initialServerItem'.+--+-- * The server accepts a 'ItemSyncRequest'.+-- * The server performs operations according to the functionality of 'processServerItemSync'.+-- * The server respons with a 'ItemSyncResponse'.+--+--+--+-- 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.Item+  ( initialClientItem+  , initialItemSyncRequest+  , makeItemSyncRequest+  , mergeItemSyncResponseRaw+  , ItemMergeResult(..)+  , mergeItemSyncResponseIgnoreProblems+  , mergeIgnoringProblems+  , mergeFromServer+  , ItemMergeStrategy(..)+  , mergeUsingStrategy+    -- * Server side+  , initialServerItem+  , processServerItemSync+    -- * Types, for reference+  , ClientItem(..)+  , ItemSyncRequest(..)+  , ItemSyncResponse(..)+  , ServerItem(..)+  ) where++import GHC.Generics (Generic)++import Data.Aeson as JSON+import Data.Validity++import Control.Applicative++import Data.Mergeful.Timed++data ClientItem a+  -- | There is no item on the client side+  = ClientEmpty+  -- | There is is an item but the server is not aware of it yet.+  | ClientAdded !a+  -- | There is is an item and it has been synced with the server.+  | ClientItemSynced !(Timed a)+  -- | There is is an item and it has been synced with the server, but it has since been modified.+  | ClientItemSyncedButChanged !(Timed a)+  -- | There was an item, and it has been deleted locally, but the server has not been made aware of this.+  | ClientDeleted !ServerTime+  deriving (Show, Eq, Generic)++instance Validity a => Validity (ClientItem a)++instance FromJSON a => FromJSON (ClientItem a) where+  parseJSON =+    withObject "ClientItem" $ \o -> do+      typ <- o .: "type"+      case typ :: String of+        "empty" -> pure ClientEmpty+        "added" -> ClientAdded <$> o .: "value"+        "synced" -> ClientItemSynced <$> (Timed <$> o .: "value" <*> o .: "time")+        "changed" -> ClientItemSyncedButChanged <$> (Timed <$> o .: "value" <*> o .: "time")+        "deleted" -> ClientDeleted <$> o .: "time"+        _ -> fail "unknown item type"++instance ToJSON a => ToJSON (ClientItem a) where+  toJSON ci =+    object $+    case ci of+      ClientEmpty -> ["type" .= ("empty" :: String)]+      ClientAdded a -> ["type" .= ("added" :: String), "value" .= a]+      ClientItemSynced Timed {..} ->+        ["type" .= ("synced" :: String), "value" .= timedValue, "time" .= timedTime]+      ClientItemSyncedButChanged Timed {..} ->+        ["type" .= ("changed" :: String), "value" .= timedValue, "time" .= timedTime]+      ClientDeleted t -> ["type" .= ("deleted" :: String), "time" .= t]++-- | A client item to start with.+--+-- It contains no value.+initialClientItem :: ClientItem a+initialClientItem = ClientEmpty++data ServerItem a+  -- | There is no item on the server side+  = ServerEmpty+  -- | There is an item on the server side, and it was last synced at the given 'ServerTime'.+  | ServerFull !(Timed a)+  deriving (Show, Eq, Generic)++instance Validity a => Validity (ServerItem a)++instance FromJSON a => FromJSON (ServerItem a) where+  parseJSON =+    withObject "ServerItem" $ \o ->+      ServerFull <$> (Timed <$> o .: "value" <*> o .: "time") <|> pure ServerEmpty++instance ToJSON a => ToJSON (ServerItem a) where+  toJSON si =+    object $+    case si of+      ServerEmpty -> []+      ServerFull Timed {..} -> ["value" .= timedValue, "time" .= timedTime]++-- | A server item to start with.+--+-- It contains no value.+initialServerItem :: ServerItem a+initialServerItem = ServerEmpty++data ItemSyncRequest a+  -- | There is no item locally+  = ItemSyncRequestPoll+  -- | There is an item locally that has not been synced to the server yet.+  | ItemSyncRequestNew !a+  -- | There is an item locally that was synced at the given 'ServerTime'+  | ItemSyncRequestKnown !ServerTime+  -- | There is an item locally that was synced at the given 'ServerTime'+  -- but it has been changed since then.+  | ItemSyncRequestKnownButChanged !(Timed a)+  -- | There was an item locally that has been deleted but the+  -- deletion wasn't synced to the server yet.+  | ItemSyncRequestDeletedLocally !ServerTime+  deriving (Show, Eq, Generic)++instance Validity a => Validity (ItemSyncRequest a)++instance FromJSON a => FromJSON (ItemSyncRequest a) where+  parseJSON =+    withObject "ItemSyncRequest" $ \o -> do+      typ <- o .: "type"+      case typ :: String of+        "empty" -> pure ItemSyncRequestPoll+        "added" -> ItemSyncRequestNew <$> o .: "value"+        "synced" -> ItemSyncRequestKnown <$> o .: "time"+        "changed" -> ItemSyncRequestKnownButChanged <$> (Timed <$> o .: "value" <*> o .: "time")+        "deleted" -> ItemSyncRequestDeletedLocally <$> o .: "time"+        _ -> fail "unknown item type"++instance ToJSON a => ToJSON (ItemSyncRequest a) where+  toJSON ci =+    object $+    let o n rest = ("type" .= (n :: String)) : rest+        oe n = o n []+     in case ci of+          ItemSyncRequestPoll -> oe "empty"+          ItemSyncRequestNew a -> o "added" ["value" .= a]+          ItemSyncRequestKnown t -> o "synced" ["time" .= t]+          ItemSyncRequestKnownButChanged Timed {..} ->+            o "changed" ["value" .= timedValue, "time" .= timedTime]+          ItemSyncRequestDeletedLocally t -> o "deleted" ["time" .= t]++-- | An intial 'ItemSyncRequest' to start with.+--+-- It just asks the server to send over whatever it knows.+initialItemSyncRequest :: ItemSyncRequest a+initialItemSyncRequest = ItemSyncRequestPoll++data ItemSyncResponse a+  -- | The client and server are fully in sync, and both empty+  --+  -- Nothing needs to be done at the client side.+  = ItemSyncResponseInSyncEmpty+  -- | The client and server are fully in sync.+  --+  -- Nothing needs to be done at the client side.+  | ItemSyncResponseInSyncFull+  -- | The client added an item and server has succesfully been made aware of that.+  --+  -- The client needs to update its server time+  | ItemSyncResponseClientAdded !ServerTime+  -- | The client changed an item and server has succesfully been made aware of that.+  --+  -- The client needs to update its server time+  | ItemSyncResponseClientChanged !ServerTime+  -- | The client deleted an item and server has succesfully been made aware of that.+  --+  -- The client can delete it from its deleted items+  | ItemSyncResponseClientDeleted+  -- | This item has been added on the server side+  --+  -- The client should add it too.+  | ItemSyncResponseServerAdded !(Timed a)+  -- | This item has been modified on the server side.+  --+  -- The client should modify it too.+  | ItemSyncResponseServerChanged !(Timed a)+  -- | The item was deleted on the server side+  --+  -- The client should delete it too.+  | ItemSyncResponseServerDeleted+  -- | A conflict occurred.+  --+  -- The server and the client both have an item, but it is different.+  -- The server kept its part, the client can either take whatever the server gave them+  -- or deal with the conflict somehow, and then try to re-sync.+  | ItemSyncResponseConflict !(Timed a) -- ^ The item at the server side+  -- | A conflict occurred.+  --+  -- The server has an item but the client does not.+  -- The server kept its part, the client can either take whatever the server gave them+  -- or deal with the conflict somehow, and then try to re-sync.+  | ItemSyncResponseConflictClientDeleted !(Timed a) -- ^ The item at the server side+  -- | A conflict occurred.+  --+  -- The client has a (modified) item but the server does not have any item.+  -- The server left its item deleted, the client can either delete its item too+  -- or deal with the conflict somehow, and then try to re-sync.+  | ItemSyncResponseConflictServerDeleted+  deriving (Show, Eq, Generic)++instance Validity a => Validity (ItemSyncResponse a)++instance FromJSON a => FromJSON (ItemSyncResponse a) where+  parseJSON =+    withObject "ItemSyncResponse" $ \o -> do+      typ <- o .: "type"+      case typ :: String of+        "in-sync-empty" -> pure ItemSyncResponseInSyncEmpty+        "in-sync-full" -> pure ItemSyncResponseInSyncFull+        "client-added" -> ItemSyncResponseClientAdded <$> o .: "time"+        "client-changed" -> ItemSyncResponseClientChanged <$> o .: "time"+        "client-deleted" -> pure ItemSyncResponseClientDeleted+        "server-added" -> ItemSyncResponseServerAdded <$> (Timed <$> o .: "value" <*> o .: "time")+        "server-changed" ->+          ItemSyncResponseServerChanged <$> (Timed <$> o .: "value" <*> o .: "time")+        "server-deleted" -> pure ItemSyncResponseServerDeleted+        "conflict" -> ItemSyncResponseConflict <$> o .: "value"+        "conflict-client-deleted" -> ItemSyncResponseConflictClientDeleted <$> o .: "value"+        "conflict-server-deleted" -> pure ItemSyncResponseConflictServerDeleted+        _ -> fail "unknown type"++instance ToJSON a => ToJSON (ItemSyncResponse a) where+  toJSON isr =+    object $+    let o s rest = ("type" .= (s :: String)) : rest+        oe s = o s []+     in case isr of+          ItemSyncResponseInSyncEmpty -> oe "in-sync-empty"+          ItemSyncResponseInSyncFull -> oe "in-sync-full"+          ItemSyncResponseClientAdded t -> o "client-added" ["time" .= t]+          ItemSyncResponseClientChanged t -> o "client-changed" ["time" .= t]+          ItemSyncResponseClientDeleted -> oe "client-deleted"+          ItemSyncResponseServerAdded Timed {..} ->+            o "server-added" ["value" .= timedValue, "time" .= timedTime]+          ItemSyncResponseServerChanged Timed {..} ->+            o "server-changed" ["value" .= timedValue, "time" .= timedTime]+          ItemSyncResponseServerDeleted -> oe "server-deleted"+          ItemSyncResponseConflict a -> o "conflict" ["value" .= a]+          ItemSyncResponseConflictClientDeleted a -> o "conflict-client-deleted" ["value" .= a]+          ItemSyncResponseConflictServerDeleted -> oe "conflict-server-deleted"++-- | Produce an 'ItemSyncRequest' from a 'ClientItem'.+--+-- Send this to the server for synchronisation.+makeItemSyncRequest :: ClientItem a -> ItemSyncRequest a+makeItemSyncRequest cs =+  case cs of+    ClientEmpty -> ItemSyncRequestPoll+    ClientAdded i -> ItemSyncRequestNew i+    ClientItemSynced t -> ItemSyncRequestKnown (timedTime t)+    ClientItemSyncedButChanged t -> ItemSyncRequestKnownButChanged t+    ClientDeleted st -> ItemSyncRequestDeletedLocally st++data ItemMergeResult a+  -- | The merger went succesfully, no conflicts or desyncs+  = MergeSuccess !(ClientItem a)+  -- | There was a merge conflict. The server and client had different, conflicting versions.+  | MergeConflict !a !(Timed a) -- ^ The item at the server side+  -- | There was a merge conflict. The client had deleted the item while the server had modified it.+  | MergeConflictClientDeleted !(Timed a) -- ^ The item at the server side+  -- | There was a merge conflict. The server had deleted the item while the client had modified it.+  | MergeConflictServerDeleted !a -- ^ The item at the client side+  -- | The server responded with a response that did not make sense given the client's request.+  | MergeMismatch+  deriving (Show, Eq, Generic)++instance Validity a => Validity (ItemMergeResult a)++-- | Merge an 'ItemSyncResponse' into the current 'ClientItem'.+--+-- This function will not make any decisions about what to do with+-- conflicts or mismatches between the request and the response.+-- It only produces a 'ItemMergeResult' so you can decide what to do with it.+mergeItemSyncResponseRaw :: ClientItem a -> ItemSyncResponse a -> ItemMergeResult a+mergeItemSyncResponseRaw cs sr =+  case cs of+    ClientEmpty ->+      case sr of+        ItemSyncResponseInSyncEmpty -> MergeSuccess cs+        ItemSyncResponseServerAdded t -> MergeSuccess $ ClientItemSynced t+        _ -> MergeMismatch+    ClientAdded ci ->+      case sr of+        ItemSyncResponseClientAdded st ->+          MergeSuccess $ ClientItemSynced $ Timed {timedValue = ci, timedTime = st}+        ItemSyncResponseConflict si -> MergeConflict ci si+        _ -> MergeMismatch+    ClientItemSynced t ->+      case sr of+        ItemSyncResponseInSyncFull -> MergeSuccess $ ClientItemSynced t+        ItemSyncResponseServerChanged st -> MergeSuccess $ ClientItemSynced st+        ItemSyncResponseServerDeleted -> MergeSuccess ClientEmpty+        _ -> MergeMismatch+    ClientItemSyncedButChanged ct ->+      case sr of+        ItemSyncResponseClientChanged st -> MergeSuccess $ ClientItemSynced $ ct {timedTime = st}+        ItemSyncResponseConflict si -> MergeConflict (timedValue ct) si+        ItemSyncResponseConflictServerDeleted -> MergeConflictServerDeleted (timedValue ct)+        _ -> MergeMismatch+    ClientDeleted _ ->+      case sr of+        ItemSyncResponseClientDeleted -> MergeSuccess ClientEmpty+        ItemSyncResponseConflictClientDeleted si -> MergeConflictClientDeleted si+        _ -> MergeMismatch++-- | Merge an 'ItemSyncResponse' into the current 'ClientItem'.+--+-- 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.+--+-- > mergeItemSyncResponseIgnoreProblems cs = mergeIgnoringProblems cs . mergeItemSyncResponseRaw cs+mergeItemSyncResponseIgnoreProblems :: ClientItem a -> ItemSyncResponse a -> ClientItem a+mergeItemSyncResponseIgnoreProblems cs = mergeIgnoringProblems cs . mergeItemSyncResponseRaw cs++-- | A strategy to merge conflicts for item synchronisation+data ItemMergeStrategy a =+  ItemMergeStrategy+    { itemMergeStrategyMergeChangeConflict :: a -> Timed a -> Timed a+      -- ^ How to merge modification conflicts+    , itemMergeStrategyMergeClientDeletedConflict :: Timed a -> Maybe (Timed a)+      -- ^ How to merge conflicts where the client deleted an item that the server modified+    , itemMergeStrategyMergeServerDeletedConflict :: a -> Maybe a+      -- ^ How to merge conflicts where the server deleted an item that the client modified+    }+  deriving (Generic)++-- | Ignore any merge problems in a 'ItemMergeResult'.+--+-- This function just returns the original 'ClientItem' if anything other than 'MergeSuccess' occurs.+--+-- 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: does not lose data+--+-- __Con: Clients will diverge when a conflict occurs__+mergeIgnoringProblems :: ClientItem a -> ItemMergeResult a -> ClientItem a+mergeIgnoringProblems cs mr =+  case mr of+    MergeSuccess cs' -> cs'+    MergeConflict _ _ -> cs+    MergeConflictServerDeleted _ -> cs+    MergeConflictClientDeleted _ -> cs+    MergeMismatch -> cs++-- | Resolve an 'ItemMergeResult' using a given merge strategy.+--+-- This function ignores 'MergeMismatch' and will just return the original 'ClientItem' in that case.+--+-- In order for clients to converge on the same item correctly, this function must be:+--+-- * Associative+-- * Idempotent+-- * The same on all clients+mergeUsingStrategy :: ItemMergeStrategy a -> ClientItem a -> ItemMergeResult a -> ClientItem a+mergeUsingStrategy ItemMergeStrategy {..} cs mr =+  case mr of+    MergeSuccess cs' -> cs'+    MergeConflict a1 a2 -> ClientItemSynced $ itemMergeStrategyMergeChangeConflict a1 a2+    MergeConflictClientDeleted sa ->+      case itemMergeStrategyMergeClientDeletedConflict sa of+        Nothing -> ClientEmpty+        Just t -> ClientItemSynced t+    MergeConflictServerDeleted ca ->+      case itemMergeStrategyMergeServerDeletedConflict ca of+        Nothing -> ClientEmpty+        Just a -> ClientAdded a+    MergeMismatch -> cs++-- | Resolve an 'ItemMergeResult' by taking whatever the server gave the client.+--+-- Pro: Clients will converge on the same value.+--+-- __Con: Conflicting updates will be lost.__+mergeFromServer :: ClientItem a -> ItemMergeResult a -> ClientItem a+mergeFromServer =+  mergeUsingStrategy+    ItemMergeStrategy+      { itemMergeStrategyMergeChangeConflict = \_ serverItem -> serverItem+      , itemMergeStrategyMergeClientDeletedConflict = \serverItem -> Just serverItem+      , itemMergeStrategyMergeServerDeletedConflict = \_ -> Nothing+      }++-- | Serve an 'ItemSyncRequest' using the current 'ServerItem', producing an 'ItemSyncResponse' and a new 'ServerItem'.+processServerItemSync :: ServerItem a -> ItemSyncRequest a -> (ItemSyncResponse a, ServerItem a)+processServerItemSync store sr =+  case store of+    ServerEmpty ->+      let t = initialServerTime+       in case sr of+            ItemSyncRequestPoll -> (ItemSyncResponseInSyncEmpty, store)+            ItemSyncRequestNew ci ->+              (ItemSyncResponseClientAdded t, ServerFull $ Timed {timedValue = ci, timedTime = t})+            ItemSyncRequestKnown _+             -- This indicates that the server synced with another client and was told to+             -- delete its item.+             --+             -- Given that the client indicates that it did not change anything locally,+             -- the server will just instruct the client to delete its item too.+             -- No conflict here.+             -> (ItemSyncResponseServerDeleted, store)+            ItemSyncRequestKnownButChanged _+             -- This indicates that the server synced with another client and was told to+             -- delete its item.+             --+             -- Given that the client indicates that it *did* change its item locally,+             -- there is a conflict.+             -> (ItemSyncResponseConflictServerDeleted, store)+            ItemSyncRequestDeletedLocally _+             -- This means that the server synced with another client,+             -- was instructed to delete its item by that client,+             -- and is now being told to delete its item again.+             --+             -- That's fine, it will just remain deleted.+             -- No conflict here+             -> (ItemSyncResponseClientDeleted, store)+    ServerFull t@(Timed si st) ->+      let st' = incrementServerTime st+       in case sr of+            ItemSyncRequestPoll+              -- The client is empty but the server is not.+              -- This means that the server has synced with another client before,+              -- so we can just send the item to the client.+             -> (ItemSyncResponseServerAdded (Timed {timedValue = si, timedTime = st}), store)+            ItemSyncRequestNew _+              -- The client has a newly added item, so it thought it was empty before that,+              -- but the server has already synced with another client before.+              -- This indicates a conflict.+              -- The server is always right, so the item at the server will remain unmodified.+              -- The client will receive the conflict.+             -> (ItemSyncResponseConflict t, store)+            ItemSyncRequestKnown ct ->+              if ct >= st+                -- The client time is equal to the server time.+                -- The client indicates that the item was not modified at their side.+                -- This means that the items are in sync.+                -- (Unless the server somehow modified the item but not its server time,+                -- which would beconsidered a bug.)+                then (ItemSyncResponseInSyncFull, store)+                -- The client time is less than the server time+                -- That means that the server has synced with another client in the meantime.+                -- Since the client indicates that the item was not modified at their side,+                -- we can just send it back to the client to have them update their version.+                -- No conflict here.+                else ( ItemSyncResponseServerChanged (Timed {timedValue = si, timedTime = st})+                     , store)+            ItemSyncRequestKnownButChanged (Timed {timedValue = ci, timedTime = ct}) ->+              if ct >= st+                -- The client time is equal to the server time.+                -- The client indicates that the item *was* modified at their side.+                -- This means that the server needs to be updated.+                then ( ItemSyncResponseClientChanged st'+                     , ServerFull (Timed {timedValue = ci, timedTime = st'}))+                -- The client time is less than the server time+                -- That means that the server has synced with another client in the meantime.+                -- Since the client indicates that the item *was* modified at their side,+                -- there is a conflict.+                else (ItemSyncResponseConflict t, store)+            ItemSyncRequestDeletedLocally ct ->+              if ct >= st+                -- The client time is equal to the server time.+                -- The client indicates that the item was deleted on their side.+                -- This means that the server item needs to be deleted as well.+                then (ItemSyncResponseClientDeleted, ServerEmpty)+                -- The client time is less than the server time+                -- That means that the server has synced with another client in the meantime.+                -- Since the client indicates that the item was deleted at their side,+                -- there is a conflict.+                else (ItemSyncResponseConflictClientDeleted t, store)
+ src/Data/Mergeful/Timed.hs view
@@ -0,0 +1,59 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE OverloadedStrings #-}++-- | Dealing with server times.+--+-- __If you are importing this module, you are probably doing something wrong.__+module Data.Mergeful.Timed+  ( ServerTime+  , initialServerTime+  , incrementServerTime+  , Timed(..)+  ) where++import GHC.Generics (Generic)++import Data.Aeson as JSON+import Data.Validity+import Data.Word++-- | A "time", as "measured" by the server.+--+-- This is closer to a version number than an actual timestamp, but that+-- distinction should not matter for your usage of this library.+--+-- In any case, a client should not be changing this value.+newtype ServerTime =+  ServerTime+    { unServerTime :: Word64+    }+  deriving (Show, Eq, Ord, Generic, ToJSON, FromJSON)++instance Validity ServerTime++-- | A server time to start with.+initialServerTime :: ServerTime+initialServerTime = ServerTime 0++-- | Increment a server time, this will only start repeating after at least @2^64 - 1@ values, so you're probably fine.+incrementServerTime :: ServerTime -> ServerTime+incrementServerTime (ServerTime w) = ServerTime (succ w)++-- | A value along with a server time.+data Timed a =+  Timed+    { timedValue :: !a+    , timedTime :: !ServerTime+    }+  deriving (Show, Eq, Generic)++instance Validity a => Validity (Timed a)++instance FromJSON a => FromJSON (Timed a) where+  parseJSON = withObject "Timed" $ \o -> Timed <$> o .: "value" <*> o .: "time"++instance ToJSON a => ToJSON (Timed a) where+  toJSON Timed {..} = object ["value" .= timedValue, "time" .= timedTime]
+ src/Data/Mergeful/Value.hs view
@@ -0,0 +1,337 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++-- | A way to synchronise a single value 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:+--+-- == For the first sychronisation+--+-- The client should ask the server for the current server value.+-- The server should send over a 'Timed' vaule, and the client should create its 'ClientValue' with 'initialClientValue'.+--+-- == For any following synchronisation:+--+--   * The client produces a 'ValueSyncRequest' with 'makeValueSyncRequest'.+--   * The client sends that request to the central server and gets a 'ValueSyncResponse'.+--   * The client then updates its local store with 'mergeValueSyncResponseRaw' or 'mergeValueSyncResponseIgnoreProblems'.+--+--+-- = The central server should operate as follows:+--+-- * The server should create an initial 'ServerValue' using 'initialServerValue'.+-- * The server accepts a 'ValueSyncRequest'.+-- * The server performs operations according to the functionality of 'processServerValueSync'.+-- * The server respons with a 'ValueSyncResponse'.+--+--+-- 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.Value+  ( initialClientValue+  , makeValueSyncRequest+  , mergeValueSyncResponseRaw+  , ValueMergeResult(..)+  , mergeValueSyncResponseIgnoreProblems+  , mergeIgnoringProblems+  , mergeFromServer+  , mergeUsingFunction+    -- * Server side+  , initialServerValue+  , processServerValueSync+    -- * Types, for reference+  , ChangedFlag(..)+  , ClientValue(..)+  , ValueSyncRequest(..)+  , ValueSyncResponse(..)+  , ServerValue(..)+  ) where++import GHC.Generics (Generic)++import Data.Aeson as JSON+import Data.Validity++import Data.Mergeful.Timed++data ChangedFlag+  = Changed+  | NotChanged+  deriving (Show, Eq, Generic)++instance Validity ChangedFlag++-- | The client side value.+--+-- The only differences between `a` and 'ClientValue a' are that+-- 'ClientValue a' also remembers the last synchronisation time from+-- the server, and whether the item has been modified at the client+--+-- There cannot be an unsynced 'ClientValue'.+data ClientValue a =+  ClientValue !(Timed a) !ChangedFlag+  deriving (Show, Eq, Generic)++instance Validity a => Validity (ClientValue a)++instance FromJSON a => FromJSON (ClientValue a) where+  parseJSON =+    withObject "ClientValue" $ \o ->+      ClientValue <$> (Timed <$> o .: "value" <*> o .: "time") <*>+      ((\b ->+          if b+            then Changed+            else NotChanged) <$>+       o .: "changed")++instance ToJSON a => ToJSON (ClientValue a) where+  toJSON (ClientValue Timed {..} cf) =+    object+      [ "value" .= timedValue+      , "time" .= timedTime+      , "changed" .=+        (case cf of+           Changed -> True+           NotChanged -> False)+      ]++-- | Produce a client value based on an initial synchronisation request+initialClientValue :: Timed a -> ClientValue a+initialClientValue t = ClientValue t NotChanged++-- | The server-side value.+--+-- The only difference between 'a' and 'ServerValue a' is that 'ServerValue a' also+-- remembers the last time this value was changed during synchronisation.+data ServerValue a =+  ServerValue !(Timed a)+  deriving (Show, Eq, Generic)++instance Validity a => Validity (ServerValue a)++instance FromJSON a => FromJSON (ServerValue a) where+  parseJSON =+    withObject "ServerValue" $ \o -> ServerValue <$> (Timed <$> o .: "value" <*> o .: "time")++instance ToJSON a => ToJSON (ServerValue a) where+  toJSON (ServerValue Timed {..}) = object ["value" .= timedValue, "time" .= timedTime]++-- | Initialise a server value.+--+-- Note that the server has to start with a value, the value 'a' cannot be omitted.+initialServerValue :: a -> ServerValue a+initialServerValue a = ServerValue $ Timed {timedValue = a, timedTime = initialServerTime}++data ValueSyncRequest a+  -- | There is an item locally that was synced at the given 'ServerTime'+  = ValueSyncRequestKnown !ServerTime+  -- | There is an item locally that was synced at the given 'ServerTime'+  -- but it has been changed since then.+  | ValueSyncRequestKnownButChanged !(Timed a)+  deriving (Show, Eq, Generic)++instance Validity a => Validity (ValueSyncRequest a)++instance FromJSON a => FromJSON (ValueSyncRequest a) where+  parseJSON =+    withObject "ValueSyncRequest" $ \o -> do+      typ <- o .: "type"+      case typ :: String of+        "synced" -> ValueSyncRequestKnown <$> o .: "time"+        "changed" -> ValueSyncRequestKnownButChanged <$> (Timed <$> o .: "value" <*> o .: "time")+        _ -> fail "unknown item type"++instance ToJSON a => ToJSON (ValueSyncRequest a) where+  toJSON ci =+    object $+    let o n rest = ("type" .= (n :: String)) : rest+     in case ci of+          ValueSyncRequestKnown t -> o "synced" ["time" .= t]+          ValueSyncRequestKnownButChanged Timed {..} ->+            o "changed" ["value" .= timedValue, "time" .= timedTime]++data ValueSyncResponse a+  -- | The client and server are fully in sync.+  --+  -- Nothing needs to be done at the client side.+  = ValueSyncResponseInSync+  -- | The client changed the value and server has succesfully been made aware of that.+  --+  -- The client needs to update its server time+  | ValueSyncResponseClientChanged !ServerTime+  -- | This value has been changed on the server side.+  --+  -- The client should change it too.+  | ValueSyncResponseServerChanged !(Timed a)+  -- | A conflict occurred.+  --+  -- The server and the client both changed the value in a conflicting manner.+  -- The server kept its part, the client can either take whatever the server gave them+  -- or deal with the conflict somehow, and then try to re-sync.+  | ValueSyncResponseConflict !(Timed a) -- ^ The item at the server side+  deriving (Show, Eq, Generic)++instance Validity a => Validity (ValueSyncResponse a)++instance FromJSON a => FromJSON (ValueSyncResponse a) where+  parseJSON =+    withObject "ValueSyncResponse" $ \o -> do+      typ <- o .: "type"+      case typ :: String of+        "in-sync" -> pure ValueSyncResponseInSync+        "client-changed" -> ValueSyncResponseClientChanged <$> o .: "time"+        "server-changed" ->+          ValueSyncResponseServerChanged <$> (Timed <$> o .: "value" <*> o .: "time")+        "conflict" -> ValueSyncResponseConflict <$> o .: "value"+        _ -> fail "unknown type"++instance ToJSON a => ToJSON (ValueSyncResponse a) where+  toJSON isr =+    object $+    let o s rest = ("type" .= (s :: String)) : rest+        oe s = o s []+     in case isr of+          ValueSyncResponseInSync -> oe "in-sync"+          ValueSyncResponseClientChanged t -> o "client-changed" ["time" .= t]+          ValueSyncResponseServerChanged Timed {..} ->+            o "server-changed" ["value" .= timedValue, "time" .= timedTime]+          ValueSyncResponseConflict a -> o "conflict" ["value" .= a]++-- | Produce an 'ItemSyncRequest' from a 'ClientItem'.+--+-- Send this to the server for synchronisation.+makeValueSyncRequest :: ClientValue a -> ValueSyncRequest a+makeValueSyncRequest (ClientValue t cf) =+  case cf of+    NotChanged -> ValueSyncRequestKnown (timedTime t)+    Changed -> ValueSyncRequestKnownButChanged t++data ValueMergeResult a+  -- | The merger went succesfully, no conflicts or desyncs+  = MergeSuccess !(ClientValue a)+  -- | There was a merge conflict. The server and client had different, conflicting versions.+  | MergeConflict !a !(Timed a) -- ^ The item at the server side+  | MergeMismatch+  -- ^ The server responded with a response that did not make sense given the client's request.+  --+  -- This should not happen in practice.+  deriving (Show, Eq, Generic)++instance Validity a => Validity (ValueMergeResult a)++-- | Merge an 'ValueSyncResponse' into the current 'ClientValue'.+--+-- This function will not make any decisions about what to do with+-- conflicts or mismatches between the request and the response.+-- It only produces a 'ValueMergeResult' so you can decide what to do with it.+mergeValueSyncResponseRaw :: ClientValue a -> ValueSyncResponse a -> ValueMergeResult a+mergeValueSyncResponseRaw cv@(ClientValue ct cf) sr =+  case cf of+    NotChanged ->+      case sr of+        ValueSyncResponseInSync -> MergeSuccess cv+        ValueSyncResponseServerChanged st -> MergeSuccess $ ClientValue st NotChanged+        _ -> MergeMismatch+    Changed ->+      case sr of+        ValueSyncResponseClientChanged st ->+          MergeSuccess $ ClientValue (ct {timedTime = st}) NotChanged+        ValueSyncResponseConflict si -> MergeConflict (timedValue ct) si+        _ -> MergeMismatch++-- | Resolve a 'ValueSyncResponse' into the current 'ClientValue'.+--+-- 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.+--+-- > mergeValueSyncResponseIgnoreProblems cs = mergeIgnoringProblems cs . mergeValueSyncResponseRaw cs+mergeValueSyncResponseIgnoreProblems :: ClientValue a -> ValueSyncResponse a -> ClientValue a+mergeValueSyncResponseIgnoreProblems cs = mergeIgnoringProblems cs . mergeValueSyncResponseRaw cs++-- | Ignore any merge problems in a 'ValueMergeResult'.+--+-- This function just returns the original 'ClientValue' if anything other than 'MergeSuccess' occurs.+--+-- 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: does not lose data+--+-- __Con: Clients will diverge when a conflict occurs__+mergeIgnoringProblems :: ClientValue a -> ValueMergeResult a -> ClientValue a+mergeIgnoringProblems cs mr =+  case mr of+    MergeSuccess cs' -> cs'+    MergeConflict _ _ -> cs+    MergeMismatch -> cs++-- | Resolve a 'ValueMergeResult' using a given merge strategy.+--+-- This function ignores 'MergeMismatch' and will just return the original 'ClientValue' in that case.+--+-- In order for clients to converge on the same value correctly, this function must be:+--+-- * Associative+-- * Idempotent+-- * The same on all clients+mergeUsingFunction ::+     (a -> Timed a -> Timed a) -> ClientValue a -> ValueMergeResult a -> ClientValue a+mergeUsingFunction func cs mr =+  case mr of+    MergeSuccess cs' -> cs'+    MergeConflict a1 a2 -> ClientValue (func a1 a2) NotChanged+    MergeMismatch -> cs++-- | Resolve a 'ValueMergeResult' by taking whatever the server gave the client.+--+-- Pro: Clients will converge on the same value.+--+-- __Con: Conflicting updates will be lost.__+mergeFromServer :: ClientValue a -> ValueMergeResult a -> ClientValue a+mergeFromServer = mergeUsingFunction (\_ serverItem -> serverItem)++-- | Serve an 'ValueSyncRequest' using the current 'ServerValue', producing an 'ValueSyncResponse' and a new 'ServerValue'.+processServerValueSync ::+     ServerValue a -> ValueSyncRequest a -> (ValueSyncResponse a, ServerValue a)+processServerValueSync sv@(ServerValue t@(Timed _ st)) sr =+  case sr of+    ValueSyncRequestKnown ct ->+      if ct >= st+                -- The client time is equal to the server time.+                -- The client indicates that the item was not modified at their side.+                -- This means that the items are in sync.+                -- (Unless the server somehow modified the item but not its server time,+                -- which would beconsidered a bug.)+        then (ValueSyncResponseInSync, sv)+                -- The client time is less than the server time+                -- That means that the server has synced with another client in the meantime.+                -- Since the client indicates that the item was not modified at their side,+                -- we can just send it back to the client to have them update their version.+                -- No conflict here.+        else (ValueSyncResponseServerChanged t, sv)+    ValueSyncRequestKnownButChanged (Timed {timedValue = ci, timedTime = ct}) ->+      if ct >= st+                -- The client time is equal to the server time.+                -- The client indicates that the item *was* modified at their side.+                -- This means that the server needs to be updated.+        then let st' = incrementServerTime st+              in ( ValueSyncResponseClientChanged st'+                 , ServerValue (Timed {timedValue = ci, timedTime = st'}))+                -- The client time is less than the server time+                -- That means that the server has synced with another client in the meantime.+                -- Since the client indicates that the item *was* modified at their side,+                -- there is a conflict.+        else (ValueSyncResponseConflict t, sv)