diff --git a/mergeful-persistent.cabal b/mergeful-persistent.cabal
new file mode 100644
--- /dev/null
+++ b/mergeful-persistent.cabal
@@ -0,0 +1,75 @@
+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: d5a70b83d35c180b25e6452adc9f780212cdc2b24a168a7afcf01f3a841af65d
+
+name:           mergeful-persistent
+version:        0.0.0.0
+synopsis:       Support for using mergeful from persistent-based databases
+homepage:       https://github.com/NorfairKing/mergeful#readme
+bug-reports:    https://github.com/NorfairKing/mergeful/issues
+author:         Tom Sydney Kerckhove
+maintainer:     syd.kerckhove@gmail.com
+copyright:      Copyright: (c) 2020 Tom Sydney Kerckhove
+license:        MIT
+build-type:     Simple
+
+source-repository head
+  type: git
+  location: https://github.com/NorfairKing/mergeful
+
+library
+  exposed-modules:
+      Data.Mergeful.Persistent
+  other-modules:
+      Paths_mergeful_persistent
+  hs-source-dirs:
+      src
+  ghc-options: -Wall -fwarn-redundant-constraints
+  build-depends:
+      base >=4.11 && <5
+    , containers
+    , mergeful
+    , microlens
+    , mtl
+    , persistent
+  default-language: Haskell2010
+
+test-suite mergeful-persistent-test
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      Data.Mergeful.Persistent.SingleClientSpec
+      Data.Mergeful.Persistent.TwoClientsSpec
+      TestUtils
+      TestUtils.ClientDB
+      TestUtils.ServerDB
+      Paths_mergeful_persistent
+  hs-source-dirs:
+      test
+  ghc-options: -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      QuickCheck
+    , base >=4.11 && <5
+    , containers
+    , genvalidity
+    , genvalidity-hspec
+    , genvalidity-mergeful
+    , genvalidity-persistent
+    , hspec
+    , mergeful
+    , mergeful-persistent
+    , monad-logger
+    , mtl
+    , path
+    , path-io
+    , persistent
+    , persistent-sqlite
+    , persistent-template
+    , text
+    , validity
+    , validity-persistent
+  default-language: Haskell2010
diff --git a/src/Data/Mergeful/Persistent.hs b/src/Data/Mergeful/Persistent.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Mergeful/Persistent.hs
@@ -0,0 +1,529 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE StandaloneDeriving #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Data.Mergeful.Persistent
+  ( -- * Client side
+    clientMakeSyncRequestQuery,
+    clientMergeSyncResponseQuery,
+
+    -- ** Raw processors
+    clientSyncProcessor,
+
+    -- ** Merging
+    ItemMergeStrategy (..),
+    mergeFromServerStrategy,
+    mergeFromClientStrategy,
+    mergeUsingCRDTStrategy,
+
+    -- * Server side
+    serverProcessSyncQuery,
+    serverProcessSyncWithCustomIdQuery,
+
+    -- ** Raw processors
+    serverSyncProcessor,
+    serverSyncWithCustomIdProcessor,
+
+    -- * Utils
+
+    -- ** Client side
+    setupClientQuery,
+    clientGetStoreQuery,
+
+    -- ** Server side
+    setupServerQuery,
+    serverGetStoreQuery,
+  )
+where
+
+import Control.Monad
+import Control.Monad.IO.Class
+import qualified Data.Map as M
+import Data.Map (Map)
+import Data.Maybe
+import Data.Mergeful
+import Data.Set (Set)
+import qualified Data.Set as S
+import Database.Persist
+import Database.Persist.Sql
+
+deriving instance PersistField ServerTime
+
+deriving instance PersistFieldSql ServerTime
+
+-- | Make a sync request
+clientMakeSyncRequestQuery ::
+  forall record sid a m.
+  ( Ord sid,
+    PersistEntity record,
+    PersistField sid,
+    PersistEntityBackend record ~ SqlBackend,
+    ToBackendKey SqlBackend record,
+    MonadIO m
+  ) =>
+  -- | The server id field
+  EntityField record (Maybe sid) ->
+  -- | The server time field
+  EntityField record (Maybe ServerTime) ->
+  -- | The changed flag
+  EntityField record Bool ->
+  -- | The deleted flag
+  EntityField record Bool ->
+  -- | How to read an unsynced client item
+  (record -> a) ->
+  -- | How to read a synced client item that's been changed
+  (record -> (sid, Timed a)) ->
+  -- | How to read a synced or deleted client item
+  (record -> (sid, ServerTime)) ->
+  SqlPersistT m (SyncRequest (Key record) sid a)
+clientMakeSyncRequestQuery
+  serverIdField
+  serverTimeField
+  changedField
+  deletedField
+  unmakeUnsyncedClientThing
+  unmakeSyncedClientThing
+  unmakeDeletedClientThing = do
+    syncRequestNewItems <-
+      M.fromList . map (\(Entity cid r) -> (cid, unmakeUnsyncedClientThing r))
+        <$> selectList
+          [ serverIdField ==. Nothing,
+            serverTimeField ==. Nothing
+          ]
+          []
+    syncRequestKnownItems <-
+      M.fromList . map (unmakeDeletedClientThing . entityVal)
+        <$> selectList
+          [ serverIdField !=. Nothing,
+            serverTimeField !=. Nothing,
+            changedField ==. False,
+            deletedField ==. False
+          ]
+          []
+    syncRequestKnownButChangedItems <-
+      M.fromList . map (unmakeSyncedClientThing . entityVal)
+        <$> selectList
+          [ serverIdField !=. Nothing,
+            serverTimeField !=. Nothing,
+            changedField ==. True,
+            deletedField ==. False
+          ]
+          []
+    syncRequestDeletedItems <-
+      M.fromList . map (unmakeDeletedClientThing . entityVal)
+        <$> selectList
+          [ deletedField ==. True
+          ]
+          []
+    pure SyncRequest {..}
+
+-- Merge a sync response
+clientMergeSyncResponseQuery ::
+  forall record sid a m.
+  ( Ord sid,
+    PersistField sid,
+    PersistEntityBackend record ~ SqlBackend,
+    ToBackendKey SqlBackend record,
+    MonadIO m
+  ) =>
+  -- | The server id field
+  EntityField record (Maybe sid) ->
+  -- | The server time field
+  EntityField record (Maybe ServerTime) ->
+  -- | The changed flag
+  EntityField record Bool ->
+  -- | The deleted flag
+  EntityField record Bool ->
+  -- | How to build a synced record from a server id and a timed item
+  (sid -> Timed a -> record) ->
+  -- | How to read a synced record back into a server id and a timed item
+  (record -> (sid, Timed a)) ->
+  -- | How to update a row with new data
+  --
+  -- You only need to perform the updates that have anything to do with the data to sync.
+  -- The housekeeping updates are already taken care of.
+  (a -> [Update record]) ->
+  -- | The merge strategy.
+  ItemMergeStrategy a ->
+  -- | The sync response to merge
+  SyncResponse (Key record) sid a ->
+  SqlPersistT m ()
+clientMergeSyncResponseQuery
+  serverIdField
+  serverTimeField
+  changedField
+  deletedField
+  makeSyncedClientThing
+  unmakeSyncedClientThing
+  recordUpdates
+  strat =
+    mergeSyncResponseCustom strat $
+      clientSyncProcessor
+        serverIdField
+        serverTimeField
+        changedField
+        deletedField
+        makeSyncedClientThing
+        unmakeSyncedClientThing
+        recordUpdates
+
+clientSyncProcessor ::
+  forall record sid a m.
+  ( Ord sid,
+    PersistField sid,
+    PersistEntityBackend record ~ SqlBackend,
+    ToBackendKey SqlBackend record,
+    MonadIO m
+  ) =>
+  -- | The server id field
+  EntityField record (Maybe sid) ->
+  -- | The server time field
+  EntityField record (Maybe ServerTime) ->
+  -- | The changed flag
+  EntityField record Bool ->
+  -- | The deleted flag
+  EntityField record Bool ->
+  -- | How to build a synced record from a server id and a timed item
+  (sid -> Timed a -> record) ->
+  -- | How to read a synced record back into a server id and a timed item
+  (record -> (sid, Timed a)) ->
+  -- | How to update a row with new data
+  --
+  -- You only need to perform the updates that have anything to do with the data to sync.
+  -- The housekeeping updates are already taken care of.
+  (a -> [Update record]) ->
+  ClientSyncProcessor (Key record) sid a (SqlPersistT m)
+clientSyncProcessor
+  serverIdField
+  serverTimeField
+  changedField
+  deletedField
+  makeSyncedClientThing
+  unmakeSyncedClientThing
+  recordUpdates = ClientSyncProcessor {..}
+    where
+      clientSyncProcessorQuerySyncedButChangedValues :: Set sid -> SqlPersistT m (Map sid (Timed a))
+      clientSyncProcessorQuerySyncedButChangedValues si = fmap (M.fromList . map (\(Entity _ r) -> unmakeSyncedClientThing r) . catMaybes) $ forM (S.toList si) $ \sid ->
+        selectFirst
+          [ serverIdField ==. Just sid,
+            serverTimeField !=. Nothing,
+            changedField ==. True,
+            deletedField ==. False
+          ]
+          []
+      clientSyncProcessorSyncClientAdded :: Map (Key record) (ClientAddition sid) -> SqlPersistT m ()
+      clientSyncProcessorSyncClientAdded m = forM_ (M.toList m) $ \(cid, ClientAddition {..}) ->
+        update
+          cid
+          [ serverIdField =. Just clientAdditionId,
+            serverTimeField =. Just clientAdditionServerTime
+          ]
+      clientSyncProcessorSyncClientChanged :: Map sid ServerTime -> SqlPersistT m ()
+      clientSyncProcessorSyncClientChanged m = forM_ (M.toList m) $ \(sid, st) ->
+        updateWhere
+          [serverIdField ==. Just sid]
+          [ serverTimeField =. Just st,
+            changedField =. False
+          ]
+      clientSyncProcessorSyncClientDeleted :: Set sid -> SqlPersistT m ()
+      clientSyncProcessorSyncClientDeleted s = forM_ (S.toList s) $ \sid ->
+        deleteWhere [serverIdField ==. Just sid]
+      clientSyncProcessorSyncMergedConflict :: Map sid (Timed a) -> SqlPersistT m ()
+      clientSyncProcessorSyncMergedConflict m = forM_ (M.toList m) $ \(sid, Timed a st) ->
+        updateWhere
+          [serverIdField ==. Just sid]
+          $ [ serverTimeField =. Just st,
+              changedField =. True
+            ]
+            ++ recordUpdates a
+      clientSyncProcessorSyncServerAdded :: Map sid (Timed a) -> SqlPersistT m ()
+      clientSyncProcessorSyncServerAdded m =
+        insertMany_ $ map (uncurry makeSyncedClientThing) (M.toList m)
+      clientSyncProcessorSyncServerChanged :: Map sid (Timed a) -> SqlPersistT m ()
+      clientSyncProcessorSyncServerChanged m = forM_ (M.toList m) $ \(sid, Timed a st) -> do
+        updateWhere
+          [serverIdField ==. Just sid]
+          $ [ serverTimeField =. Just st,
+              changedField =. False
+            ]
+            ++ recordUpdates a
+      clientSyncProcessorSyncServerDeleted :: Set sid -> SqlPersistT m ()
+      clientSyncProcessorSyncServerDeleted s = forM_ (S.toList s) $ \sid ->
+        deleteWhere [serverIdField ==. Just sid]
+
+-- | Set up a client store.
+--
+-- You shouldn't need this.
+setupClientQuery ::
+  forall record sid a m.
+  ( PersistEntityBackend record ~ SqlBackend,
+    ToBackendKey SqlBackend record,
+    MonadIO m
+  ) =>
+  (a -> record) ->
+  (sid -> Timed a -> record) ->
+  (sid -> Timed a -> record) ->
+  (sid -> ServerTime -> record) ->
+  ClientStore (Key record) sid a ->
+  SqlPersistT m ()
+setupClientQuery makeUnsyncedClientThing makeSyncedClientThing makeSyncedButChangedClientThing makeDeletedClientThing ClientStore {..} = do
+  forM_ (M.toList clientStoreAddedItems) $ \(cid, t) ->
+    insertKey cid $ makeUnsyncedClientThing t
+  forM_ (M.toList clientStoreSyncedItems) $ \(sid, tt) ->
+    insert_ $ makeSyncedClientThing sid tt
+  forM_ (M.toList clientStoreSyncedButChangedItems) $ \(sid, tt) ->
+    insert_ $ makeSyncedButChangedClientThing sid tt
+  forM_ (M.toList clientStoreDeletedItems) $ \(sid, st) -> insert_ $ makeDeletedClientThing sid st
+
+-- | Get the client store.
+--
+-- You shouldn't need this.
+clientGetStoreQuery ::
+  forall record sid a m.
+  ( Ord sid,
+    PersistEntity record,
+    PersistField sid,
+    PersistEntityBackend record ~ SqlBackend,
+    ToBackendKey SqlBackend record,
+    MonadIO m
+  ) =>
+  EntityField record (Maybe sid) ->
+  EntityField record (Maybe ServerTime) ->
+  EntityField record Bool ->
+  EntityField record Bool ->
+  (record -> a) ->
+  (record -> (sid, Timed a)) ->
+  (record -> (sid, ServerTime)) ->
+  SqlPersistT m (ClientStore (Key record) sid a)
+clientGetStoreQuery
+  serverIdField
+  serverTimeField
+  changedField
+  deletedField
+  unmakeUnsyncedClientThing
+  unmakeSyncedClientThing
+  unmakeDeletedClientThing = do
+    clientStoreAddedItems <-
+      M.fromList . map (\(Entity cid cr) -> (cid, unmakeUnsyncedClientThing cr))
+        <$> selectList
+          [ serverIdField ==. Nothing,
+            serverTimeField ==. Nothing
+          ]
+          []
+    clientStoreSyncedItems <-
+      M.fromList . map (unmakeSyncedClientThing . entityVal)
+        <$> selectList
+          [ serverIdField !=. Nothing,
+            serverTimeField !=. Nothing,
+            changedField ==. False,
+            deletedField ==. False
+          ]
+          []
+    clientStoreSyncedButChangedItems <-
+      M.fromList . map (unmakeSyncedClientThing . entityVal)
+        <$> selectList
+          [ serverIdField !=. Nothing,
+            serverTimeField !=. Nothing,
+            changedField ==. True,
+            deletedField ==. False
+          ]
+          []
+    clientStoreDeletedItems <-
+      M.fromList . map (unmakeDeletedClientThing . entityVal)
+        <$> selectList
+          [ deletedField ==. True
+          ]
+          []
+    pure ClientStore {..}
+
+-- | Process a sync request on the server side
+serverProcessSyncQuery ::
+  forall cid record a m.
+  ( PersistEntity record,
+    PersistEntityBackend record ~ SqlBackend,
+    ToBackendKey SqlBackend record,
+    MonadIO m
+  ) =>
+  -- | The server time field
+  EntityField record ServerTime ->
+  -- | The filters to select the relevant records
+  --
+  -- Use this if you want per-user syncing
+  [Filter record] ->
+  -- | How to load an item from the database
+  (record -> Timed a) ->
+  -- | How to add an item in the database with initial server time
+  (a -> record) ->
+  -- | How to update a record given new data
+  (a -> [Update record]) ->
+  -- | A sync request
+  SyncRequest cid (Key record) a ->
+  SqlPersistT m (SyncResponse cid (Key record) a)
+serverProcessSyncQuery
+  serverTimeField
+  filters
+  unmakeFunc
+  makeFunc
+  recordUpdates =
+    processServerSyncCustom $
+      serverSyncProcessor
+        serverTimeField
+        filters
+        unmakeFunc
+        makeFunc
+        recordUpdates
+
+serverSyncProcessor ::
+  forall cid record a m.
+  ( PersistEntity record,
+    PersistEntityBackend record ~ SqlBackend,
+    ToBackendKey SqlBackend record,
+    MonadIO m
+  ) =>
+  -- | The server time field
+  EntityField record ServerTime ->
+  -- | The filters to select the relevant records
+  --
+  -- Use this if you want per-user syncing
+  [Filter record] ->
+  -- | How to load an item from the database
+  (record -> Timed a) ->
+  -- | How to add an item in the database with initial server time
+  (a -> record) ->
+  -- | How to update a record given new data
+  (a -> [Update record]) ->
+  ServerSyncProcessor cid (Key record) a (SqlPersistT m)
+serverSyncProcessor
+  serverTimeField
+  filters
+  unmakeFunc
+  makeFunc
+  recordUpdates =
+    ServerSyncProcessor {..} :: ServerSyncProcessor cid (Key record) a (SqlPersistT m)
+    where
+      serverSyncProcessorRead = M.fromList . map (\(Entity i r) -> (i, unmakeFunc r)) <$> selectList filters []
+      serverSyncProcessorAddItem = insert . makeFunc
+      serverSyncProcessorChangeItem si st a = update si $ (serverTimeField =. st) : recordUpdates a
+      serverSyncProcessorDeleteItem = delete
+
+-- | Process a sync request on the server side with a custom id field
+--
+-- You can use this function if you want to use a UUID as your id instead of the sqlkey of the item.
+serverProcessSyncWithCustomIdQuery ::
+  forall cid sid record a m.
+  ( Ord sid,
+    PersistField sid,
+    PersistEntityBackend record ~ SqlBackend,
+    ToBackendKey SqlBackend record,
+    MonadIO m
+  ) =>
+  -- | The custom id field
+  EntityField record sid ->
+  -- | The generator to generate the custom id field
+  SqlPersistT m sid ->
+  -- | The server time field
+  EntityField record ServerTime ->
+  -- | The filters to select the relevant records
+  --
+  -- Use this if you want per-user syncing
+  [Filter record] ->
+  -- | How to load an item from the database
+  (record -> (sid, Timed a)) ->
+  -- | How to add an item in the database with initial server time
+  (sid -> a -> record) ->
+  -- | How to update a record given new data
+  (a -> [Update record]) ->
+  -- | A sync request
+  SyncRequest cid sid a ->
+  SqlPersistT m (SyncResponse cid sid a)
+serverProcessSyncWithCustomIdQuery
+  idField
+  uuidGen
+  serverTimeField
+  filters
+  unmakeFunc
+  makeFunc
+  recordUpdates =
+    processServerSyncCustom $
+      serverSyncWithCustomIdProcessor
+        idField
+        uuidGen
+        serverTimeField
+        filters
+        unmakeFunc
+        makeFunc
+        recordUpdates
+
+serverSyncWithCustomIdProcessor ::
+  forall cid sid record a m.
+  ( Ord sid,
+    PersistField sid,
+    PersistEntityBackend record ~ SqlBackend,
+    ToBackendKey SqlBackend record,
+    MonadIO m
+  ) =>
+  -- | The custom id field
+  EntityField record sid ->
+  -- | The generator to generate the custom id field
+  SqlPersistT m sid ->
+  -- | The server time field
+  EntityField record ServerTime ->
+  -- | The filters to select the relevant records
+  --
+  -- Use this if you want per-user syncing
+  [Filter record] ->
+  -- | How to load an item from the database
+  (record -> (sid, Timed a)) ->
+  -- | How to add an item in the database with 'initialServerTime'
+  (sid -> a -> record) ->
+  -- | How to update a record given new data
+  (a -> [Update record]) ->
+  ServerSyncProcessor cid sid a (SqlPersistT m)
+serverSyncWithCustomIdProcessor
+  idField
+  uuidGen
+  serverTimeField
+  filters
+  unmakeFunc
+  makeFunc
+  recordUpdates = ServerSyncProcessor {..} :: ServerSyncProcessor cid sid a (SqlPersistT m)
+    where
+      serverSyncProcessorRead = M.fromList . map (\(Entity _ record) -> unmakeFunc record) <$> selectList filters []
+      serverSyncProcessorAddItem a = do
+        uuid <- uuidGen
+        insert_ $ makeFunc uuid a
+        pure uuid
+      serverSyncProcessorChangeItem si st a = updateWhere [idField ==. si] $ (serverTimeField =. st) : recordUpdates a
+      serverSyncProcessorDeleteItem si = deleteWhere [idField ==. si]
+
+-- | Set up the server store
+--
+-- You shouldn't need this.
+setupServerQuery ::
+  forall sid record a.
+  ( PersistEntity record,
+    PersistEntityBackend record ~ SqlBackend
+  ) =>
+  (sid -> Timed a -> Entity record) ->
+  ServerStore sid a ->
+  SqlPersistT IO ()
+setupServerQuery func ServerStore {..} =
+  forM_ (M.toList serverStoreItems) $ \(sid, tt) ->
+    let (Entity k r) = func sid tt
+     in insertKey k r
+
+-- | Get the server store
+--
+-- You shouldn't need this.
+serverGetStoreQuery ::
+  ( Ord sid,
+    PersistEntity record,
+    PersistEntityBackend record ~ SqlBackend
+  ) =>
+  (Entity record -> (sid, Timed a)) ->
+  SqlPersistT IO (ServerStore sid a)
+serverGetStoreQuery func = ServerStore . M.fromList . map func <$> selectList [] []
diff --git a/test/Data/Mergeful/Persistent/SingleClientSpec.hs b/test/Data/Mergeful/Persistent/SingleClientSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Data/Mergeful/Persistent/SingleClientSpec.hs
@@ -0,0 +1,185 @@
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeFamilies #-}
+
+module Data.Mergeful.Persistent.SingleClientSpec
+  ( spec,
+  )
+where
+
+import Control.Monad.Reader
+import Data.List
+import qualified Data.Map as M
+import Data.Mergeful
+import Database.Persist.Sql
+import Test.Hspec
+import Test.Hspec.QuickCheck
+import Test.Validity
+import TestUtils
+
+{-# ANN module ("HLint: ignore Reduce duplication" :: String) #-}
+
+spec :: Spec
+spec = modifyMaxShrinks (const 0) $ oneClientSpec $ do
+  describe "sanity" $ do
+    describe "setupClient & clientGetStore" $ do
+      it "roundtrips" $ \te -> forAllValid $ \cstore -> runTest te $ do
+        setupClient cstore
+        cstore' <- clientGetStore
+        liftIO $ cstore' `shouldBe` cstore
+    describe "setupServer & serverGetStore" $ do
+      it "roundtrips" $ \te -> forAllValid $ \sstore -> runTest te $ do
+        setupServer sstore
+        sstore' <- serverGetStore
+        liftIO $ sstore' `shouldBe` sstore
+  describe "mergeFromServerStrategy" $ do
+    let strat = mergeFromServerStrategy
+    mergeFunctionSpec strat
+    emptyResponseSpec strat
+  describe "mergeFromClientStrategy" $ do
+    let strat = mergeFromClientStrategy
+    mergeFunctionSpec strat
+    xdescribe "does not hold" $ emptyResponseSpec strat
+  describe "mergeUsingCRDTStrategy" $ do
+    let strat = mergeUsingCRDTStrategy max
+    mergeFunctionSpec strat
+    emptyResponseSpec strat
+
+mergeFunctionSpec :: ItemMergeStrategy Thing -> SpecWith TestEnv
+mergeFunctionSpec strat = do
+  let mergeFunc = clientMergeSyncResponse strat
+  describe "Single Client" $ do
+    describe "Single item" $ do
+      it "succesfully downloads an item from the server for an empty client" $ \te ->
+        forAllValid $ \uuid ->
+          forAllValid $ \tst -> runTest te $ do
+            let sstore1 = ServerStore $ M.singleton uuid tst
+            setupClient initialClientStore
+            setupServer sstore1
+            req <- clientMakeSyncRequest
+            resp <- serverProcessSync req
+            sstore2 <- serverGetStore
+            mergeFunc resp
+            cstore2 <- clientGetStore
+            lift $ do
+              sstore2 `shouldBe` sstore1
+              clientStoreSyncedItems cstore2 `shouldBe` serverStoreItems sstore2
+      it "succesfully uploads an item to the server for an empty server" $ \te ->
+        forAllValid $ \cid ->
+          forAllValid $ \t ->
+            runTest te $ do
+              let cstore1 = initialClientStore {clientStoreAddedItems = M.singleton cid t}
+              let sstore1 = initialServerStore
+              setupClient cstore1
+              setupServer sstore1
+              req <- clientMakeSyncRequest
+              resp <- serverProcessSync req
+              sstore2 <- serverGetStore
+              mergeFunc resp
+              cstore2 <- clientGetStore
+              lift $ do
+                sort (M.elems (M.map timedValue (clientStoreSyncedItems cstore2)))
+                  `shouldBe` sort [t]
+                clientStoreSyncedItems cstore2 `shouldBe` serverStoreItems sstore2
+    describe "Multiple items" $ do
+      it "succesfully downloads an item from the server for an empty client" $ \te ->
+        forAllValid $ \sstore1 -> runTest te $ do
+          setupClient initialClientStore
+          setupServer sstore1
+          req <- clientMakeSyncRequest
+          resp <- serverProcessSync req
+          sstore2 <- serverGetStore
+          mergeFunc resp
+          cstore2 <- clientGetStore
+          lift $ do
+            sstore2 `shouldBe` sstore1
+            clientStoreSyncedItems cstore2 `shouldBe` serverStoreItems sstore2
+      it "succesfully uploads everything to the server for an empty server" $ \te ->
+        forAllValid $ \items ->
+          runTest te $ do
+            let cstore1 = initialClientStore {clientStoreAddedItems = items}
+            let sstore1 = initialServerStore
+            setupClient cstore1
+            setupServer sstore1
+            req <- clientMakeSyncRequest
+            resp <- serverProcessSync req
+            sstore2 <- serverGetStore
+            mergeFunc resp
+            cstore2 <- clientGetStore
+            lift $ do
+              sort (M.elems (M.map timedValue (clientStoreSyncedItems cstore2)))
+                `shouldBe` sort (M.elems items)
+              clientStoreSyncedItems cstore2 `shouldBe` serverStoreItems sstore2
+
+emptyResponseSpec :: ItemMergeStrategy Thing -> SpecWith TestEnv
+emptyResponseSpec strat = do
+  let mergeFunc = clientMergeSyncResponse strat
+  it "is returns an empty response on the second sync with no modifications" $ \te -> forAllValid $ \cstore1 -> forAllValid $ \sstore1 ->
+    runTest te $ do
+      setupClient cstore1
+      setupServer sstore1
+      req1 <- clientMakeSyncRequest
+      resp1 <- serverProcessSync req1
+      mergeFunc resp1
+      req2 <- clientMakeSyncRequest
+      resp2 <- serverProcessSync req2
+      lift $ resp2 `shouldBe` emptySyncResponse
+
+type T a = ReaderT TestEnv IO a
+
+runTest :: TestEnv -> T a -> IO a
+runTest = flip runReaderT
+
+runClientDB :: SqlPersistT IO a -> T a
+runClientDB func = do
+  pool <- asks testEnvClientPool
+  liftIO $ runSqlPool func pool
+
+runServerDB :: SqlPersistT IO a -> T a
+runServerDB func = do
+  pool <- asks testEnvServerPool
+  liftIO $ runSqlPool func pool
+
+type CS = ClientStore ClientThingId ServerThingId Thing
+
+type SReq = SyncRequest ClientThingId ServerThingId Thing
+
+type SS = ServerStore ServerThingId Thing
+
+type SResp = SyncResponse ClientThingId ServerThingId Thing
+
+setupClient :: CS -> T ()
+setupClient = runClientDB . setupClientThingQuery
+
+setupServer :: SS -> T ()
+setupServer = runServerDB . setupServerThingQuery
+
+clientGetStore :: T CS
+clientGetStore = runClientDB clientGetStoreThingQuery
+
+clientMakeSyncRequest :: T SReq
+clientMakeSyncRequest = runClientDB clientMakeSyncRequestThingQuery
+
+serverGetStore :: T SS
+serverGetStore = runServerDB serverGetStoreThingQuery
+
+serverProcessSync :: SReq -> T SResp
+serverProcessSync = runServerDB . serverProcessSyncThingQuery
+
+clientMergeSyncResponse :: ItemMergeStrategy Thing -> SResp -> T ()
+clientMergeSyncResponse strat = runClientDB . clientMergeSyncResponseThingQuery strat
+
+data TestEnv
+  = TestEnv
+      { testEnvClientPool :: ConnectionPool,
+        testEnvServerPool :: ConnectionPool
+      }
+
+oneClientSpec :: SpecWith TestEnv -> Spec
+oneClientSpec = around withTestEnv
+
+withTestEnv :: (TestEnv -> IO a) -> IO a
+withTestEnv func =
+  withServerPool $ \serverPool -> withClientPool $ \clientPool -> do
+    let tenv = TestEnv {testEnvClientPool = clientPool, testEnvServerPool = serverPool}
+    liftIO $ func tenv
diff --git a/test/Data/Mergeful/Persistent/TwoClientsSpec.hs b/test/Data/Mergeful/Persistent/TwoClientsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Data/Mergeful/Persistent/TwoClientsSpec.hs
@@ -0,0 +1,527 @@
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeFamilies #-}
+
+module Data.Mergeful.Persistent.TwoClientsSpec
+  ( spec,
+  )
+where
+
+import Control.Monad.Reader
+import qualified Data.Map as M
+import Data.Mergeful
+import qualified Data.Set as S
+import Database.Persist.Sql
+import Test.Hspec
+import Test.Hspec.QuickCheck
+import Test.Validity
+import TestUtils
+
+{-# ANN module ("HLint: ignore Reduce duplication" :: String) #-}
+
+spec :: Spec
+spec = modifyMaxShrinks (const 0) $ twoClientsSpec $ do
+  describe "sanity" $ do
+    describe "setupClient & clientGetStore" $ do
+      it "roundtrips" $ \te -> forAllValid $ \cstore -> runTest te $ do
+        setupClient A cstore
+        cstore' <- clientGetStore A
+        liftIO $ cstore' `shouldBe` cstore
+    describe "setupServer & serverGetStore" $ do
+      it "roundtrips" $ \te -> forAllValid $ \sstore -> runTest te $ do
+        setupServer sstore
+        sstore' <- serverGetStore
+        liftIO $ sstore' `shouldBe` sstore
+  describe "mergeFromServerStrategy" $ do
+    let strat = mergeFromServerStrategy
+    mergeFunctionSpec strat
+    noDivergenceSpec strat
+    xdescribe "Does not hold" $ noDataLossSpec strat
+  describe "mergeFromClientStrategy" $ do
+    let strat = mergeFromClientStrategy
+    mergeFunctionSpec strat
+    noDataLossSpec strat
+    xdescribe "Does not hold" $ noDivergenceSpec strat
+  describe "mergeUsingCRDTStrategy" $ do
+    let strat = mergeUsingCRDTStrategy max
+    mergeFunctionSpec strat
+    noDataLossSpec strat
+    noDivergenceSpec strat
+
+mergeFunctionSpec :: ItemMergeStrategy Thing -> SpecWith TestEnv
+mergeFunctionSpec strat = do
+  let mergeFunc = clientMergeSyncResponse strat
+  describe "Multiple clients" $ do
+    describe "Single item" $ do
+      it "successfully syncs an addition accross to a second client" $ \te -> forAllValid $ \k -> forAllValid $ \i -> runTest te $ do
+        -- Client A has one item
+        setupClient A $ initialClientStore {clientStoreAddedItems = M.singleton k i}
+        -- Client B is empty
+        setupClient B initialClientStore
+        -- The server is empty
+        setupServer initialServerStore
+        -- Client A makes sync request 1
+        req1 <- clientMakeSyncRequest A
+        -- The server processes sync request 1
+        resp1 <- serverProcessSync req1
+        sstore2 <- serverGetStore
+        let addedItems = syncResponseClientAdded resp1
+        case M.toList addedItems of
+          [(k', ClientAddition uuid st)] -> do
+            lift $ k' `shouldBe` k
+            let time = initialServerTime
+            lift $ st `shouldBe` time
+            let items = M.singleton uuid (Timed i st)
+            lift $ sstore2 `shouldBe` (ServerStore {serverStoreItems = items})
+            -- Client A merges the response
+            mergeFunc A resp1
+            cAstore2 <- clientGetStore A
+            lift $ cAstore2 `shouldBe` (initialClientStore {clientStoreSyncedItems = items})
+            -- Client B makes sync request 2
+            req2 <- clientMakeSyncRequest B
+            -- The server processes sync request 2
+            resp2 <- serverProcessSync req2
+            sstore3 <- serverGetStore
+            lift $ do
+              resp2 `shouldBe` (emptySyncResponse {syncResponseServerAdded = items})
+              sstore3 `shouldBe` sstore2
+            -- Client B merges the response
+            mergeFunc B resp2
+            cBstore2 <- clientGetStore B
+            lift $ cBstore2 `shouldBe` (initialClientStore {clientStoreSyncedItems = items})
+            -- Client A and Client B now have the same store
+            lift $ cAstore2 `shouldBe` cBstore2
+          _ ->
+            lift $
+              expectationFailure
+                "Should have found exactly one added item."
+      it "successfully syncs a modification accross to a second client" $ \te -> forAllValid $ \uuid -> forAllValid $ \i -> forAllValid $ \j -> forAllValid $ \time1 ->
+        runTest te $ do
+          -- Client A has a synced item.
+          setupClient A $
+            initialClientStore
+              { clientStoreSyncedItems = M.singleton uuid (Timed i time1)
+              }
+          -- Client B had synced that same item, but has since modified it
+          setupClient B $
+            initialClientStore
+              { clientStoreSyncedButChangedItems = M.singleton uuid (Timed j time1)
+              }
+          -- The server is has the item that both clients had before
+          setupServer $ ServerStore {serverStoreItems = M.singleton uuid (Timed i time1)}
+          -- Client B makes sync request 1
+          req1 <- clientMakeSyncRequest B
+          -- The server processes sync request 1
+          resp1 <- serverProcessSync req1
+          sstore2 <- serverGetStore
+          let time2 = incrementServerTime time1
+          lift $ do
+            resp1
+              `shouldBe` emptySyncResponse {syncResponseClientChanged = M.singleton uuid time2}
+            sstore2
+              `shouldBe` ServerStore {serverStoreItems = M.singleton uuid (Timed j time2)}
+          -- Client B merges the response
+          mergeFunc B resp1
+          cBstore2 <- clientGetStore B
+          lift $
+            cBstore2
+              `shouldBe` initialClientStore {clientStoreSyncedItems = M.singleton uuid (Timed j time2)}
+          -- Client A makes sync request 2
+          req2 <- clientMakeSyncRequest A
+          -- The server processes sync request 2
+          resp2 <- serverProcessSync req2
+          sstore3 <- serverGetStore
+          lift $ do
+            resp2
+              `shouldBe` emptySyncResponse
+                { syncResponseServerChanged = M.singleton uuid (Timed j time2)
+                }
+            sstore3 `shouldBe` sstore2
+          -- Client A merges the response
+          mergeFunc A resp2
+          cAstore2 <- clientGetStore A
+          lift $
+            cAstore2
+              `shouldBe` initialClientStore {clientStoreSyncedItems = M.singleton uuid (Timed j time2)}
+          -- Client A and Client B now have the same store
+          lift $ cAstore2 `shouldBe` cBstore2
+      it "succesfully syncs a deletion across to a second client" $ \te -> forAllValid $ \uuid -> forAllValid $ \time1 -> forAllValid $ \i ->
+        runTest te $ do
+          setupClient A $
+            initialClientStore {clientStoreSyncedItems = M.singleton uuid (Timed i time1)}
+          -- Client A has a synced item.
+          -- Client B had synced that same item, but has since deleted it.
+          setupClient B $ initialClientStore {clientStoreDeletedItems = M.singleton uuid time1}
+          -- The server still has the undeleted item
+          setupServer $ ServerStore {serverStoreItems = M.singleton uuid (Timed i time1)}
+          -- Client B makes sync request 1
+          req1 <- clientMakeSyncRequest B
+          -- The server processes sync request 1
+          resp1 <- serverProcessSync req1
+          sstore2 <- serverGetStore
+          lift $ do
+            resp1 `shouldBe` emptySyncResponse {syncResponseClientDeleted = S.singleton uuid}
+            sstore2 `shouldBe` initialServerStore
+          -- Client B merges the response
+          mergeFunc B resp1
+          cBstore2 <- clientGetStore B
+          lift $ cBstore2 `shouldBe` initialClientStore
+          -- Client A makes sync request 2
+          req2 <- clientMakeSyncRequest A
+          -- The server processes sync request 2
+          resp2 <- serverProcessSync req2
+          sstore3 <- serverGetStore
+          lift $ do
+            resp2 `shouldBe` emptySyncResponse {syncResponseServerDeleted = S.singleton uuid}
+            sstore3 `shouldBe` sstore2
+          -- Client A merges the response
+          mergeFunc A resp2
+          cAstore2 <- clientGetStore A
+          lift $ cAstore2 `shouldBe` initialClientStore
+          -- Client A and Client B now have the same store
+          lift $ cAstore2 `shouldBe` cBstore2
+      it "does not run into a conflict if two clients both try to sync a deletion" $ \te -> forAllValid $ \uuid -> forAllValid $ \time1 -> forAllValid $ \i ->
+        runTest te $ do
+          setupClient A $ initialClientStore {clientStoreDeletedItems = M.singleton uuid time1}
+          -- Both client a and client b delete an item.
+          setupClient B $ initialClientStore {clientStoreDeletedItems = M.singleton uuid time1}
+          -- The server still has the undeleted item
+          setupServer $ ServerStore {serverStoreItems = M.singleton uuid (Timed i time1)}
+          -- Client A makes sync request 1
+          req1 <- clientMakeSyncRequest A
+          -- The server processes sync request 1
+          resp1 <- serverProcessSync req1
+          sstore2 <- serverGetStore
+          lift $ do
+            resp1
+              `shouldBe` (emptySyncResponse {syncResponseClientDeleted = S.singleton uuid})
+            sstore2 `shouldBe` (ServerStore {serverStoreItems = M.empty})
+          -- Client A merges the response
+          mergeFunc A resp1
+          cAstore2 <- clientGetStore A
+          lift $ cAstore2 `shouldBe` initialClientStore
+          -- Client B makes sync request 2
+          req2 <- clientMakeSyncRequest B
+          -- The server processes sync request 2
+          resp2 <- serverProcessSync req2
+          sstore3 <- serverGetStore
+          lift $ do
+            resp2
+              `shouldBe` (emptySyncResponse {syncResponseClientDeleted = S.singleton uuid})
+            sstore3 `shouldBe` sstore2
+          -- Client B merges the response
+          mergeFunc B resp2
+          cBstore2 <- clientGetStore B
+          lift $ do
+            cBstore2 `shouldBe` initialClientStore
+            -- Client A and Client B now have the same store
+            cAstore2 `shouldBe` cBstore2
+    describe "Multiple items" $ do
+      it "successfully syncs additions accross to a second client" $ \te -> forAllValid $ \is ->
+        runTest te $ do
+          setupClient A $ initialClientStore {clientStoreAddedItems = is}
+          -- Client B is empty
+          setupClient B initialClientStore
+          -- The server is empty
+          setupServer initialServerStore
+          -- Client A makes sync request 1
+          req1 <- clientMakeSyncRequest A
+          -- The server processes sync request 1
+          resp1 <- serverProcessSync req1
+          sstore2 <- serverGetStore
+          let (rest, items) = mergeAddedItems is (syncResponseClientAdded resp1)
+          lift $ do
+            rest `shouldBe` M.empty
+            sstore2 `shouldBe` (ServerStore {serverStoreItems = items})
+          -- Client A merges the response
+          mergeFunc A resp1
+          cAstore2 <- clientGetStore A
+          lift $ cAstore2 `shouldBe` (initialClientStore {clientStoreSyncedItems = items})
+          -- Client B makes sync request 2
+          req2 <- clientMakeSyncRequest B
+          -- The server processes sync request 2
+          resp2 <- serverProcessSync req2
+          sstore3 <- serverGetStore
+          lift $ do
+            resp2 `shouldBe` (emptySyncResponse {syncResponseServerAdded = items})
+            sstore3 `shouldBe` sstore2
+          -- Client B merges the response
+          mergeFunc B resp2
+          cBstore2 <- clientGetStore B
+          lift $ cBstore2 `shouldBe` (initialClientStore {clientStoreSyncedItems = items})
+          -- Client A and Client B now have the same store
+          lift $ cAstore2 `shouldBe` cBstore2
+      it "succesfully syncs deletions across to a second client" $ \te -> forAllValid $ \items -> forAllValid $ \time1 ->
+        runTest te $ do
+          let syncedItems = M.map (\i -> Timed i time1) items
+              itemTimes = M.map (const time1) items
+              itemIds = M.keysSet items
+          setupClient A $ initialClientStore {clientStoreSyncedItems = syncedItems}
+          -- Client A has synced items
+          -- Client B had synced the same items, but has since deleted them.
+          setupClient B $ initialClientStore {clientStoreDeletedItems = itemTimes}
+          -- The server still has the undeleted item
+          setupServer $ ServerStore {serverStoreItems = syncedItems}
+          -- Client B makes sync request 1
+          req1 <- clientMakeSyncRequest B
+          -- The server processes sync request 1
+          resp1 <- serverProcessSync req1
+          sstore2 <- serverGetStore
+          lift $ do
+            resp1 `shouldBe` emptySyncResponse {syncResponseClientDeleted = itemIds}
+            sstore2 `shouldBe` initialServerStore
+          -- Client B merges the response
+          mergeFunc B resp1
+          cBstore2 <- clientGetStore B
+          lift $ cBstore2 `shouldBe` initialClientStore
+          -- Client A makes sync request 2
+          req2 <- clientMakeSyncRequest A
+          -- The server processes sync request 2
+          resp2 <- serverProcessSync req2
+          sstore3 <- serverGetStore
+          lift $ do
+            resp2 `shouldBe` emptySyncResponse {syncResponseServerDeleted = itemIds}
+            sstore3 `shouldBe` sstore2
+          -- Client A merges the response
+          mergeFunc A resp2
+          cAstore2 <- clientGetStore A
+          lift $ cAstore2 `shouldBe` initialClientStore
+          -- Client A and Client B now have the same store
+          lift $ cAstore2 `shouldBe` cBstore2
+      it "does not run into a conflict if two clients both try to sync a deletion" $ \te -> forAllValid $ \items -> forAllValid $ \time1 ->
+        runTest te $ do
+          setupClient A $
+            initialClientStore {clientStoreDeletedItems = M.map (const time1) items}
+          -- Both client a and client b delete their items.
+          setupClient B $
+            initialClientStore {clientStoreDeletedItems = M.map (const time1) items}
+          -- The server still has the undeleted items
+          setupServer $ ServerStore {serverStoreItems = M.map (\i -> Timed i time1) items}
+          -- Client A makes sync request 1
+          req1 <- clientMakeSyncRequest A
+          -- The server processes sync request 1
+          resp1 <- serverProcessSync req1
+          sstore2 <- serverGetStore
+          lift $ do
+            resp1 `shouldBe` (emptySyncResponse {syncResponseClientDeleted = M.keysSet items})
+            sstore2 `shouldBe` (ServerStore {serverStoreItems = M.empty}) -- TODO will probably need some sort of tombstoning.
+                  -- Client A merges the response
+          mergeFunc A resp1
+          cAstore2 <- clientGetStore A
+          lift $ cAstore2 `shouldBe` initialClientStore
+          -- Client B makes sync request 2
+          req2 <- clientMakeSyncRequest B
+          -- The server processes sync request 2
+          resp2 <- serverProcessSync req2
+          sstore3 <- serverGetStore
+          lift $ do
+            resp2 `shouldBe` (emptySyncResponse {syncResponseClientDeleted = M.keysSet items})
+            sstore3 `shouldBe` sstore2
+          -- Client B merges the response
+          mergeFunc B resp2
+          cBstore2 <- clientGetStore B
+          lift $ do
+            cBstore2 `shouldBe` initialClientStore
+            -- Client A and Client B now have the same store
+            cAstore2 `shouldBe` cBstore2
+
+noDataLossSpec ::
+  ItemMergeStrategy Thing ->
+  SpecWith TestEnv
+noDataLossSpec strat = do
+  let mergeFunc = clientMergeSyncResponse strat
+  it "does not lose data after a conflict occurs" $ \te -> forAllValid $ \uuid -> forAllValid $ \time1 -> forAllValid $ \i1 -> forAllValid $ \i2 -> forAllValid $ \i3 ->
+    runTest te $ do
+      setupServer $ ServerStore {serverStoreItems = M.singleton uuid (Timed i1 time1)}
+      -- The server has an item
+      -- The first client has synced it, and modified it.
+      setupClient A $
+        initialClientStore
+          { clientStoreSyncedButChangedItems = M.singleton uuid (Timed i2 time1)
+          }
+      -- The second client has synced it too, and modified it too.
+      setupClient B $
+        initialClientStore
+          { clientStoreSyncedButChangedItems = M.singleton uuid (Timed i3 time1)
+          }
+      -- Client A makes sync request 1
+      req1 <- clientMakeSyncRequest A
+      -- The server processes sync request 1
+      resp1 <- serverProcessSync req1
+      sstore2 <- serverGetStore
+      let time2 = incrementServerTime time1
+      -- The server updates the item accordingly
+      lift $ do
+        resp1
+          `shouldBe` (emptySyncResponse {syncResponseClientChanged = M.singleton uuid time2})
+        sstore2
+          `shouldBe` (ServerStore {serverStoreItems = M.singleton uuid (Timed i2 time2)})
+      -- Client A merges the response
+      mergeFunc A resp1
+      cAstore2 <- clientGetStore A
+      lift $
+        cAstore2
+          `shouldBe` (initialClientStore {clientStoreSyncedItems = M.singleton uuid (Timed i2 time2)})
+      -- Client B makes sync request 2
+      req2 <- clientMakeSyncRequest B
+      -- The server processes sync request 2
+      resp2 <- serverProcessSync req2
+      sstore3 <- serverGetStore
+      -- The server reports a conflict and does not change its store
+      lift $ do
+        resp2
+          `shouldBe` (emptySyncResponse {syncResponseConflicts = M.singleton uuid (Timed i2 time2)})
+        sstore3 `shouldBe` sstore2
+      -- Client B merges the response
+      clientMergeSyncResponse mergeFromClientStrategy B resp2
+      cBstore2 <- clientGetStore B
+      -- Client does not update, but keeps its conflict
+      -- Client A and Client B now *do not* have the same store
+      lift $
+        cBstore2
+          `shouldBe` ( initialClientStore
+                         { clientStoreSyncedButChangedItems = M.singleton uuid (Timed i3 time1)
+                         }
+                     )
+
+noDivergenceSpec :: ItemMergeStrategy Thing -> SpecWith TestEnv
+noDivergenceSpec strat = do
+  let mergeFunc = clientMergeSyncResponse strat
+  it "does not diverge after a conflict occurs" $ \te ->
+    forAllValid $ \uuid -> forAllValid $ \time1 -> forAllValid $ \iS -> forAllValid $ \iA ->
+      forAllValid $ \iB ->
+        runTest te $ do
+          setupServer $ ServerStore {serverStoreItems = M.singleton uuid (Timed iS time1)}
+          -- The server has an item
+          -- The first client has synced it, and modified it.
+          setupClient A $
+            initialClientStore
+              { clientStoreSyncedButChangedItems = M.singleton uuid (Timed iA time1)
+              }
+          -- The second client has synced it too, and modified it too.
+          setupClient B $
+            initialClientStore
+              { clientStoreSyncedButChangedItems = M.singleton uuid (Timed iB time1)
+              }
+          -- Client A makes sync request 1
+          req1 <- clientMakeSyncRequest A
+          -- The server processes sync request 1
+          resp1 <- serverProcessSync req1
+          sstore2 <- serverGetStore
+          let time2 = incrementServerTime time1
+          -- The server updates the item accordingly
+          lift $ do
+            resp1
+              `shouldBe` (emptySyncResponse {syncResponseClientChanged = M.singleton uuid time2})
+            sstore2
+              `shouldBe` (ServerStore {serverStoreItems = M.singleton uuid (Timed iA time2)})
+          -- Client A merges the response
+          mergeFunc A resp1
+          cAstore2 <- clientGetStore A
+          -- Client A has the item from the server because there was no conflict.
+          lift $
+            cAstore2
+              `shouldBe` initialClientStore {clientStoreSyncedItems = M.singleton uuid (Timed iA time2)}
+          -- Client B makes sync request 2
+          req2 <- clientMakeSyncRequest B
+          -- The server processes sync request 2
+          resp2 <- serverProcessSync req2
+          sstore3 <- serverGetStore
+          -- The server reports a conflict and does not change its store
+          lift $ do
+            resp2
+              `shouldBe` (emptySyncResponse {syncResponseConflicts = M.singleton uuid (Timed iA time2)})
+            sstore3 `shouldBe` sstore2
+          -- Client B merges the response
+          mergeFunc B resp2
+          cBstore2 <- clientGetStore B
+          lift $ do
+            let expected = case itemMergeStrategyMergeChangeConflict strat iB iA of
+                  KeepLocal -> initialClientStore {clientStoreSyncedButChangedItems = M.singleton uuid (Timed iB time1)}
+                  TakeRemote -> initialClientStore {clientStoreSyncedItems = M.singleton uuid (Timed iA time2)}
+                  Merged im -> initialClientStore {clientStoreSyncedButChangedItems = M.singleton uuid (Timed im time2)}
+            cBstore2
+              `shouldBe` expected
+          -- In case of a previous merge, the synced item will still be changed, so we need to sync again with B and then with A
+          req3 <- clientMakeSyncRequest B
+          resp3 <- serverProcessSync req3
+          mergeFunc B resp3
+          cBstore3 <- clientGetStore B
+          req4 <- clientMakeSyncRequest A
+          resp4 <- serverProcessSync req4
+          mergeFunc A resp4
+          cAstore3 <- clientGetStore A
+          lift $
+            cBstore3 `shouldBe` cAstore3
+
+type T a = ReaderT TestEnv IO a
+
+runTest :: TestEnv -> T a -> IO a
+runTest = flip runReaderT
+
+runClientDB :: Client -> SqlPersistT IO a -> T a
+runClientDB num func = do
+  pool <- asks $ case num of
+    A -> testEnvClient1Pool
+    B -> testEnvClient2Pool
+  liftIO $ runSqlPool func pool
+
+runServerDB :: SqlPersistT IO a -> T a
+runServerDB func = do
+  pool <- asks testEnvServerPool
+  liftIO $ runSqlPool func pool
+
+type CS = ClientStore ClientThingId ServerThingId Thing
+
+type SReq = SyncRequest ClientThingId ServerThingId Thing
+
+type SS = ServerStore ServerThingId Thing
+
+type SResp = SyncResponse ClientThingId ServerThingId Thing
+
+setupClient :: Client -> CS -> T ()
+setupClient client = runClientDB client . setupClientThingQuery
+
+setupServer :: SS -> T ()
+setupServer = runServerDB . setupServerThingQuery
+
+clientGetStore :: Client -> T CS
+clientGetStore client = runClientDB client clientGetStoreThingQuery
+
+clientMakeSyncRequest :: Client -> T SReq
+clientMakeSyncRequest client = runClientDB client clientMakeSyncRequestThingQuery
+
+serverGetStore :: T SS
+serverGetStore = runServerDB serverGetStoreThingQuery
+
+serverProcessSync :: SReq -> T SResp
+serverProcessSync = runServerDB . serverProcessSyncThingQuery
+
+clientMergeSyncResponse :: ItemMergeStrategy Thing -> Client -> SResp -> T ()
+clientMergeSyncResponse strat client = runClientDB client . clientMergeSyncResponseThingQuery strat
+
+data Client = A | B
+  deriving (Show, Eq)
+
+data TestEnv
+  = TestEnv
+      { testEnvServerPool :: ConnectionPool,
+        testEnvClient1Pool :: ConnectionPool,
+        testEnvClient2Pool :: ConnectionPool
+      }
+
+twoClientsSpec :: SpecWith TestEnv -> Spec
+twoClientsSpec = around withTestEnv
+
+withTestEnv :: (TestEnv -> IO a) -> IO a
+withTestEnv func =
+  withServerPool $ \serverPool ->
+    withClientPool $ \client1Pool ->
+      withClientPool $ \client2Pool -> do
+        let tenv =
+              TestEnv
+                { testEnvServerPool = serverPool,
+                  testEnvClient1Pool = client1Pool,
+                  testEnvClient2Pool = client2Pool
+                }
+        liftIO $ func tenv
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
diff --git a/test/TestUtils.hs b/test/TestUtils.hs
new file mode 100644
--- /dev/null
+++ b/test/TestUtils.hs
@@ -0,0 +1,36 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE UndecidableInstances #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module TestUtils
+  ( module TestUtils,
+    module TestUtils.ServerDB,
+    module TestUtils.ClientDB,
+  )
+where
+
+import Control.Monad
+import Control.Monad.IO.Class
+import Control.Monad.Logger
+import Data.GenValidity.Persist ()
+import Database.Persist
+import Database.Persist.Sql
+import Database.Persist.Sqlite
+import TestUtils.ClientDB
+import TestUtils.ServerDB
+
+withServerPool :: (ConnectionPool -> IO a) -> IO a
+withServerPool func =
+  runNoLoggingT $ withSqlitePool ":memory:" 1 $ \serverPool -> do
+    flip runSqlPool serverPool $ void $ runMigrationSilent migrateServer
+    liftIO $ func serverPool
+
+withClientPool :: (ConnectionPool -> IO a) -> IO a
+withClientPool func =
+  runNoLoggingT $ withSqlitePool ":memory:" 1 $ \clientPool -> do
+    flip runSqlPool clientPool $ void $ runMigrationSilent migrateClient
+    liftIO $ func clientPool
diff --git a/test/TestUtils/ClientDB.hs b/test/TestUtils/ClientDB.hs
new file mode 100644
--- /dev/null
+++ b/test/TestUtils/ClientDB.hs
@@ -0,0 +1,172 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE StandaloneDeriving #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module TestUtils.ClientDB where
+
+import Data.Maybe
+import Data.Mergeful
+import Data.Mergeful.Persistent
+import Data.Validity
+import Data.Validity.Persist ()
+import Database.Persist.Sql
+import Database.Persist.TH
+import GHC.Generics (Generic)
+import TestUtils.ServerDB
+
+share
+  [mkPersist sqlSettings, mkMigrate "migrateClient"]
+  [persistLowerCase|
+
+ClientThing
+  -- All the fields of 'Thing' go here.
+  number Int
+  serverId ServerThingId Maybe -- Nothing means it's not been synced
+  serverTime ServerTime Maybe -- Nothing means it's not been synced
+  deleted Bool -- True means this item has been tombstoned, and it must have been synced before.
+  changed Bool -- True means it's been changed after it's been synced, it must have been synced before.
+
+  ClientUniqueServerId serverId !force
+
+  deriving Show
+  deriving Eq
+  deriving Ord
+  deriving Generic
+
+|]
+
+instance Validity ClientThing where
+  validate ct@ClientThing {..} =
+    mconcat
+      [ genericValidate ct,
+        declare "The server id and time are either both Nothing or both Just" $ case (clientThingServerId, clientThingServerTime) of
+          (Nothing, Nothing) -> True
+          (Just _, Just _) -> True
+          (Just _, Nothing) -> True
+          (Nothing, Just _) -> True,
+        declare "If it has been deleted, then it must have been synced" $
+          if clientThingDeleted
+            then isJust clientThingServerId
+            else True,
+        declare
+          "If it has been changed, then it must have been synced before"
+          $ if clientThingChanged
+            then isJust clientThingServerId
+            else True
+      ]
+
+setupUnsyncedClientQuery :: [Thing] -> SqlPersistT IO ()
+setupUnsyncedClientQuery = mapM_ $ \Thing {..} ->
+  insert_
+    ClientThing
+      { clientThingNumber = thingNumber,
+        clientThingServerId = Nothing,
+        clientThingServerTime = Nothing,
+        clientThingDeleted = False,
+        clientThingChanged = False
+      }
+
+setupClientThingQuery :: ClientStore ClientThingId ServerThingId Thing -> SqlPersistT IO ()
+setupClientThingQuery = setupClientQuery makeUnsyncedClientThing makeSyncedClientThing makeSyncedButChangedClientThing makeDeletedClientThing
+
+clientGetStoreThingQuery :: SqlPersistT IO (ClientStore ClientThingId ServerThingId Thing)
+clientGetStoreThingQuery =
+  clientGetStoreQuery
+    ClientThingServerId
+    ClientThingServerTime
+    ClientThingChanged
+    ClientThingDeleted
+    unmakeUnsyncedClientThing
+    unmakeSyncedClientThing
+    unmakeDeletedClientThing
+
+clientMakeSyncRequestThingQuery :: SqlPersistT IO (SyncRequest ClientThingId ServerThingId Thing)
+clientMakeSyncRequestThingQuery =
+  clientMakeSyncRequestQuery
+    ClientThingServerId
+    ClientThingServerTime
+    ClientThingChanged
+    ClientThingDeleted
+    unmakeUnsyncedClientThing
+    unmakeSyncedClientThing
+    unmakeDeletedClientThing
+
+clientMergeSyncResponseThingQuery :: ItemMergeStrategy Thing -> SyncResponse ClientThingId ServerThingId Thing -> SqlPersistT IO ()
+clientMergeSyncResponseThingQuery =
+  clientMergeSyncResponseQuery
+    ClientThingServerId
+    ClientThingServerTime
+    ClientThingChanged
+    ClientThingDeleted
+    makeSyncedClientThing
+    unmakeSyncedClientThing
+    clientThingRecordUpdates
+
+unmakeUnsyncedClientThing :: ClientThing -> Thing
+unmakeUnsyncedClientThing ClientThing {..} = Thing {thingNumber = clientThingNumber}
+
+makeUnsyncedClientThing :: Thing -> ClientThing
+makeUnsyncedClientThing Thing {..} =
+  ClientThing
+    { clientThingNumber = thingNumber,
+      clientThingDeleted = False,
+      clientThingServerId = Nothing,
+      clientThingChanged = False,
+      clientThingServerTime = Nothing
+    }
+
+unmakeSyncedClientThing :: ClientThing -> (ServerThingId, Timed Thing)
+unmakeSyncedClientThing ClientThing {..} =
+  ( fromJust clientThingServerId,
+    Timed
+      { timedValue = Thing {thingNumber = clientThingNumber},
+        timedTime = fromJust clientThingServerTime
+      }
+  )
+
+makeSyncedClientThing :: ServerThingId -> Timed Thing -> ClientThing
+makeSyncedClientThing sid (Timed Thing {..} st) =
+  ClientThing
+    { clientThingNumber = thingNumber,
+      clientThingServerId = Just sid,
+      clientThingServerTime = Just st,
+      clientThingChanged = False,
+      clientThingDeleted = False
+    }
+
+makeSyncedButChangedClientThing :: ServerThingId -> Timed Thing -> ClientThing
+makeSyncedButChangedClientThing sid (Timed Thing {..} st) =
+  ClientThing
+    { clientThingNumber = thingNumber,
+      clientThingServerId = Just sid,
+      clientThingServerTime = Just st,
+      clientThingChanged = True,
+      clientThingDeleted = False
+    }
+
+unmakeDeletedClientThing :: ClientThing -> (ServerThingId, ServerTime)
+unmakeDeletedClientThing ClientThing {..} =
+  (fromJust clientThingServerId, fromJust clientThingServerTime)
+
+makeDeletedClientThing :: ServerThingId -> ServerTime -> ClientThing
+makeDeletedClientThing sid st =
+  ClientThing
+    { clientThingNumber = 0, -- dummy
+      clientThingServerId = Just sid,
+      clientThingServerTime = Just st,
+      clientThingChanged = False, -- dummy
+      clientThingDeleted = True
+    }
+
+clientMakeThing :: ClientThing -> Thing
+clientMakeThing ClientThing {..} = Thing {thingNumber = clientThingNumber}
+
+clientThingRecordUpdates :: Thing -> [Update ClientThing]
+clientThingRecordUpdates Thing {..} = [ClientThingNumber =. thingNumber]
diff --git a/test/TestUtils/ServerDB.hs b/test/TestUtils/ServerDB.hs
new file mode 100644
--- /dev/null
+++ b/test/TestUtils/ServerDB.hs
@@ -0,0 +1,73 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module TestUtils.ServerDB where
+
+import Data.GenValidity
+import Data.GenValidity.Mergeful ()
+import Data.Mergeful
+import Data.Mergeful.Persistent
+import Database.Persist.Sql
+import Database.Persist.TH
+import GHC.Generics (Generic)
+
+-- The thing that we'll synchronise on
+newtype Thing = Thing {thingNumber :: Int}
+  deriving (Show, Eq, Ord, Generic)
+
+instance Validity Thing
+
+instance GenUnchecked Thing
+
+instance GenValid Thing
+
+share
+  [mkPersist sqlSettings, mkMigrate "migrateServer"]
+  [persistLowerCase|
+
+ServerThing
+  -- All the fields of 'Thing' go here.
+  number Int
+  time ServerTime
+
+  deriving Show
+  deriving Eq
+  deriving Ord
+  deriving Generic
+
+|]
+
+instance Validity ServerThing
+
+instance GenUnchecked ServerThing
+
+instance GenValid ServerThing
+
+setupServerThingQuery :: ServerStore ServerThingId Thing -> SqlPersistT IO ()
+setupServerThingQuery = setupServerQuery serverUnmakeThing
+
+serverGetStoreThingQuery :: SqlPersistT IO (ServerStore ServerThingId Thing)
+serverGetStoreThingQuery = serverGetStoreQuery (\(Entity sid sr) -> (sid, serverMakeThing sr))
+
+serverProcessSyncThingQuery :: forall ci. Ord ci => SyncRequest ci ServerThingId Thing -> SqlPersistT IO (SyncResponse ci ServerThingId Thing)
+serverProcessSyncThingQuery = serverProcessSyncQuery ServerThingTime [] serverMakeThing thingToServer serverRecordUpdates
+
+serverMakeThing :: ServerThing -> Timed Thing
+serverMakeThing ServerThing {..} = Timed {timedValue = Thing {thingNumber = serverThingNumber}, timedTime = serverThingTime}
+
+serverUnmakeThing :: ServerThingId -> Timed Thing -> Entity ServerThing
+serverUnmakeThing sid Timed {..} = Entity sid $ ServerThing {serverThingNumber = thingNumber timedValue, serverThingTime = timedTime}
+
+serverRecordUpdates :: Thing -> [Update ServerThing]
+serverRecordUpdates Thing {..} = [ServerThingNumber =. thingNumber]
+
+thingToServer :: Thing -> ServerThing
+thingToServer Thing {..} = ServerThing {serverThingTime = initialServerTime, serverThingNumber = thingNumber}
