packages feed

sofetch-0.1.0.0: src/Fetch/Mutate.hs

{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE TypeApplications #-}

-- | Principled mutation support for sofetch.
--
-- 'Mutate' is a free-monad-like transformer layered on top of 'Fetch'.
-- A computation is a sequence of __fetch phases__ (batched reads via
-- 'Fetch') interleaved with __mutation steps__ (sequential writes).
--
-- Mutations are inert data during 'Fetch' probing; they only
-- execute when the runner processes them. This guarantees:
--
-- * Within a fetch phase: all fetches batch via 'Fetch'\'s 'Applicative'.
-- * Between phases: mutations execute one at a time, sequentially.
-- * In @\<*\>@: fetches run first (batched), then mutations left-to-right.
-- * Cache consistency: 'reconcileCache' runs after each mutation,
--   before any subsequent fetch phase sees the cache.
module Fetch.Mutate
  ( -- * Mutation classes
    MutationSource(..)
  , MonadMutate(..)
    -- * Mutate transformer
  , Mutate(..)
  , Step(..)
  , liftFetch
    -- * Runners
  , runMutate
  ) where

import Fetch.Class
import Fetch.Cache (CacheRef, newCacheRef)
import Fetch.Batched (Fetch, FetchConfig(..), runFetch)

import Control.Exception (try)
import Control.Monad.Catch (MonadThrow(..), MonadCatch(..))

-- ──────────────────────────────────────────────
-- MutationSource
-- ──────────────────────────────────────────────

-- | How to execute a mutation in the source monad @m@.
--
-- The @m@ parameter replaces the old @env@ parameter, just as
-- in 'DataSource'. The monad @m@ provides access to any needed
-- resources (database connections, etc.).
--
-- @
-- instance MutationSource AppM UpdateUserName where
--   executeMutation (UpdateUserName uid name) =
--     updateUserInDB uid name
--
--   reconcileCache (UpdateUserName uid _) result cRef =
--     cacheWarm cRef (HM.singleton (UserId uid) result)
-- @
class (MutationKey k, Typeable (MutationResult k)) => MutationSource m k where
  -- | Execute the mutation. Called by the runner, never during
  -- 'Fetch' probing.
  executeMutation :: k -> m (MutationResult k)

  -- | Reconcile the cache after a successful mutation.
  -- Use this to evict stale entries or warm the cache with
  -- fresh data from the mutation response.
  --
  -- Note: 'reconcileCache' runs in @IO@ because cache operations
  -- are inherently @IO@-based ('CacheRef' is an 'IORef').
  --
  -- Default: no-op.
  reconcileCache :: k -> MutationResult k -> CacheRef -> IO ()
  reconcileCache _ _ _ = pure ()

-- ──────────────────────────────────────────────
-- MonadMutate
-- ──────────────────────────────────────────────

-- | The mutation interface. Extends 'MonadFetch' with write operations.
--
-- @mutate@ ends the current fetch phase, executes the mutation via
-- the runner, reconciles the cache, and returns the result. Subsequent
-- fetches see the reconciled cache.
class MonadFetch m n => MonadMutate m n | n -> m where
  -- | Execute a mutation. Throws on error.
  mutate :: MutationSource m k => k -> n (MutationResult k)

  -- | Execute a mutation with explicit error handling.
  tryMutate :: MutationSource m k
            => k -> n (Either SomeException (MutationResult k))

-- ──────────────────────────────────────────────
-- Step
-- ──────────────────────────────────────────────

-- | The result of a fetch phase: either a final value or a mutation
-- boundary with a continuation.
data Step m n a
  = StepDone a
  | forall k. MutationSource m k
    => StepMutate k (MutationResult k -> Mutate m n a)
  | forall k. MutationSource m k
    => StepTryMutate k (Either SomeException (MutationResult k) -> Mutate m n a)

-- | Map a function over the final value of a 'Step'.
mapStep :: Monad n => (a -> b) -> Step m n a -> Step m n b
mapStep f (StepDone a)          = StepDone (f a)
mapStep f (StepMutate k cont)   = StepMutate k (fmap f . cont)
mapStep f (StepTryMutate k cont) = StepTryMutate k (fmap f . cont)

-- | Combine two 'Step' values applicatively.
-- Fetch-phase results combine directly; mutations sequence left-to-right.
apStep :: Monad n => Step m n (a -> b) -> Step m n a -> Step m n b
apStep (StepDone f) (StepDone x) =
  StepDone (f x)
apStep (StepDone f) (StepMutate k cont) =
  StepMutate k (fmap f . cont)
apStep (StepDone f) (StepTryMutate k cont) =
  StepTryMutate k (fmap f . cont)
apStep (StepMutate k cont) (StepDone x) =
  StepMutate k (fmap ($ x) . cont)
apStep (StepTryMutate k cont) (StepDone x) =
  StepTryMutate k (fmap ($ x) . cont)
-- Two mutations: sequence left first, then embed the right step
-- into the left's continuation.
apStep (StepMutate k1 cont1) step2 =
  StepMutate k1 $ \r1 ->
    cont1 r1 <*> embedStep step2
apStep (StepTryMutate k1 cont1) step2 =
  StepTryMutate k1 $ \r1 ->
    cont1 r1 <*> embedStep step2

-- | Inject a 'Step' into 'Mutate' as a trivial fetch phase.
embedStep :: Monad n => Step m n a -> Mutate m n a
embedStep (StepDone a)          = Mutate (pure (StepDone a))
embedStep (StepMutate k cont)   = Mutate (pure (StepMutate k cont))
embedStep (StepTryMutate k cont) = Mutate (pure (StepTryMutate k cont))

-- ──────────────────────────────────────────────
-- Mutate
-- ──────────────────────────────────────────────

-- | A computation that interleaves batched fetch phases with
-- sequential mutations.
--
-- @m@ is the source monad (same as in 'DataSource' and 'Fetch').
-- @n@ is the base monad for 'Fetch'.
--
-- In practice, @n@ is always @m@ and 'Mutate m m' is layered on
-- 'Fetch m'.
--
-- @
-- do (user, posts) <- (,) \<$\> fetch uid \<*\> fetch pid  -- batched
--    updated <- mutate (UpdateUserName uid "new")        -- mutation boundary
--    fetch uid                                            -- cache hit
-- @
newtype Mutate m n a = Mutate
  { unMutate :: Fetch n (Step m n a) }

instance Monad n => Functor (Mutate m n) where
  fmap f (Mutate inner) = Mutate (fmap (mapStep f) inner)

instance Monad n => Applicative (Mutate m n) where
  pure a = Mutate (pure (StepDone a))

  Mutate ff <*> Mutate fx = Mutate $
    -- Delegate to Fetch's Applicative for batching, then combine Steps.
    liftA2 apStep ff fx

instance Monad n => Monad (Mutate m n) where
  Mutate ma >>= f = Mutate $ do
    step <- ma  -- runs in Fetch
    case step of
      StepDone a -> unMutate (f a)  -- continue in same Fetch phase
      StepMutate k cont ->
        pure $ StepMutate k (\r -> cont r >>= f)
      StepTryMutate k cont ->
        pure $ StepTryMutate k (\r -> cont r >>= f)

-- ──────────────────────────────────────────────
-- Instances
-- ──────────────────────────────────────────────

instance Monad m => MonadFetch m (Mutate m m) where
  fetch k      = Mutate (StepDone <$> fetch k)
  tryFetch k   = Mutate (StepDone <$> tryFetch k)
  primeCache k v = Mutate (StepDone <$> primeCache k v)

instance Monad m => MonadMutate m (Mutate m m) where
  mutate k    = Mutate (pure (StepMutate k pure))
  tryMutate k = Mutate (pure (StepTryMutate k pure))

instance MonadFail m => MonadFail (Mutate m m) where
  fail msg = Mutate (fail msg)

instance MonadThrow m => MonadThrow (Mutate m m) where
  throwM e = Mutate (throwM e)

-- | Propagates the handler through both 'Fetch' round continuations
-- (handled by 'Fetch'\'s 'MonadCatch') and 'Step' mutation
-- continuations.
instance MonadCatch m => MonadCatch (Mutate m m) where
  catch (Mutate inner) handler = Mutate $
    fmap catchStep $
      catch inner (\ex -> unMutate (handler ex))
    where
      catchStep (StepDone a)           = StepDone a
      catchStep (StepMutate k cont)    = StepMutate k (\r -> catch (cont r) handler)
      catchStep (StepTryMutate k cont) = StepTryMutate k (\r -> catch (cont r) handler)

-- | Lift a 'Fetch' computation into 'Mutate'.
-- The entire 'Fetch' runs as a single fetch phase.
liftFetch :: Monad m => Fetch m a -> Mutate m m a
liftFetch action = Mutate (StepDone <$> action)

-- ──────────────────────────────────────────────
-- Runners
-- ──────────────────────────────────────────────

-- | Run a 'Mutate' computation.
--
-- @
-- let cfg = fetchConfig (runAppM env) liftIO
-- runMutate cfg action
-- @
runMutate :: forall m a. Monad m => FetchConfig m -> Mutate m m a -> m a
runMutate cfg action = do
  cRef <- case configCache cfg of
    Just ref -> pure ref
    Nothing  -> configLift cfg newCacheRef
  let fetchCfg = cfg { configCache = Just cRef }
      go :: Mutate m m a -> m a
      go (Mutate fetchPhase) = do
        step <- runFetch fetchCfg fetchPhase
        case step of
          StepDone a -> pure a
          StepMutate k cont -> do
            result <- executeMutation k
            configLift cfg $ reconcileCache @m k result cRef
            go (cont result)
          StepTryMutate k cont -> do
            result <- configLift cfg $ try $ configLower cfg $ executeMutation k
            case result of
              Right v -> do
                configLift cfg $ reconcileCache @m k v cRef
                go (cont (Right v))
              Left ex ->
                go (cont (Left ex))
  go action