packages feed

fractal-layer (empty) → 0.1.0.0

raw patch · 12 files changed

+4947/−0 lines, 12 filesdep +QuickCheckdep +aesondep +base

Dependencies added: QuickCheck, aeson, base, bytestring, containers, fractal-layer, hashable, hspec, mtl, profunctors, resourcet, selective, text, time, transformers, unliftio, unordered-containers

Files

+ CHANGELOG.md view
@@ -0,0 +1,11 @@+# Changelog++## 0.1.0.0 — 2026-03-18++* Initial release.+* Core `Layer` type with `Monad`, `Arrow`, `Category`, `ArrowChoice`,+  `Profunctor`, `Traversing`, `Alternative`, and `Selective` instances.+* Three construction primitives: `effect`, `resource`, `bracketed`.+* `Service` for singleton caching with thread-safe initialisation.+* `LayerInterceptor` for observability hooks.+* `Fractal.Layer.Diagnostics` for tree-structured initialisation tracing.
+ LICENSE view
@@ -0,0 +1,27 @@+Copyright (c) 2024-2026, Ian Duncan++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice,+   this list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its+   contributors may be used to endorse or promote products derived from this+   software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE+POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,76 @@+# fractal-layer++Composable, type-safe resource management and dependency injection for+Haskell, inspired by [ZIO's ZLayer](https://zio.dev/reference/contextual/zlayer).++## The idea++A `Layer m deps env` is a recipe for building a value of type `env` from+dependencies `deps`, with automatic resource cleanup. Think of it as+`deps -> m env` plus a finaliser that runs when the scope ends.++```haskell+configLayer :: Layer IO () Config+dbLayer     :: Layer IO Config Database+cacheLayer  :: Layer IO Config Cache+```++Each layer declares exactly what it needs and what it produces. The+compiler enforces correct wiring at every composition site.++## Composition++Layers compose via standard Haskell typeclasses:++```haskell+-- sequential: config feeds into db and web+appLayer :: Layer IO () (Config, (Database, WebServer))+appLayer = configLayer >>> (dbLayer &&& webLayer)++-- fallback: try remote, fall back to local+dbLayer :: Layer IO Config Database+dbLayer = remotePostgres <|> localSQLite+```++`(&&&)` runs both sides concurrently. `(<|>)` cleans up the failed+branch before trying the fallback.++## Services (shared singletons)++When two layers need the same expensive resource, `Service` gives you+at-most-once initialisation with automatic sharing:++```haskell+poolService :: Service IO Config ConnectionPool+poolService = mkService $ do+  cfg <- ask+  resource (createPool cfg) drainPool++webLayer :: Layer IO Config WebServer+webLayer = do+  pool <- service poolService   -- creates the pool+  resource (startWeb pool) stopWeb++apiLayer :: Layer IO Config ApiServer+apiLayer = do+  pool <- service poolService   -- reuses the same pool+  resource (startApi pool) stopApi+```++## Running++```haskell+main :: IO ()+main = withLayer () appLayer $ \(cfg, (db, ws)) ->+  serveRequests ws+  -- all resources released here, in reverse acquisition order+```++## Documentation++See the Haddock docs on `Fractal.Layer` for a full walkthrough with+examples.++## License++BSD-3-Clause
+ fractal-layer.cabal view
@@ -0,0 +1,91 @@+cabal-version:      3.0+name:               fractal-layer+version:            0.1.0.0+synopsis:           Composable resource management and dependency injection+description:+    Composable, type-safe resource management and dependency injection+    for Haskell, inspired by ZIO's ZLayer.+    .+    Break your application's god record into independent layers, each+    declaring exactly what it needs and what it produces. Layers compose+    via Arrow, Category, and Alternative, with automatic resource cleanup,+    concurrent initialisation, and singleton service caching.++license:            BSD-3-Clause+license-file:       LICENSE+author:             Ian Duncan+maintainer:         ian@mercury.com+category:           Control+build-type:         Simple+tested-with:        GHC == 9.10.3+extra-doc-files:    README.md+                  , CHANGELOG.md++source-repository head+  type:     git+  location: https://github.com/iand675/fractal+  subdir:   fractal-layer++common warnings+    ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates+                 -Wincomplete-uni-patterns -Wmissing-home-modules+                 -Wpartial-fields -Wredundant-constraints++common extensions+    default-extensions: OverloadedStrings+                      , OverloadedRecordDot+                      , RecordWildCards+                      , DeriveGeneric+                      , DerivingStrategies+                      , LambdaCase+                      , GeneralizedNewtypeDeriving+                      , RankNTypes+                      , ScopedTypeVariables+                      , BlockArguments++library+    import:           warnings, extensions+    exposed-modules:  Fractal.Layer+                    , Fractal.Layer.Diagnostics+                    , Fractal.Layer.Interceptor+                    , Fractal.Layer.Internal+    build-depends:    base >= 4.17.0.0 && < 5+                    , resourcet >= 1.2 && < 2+                    , unliftio >= 0.2 && < 1+                    , transformers >= 0.5 && < 0.7+                    , mtl >= 2.2 && < 3+                    , containers >= 0.6 && < 1+                    , hashable >= 1.4 && < 2+                    , unordered-containers >= 0.2 && < 1+                    , profunctors >= 5.6 && < 6+                    , selective >= 0.5 && < 1+                    , aeson >= 2.0 && < 3+                    , text >= 2.0 && < 3+                    , time >= 1.12 && < 2+    hs-source-dirs:   src+    default-language: Haskell2010++test-suite fractal-layer-test+    import:           warnings, extensions+    default-language: Haskell2010+    type:             exitcode-stdio-1.0+    hs-source-dirs:   test+    main-is:          Spec.hs+    other-modules:    Fractal.Layer.DiagnosticsSpec+                    , Fractal.Layer.InterceptorSpec+                    , Fractal.Layer.LayerSpec+    build-tool-depends: hspec-discover:hspec-discover >= 2.10 && < 3+    build-depends:    base >= 4.17.0.0 && < 5+                    , fractal-layer+                    , hspec >= 2.10 && < 3+                    , mtl >= 2.2 && < 3+                    , profunctors >= 5.6 && < 6+                    , QuickCheck >= 2.14 && < 3+                    , unliftio >= 0.2 && < 1+                    , text >= 2.0 && < 3+                    , aeson >= 2.0 && < 3+                    , bytestring >= 0.11 && < 1+                    , resourcet >= 1.2 && < 2+                    , time >= 1.12 && < 2+                    , unordered-containers >= 0.2 && < 1+                    , selective >= 0.5 && < 1
+ src/Fractal/Layer.hs view
@@ -0,0 +1,1087 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE InstanceSigs #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# OPTIONS_GHC -Wno-orphans #-}++-- |+-- Module      : Fractal.Layer+-- Description : Composable, type-safe resource management and dependency injection+-- Stability   : experimental+--+-- = What is a Layer?+--+-- A @'Layer' m deps env@ describes one layer of an application: it+-- requires some services as input (@deps@) and produces some services+-- as output (@env@).  You can think of it as an effectful function+-- with managed resource lifetimes:+--+-- @+-- type Layer m deps env  ≈  deps -> m env   (+ automatic cleanup)+-- @+--+-- For example, a @Layer IO (Socket, Config) Database@ says: "give me a+-- @Socket@ and a @Config@, and I will build you a @Database@, cleaning+-- it up when you're done."+--+-- A layer with no dependencies uses @()@ for @deps@:+--+-- @+-- configLayer :: Layer IO () Config+-- @+--+-- This means @configLayer@ is a self-contained recipe for producing a+-- @Config@ value. It doesn't need anything from the outside world+-- other than @IO@.+--+-- = Why Layers?+--+-- == The Problem: God Records+--+-- Most Haskell applications centralise their dependencies in a single+-- record:+--+-- @+-- data App = App+--   { appConfig  :: Config+--   , appDB      :: Database+--   , appCache   :: Cache+--   , appWeb     :: WebServer+--   , appMetrics :: MetricsCollector+--   }+--+-- initApp :: IO App+-- initApp = do+--   cfg     <- loadConfig+--   db      <- connectDB cfg        -- what if this throws?+--   cache   <- connectCache cfg     -- do we close db?+--   web     <- startServer cfg db   -- what about cache?+--   metrics <- startMetrics cfg     -- cleanup order?+--   pure App {..}+-- @+--+-- This works for small programs. As the application grows, three+-- problems compound:+--+-- 1. __No local reasoning.__  Every function that accepts @App@ can+--    touch every field. Changing the cache implementation forces you+--    to audit every consumer of @App@, even those that never use the+--    cache, because the type doesn't tell you what is actually needed.+--+-- 2. __Fragile resource safety.__  If @connectCache@ throws, is @db@+--    closed?  If @startServer@ throws, is @cache@ released?  The+--    compiler gives you no help. Maintaining the cleanup order by hand,+--    and keeping it correct as the application evolves, is tedious and+--    error-prone. Mistakes show up as leaked connections and file+--    handles in production.+--+-- 3. __Hidden sharing.__  Two subsystems that both need a connection+--    pool either get two independent pools (wasteful, and potentially+--    incorrect if they need to share a transaction context) or share+--    one via convention. Nothing in the type system enforces or even+--    documents the sharing.+--+-- == The Solution+--+-- With layers, you break the god record into independent pieces, each+-- declaring exactly what it needs:+--+-- @+-- configLayer :: Layer IO () Config+-- dbLayer     :: Layer IO Config Database+-- cacheLayer  :: Layer IO Config Cache+-- webLayer    :: Layer IO (Config, Database) WebServer+-- @+--+-- Each layer is self-contained. You can read, test, and refactor+-- @dbLayer@ without looking at @cacheLayer@ or @webLayer@. The type+-- checker enforces that dependencies are satisfied at every+-- composition site. If you change the signature of @dbLayer@, every+-- composition that uses it produces a type error until it is updated.+--+-- = Properties of Layers+--+-- == Recipes for Creating Services+--+-- A layer describes /how/ to build a service from its dependencies.+-- @Layer IO Config Database@ is a recipe that says: "given a @Config@,+-- I know how to create a @Database@." The recipe includes both the+-- initialisation logic and any associated cleanup.+--+-- == An Alternative to Manual Initialisation+--+-- A layer plays the same role as a hand-written @initFoo :: Config -> IO Foo@+-- function, but with important extras: it tracks resource lifetimes, composes+-- with other layers via standard typeclasses, and participates in parallel+-- startup and fallback. You get the same "build a value from its inputs"+-- workflow, without any of the manual plumbing.+--+-- == Effectful and Resourceful+--+-- Layer construction is fully effectful. Layers can acquire resources+-- (open connections, start threads) and register finalisers that are+-- __guaranteed__ to run when the layer's scope ends, even under+-- exceptions or cancellation. You never write a manual cleanup chain.+--+-- For example, a @Database@ layer can open a connection pool during+-- construction and drain it on shutdown:+--+-- @+-- dbLayer :: Layer IO Config Database+-- dbLayer = do+--   cfg <- ask+--   resource (createPool cfg) drainPool+-- @+--+-- == Asynchronous+--+-- Layer construction is fully asynchronous and non-blocking. This+-- matters when initialisation involves network calls, waiting for+-- readiness checks, or other long-running work. You can construct+-- an entire application's dependency graph without blocking the main+-- thread on any single subsystem.+--+-- == Parallel+--+-- Independent layers are acquired in parallel automatically. When you+-- compose two layers with @&&&@, both sides are started concurrently+-- via @async@. For applications with many independent subsystems, this+-- can significantly reduce startup time compared to sequential+-- initialisation.+--+-- @+-- -- db and cache start concurrently, both using the same config+-- backend :: Layer IO Config (Database, Cache)+-- backend = dbLayer &&& cacheLayer+-- @+--+-- == Composable+--+-- Layers compose through standard Haskell typeclasses. You don't need+-- to learn a new composition API; if you know 'Category', 'Arrow',+-- and 'Alternative', you already know how to wire layers together:+--+-- [@>>>@]  __Sequential__: pipe the output of one layer as the input+--    to the next.+--+--    @configLayer >>> dbLayer  ::  Layer IO () Database@+--+-- [@&&&@]  __Parallel__: run two layers concurrently on the same+--    input, returning both results as a tuple.+--+--    @dbLayer &&& cacheLayer  ::  Layer IO Config (Database, Cache)@+--+-- [@***@]  __Split__: run two layers concurrently on different inputs.+--+-- [@\<|\>@]  __Fallback__: try the first layer; if it fails, clean up+--    its resources and try the second.+--+--    @remoteDB \<|\> localDB  ::  Layer IO Config Database@+--+-- [@+++@, @|||@]  __Choice__: route @Either@ inputs through different+--    layers.+--+-- A complete example wiring up an application:+--+-- @+-- appLayer :: Layer IO () (Config, (Database, WebServer))+-- appLayer = configLayer >>> (dbLayer &&& webLayer)+-- @+--+-- This reads as: "build a @Config@ from nothing, then use it to build+-- a @Database@ and a @WebServer@ in parallel." The compiler verifies+-- that the output type of @configLayer@ (@Config@) matches the input+-- type expected by @dbLayer@ and @webLayer@.+--+-- == Resilient via Fallback+--+-- Layer construction can be made resilient using 'Alternative'. If+-- the primary layer fails, @\<|\>@ cleans up any resources the failed+-- attempt acquired and tries an alternative:+--+-- @+-- databaseLayer :: Layer IO Config Database+-- databaseLayer = remotePostgres \<|\> localSQLite+-- @+--+-- The failed branch's resources (partially-opened connections, temp+-- files) are released before the fallback begins. This is safe by+-- construction; you do not need to write any cleanup code for the+-- failure path.+--+-- = Running Layers+--+-- Once you have composed your layer graph, you run it with 'withLayer':+--+-- @+-- main :: IO ()+-- main = withLayer () appLayer $ \\(cfg, (db, ws)) -> do+--   putStrLn "All services ready."+--   serveRequests ws+--   -- when this callback returns (or throws), all resources+--   -- are released in reverse acquisition order+-- @+--+-- 'withLayer' acquires every resource in the composition, passes the+-- resulting environment to your callback, and releases everything when+-- the callback finishes. This guarantee holds even if the callback+-- throws an exception.+--+-- For tests and REPL experiments where you don't need the resources to+-- outlive construction, 'runLayer' is a simpler alternative:+--+-- @+-- cfg <- runLayer () configLayer+-- @+--+-- = Services: Shared Singletons+--+-- Real applications have diamond dependencies. Both @webLayer@ and+-- @apiLayer@ need a connection pool, but you want exactly one pool,+-- not two.+--+-- 'Service' solves this. Wrap any layer with 'mkService' to mark it+-- as shared, and consume it with 'service':+--+-- @+-- poolService :: Service IO Config ConnectionPool+-- poolService = mkService $ do+--   cfg <- ask+--   resource (createPool cfg) drainPool+-- @+--+-- Now every consumer gets the same instance:+--+-- @+-- webLayer :: Layer IO Config WebServer+-- webLayer = do+--   pool <- service poolService   -- first call creates the pool+--   resource (startWeb pool) stopWeb+--+-- apiLayer :: Layer IO Config ApiServer+-- apiLayer = do+--   pool <- service poolService   -- second call reuses the same pool+--   resource (startApi pool) stopApi+--+-- -- one pool, two consumers, automatic cleanup+-- app :: Layer IO Config (WebServer, ApiServer)+-- app = webLayer &&& apiLayer+-- @+--+-- Key properties of services:+--+-- * __At-most-once initialisation.__ The first call to 'service'+--   within a composition creates the instance. Every subsequent call+--   returns the same value.+--+-- * __Thread-safe.__ If @webLayer@ and @apiLayer@ race to create the+--   pool (as they will under @&&&@), only one thread performs the+--   initialisation and the other blocks until it completes.+--+-- * __Failure caching.__ If initialisation fails, the failure is+--   cached. Later calls to 'service' will re-throw the same exception+--   rather than retrying.+--+-- * __Automatic cleanup.__ The service's finaliser runs when the+--   enclosing 'withLayer' scope ends, just like any other resource.+--+-- = Observability+--+-- Every resource acquisition, effect, service lookup, and composition+-- step fires callbacks on a 'LayerInterceptor'. You can plug in+-- logging, metrics, or distributed tracing without modifying any layer+-- code. The companion module "Fractal.Layer.Diagnostics" turns these+-- callbacks into a tree you can render to the terminal or export as+-- JSON.+--+-- @+-- main :: IO ()+-- main = withLayerAndInterceptor loggingInterceptor () appLayer $ \\env ->+--   runApp env+-- @+--+-- = Creating Layers+--+-- There are three ways to create a layer, depending on the lifecycle+-- of the value it produces:+--+-- ['effect']  For values that don't need cleanup: configuration,+--   computed values, or any action whose result can be garbage+--   collected normally.+--+-- ['resource']  For values with an acquire\/release lifecycle:+--   database connections, file handles, thread pools. The release+--   action is guaranteed to run.+--+-- ['bracketed']  For adapting existing @with@-style functions from+--   third-party libraries, e.g. @withSocketsDo@, @withConnection@.+--+-- In all three cases, dependencies are accessed via 'ask' from+-- 'MonadReader', not through function arguments:+--+-- @+-- configLayer :: Layer IO () Config+-- configLayer = effect loadConfig+--+-- dbLayer :: Layer IO Config Database+-- dbLayer = do+--   cfg <- ask+--   resource (connectDB cfg) closeDB+--+-- poolLayer :: Layer IO () ConnectionPool+-- poolLayer = bracketed withConnectionPool+-- @+--+-- = Design Influences+--+-- The layer model is inspired by ZIO's @ZLayer@+-- (<https://zio.dev/reference/contextual/zlayer>), adapted to+-- idiomatic Haskell using 'Arrow', 'Category', and 'Alternative'.+module Fractal.Layer+  ( -- * Core type+    Layer,++    -- * Building layers+    -- $building+    effect,+    resource,+    bracketed,++    -- * Running layers+    -- $running+    withLayer,+    runLayer,++    -- * Services (shared singletons)+    -- $services+    Service,+    mkService,+    service,+    uncached,++    -- * Composition helpers+    -- $composition+    composeLayer,+    zipLayer,+    mapLayer,++    -- * Interceptor-aware runners+    runLayerWithInterceptor,+    withLayerAndInterceptor,++    -- * Interceptors (re-exported from "Fractal.Layer.Interceptor")+    LayerInterceptor (..),+    nullInterceptor,+    combineInterceptors,++    -- * Exceptions+    EmptyLayer (..),++    -- * Re-exports+    MonadResource,+  )+where++import Control.Applicative+import Control.Arrow+import Control.Category+import Control.Monad+import Control.Monad.Reader.Class+import Control.Monad.Trans.Class (lift)+import Control.Monad.Trans.Resource (createInternalState, runInternalState, withInternalState)+import Control.Monad.Trans.Resource.Internal (ReleaseMap (..), stateCleanupChecked)+import Control.Selective+import Data.List.NonEmpty (NonEmpty(..))+import Data.Semigroup (Semigroup(..))+import Data.IntMap.Strict (mapKeysMonotonic)+import Data.Profunctor+import Data.Profunctor.Traversing+import qualified Data.Text as T+import Data.Time.Clock (getCurrentTime, diffUTCTime)+import Data.Typeable+import Fractal.Layer.Interceptor+import Fractal.Layer.Internal+import UnliftIO+import UnliftIO.Resource+import Prelude hiding ((.))++-- $building+--+-- Three primitives cover the full spectrum of resource lifecycles:+--+-- * 'effect': for values that don't need cleanup (config, computed+--   values). Analogous to @ZLayer.fromZIO@ or @ZLayer.succeed@ in ZIO.+-- * 'resource': for values with an explicit acquire\/release pair.+--   The finaliser is guaranteed to run even under exceptions.+--   Analogous to @ZLayer.scoped@ with @acquireRelease@.+-- * 'bracketed': for adapting existing @withFoo@-style bracket+--   functions from third-party libraries.+--+-- In all three cases, dependencies come from 'ask'+-- ('Control.Monad.Reader.Class.MonadReader'), not through the+-- primitive's function arguments. This keeps the creation API uniform+-- and lets you compose layers via 'Monad' @do@-notation.++-- $running+--+-- 'withLayer' is the primary entry point for production code. It+-- acquires every resource in the layer graph, passes the resulting+-- environment to your callback, and releases everything when the+-- callback returns or throws. Resources are released in reverse+-- acquisition order.+--+-- 'runLayer' is a simpler alternative for tests and REPL sessions. It+-- builds the layer, returns the output, and immediately releases all+-- resources. Any handles or connections in the output will be invalid+-- after 'runLayer' returns.++-- $services+--+-- Services solve the diamond-dependency problem: when two independent+-- layers both need the same expensive resource (a connection pool, a+-- thread manager), you want exactly one instance, not two.+--+-- Wrap any layer with 'mkService' to declare it as shared, then+-- consume it with 'service'. The first call initialises the service+-- and caches the result. Every subsequent call returns the cached+-- instance. Initialisation is thread-safe and failure is cached.+--+-- See the module introduction for a complete worked example.++-- $composition+--+-- Most of the time you compose layers via their typeclass instances+-- ('Category' for @>>>@, 'Arrow' for @&&&@/@***@, 'ArrowChoice' for+-- @+++@/@|||@, 'Alternative' for @\<|\>@). The functions below are the+-- named equivalents that implement those instances. They are exported+-- for cases where you need to pass them as first-class values or where+-- the operator syntax is ambiguous.+++getOrCreateCachedService :: forall m deps env. (MonadUnliftIO m, Typeable env) => Service m deps env -> Layer m deps env+getOrCreateCachedService (Service m) = Layer $ \lenv deps -> do+  let rep = typeRep (Proxy @env)+      serviceName = T.pack (show rep)++  -- Fast path: check without write lock+  snapshot <- liftIO $ readMVar lenv.serviceStates+  case lookupTypeMap @env snapshot of+    Just (Initialized x) -> do+      lift $ onServiceReuse (interceptor lenv) serviceName rep+      pure x+    Just (Failed e) -> throwIO e+    _ -> join $ modifyMVar lenv.serviceStates $ \currentStates -> do+      case lookupTypeMap @env currentStates of+        Just (Initialized x) -> do+          lift $ onServiceReuse (interceptor lenv) serviceName rep+          pure (currentStates, pure x)+        Just (Failed e) -> pure (currentStates, throwIO e)+        Nothing -> do+          let ctx = OperationContext+                { operationName = serviceName+                , operationType = Just rep+                , operationMetadata = []+                }+          lift $ onServiceCreate (interceptor lenv) ctx++          eRes <- try $ build m lenv deps+          case eRes of+            Left e -> do+              let states' = insertTypeMap @env (Failed e) currentStates+              pure (states', throwIO e)+            Right x -> do+              let states' = insertTypeMap @env (Initialized x) currentStates+              pure (states', pure x)++-------------------------------------------------------------------------------+-- Construction helpers+-------------------------------------------------------------------------------++-- | Acquire a resource and register its finaliser.+--+-- Use 'resource' when you have a matching acquire\/release pair.  The+-- release action runs when the enclosing 'withLayer' scope ends,+-- whether normally or via exception, so you never need to write+-- manual cleanup code.+--+-- __Database connection:__+--+-- @+-- dbLayer :: Layer IO Config Database+-- dbLayer = do+--   cfg <- ask+--   resource (connectDB cfg) closeDB+-- @+--+-- __Thread pool:__+--+-- @+-- workerPool :: Layer IO PoolConfig ThreadPool+-- workerPool = do+--   cfg <- ask+--   resource (newThreadPool cfg.numWorkers) shutdownPool+-- @+--+-- __File handle (release guaranteed even on exception):__+--+-- @+-- logFile :: Layer IO FilePath Handle+-- logFile = do+--   path <- ask+--   resource (openFile path AppendMode) hClose+-- @+--+-- The acquire action runs inside 'ResourceT', so any exception during+-- acquisition prevents the finaliser from being registered (there is+-- nothing to clean up). Once acquisition succeeds, the finaliser is+-- registered and will run no matter what happens later.+resource ::+  forall m deps env. (MonadUnliftIO m, Typeable env) =>+  -- | Acquire resource+  m env ->+  -- | Release resource+  (env -> m ()) ->+  Layer m deps env+resource acq rel = Layer $ \lenv _deps -> do+  let rep = typeRep (Proxy @env)+      ctx = OperationContext+        { operationName = T.pack (show rep)+        , operationType = Just rep+        , operationMetadata = []+        }+  lift $ onResourceAcquire (interceptor lenv) ctx+  startTime <- liftIO getCurrentTime++  (_, env) <- allocateU+    (lift acq)+    (\x -> do+      lift $ onResourceRelease (interceptor lenv) (operationName ctx)+      lift $ rel x)++  endTime <- liftIO getCurrentTime+  let duration = diffUTCTime endTime startTime+  lift $ onResourceAcquireComplete (interceptor lenv) (operationName ctx) duration++  pure env+{-# INLINABLE resource #-}++-- | Lift a monadic action into a layer.  No finaliser is registered,+-- so this is the right choice for values that can be garbage collected+-- normally: configuration, computed values, or one-shot effects whose+-- result doesn't hold on to external resources.+--+-- __Loading configuration:__+--+-- @+-- configLayer :: Layer IO () Config+-- configLayer = effect $+--   Config \<$\> readEnvVar \"PORT\"+--          \<*\> readEnvVar \"HOST\"+-- @+--+-- __Pure computation (no IO needed):__+--+-- @+-- seedLayer :: Layer IO () Seed+-- seedLayer = effect (pure (Seed 42))+-- @+--+-- __Reading a file at startup:__+--+-- @+-- templatesLayer :: Layer IO Config Templates+-- templatesLayer = do+--   cfg <- ask+--   effect $ loadTemplates cfg.templateDir+-- @+--+-- If the value you are producing holds on to an external resource+-- (a file handle, a socket, a connection), use 'resource' instead+-- so the resource is cleaned up automatically.+effect ::+  forall m deps env. (MonadUnliftIO m, Typeable env) =>+  -- | Run effect to produce environment+  m env ->+  Layer m deps env+effect f = Layer $ \lenv _deps -> do+  let rep = typeRep (Proxy @env)+      ctx = OperationContext+        { operationName = T.pack (show rep)+        , operationType = Just rep+        , operationMetadata = []+        }+  lift $ onEffectRun (interceptor lenv) ctx+  startTime <- liftIO getCurrentTime++  result <- lift f++  endTime <- liftIO getCurrentTime+  let duration = diffUTCTime endTime startTime+  lift $ onEffectComplete (interceptor lenv) (operationName ctx) duration++  pure result+{-# INLINABLE effect #-}++-- | Adapt a @with@-style bracket function into a layer.+--+-- Many Haskell libraries expose resource management in continuation-+-- passing style: @withFoo :: (Foo -> m a) -> m a@. The library+-- handles acquisition and cleanup internally, and you use the+-- resource within the continuation.+--+-- 'bracketed' captures the resource from this pattern and extends its+-- lifetime to match the enclosing 'withLayer' scope, so it+-- participates in the same cleanup guarantees as 'resource'.+--+-- __Connection pool from a library:__+--+-- @+-- poolLayer :: Layer IO () ConnectionPool+-- poolLayer = bracketed withConnectionPool+-- @+--+-- __TLS manager:__+--+-- @+-- tlsLayer :: Layer IO TlsConfig TlsManager+-- tlsLayer = do+--   cfg <- ask+--   bracketed (withTlsManager cfg)+-- @+--+-- __Network socket:__+--+-- @+-- socketLayer :: Layer IO SocketConfig Socket+-- socketLayer = do+--   cfg <- ask+--   bracketed (withBoundSocket cfg.host cfg.port)+-- @+--+-- Prefer 'resource' when you have separate acquire and release+-- functions; 'bracketed' is specifically for libraries that only+-- expose the @with@ pattern.+bracketed ::+  forall m deps env. MonadUnliftIO m =>+  (forall a. (env -> m a) -> m a) ->+  Layer m deps env+bracketed f = Layer $ \_ _deps -> do+  (resourceVar :: MVar (Either SomeException env)) <- newEmptyMVar+  cleanupVar <- newEmptyMVar+  void $ allocateU+    ( async do+      lift $ f (\env -> putMVar resourceVar (Right env) >> takeMVar cleanupVar)+        `catch` (\(e :: SomeException) -> putMVar resourceVar (Left e) >> throwIO e)+    )+    (\h -> do+      putMVar cleanupVar ()+      wait h+    )+  takeMVar resourceVar >>= either throwIO pure++-- | Wrap a layer so it can be shared via 'service'.+--+-- 'mkService' does not change the layer's behaviour. It simply tags+-- the layer so that 'service' knows to cache its result. Define your+-- service once at the top level, and reference it from every consumer:+--+-- @+-- poolService :: Service IO Config ConnectionPool+-- poolService = mkService $ do+--   cfg <- ask+--   resource (createPool cfg) drainPool+-- @+mkService :: Layer m deps env -> Service m deps env+mkService = Service++-- | Obtain a shared instance of a 'Service'.+--+-- The first call within a composition initialises the service and+-- caches the result, keyed by the output 'TypeRep'. Subsequent calls+-- return the cached instance. Initialisation is thread-safe:+-- concurrent requests block until the first completes.+--+-- __Two consumers sharing one pool:__+--+-- @+-- webLayer :: Layer IO Config WebServer+-- webLayer = do+--   pool <- service poolService  -- first call creates the pool+--   resource (startWeb pool) stopWeb+--+-- apiLayer :: Layer IO Config ApiServer+-- apiLayer = do+--   pool <- service poolService  -- reuses the same pool+--   resource (startApi pool) stopApi+--+-- app :: Layer IO Config (WebServer, ApiServer)+-- app = webLayer &&& apiLayer   -- one pool, two consumers+-- @+--+-- __Using a service in a sequential pipeline:__+--+-- @+-- appLayer :: Layer IO () App+-- appLayer = configLayer >>> do+--   pool <- service poolService+--   db   <- service dbService+--   effect $ pure (App pool db)+-- @+--+-- __Caveat__: caching is keyed by output type. Two @Service@ values+-- that produce the same type will share a cache entry. Use a newtype+-- wrapper if you need distinct instances of the same underlying type.+service :: forall m deps env. (MonadUnliftIO m, Typeable env) => Service m deps env -> Layer m deps env+service = getOrCreateCachedService++-------------------------------------------------------------------------------+-- Typeclass instances --------------------------------------------------------+-------------------------------------------------------------------------------++instance Functor m => Functor (Layer m deps) where+  fmap f (Layer l) = Layer $ \lenv deps -> fmap f (l lenv deps)++instance MonadUnliftIO m => Applicative (Layer m deps) where+  pure = pureLayer+  lf <*> la = dimap (\d -> (d, d)) (uncurry ($)) (zipLayer lf la)++instance MonadUnliftIO m => Selective (Layer m deps) where+  select = selectM++instance MonadUnliftIO m => Monad (Layer m deps) where+  return = pure+  (Layer l) >>= f = Layer $ \lenv deps -> do+    a <- l lenv deps+    let Layer l' = f a+    l' lenv deps++instance (MonadUnliftIO m, Semigroup env) => Semigroup (Layer m deps env) where+  lf <> la = liftA2 (<>) lf la+  sconcat (h :| t) = foldl (<>) h t+  stimes n x+    | n <= 0 = error "stimes: positive multiplier expected"+    | n == 1 = x+    | otherwise = x <> stimes (n - 1) x++instance (MonadUnliftIO m, Monoid env) => Monoid (Layer m deps env) where+  mempty = pure mempty+  mappend = (<>)+  mconcat = foldl (<>) mempty++-- | Contravariantly map over the input. Named form of 'lmap'.+--+-- Useful when a layer expects a different input type than you have,+-- and you can project from one to the other:+--+-- @+-- -- dbLayer needs Config, but we have AppEnv+-- dbFromAppEnv :: Layer IO AppEnv Database+-- dbFromAppEnv = mapLayer (.config) dbLayer+-- @+mapLayer :: (b -> a) -> Layer m a c -> Layer m b c+mapLayer f (Layer l) = Layer $ \lenv env -> l lenv (f env)++instance (MonadUnliftIO m) => MonadReader deps (Layer m deps) where+  ask = Layer $ \_ deps -> pure deps+  local f (Layer l) = Layer $ \lenv deps -> l lenv (f deps)++-- | Sequential (vertical) composition. Named form of @>>>@.+--+-- Feeds the output of the upstream layer as dependencies to the+-- downstream layer. This is how you express "build A, then use A to+-- build B":+--+-- @+-- -- using the operator:+-- appLayer = configLayer >>> dbLayer+--+-- -- equivalent named version:+-- appLayer = composeLayer configLayer dbLayer+-- @+composeLayer ::+  MonadUnliftIO m =>+  -- | "Upstream" layer+  Layer m a b ->+  -- | "Downstream" layer+  Layer m b c ->+  Layer m a c+composeLayer (Layer upper) (Layer lower) = Layer $ \lenv deps -> do+  lift $ onCompositionStart (interceptor lenv) Sequential+  startTime <- liftIO getCurrentTime+  b <- upper lenv deps+  c <- lower lenv b+  endTime <- liftIO getCurrentTime+  lift $ onCompositionEnd (interceptor lenv) Sequential (diffUTCTime endTime startTime)+  pure c++instance MonadUnliftIO m => Category (Layer m) where+  id = Layer $ \_ deps -> pure deps+  (.) = flip composeLayer++instance MonadUnliftIO m => Profunctor (Layer m) where+  dimap f g (Layer l) = Layer $ \lenv a -> fmap g (l lenv (f a))+  lmap  = mapLayer+  rmap = fmap++instance MonadUnliftIO m => Strong (Layer m) where+  first' :: Layer m a b -> Layer m (a, c) (b, c)+  first' (Layer l) = Layer $ \lenv (a, c) -> do+    b <- l lenv a+    pure (b, c)++  second' :: Layer m a b -> Layer m (c, a) (c, b)+  second' (Layer l) = Layer $ \lenv (c, a) -> do+    b <- l lenv a+    pure (c, b)++instance MonadUnliftIO m => Choice (Layer m) where+  left' :: Layer m a b -> Layer m (Either a c) (Either b c)+  left' (Layer l) = Layer $ \lenv -> \case+    Left a -> Left <$> l lenv a+    Right c -> pure (Right c)++  right' :: Layer m a b -> Layer m (Either c a) (Either c b)+  right' (Layer l) = Layer $ \lenv -> \case+    Right a -> Right <$> l lenv a+    Left c -> pure (Left c)++instance MonadUnliftIO m => Traversing (Layer m) where+  traverse' :: Traversable f => Layer m a b -> Layer m (f a) (f b)+  traverse' (Layer l) = Layer $ \lenv fa -> traverse (l lenv) fa++instance MonadUnliftIO m => Arrow (Layer m) where+  arr f = Layer $ \_ a -> pure (f a)+  first = first'+  second = second'++instance MonadUnliftIO m => ArrowChoice (Layer m) where+  left = left'+  right = right'++-------------------------------------------------------------------------------++-- | Thrown by 'empty' \/ 'zeroArrow'.  Caught internally by @\<|\>@ and+-- @\<+\>@ to implement fallback behavior. You should not need to catch+-- this yourself; it is an implementation detail of the 'Alternative'+-- instance.+data EmptyLayer = EmptyLayer+  deriving stock (Show)++instance Exception EmptyLayer++instance MonadUnliftIO m => Alternative (Layer m deps) where+  empty = Layer $ \_ _ -> throwIO EmptyLayer+  Layer l1 <|> Layer l2 = Layer $ \lenv deps ->+    tryWithCleanup (l1 lenv deps) (l2 lenv deps)++instance MonadUnliftIO m => MonadPlus (Layer m deps) where+  mzero = empty+  mplus = (<|>)++instance MonadUnliftIO m => ArrowZero (Layer m) where+  zeroArrow = Layer $ \_ _ -> throwIO EmptyLayer++instance MonadUnliftIO m => ArrowPlus (Layer m) where+  (Layer l1) <+> (Layer l2) = Layer $ \lenv deps ->+    tryWithCleanup (l1 lenv deps) (l2 lenv deps)++instance MonadUnliftIO m => ArrowApply (Layer m) where+  app = Layer $ \lenv (Layer l, b) -> l lenv b++tryWithCleanup :: MonadUnliftIO m => ResourceT m a -> ResourceT m a -> ResourceT m a+tryWithCleanup l r = mask $ \restore -> do+  tmpState <- liftIO createInternalState+  try (restore $ runInternalState (lift l) tmpState) >>= \case+    Left e -> do+      liftIO $ stateCleanupChecked (Just (e :: SomeException)) tmpState+      restore r+    Right result -> withInternalState $ \stateMain -> do+      tmpState' <- liftIO $ readIORef tmpState+      atomicModifyIORef' stateMain $ \state -> (unsafelyMergeReleaseMap state tmpState', ())+      pure result++-------------------------------------------------------------------------------+-- Composition+-------------------------------------------------------------------------------++-- | Parallel (horizontal) composition. Powers @&&&@ and @\<*\>@.+--+-- Both layers run concurrently via @async@ in isolated resource+-- scopes. On success, the scopes merge into the parent. On failure,+-- the surviving branch is cleaned up before the exception propagates.+--+-- This means @&&&@ is not just a convenience operator; it actually+-- reduces startup time by running independent layers in parallel.+--+-- @+-- -- named form:+-- fullSystem :: Layer IO Config (Database, WebServer)+-- fullSystem = zipLayer dbLayer webLayer+--+-- -- operator form (more common):+-- fullSystem :: Layer IO Config (Database, WebServer)+-- fullSystem = dbLayer &&& webLayer+-- @+--+-- If one branch throws, the other is cancelled and its resources are+-- released before the exception propagates to the caller.+zipLayer ::+  (MonadUnliftIO m) =>+  Layer m d1 o1 ->+  Layer m d2 o2 ->+  Layer m (d1, d2) (o1, o2)+zipLayer (Layer l1) (Layer l2) = Layer $ \lenv (d1, d2) -> do+  -- For concurrent execution, we need to:+  -- 1. Run each layer in isolation to prevent cross-contamination if one fails+  -- 2. Capture their resources and transfer to parent context if successful++  -- Create isolated states for each branch+  stateA <- liftIO createInternalState+  stateB <- liftIO createInternalState++  mask $ \restore -> do+    asyncA <- async $ restore $ runInternalState (lift $ l1 lenv d1) stateA+    asyncB <- async $ restore $ runInternalState (lift $ l2 lenv d2) stateB+    eRes <- try (restore (atomically $ (,) <$> waitCatchSTM asyncA <*> waitCatchSTM asyncB))+    stateA' <- liftIO $ readIORef stateA+    stateB' <- liftIO $ readIORef stateB+    case eRes of+      Left e -> do+        uninterruptibleCancel asyncA+        uninterruptibleCancel asyncB+        liftIO (stateCleanupChecked (Just e) stateA `finally` stateCleanupChecked (Just e) stateB)+        throwIO e+      Right res -> case res of+        (Left eA, Left eB) -> do+          liftIO (stateCleanupChecked (Just eA) stateA `finally` stateCleanupChecked (Just eB) stateB)+          throwIO eA+        (Left eA, Right _) -> do+          liftIO (stateCleanupChecked (Just eA) stateA `finally` stateCleanupChecked Nothing stateB)+          throwIO eA+        (Right _envA, Left eB) -> do+          liftIO (stateCleanupChecked (Just eB) stateB `finally` stateCleanupChecked Nothing stateA)+          throwIO eB+        (Right envA, Right envB) -> withInternalState $ \stateMain -> do+          let mergedStates = unsafelyMergeReleaseMap stateA' stateB'+          atomicModifyIORef' stateMain $ \state -> (unsafelyMergeReleaseMap state mergedStates, ())+          pure (envA, envB)++-- The first map is the one that is being merged into. Its refcount is not affected.+unsafelyMergeReleaseMap :: ReleaseMap -> ReleaseMap -> ReleaseMap+unsafelyMergeReleaseMap (ReleaseMap nextKey refCount stateA) (ReleaseMap nextKey' _ stateB) = ReleaseMap (nextKey + nextKey') refCount (stateA <> mapKeysMonotonic (+ nextKey) stateB)+unsafelyMergeReleaseMap l ReleaseMapClosed = l+unsafelyMergeReleaseMap ReleaseMapClosed r = r++-------------------------------------------------------------------------------+-- Running layers+-------------------------------------------------------------------------------++-- | Build a layer and return its output, releasing resources immediately.+--+-- This is useful in tests and REPL sessions where you want a quick+-- result without managing a callback scope:+--+-- @+-- -- in a test:+-- cfg <- runLayer () configLayer+-- cfg.port \`shouldBe\` 8080+--+-- -- in GHCi:+-- >>> runLayer () configLayer+-- Config {port = 8080, host = "localhost"}+-- @+--+-- __Warning__: any handles or connections in the output will be+-- invalid after this returns, because resources are released as+-- soon as the layer finishes building.  Use 'withLayer' for real+-- applications.+runLayer :: MonadUnliftIO m => deps -> Layer m deps env -> m env+runLayer deps (Layer l) = do+  lenv <- LayerEnv <$> newMVar emptyTypeMap <*> pure nullInterceptor+  runResourceT (l lenv deps)++-- | Build a layer, use its environment, and guarantee cleanup.+--+-- This is the primary way to run a layer in production code.+-- Resources are released in reverse acquisition order when the+-- callback returns or throws.+--+-- __Typical main:__+--+-- @+-- main :: IO ()+-- main = withLayer () appLayer $ \\(cfg, (db, ws)) ->+--   serveRequests ws+-- @+--+-- __Nested scopes:__+--+-- @+-- main :: IO ()+-- main = withLayer () configLayer $ \\cfg ->+--   withLayer cfg dbLayer $ \\db -> do+--     rows <- query db "SELECT 1"+--     print rows+-- @+--+-- Prefer the composed form (@configLayer >>> dbLayer@) over nesting+-- when possible, since it is both more concise and enables parallel+-- composition.+withLayer ::+  MonadUnliftIO m =>+  -- | Dependencies+  deps ->+  -- | Layer to run+  Layer m deps env ->+  -- | Action that needs the env+  (env -> m r) ->+  m r+withLayer deps (Layer l) useEnv = runResourceT $ do+  lenv <- LayerEnv <$> newMVar emptyTypeMap <*> pure nullInterceptor+  env <- l lenv deps+  lift $ useEnv env++-- | Like 'runLayer', but with a custom 'LayerInterceptor' for+-- observing construction events.+--+-- @+-- diags <- runLayerWithInterceptor loggingInterceptor () appLayer+-- @+runLayerWithInterceptor ::+  MonadUnliftIO m =>+  -- | Custom interceptor+  LayerInterceptor m ->+  -- | Dependencies+  deps ->+  -- | Layer to run+  Layer m deps env ->+  m env+runLayerWithInterceptor i deps (Layer l) = do+  lenv <- LayerEnv <$> newMVar emptyTypeMap <*> pure i+  runResourceT (l lenv deps)++-- | Like 'withLayer', but with a custom 'LayerInterceptor'.+--+-- @+-- main :: IO ()+-- main = withLayerAndInterceptor loggingInterceptor () appLayer $ \\env ->+--   runApp env+-- @+withLayerAndInterceptor ::+  MonadUnliftIO m =>+  -- | Custom interceptor+  LayerInterceptor m ->+  -- | Dependencies+  deps ->+  -- | Layer to run+  Layer m deps env ->+  -- | Action that needs the env+  (env -> m r) ->+  m r+withLayerAndInterceptor i deps (Layer l) useEnv = runResourceT $ do+  lenv <- LayerEnv <$> newMVar emptyTypeMap <*> pure i+  env <- l lenv deps+  lift $ useEnv env
+ src/Fractal/Layer/Diagnostics.hs view
@@ -0,0 +1,583 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- |+-- Module      : Fractal.Layer.Diagnostics+-- Description : Diagnostics and visualization for layer initialization+-- Stability   : experimental+-- Portability : Portable+--+-- This module provides tools for visualizing and debugging layer initialization.+-- It can track how layers are composed, which services are shared, and render+-- the dependency tree in various formats.+--+-- == Example Usage+--+-- @+-- appLayer :: Layer IO () (Config, Database, WebServer)+-- appLayer = configLayer >>> (dbLayer &&& webLayer)+--+-- main :: IO ()+-- main = withLayerDiagnostics appLayer () $ \(env, diags) -> do+--   -- Print ASCII tree+--   putStrLn $ renderLayerTree diags+--+--   -- Or export as JSON+--   BSL.writeFile "layer-tree.json" (encode diags)+--+--   -- Use the environment+--   let (cfg, db, ws) = env+--   serveRequests ws+-- @+module Fractal.Layer.Diagnostics+  ( -- * Diagnostics Types+    LayerDiagnostics (..),+    LayerNode (..),+    LayerNodeType (..),+    ResourceStatus (..),++    -- * Diagnostics Interceptor+    createDiagnosticsInterceptor,+    DiagnosticsCollector,+    newDiagnosticsCollector,+    finalizeDiagnostics,++    -- * Running with Diagnostics+    withLayerDiagnostics,+    buildLayerDiagnostics,++    -- * Rendering+    renderLayerTree,+    renderLayerTreeDetailed,++    -- * Snapshots+    snapshotDiagnostics,++    -- * JSON Export+    diagnosticsToJSON,+  )+where++import Control.Monad.IO.Class+import Data.Aeson (ToJSON (..), FromJSON (..), object, (.=), (.:), Value, withObject, withText)+import Data.HashMap.Strict (HashMap)+import qualified Data.HashMap.Strict as HashMap+import Control.Concurrent.MVar+import Data.Text (Text)+import qualified Data.Text as T+import Data.Time.Clock (NominalDiffTime, getCurrentTime, diffUTCTime, UTCTime)+import Data.Typeable+import Fractal.Layer.Interceptor+import UnliftIO (MonadUnliftIO)++-- Import Layer types and functions (defined later in dependency order)+import Fractal.Layer (Layer, runLayerWithInterceptor, withLayerAndInterceptor)++-------------------------------------------------------------------------------+-- Types+-------------------------------------------------------------------------------++-- | Complete diagnostics for a layer initialization+data LayerDiagnostics = LayerDiagnostics+  { rootNode :: LayerNode+  -- ^ The root of the initialization tree+  , totalDuration :: Double+  -- ^ Total time taken to initialize (seconds)+  , totalResources :: Int+  -- ^ Total number of resources allocated+  , sharedResources :: Int+  -- ^ Number of resources that were shared/cached+  }+  deriving (Show)++-- | A node in the layer initialization tree+data LayerNode = LayerNode+  { nodeId :: Text+  -- ^ Unique identifier for this node+  , nodeName :: Text+  -- ^ Human-readable name (e.g., "DatabaseLayer", "ConfigService")+  , nodeType :: LayerNodeType+  -- ^ Type of layer node+  , resourceType :: Maybe TypeRep+  -- ^ The type of resource produced (if available)+  , status :: ResourceStatus+  -- ^ Status of this resource+  , duration :: Maybe Double+  -- ^ Time taken to initialize this layer (seconds)+  , children :: [LayerNode]+  -- ^ Child layers+  , metadata :: HashMap Text Text+  -- ^ Additional metadata+  }+  deriving (Show)++-- | The type of layer node+data LayerNodeType+  = ResourceNode+  -- ^ A resource that was allocated+  | EffectNode+  -- ^ An effect that was run+  | ServiceNode+  -- ^ A cached service+  | ComposedNode+  -- ^ A composed layer (>>> or &&&)+  | ParallelNode+  -- ^ Parallel composition (&&&)+  | SequentialNode+  -- ^ Sequential composition (>>>)+  deriving (Show, Eq)++-- | Status of a resource+data ResourceStatus+  = Initializing+  -- ^ Currently being initialized+  | Initialized+  -- ^ Successfully initialized+  | Failed String+  -- ^ Failed to initialize with error+  | SharedReference Text+  -- ^ Reference to a shared resource (with node ID)+  deriving (Show, Eq)++-------------------------------------------------------------------------------+-- JSON Instances+-------------------------------------------------------------------------------++instance ToJSON LayerDiagnostics where+  toJSON LayerDiagnostics{..} =+    object+      [ "root" .= rootNode+      , "totalDuration" .= totalDuration+      , "totalResources" .= totalResources+      , "sharedResources" .= sharedResources+      ]++instance ToJSON LayerNode where+  toJSON LayerNode{..} =+    object+      [ "id" .= nodeId+      , "name" .= nodeName+      , "type" .= nodeType+      , "resourceType" .= (show <$> resourceType)+      , "status" .= status+      , "duration" .= duration+      , "children" .= children+      , "metadata" .= metadata+      ]++instance ToJSON LayerNodeType where+  toJSON = \case+    ResourceNode -> "resource"+    EffectNode -> "effect"+    ServiceNode -> "service"+    ComposedNode -> "composed"+    ParallelNode -> "parallel"+    SequentialNode -> "sequential"++instance ToJSON ResourceStatus where+  toJSON = \case+    Initializing -> object ["status" .= ("initializing" :: Text)]+    Initialized -> object ["status" .= ("initialized" :: Text)]+    Failed err -> object ["status" .= ("failed" :: Text), "error" .= err]+    SharedReference nodeId -> object ["status" .= ("shared" :: Text), "reference" .= nodeId]++-- FromJSON instances for deserialization+instance FromJSON LayerDiagnostics where+  parseJSON = withObject "LayerDiagnostics" $ \v -> LayerDiagnostics+    <$> v .: "root"+    <*> v .: "totalDuration"+    <*> v .: "totalResources"+    <*> v .: "sharedResources"++instance FromJSON LayerNode where+  parseJSON = withObject "LayerNode" $ \v -> LayerNode+    <$> v .: "id"+    <*> v .: "name"+    <*> v .: "type"+    <*> pure Nothing  -- resourceType is serialized as String, skip for now+    <*> v .: "status"+    <*> v .: "duration"+    <*> v .: "children"+    <*> v .: "metadata"++instance FromJSON LayerNodeType where+  parseJSON = withText "LayerNodeType" $ \case+    "resource" -> pure ResourceNode+    "effect" -> pure EffectNode+    "service" -> pure ServiceNode+    "composed" -> pure ComposedNode+    "parallel" -> pure ParallelNode+    "sequential" -> pure SequentialNode+    _ -> fail "Unknown layer node type"++instance FromJSON ResourceStatus where+  parseJSON = withObject "ResourceStatus" $ \v -> do+    status <- v .: "status"+    case status :: Text of+      "initializing" -> pure Initializing+      "initialized" -> pure Initialized+      "failed" -> Failed <$> v .: "error"+      "shared" -> SharedReference <$> v .: "reference"+      _ -> fail "Unknown status"++-------------------------------------------------------------------------------+-- Diagnostics Collector (Interceptor-based)+-------------------------------------------------------------------------------++-- | Mutable state for collecting diagnostics during layer initialization+data DiagnosticsCollectorState = DiagnosticsCollectorState+  { collectorNodeStack :: ![LayerNode]+  -- ^ Stack of nodes being built (top = current)+  , collectorNextId :: !Int+  -- ^ Next available node ID+  , collectorStartTime :: !UTCTime+  -- ^ When collection started+  , collectorServiceMap :: !(HashMap TypeRep Text)+  -- ^ Map of service types to their node IDs (for tracking sharing)+  , collectorTotalResources :: !Int+  -- ^ Total number of resources allocated+  , collectorSharedResources :: !Int+  -- ^ Number of shared/reused resources+  }++-- | Opaque handle to a diagnostics collector+newtype DiagnosticsCollector = DiagnosticsCollector (MVar DiagnosticsCollectorState)++-- | Create a new diagnostics collector+newDiagnosticsCollector :: MonadIO m => m DiagnosticsCollector+newDiagnosticsCollector = liftIO $ do+  start <- getCurrentTime+  ref <- newMVar DiagnosticsCollectorState+    { collectorNodeStack = [rootNode]+    , collectorNextId = 1+    , collectorStartTime = start+    , collectorServiceMap = HashMap.empty+    , collectorTotalResources = 0+    , collectorSharedResources = 0+    }+  pure $ DiagnosticsCollector ref+  where+    rootNode = LayerNode+      { nodeId = "root"+      , nodeName = "Root"+      , nodeType = ComposedNode+      , resourceType = Nothing+      , status = Initializing+      , duration = Nothing+      , children = []+      , metadata = HashMap.empty+      }++-- | Finalize diagnostics and get the complete tree+finalizeDiagnostics :: MonadIO m => DiagnosticsCollector -> m LayerDiagnostics+finalizeDiagnostics (DiagnosticsCollector ref) = liftIO $ do+  state <- readMVar ref+  endTime <- getCurrentTime+  let totalDur = realToFrac (diffUTCTime endTime (collectorStartTime state))+      rootNode = case collectorNodeStack state of+        [] -> error "Diagnostics collector has empty stack"+        (node:_) -> node { status = Initialized, duration = Just totalDur }+  pure $ LayerDiagnostics+    { rootNode = rootNode+    , totalDuration = totalDur+    , totalResources = collectorTotalResources state+    , sharedResources = collectorSharedResources state+    }++-- | Create a LayerInterceptor that collects diagnostics+createDiagnosticsInterceptor :: MonadIO m => DiagnosticsCollector -> LayerInterceptor m+createDiagnosticsInterceptor collector = LayerInterceptor+  { onResourceAcquire = \ctx -> liftIO $+      diagStartNode collector ctx ResourceNode+  , onResourceAcquireComplete = \_ dur -> liftIO $+      diagEndNode collector dur Initialized+  , onResourceRelease = \_ -> pure ()+  , onEffectRun = \ctx -> liftIO $+      diagStartNode collector ctx EffectNode+  , onEffectComplete = \_ dur -> liftIO $+      diagEndNode collector dur Initialized+  , onServiceCreate = \ctx -> liftIO $ do+      diagStartNode collector ctx ServiceNode+      diagRegisterService collector ctx+  , onServiceReuse = \name tr -> liftIO $+      diagAddSharedRef collector name tr+  , onCompositionStart = \typ -> liftIO $ do+      let nt = case typ of+            Sequential -> SequentialNode+            Parallel -> ParallelNode+          ctx = OperationContext (T.pack (show typ)) Nothing []+      diagStartNode collector ctx nt+  , onCompositionEnd = \_ dur -> liftIO $+      diagEndNode collector dur Initialized+  }++diagStartNode :: DiagnosticsCollector -> OperationContext -> LayerNodeType -> IO ()+diagStartNode (DiagnosticsCollector ref) ctx nt =+  modifyMVar_ ref $ \state -> do+    let newNode = LayerNode+          { nodeId = T.pack ("node-" <> show (collectorNextId state))+          , nodeName = operationName ctx+          , nodeType = nt+          , resourceType = operationType ctx+          , status = Initializing+          , duration = Nothing+          , children = []+          , metadata = HashMap.fromList (operationMetadata ctx)+          }+    pure state+      { collectorNodeStack = newNode : collectorNodeStack state+      , collectorNextId = collectorNextId state + 1+      }++diagEndNode :: DiagnosticsCollector -> NominalDiffTime -> ResourceStatus -> IO ()+diagEndNode (DiagnosticsCollector ref) dur st =+  modifyMVar_ ref $ \state ->+    pure $ case collectorNodeStack state of+      [] -> state+      [root] ->+        state { collectorNodeStack =+            [root { status = st, duration = Just (realToFrac dur) }]+        }+      (current:parent:rest) ->+        let completed = current { status = st, duration = Just (realToFrac dur) }+        in state { collectorNodeStack =+            parent { children = children parent ++ [completed] } : rest+        }++diagRegisterService :: DiagnosticsCollector -> OperationContext -> IO ()+diagRegisterService (DiagnosticsCollector ref) ctx =+  modifyMVar_ ref $ \s -> pure $ case operationType ctx of+    Just tr -> s+      { collectorServiceMap = HashMap.insert tr (operationName ctx) (collectorServiceMap s)+      , collectorTotalResources = collectorTotalResources s + 1+      }+    Nothing -> s+      { collectorTotalResources = collectorTotalResources s + 1+      }++diagAddSharedRef :: DiagnosticsCollector -> Text -> TypeRep -> IO ()+diagAddSharedRef (DiagnosticsCollector ref) name tr =+  modifyMVar_ ref $ \state ->+    case HashMap.lookup tr (collectorServiceMap state) of+      Just originalNodeId -> do+        let sn = LayerNode+              { nodeId = T.pack ("shared-" <> show (collectorNextId state))+              , nodeName = name+              , nodeType = ServiceNode+              , resourceType = Just tr+              , status = SharedReference originalNodeId+              , duration = Nothing+              , children = []+              , metadata = HashMap.empty+              }+        pure $ case collectorNodeStack state of+          [] -> state+          (top:rest) ->+            state+              { collectorNodeStack = top { children = children top ++ [sn] } : rest+              , collectorNextId = collectorNextId state + 1+              , collectorSharedResources = collectorSharedResources state + 1+              }+      Nothing -> pure state++-------------------------------------------------------------------------------+-- Running with Diagnostics+-------------------------------------------------------------------------------++-- | Build a layer with diagnostics tracking and return only the diagnostics.+--+-- The environment is discarded and resources are released immediately.+-- Useful for inspecting layer structure without keeping resources alive.+-- Use 'withLayerDiagnostics' when you need both the environment and diagnostics.+buildLayerDiagnostics ::+  MonadUnliftIO m =>+  -- | Layer to build+  Layer m deps env ->+  -- | Dependencies+  deps ->+  m LayerDiagnostics+buildLayerDiagnostics layer deps = do+  collector <- newDiagnosticsCollector+  let interceptor = createDiagnosticsInterceptor collector+  _ <- runLayerWithInterceptor interceptor deps layer+  finalizeDiagnostics collector++-- | Run a layer with diagnostics enabled.+-- Returns both the environment and the diagnostics information.+--+-- This function runs the layer with diagnostics collection enabled, then provides+-- both the initialized environment and collected diagnostics to your continuation.+--+-- Example:+-- @+-- withLayerDiagnostics myLayer config $ \(env, diags) -> do+--   putStrLn $ renderLayerTree diags+--   -- Use the environment...+-- @+withLayerDiagnostics ::+  MonadUnliftIO m =>+  -- | Layer to run+  Layer m deps env ->+  -- | Dependencies+  deps ->+  -- | Action with environment and diagnostics+  ((env, LayerDiagnostics) -> m r) ->+  m r+withLayerDiagnostics layer deps action = do+  collector <- newDiagnosticsCollector+  let interceptor = createDiagnosticsInterceptor collector+  withLayerAndInterceptor interceptor deps layer $ \env -> do+    diags <- finalizeDiagnostics collector+    action (env, diags)++-------------------------------------------------------------------------------+-- Rendering to Terminal+-------------------------------------------------------------------------------++-- | Render the layer tree as ASCII art (compact version)+--+-- Example output:+-- @+-- Root+-- ├── ConfigLayer (0.05s)+-- └── ParallelComposition (0.3s)+--     ├── DatabaseLayer (0.2s)+--     │   └── ConnectionPool [SHARED: svc-001]+--     └── WebServerLayer (0.1s)+--         └── MetricsCollector [SHARED: svc-001]+-- @+renderLayerTree :: LayerDiagnostics -> String+renderLayerTree diags =+  unlines $+    [ "Layer Initialization Tree"+    , "═════════════════════════"+    , ""+    , "⧗ Duration: " ++ show (totalDuration diags) ++ "s"+    , "◆ Resources: " ++ show (totalResources diags)+    , "↻ Shared: " ++ show (sharedResources diags)+    , ""+    ] ++ renderNode "" True (rootNode diags)++-- | Detailed rendering with metadata+renderLayerTreeDetailed :: LayerDiagnostics -> String+renderLayerTreeDetailed diags =+  unlines $+    [ "Layer Initialization Tree (Detailed)"+    , "═════════════════════════════════════"+    , ""+    , "⧗ Duration: " ++ show (totalDuration diags) ++ "s"+    , "◆ Resources: " ++ show (totalResources diags)+    , "↻ Shared: " ++ show (sharedResources diags)+    , ""+    ] ++ renderNodeDetailed "" True (rootNode diags)++-- | Render a single node in the tree+renderNode :: String -> Bool -> LayerNode -> [String]+renderNode prefix isLast node =+  let connector = if isLast then "└── " else "├── "+      extension = if isLast then "    " else "│   "+      typeSymbol = nodeTypeSymbol (nodeType node)+      nodeInfo = typeSymbol ++ " " ++ T.unpack (nodeName node) ++ formatDuration (duration node) ++ formatStatus (status node)+      header = prefix ++ connector ++ nodeInfo+      childPrefix = prefix ++ extension+      childCount = length (children node)+      childLines = concat $ zipWith (\i child -> renderNode childPrefix (i == childCount - 1) child) [1..] (children node)+  in header : childLines++-- | Render a node with detailed metadata+renderNodeDetailed :: String -> Bool -> LayerNode -> [String]+renderNodeDetailed prefix isLast node =+  let connector = if isLast then "└── " else "├── "+      extension = if isLast then "    " else "│   "+      typeSymbol = nodeTypeSymbol (nodeType node)+      nodeInfo = typeSymbol ++ " " ++ T.unpack (nodeName node) ++ " [" ++ show (nodeType node) ++ "]"+      header = prefix ++ connector ++ nodeInfo+      childPrefix = prefix ++ extension++      -- Add metadata lines+      metaLines = if HashMap.null (metadata node)+        then []+        else map (\(k, v) -> childPrefix ++ "  ▸ " ++ T.unpack k ++ ": " ++ T.unpack v) (HashMap.toList $ metadata node)++      -- Add type info if available+      typeLines = case resourceType node of+        Nothing -> []+        Just tr -> [childPrefix ++ "  ▸ Type: " ++ show tr]++      -- Add duration+      durationLines = case duration node of+        Nothing -> []+        Just d -> [childPrefix ++ "  ⧗ " ++ show d ++ "s"]++      -- Add status+      statusLines = [childPrefix ++ "  " ++ formatStatusDetailed (status node)]++      detailLines = typeLines ++ durationLines ++ statusLines ++ metaLines++      childCount = length (children node)+      childLines = concat $ zipWith (\i child -> renderNodeDetailed childPrefix (i == childCount - 1) child) [1..] (children node)+  in (header : detailLines) ++ childLines++-- | Symbol for each node type+nodeTypeSymbol :: LayerNodeType -> String+nodeTypeSymbol ResourceNode = "◆"+nodeTypeSymbol EffectNode = "⚡"+nodeTypeSymbol ServiceNode = "◉"+nodeTypeSymbol ComposedNode = "⊕"+nodeTypeSymbol ParallelNode = "⋈"+nodeTypeSymbol SequentialNode = "⇒"++formatDuration :: Maybe Double -> String+formatDuration Nothing = ""+formatDuration (Just d) = " ⧗" ++ show d ++ "s"++formatStatus :: ResourceStatus -> String+formatStatus Initializing = " ⟳"+formatStatus Initialized = " ✓"+formatStatus (Failed err) = " ✗ [" ++ err ++ "]"+formatStatus (SharedReference refId) = " ↻ " ++ T.unpack refId++formatStatusDetailed :: ResourceStatus -> String+formatStatusDetailed Initializing = "⟳ Initializing"+formatStatusDetailed Initialized = "✓ Initialized"+formatStatusDetailed (Failed err) = "✗ Failed - " ++ err+formatStatusDetailed (SharedReference refId) = "↻ Shared reference to " ++ T.unpack refId++-- | Get a snapshot of current diagnostics without finalizing+--+-- This is useful for live rendering while initialization is still in progress.+-- Unlike 'finalizeDiagnostics', this doesn't mark the collector as finished.+snapshotDiagnostics :: MonadIO m => DiagnosticsCollector -> m LayerDiagnostics+snapshotDiagnostics (DiagnosticsCollector ref) = liftIO $ do+  state <- readMVar ref+  endTime <- getCurrentTime+  let totalDur = realToFrac (diffUTCTime endTime (collectorStartTime state))+      root = case collectorNodeStack state of+        [] -> LayerNode+          { nodeId = "empty"+          , nodeName = "Empty"+          , nodeType = ComposedNode+          , resourceType = Nothing+          , status = Initialized+          , duration = Nothing+          , children = []+          , metadata = HashMap.empty+          }+        (n:_) -> n+  pure LayerDiagnostics+    { rootNode = root+    , totalDuration = totalDur+    , totalResources = collectorTotalResources state+    , sharedResources = collectorSharedResources state+    }++-------------------------------------------------------------------------------+-- JSON Export+-------------------------------------------------------------------------------++-- | Convert diagnostics to a JSON Value+diagnosticsToJSON :: LayerDiagnostics -> Value+diagnosticsToJSON = toJSON+
+ src/Fractal/Layer/Interceptor.hs view
@@ -0,0 +1,162 @@+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- |+-- Module      : Fractal.Layer.Interceptor+-- Description : Observable hooks for layer construction events+-- Stability   : experimental+--+-- Every 'Fractal.Layer.Layer' fires lifecycle callbacks as it acquires+-- resources, runs effects, creates services, and composes sub-layers.+-- A 'LayerInterceptor' is a record of those callbacks. Plug in your+-- own to add logging, metrics, tracing, or diagnostics without+-- modifying layer code.+--+-- The default 'nullInterceptor' is a no-op with zero overhead.+-- Combine multiple interceptors with 'combineInterceptors'.+--+-- == Example: simple logging+--+-- @+-- loggingInterceptor :: LayerInterceptor IO+-- loggingInterceptor = nullInterceptor+--   { onResourceAcquire = \\ctx ->+--       putStrLn $ \"Acquiring: \" <> show (operationName ctx)+--   , onResourceRelease = \\name ->+--       putStrLn $ \"Released: \" <> show name+--   }+-- @+module Fractal.Layer.Interceptor+  ( -- * Core Interface+    LayerInterceptor (..),+    CompositionType (..),+    OperationContext (..),++    -- * Built-in Interceptors+    nullInterceptor,+    combineInterceptors,++    -- * Helper Functions+    simpleContext,+    withType,+    withMetadata,+  )+where++import Data.Text (Text)+import Data.Time.Clock (NominalDiffTime)+import Data.Typeable (TypeRep)++-------------------------------------------------------------------------------+-- Types+-------------------------------------------------------------------------------++-- | Type of layer composition+data CompositionType+  = Sequential+  -- ^ Sequential composition (>>>)+  | Parallel+  -- ^ Parallel composition (&&&)+  deriving (Show, Eq)++-- | Context information about an operation+data OperationContext = OperationContext+  { operationName :: !Text+  -- ^ Human-readable name of the operation+  , operationType :: !(Maybe TypeRep)+  -- ^ Type representation if available+  , operationMetadata :: ![(Text, Text)]+  -- ^ Additional metadata as key-value pairs+  }+  deriving (Show)++-- | Callbacks fired at each stage of layer construction.+--+-- Override individual fields from 'nullInterceptor' to observe only+-- the events you care about.  Lifecycle for a 'Fractal.Layer.resource'+-- layer:+--+-- @+-- onResourceAcquire  →  (acquire action)  →  onResourceAcquireComplete+--    ⋮                                            ⋮+--    ⋮   ... layer is used ...                    ⋮+--    ⋮                                            ⋮+-- onResourceRelease  ←  (release action runs during cleanup)+-- @+data LayerInterceptor m = LayerInterceptor+  { onResourceAcquire :: OperationContext -> m ()+  -- ^ Called before acquiring a resource+  , onResourceAcquireComplete :: Text -> NominalDiffTime -> m ()+  -- ^ Called after a resource is successfully acquired (with operation name and duration)+  , onResourceRelease :: Text -> m ()+  -- ^ Called when a resource finalizer actually runs during cleanup+  , onEffectRun :: OperationContext -> m ()+  -- ^ Called before running an effect+  , onEffectComplete :: Text -> NominalDiffTime -> m ()+  -- ^ Called after an effect completes+  , onServiceCreate :: OperationContext -> m ()+  -- ^ Called when creating a new service+  , onServiceReuse :: Text -> TypeRep -> m ()+  -- ^ Called when reusing a cached service+  , onCompositionStart :: CompositionType -> m ()+  -- ^ Called when starting a composition+  , onCompositionEnd :: CompositionType -> NominalDiffTime -> m ()+  -- ^ Called when completing a composition+  }++-------------------------------------------------------------------------------+-- Built-in Interceptors+-------------------------------------------------------------------------------++-- | All-no-op interceptor.  This is the default when no interceptor is+-- specified; all callbacks immediately return @pure ()@.+nullInterceptor :: Applicative m => LayerInterceptor m+nullInterceptor =+  LayerInterceptor+    { onResourceAcquire = \_ -> pure ()+    , onResourceAcquireComplete = \_ _ -> pure ()+    , onResourceRelease = \_ -> pure ()+    , onEffectRun = \_ -> pure ()+    , onEffectComplete = \_ _ -> pure ()+    , onServiceCreate = \_ -> pure ()+    , onServiceReuse = \_ _ -> pure ()+    , onCompositionStart = \_ -> pure ()+    , onCompositionEnd = \_ _ -> pure ()+    }++-- | Fan out each event to every interceptor in order.+-- If a callback throws, later interceptors for that event are skipped.+combineInterceptors :: Monad m => [LayerInterceptor m] -> LayerInterceptor m+combineInterceptors interceptors =+  LayerInterceptor+    { onResourceAcquire = \ctx -> mapM_ (\i -> onResourceAcquire i ctx) interceptors+    , onResourceAcquireComplete = \name dur -> mapM_ (\i -> onResourceAcquireComplete i name dur) interceptors+    , onResourceRelease = \name -> mapM_ (\i -> onResourceRelease i name) interceptors+    , onEffectRun = \ctx -> mapM_ (\i -> onEffectRun i ctx) interceptors+    , onEffectComplete = \name dur -> mapM_ (\i -> onEffectComplete i name dur) interceptors+    , onServiceCreate = \ctx -> mapM_ (\i -> onServiceCreate i ctx) interceptors+    , onServiceReuse = \name tr -> mapM_ (\i -> onServiceReuse i name tr) interceptors+    , onCompositionStart = \typ -> mapM_ (\i -> onCompositionStart i typ) interceptors+    , onCompositionEnd = \typ dur -> mapM_ (\i -> onCompositionEnd i typ dur) interceptors+    }++-------------------------------------------------------------------------------+-- Helper Functions+-------------------------------------------------------------------------------++-- | Create a simple operation context with just a name+simpleContext :: Text -> OperationContext+simpleContext name =+  OperationContext+    { operationName = name+    , operationType = Nothing+    , operationMetadata = []+    }++-- | Add type information to a context+withType :: TypeRep -> OperationContext -> OperationContext+withType tr ctx = ctx {operationType = Just tr}++-- | Add metadata to a context+withMetadata :: [(Text, Text)] -> OperationContext -> OperationContext+withMetadata meta ctx = ctx {operationMetadata = operationMetadata ctx ++ meta}
+ src/Fractal/Layer/Internal.hs view
@@ -0,0 +1,168 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}++-- |+-- Module      : Fractal.Layer.Internal+-- Description : Internal implementation details for Fractal.Layer+-- Stability   : unstable+--+-- This module exposes the full internals of 'Layer', including the+-- @Layer@ constructor and @build@ record field. Import this only if+-- you are extending the library itself or building custom runners.+-- The API here may change between minor versions without notice.+--+-- For normal usage, import "Fractal.Layer" instead.+module Fractal.Layer.Internal+  ( -- * Core types+    Layer (..),+    LayerEnv (..),+    Service (..),++    -- * Type-safe map keyed by types+    TypeMap,+    DynF,+    emptyTypeMap,+    lookupTypeMap,+    insertTypeMap,++    -- * Service state+    ServiceState (..),++    -- * Low-level construction+    pureLayer,+    unsafeMkLayer,+  )+where++import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Typeable+import qualified Type.Reflection as TR+import Control.Monad.Trans.Resource (ResourceT)+import Fractal.Layer.Interceptor (LayerInterceptor)+import UnliftIO (MVar, SomeException)++-------------------------------------------------------------------------------+-- Type-safe map keyed by types+--+-- Uses SomeTypeRep's Ord instance (fingerprint comparison) for O(log n)+-- lookups. Existential GADT witness eliminates unsafeCoerce.+-------------------------------------------------------------------------------++data DynF f where+  MkDynF :: !(TR.TypeRep a) -> f a -> DynF f++newtype TypeMap f = TypeMap (Map TR.SomeTypeRep (DynF f))++instance Semigroup (TypeMap f) where+  TypeMap a <> TypeMap b = TypeMap (a <> b)++instance Monoid (TypeMap f) where+  mempty = TypeMap Map.empty++emptyTypeMap :: TypeMap f+emptyTypeMap = TypeMap Map.empty++lookupTypeMap :: forall a f. Typeable a => TypeMap f -> Maybe (f a)+lookupTypeMap (TypeMap m) = do+  MkDynF rep v <- Map.lookup (TR.SomeTypeRep (TR.typeRep @a)) m+  TR.HRefl <- TR.eqTypeRep (TR.typeRep @a) rep+  pure v++insertTypeMap :: forall a f. Typeable a => f a -> TypeMap f -> TypeMap f+insertTypeMap v (TypeMap m) =+  TypeMap (Map.insert (TR.SomeTypeRep (TR.typeRep @a)) (MkDynF (TR.typeRep @a) v) m)++-------------------------------------------------------------------------------+-- Service state+-------------------------------------------------------------------------------++data ServiceState a+  = Initialized a+  | Failed SomeException++-------------------------------------------------------------------------------+-- Layer environment+-------------------------------------------------------------------------------++-- | Internal environment threaded through layer construction.+-- Carries the service cache and the active interceptor.+data LayerEnv m = LayerEnv+  { serviceStates :: !(MVar (TypeMap ServiceState))+  -- MVar rather than IORef because zipLayer runs sub-layers+  -- concurrently via async.  Two parallel layers calling `service`+  -- on the same Service must serialise the check-then-initialise+  -- sequence so only one thread performs initialisation and the+  -- other blocks until it completes.+  , interceptor :: !(LayerInterceptor m)+  }++-------------------------------------------------------------------------------+-- Layer+-------------------------------------------------------------------------------++-- | A recipe for producing an @env@ from @deps@, with managed+-- resource lifetimes.+--+-- Conceptually, @Layer m deps env ≈ deps -> m env@, but with+-- automatic resource tracking: every resource acquired during+-- construction is released when the layer's scope ends.+--+-- The @deps@ parameter makes dependencies explicit in the type. You+-- can inspect a layer's signature and know exactly what it needs+-- without reading the implementation. The compiler enforces that the+-- wiring is correct at every composition site.+--+-- @Layer@ implements many standard typeclasses, giving you a rich+-- vocabulary for composition:+--+-- * 'Monad': sequential resource threading via @do@-notation.+-- * 'Category': sequential composition via @>>>@.+-- * 'Arrow': parallel composition via @&&&@ and @***@.+-- * 'ArrowChoice': branching via @+++@ and @|||@.+-- * 'Profunctor': contramap inputs with 'lmap', map outputs with 'fmap'.+-- * 'Alternative': fallback with resource cleanup via @\<|\>@.+--+-- Construct layers with 'effect', 'resource', or 'bracketed'.+-- Run them with 'withLayer' or 'runLayer'.+newtype Layer m deps env = Layer+  { build :: LayerEnv m -> deps -> ResourceT m env+  -- ^ Build the layer inside 'ResourceT', acquiring any required+  --   resources and registering their corresponding finalizers.+  }++-------------------------------------------------------------------------------+-- Service+-------------------------------------------------------------------------------++-- | A layer tagged for singleton caching.  Create with 'mkService',+-- consume with 'service'.  The 'uncached' accessor retrieves the+-- original layer without caching behaviour.+newtype Service m deps env = Service+  { uncached :: Layer m deps env+  -- ^ The underlying layer, without caching.+  }++-------------------------------------------------------------------------------+-- Low-level construction+-------------------------------------------------------------------------------++-- | Construct a pure layer from a plain value. No effects, no cleanup.+--+-- Primarily used to implement 'pure' for the 'Applicative' instance.+-- In application code, 'effect' with a pure action is usually clearer:+--+-- @+-- -- these are equivalent:+-- pureLayer defaultConfig+-- effect (pure defaultConfig)+-- @+pureLayer :: Applicative m => env -> Layer m deps env+pureLayer env = Layer $ \_ -> const (pure env)++-- | Lowest-level smart constructor for effectful layers. Bypasses the+-- interceptor machinery, so operations built this way are invisible to+-- diagnostics and tracing. Prefer 'effect' or 'resource' when possible.+unsafeMkLayer :: (deps -> ResourceT m env) -> Layer m deps env+unsafeMkLayer f = Layer $ \_ -> f
+ test/Fractal/Layer/DiagnosticsSpec.hs view
@@ -0,0 +1,962 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}++module Fractal.Layer.DiagnosticsSpec (spec) where++import Control.Category ((>>>))+import Control.Monad (void)+import Control.Monad.Reader.Class (ask)+import Data.Aeson (encode, decode, toJSON, toEncoding)+import Data.Aeson.Encoding (encodingToLazyByteString)+import qualified Data.ByteString.Lazy as BSL+import qualified Data.HashMap.Strict as HashMap+import qualified Data.Text as T+import Data.Typeable (Proxy(..), typeRep)+import Fractal.Layer+import Fractal.Layer.Diagnostics+import Fractal.Layer.Interceptor+import Test.Hspec+import UnliftIO+import Prelude hiding ((.), id)++-- Test data types+newtype Config = Config { configPort :: Int }+  deriving (Show, Eq)++newtype Database = Database { dbConnection :: String }+  deriving (Show, Eq)++newtype WebServer = WebServer { serverPort :: Int }+  deriving (Show, Eq)++newtype CacheService = CacheService { cacheSize :: Int }+  deriving (Show, Eq)++spec :: Spec+spec = do+  describe "LayerInterceptor" $ do+    it "nullInterceptor has no effect" $ do+      ref <- newIORef ([] :: [String])+      let layer = effect @IO @() @Config $ do+            modifyIORef ref (++ ["effect"])+            pure (Config 8080)+      result <- runLayer () layer+      configPort result `shouldBe` 8080+      logs <- readIORef ref+      logs `shouldBe` ["effect"]++    it "custom interceptor captures operations" $ do+      ref <- newIORef ([] :: [String])+      let customInterceptor = LayerInterceptor+            { onResourceAcquire = \ctx -> liftIO $ modifyIORef ref (++ ["resource-acquire:" <> T.unpack (operationName ctx)])+            , onResourceAcquireComplete = \name _ -> liftIO $ modifyIORef ref (++ ["resource-acquire-complete:" <> T.unpack name])+            , onResourceRelease = \name -> liftIO $ modifyIORef ref (++ ["resource-release:" <> T.unpack name])+            , onEffectRun = \ctx -> liftIO $ modifyIORef ref (++ ["effect-run:" <> T.unpack (operationName ctx)])+            , onEffectComplete = \name _ -> liftIO $ modifyIORef ref (++ ["effect-complete:" <> T.unpack name])+            , onServiceCreate = \ctx -> liftIO $ modifyIORef ref (++ ["service-create:" <> T.unpack (operationName ctx)])+            , onServiceReuse = \name _ -> liftIO $ modifyIORef ref (++ ["service-reuse:" <> T.unpack name])+            , onCompositionStart = \_ -> liftIO $ modifyIORef ref (++ ["composition-start"])+            , onCompositionEnd = \_ _ -> liftIO $ modifyIORef ref (++ ["composition-end"])+            }++      let layer = effect @IO @() @Config $ pure (Config 8080)+      void $ runLayerWithInterceptor customInterceptor () layer++      logs <- readIORef ref+      logs `shouldContain` ["effect-run:Config"]+      logs `shouldContain` ["effect-complete:Config"]++    it "combines multiple interceptors" $ do+      ref1 <- newIORef ([] :: [String])+      ref2 <- newIORef ([] :: [String])++      let interceptor1 = LayerInterceptor+            { onResourceAcquire = \_ -> liftIO $ modifyIORef ref1 (++ ["i1-resource"])+            , onResourceAcquireComplete = \_ _ -> pure ()+            , onResourceRelease = \_ -> liftIO $ modifyIORef ref1 (++ ["i1-release"])+            , onEffectRun = \_ -> liftIO $ modifyIORef ref1 (++ ["i1-effect"])+            , onEffectComplete = \_ _ -> pure ()+            , onServiceCreate = \_ -> pure ()+            , onServiceReuse = \_ _ -> pure ()+            , onCompositionStart = \_ -> pure ()+            , onCompositionEnd = \_ _ -> pure ()+            }++      let interceptor2 = LayerInterceptor+            { onResourceAcquire = \_ -> liftIO $ modifyIORef ref2 (++ ["i2-resource"])+            , onResourceAcquireComplete = \_ _ -> pure ()+            , onResourceRelease = \_ -> liftIO $ modifyIORef ref2 (++ ["i2-release"])+            , onEffectRun = \_ -> liftIO $ modifyIORef ref2 (++ ["i2-effect"])+            , onEffectComplete = \_ _ -> pure ()+            , onServiceCreate = \_ -> pure ()+            , onServiceReuse = \_ _ -> pure ()+            , onCompositionStart = \_ -> pure ()+            , onCompositionEnd = \_ _ -> pure ()+            }++      let combined = combineInterceptors [interceptor1, interceptor2]+      let layer = effect @IO @() @Config $ pure (Config 8080)+      void $ runLayerWithInterceptor combined () layer++      logs1 <- readIORef ref1+      logs2 <- readIORef ref2+      logs1 `shouldContain` ["i1-effect"]+      logs2 `shouldContain` ["i2-effect"]++  describe "Diagnostics Collection" $ do+    it "collects effect operations" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let layer = effect @IO @() @Config $ pure (Config 8080)+      result <- runLayerWithInterceptor interceptor () layer++      configPort result `shouldBe` 8080++      diags <- finalizeDiagnostics collector+      totalResources diags `shouldBe` 0+      length (children $ rootNode diags) `shouldSatisfy` (>= 0)++    it "collects resource operations" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let layer = resource @IO @() @Database+            (pure $ Database "connected")+            (\_ -> pure ())+      result <- runLayerWithInterceptor interceptor () layer++      dbConnection result `shouldBe` "connected"++      diags <- finalizeDiagnostics collector+      let root = rootNode diags+      nodeName root `shouldBe` "Root"++    it "tracks service creation and reuse" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let cacheLayer = effect @IO @() @CacheService $ pure (CacheService 100)+      let cacheService = mkService cacheLayer++      let useServiceTwice = do+            cache1 <- service cacheService+            cache2 <- service cacheService+            pure (cache1, cache2)++      (cache1, cache2) <- runLayerWithInterceptor interceptor () useServiceTwice++      cacheSize cache1 `shouldBe` 100+      cacheSize cache2 `shouldBe` 100++      diags <- finalizeDiagnostics collector+      totalResources diags `shouldSatisfy` (>= 1)+      sharedResources diags `shouldSatisfy` (>= 1)++    it "tracks multiple resources" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let layer = do+            _ <- resource @IO @() @Config (pure (Config 1)) (\_ -> pure ())+            _ <- resource @IO @() @Database (pure (Database "db")) (\_ -> pure ())+            _ <- resource @IO @() @WebServer (pure (WebServer 80)) (\_ -> pure ())+            pure ()++      void $ runLayerWithInterceptor interceptor () layer++      diags <- finalizeDiagnostics collector+      let root = rootNode diags+      length (children root) `shouldSatisfy` (>= 3)++  describe "Diagnostics Rendering" $ do+    it "renders a tree structure" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let layer = effect @IO @() @Config $ pure (Config 8080)+      void $ runLayerWithInterceptor interceptor () layer++      diags <- finalizeDiagnostics collector+      let rendered = renderLayerTree diags+      rendered `shouldContain` "Layer Initialization Tree"+      rendered `shouldContain` "Duration:"++    it "renders detailed tree with metadata" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let layer = effect @IO @() @Config $ pure (Config 8080)+      void $ runLayerWithInterceptor interceptor () layer++      diags <- finalizeDiagnostics collector+      let rendered = renderLayerTreeDetailed diags+      rendered `shouldContain` "Layer Initialization Tree (Detailed)"+      rendered `shouldContain` "Initialized"++    it "renders tree with children" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let layer = do+            _ <- resource @IO @() @Config (pure (Config 1)) (\_ -> pure ())+            _ <- effect @IO @() @Database $ pure (Database "db")+            pure ()++      void $ runLayerWithInterceptor interceptor () layer++      diags <- finalizeDiagnostics collector+      let rendered = renderLayerTree diags+      rendered `shouldContain` "Root"++    it "detailed rendering includes type information" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let svc = mkService $ resource @IO @() @Config (pure (Config 1)) (\_ -> pure ())+      let layer = do+            _ <- service svc+            _ <- service svc+            pure ()++      void $ runLayerWithInterceptor interceptor () layer++      diags <- finalizeDiagnostics collector+      let rendered = renderLayerTreeDetailed diags+      rendered `shouldContain` "Detailed"+      totalResources diags `shouldSatisfy` (>= 1)+      sharedResources diags `shouldSatisfy` (>= 1)++    it "exports to JSON" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let layer = effect @IO @() @Config $ pure (Config 8080)+      void $ runLayerWithInterceptor interceptor () layer++      diags <- finalizeDiagnostics collector+      let json = encode diags+      BSL.length json `shouldSatisfy` (> 0)++      let decoded = decode json :: Maybe LayerDiagnostics+      case decoded of+        Nothing -> expectationFailure "Failed to decode diagnostics JSON"+        Just diags' -> do+          totalDuration diags' `shouldBe` totalDuration diags+          totalResources diags' `shouldBe` totalResources diags++    it "diagnosticsToJSON produces valid JSON" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let layer = effect @IO @() @Int $ pure 42+      _ <- runLayerWithInterceptor interceptor () layer++      diags <- finalizeDiagnostics collector+      let json = diagnosticsToJSON diags+      let encoded = encode json+      BSL.length encoded `shouldSatisfy` (> 0)++  describe "LayerNodeType JSON roundtrip" $ do+    it "ResourceNode roundtrips" $ do+      let encoded = encode ResourceNode+      decode encoded `shouldBe` Just ResourceNode++    it "EffectNode roundtrips" $ do+      let encoded = encode EffectNode+      decode encoded `shouldBe` Just EffectNode++    it "ServiceNode roundtrips" $ do+      let encoded = encode ServiceNode+      decode encoded `shouldBe` Just ServiceNode++    it "ComposedNode roundtrips" $ do+      let encoded = encode ComposedNode+      decode encoded `shouldBe` Just ComposedNode++    it "ParallelNode roundtrips" $ do+      let encoded = encode ParallelNode+      decode encoded `shouldBe` Just ParallelNode++    it "SequentialNode roundtrips" $ do+      let encoded = encode SequentialNode+      decode encoded `shouldBe` Just SequentialNode++    it "unknown node type fails" $ do+      let decoded = decode "\"unknown\"" :: Maybe LayerNodeType+      decoded `shouldBe` Nothing++  describe "LayerNodeType equality" $ do+    it "same types are equal" $ do+      ResourceNode `shouldBe` ResourceNode+      EffectNode `shouldBe` EffectNode+      ServiceNode `shouldBe` ServiceNode+      ComposedNode `shouldBe` ComposedNode+      ParallelNode `shouldBe` ParallelNode+      SequentialNode `shouldBe` SequentialNode++    it "different types are not equal" $ do+      ResourceNode `shouldNotBe` EffectNode+      EffectNode `shouldNotBe` ServiceNode+      ComposedNode `shouldNotBe` ParallelNode++  describe "LayerNodeType show" $ do+    it "shows all types" $ do+      show ResourceNode `shouldBe` "ResourceNode"+      show EffectNode `shouldBe` "EffectNode"+      show ServiceNode `shouldBe` "ServiceNode"+      show ComposedNode `shouldBe` "ComposedNode"+      show ParallelNode `shouldBe` "ParallelNode"+      show SequentialNode `shouldBe` "SequentialNode"++  describe "ResourceStatus JSON roundtrip" $ do+    it "Initializing roundtrips" $ do+      let encoded = encode Initializing+      decode encoded `shouldBe` Just Initializing++    it "Initialized roundtrips" $ do+      let encoded = encode Initialized+      decode encoded `shouldBe` Just Initialized++    it "Failed roundtrips" $ do+      let encoded = encode (Failed "some error")+      decode encoded `shouldBe` Just (Failed "some error")++    it "SharedReference roundtrips" $ do+      let encoded = encode (SharedReference "node-42")+      decode encoded `shouldBe` Just (SharedReference "node-42")++    it "unknown status fails" $ do+      let decoded = decode "{\"status\":\"bogus\"}" :: Maybe ResourceStatus+      decoded `shouldBe` Nothing++  describe "ResourceStatus equality" $ do+    it "same statuses are equal" $ do+      Initializing `shouldBe` Initializing+      Initialized `shouldBe` Initialized+      Failed "x" `shouldBe` Failed "x"+      SharedReference "a" `shouldBe` SharedReference "a"++    it "different statuses are not equal" $ do+      Initializing `shouldNotBe` Initialized+      Failed "x" `shouldNotBe` Failed "y"+      SharedReference "a" `shouldNotBe` SharedReference "b"++  describe "ResourceStatus show" $ do+    it "shows all variants" $ do+      show Initializing `shouldContain` "Initializing"+      show Initialized `shouldContain` "Initialized"+      show (Failed "err") `shouldContain` "err"+      show (SharedReference "ref") `shouldContain` "ref"++  describe "LayerDiagnostics show" $ do+    it "shows the diagnostics" $ do+      collector <- newDiagnosticsCollector+      diags <- finalizeDiagnostics collector+      let s = show diags+      s `shouldContain` "LayerDiagnostics"++  describe "LayerNode show" $ do+    it "shows the node" $ do+      let node = LayerNode+            { nodeId = "test"+            , nodeName = "TestNode"+            , nodeType = EffectNode+            , resourceType = Nothing+            , status = Initialized+            , duration = Just 0.1+            , children = []+            , metadata = HashMap.empty+            }+      let s = show node+      s `shouldContain` "TestNode"+      s `shouldContain` "EffectNode"++  describe "LayerNode JSON roundtrip" $ do+    it "basic node roundtrips" $ do+      let node = LayerNode+            { nodeId = "n1"+            , nodeName = "TestNode"+            , nodeType = ResourceNode+            , resourceType = Nothing+            , status = Initialized+            , duration = Just 1.5+            , children = []+            , metadata = HashMap.empty+            }+      let encoded = encode node+      let decoded = decode encoded :: Maybe LayerNode+      case decoded of+        Nothing -> expectationFailure "Failed to decode LayerNode"+        Just n -> do+          nodeId n `shouldBe` "n1"+          nodeName n `shouldBe` "TestNode"+          nodeType n `shouldBe` ResourceNode+          status n `shouldBe` Initialized+          duration n `shouldBe` Just 1.5++    it "node with children roundtrips" $ do+      let child = LayerNode "c1" "Child" EffectNode Nothing Initialized (Just 0.5) [] HashMap.empty+      let parent = LayerNode "p1" "Parent" ComposedNode Nothing Initialized (Just 1.0) [child] HashMap.empty+      let encoded = encode parent+      let decoded = decode encoded :: Maybe LayerNode+      case decoded of+        Nothing -> expectationFailure "Failed to decode parent node"+        Just n -> do+          length (children n) `shouldBe` 1+          case children n of+            [c] -> nodeName c `shouldBe` "Child"+            _ -> expectationFailure "Expected exactly one child"++    it "node with metadata roundtrips" $ do+      let node = LayerNode "m1" "Meta" ServiceNode Nothing+                   (SharedReference "ref-1") Nothing []+                   (HashMap.fromList [("pool", "10"), ("timeout", "30")])+      let encoded = encode node+      let decoded = decode encoded :: Maybe LayerNode+      case decoded of+        Nothing -> expectationFailure "Failed to decode node with metadata"+        Just n -> do+          status n `shouldBe` SharedReference "ref-1"+          HashMap.lookup "pool" (metadata n) `shouldBe` Just "10"+          HashMap.lookup "timeout" (metadata n) `shouldBe` Just "30"++    it "node with Failed status roundtrips" $ do+      let node = LayerNode "f1" "Failing" ResourceNode Nothing (Failed "boom") Nothing [] HashMap.empty+      let encoded = encode node+      let decoded = decode encoded :: Maybe LayerNode+      case decoded of+        Nothing -> expectationFailure "Failed to decode failing node"+        Just n -> status n `shouldBe` Failed "boom"++  describe "LayerDiagnostics JSON roundtrip" $ do+    it "full diagnostics roundtrips" $ do+      let child1 = LayerNode "c1" "ConfigLayer" EffectNode Nothing Initialized (Just 0.05) [] HashMap.empty+      let child2 = LayerNode "c2" "DbLayer" ResourceNode Nothing Initialized (Just 0.2) [] HashMap.empty+      let root = LayerNode "root" "App" SequentialNode Nothing Initialized (Just 0.25)+                   [child1, child2] HashMap.empty+      let diags = LayerDiagnostics+            { rootNode = root+            , totalDuration = 0.25+            , totalResources = 2+            , sharedResources = 0+            }+      let encoded = encode diags+      let decoded = decode encoded :: Maybe LayerDiagnostics+      case decoded of+        Nothing -> expectationFailure "Failed to decode LayerDiagnostics"+        Just d -> do+          totalDuration d `shouldBe` 0.25+          totalResources d `shouldBe` 2+          sharedResources d `shouldBe` 0+          nodeName (rootNode d) `shouldBe` "App"+          length (children (rootNode d)) `shouldBe` 2++  describe "Complex Layer Compositions" $ do+    it "tracks composed layers" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let configLayer = effect @IO @() @Config $ pure (Config 8080)+      let dbLayer = do+            cfg <- ask+            effect @IO @Config @Database $ pure $ Database ("localhost:" <> show (configPort cfg))++      let composed = configLayer >>> dbLayer+      result <- runLayerWithInterceptor interceptor () composed++      dbConnection result `shouldBe` "localhost:8080"++      diags <- finalizeDiagnostics collector+      nodeName (rootNode diags) `shouldBe` "Root"++    it "tracks parallel composition" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let layer1 = effect @IO @() @Config $ pure (Config 8080)+      let layer2 = effect @IO @() @WebServer $ pure (WebServer 9090)++      let parallelLayers = liftA2 (,) layer1 layer2+      (cfg, ws) <- runLayerWithInterceptor interceptor () parallelLayers++      configPort cfg `shouldBe` 8080+      serverPort ws `shouldBe` 9090++      diags <- finalizeDiagnostics collector+      nodeName (rootNode diags) `shouldBe` "Root"++  describe "Interceptor Edge Cases" $ do+    it "combines interceptors with empty list" $ do+      let combined = combineInterceptors []+      ref <- newIORef ([] :: [String])+      let layer = effect @IO @() @String $ do+            modifyIORef ref (++ ["effect"])+            pure "test"+      result <- runLayerWithInterceptor combined () layer+      result `shouldBe` "test"+      logs <- readIORef ref+      logs `shouldBe` ["effect"]++    it "interceptor captures all operation types" $ do+      ref <- newIORef ([] :: [String])+      let loggingInterceptor = LayerInterceptor+            { onResourceAcquire = \_ -> liftIO $ modifyIORef ref (++ ["acquire"])+            , onResourceAcquireComplete = \_ _ -> pure ()+            , onResourceRelease = \_ -> liftIO $ modifyIORef ref (++ ["release"])+            , onEffectRun = \_ -> liftIO $ modifyIORef ref (++ ["effect"])+            , onEffectComplete = \_ _ -> liftIO $ modifyIORef ref (++ ["effect-done"])+            , onServiceCreate = \_ -> liftIO $ modifyIORef ref (++ ["service-create"])+            , onServiceReuse = \_ _ -> liftIO $ modifyIORef ref (++ ["service-reuse"])+            , onCompositionStart = \_ -> liftIO $ modifyIORef ref (++ ["comp-start"])+            , onCompositionEnd = \_ _ -> liftIO $ modifyIORef ref (++ ["comp-end"])+            }++      let resourceLayer = resource @IO @() @Int (pure 100) (\_ -> pure ())+      let effectLayer = effect @IO @() @String (pure "test")+      let serviceLayer = mkService resourceLayer+      let composed = do+            _ <- resourceLayer+            _ <- effectLayer+            _ <- service serviceLayer+            _ <- service serviceLayer+            pure ()++      void $ runLayerWithInterceptor loggingInterceptor () composed++      logs <- readIORef ref+      "acquire" `elem` logs `shouldBe` True+      "effect" `elem` logs `shouldBe` True+      "service-create" `elem` logs `shouldBe` True+      "service-reuse" `elem` logs `shouldBe` True++    it "helper functions create proper contexts" $ do+      let ctx1 = simpleContext "test"+      operationName ctx1 `shouldBe` "test"+      operationType ctx1 `shouldBe` Nothing++      let ctx2 = withType (typeRep (Proxy @Int)) ctx1+      operationType ctx2 `shouldSatisfy` (/= Nothing)++      let ctx3 = withMetadata [("key", "value")] ctx1+      operationMetadata ctx3 `shouldBe` [("key", "value")]++  describe "Snapshot Diagnostics" $ do+    it "snapshotDiagnostics doesn't finalize collector" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      snap1 <- snapshotDiagnostics collector+      totalResources snap1 `shouldBe` 0++      let layer = effect @IO @() @Int (pure 42)+      void $ runLayerWithInterceptor interceptor () layer++      snap2 <- snapshotDiagnostics collector+      totalDuration snap2 `shouldSatisfy` (>= 0)++      final <- finalizeDiagnostics collector+      totalDuration final `shouldSatisfy` (>= 0)++    it "live diagnostics rendering with manual control" $ do+      collector <- newDiagnosticsCollector++      snap <- snapshotDiagnostics collector+      nodeName (rootNode snap) `shouldBe` "Root"++      let rendered = renderLayerTree snap+      rendered `shouldContain` "Layer Initialization Tree"++    it "snapshot reflects empty collector" $ do+      collector <- newDiagnosticsCollector+      snap <- snapshotDiagnostics collector+      totalResources snap `shouldBe` 0+      sharedResources snap `shouldBe` 0+      nodeName (rootNode snap) `shouldBe` "Root"+      nodeType (rootNode snap) `shouldBe` ComposedNode++  describe "Complex Diagnostics with withLayerDiagnostics" $ do+    it "withLayerDiagnostics provides both environment and diagnostics" $ do+      let testLayer = effect @IO @() @Int (pure 42)++      withLayerDiagnostics testLayer () $ \(env, diags) -> liftIO $ do+        env `shouldBe` 42+        totalDuration diags `shouldSatisfy` (>= 0)+        nodeName (rootNode diags) `shouldBe` "Root"++    it "buildLayerDiagnostics runs layer and returns diagnostics" $ do+      let testLayer = effect @IO @() @String (pure "test")++      diags <- buildLayerDiagnostics testLayer ()+      totalDuration diags `shouldSatisfy` (>= 0)+      nodeName (rootNode diags) `shouldBe` "Root"++    it "withLayerDiagnostics with resource layers" $ do+      let testLayer = resource @IO @() @String+            (pure "managed-resource")+            (\_ -> pure ())++      withLayerDiagnostics testLayer () $ \(env, diags) -> liftIO $ do+        env `shouldBe` ("managed-resource" :: String)+        totalDuration diags `shouldSatisfy` (>= 0)++    it "buildLayerDiagnostics with service tracking" $ do+      let svc = mkService $ effect @IO @() @Int (pure 99)+      let testLayer = do+            a <- service svc+            b <- service svc+            pure (a + b)++      diags <- buildLayerDiagnostics testLayer ()+      totalResources diags `shouldSatisfy` (>= 1)+      sharedResources diags `shouldSatisfy` (>= 1)++    it "buildLayerDiagnostics with composed layers exercises composition callbacks" $ do+      let configL = effect @IO @() @Config $ pure (Config 8080)+      let dbL = do+            cfg <- ask+            effect @IO @Config @Database $ pure (Database ("db:" <> show (configPort cfg)))+      let composed = configL >>> dbL+      diags <- buildLayerDiagnostics composed ()+      totalDuration diags `shouldSatisfy` (>= 0)+      let root = rootNode diags+      length (children root) `shouldSatisfy` (>= 1)++    it "buildLayerDiagnostics with service reuse through full pipeline" $ do+      let svc = mkService $ resource @IO @() @Config+            (pure (Config 42))+            (\_ -> pure ())+      let testLayer = do+            a <- service svc+            b <- service svc+            pure (configPort a + configPort b)+      diags <- buildLayerDiagnostics testLayer ()+      sharedResources diags `shouldSatisfy` (>= 1)+      totalResources diags `shouldSatisfy` (>= 1)++  describe "Diagnostics - endNode edge cases" $ do+    it "handles completing root node directly" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let layer = effect @IO @() @Int $ pure 42+      _ <- runLayerWithInterceptor interceptor () layer++      diags <- finalizeDiagnostics collector+      status (rootNode diags) `shouldBe` Initialized++    it "service without type still tracked" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector++      let svc = mkService $ effect @IO @() @Int $ pure 42+      let layer = service svc+      _ <- runLayerWithInterceptor interceptor () layer++      diags <- finalizeDiagnostics collector+      totalResources diags `shouldSatisfy` (>= 1)++  describe "Diagnostics - rendering edge cases" $ do+    it "renders node with no duration" $ do+      let node = LayerNode "n1" "NoDuration" EffectNode Nothing Initializing Nothing [] HashMap.empty+      let diags = LayerDiagnostics+            { rootNode = node+            , totalDuration = 0+            , totalResources = 0+            , sharedResources = 0+            }+      let rendered = renderLayerTree diags+      rendered `shouldContain` "NoDuration"++    it "renders node with Failed status" $ do+      let node = LayerNode "n1" "FailedNode" ResourceNode Nothing (Failed "crash") (Just 0.1) [] HashMap.empty+      let diags = LayerDiagnostics+            { rootNode = node+            , totalDuration = 0.1+            , totalResources = 1+            , sharedResources = 0+            }+      let rendered = renderLayerTree diags+      rendered `shouldContain` "crash"++    it "renders node with SharedReference status" $ do+      let shared = LayerNode "s1" "SharedSvc" ServiceNode Nothing (SharedReference "svc-001") Nothing [] HashMap.empty+      let parent = LayerNode "p1" "Parent" ComposedNode Nothing Initialized (Just 0.5) [shared] HashMap.empty+      let diags = LayerDiagnostics+            { rootNode = parent+            , totalDuration = 0.5+            , totalResources = 1+            , sharedResources = 1+            }+      let rendered = renderLayerTree diags+      rendered `shouldContain` "svc-001"++    it "renders Initializing status" $ do+      let node = LayerNode "n1" "InProgress" EffectNode Nothing Initializing Nothing [] HashMap.empty+      let diags = LayerDiagnostics { rootNode = node, totalDuration = 0, totalResources = 0, sharedResources = 0 }+      let rendered = renderLayerTree diags+      rendered `shouldContain` "InProgress"++    it "renders deeply nested tree" $ do+      let leaf = LayerNode "l1" "Leaf" EffectNode Nothing Initialized (Just 0.01) [] HashMap.empty+      let mid = LayerNode "m1" "Mid" ResourceNode Nothing Initialized (Just 0.05) [leaf] HashMap.empty+      let root = LayerNode "r1" "Root" ComposedNode Nothing Initialized (Just 0.1) [mid] HashMap.empty+      let diags = LayerDiagnostics { rootNode = root, totalDuration = 0.1, totalResources = 2, sharedResources = 0 }+      let rendered = renderLayerTree diags+      rendered `shouldContain` "Leaf"+      rendered `shouldContain` "Mid"++    it "renders multiple children" $ do+      let c1 = LayerNode "c1" "Child1" EffectNode Nothing Initialized (Just 0.01) [] HashMap.empty+      let c2 = LayerNode "c2" "Child2" ResourceNode Nothing Initialized (Just 0.02) [] HashMap.empty+      let c3 = LayerNode "c3" "Child3" ServiceNode Nothing Initialized (Just 0.03) [] HashMap.empty+      let root = LayerNode "r" "Root" ParallelNode Nothing Initialized (Just 0.1) [c1, c2, c3] HashMap.empty+      let diags = LayerDiagnostics { rootNode = root, totalDuration = 0.1, totalResources = 3, sharedResources = 0 }+      let rendered = renderLayerTree diags+      rendered `shouldContain` "Child1"+      rendered `shouldContain` "Child2"+      rendered `shouldContain` "Child3"++    it "detailed rendering shows metadata" $ do+      let node = LayerNode "n1" "WithMeta" ResourceNode (Just (typeRep (Proxy @Int))) Initialized+                   (Just 0.5) [] (HashMap.fromList [("pool", "10")])+      let diags = LayerDiagnostics { rootNode = node, totalDuration = 0.5, totalResources = 1, sharedResources = 0 }+      let rendered = renderLayerTreeDetailed diags+      rendered `shouldContain` "pool"+      rendered `shouldContain` "10"+      rendered `shouldContain` "Int"++    it "detailed rendering shows all status types" $ do+      let mkNode s = LayerNode "n" "N" EffectNode Nothing s Nothing [] HashMap.empty+      let mkDiags n = LayerDiagnostics { rootNode = n, totalDuration = 0, totalResources = 0, sharedResources = 0 }++      let r1 = renderLayerTreeDetailed (mkDiags (mkNode Initializing))+      r1 `shouldContain` "Initializing"++      let r2 = renderLayerTreeDetailed (mkDiags (mkNode Initialized))+      r2 `shouldContain` "Initialized"++      let r3 = renderLayerTreeDetailed (mkDiags (mkNode (Failed "err")))+      r3 `shouldContain` "Failed"+      r3 `shouldContain` "err"++      let r4 = renderLayerTreeDetailed (mkDiags (mkNode (SharedReference "ref-1")))+      r4 `shouldContain` "Shared reference"+      r4 `shouldContain` "ref-1"++    it "detailed rendering with SequentialNode type symbol" $ do+      let node = LayerNode "n1" "Seq" SequentialNode Nothing Initialized Nothing [] HashMap.empty+      let diags = LayerDiagnostics { rootNode = node, totalDuration = 0, totalResources = 0, sharedResources = 0 }+      let rendered = renderLayerTreeDetailed diags+      rendered `shouldContain` "SequentialNode"++    it "detailed rendering with ParallelNode type symbol" $ do+      let node = LayerNode "n1" "Par" ParallelNode Nothing Initialized Nothing [] HashMap.empty+      let diags = LayerDiagnostics { rootNode = node, totalDuration = 0, totalResources = 0, sharedResources = 0 }+      let rendered = renderLayerTreeDetailed diags+      rendered `shouldContain` "ParallelNode"++    it "detailed rendering shows duration" $ do+      let node = LayerNode "n1" "Timed" EffectNode Nothing Initialized (Just 1.234) [] HashMap.empty+      let diags = LayerDiagnostics { rootNode = node, totalDuration = 1.234, totalResources = 0, sharedResources = 0 }+      let rendered = renderLayerTreeDetailed diags+      rendered `shouldContain` "1.234"++    it "detailed rendering with children" $ do+      let child = LayerNode "c1" "Kid" EffectNode Nothing Initialized (Just 0.1) [] HashMap.empty+      let parent = LayerNode "p1" "Dad" ComposedNode Nothing Initialized (Just 0.5) [child] HashMap.empty+      let diags = LayerDiagnostics { rootNode = parent, totalDuration = 0.5, totalResources = 1, sharedResources = 0 }+      let rendered = renderLayerTreeDetailed diags+      rendered `shouldContain` "Kid"+      rendered `shouldContain` "Dad"++  describe "Diagnostics - snapshot support" $ do+    it "snapshotDiagnostics computes totalDuration" $ do+      collector <- newDiagnosticsCollector+      snap <- snapshotDiagnostics collector+      totalDuration snap `shouldSatisfy` (>= 0)+      totalResources snap `shouldBe` 0++  describe "Diagnostics interceptor - composition callbacks" $ do+    it "onCompositionStart creates Sequential node" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector+      onCompositionStart interceptor Sequential+      onCompositionEnd interceptor Sequential 0.1+      diags <- finalizeDiagnostics collector+      let root = rootNode diags+      length (children root) `shouldSatisfy` (>= 1)++    it "onCompositionStart creates Parallel node" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector+      onCompositionStart interceptor Parallel+      onCompositionEnd interceptor Parallel 0.2+      diags <- finalizeDiagnostics collector+      length (children (rootNode diags)) `shouldSatisfy` (>= 1)++  describe "Diagnostics interceptor - service reuse shared node" $ do+    it "onServiceReuse creates shared reference when service was tracked" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector+      let tr = typeRep (Proxy @Int)+      let ctx = OperationContext "IntService" (Just tr) []+      onServiceCreate interceptor ctx+      onServiceReuse interceptor "IntService" tr+      diags <- finalizeDiagnostics collector+      sharedResources diags `shouldBe` 1+      totalResources diags `shouldBe` 1++    it "onServiceReuse with untracked type is a no-op" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector+      let tr = typeRep (Proxy @Bool)+      onServiceReuse interceptor "Unknown" tr+      diags <- finalizeDiagnostics collector+      sharedResources diags `shouldBe` 0++  describe "Diagnostics interceptor - endNode edge cases" $ do+    it "endNode on empty stack is a no-op" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector+      onEffectComplete interceptor "test" 0.1+      onEffectComplete interceptor "test2" 0.2+      diags <- finalizeDiagnostics collector+      totalDuration diags `shouldSatisfy` (>= 0)++    it "endNode completing root directly" $ do+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector+      onEffectRun interceptor (OperationContext "root-effect" Nothing [])+      onEffectComplete interceptor "root-effect" 0.5+      diags <- finalizeDiagnostics collector+      let root = rootNode diags+      length (children root) `shouldSatisfy` (>= 1)++  describe "Diagnostics - showList coverage" $ do+    it "showList for LayerNodeType" $ do+      let s = show [ResourceNode, EffectNode, ServiceNode]+      s `shouldContain` "ResourceNode"+      s `shouldContain` "EffectNode"++    it "showList for ResourceStatus" $ do+      let s = show [Initializing, Initialized, Failed "x"]+      s `shouldContain` "Initializing"++    it "showList for LayerDiagnostics" $ do+      let d = LayerDiagnostics+                (LayerNode "r" "R" ComposedNode Nothing Initialized Nothing [] HashMap.empty)+                0 0 0+      let s = show [d]+      s `shouldContain` "LayerDiagnostics"++    it "showList for LayerNode" $ do+      let n = LayerNode "n" "N" EffectNode Nothing Initialized Nothing [] HashMap.empty+      let s = show [n, n]+      s `shouldContain` "LayerNode"++  describe "Diagnostics - JSON list encoding" $ do+    it "toJSON list of LayerNodeType" $ do+      let val = toJSON [ResourceNode, EffectNode, ServiceNode]+      BSL.length (encode val) `shouldSatisfy` (> 0)++    it "toEncoding of LayerNodeType" $ do+      BSL.length (encodingToLazyByteString (toEncoding ResourceNode)) `shouldSatisfy` (> 0)+      BSL.length (encodingToLazyByteString (toEncoding EffectNode)) `shouldSatisfy` (> 0)+      BSL.length (encodingToLazyByteString (toEncoding ServiceNode)) `shouldSatisfy` (> 0)+      BSL.length (encodingToLazyByteString (toEncoding ComposedNode)) `shouldSatisfy` (> 0)+      BSL.length (encodingToLazyByteString (toEncoding ParallelNode)) `shouldSatisfy` (> 0)+      BSL.length (encodingToLazyByteString (toEncoding SequentialNode)) `shouldSatisfy` (> 0)++    it "toEncoding of ResourceStatus" $ do+      BSL.length (encodingToLazyByteString (toEncoding Initializing)) `shouldSatisfy` (> 0)+      BSL.length (encodingToLazyByteString (toEncoding Initialized)) `shouldSatisfy` (> 0)+      BSL.length (encodingToLazyByteString (toEncoding (Failed "e"))) `shouldSatisfy` (> 0)+      BSL.length (encodingToLazyByteString (toEncoding (SharedReference "r"))) `shouldSatisfy` (> 0)++    it "toEncoding of LayerNode" $ do+      let n = LayerNode "n" "N" EffectNode Nothing Initialized (Just 0.1) [] HashMap.empty+      BSL.length (encodingToLazyByteString (toEncoding n)) `shouldSatisfy` (> 0)++    it "toEncoding of LayerDiagnostics" $ do+      let d = LayerDiagnostics+                (LayerNode "r" "R" ComposedNode Nothing Initialized Nothing [] HashMap.empty)+                0.5 2 1+      BSL.length (encodingToLazyByteString (toEncoding d)) `shouldSatisfy` (> 0)++    it "decode JSON list of LayerNodeType" $ do+      let encoded = encode [ResourceNode, EffectNode]+      let decoded = decode encoded :: Maybe [LayerNodeType]+      decoded `shouldBe` Just [ResourceNode, EffectNode]++    it "decode JSON list of ResourceStatus" $ do+      let encoded = encode [Initializing, Initialized]+      let decoded = decode encoded :: Maybe [ResourceStatus]+      decoded `shouldBe` Just [Initializing, Initialized]++    it "decode JSON list of LayerNode" $ do+      let n = LayerNode "n" "N" EffectNode Nothing Initialized (Just 0.1) [] HashMap.empty+      let encoded = encode [n]+      let decoded = decode encoded :: Maybe [LayerNode]+      case decoded of+        Nothing -> expectationFailure "Failed to decode"+        Just ns -> length ns `shouldBe` 1++  describe "Diagnostics - DiagnosticsCollector field" $ do+    it "newDiagnosticsCollector returns usable collector" $ do+      collector <- newDiagnosticsCollector+      diags <- finalizeDiagnostics collector+      nodeName (rootNode diags) `shouldBe` "Root"++  describe "Diagnostics - full pipeline integration" $ do+    it "composed layers produce sequential composition nodes in diagnostics" $ do+      let l1 = effect @IO @() @Config $ pure (Config 1)+      let l2 = do+            c <- ask+            effect @IO @Config @Database $ pure (Database (show (configPort c)))+      diags <- buildLayerDiagnostics (l1 >>> l2) ()+      totalDuration diags `shouldSatisfy` (>= 0)++    it "service create+reuse through full pipeline" $ do+      let svc = mkService $ resource @IO @() @Database+            (pure (Database "svc"))+            (\_ -> pure ())+      let layer = do+            a <- service svc+            b <- service svc+            pure (dbConnection a, dbConnection b)+      diags <- buildLayerDiagnostics layer ()+      totalResources diags `shouldSatisfy` (>= 1)+      sharedResources diags `shouldSatisfy` (>= 1)++    it "resource layer produces resource node in diagnostics" $ do+      let layer = resource @IO @() @Config (pure (Config 1)) (\_ -> pure ())+      diags <- buildLayerDiagnostics layer ()+      let root = rootNode diags+      length (children root) `shouldSatisfy` (>= 1)++  describe "Diagnostics - concurrent safety" $ do+    it "does not lose events under concurrent zipLayer composition" $ do+      (barrier :: MVar ()) <- newEmptyMVar+      let layer1 = resource @IO @() @Config+            (takeMVar barrier >> pure (Config 1))+            (\_ -> pure ())+      let layer2 = resource @IO @() @Database+            (putMVar barrier () >> pure (Database "db"))+            (\_ -> pure ())+      let combined = zipLayer layer1 layer2+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector+      _ <- runLayerWithInterceptor interceptor ((), ()) combined+      diags <- finalizeDiagnostics collector+      totalDuration diags `shouldSatisfy` (>= 0)++    it "tracks all resources under repeated concurrent composition" $ do+      let mkRes i = resource @IO @() @Int (pure i) (\_ -> pure ())+      let combined = zipLayer (zipLayer (mkRes 1) (mkRes 2)) (zipLayer (mkRes 3) (mkRes 4))+      collector <- newDiagnosticsCollector+      let interceptor = createDiagnosticsInterceptor collector+      _ <- runLayerWithInterceptor interceptor (((),()), ((),())) combined+      diags <- finalizeDiagnostics collector+      nodeName (rootNode diags) `shouldBe` "Root"
+ test/Fractal/Layer/InterceptorSpec.hs view
@@ -0,0 +1,169 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}++module Fractal.Layer.InterceptorSpec (spec) where++import Test.Hspec+import Data.Typeable (Proxy(..), typeRep)+import Fractal.Layer.Interceptor+import UnliftIO++spec :: Spec+spec = do+  describe "CompositionType" $ do+    it "Sequential show" $+      show Sequential `shouldBe` "Sequential"++    it "Parallel show" $+      show Parallel `shouldBe` "Parallel"++    it "Sequential == Sequential" $+      Sequential `shouldBe` Sequential++    it "Parallel == Parallel" $+      Parallel `shouldBe` Parallel++    it "Sequential /= Parallel" $+      Sequential `shouldNotBe` Parallel++  describe "OperationContext" $ do+    it "show includes all fields" $ do+      let ctx = OperationContext+            { operationName = "test"+            , operationType = Just (typeRep (Proxy @Int))+            , operationMetadata = [("key", "value")]+            }+      let s = show ctx+      s `shouldContain` "test"+      s `shouldContain` "key"+      s `shouldContain` "value"++    it "show with Nothing type" $ do+      let ctx = simpleContext "bare"+      show ctx `shouldContain` "bare"++  describe "nullInterceptor" $ do+    it "all callbacks are no-ops" $ do+      let ni = nullInterceptor :: LayerInterceptor IO+      onResourceAcquire ni (simpleContext "test")+      onResourceAcquireComplete ni "test" 0+      onResourceRelease ni "test"+      onEffectRun ni (simpleContext "test")+      onEffectComplete ni "test" 0+      onServiceCreate ni (simpleContext "test")+      onServiceReuse ni "test" (typeRep (Proxy @Int))+      onCompositionStart ni Sequential+      onCompositionEnd ni Sequential 0++  describe "combineInterceptors" $ do+    it "empty list behaves like nullInterceptor" $ do+      let combined = combineInterceptors ([] :: [LayerInterceptor IO])+      onResourceAcquire combined (simpleContext "test")+      onCompositionStart combined Sequential+      onCompositionEnd combined Parallel 1.0++    it "preserves order of callbacks" $ do+      ref <- newIORef ([] :: [Int])+      let i1 = nullInterceptor { onEffectRun = \_ -> modifyIORef ref (++ [1]) }+      let i2 = nullInterceptor { onEffectRun = \_ -> modifyIORef ref (++ [2]) }+      let i3 = nullInterceptor { onEffectRun = \_ -> modifyIORef ref (++ [3]) }+      let combined = combineInterceptors [i1, i2, i3]+      onEffectRun combined (simpleContext "test")+      readIORef ref >>= (`shouldBe` [1, 2, 3])++    it "combines onCompositionStart callbacks" $ do+      ref <- newIORef ([] :: [String])+      let i1 = nullInterceptor { onCompositionStart = \t -> modifyIORef ref (++ ["i1:" ++ show t]) }+      let i2 = nullInterceptor { onCompositionStart = \t -> modifyIORef ref (++ ["i2:" ++ show t]) }+      let combined = combineInterceptors [i1, i2]++      onCompositionStart combined Sequential+      onCompositionStart combined Parallel++      logs <- readIORef ref+      logs `shouldBe` ["i1:Sequential", "i2:Sequential", "i1:Parallel", "i2:Parallel"]++    it "combines onCompositionEnd callbacks" $ do+      ref <- newIORef ([] :: [String])+      let i1 = nullInterceptor { onCompositionEnd = \t _ -> modifyIORef ref (++ ["i1:" ++ show t]) }+      let i2 = nullInterceptor { onCompositionEnd = \t _ -> modifyIORef ref (++ ["i2:" ++ show t]) }+      let combined = combineInterceptors [i1, i2]++      onCompositionEnd combined Sequential 1.0+      logs <- readIORef ref+      logs `shouldBe` ["i1:Sequential", "i2:Sequential"]++    it "combines all callback types" $ do+      ref <- newIORef ([] :: [String])+      let tracker :: String -> LayerInterceptor IO+          tracker tag = (nullInterceptor :: LayerInterceptor IO)+            { onResourceAcquire = \_ -> modifyIORef ref (++ [tag ++ ":acq"])+            , onResourceAcquireComplete = \_ _ -> modifyIORef ref (++ [tag ++ ":acqd"])+            , onResourceRelease = \_ -> modifyIORef ref (++ [tag ++ ":rel"])+            , onEffectRun = \_ -> modifyIORef ref (++ [tag ++ ":eff"])+            , onEffectComplete = \_ _ -> modifyIORef ref (++ [tag ++ ":done"])+            , onServiceCreate = \_ -> modifyIORef ref (++ [tag ++ ":svc"])+            , onServiceReuse = \_ _ -> modifyIORef ref (++ [tag ++ ":reuse"])+            , onCompositionStart = \_ -> modifyIORef ref (++ [tag ++ ":cstart"])+            , onCompositionEnd = \_ _ -> modifyIORef ref (++ [tag ++ ":cend"])+            }+      let combined = combineInterceptors [tracker "a", tracker "b"]++      onResourceAcquire combined (simpleContext "r")+      onResourceAcquireComplete combined "r" 0+      onResourceRelease combined "r"+      onEffectRun combined (simpleContext "e")+      onEffectComplete combined "e" 0+      onServiceCreate combined (simpleContext "s")+      onServiceReuse combined "s" (typeRep (Proxy @Int))+      onCompositionStart combined Sequential+      onCompositionEnd combined Sequential 0++      logs <- readIORef ref+      logs `shouldBe`+        [ "a:acq", "b:acq"+        , "a:acqd", "b:acqd"+        , "a:rel", "b:rel"+        , "a:eff", "b:eff"+        , "a:done", "b:done"+        , "a:svc", "b:svc"+        , "a:reuse", "b:reuse"+        , "a:cstart", "b:cstart"+        , "a:cend", "b:cend"+        ]++  describe "simpleContext" $ do+    it "creates context with name only" $ do+      let ctx = simpleContext "my-op"+      operationName ctx `shouldBe` "my-op"+      operationType ctx `shouldBe` Nothing+      operationMetadata ctx `shouldBe` []++  describe "withType" $ do+    it "adds type to context" $ do+      let ctx = withType (typeRep (Proxy @Bool)) (simpleContext "op")+      operationType ctx `shouldBe` Just (typeRep (Proxy @Bool))+      operationName ctx `shouldBe` "op"+      operationMetadata ctx `shouldBe` []++  describe "withMetadata" $ do+    it "adds metadata to context" $ do+      let ctx = withMetadata [("k1", "v1"), ("k2", "v2")] (simpleContext "op")+      operationMetadata ctx `shouldBe` [("k1", "v1"), ("k2", "v2")]++    it "appends to existing metadata" $ do+      let ctx0 = OperationContext "op" Nothing [("existing", "data")]+      let ctx1 = withMetadata [("new", "field")] ctx0+      operationMetadata ctx1 `shouldBe` [("existing", "data"), ("new", "field")]++  describe "showList coverage" $ do+    it "showList for CompositionType" $ do+      let s = show [Sequential, Parallel, Sequential]+      s `shouldContain` "Sequential"+      s `shouldContain` "Parallel"++    it "showList for OperationContext" $ do+      let ctx = simpleContext "test"+      let s = show [ctx, ctx]+      s `shouldContain` "test"
+ test/Fractal/Layer/LayerSpec.hs view
@@ -0,0 +1,1610 @@+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeOperators #-}+{-# LANGUAGE DataKinds #-}++module Fractal.Layer.LayerSpec (spec) where++import Test.Hspec+import Test.QuickCheck+import Test.QuickCheck.Monadic+import Fractal.Layer+import Fractal.Layer.Internal (unsafeMkLayer)+import Fractal.Layer.Interceptor+import Control.Category ((>>>), id, (.))+import Control.Arrow ((&&&), (***), arr, first, second, ArrowZero(..), ArrowPlus(..), ArrowChoice(..), app)+import Control.Concurrent (myThreadId)+import Control.Exception (AsyncException(ThreadKilled))+import qualified Control.Exception as E+import Control.Monad+import Control.Monad.Reader+import Control.Applicative+import qualified Control.Selective as S+import Data.List.NonEmpty (NonEmpty(..))+import Data.Semigroup (stimes, sconcat)+import Data.Typeable+import Data.Profunctor (lmap, first', second', left', right')+import qualified Data.Profunctor as P+import Data.Profunctor.Traversing (traverse')+import UnliftIO hiding (assert)+import Prelude hiding (id, (.))++-- Test data types+data Config = Config { port :: Int, host :: String } deriving (Eq, Show)+data Database = Database { connId :: Int } deriving (Eq, Show)+data WebServer = WebServer { serverId :: Int } deriving (Eq, Show)+data Cache = Cache { cacheId :: Int } deriving (Eq, Show)++-- Helper to track resource lifecycles+data ResourceTracker = ResourceTracker+  { acquired :: IORef [String]+  , released :: IORef [String]+  }++newResourceTracker :: IO ResourceTracker+newResourceTracker = ResourceTracker <$> newIORef [] <*> newIORef []++trackAcquire :: ResourceTracker -> String -> IO ()+trackAcquire tracker name = atomicModifyIORef' (acquired tracker) $ \xs -> (name : xs, ())++trackRelease :: ResourceTracker -> String -> IO ()+trackRelease tracker name = atomicModifyIORef' (released tracker) $ \xs -> (name : xs, ())++-- Test layers+configLayer :: Layer IO () Config+configLayer = effect $ pure $ Config 8080 "localhost"++trackedResource :: (Typeable a) => ResourceTracker -> String -> a -> Layer IO () a+trackedResource tracker name value = resource+  (trackAcquire tracker name >> pure value)+  (\_ -> trackRelease tracker name)++dbLayer :: ResourceTracker -> Layer IO Config Database+dbLayer tracker = do+  cfg <- ask+  resource+    (do trackAcquire tracker "database"+        pure $ Database (port cfg))+    (\_ -> trackRelease tracker "database")++webLayer :: ResourceTracker -> Layer IO Config WebServer+webLayer tracker = do+  cfg <- ask+  resource+    (do trackAcquire tracker "webserver"+        pure $ WebServer (port cfg + 1000))+    (\_ -> trackRelease tracker "webserver")++cacheLayer :: ResourceTracker -> Layer IO a Cache+cacheLayer tracker = resource+  (do trackAcquire tracker "cache"+      pure $ Cache 42)+  (\_ -> trackRelease tracker "cache")++-- A layer that waits for a signal before failing, for testing concurrent failure+signaledFailingLayer :: MVar () -> Layer IO () ()+signaledFailingLayer signal = resource+  (do takeMVar signal+      throwIO $ userError "Signaled failure")+  (\_ -> pure ())++spec :: Spec+spec = do+  describe "Layer - Basic Construction" $ do+    it "effect creates a simple layer" $ do+      let layer = effect (pure ("test" :: String)) :: Layer IO () String+      result <- runLayer () layer+      result `shouldBe` "test"++    it "resource manages acquisition and release" $ do+      tracker <- newResourceTracker+      let layer = trackedResource tracker "test-resource" ("value" :: String)++      -- Run the layer+      withLayer () layer $ \val -> liftIO do+        val `shouldBe` ("value" :: String)+        acq <- readIORef (acquired tracker)+        acq `shouldBe` ["test-resource"]+        rel <- readIORef (released tracker)+        rel `shouldBe` []++      -- Check cleanup happened+      rel <- readIORef (released tracker)+      rel `shouldBe` ["test-resource"]++    it "pureLayer creates a layer with no effects" $ do+      let layer = pure @(Layer IO ()) ("pure value" :: String)+      result <- runLayer () layer+      result `shouldBe` ("pure value" :: String)++  describe "Layer - Composition" $ do+    it "vertical composition with >>> works correctly" $ do+      tracker <- newResourceTracker+      let composed = configLayer >>> dbLayer tracker++      withLayer () composed $ \db -> liftIO do+        connId db `shouldBe` 8080++      -- Check both acquired and released+      acq <- readIORef (acquired tracker)+      rel <- readIORef (released tracker)+      acq `shouldBe` ["database"]+      rel `shouldBe` ["database"]++    it "horizontal composition with &&& works correctly" $ do+      tracker <- newResourceTracker+      let layer = dbLayer tracker &&& webLayer tracker++      withLayer (Config 3000 "test") layer $ \(db, ws) -> liftIO do+        connId db `shouldBe` 3000+        serverId ws `shouldBe` 4000++      -- Check both are cleaned up (order doesn't matter for parallel composition)+      rel <- readIORef (released tracker)+      length rel `shouldBe` 2+      "database" `elem` rel `shouldBe` True+      "webserver" `elem` rel `shouldBe` True++    it "complex composition maintains proper order" $ do+      tracker <- newResourceTracker+      let layer = configLayer >>> (dbLayer tracker &&& webLayer tracker &&& cacheLayer tracker)++      withLayer () layer $ \(db, (ws, cache)) -> liftIO do+        connId db `shouldBe` 8080+        serverId ws `shouldBe` 9080+        cacheId cache `shouldBe` 42++  describe "Layer - Functor instance" $ do+    it "fmap transforms the output" $ do+      let layer = fmap (*2) (effect $ pure (21 :: Int)) :: Layer IO () Int+      result <- runLayer () layer+      result `shouldBe` (42 :: Int)++    it "preserves resource management with fmap" $ do+      tracker <- newResourceTracker+      let layer = fmap show (trackedResource tracker "mapped" (123 :: Int))++      withLayer () layer $ \val -> liftIO do+        val `shouldBe` ("123" :: String)++      rel <- readIORef (released tracker)+      rel `shouldBe` ["mapped"]++  describe "Layer - Applicative instance" $ do+    it "pure creates a pure layer" $ do+      let layer = pure @(Layer IO ()) (42 :: Int)+      result <- runLayer () layer+      result `shouldBe` (42 :: Int)++    it "<*> combines layers applicatively" $ do+      let funcLayer = pure @(Layer IO ()) ((+10) :: Int -> Int)+      let valueLayer = pure @(Layer IO ()) (32 :: Int)+      let combined = funcLayer <*> valueLayer+      result <- runLayer () combined+      result `shouldBe` (42 :: Int)++    it "liftA2 works correctly" $ do+      tracker <- newResourceTracker+      let layer1 = trackedResource tracker "res1" (10 :: Int)+      let layer2 = trackedResource tracker "res2" (32 :: Int)+      let combined = liftA2 ((+) :: Int -> Int -> Int) layer1 layer2++      result <- runLayer () combined+      result `shouldBe` (42 :: Int)++  describe "Layer - Monad instance" $ do+    it ">>= sequences operations correctly" $ do+      tracker <- newResourceTracker+      let layer = do+            x <- trackedResource tracker "first" (10 :: Int)+            y <- trackedResource tracker "second" (x + 5 :: Int)+            pure (x * y :: Int)++      result <- runLayer () layer+      result `shouldBe` (150 :: Int)++      -- Check acquisition order+      acq <- readIORef (acquired tracker)+      reverse acq `shouldBe` ["first", "second"]++    it ">> discards first result" $ do+      let layer = effect (putStrLn "side effect") >> pure (42 :: Int) :: Layer IO () Int+      result <- runLayer () layer+      result `shouldBe` (42 :: Int)++  describe "Layer - Arrow instance" $ do+    it "arr lifts pure functions" $ do+      let layer = arr @(Layer IO) ((*2) :: Int -> Int)+      result <- runLayer (21 :: Int) layer+      result `shouldBe` (42 :: Int)++    it "first applies to first component" $ do+      let layer = first @(Layer IO) (arr @(Layer IO) @Int @Int ((*2) :: Int -> Int))+      result <- runLayer ((21 :: Int), ("test" :: String)) layer+      result `shouldBe` ((42 :: Int), ("test" :: String))++    it "second applies to second component" $ do+      let layer = second @(Layer IO) (arr @(Layer IO) @Int @Int ((*2) :: Int -> Int))+      result <- runLayer (("test" :: String), (21 :: Int)) layer+      result `shouldBe` (("test" :: String), (42 :: Int))++    it "*** combines two arrows" $ do+      let layer = arr ((*2) :: Int -> Int) *** arr ((++ "!") :: String -> String)+      result <- runLayer ((21 :: Int), ("test" :: String)) layer+      result `shouldBe` ((42 :: Int), ("test!" :: String))++  describe "Layer - Alternative instance" $ do+    it "empty throws EmptyLayer exception" $ do+      let layer = empty :: Layer IO () Int+      runLayer () layer `shouldThrow` anyException++    it "<|> provides fallback behavior" $ do+      let primary = empty :: Layer IO () String+      let fallback = pure ("fallback" :: String) :: Layer IO () String+      let combined = primary <|> fallback++      result <- runLayer () combined+      result `shouldBe` ("fallback" :: String)++    it "<|> cleans up failed branch resources" $ do+      tracker <- newResourceTracker+      let failing = trackedResource tracker "failing" () >> empty :: Layer IO () String+      let success = trackedResource tracker "success" ("ok" :: String)+      let combined = failing <|> success++      result <- runLayer () combined+      result `shouldBe` ("ok" :: String)++      -- Check that failing resource was cleaned up+      acq <- readIORef (acquired tracker)+      rel <- readIORef (released tracker)+      "failing" `elem` acq `shouldBe` True+      "failing" `elem` rel `shouldBe` True+      "success" `elem` acq `shouldBe` True++  describe "Layer - MonadReader instance" $ do+    it "ask returns the dependencies" $ do+      let layer = ask :: Layer IO Config Config+      result <- runLayer (Config 8080 "test") layer+      result `shouldBe` Config 8080 "test"++    it "local modifies dependencies" $ do+      let layer = local (\(Config p h) -> Config (p+1) h) ask+      result <- runLayer (Config 8080 "test") layer+      result `shouldBe` Config 8081 "test"++  describe "Layer - Resource Management" $ do+    -- Note: Resource cleanup order varies based on composition type:+    -- - Monadic bind (>>=): Resources are released in reverse order within each monadic chain+    -- - Parallel composition (&&&): Resources may be released in any order since they're peers+    -- - The important guarantee is that ALL resources are properly cleaned up++    it "releases resources in LIFO order for monadic composition" $ do+      tracker <- newResourceTracker+      let layer = do+            _ <- trackedResource tracker "first" ()+            _ <- trackedResource tracker "second" ()+            _ <- trackedResource tracker "third" ()+            pure ()++      withLayer () layer $ \_ -> pure ()++      -- For monadic composition, check all are released+      rel <- readIORef (released tracker)+      length rel `shouldBe` 3+      "first" `elem` rel `shouldBe` True+      "second" `elem` rel `shouldBe` True+      "third" `elem` rel `shouldBe` True++    it "releases resources on exception" $ do+      tracker <- newResourceTracker+      let layer = do+            _ <- trackedResource tracker "resource" ()+            effect (throwIO $ userError "boom" :: IO ())++      withLayer () layer (\_ -> pure ()) `shouldThrow` anyException++      rel <- readIORef (released tracker)+      rel `shouldBe` ["resource"]++    it "handles nested resource scopes correctly" $ do+      tracker <- newResourceTracker+      let inner = trackedResource tracker "inner" "inner-value"+      let outer = resource+            (do+              trackAcquire tracker "outer"+              withLayer () inner $ \val -> do+                pure $ "outer-" ++ val)+            (\_ -> trackRelease tracker "outer")++      result <- runLayer () outer+      result `shouldBe` "outer-inner-value"++      -- Both should be released, inner first+      rel <- readIORef (released tracker)+      rel `shouldBe` ["outer", "inner"]++    it "demonstrates cleanup order matters for dependent resources" $ do+      -- This test shows a case where order DOES matter+      connectionOpen <- newIORef False+      queryExecuted <- newIORef False++      let dbConnection = resource+            (do+              writeIORef connectionOpen True+              pure ("connection" :: String))+            (\_ -> writeIORef connectionOpen False) :: Layer IO () String++      let dbQuery = resource+            (do+              isOpen <- readIORef connectionOpen+              if isOpen+                then writeIORef queryExecuted True >> pure ("query" :: String)+                else error "Connection closed before query!")+            (\_ -> pure ()) :: Layer IO () String++      let layer = dbConnection >>= \_ -> dbQuery++      -- Run the layer+      result <- runLayer () layer+      result `shouldBe` "query"++      -- Both should have been executed+      readIORef queryExecuted >>= (`shouldBe` True)+      -- And connection should be closed after+      readIORef connectionOpen >>= (`shouldBe` False)++  describe "Layer - Concurrent Composition" $ do+    it "zipLayer runs layers concurrently" $ do+      barrier1 <- newEmptyMVar+      barrier2 <- newEmptyMVar+      let layer1 = effect $ do+            putMVar barrier1 ()+            readMVar barrier2+            pure (1 :: Int)+      let layer2 = effect $ do+            putMVar barrier2 ()+            readMVar barrier1+            pure (2 :: Int)+      let combined = zipLayer layer1 layer2++      (r1, r2) <- runLayer ((), ()) combined+      r1 `shouldBe` 1+      r2 `shouldBe` 2++    it "zipLayer handles concurrent failures correctly" $ do+      tracker <- newResourceTracker+      failSignal <- newEmptyMVar+      let layer1 = trackedResource tracker "res1" () >> signaledFailingLayer failSignal+      let layer2 = trackedResource tracker "res2" ("value" :: String)+      let combined = zipLayer layer1 layer2++      resultVar <- newEmptyMVar+      void $ async $ do+        res <- try $ runLayer ((), ()) combined+        putMVar resultVar (res :: Either SomeException ((), String))++      putMVar failSignal ()+      result <- takeMVar resultVar++      case result of+        Left _ -> pure ()+        Right _ -> expectationFailure "Expected exception"++      rel <- readIORef (released tracker)+      "res1" `elem` rel `shouldBe` True+      "res2" `elem` rel `shouldBe` True++  describe "Layer - Property Tests" $ do+    it "Functor laws" $ property $ \(n :: Int) ->+      monadicIO $ do+        -- fmap id = id+        let layer = pure n :: Layer IO () Int+        r1 <- run $ runLayer () (fmap id layer)+        r2 <- run $ runLayer () layer+        assert $ r1 == r2++    it "Applicative identity law" $ property $ \(n :: Int) ->+      monadicIO $ do+        let layer = pure n :: Layer IO () Int+        r1 <- run $ runLayer () (pure id <*> layer)+        r2 <- run $ runLayer () layer+        assert $ r1 == r2++    it "Category identity laws" $ property $ \(n :: Int) ->+      monadicIO $ do+        let layer = pure n :: Layer IO () Int+        r1 <- run $ runLayer () (id >>> layer)+        r2 <- run $ runLayer () (layer >>> id)+        r3 <- run $ runLayer () layer+        assert $ r1 == r2 && r2 == r3++  describe "Layer - Service" $ do+    it "mkService creates a service from a layer" $ do+      tracker <- newResourceTracker+      let layer = trackedResource tracker "test-service" ("service-value" :: String)+      let svc = mkService layer++      -- Service should behave like a normal layer+      withLayer () (service svc) $ \val -> liftIO do+        val `shouldBe` ("service-value" :: String)++      rel <- readIORef (released tracker)+      rel `shouldBe` ["test-service"]++    it "service caches initialization - single access" $ do+      initCount <- newIORef (0 :: Int)+      let expensiveLayer = resource+            (do+              atomicModifyIORef' initCount $ \n -> (n + 1, ())+              pure ("expensive result" :: String))+            (\_ -> pure ())++      let svc = mkService expensiveLayer++      -- First access should initialize+      result <- runLayer () (service svc)+      result `shouldBe` ("expensive result" :: String)+      count <- readIORef initCount+      count `shouldBe` 1++    it "service caches initialization - multiple sequential accesses" $ do+      initCount <- newIORef (0 :: Int)+      tracker <- newResourceTracker++      let expensiveLayer = resource+            (do+              n <- atomicModifyIORef' initCount $ \n -> (n + 1, n + 1)+              trackAcquire tracker (("init-" ++ show n) :: String)+              pure $ (("result-" ++ show n) :: String))+            (\res -> trackRelease tracker res)++      let svc = mkService expensiveLayer++      -- Multiple accesses should reuse the same instance+      let multiAccessLayer = do+            r1 <- service svc+            r2 <- service svc+            r3 <- service svc+            pure (r1, r2, r3)++      (res1, res2, res3) <- runLayer () multiAccessLayer+      res1 `shouldBe` ("result-1" :: String)+      res2 `shouldBe` ("result-1" :: String)  -- Same instance+      res3 `shouldBe` ("result-1" :: String)  -- Same instance++      count <- readIORef initCount+      count `shouldBe` 1  -- Only initialized once++    it "service caches initialization - concurrent accesses" $ do+      initCount <- newIORef (0 :: Int)+      initStarted <- newEmptyMVar+      initFinish <- newEmptyMVar++      let slowLayer = resource+            (do+              putMVar initStarted ()+              takeMVar initFinish+              n <- atomicModifyIORef' initCount $ \n -> (n + 1, n + 1)+              pure $ (("concurrent-result-" ++ show n) :: String))+            (\_ -> pure ())++      let svc = mkService slowLayer++      let concurrentLayer =+            (,,,) <$> service svc <*> service svc <*> service svc <*> service svc++      resultVar <- newEmptyMVar+      void $ async $ do+        result <- runLayer () concurrentLayer+        putMVar resultVar result++      takeMVar initStarted+      putMVar initFinish ()++      (r1, r2, r3, r4) <- takeMVar resultVar++      r1 `shouldBe` ("concurrent-result-1" :: String)+      r2 `shouldBe` ("concurrent-result-1" :: String)+      r3 `shouldBe` ("concurrent-result-1" :: String)+      r4 `shouldBe` ("concurrent-result-1" :: String)++      count <- readIORef initCount+      count `shouldBe` 1++    it "service propagates initialization errors" $ do+      errorCount <- newIORef (0 :: Int)++      let failingServiceLayer :: Layer IO () Int = resource+            (do+              atomicModifyIORef' errorCount $ \n -> (n + 1, ())+              throwIO $ userError "Service initialization failed")+            (\_ -> pure ())++      let svc = mkService failingServiceLayer++      -- First access should fail+      runLayer () (service svc <|> service svc) `shouldThrow` anyException++      count <- readIORef errorCount+      count `shouldBe` 1  -- Should only try to initialize once++    it "service works with complex layer compositions" $ do+      tracker <- newResourceTracker++      -- Shared service used by multiple layers+      let sharedService = mkService $ trackedResource tracker "shared" (42 :: Int)++      -- Layer that uses the service+      let consumerLayer1 = do+            shared <- service sharedService+            trackedResource tracker (("consumer1-" ++ show shared) :: String) (("c1-" ++ show shared) :: String)++      -- Another layer that uses the same service+      let consumerLayer2 = do+            shared <- service sharedService+            trackedResource tracker (("consumer2-" ++ show shared) :: String) (("c2-" ++ show shared) :: String)++      -- Compose them together+      let composedLayer = liftA2 (,) consumerLayer1 consumerLayer2++      (r1, r2) <- runLayer () composedLayer+      r1 `shouldBe` ("c1-42" :: String)+      r2 `shouldBe` ("c2-42" :: String)++      -- Check that shared service was only initialized once+      acq <- readIORef (acquired tracker)+      length (filter (== "shared") acq) `shouldBe` 1++    it "service respects layer lifecycle" $ do+      tracker <- newResourceTracker++      let svc = mkService $ trackedResource tracker "service" ("value" :: String)++      -- Use service within a layer+      withLayer () (service svc) $ \val -> liftIO do+        val `shouldBe` ("value" :: String)+        -- Service should be acquired+        acq <- readIORef (acquired tracker)+        "service" `elem` acq `shouldBe` True+        -- But not yet released+        rel <- readIORef (released tracker)+        "service" `elem` rel `shouldBe` False++      -- After layer exits, service should be cleaned up+      rel <- readIORef (released tracker)+      "service" `elem` rel `shouldBe` True++    it "different services with same type can coexist in separate layer scopes" $ do+      initCounter <- newIORef (0 :: Int)++      let makeService name = mkService $ resource+            (do+              n <- atomicModifyIORef' initCounter $ \n -> (n + 1, n)+              pure $ ((name ++ "-" ++ show n) :: String))+            (\_ -> pure ())++      let service1 = makeService ("service1" :: String)+      let service2 = makeService ("service2" :: String)++      -- Use services in separate scopes+      result1 <- runLayer () (service service1)+      result2 <- runLayer () (service service2)++      -- Each scope gets its own cache+      result1 `shouldBe` ("service1-0" :: String)+      result2 `shouldBe` ("service2-1" :: String)++    it "service with monadic bind maintains caching" $ do+      initCount <- newIORef (0 :: Int)++      let expensiveService = mkService $ resource+            (do+              atomicModifyIORef' initCount $ \n -> (n + 1, ())+              pure (100 :: Int))+            (\_ -> pure ())++      -- Use service multiple times in monadic composition+      let layer = do+            x <- service expensiveService+            y <- service expensiveService+            z <- effect $ do+              -- Even in nested effect+              runLayer () (service expensiveService)+            pure ((x + y + z) :: Int)++      result <- runLayer () layer+      result `shouldBe` (300 :: Int)++      -- Should still only initialize once within the same layer scope+      count <- readIORef initCount+      count `shouldBe` 2  -- Once for main layer, once for nested runLayer++    it "service initialization failure doesn't prevent cleanup of other resources" $ do+      tracker <- newResourceTracker++      let goodLayer = trackedResource tracker "good" ("good-value" :: String)+      let badService :: Service IO () Int = mkService do+            () <- resource+              (do+                trackAcquire tracker ("bad" :: String)+                pure ())+              (\_ -> trackRelease tracker ("bad" :: String))++            effect $ throwIO $ userError "Bad service"++      let composedLayer = do+            good <- goodLayer+            bad <- service badService+            pure (good, bad)++      -- Should fail+      runLayer () composedLayer `shouldThrow` anyException++      -- But good resource should still be cleaned up+      rel <- readIORef (released tracker)+      "good" `elem` rel `shouldBe` True+      "bad" `elem` rel `shouldBe` True  -- Even failed initialization should clean up++  describe "Layer - bracketed function" $ do+    it "manages scoped resources correctly" $ do+      acquired <- newIORef False+      released <- newIORef False++      let scopedBracket = \use -> bracket+            (writeIORef acquired True >> pure ("resource" :: String))+            (\_ -> writeIORef released True)+            use++      let layer = bracketed scopedBracket++      withLayer () layer $ \res -> liftIO $ do+        res `shouldBe` ("resource" :: String)+        readIORef acquired >>= (`shouldBe` True)+        readIORef released >>= (`shouldBe` False)  -- Not yet released++      -- After layer exits, resource should be released+      readIORef released >>= (`shouldBe` True)++    it "keeps resource alive during layer lifetime" $ do+      ref <- newIORef (0 :: Int)++      let scopedBracket = \use -> bracket+            (modifyIORef' ref (+1) >> pure ())+            (\_ -> modifyIORef' ref (*10))+            use++      let layer = bracketed scopedBracket++      withLayer () layer $ \_ -> liftIO $ do+        -- Bracket opened, count should be 1+        count <- readIORef ref+        count `shouldBe` 1+        -- Modify it during use+        modifyIORef' ref (+5)++      -- After layer exits, should have been multiplied by 10+      readIORef ref >>= (`shouldBe` 60)  -- (1 + 5) * 10++    it "handles exceptions correctly" $ do+      acquired <- newIORef False+      released <- newIORef False++      let scopedBracket = \use -> bracket+            (writeIORef acquired True >> pure ("resource" :: String))+            (\_ -> writeIORef released True)+            use++      let layer = bracketed scopedBracket >> (effect (throwIO $ userError "boom") :: Layer IO () String)++      withLayer () layer (\_ -> pure ()) `shouldThrow` anyException++      -- Resource should still be released despite exception+      readIORef released >>= (`shouldBe` True)++    it "works with concurrent operations" $ do+      counter <- newIORef (0 :: Int)++      let scopedBracket = \use -> bracket+            (atomicModifyIORef' counter $ \n -> (n+1, n+1))+            (\_ -> atomicModifyIORef' counter $ \n -> (n*2, ()))+            use++      let layer1 = bracketed scopedBracket+      let layer2 = bracketed scopedBracket++      let composed = liftA2 (,) layer1 layer2++      (a, b) <- runLayer () composed+      -- Each bracket should get a unique counter value+      a `shouldNotBe` b++      -- Both should be released (counter doubled twice)+      -- After acquiring both: counter = 2+      -- After first release: counter = 2 * 2 = 4+      -- After second release: counter = 4 * 2 = 8+      final <- readIORef counter+      final `shouldBe` 8++    it "resource survives beyond immediate scope" $ do+      resultRef <- newIORef ("" :: String)+      let testValue = "test-value" :: String++      let scopedBracket = \use -> bracket+            (pure testValue)+            (\val -> writeIORef resultRef val)+            use++      let layer = bracketed scopedBracket++      result <- runLayer () layer+      result `shouldBe` testValue++      -- Cleanup should have recorded the value+      readIORef resultRef >>= (`shouldBe` testValue)++    it "handles nested bracketed resources" $ do+      tracker <- newResourceTracker++      let bracket1 = \use -> bracket+            (trackAcquire tracker ("outer" :: String) >> pure ("outer" :: String))+            (\_ -> trackRelease tracker ("outer" :: String))+            use++      let bracket2 = \use -> bracket+            (trackAcquire tracker ("inner" :: String) >> pure ("inner" :: String))+            (\_ -> trackRelease tracker ("inner" :: String))+            use++      let layer = do+            outer <- bracketed bracket1+            inner <- bracketed bracket2+            pure (outer, inner)++      (o, i) <- runLayer () layer+      o `shouldBe` ("outer" :: String)+      i `shouldBe` ("inner" :: String)++      -- Both should be acquired+      acq <- readIORef (acquired tracker)+      length acq `shouldBe` 2++      -- Both should be released+      rel <- readIORef (released tracker)+      length rel `shouldBe` 2++    it "propagates exception when acquire throws instead of deadlocking" $ do+      let failingBracket :: forall a. (String -> IO a) -> IO a+          failingBracket _use =+            throwIO (userError "acquire exploded")++      let layer = bracketed failingBracket+      runLayer () layer `shouldThrow` anyException++  describe "Layer - ArrowZero and ArrowPlus instances" $ do+    it "zeroArrow throws EmptyLayer exception" $ do+      let layer = zeroArrow :: Layer IO Int String+      runLayer (42 :: Int) layer `shouldThrow` anyException++    it "<+> provides fallback with zeroArrow" $ do+      tracker <- newResourceTracker+      let failing = zeroArrow :: Layer IO () String+      let success = trackedResource tracker "success" ("fallback-value" :: String)+      let combined = failing <+> success++      result <- runLayer () combined+      result `shouldBe` ("fallback-value" :: String)++      acq <- readIORef (acquired tracker)+      acq `shouldBe` ["success"]++    it "<+> tries first arrow before fallback" $ do+      tracker <- newResourceTracker+      let primary = trackedResource tracker "primary" ("primary-value" :: String)+      let fallback = trackedResource tracker "fallback" ("fallback-value" :: String)+      let combined = primary <+> fallback++      result <- runLayer () combined+      result `shouldBe` ("primary-value" :: String)++      acq <- readIORef (acquired tracker)+      "primary" `elem` acq `shouldBe` True+      "fallback" `elem` acq `shouldBe` False  -- Fallback not tried++    it "<+> cleans up failed branch" $ do+      tracker <- newResourceTracker+      let failing = trackedResource tracker "failing" () >> zeroArrow :: Layer IO () String+      let success = trackedResource tracker "success" ("ok" :: String)+      let combined = failing <+> success++      result <- runLayer () combined+      result `shouldBe` ("ok" :: String)++      acq <- readIORef (acquired tracker)+      rel <- readIORef (released tracker)+      "failing" `elem` rel `shouldBe` True  -- Failed branch cleaned up+      "success" `elem` acq `shouldBe` True++    it "<+> chains multiple alternatives" $ do+      let opt1 = zeroArrow :: Layer IO () String+      let opt2 = zeroArrow :: Layer IO () String+      let opt3 = pure ("third-time-lucky" :: String) :: Layer IO () String+      let combined = opt1 <+> opt2 <+> opt3++      result <- runLayer () combined+      result `shouldBe` ("third-time-lucky" :: String)++  describe "Layer - ArrowChoice instance" $ do+    it "left routes Left values through the arrow" $ do+      let layer = arr ((*2) :: Int -> Int) :: Layer IO Int Int+      let leftLayer = left layer :: Layer IO (Either Int String) (Either Int String)++      result <- runLayer (Left 5) leftLayer+      result `shouldBe` Left 10++    it "left passes through Right values unchanged" $ do+      let layer = arr ((*2) :: Int -> Int) :: Layer IO Int Int+      let leftLayer = left layer :: Layer IO (Either Int String) (Either Int String)++      result <- runLayer (Right ("hello" :: String)) leftLayer+      result `shouldBe` Right "hello"++    it "right routes Right values through the arrow" $ do+      let layer = arr ((++ "!") :: String -> String) :: Layer IO String String+      let rightLayer = right layer :: Layer IO (Either Int String) (Either Int String)++      result <- runLayer (Right ("hello" :: String)) rightLayer+      result `shouldBe` Right "hello!"++    it "right passes through Left values unchanged" $ do+      let layer = arr ((++ "!") :: String -> String) :: Layer IO String String+      let rightLayer = right layer :: Layer IO (Either Int String) (Either Int String)++      result <- runLayer (Left (42 :: Int)) rightLayer+      result `shouldBe` Left 42++    it "+++ applies different arrows to Left and Right" $ do+      let intLayer = arr ((*3) :: Int -> Int) :: Layer IO Int Int+      let strLayer = arr ((++ "!") :: String -> String) :: Layer IO String String+      let combined = intLayer +++ strLayer :: Layer IO (Either Int String) (Either Int String)++      resultL <- runLayer (Left 7) combined+      resultL `shouldBe` Left 21++      resultR <- runLayer (Right ("hi" :: String)) combined+      resultR `shouldBe` Right "hi!"++    it "||| merges Either into a single output" $ do+      let intToStr = arr (show :: Int -> String) :: Layer IO Int String+      let idStr = arr ((\x -> x) :: String -> String) :: Layer IO String String+      let merged = intToStr ||| idStr :: Layer IO (Either Int String) String++      result1 <- runLayer (Left 42) merged+      result1 `shouldBe` ("42" :: String)++      result2 <- runLayer (Right ("hello" :: String)) merged+      result2 `shouldBe` ("hello" :: String)++  describe "Layer - ArrowApply instance" $ do+    it "app applies layer to input" $ do+      let makeLayer n = arr ((*n) :: Int -> Int) :: Layer IO Int Int+      let dynamicLayer = app :: Layer IO (Layer IO Int Int, Int) Int++      result <- runLayer (makeLayer (5 :: Int), (7 :: Int)) dynamicLayer+      result `shouldBe` (35 :: Int)++    it "app works with resource-based layers" $ do+      tracker <- newResourceTracker+      let layer = trackedResource tracker "dynamic" (100 :: Int)+      let dynamicApply = app :: Layer IO (Layer IO () Int, ()) Int++      result <- runLayer (layer, ()) dynamicApply+      result `shouldBe` (100 :: Int)++      acq <- readIORef (acquired tracker)+      acq `shouldBe` ["dynamic"]++    it "app enables dynamic layer selection" $ do+      tracker <- newResourceTracker+      let layer1 = trackedResource tracker "layer1" ("option-a" :: String)+      let layer2 = trackedResource tracker "layer2" ("option-b" :: String)++      let selectLayer :: Bool -> Layer IO () String+          selectLayer True = layer1+          selectLayer False = layer2++      -- Test with True+      result1 <- runLayer (selectLayer True, ()) app+      result1 `shouldBe` ("option-a" :: String)++      -- Test with False+      result2 <- runLayer (selectLayer False, ()) app+      result2 `shouldBe` ("option-b" :: String)++    it "app with complex arrow composition" $ do+      let doubler = arr ((*2) :: Int -> Int) :: Layer IO Int Int+      let adder = arr ((+10) :: Int -> Int) :: Layer IO Int Int++      -- Create a layer that chooses which operation to apply+      let picker = arr (\(choice, val) ->+            if choice then (doubler, val) else (adder, val))++      let pipeline = picker >>> app++      result1 <- runLayer ((True, 5 :: Int) :: (Bool, Int)) pipeline+      result1 `shouldBe` (10 :: Int)  -- 5 * 2++      result2 <- runLayer ((False, 5 :: Int) :: (Bool, Int)) pipeline+      result2 `shouldBe` (15 :: Int)  -- 5 + 10++  describe "Layer - Strong (Profunctor) instance" $ do+    it "first' processes first element of tuple" $ do+      let layer = do n <- ask; effect $ pure ((n * 2) :: Int)+      let firstLayer = first' layer :: Layer IO (Int, String) (Int, String)++      result <- runLayer ((5 :: Int), ("test" :: String)) firstLayer+      result `shouldBe` ((10 :: Int), ("test" :: String))++    it "second' processes second element of tuple" $ do+      let layer = do s <- ask; effect $ pure ((s ++ "!") :: String)+      let secondLayer = second' layer :: Layer IO (Int, String) (Int, String)++      result <- runLayer ((42 :: Int), ("hello" :: String)) secondLayer+      result `shouldBe` ((42 :: Int), ("hello!" :: String))++    it "first' with resource management" $ do+      tracker <- newResourceTracker+      let layer = trackedResource tracker "first-resource" (100 :: Int)+      let firstLayer = first' layer++      withLayer ((), ("data" :: String)) firstLayer $ \(val, str) -> liftIO $ do+        val `shouldBe` (100 :: Int)+        str `shouldBe` ("data" :: String)+        acq <- readIORef (acquired tracker)+        acq `shouldBe` ["first-resource"]++      rel <- readIORef (released tracker)+      rel `shouldBe` ["first-resource"]++    it "second' with resource management" $ do+      tracker <- newResourceTracker+      let layer = trackedResource tracker "second-resource" ("result" :: String)+      let secondLayer = second' layer++      withLayer (("data" :: String), ()) secondLayer $ \(str, val) -> liftIO $ do+        str `shouldBe` ("data" :: String)+        val `shouldBe` ("result" :: String)++      rel <- readIORef (released tracker)+      rel `shouldBe` ["second-resource"]++  describe "Layer - Choice (Profunctor) instance" $ do+    it "left' processes Left values" $ do+      let layer = do n <- ask; effect (pure (n * 2) :: IO Int) :: Layer IO Int Int+      let leftLayer = left' layer :: Layer IO (Either Int String) (Either Int String)++      result <- runLayer (Left 5) leftLayer+      result `shouldBe` Left 10++    it "left' passes through Right values" $ do+      let layer = do n <- ask; effect (pure (n * 2) :: IO Int) :: Layer IO Int Int+      let leftLayer = left' layer :: Layer IO (Either Int String) (Either Int String)++      result <- runLayer (Right ("test" :: String) :: Either Int String) leftLayer+      result `shouldBe` (Right ("test" :: String) :: Either Int String)++    it "right' processes Right values" $ do+      let layer = do s <- ask; effect (pure (s ++ "!") :: IO String) :: Layer IO String String+      let rightLayer = right' layer :: Layer IO (Either Int String) (Either Int String)++      result <- runLayer (Right ("hello" :: String) :: Either Int String) rightLayer+      result `shouldBe` (Right ("hello!" :: String) :: Either Int String)++    it "right' passes through Left values" $ do+      let layer = do s <- ask; effect (pure (s ++ "!") :: IO String) :: Layer IO String String+      let rightLayer = right' layer :: Layer IO (Either Int String) (Either Int String)++      result <- runLayer (Left (42 :: Int) :: Either Int String) rightLayer+      result `shouldBe` (Left (42 :: Int) :: Either Int String)++    it "left' with resource management only for Left" $ do+      tracker <- newResourceTracker+      let layer = trackedResource tracker "left-resource" (100 :: Int)+      let leftLayer = left' layer++      -- Test with Left - should acquire resource+      result1 <- runLayer (Left () :: Either () String) leftLayer+      result1 `shouldBe` (Left (100 :: Int) :: Either Int String)+      acq1 <- readIORef (acquired tracker)+      acq1 `shouldBe` ["left-resource"]++      -- Clear tracker+      writeIORef (acquired tracker) []+      writeIORef (released tracker) []++      -- Test with Right - should NOT acquire resource+      result2 <- runLayer (Right ("skip" :: String) :: Either () String) leftLayer+      result2 `shouldBe` (Right ("skip" :: String) :: Either Int String)+      acq2 <- readIORef (acquired tracker)+      acq2 `shouldBe` []  -- No resource acquired++  describe "Layer - Traversing (Profunctor) instance" $ do+    it "traverse' processes list elements" $ do+      let layer = do n <- ask; effect (pure (n * 2) :: IO Int) :: Layer IO Int Int+      let travLayer = traverse' layer :: Layer IO [Int] [Int]++      result <- runLayer [1, 2, 3, 4] travLayer+      result `shouldBe` [2, 4, 6, 8]++    it "traverse' works with empty lists" $ do+      let layer = do n <- ask; effect (pure (n * 2) :: IO Int) :: Layer IO Int Int+      let travLayer = traverse' layer :: Layer IO [Int] [Int]++      result <- runLayer [] travLayer+      result `shouldBe` []++    it "traverse' works with Maybe" $ do+      let layer = do n <- ask; effect (pure (n + 10) :: IO Int) :: Layer IO Int Int+      let travLayer = traverse' layer :: Layer IO (Maybe Int) (Maybe Int)++      result1 <- runLayer (Just 5) travLayer+      result1 `shouldBe` Just 15++      result2 <- runLayer Nothing travLayer+      result2 `shouldBe` Nothing++    it "traverse' with resource management" $ do+      tracker <- newResourceTracker+      counter <- newIORef (0 :: Int)++      let layer = do+            n <- ask+            resource+              (do+                i <- atomicModifyIORef' counter $ \c -> (c+1, c)+                trackAcquire tracker (("resource-" ++ show i) :: String)+                pure ((n * 2) :: Int))+              (\_ -> pure ())++      let travLayer = traverse' layer :: Layer IO [Int] [Int]++      result <- runLayer ([1, 2, 3] :: [Int]) travLayer+      result `shouldBe` ([2, 4, 6] :: [Int])++      -- Should acquire multiple resources (one per list element)+      acq <- readIORef (acquired tracker)+      length acq `shouldBe` 3++    it "traverse' processes complex structures" $ do+      let layer = do s <- ask; effect (pure (length s) :: IO Int) :: Layer IO String Int+      let travLayer = traverse' layer :: Layer IO [String] [Int]++      result <- runLayer (["a", "bb", "ccc"] :: [String]) travLayer+      result `shouldBe` ([1, 2, 3] :: [Int])++  describe "Layer - Semigroup instance" $ do+    it "combines layers with <>" $ do+      let layer1 = pure ([1, 2, 3] :: [Int]) :: Layer IO () [Int]+      let layer2 = pure ([4, 5, 6] :: [Int]) :: Layer IO () [Int]+      let combined = layer1 <> layer2++      result <- runLayer () combined+      result `shouldBe` ([1, 2, 3, 4, 5, 6] :: [Int])++    it "respects semigroup associativity" $ property $ \(a :: Int) (b :: Int) (c :: Int) ->+      monadicIO $ do+        let l1 = pure [a] :: Layer IO () [Int]+        let l2 = pure [b] :: Layer IO () [Int]+        let l3 = pure [c] :: Layer IO () [Int]++        r1 <- run $ runLayer () ((l1 <> l2) <> l3)+        r2 <- run $ runLayer () (l1 <> (l2 <> l3))+        assert $ r1 == r2++    it "combines with resource management" $ do+      tracker <- newResourceTracker+      let layer1 = trackedResource tracker "res1" ("hello" :: String)+      let layer2 = trackedResource tracker "res2" (" world" :: String)+      let combined = liftA2 ((<>) :: String -> String -> String) layer1 layer2++      result <- runLayer () combined+      result `shouldBe` ("hello world" :: String)++      acq <- readIORef (acquired tracker)+      length acq `shouldBe` 2++    it "works with String (semigroup)" $ do+      let layer1 = pure "Hello" :: Layer IO () String+      let layer2 = pure " World" :: Layer IO () String+      let combined = layer1 <> layer2++      result <- runLayer () combined+      result `shouldBe` "Hello World"++  describe "Layer - Monoid instance" $ do+    it "mempty produces empty value" $ do+      let layer = mempty :: Layer IO () [Int]+      result <- runLayer () layer+      result `shouldBe` []++    it "mempty is left identity" $ property $ \(xs :: [Int]) ->+      monadicIO $ do+        let layer = pure xs :: Layer IO () [Int]+        r1 <- run $ runLayer () (mempty <> layer)+        r2 <- run $ runLayer () layer+        assert $ r1 == r2++    it "mempty is right identity" $ property $ \(xs :: [Int]) ->+      monadicIO $ do+        let layer = pure xs :: Layer IO () [Int]+        r1 <- run $ runLayer () (layer <> mempty)+        r2 <- run $ runLayer () layer+        assert $ r1 == r2++    it "works with String (monoid)" $ do+      let combined = mempty <> pure "test" <> mempty :: Layer IO () String+      result <- runLayer () combined+      result `shouldBe` "test"++    it "mconcat combines multiple layers" $ do+      let layers = [pure [1], pure [2], pure [3], pure [4]] :: [Layer IO () [Int]]+      let combined = mconcat layers++      result <- runLayer () combined+      result `shouldBe` [1, 2, 3, 4]++  describe "Layer - Complex Alternative/MonadPlus chains" $ do+    it "deeply nested <|> chains work correctly" $ do+      tracker <- newResourceTracker+      let opt1 = trackedResource tracker "opt1" () >> empty :: Layer IO () String+      let opt2 = trackedResource tracker "opt2" () >> empty :: Layer IO () String+      let opt3 = trackedResource tracker "opt3" () >> empty :: Layer IO () String+      let opt4 = trackedResource tracker "opt4" ("success" :: String)+      let combined = opt1 <|> opt2 <|> opt3 <|> opt4++      result <- runLayer () combined+      result `shouldBe` ("success" :: String)++      -- All failed options should be cleaned up+      rel <- readIORef (released tracker)+      length (filter (`elem` ["opt1", "opt2", "opt3"]) rel) `shouldBe` 3++    it "mzero behaves like empty" $ do+      let layer = mzero :: Layer IO () String+      runLayer () layer `shouldThrow` anyException++    it "mplus behaves like <|>" $ do+      let failing = mzero :: Layer IO () String+      let success = pure ("fallback" :: String) :: Layer IO () String+      let combined = mplus failing success++      result <- runLayer () combined+      result `shouldBe` ("fallback" :: String)++    it "Alternative with resource dependencies" $ do+      tracker <- newResourceTracker+      let primary = do+            _ <- trackedResource tracker "primary-dep" ()+            empty :: Layer IO () String+      let fallback = do+            _ <- trackedResource tracker "fallback-dep" ()+            pure ("fallback-result" :: String)+      let combined = primary <|> fallback++      result <- runLayer () combined+      result `shouldBe` ("fallback-result" :: String)++      -- Primary dependency should be cleaned up before fallback runs+      acq <- readIORef (acquired tracker)+      rel <- readIORef (released tracker)+      "primary-dep" `elem` rel `shouldBe` True+      "fallback-dep" `elem` acq `shouldBe` True++    it "all alternatives fail propagates exception" $ do+      let opt1 = empty :: Layer IO () String+      let opt2 = empty :: Layer IO () String+      let opt3 = empty :: Layer IO () String+      let combined = opt1 <|> opt2 <|> opt3++      runLayer () combined `shouldThrow` anyException++  describe "Layer - Exception during cleanup" $ do+    it "exception in finalizer doesn't prevent other cleanups" $ do+      tracker <- newResourceTracker+      let layer1 = resource+            (do+              trackAcquire tracker ("res1" :: String)+              pure ("res1" :: String))+            (\_ -> do+              trackRelease tracker ("res1" :: String)+              throwIO $ userError "cleanup1 failed") :: Layer IO () String++      let layer2 = resource+            (do+              trackAcquire tracker ("res2" :: String)+              pure ("res2" :: String))+            (\_ -> trackRelease tracker ("res2" :: String)) :: Layer IO () String++      let combined = liftA2 (,) layer1 layer2++      -- The cleanup exception should propagate+      runLayer () combined `shouldThrow` anyException++      -- But both should attempt cleanup+      rel <- readIORef (released tracker)+      "res1" `elem` rel `shouldBe` True+      -- Note: res2 cleanup behavior depends on exception handling order++    it "exception in use phase still triggers cleanup" $ do+      tracker <- newResourceTracker+      let layer = trackedResource tracker "resource" ("value" :: String)++      withLayer () layer (\_ -> liftIO $ throwIO $ userError "use failed") `shouldThrow` anyException++      -- Resource should still be cleaned up+      rel <- readIORef (released tracker)+      "resource" `elem` rel `shouldBe` True++    it "multiple concurrent exceptions in zipLayer" $ do+      tracker <- newResourceTracker+      gate1 <- newEmptyMVar+      gate2 <- newEmptyMVar+      let layer1 = do+            _ <- trackedResource tracker "res1" ()+            effect (do+              putMVar gate1 ()+              takeMVar gate2+              throwIO (userError "error1")) :: Layer IO () String+      let layer2 = do+            _ <- trackedResource tracker "res2" ()+            effect (do+              putMVar gate2 ()+              takeMVar gate1+              throwIO (userError "error2")) :: Layer IO () String++      let combined = zipLayer layer1 layer2++      runLayer ((), ()) combined `shouldThrow` anyException++      rel <- readIORef (released tracker)+      "res1" `elem` rel `shouldBe` True+      "res2" `elem` rel `shouldBe` True++    it "exception in one zipLayer branch cleans up both" $ do+      tracker <- newResourceTracker+      let successLayer = trackedResource tracker "success" ("ok" :: String)+      let failLayer = trackedResource tracker "failing" () >> effect (throwIO $ userError "boom") :: Layer IO () String++      let combined = zipLayer successLayer failLayer++      runLayer ((), ()) combined `shouldThrow` anyException++      rel <- readIORef (released tracker)+      "success" `elem` rel `shouldBe` True+      "failing" `elem` rel `shouldBe` True++  describe "Layer - mapLayer" $ do+    it "transforms dependencies before layer runs" $ do+      let layer = do n <- ask; effect $ pure (n * 2 :: Int)+      let add10 = (+ 10) :: Int -> Int+      let mapped = mapLayer add10 layer+      result <- runLayer (5 :: Int) mapped+      result `shouldBe` (30 :: Int)++    it "mapLayer with resource management" $ do+      tracker <- newResourceTracker+      let layer = do+            n <- ask+            resource+              (do+                trackAcquire tracker "mapped-res"+                pure (n + 100 :: Int))+              (\_ -> trackRelease tracker "mapped-res")+      let mapped = mapLayer ((*3) :: Int -> Int) layer++      withLayer (7 :: Int) mapped $ \val -> liftIO $+        val `shouldBe` (121 :: Int)++      rel <- readIORef (released tracker)+      rel `shouldBe` ["mapped-res"]++  describe "Layer - unsafeMkLayer" $ do+    it "creates a layer from a ResourceT action" $ do+      let layer = unsafeMkLayer (\n -> pure (n * 3 :: Int)) :: Layer IO Int Int+      result <- runLayer (14 :: Int) layer+      result `shouldBe` (42 :: Int)++    it "unsafeMkLayer ignores interceptor" $ do+      ref <- newIORef (0 :: Int)+      let layer = unsafeMkLayer (\() -> do+            liftIO $ modifyIORef' ref (+ 1)+            pure (42 :: Int)) :: Layer IO () Int++      result <- runLayer () layer+      result `shouldBe` (42 :: Int)+      readIORef ref >>= (`shouldBe` 1)++  describe "Layer - uncached accessor" $ do+    it "extracts the underlying layer from a service" $ do+      let layer = effect $ pure (42 :: Int)+      let svc = mkService layer+      result <- runLayer () (uncached svc)+      result `shouldBe` (42 :: Int)++    it "uncached layer is not cached" $ do+      counter <- newIORef (0 :: Int)+      let layer = effect $ do+            atomicModifyIORef' counter $ \n -> (n + 1, n + 1)+      let svc = mkService layer+      let multiAccess = do+            a <- uncached svc+            b <- uncached svc+            pure (a, b)+      (r1, r2) <- runLayer () multiAccess+      r1 `shouldBe` (1 :: Int)+      r2 `shouldBe` (2 :: Int)++  describe "Layer - Profunctor lmap/rmap" $ do+    it "lmap transforms input" $ do+      let layer = do n <- ask; effect $ pure (n + 1 :: Int)+      let mapped = lmap ((*2) :: Int -> Int) layer+      result <- runLayer (5 :: Int) mapped+      result `shouldBe` (11 :: Int)++    it "rmap transforms output" $ do+      let layer = do n <- ask; effect $ pure (n + 1 :: Int)+      let mapped = P.rmap ((*3) :: Int -> Int) layer+      result <- runLayer (5 :: Int) mapped+      result `shouldBe` (18 :: Int)++  describe "Layer - Async exception safety" $ do+    it "async exception during layer build triggers resource cleanup" $ do+      tracker <- newResourceTracker+      cleanupDone <- newEmptyMVar+      readyForException <- newEmptyMVar+      let layer = resource+            (do+              trackAcquire tracker "async-resource"+              pure ("resource" :: String))+            (\_ -> do+              trackRelease tracker "async-resource"+              putMVar cleanupDone ())++      tid <- myThreadId+      void $ async $ do+        takeMVar readyForException+        E.throwTo tid ThreadKilled++      let action = withLayer () layer $ \val -> liftIO $ do+            val `shouldBe` ("resource" :: String)+            putMVar readyForException ()+            takeMVar cleanupDone++      action `shouldThrow` (\e -> case fromException e of+        Just ThreadKilled -> True+        _ -> False)++      rel <- readIORef (released tracker)+      "async-resource" `elem` rel `shouldBe` True++    it "mask protects critical sections in zipLayer" $ do+      tracker <- newResourceTracker+      let layer1 = trackedResource tracker "zip-a" ("a" :: String)+      let layer2 = trackedResource tracker "zip-b" ("b" :: String)+      let combined = zipLayer layer1 layer2++      (a, b) <- runLayer ((), ()) combined+      a `shouldBe` "a"+      b `shouldBe` "b"++  describe "Layer - Selective instance" $ do+    it "selectM works" $ do+      let layer = pure (Right (42 :: Int)) >>= \case+            Left f -> pure (f (0 :: Int))+            Right x -> pure x+      result <- runLayer () (layer :: Layer IO () Int)+      result `shouldBe` (42 :: Int)++  describe "Layer - composeLayer" $ do+    it "composes two layers sequentially" $ do+      let upper = effect $ pure (10 :: Int)+      let lower = do n <- ask; effect $ pure (n * 2 :: Int)+      let composed = composeLayer upper lower+      result <- runLayer () composed+      result `shouldBe` (20 :: Int)++    it "composeLayer preserves resource management" $ do+      tracker <- newResourceTracker+      let upper = trackedResource tracker "upper" (5 :: Int)+      let lower = do+            n <- ask+            resource+              (do+                trackAcquire tracker "lower"+                pure (n + 10 :: Int))+              (\_ -> trackRelease tracker "lower")+      let composed = composeLayer upper lower+      withLayer () composed $ \val -> liftIO $+        val `shouldBe` (15 :: Int)+      rel <- readIORef (released tracker)+      "upper" `elem` rel `shouldBe` True+      "lower" `elem` rel `shouldBe` True++  describe "Layer - Monad laws (property-based)" $ do+    it "left identity: return a >>= f  ===  f a" $ property $ \(n :: Int) ->+      monadicIO $ do+        let f x = pure (x * 2) :: Layer IO () Int+        r1 <- run $ runLayer () (return n >>= f)+        r2 <- run $ runLayer () (f n)+        assert $ r1 == r2++    it "right identity: m >>= return  ===  m" $ property $ \(n :: Int) ->+      monadicIO $ do+        let m = pure n :: Layer IO () Int+        r1 <- run $ runLayer () (m >>= return)+        r2 <- run $ runLayer () m+        assert $ r1 == r2++    it "associativity: (m >>= f) >>= g  ===  m >>= (\\x -> f x >>= g)" $ property $ \(n :: Int) ->+      monadicIO $ do+        let m = pure n :: Layer IO () Int+        let f x = pure (x + 1) :: Layer IO () Int+        let g x = pure (x * 2) :: Layer IO () Int+        r1 <- run $ runLayer () ((m >>= f) >>= g)+        r2 <- run $ runLayer () (m >>= (\x -> f x >>= g))+        assert $ r1 == r2++  describe "Layer - Arrow laws (property-based)" $ do+    it "arr id === id" $ property $ \(n :: Int) ->+      monadicIO $ do+        r1 <- run $ runLayer n (arr id :: Layer IO Int Int)+        r2 <- run $ runLayer n (id :: Layer IO Int Int)+        assert $ r1 == r2++    it "arr (f >>> g) === arr f >>> arr g" $ property $ \(n :: Int) ->+      monadicIO $ do+        let f = (+1) :: Int -> Int+        let g = (*2) :: Int -> Int+        let gf x = g (f x)+        r1 <- run $ runLayer n (arr gf :: Layer IO Int Int)+        r2 <- run $ runLayer n ((arr f :: Layer IO Int Int) >>> (arr g :: Layer IO Int Int))+        assert $ r1 == r2++  describe "Layer - withLayerAndInterceptor" $ do+    it "runs a layer with custom interceptor" $ do+      ref <- newIORef ([] :: [String])+      let interceptor = nullInterceptor+            { onEffectRun = \ctx -> modifyIORef ref (++ [show (operationName ctx)])+            }+      let layer = effect @IO @() @Int $ pure 42+      withLayerAndInterceptor interceptor () layer $ \val -> liftIO $+        val `shouldBe` (42 :: Int)+      logs <- readIORef ref+      length logs `shouldBe` 1++  describe "Layer - runLayerWithInterceptor" $ do+    it "runs a layer and returns value with interceptor" $ do+      ref <- newIORef ([] :: [String])+      let interceptor = nullInterceptor+            { onResourceAcquire = \ctx -> modifyIORef ref (++ ["acquire:" ++ show (operationName ctx)])+            , onResourceRelease = \_ -> modifyIORef ref (++ ["release"])+            }+      let layer = resource @IO @() @String+            (pure "hello")+            (\_ -> pure ())+      result <- runLayerWithInterceptor interceptor () layer+      result `shouldBe` ("hello" :: String)+      logs <- readIORef ref+      logs `shouldContain` ["release"]++  describe "Layer - Interceptor timing correctness" $ do+    it "onResourceRelease fires at actual release time, not acquisition time" $ do+      ref <- newIORef ([] :: [String])+      let interceptor = nullInterceptor+            { onResourceAcquire = \_ -> modifyIORef ref (++ ["acquire"])+            , onResourceRelease = \_ -> modifyIORef ref (++ ["release"])+            }+      let layer = resource @IO @() @String+            (pure "hello")+            (\_ -> pure ())+      withLayerAndInterceptor interceptor () layer $ \val -> liftIO $ do+        val `shouldBe` ("hello" :: String)+        logs <- readIORef ref+        logs `shouldBe` ["acquire"]+        logs `shouldNotContain` ["release"]+      logs <- readIORef ref+      logs `shouldBe` ["acquire", "release"]++  describe "Layer - EmptyLayer Show and Exception" $ do+    it "show EmptyLayer" $+      show EmptyLayer `shouldBe` "EmptyLayer"++    it "showList for EmptyLayer" $ do+      let s = show [EmptyLayer, EmptyLayer]+      s `shouldContain` "EmptyLayer"++    it "displayException for EmptyLayer" $ do+      let e = toException EmptyLayer+      displayException (e :: SomeException) `shouldContain` "EmptyLayer"++    it "fromException round-trips" $ do+      let e = toException EmptyLayer+      case fromException e of+        Just EmptyLayer -> pure ()+        Nothing -> expectationFailure "fromException failed"++  describe "Layer - Applicative <* and *> and <$" $ do+    it "*> discards left value" $ do+      let layer = pure (1 :: Int) *> pure (2 :: Int) :: Layer IO () Int+      result <- runLayer () layer+      result `shouldBe` (2 :: Int)++    it "<* discards right value" $ do+      let layer = pure (1 :: Int) <* pure (2 :: Int) :: Layer IO () Int+      result <- runLayer () layer+      result `shouldBe` (1 :: Int)++    it "<$ replaces value" $ do+      let layer = (99 :: Int) <$ pure ("ignored" :: String) :: Layer IO () Int+      result <- runLayer () layer+      result `shouldBe` (99 :: Int)++  describe "Layer - Semigroup stimes/sconcat" $ do+    it "stimes repeats the semigroup operation" $ do+      let layer = pure [1 :: Int] :: Layer IO () [Int]+      let repeated = stimes (3 :: Int) layer+      result <- runLayer () repeated+      result `shouldBe` [1, 1, 1]++    it "sconcat folds non-empty list" $ do+      let layers = pure [1 :: Int] :| [pure [2], pure [3]]+      let combined = sconcat layers :: Layer IO () [Int]+      result <- runLayer () combined+      result `shouldBe` [1, 2, 3]++  describe "Layer - Monoid mappend/mconcat" $ do+    it "mappend combines two layers" $ do+      let l1 = pure [1 :: Int] :: Layer IO () [Int]+      let l2 = pure [2 :: Int] :: Layer IO () [Int]+      result <- runLayer () (mappend l1 l2)+      result `shouldBe` [1, 2]++    it "mconcat combines list of layers" $ do+      let layers = [pure [1], pure [2], pure [3]] :: [Layer IO () [Int]]+      result <- runLayer () (mconcat layers)+      result `shouldBe` [1, 2, 3]++  describe "Layer - Selective select" $ do+    it "select passes through Right" $ do+      let condLayer = pure (Right (42 :: Int)) :: Layer IO () (Either Int Int)+      let funcLayer = pure ((*2) :: Int -> Int) :: Layer IO () (Int -> Int)+      result <- runLayer () (S.select condLayer funcLayer)+      result `shouldBe` (42 :: Int)++    it "select applies function on Left" $ do+      let condLayer = pure (Left (7 :: Int)) :: Layer IO () (Either Int Int)+      let funcLayer = pure ((*3) :: Int -> Int) :: Layer IO () (Int -> Int)+      result <- runLayer () (S.select condLayer funcLayer)+      result `shouldBe` (21 :: Int)++  describe "Layer - MonadReader reader" $ do+    it "reader maps over the environment" $ do+      let layer = reader (port :: Config -> Int) :: Layer IO Config Int+      result <- runLayer (Config 8080 "host") layer+      result `shouldBe` 8080++  describe "Layer - Profunctor composition" $ do+    it "Category composition chains layers" $ do+      let layer1 = do n <- ask; effect $ pure (n + 1 :: Int)+      let layer2 = do n <- ask; effect $ pure (n * 2 :: Int)+      let composed = layer2 . layer1+      result <- runLayer (5 :: Int) composed+      result `shouldBe` (12 :: Int)++  describe "Layer - Traversing wander" $ do+    it "wander processes traversable structure" $ do+      let layer = do n <- ask; effect (pure (n * 10 :: Int)) :: Layer IO Int Int+      let wandered = traverse' layer :: Layer IO [Int] [Int]+      result <- runLayer [1, 2, 3] wandered+      result `shouldBe` [10, 20, 30]
+ test/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}