packages feed

mergeless (empty) → 0.0.0.0

raw patch · 5 files changed

+450/−0 lines, 5 filesdep +aesondep +basedep +containerssetup-changed

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

Files

+ ChangeLog.md view
@@ -0,0 +1,3 @@+# Changelog for mergeless++## Unreleased changes
+ README.md view
@@ -0,0 +1,1 @@+# mergeless
+ Setup.hs view
@@ -0,0 +1,3 @@+import Distribution.Simple++main = defaultMain
+ mergeless.cabal view
@@ -0,0 +1,42 @@+-- This file has been generated from package.yaml by hpack version 0.28.2.+--+-- see: https://github.com/sol/hpack+--+-- hash: 1d48986631abbde319b213203864f3a03d2b7033281b04b021493365844a0eb8++name:           mergeless+version:        0.0.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+author:         Tom Sydney Kerckhove+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++source-repository head+  type: git+  location: https://github.com/NorfairKing/mergeless++library+  exposed-modules:+      Data.Mergeless+  other-modules:+      Paths_mergeless+  hs-source-dirs:+      src+  build-depends:+      aeson+    , base >=4.7 && <5+    , containers+    , mtl+    , time+    , validity+    , validity-containers+    , validity-time+  default-language: Haskell2010
+ src/Data/Mergeless.hs view
@@ -0,0 +1,401 @@+{-# 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+    ( Store(..)+    , StoreItem(..)+    , Added(..)+    , Synced(..)+    , SyncRequest(..)+    , makeSyncRequest+    , SyncResponse(..)+    , mergeSyncResponse+    , CentralStore(..)+    , CentralItem(..)+    , processSync+    , processSyncWith+    ) 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)++-- | 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+            ]++-- | 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 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 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+       }++-- | 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@+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.+--+-- 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.+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 SyncRequest {..} =+    flip runStateT cs $ 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 :: StateT (CentralStore i a) m ()+    deleteUndeleted = deleteMany syncRequestUndeletedItems+    deleteMany :: Set i -> StateT (CentralStore i a) m ()+    deleteMany s = modC (`M.withoutKeys` s)+    syncItemsToBeDeletedLocally :: StateT (CentralStore i a) m (Set i)+    syncItemsToBeDeletedLocally = do+        foundItems <- query (`M.restrictKeys` syncRequestSyncedItems)+        pure $ syncRequestSyncedItems `S.difference` M.keysSet foundItems+    syncNewRemoteItems :: StateT (CentralStore i a) m (Set (Synced i a))+    syncNewRemoteItems = do+        syncedValues <- query (`M.withoutKeys` syncRequestSyncedItems)+        pure $+            S.fromList $+            flip map (M.toList syncedValues) $ \(u, CentralItem {..}) ->+                Synced+                { syncedUuid = u+                , syncedValue = centralValue+                , syncedCreated = centralCreated+                , syncedSynced = centralSynced+                }+    query :: (Map i (CentralItem a) -> b) -> StateT (CentralStore i a) m b+    query func = gets $ func . centralStoreItems+    syncAddedItems :: StateT (CentralStore i a) m (Set (Synced i a))+    syncAddedItems =+        fmap S.fromList $+        forM (S.toList syncRequestAddedItems) $ \Added {..} -> do+            uuid <- lift genUuid+            ins+                uuid+                CentralItem+                { centralValue = addedValue+                , centralCreated = addedCreated+                , centralSynced = now+                }+            pure+                Synced+                { syncedUuid = uuid+                , syncedCreated = addedCreated+                , syncedSynced = now+                , syncedValue = addedValue+                }+    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++distinct :: Ord a => [a] -> Bool+distinct ls = sort ls == S.toAscList (S.fromList ls)