packages feed

atelier-core 0.2.0.0 → 0.3.0.0

raw patch · 15 files changed

+419/−195 lines, 15 filesdep ~atelier-corenew-uploaderPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: atelier-core

API changes (from Hackage documentation)

- Atelier.Effects.Publishing: data Pub event (a :: Type -> Type) b
- Atelier.Effects.Publishing: data Sub event (a :: Type -> Type) b
- Atelier.Effects.Publishing: forkListener :: forall event (es :: [Effect]). (Conc :> es, Concurrent :> es, Sub event :> es) => (UTCTime -> event -> Eff es ()) -> Eff es ()
- Atelier.Effects.Publishing: forkListener_ :: forall event (es :: [Effect]). (Conc :> es, Concurrent :> es, Sub event :> es) => (event -> Eff es ()) -> Eff es ()
- Atelier.Effects.Publishing: instance GHC.Internal.Show.Show (Atelier.Effects.Publishing.OnceEx ev)
- Atelier.Effects.Publishing: listen :: forall event (es :: [Effect]). Sub event :> es => (UTCTime -> event -> Eff es ()) -> Eff es Void
- Atelier.Effects.Publishing: listenOnce :: forall event (es :: [Effect]). Sub event :> es => Eff es (UTCTime, event)
- Atelier.Effects.Publishing: listenOnce_ :: forall event (es :: [Effect]). Sub event :> es => Eff es event
- Atelier.Effects.Publishing: listenWith :: forall event (es :: [Effect]). (HasCallStack, Sub event :> es) => Eff es () -> (UTCTime -> event -> Eff es ()) -> Eff es Void
- Atelier.Effects.Publishing: listenWith_ :: forall event (es :: [Effect]). Sub event :> es => Eff es () -> (event -> Eff es ()) -> Eff es Void
- Atelier.Effects.Publishing: listen_ :: forall event (es :: [Effect]). Sub event :> es => (event -> Eff es ()) -> Eff es Void
- Atelier.Effects.Publishing: publish :: forall event (es :: [Effect]). (HasCallStack, Pub event :> es) => event -> Eff es ()
- Atelier.Effects.Publishing: runPubWriter :: forall event (es :: [Effect]) a. Writer [event] :> es => Eff (Pub event ': es) a -> Eff es a
+ Atelier.Effects.Conc: restartableFork :: forall (es :: [Effect]) x a. Conc :> es => Eff es x -> Eff es a -> Eff es Void
+ Atelier.Effects.Input: fromState :: forall i (es :: [Effect]) a. State i :> es => Eff (Input i ': es) a -> Eff es a
+ Atelier.Effects.Input: toReader :: forall i (es :: [Effect]) a. Input i :> es => Eff (Reader i ': es) a -> Eff es a
+ Atelier.Effects.Process: getExecutablePath :: forall (es :: [Effect]). (HasCallStack, Process :> es) => Eff es FilePath
+ Atelier.Effects.Process: readProcess :: forall i o e (es :: [Effect]). (HasCallStack, Process :> es) => ProcessConfig i o e -> Eff es (ExitCode, LByteString, LByteString)
+ Atelier.Effects.Process: runProcess :: forall i o e (es :: [Effect]). (HasCallStack, Process :> es) => ProcessConfig i o e -> Eff es ExitCode
+ Atelier.Effects.Publishing: runPubSub_ :: forall event (es :: [Effect]) a. (Chan :> es, Clock :> es) => Eff (Pub event ': (Sub event ': es)) a -> Eff es a
+ Atelier.Effects.Publishing.Pub: [Publish] :: forall event (a :: Type -> Type). event -> Pub event a ()
+ Atelier.Effects.Publishing.Pub: consume :: forall event (es :: [Effect]) a. (event -> Eff es ()) -> Eff (Pub event ': es) a -> Eff es a
+ Atelier.Effects.Publishing.Pub: data Pub event (a :: Type -> Type) b
+ Atelier.Effects.Publishing.Pub: map :: forall e1 e2 (es :: [Effect]) a. Pub e2 :> es => (e1 -> e2) -> Eff (Pub e1 ': es) a -> Eff es a
+ Atelier.Effects.Publishing.Pub: mapM :: forall e1 e2 (es :: [Effect]) a. Pub e2 :> es => (e1 -> Eff es e2) -> Eff (Pub e1 ': es) a -> Eff es a
+ Atelier.Effects.Publishing.Pub: publish :: forall event (es :: [Effect]). (HasCallStack, Pub event :> es) => event -> Eff es ()
+ Atelier.Effects.Publishing.Pub: runNoOp :: forall event (es :: [(Type -> Type) -> Type -> Type]) a. Eff (Pub event ': es) a -> Eff es a
+ Atelier.Effects.Publishing.Pub: toWriter :: forall event (es :: [Effect]) a. Writer [event] :> es => Eff (Pub event ': es) a -> Eff es a
+ Atelier.Effects.Publishing.Sub: [ListenWith] :: forall (a :: Type -> Type) event. a () -> (UTCTime -> event -> a ()) -> Sub event a Void
+ Atelier.Effects.Publishing.Sub: data Sub event (a :: Type -> Type) b
+ Atelier.Effects.Publishing.Sub: forkListener :: forall event (es :: [Effect]). (Conc :> es, Concurrent :> es, Sub event :> es) => (UTCTime -> event -> Eff es ()) -> Eff es ()
+ Atelier.Effects.Publishing.Sub: forkListener_ :: forall event (es :: [Effect]). (Conc :> es, Concurrent :> es, Sub event :> es) => (event -> Eff es ()) -> Eff es ()
+ Atelier.Effects.Publishing.Sub: instance GHC.Internal.Show.Show (Atelier.Effects.Publishing.Sub.OnceEx ev)
+ Atelier.Effects.Publishing.Sub: listen :: forall event (es :: [Effect]). Sub event :> es => (UTCTime -> event -> Eff es ()) -> Eff es Void
+ Atelier.Effects.Publishing.Sub: listenOnce :: forall event (es :: [Effect]). Sub event :> es => Eff es (UTCTime, event)
+ Atelier.Effects.Publishing.Sub: listenOnce_ :: forall event (es :: [Effect]). Sub event :> es => Eff es event
+ Atelier.Effects.Publishing.Sub: listenUntil :: forall event (es :: [Effect]) a. Sub event :> es => (UTCTime -> event -> Maybe a) -> Eff es (UTCTime, a)
+ Atelier.Effects.Publishing.Sub: listenUntilM :: forall event (es :: [Effect]) a. Sub event :> es => (UTCTime -> event -> Eff es (Maybe a)) -> Eff es (UTCTime, a)
+ Atelier.Effects.Publishing.Sub: listenUntilM_ :: forall event (es :: [Effect]) a. Sub event :> es => (event -> Eff es (Maybe a)) -> Eff es a
+ Atelier.Effects.Publishing.Sub: listenUntil_ :: forall event (es :: [Effect]) a. Sub event :> es => (event -> Maybe a) -> Eff es a
+ Atelier.Effects.Publishing.Sub: listenWith :: forall event (es :: [Effect]). (HasCallStack, Sub event :> es) => Eff es () -> (UTCTime -> event -> Eff es ()) -> Eff es Void
+ Atelier.Effects.Publishing.Sub: listenWith_ :: forall event (es :: [Effect]). Sub event :> es => Eff es () -> (event -> Eff es ()) -> Eff es Void
+ Atelier.Effects.Publishing.Sub: listen_ :: forall event (es :: [Effect]). Sub event :> es => (event -> Eff es ()) -> Eff es Void
- Atelier.Effects.Conc: restartableForkWith :: forall (es :: [Effect]) r a. Conc :> es => Eff es () -> Eff es r -> (r -> Eff es a) -> Eff es Void
+ Atelier.Effects.Conc: restartableForkWith :: forall (es :: [Effect]) x r a. Conc :> es => Eff es x -> Eff es r -> (r -> Eff es a) -> Eff es Void

Files

CHANGELOG.md view
@@ -5,6 +5,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to the [PVP](https://pvp.haskell.org/). +## [Unreleased]++## [0.3.0.0] - 2026-08-06++### Added++- `Atelier.Effects.Process`: `getExecutablePath` returns the absolute path of+  the currently running executable, for re-invoking the program as a+  subprocess.+- `Atelier.Effects.Publishing.Pub:` `map` and `mapM` allows mapping over+  published effects, producing a new `Pub` effect with the transformed values.++### Changed++- Moved `Pub` and `Sub` out of `Atelier.Effects.Publishing` to separate modules,+  `Atelier.Effects.Publishing.Pub` and `Atelier.Effects.Publishing.Sub`+  respectively.+ ## [0.2.0.0] - 2026-06-26  ### Added
README.md view
@@ -23,10 +23,10 @@  ## Part of atelier -- [`atelier-prelude`](https://github.com/atelier-hub/tricorder/tree/main/atelier-prelude) — relude-based prelude with Effectful conventions-- [`atelier-core`](https://github.com/atelier-hub/tricorder/tree/main/atelier-core) — this package-- [`atelier-db`](https://github.com/atelier-hub/tricorder/tree/main/atelier-db) — relational database effect (Hasql/Rel8)-- [`atelier-testing`](https://github.com/atelier-hub/tricorder/tree/main/atelier-testing) — database-backed test utilities+- [`atelier-prelude`](https://github.com/tweag/tricorder/tree/main/atelier-prelude) — relude-based prelude with Effectful conventions+- [`atelier-core`](https://github.com/tweag/tricorder/tree/main/atelier-core) — this package+- [`atelier-db`](https://github.com/tweag/tricorder/tree/main/atelier-db) — relational database effect (Hasql/Rel8)+- [`atelier-testing`](https://github.com/tweag/tricorder/tree/main/atelier-testing) — database-backed test utilities  ## License 
atelier-core.cabal view
@@ -5,12 +5,12 @@ -- see: https://github.com/sol/hpack  name:           atelier-core-version:        0.2.0.0+version:        0.3.0.0 synopsis:       Foundational Effectful-based effects and utilities description:    Core effects and utilities for effect-based applications, built on Effectful — part of the atelier toolkit. category:       Control-homepage:       https://github.com/atelier-hub/tricorder#readme-bug-reports:    https://github.com/atelier-hub/tricorder/issues+homepage:       https://github.com/tweag/tricorder#readme+bug-reports:    https://github.com/tweag/tricorder/issues author:         Christian Georgii maintainer:     christian.georgii@tweag.io license:        MIT@@ -27,7 +27,7 @@  source-repository head   type: git-  location: https://github.com/atelier-hub/tricorder+  location: https://github.com/tweag/tricorder  library   exposed-modules:@@ -64,6 +64,8 @@       Atelier.Effects.Process       Atelier.Effects.Process.Internal       Atelier.Effects.Publishing+      Atelier.Effects.Publishing.Pub+      Atelier.Effects.Publishing.Sub       Atelier.Effects.Tally       Atelier.Effects.Timeout       Atelier.Effects.UUID@@ -165,6 +167,7 @@       Unit.Atelier.Effects.FileWatcherSpec       Unit.Atelier.Effects.IteratorSpec       Unit.Atelier.Effects.LogSpec+      Unit.Atelier.Effects.Publishing.PubSpec       Unit.Atelier.Effects.PublishingSpec       Unit.Atelier.Effects.TallySpec       Unit.Atelier.Effects.YieldSpec@@ -199,7 +202,7 @@   build-depends:       aeson ==2.2.*     , async ==2.2.*-    , atelier-core ==0.2.*+    , atelier-core ==0.3.*     , atelier-prelude ==0.1.*     , base >=4.18 && <4.23     , bytestring >=0.11 && <0.13
src/Atelier/Effects/Conc.hs view
@@ -19,6 +19,7 @@       -- * Scope     , Scope (..)     , scoped+    , restartableFork     , restartableForkWith     , restartableForkLoop @@ -87,11 +88,33 @@ makeEffect ''Conc  +-- | Forks an action in a loop. Each time @signal@ returns, the current fork is+-- cancelled, and forked again.+restartableFork+    :: (Conc :> es)+    => Eff es x+    -- ^ Signal action+    -> Eff es a+    -- ^ Cancellable action+    -> Eff es Void+restartableFork signal action = forever $ scoped do+    _ <- fork action+    signal++ -- | Forks an action in a loop, with a setup step that runs in the scope before -- each fork. Each time @signal@ returns, the current fork is cancelled, and -- setup and fork are run again. The setup result is passed to the forked -- action, structurally guaranteeing it completes before the fork starts.-restartableForkWith :: (Conc :> es) => Eff es () -> Eff es r -> (r -> Eff es a) -> Eff es Void+restartableForkWith+    :: (Conc :> es)+    => Eff es x+    -- ^ Signalling action to block on.+    -> Eff es r+    -- ^ Setup action that is run for each iteration+    -> (r -> Eff es a)+    -- ^ Action to run with the result of the setup+    -> Eff es Void restartableForkWith signal setup action = forever $ scoped do     r <- setup     _ <- fork (action r)
src/Atelier/Effects/Input.hs view
@@ -8,13 +8,19 @@     , input     , runInputEff     , runInputConst+    , toReader+    , fromState     ) where  import Effectful (Effect) import Effectful.Dispatch.Dynamic (interpret_)+import Effectful.Reader.Static (Reader, runReader)+import Effectful.State.Static.Shared (State) import Effectful.TH (makeEffect) +import Effectful.State.Static.Shared qualified as State + -- | Request a value from the effect system. -- -- This effect does not carry any@@ -45,3 +51,14 @@ -- 'Effectful.Reader.Local', but is available here for testing. runInputConst :: i -> Eff (Input i : es) a -> Eff es a runInputConst = runInputEff . pure+++-- | Retrieves the value once, and exposes it as a 'Reader' effect.+toReader :: (Input i :> es) => Eff (Reader i : es) a -> Eff es a+toReader act = do+    x <- input+    runReader x act+++fromState :: (State i :> es) => Eff (Input i : es) a -> Eff es a+fromState = interpret_ \Input -> State.get
src/Atelier/Effects/Internal/Coroutine.hs view
@@ -52,11 +52,11 @@  import Atelier.Effects.Chan (Chan) import Atelier.Effects.Conc (Conc)-import Atelier.Effects.Publishing (Sub)+import Atelier.Effects.Publishing.Sub (Sub)  import Atelier.Effects.Chan qualified as Chan import Atelier.Effects.Conc qualified as Conc-import Atelier.Effects.Publishing qualified as Sub+import Atelier.Effects.Publishing.Sub qualified as Sub   -- * Yield
src/Atelier/Effects/Iterator.hs view
@@ -17,11 +17,11 @@  import Atelier.Effects.Chan (Chan) import Atelier.Effects.Conc (Conc)-import Atelier.Effects.Publishing (Sub)+import Atelier.Effects.Publishing.Sub (Sub)  import Atelier.Effects.Chan qualified as Chan import Atelier.Effects.Conc qualified as Conc-import Atelier.Effects.Publishing qualified as Sub+import Atelier.Effects.Publishing.Sub qualified as Sub   -- | A pull-based iterator of (potentially infinite) values.
src/Atelier/Effects/Process.hs view
@@ -28,11 +28,14 @@        -- * Operations     , readProcessStdout+    , readProcess+    , runProcess     , readProcessSafe     , withProcessGroup     , terminateProcessGroup     , interruptProcessGroup     , waitExitCode+    , getExecutablePath        -- * Interpreters     , runProcessIO@@ -58,6 +61,7 @@     , shell     ) +import System.Environment qualified as Env import System.Process.Typed qualified as TP  import Atelier.Effects.Process.Internal (RunningProcess (..))@@ -81,6 +85,15 @@ data Process :: Effect where     -- | Run a process to completion, returning its exit code and captured stdout.     ReadProcessStdout :: ProcessConfig i o e -> Process m (ExitCode, LByteString)+    -- | Run a process to completion, capturing both stdout and stderr along with+    -- its exit code.+    ReadProcess :: ProcessConfig i o e -> Process m (ExitCode, LByteString, LByteString)+    -- | Run a process to completion, returning only its exit code. Unlike+    -- 'ReadProcessStdout' this does not capture stdout.+    RunProcess :: ProcessConfig i o e -> Process m ExitCode+    -- | The absolute path of the currently running executable, for re-invoking+    -- this program as a subprocess.+    GetExecutablePath :: Process m FilePath     -- | Spawn a process and return its handle. Internal; callers use 'withProcessGroup'.     StartProcess :: ProcessConfig i o e -> Process m (RunningProcess i o e)     -- | Terminate the leader and close its streams. Internal; does not reach the@@ -148,6 +161,9 @@ runProcessIO :: (IOE :> es) => Eff (Process : es) a -> Eff es a runProcessIO = interpret_ \case     ReadProcessStdout cfg -> liftIO $ TP.readProcessStdout cfg+    ReadProcess cfg -> liftIO $ TP.readProcess cfg+    RunProcess cfg -> liftIO $ TP.runProcess cfg+    GetExecutablePath -> liftIO Env.getExecutablePath     StartProcess cfg -> liftIO $ RunningProcess <$> TP.startProcess cfg     StopProcess (RunningProcess p) -> liftIO $ TP.stopProcess p     WaitExitCode (RunningProcess p) -> liftIO $ TP.waitExitCode p
src/Atelier/Effects/Publishing.hs view
@@ -1,144 +1,23 @@--- | A typed publish/subscribe effect pair.------ 'Pub' publishes events of a given type; 'Sub' subscribes and delivers each--- published event to a listener. 'runPubSub' wires the two together over an--- internal broadcast channel, propagating tracing context from publisher to--- listener; 'runPubWriter' instead records published events to a 'Writer', for--- tests.------ Subscriptions are established asynchronously, so a listener you intend to--- publish to should be started with 'forkListener' or 'forkListener_', which--- block until the subscription is live and therefore cannot miss early events. module Atelier.Effects.Publishing-    ( Pub-    , Sub-    , listen-    , listen_-    , listenWith-    , listenWith_-    , listenOnce-    , listenOnce_-    , forkListener-    , forkListener_-    , publish-    , runPubSub-    , runPubWriter+    ( runPubSub+    , runPubSub_     ) where  import Data.Time (UTCTime)-import Effectful (Effect)-import Effectful.Concurrent.STM-    ( Concurrent-    , atomically-    , newEmptyTMVar-    , putTMVar-    , takeTMVar-    )-import Effectful.Dispatch.Dynamic (interpretWith, interpretWith_, interpret_, localSeqUnlift)-import Effectful.Error.Static (runErrorNoCallStack, throwError)-import Effectful.TH (makeEffect)-import Effectful.Writer.Static.Shared (Writer, tell)--import Text.Show qualified as S+import Effectful.Dispatch.Dynamic (interpret, interpretWith, interpretWith_, interpret_, localSeqUnlift)  import Atelier.Effects.Chan (Chan) import Atelier.Effects.Clock (Clock)-import Atelier.Effects.Conc (Conc, fork_) import Atelier.Effects.Monitoring.Tracing (SpanContext, Tracing)+import Atelier.Effects.Publishing.Pub (Pub (..))+import Atelier.Effects.Publishing.Sub (Sub (..))  import Atelier.Effects.Chan qualified as Chan import Atelier.Effects.Clock qualified as Clock import Atelier.Effects.Monitoring.Tracing qualified as Tracing  --- | Effect for publishing events of type @event@.-data Pub (event :: Type) :: Effect where-    -- | Publish an event to all current subscribers.-    Publish :: event -> Pub event m ()----- | Effect for subscribing to events of type @event@.-data Sub (event :: Type) :: Effect where-    -- | Subscribe, then run @onSubscribed@ once the subscription is established-    -- — after the internal channel has been duplicated and before any event is-    -- delivered — and thereafter deliver every published event to the listener,-    -- forever. The @onSubscribed@ hook lets a caller synchronize on "subscribed"-    -- so a concurrently-started publisher cannot race ahead of the subscription-    -- and have its events missed. Most callers want 'listen' (no hook); a caller-    -- that forks the listener and then publishes must wait on this hook first.-    ListenWith :: m () -> (UTCTime -> event -> m ()) -> Sub event m Void---makeEffect ''Pub-makeEffect ''Sub----- | Subscribe and deliver every published event to the listener, forever.--- Defined in terms of 'listenWith' with a no-op subscribed hook.-listen :: (Sub event :> es) => (UTCTime -> event -> Eff es ()) -> Eff es Void-listen = listenWith (pure ())----- | Like 'listen', but the listener ignores the event timestamp.-listen_ :: (Sub event :> es) => (event -> Eff es ()) -> Eff es Void-listen_ listener = listen $ \_timestamp event -> listener event----- | Like 'listen_', but runs @onSubscribed@ once the subscription is--- established and before any event is delivered. See 'ListenWith'.-listenWith_ :: (Sub event :> es) => Eff es () -> (event -> Eff es ()) -> Eff es Void-listenWith_ onSubscribed listener = listenWith onSubscribed $ \_timestamp event -> listener event----- | Fork a background listener and block until it has actually subscribed,--- then return. The listener runs until the enclosing 'Conc' scope closes.------ This is the safe way to start a listener you intend to publish to: a plain--- @'fork_' . 'listen'@ followed by a 'publish' races the subscription (which--- happens asynchronously in the forked thread) and, under scheduler pressure,--- can drop early events and wedge the listener forever. 'forkListener' closes--- that window by waiting on the subscribed hook before returning.-forkListener-    :: forall event es-     . (Conc :> es, Concurrent :> es, Sub event :> es)-    => (UTCTime -> event -> Eff es ())-    -> Eff es ()-forkListener listener = do-    subscribed <- atomically newEmptyTMVar-    fork_ $ listenWith (atomically (putTMVar subscribed ())) listener-    atomically (takeTMVar subscribed)----- | Like 'forkListener', but the listener ignores the timestamp.-forkListener_-    :: forall event es-     . (Conc :> es, Concurrent :> es, Sub event :> es)-    => (event -> Eff es ())-    -> Eff es ()-forkListener_ listener = forkListener @event (\_timestamp event -> listener event)----- | Wait for a single event and then return said event.-listenOnce :: forall event es. (Sub event :> es) => Eff es (UTCTime, event)-listenOnce = do-    res <- runErrorNoCallStack-        $ listen-        $ \timestamp event -> throwError $ OnceEx (timestamp, event)-    case res of-        Left (OnceEx x) -> pure x-        Right v -> absurd v----- | Same as 'listenOnce', but discards the timestamp.-listenOnce_ :: (Sub event :> es) => Eff es event-listenOnce_ = snd <$> listenOnce---data OnceEx ev = OnceEx ev-instance Show (OnceEx ev) where show _ = "OnceEx"-- -- | Internal wrapper for events with trace context data TracedEvent event = TracedEvent     { event :: event@@ -147,8 +26,9 @@     }  --- | Runs Pub and Sub effects with an internal channel for a specific event type.--- Automatically captures span context from the publisher and creates linked spans in listeners.+-- | Runs 'Pub' and 'Sub' effects with an internal channel for a specific event+-- type. Automatically captures span context from the publisher and creates+-- linked spans in listeners. runPubSub     :: forall event es a      . ( Chan :> es@@ -177,9 +57,25 @@     handleSub . handlePub $ action  --- | Handler that uses a provided Writer effect instead of actually publishing.--- Useful for testing and inspecting what events were published.-runPubWriter :: forall event es a. (Writer [event] :> es) => Eff (Pub event : es) a -> Eff es a-runPubWriter =-    interpret_ \case-        Publish event -> tell [event]+-- | Runs 'Pub' and 'Sub' effects with an internal channel for a specific event+-- type.+runPubSub_+    :: forall event es a+     . (Chan :> es, Clock :> es)+    => Eff (Pub event : Sub event : es) a -> Eff es a+runPubSub_ action = do+    (inChan, _) <- Chan.newChan @(UTCTime, event)+    let handlePub = interpret_ \case+            Publish event -> do+                timestamp <- Clock.currentTime+                Chan.writeChan inChan (timestamp, event)++        handleSub = interpret \env -> \case+            ListenWith onSubscribed listener -> localSeqUnlift env \unlift -> do+                chan <- Chan.dupChan inChan+                unlift onSubscribed+                forever do+                    (timestamp, event) <- Chan.readChan chan+                    unlift $ listener timestamp event++    handleSub . handlePub $ action
+ src/Atelier/Effects/Publishing/Pub.hs view
@@ -0,0 +1,57 @@+module Atelier.Effects.Publishing.Pub+    ( -- * Effect+      Pub (..)+    , publish++      -- * Interpeters+    , runNoOp+    , toWriter+    , map+    , mapM+    , consume+    ) where++import Effectful (Effect)+import Effectful.Dispatch.Dynamic (interpret_)+import Effectful.TH (makeEffect)+import Effectful.Writer.Static.Shared (Writer, tell)+import Prelude hiding (map, mapM)+++-- | Effect for publishing events of type @event@.+data Pub (event :: Type) :: Effect where+    -- | Publish an event to all current subscribers.+    Publish :: event -> Pub event m ()+++makeEffect ''Pub+++runNoOp :: forall event es a. Eff (Pub event : es) a -> Eff es a+runNoOp = interpret_ \(Publish _) -> pure ()+++-- | Handler that uses a provided Writer effect instead of actually publishing.+-- Useful for testing and inspecting what events were published.+toWriter :: forall event es a. (Writer [event] :> es) => Eff (Pub event : es) a -> Eff es a+toWriter =+    interpret_ \case+        Publish event -> tell [event]+++-- | Convert published events of one type into another, utilizing an existing+-- effect in the effect stack.+map :: forall e1 e2 es a. (Pub e2 :> es) => (e1 -> e2) -> Eff (Pub e1 : es) a -> Eff es a+map f = interpret_ \(Publish event) -> publish $ f event+++-- | Convert published events of one type into another with an effectful+-- transformation, utilizing an existing effect int he effect stack.+mapM :: forall e1 e2 es a. (Pub e2 :> es) => (e1 -> Eff es e2) -> Eff (Pub e1 : es) a -> Eff es a+mapM f = interpret_ \(Publish event) -> f event >>= publish+++-- | Perform an effectful action for each published event.+consume :: (event -> Eff es ()) -> Eff (Pub event : es) a -> Eff es a+consume handler = interpret_ \case+    Publish event -> handler event
+ src/Atelier/Effects/Publishing/Sub.hs view
@@ -0,0 +1,152 @@+module Atelier.Effects.Publishing.Sub+    ( Sub (..)+    , listen+    , listen_+    , listenWith+    , listenWith_+    , listenOnce+    , listenOnce_+    , listenUntil+    , listenUntil_+    , listenUntilM+    , listenUntilM_+    , forkListener+    , forkListener_+    ) where++import Data.Time (UTCTime)+import Effectful (Effect, inject)+import Effectful.Concurrent.STM+    ( Concurrent+    , atomically+    , newEmptyTMVar+    , putTMVar+    , takeTMVar+    )+import Effectful.Error.Static (runErrorNoCallStack, throwError)+import Effectful.TH (makeEffect)++import Text.Show qualified as S++import Atelier.Effects.Conc (Conc, fork_)+++-- | Effect for subscribing to events of type @event@.+data Sub (event :: Type) :: Effect where+    -- | Subscribe, then run @onSubscribed@ once the subscription is established+    -- — after the internal channel has been duplicated and before any event is+    -- delivered — and thereafter deliver every published event to the listener,+    -- forever. The @onSubscribed@ hook lets a caller synchronize on "subscribed"+    -- so a concurrently-started publisher cannot race ahead of the subscription+    -- and have its events missed. Most callers want 'listen' (no hook); a caller+    -- that forks the listener and then publishes must wait on this hook first.+    ListenWith+        :: m ()+        -- ^ @onSubscribed@ hook to better synchronize on "subscribed".+        -> (UTCTime -> event -> m ())+        -- ^ Listener function to react to events.+        -> Sub event m Void+++makeEffect ''Sub+++-- | Subscribe and deliver every published event to the listener, forever.+-- Defined in terms of 'listenWith' with a no-op subscribed hook.+listen :: (Sub event :> es) => (UTCTime -> event -> Eff es ()) -> Eff es Void+listen = listenWith (pure ())+++-- | Like 'listen', but the listener ignores the event timestamp.+listen_ :: (Sub event :> es) => (event -> Eff es ()) -> Eff es Void+listen_ listener = listen $ \_timestamp event -> listener event+++-- | Like 'listen_', but runs @onSubscribed@ once the subscription is+-- established and before any event is delivered. See 'ListenWith'.+listenWith_ :: (Sub event :> es) => Eff es () -> (event -> Eff es ()) -> Eff es Void+listenWith_ onSubscribed listener = listenWith onSubscribed $ \_timestamp event -> listener event+++-- | Listens until the passed function returns @Just a@, returning said @a@+-- with a timestamp.+listenUntil :: (Sub event :> es) => (UTCTime -> event -> Maybe a) -> Eff es (UTCTime, a)+listenUntil f = do+    res <- runErrorNoCallStack+        $ listen+        $ \timestamp event -> whenJust (f timestamp event) \a -> do+            throwError $ OnceEx (timestamp, a)+    case res of+        Left (OnceEx x) -> pure x+        Right v -> absurd v+++listenUntil_ :: (Sub event :> es) => (event -> Maybe a) -> Eff es a+listenUntil_ f = snd <$> listenUntil (\_ -> f)+++-- | Listens until the passed event handler returns @Just a@, returning said+-- @a@ with a timestamp.+listenUntilM :: (Sub event :> es) => (UTCTime -> event -> Eff es (Maybe a)) -> Eff es (UTCTime, a)+listenUntilM f = do+    res <- runErrorNoCallStack+        $ listen+        $ \timestamp event -> whenJustM (inject $ f timestamp event) \a -> do+            throwError $ OnceEx (timestamp, a)+    case res of+        Left (OnceEx x) -> pure x+        Right v -> absurd v+++-- | Listens until the passed event handler returns @Just a@, returning said+-- @a@.+listenUntilM_ :: (Sub event :> es) => (event -> Eff es (Maybe a)) -> Eff es a+listenUntilM_ f = snd <$> listenUntilM (\_ -> f)+++-- | Fork a background listener and block until it has actually subscribed,+-- then return. The listener runs until the enclosing 'Conc' scope closes.+--+-- This is the safe way to start a listener you intend to publish to: a plain+-- @'fork_' . 'listen'@ followed by a 'publish' races the subscription (which+-- happens asynchronously in the forked thread) and, under scheduler pressure,+-- can drop early events and wedge the listener forever. 'forkListener' closes+-- that window by waiting on the subscribed hook before returning.+forkListener+    :: forall event es+     . (Conc :> es, Concurrent :> es, Sub event :> es)+    => (UTCTime -> event -> Eff es ())+    -> Eff es ()+forkListener listener = do+    subscribed <- atomically newEmptyTMVar+    fork_ $ listenWith (atomically (putTMVar subscribed ())) listener+    atomically (takeTMVar subscribed)+++-- | Like 'forkListener', but the listener ignores the timestamp.+forkListener_+    :: forall event es+     . (Conc :> es, Concurrent :> es, Sub event :> es)+    => (event -> Eff es ())+    -> Eff es ()+forkListener_ listener = forkListener @event (\_timestamp event -> listener event)+++-- | Wait for a single event and then return said event.+listenOnce :: forall event es. (Sub event :> es) => Eff es (UTCTime, event)+listenOnce = do+    res <- runErrorNoCallStack+        $ listen+        $ \timestamp event -> throwError $ OnceEx (timestamp, event)+    case res of+        Left (OnceEx x) -> pure x+        Right v -> absurd v+++-- | Same as 'listenOnce', but discards the timestamp.+listenOnce_ :: (Sub event :> es) => Eff es event+listenOnce_ = snd <$> listenOnce+++data OnceEx ev = OnceEx ev+instance Show (OnceEx ev) where show _ = "OnceEx"
test/Unit/Atelier/Effects/Conc/TeardownStressSpec.hs view
@@ -28,9 +28,12 @@ import Atelier.Effects.Clock (Clock, runClock) import Atelier.Effects.Conc (Conc, fork, fork_, runConc, scoped) import Atelier.Effects.Monitoring.Tracing (Tracing, runTracingNoOp)-import Atelier.Effects.Publishing (Pub, Sub, publish, runPubSub)+import Atelier.Effects.Publishing (runPubSub)+import Atelier.Effects.Publishing.Pub (Pub)+import Atelier.Effects.Publishing.Sub (Sub)  import Atelier.Effects.Iterator qualified as Iter+import Atelier.Effects.Publishing.Pub qualified as Pub   spec_ConcTeardownStress :: Spec@@ -78,7 +81,7 @@                     $ Iter.fromEvents @Int \iter -> do                         _ <- fork do                             liftIO (threadDelay publishDelayUs)-                            traverse_ publish [1, 2, 3 :: Int]+                            traverse_ Pub.publish [1, 2, 3 :: Int]                         _ <- replicateM 3 (Iter.next iter)                         pure ()             completed `shouldBe` Just ()
test/Unit/Atelier/Effects/IteratorSpec.hs view
@@ -9,9 +9,12 @@ import Atelier.Effects.Clock (Clock, runClock) import Atelier.Effects.Conc (Conc, fork, runConc) import Atelier.Effects.Monitoring.Tracing (Tracing, runTracingNoOp)-import Atelier.Effects.Publishing (Pub, Sub, publish, runPubSub)+import Atelier.Effects.Publishing (runPubSub)+import Atelier.Effects.Publishing.Pub (Pub)+import Atelier.Effects.Publishing.Sub (Sub)  import Atelier.Effects.Iterator qualified as Iter+import Atelier.Effects.Publishing.Pub qualified as Pub   spec_Iterator :: Spec@@ -28,7 +31,7 @@             Iter.fromEvents @Int \iter -> do                 _ <- fork do                     liftIO $ threadDelay 10-                    publish (42 :: Int)+                    Pub.publish (42 :: Int)                 Iter.next iter         result `shouldBe` 42 @@ -37,7 +40,7 @@             Iter.fromEvents @Int \iter -> do                 _ <- fork do                     liftIO $ threadDelay 10-                    traverse_ publish [1, 2, 3]+                    traverse_ Pub.publish [1, 2, 3]                 replicateM 3 (Iter.next iter)         result `shouldBe` [1, 2, 3] @@ -46,7 +49,7 @@             Iter.fromEvents @Int \iter -> do                 _ <- fork do                     liftIO $ threadDelay 10-                    traverse_ publish [1, 2, 3]+                    traverse_ Pub.publish [1, 2, 3]                 liftIO $ threadDelay 5_000                 replicateM 3 (Iter.next iter)         result `shouldBe` [1, 2, 3]@@ -59,7 +62,7 @@             Iter.fromEvents @Int \iter -> do                 _ <- fork do                     liftIO $ threadDelay 10-                    traverse_ publish [1 .. 4]+                    traverse_ Pub.publish [1 .. 4]                 Iter.next (Iter.filter even iter)         result `shouldBe` 2 @@ -68,7 +71,7 @@             Iter.fromEvents @Int \iter -> do                 _ <- fork do                     liftIO $ threadDelay 10-                    traverse_ publish [1 .. 6]+                    traverse_ Pub.publish [1 .. 6]                 replicateM 3 (Iter.next (Iter.filter even iter))         result `shouldBe` [2, 4, 6] @@ -80,7 +83,7 @@             Iter.fromEvents @Int \iter -> do                 _ <- fork do                     liftIO $ threadDelay 10-                    traverse_ publish [0, 0, 1]+                    traverse_ Pub.publish [0, 0, 1]                 Iter.next (Iter.changes 0 iter)         result `shouldBe` 1 @@ -89,7 +92,7 @@             Iter.fromEvents @Int \iter -> do                 _ <- fork do                     liftIO $ threadDelay 10-                    traverse_ publish [1, 2, 3]+                    traverse_ Pub.publish [1, 2, 3]                 replicateM 3 (Iter.next (Iter.changes 0 iter))         result `shouldBe` [1, 2, 3] @@ -98,7 +101,7 @@             Iter.fromEvents @Int \iter -> do                 _ <- fork do                     liftIO $ threadDelay 10-                    traverse_ publish [0, 1, 0, 2, 0, 3]+                    traverse_ Pub.publish [0, 1, 0, 2, 0, 3]                 replicateM 3 (Iter.next (Iter.changes 0 iter))         result `shouldBe` [1, 2, 3] 
+ test/Unit/Atelier/Effects/Publishing/PubSpec.hs view
@@ -0,0 +1,53 @@+module Unit.Atelier.Effects.Publishing.PubSpec (spec_Pub) where++import Effectful (runPureEff)+import Effectful.Writer.Static.Shared (execWriter)+import Test.Hspec (Spec, context, describe, it, shouldMatchList)++import Atelier.Effects.Publishing.Pub qualified as Pub+++data TestEvent = TestEvent Text+    deriving stock (Eq, Show)+++spec_Pub :: Spec+spec_Pub = do+    describe "toWriter" do+        context "no events published" do+            it "doesn't record events" do+                let events =+                        runPureEff . execWriter . Pub.toWriter @TestEvent+                            $ pure ()++                events `shouldMatchList` []++        context "events published" do+            it "records events" do+                let events =+                        runPureEff . execWriter . Pub.toWriter @TestEvent $ do+                            Pub.publish $ TestEvent "payload"+                            pure ()++                events `shouldMatchList` [TestEvent "payload"]++    describe "map" do+        it "maps over one event" do+            let events =+                    runPureEff+                        . execWriter+                        . Pub.toWriter @Text+                        . Pub.map show+                        $ Pub.publish @Int 1++            events `shouldMatchList` ["1"]++        it "maps over many events" do+            let events =+                    runPureEff+                        . execWriter+                        . Pub.toWriter @Text+                        . Pub.map show+                        $ traverse Pub.publish [1 .. 10 :: Int]++            events `shouldMatchList` ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]
test/Unit/Atelier/Effects/PublishingSpec.hs view
@@ -1,53 +1,36 @@-module Unit.Atelier.Effects.PublishingSpec (spec_Pub) where+module Unit.Atelier.Effects.PublishingSpec (spec_Publishing) where  import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) import Data.Time (UTCTime, getCurrentTime)-import Effectful (IOE, runEff, runPureEff)+import Effectful (IOE, runEff) import Effectful.Concurrent (Concurrent, runConcurrent)-import Effectful.Writer.Static.Shared (runWriter)-import Test.Hspec (Spec, context, describe, expectationFailure, it, shouldBe)+import Test.Hspec (Spec, describe, it, shouldBe)  import Atelier.Effects.Chan (Chan, runChan) import Atelier.Effects.Clock (Clock, runClock, runClockConst) import Atelier.Effects.Conc (Conc, runConc) import Atelier.Effects.Monitoring.Tracing (Tracing, runTracingNoOp)-import Atelier.Effects.Publishing (Pub, Sub, forkListener, forkListener_, publish, runPubSub, runPubWriter)+import Atelier.Effects.Publishing (runPubSub)+import Atelier.Effects.Publishing.Pub (Pub)+import Atelier.Effects.Publishing.Sub (Sub) +import Atelier.Effects.Publishing.Pub qualified as Pub+import Atelier.Effects.Publishing.Sub qualified as Sub + data TestEvent = TestEvent Text     deriving stock (Eq, Show)  -spec_Pub :: Spec-spec_Pub = do-    describe "Writer implementation" $ do-        context "no events published" do-            it "doesn't record events" $ do-                let ((), events) = runPureEff . runWriter . runPubWriter @TestEvent $ do-                        pure ()--                length events `shouldBe` 0--        context "events published" do-            it "records events" $ do-                let ((), events) = runPureEff . runWriter . runPubWriter @TestEvent $ do-                        publish $ TestEvent "payload"-                        pure ()--                length events `shouldBe` 1-                case events of-                    [TestEvent payload] ->-                        payload `shouldBe` "payload"-                    xs ->-                        expectationFailure $ "Expected 1 TestEvent event, got: " <> show (length xs)--    describe "PubSub implementation" do+spec_Publishing :: Spec+spec_Publishing = do+    describe "runPubSub" do         it "listener receives a published event" do             result <- runPubSubTest $ do                 received <- liftIO newEmptyMVar-                forkListener_ @TestEvent \event ->+                Sub.forkListener_ @TestEvent \event ->                     liftIO $ putMVar received event-                publish (TestEvent "hello")+                Pub.publish (TestEvent "hello")                 liftIO $ takeMVar received             result `shouldBe` TestEvent "hello" @@ -55,9 +38,9 @@             t0 <- getCurrentTime             result <- runPubSubTestWithClock t0 $ do                 received <- liftIO newEmptyMVar-                forkListener @TestEvent \ts _event ->+                Sub.forkListener @TestEvent \ts _event ->                     liftIO $ putMVar received ts-                publish (TestEvent "hello")+                Pub.publish (TestEvent "hello")                 liftIO $ takeMVar received             result `shouldBe` t0 @@ -65,9 +48,9 @@             result <- runPubSubTest $ do                 recv1 <- liftIO newEmptyMVar                 recv2 <- liftIO newEmptyMVar-                forkListener_ @TestEvent \event -> liftIO $ putMVar recv1 event-                forkListener_ @TestEvent \event -> liftIO $ putMVar recv2 event-                publish (TestEvent "hello")+                Sub.forkListener_ @TestEvent \event -> liftIO $ putMVar recv1 event+                Sub.forkListener_ @TestEvent \event -> liftIO $ putMVar recv2 event+                Pub.publish (TestEvent "hello")                 e1 <- liftIO $ takeMVar recv1                 e2 <- liftIO $ takeMVar recv2                 pure (e1, e2)