packages feed

sofetch (empty) → 0.1.0.0

raw patch · 20 files changed

+7864/−0 lines, 20 filesdep +aesondep +asyncdep +basesetup-changed

Dependencies added: aeson, async, base, bytestring, containers, exceptions, hashable, hspec, http-client, http-client-tls, http-types, semigroupoids, sofetch, sqlite-simple, text, time, transformers, unliftio-core, unordered-containers

Files

+ CHANGELOG.md view
@@ -0,0 +1,11 @@+# Changelog for `sofetch`++All notable changes to this project will be documented in this file.++The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),+and this project adheres to the+[Haskell Package Versioning Policy](https://pvp.haskell.org/).++## Unreleased++## 0.1.0.0 - YYYY-MM-DD
+ LICENSE view
@@ -0,0 +1,26 @@+Copyright 2026 Author name here++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,404 @@+<p align="center">+  <img src="logo.svg" alt="sofetch" width="400">+</p>++<p align="center">+  <img src="fetch.gif" alt="That's so fetch" width="300">+</p>++<p align="center">+  <a href="LICENSE"><img src="https://img.shields.io/badge/license-BSD--3--Clause-blue.svg" alt="License: BSD-3-Clause"></a>+  <img src="https://img.shields.io/badge/language-Haskell-purple.svg" alt="Haskell">+  <img src="https://img.shields.io/badge/GHC-9.2_%7C_9.4_%7C_9.6_%7C_9.8-informational.svg" alt="GHC versions">+</p>++---++## The problem++Suppose you have a web page that shows a list of blog posts, each with its+author's name. A naive implementation fetches each author one at a time:++```haskell+-- Fetch each author individually, one query per post!+renderPosts :: [Post] -> AppM [Html]+renderPosts posts = forM posts $ \post -> do+  author <- getUser (postAuthorId post)    -- DB round-trip+  pure (renderPostCard post author)+```++Ten posts means ten separate database queries. A hundred posts means a+hundred queries. This is the **N+1 problem**: you run 1 query to get the+list, then N more queries to get each related item. It's one of the most+common performance pitfalls in data-access code, and it's easy to+introduce without noticing because each function in isolation looks+perfectly reasonable.++The typical fix is to restructure your code: collect all the+IDs up front, run a single batched query, then stitch the results back+together. That works, but it forces your code shape to match your+optimisation strategy. Composition suffers: you can't freely combine small+functions without worrying about the data-access pattern they produce.++## The solution++**sofetch** fixes this automatically. Write simple, sequential-looking+code, and sofetch batches and deduplicates your data access behind the+scenes:++```haskell+renderPosts :: (MonadFetch m n, DataSource m UserById) => [Post] -> n [Html]+renderPosts posts =+  -- All author fetches are batched into ONE query, automatically.+  fetchThrough (UserById . postAuthorId) posts+    <&> map (\(post, author) -> renderPostCard post author)+```++No matter how many posts you have, this issues a single `WHERE id IN (...)`+query for all the authors. You didn't have to restructure anything. You+wrote the obvious code and sofetch made it fast.++<p align="center">+  <img src="docs/n-plus-one-vs-batched.svg" alt="N+1 queries vs 1 batched query with sofetch" width="720">+</p>++This works across function boundaries too. If `renderPostCard` internally+fetches comment counts, and `renderSidebar` fetches the same authors for a+"top contributors" widget, sofetch merges all of those fetches together.+Functions that were written independently, without any knowledge of each+other, still get optimal batching when composed.++## How it works (in brief)++sofetch gives you a special `Fetch` monad. When you write:++```haskell+(,) <$> fetch (UserById 1) <*> fetch (UserById 2)+```++...the two fetches don't happen immediately. Instead, sofetch collects them+into a **round**, groups them by data source, and dispatches one batched+call per source. The `<*>` operator (or `ApplicativeDo` if you prefer+do-notation) is the signal that two fetches are independent and can be+batched together. The `>>=` operator (monadic bind) introduces a round+boundary: the right side depends on the left side's result, so it has to+wait.++```mermaid+flowchart LR+  f1["fetch (UserById 1)"] --> b1["batchFetch<br/>[UserById 1, 2]"]+  f2["fetch (UserById 2)"] --> b1+  f3["fetch (PostsByAuthor 1)"] --> b2["batchFetch<br/>[PostsByAuthor 1]"]+  b1 -. "concurrent" .- b2+```++Within each round:++- Keys for the **same data source** are grouped into one `batchFetch` call.+- Keys for **different data sources** run concurrently.+- **Duplicate keys** are deduplicated. The same key appearing in multiple+  places produces only one fetch, and all callers share the result.+- Results are **cached** so the same key never hits the database twice (unless+  you opt out).++## Quick start++### 1. Define key types++Each kind of data you want to fetch gets a **key type**, a small type that+says "I want to look up *this thing*" and declares what the result will be.+This is the core modelling step: one key type per query shape.++```haskell+{-# LANGUAGE DeriveGeneric, DeriveAnyClass, DerivingStrategies, TypeFamilies #-}++data User = User { userId :: Int, userName :: Text }+data Post = Post { postId :: Int, postAuthorId :: Int, postTitle :: Text }++-- "Give me a user by their ID"+newtype UserById = UserById Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey UserById where+  type Result UserById = User++-- "Give me all posts by this author"+newtype PostsByAuthor = PostsByAuthor Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey PostsByAuthor where+  type Result PostsByAuthor = [Post]+```++The key type carries the query parameter (the user ID, the author ID) and+the `FetchKey` instance tells sofetch what type the answer will be. All the+required instances (`Eq`, `Hashable`, `Show`, etc.) are stock-derivable, no+boilerplate.++### 2. Teach sofetch how to fetch them++A `DataSource` instance tells sofetch how to batch-fetch a group of keys.+You receive a `NonEmpty` list of keys and return a `HashMap` of results,+one entry per key:++```haskell+instance DataSource AppM UserById where+  batchFetch keys = do+    pool <- asks appPool+    let ids = [uid | UserById uid <- toList keys]+    rows <- liftIO $ withResource pool $ \conn ->+      query conn "SELECT id, name FROM users WHERE id = ANY(?)" (Only ids)+    pure $ HM.fromList [(UserById (userId u), u) | u <- rows]+```++The `AppM` parameter is *your* monad. If it has access to a connection+pool, config, or anything else, your data source has access to it too.+No special environment setup is needed.++If your backend doesn't support batch lookups (e.g. a REST API that only+fetches one item at a time), implement `fetchOne` instead. sofetch will+call it for each key:++```haskell+instance DataSource AppM UserById where+  fetchOne (UserById uid) = lookupUserById uid+```++You still get deduplication and caching; you just don't get the batched+SQL.++### 3. Write data-access code++Now use `fetch` in your application code. Program against the `MonadFetch`+typeclass so your functions work with any implementation (production, tests,+tracing):++```haskell+getUserFeed :: (MonadFetch m n, DataSource m UserById, DataSource m PostsByAuthor)+            => Int -> n (User, [Post])+getUserFeed uid =+  (,) <$> fetch (UserById uid) <*> fetch (PostsByAuthor uid)+```++These two fetches are independent (`<*>`), so sofetch batches them into a+single round. If you prefer do-notation, enable `ApplicativeDo` and write+the equivalent:++```haskell+{-# LANGUAGE ApplicativeDo #-}++getUserFeed uid = do+  user  <- fetch (UserById uid)        -- batched together+  posts <- fetch (PostsByAuthor uid)   -- in one round+  pure (user, posts)+```++Both forms produce identical batching behaviour.++### 4. Run it++```haskell+handleRequest :: AppEnv -> Int -> IO (User, [Post])+handleRequest env uid = runAppM env $ do+  cfg <- fetchConfigIO+  runFetch cfg (getUserFeed uid)+```++`fetchConfigIO` works for any `MonadUnliftIO` monad (which includes any+`ReaderT env IO` stack, the most common pattern). It wires everything up+automatically.++### 5. Test it++Swap the real data sources for canned data. No IO, no database:++```haskell+testGetUserFeed :: IO ()+testGetUserFeed = do+  let mocks = mockData @UserById       [(UserById 1, testUser)]+           <> mockData @PostsByAuthor   [(PostsByAuthor 1, [testPost])]+  (user, posts) <- runMockFetch @AppM mocks (getUserFeed 1)+  assertEqual user testUser+  assertEqual posts [testPost]+```++Because `getUserFeed` is polymorphic over `MonadFetch`, it runs unchanged+against `MockFetch`. No special test wiring needed.++## A real example: collapsing N+1 cascades++Here's a scenario from the included SQLite example. A blog page needs to+render three authors, each with their posts, each post with its comments,+each comment with its author name. The functions are written independently+at four different levels:++```+renderBlogPage                    fetches 3 authors+  └─ renderAuthorProfile          fetches posts for an author+       └─ renderPostWithComments  fetches comments for a post+            └─ renderComment      fetches the comment's author+```++Without sofetch, this is 25+ database queries. With sofetch, `traverse`+automatically merges fetches at the same depth:++```mermaid+flowchart LR+  subgraph R1 ["Round 1"]+    A1["UserById 1, 2, 3"]+  end+  subgraph R2 ["Round 2"]+    A2["PostsByAuthor 1, 2, 3"]+  end+  subgraph R3 ["Round 3"]+    A3["CommentsByPost 1 … 7"]+  end+  subgraph R4 ["Round 4"]+    A4["UserById 4, 5 (deduped)"]+  end+  R1 --> R2 --> R3 --> R4+```++**4 rounds, 4 SQL queries**, regardless of the data size. The functions+never coordinate with each other. They don't know they're being composed.+sofetch handles it.++## Key features++- **No GADTs.** Data sources are ordinary typeclasses. Key types use stock+  `deriving`. If you've defined a newtype, you're 90% of the way to a data+  source.+- **Your monad, your resources.** `DataSource` is parameterised by your+  monad, not some framework environment. Connection pools, config, whatever+  your monad carries, your data sources have access to it. Missing+  instances are compile-time errors, not runtime crashes.+- **Monad transformer.** `Fetch m a` layers over your existing monad stack.+  Drop it in without restructuring your application.+- **Swappable implementations.** `MonadFetch` is the interface your+  application code uses. Production, test, and traced implementations all+  satisfy it. Swap without code changes.+- **Extensible instrumentation.** `runLoopWith` lets you wrap each batch+  round (e.g. with tracing spans). OpenTelemetry support lives in the+  separate [`sofetch-otel`](./sofetch-otel) package.++```mermaid+flowchart TD+  A["Application code"] -->|"programs against"| B["MonadFetch (typeclass)"]+  B --> C["Fetch m<br/>production"]+  B --> D["MockFetch<br/>testing"]+  B --> E["TracedFetch<br/>instrumentation"]+  C --> F["DataSource instances<br/>UserById · PostsByAuthor · …"]+```++## Combinators++sofetch includes a toolkit for common patterns:++| Combinator | What it does |+|---|---|+| `fetchAll keys` | Fetch a list of keys in one round |+| `fetchThrough toKey items` | Extract a key from each item, fetch, pair back |+| `fetchMap toKey combine items` | Like `fetchThrough` but transform the pair |+| `fetchMaybe maybeKey` | Fetch if the key is present |+| `fetchMapWith keys` | Fetch a collection, return a `HashMap` of results |+| `filterA predicate items` | Applicative filter; all predicates batched |+| `withDefault val action` | Return a default on any exception |+| `pAnd` / `pOr` | Parallel short-circuiting boolean combinators |++## Advanced usage++### Shared cache across phases++To preserve the cache across sequential computations, use `runFetch'` which+returns the cache alongside the result:++```haskell+handleTwoPhases :: AppEnv -> IO [Post]+handleTwoPhases env = runAppM env $ do+  cfg <- fetchConfigIO++  -- Phase 1: populate cache+  (_users, cache) <- runFetch' cfg $+    fetchAll [UserById 1, UserById 2, UserById 3]++  -- Phase 2: cached keys resolve without hitting the DB+  runFetch cfg { configCache = Just cache } $+    fetchAll [PostsByAuthor 1, PostsByAuthor 2]+```++### Restricted monads (no MonadIO)++For monads that deliberately hide IO (e.g. a `Transaction` type that+prevents arbitrary IO inside database transactions), use `fetchConfig` with+explicit natural transformations and export a safe runner:++```haskell+fetchInTransaction :: Fetch Transaction a -> Transaction a+fetchInTransaction = runFetch (fetchConfig unsafeRunTransaction unsafeLiftIO)+```++The unsafe escape hatches stay private to your DB module. Application code+calls `fetchInTransaction` and never touches IO.++See `examples/SqliteBlog.hs` (scenario 12) for a worked proof-of-concept.++## Examples++The `examples/` directory contains two runnable programs:++```bash+stack build --flag sofetch:examples+stack exec sqlite-blog+stack exec github-explorer+```++**SQLite blog** (`examples/SqliteBlog.hs`): A blog platform backed by+in-memory SQLite. Every `batchFetch` prints its SQL so you can see exactly+how fetches are batched. Covers applicative batching, N+1 avoidance,+deduplication, deep N+1 across function boundaries, faceted queries, chunked+batching, shared caches, mocks, and restricted monads.++**GitHub explorer** (`examples/GitHubExplorer.hs`): Concurrent exploration+of the GitHub REST API. Demonstrates sofetch with HTTP backends where the+value is concurrency, deduplication, and caching rather than SQL batching.++## Packages++| Package | Description |+|---|---|+| **sofetch** | Core library: `Fetch`, `DataSource`, `MonadFetch`, cache, engine, mocks, tracing hooks |+| **[sofetch-otel](./sofetch-otel)** | OpenTelemetry instrumentation via `runFetchWithOTel` |++## Modules++| Module | Contents |+|---|---|+| `Fetch` | Top-level re-exports |+| `Fetch.Class` | `FetchKey`, `DataSource`, `MonadFetch`, `MonadFetchBatch`, `Status`, `Batches` |+| `Fetch.Batched` | `Fetch` monad transformer, runners, `runLoopWith` |+| `Fetch.Engine` | Batch dispatch with strategy-based scheduling |+| `Fetch.Cache` | IVar-based cache with dedup, eviction, warming |+| `Fetch.IVar` | Write-once variable with error support |+| `Fetch.Combinators` | `fetchAll`, `fetchThrough`, `fetchMap`, etc. |+| `Fetch.Mock` | `MockFetch` for testing |+| `Fetch.Traced` | `TracedFetch` with per-round callbacks |+| `Fetch.Mutate` | `Mutate` for interleaved read-write computations |+| `Fetch.Memo` | `MemoStore`, `memo`, `memoOn` |+| `Fetch.Deriving` | Helpers for writing instances (`optionalBatchFetch`, DerivingVia docs) |++## Design++See [docs/DESIGN.md](./docs/DESIGN.md) for the full set of design decisions+and tradeoffs.++---++<sub>sofetch is inspired by Facebook's [Haxl](https://github.com/facebook/Haxl)+(Marlow et al., *There is no fork: an abstraction for efficient, concurrent,+and concise data access*, ICFP 2014). It keeps the core idea (write+sequential-looking code, get batched data access) while replacing the+GADT-based data source API with type families and ordinary typeclasses, and+using a monad-transformer design instead of a bespoke environment. See+[DESIGN.md](./docs/DESIGN.md) for a detailed comparison.</sub>
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ examples/GitHubExplorer.hs view
@@ -0,0 +1,340 @@+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE RankNTypes #-}++-- | Concurrent exploration of the GitHub REST API.+--+-- This example demonstrates sofetch with an HTTP backend where the+-- value is concurrency, deduplication, and caching, not SQL batching.+--+-- Run it with:+--+-- @+-- stack run github-explorer+-- @+--+-- __Rate limit note:__ GitHub allows 60 unauthenticated requests per+-- hour. This example uses ~15 requests against a handful of stable+-- accounts. To raise the limit to 5,000/hour, set:+--+-- @+-- export GITHUB_TOKEN=ghp_your_token_here+-- @+module Main (main) where++import Fetch++import Control.Exception (SomeException)+import Control.Monad (when)+import Data.Aeson ((.:), (.:?), withObject, FromJSON(..), eitherDecode')+import Data.ByteString.Lazy (ByteString)+import Data.IORef+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import GHC.Generics (Generic)+import Network.HTTP.Client+  ( Manager+  , newManager, parseRequest, httpLbs, responseBody, responseStatus+  , requestHeaders+  )+import Network.HTTP.Client.TLS (tlsManagerSettings)+import Network.HTTP.Types.Status (statusCode)+import System.Environment (lookupEnv)++-- ══════════════════════════════════════════════+-- Domain types (parsed from GitHub JSON)+-- ══════════════════════════════════════════════++data GitHubUser = GitHubUser+  { ghUserLogin      :: !Text+  , ghUserName       :: !(Maybe Text)+  , ghUserPublicRepos :: !Int+  , ghUserFollowers  :: !Int+  } deriving (Show, Eq, Generic)++instance FromJSON GitHubUser where+  parseJSON = withObject "GitHubUser" $ \o -> GitHubUser+    <$> o .: "login"+    <*> o .:? "name"+    <*> o .: "public_repos"+    <*> o .: "followers"++data GitHubRepo = GitHubRepo+  { ghRepoName       :: !Text+  , ghRepoStars      :: !Int+  , ghRepoLanguage   :: !(Maybe Text)+  , ghRepoFork       :: !Bool+  } deriving (Show, Eq, Generic)++instance FromJSON GitHubRepo where+  parseJSON = withObject "GitHubRepo" $ \o -> GitHubRepo+    <$> o .: "name"+    <*> o .: "stargazers_count"+    <*> o .:? "language"+    <*> o .: "fork"++-- ══════════════════════════════════════════════+-- FetchKey types+-- ══════════════════════════════════════════════++-- | Fetch a GitHub user profile by login name.+newtype UserLogin = UserLogin Text+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey UserLogin where+  type Result UserLogin = GitHubUser++-- | Fetch the public repos for a GitHub user.+newtype UserRepos = UserRepos Text+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey UserRepos where+  type Result UserRepos = [GitHubRepo]++-- ══════════════════════════════════════════════+-- Application monad+-- ══════════════════════════════════════════════++-- | Environment: HTTP manager + optional auth token.+data AppEnv = AppEnv+  { envManager :: !Manager+  , envToken   :: !(Maybe Text)+  }++newtype AppM a = AppM { unAppM :: AppEnv -> IO a }++instance Functor AppM where+  fmap f (AppM g) = AppM $ \e -> fmap f (g e)++instance Applicative AppM where+  pure a = AppM $ \_ -> pure a+  AppM ff <*> AppM fx = AppM $ \e -> ff e <*> fx e++instance Monad AppM where+  AppM ma >>= f = AppM $ \e -> do+    a <- ma e+    unAppM (f a) e++askEnv :: AppM AppEnv+askEnv = AppM pure++liftIO :: IO a -> AppM a+liftIO io = AppM $ \_ -> io++runAppM :: AppEnv -> AppM a -> IO a+runAppM env (AppM f) = f env++-- ══════════════════════════════════════════════+-- HTTP helpers+-- ══════════════════════════════════════════════++-- | Make an authenticated GET request to the GitHub API.+-- Returns Nothing on non-200 responses (so per-key errors don't+-- take down the entire batch).+githubGet :: String -> AppM (Maybe ByteString)+githubGet url = do+  env <- askEnv+  req <- Main.liftIO $ parseRequest url+  let authHeaders = case envToken env of+        Just tok -> [("Authorization", "token " <> TE.encodeUtf8 tok)]+        Nothing  -> []+      req' = req+        { requestHeaders =+            ("User-Agent", "sofetch-example/0.1")+          : ("Accept", "application/vnd.github.v3+json")+          : authHeaders ++ requestHeaders req+        }+  Main.liftIO $ putStrLn $ "  [HTTP] GET " <> url+  resp <- Main.liftIO $ httpLbs req' (envManager env)+  let code = statusCode (responseStatus resp)+  if code == 200+    then pure (Just (responseBody resp))+    else do+      Main.liftIO $ putStrLn $ "  [HTTP] " <> show code <> " " <> url+      pure Nothing++-- ══════════════════════════════════════════════+-- DataSource instances+-- ══════════════════════════════════════════════++-- | Each UserLogin key fires a separate HTTP request, but sofetch+-- runs them concurrently (default FetchStrategy = Concurrent) and+-- deduplicates across the computation.+--+-- Uses 'optionalBatchFetch': keys whose HTTP request fails are+-- omitted from the result map. The engine fills them with an error+-- so that 'tryFetch' returns @Left@ and 'fetch' throws, but+-- successful keys are unaffected.+instance DataSource AppM UserLogin where+  batchFetch = optionalBatchFetch $ \(UserLogin login) -> do+    mbs <- githubGet $ "https://api.github.com/users/" <> T.unpack login+    case mbs of+      Nothing -> pure Nothing+      Just bs -> case eitherDecode' bs of+        Right a  -> pure (Just a)+        Left err -> do+          Main.liftIO $ putStrLn $ "  [JSON] decode error: " <> err+          pure Nothing++instance DataSource AppM UserRepos where+  batchFetch = optionalBatchFetch $ \(UserRepos login) -> do+    mbs <- githubGet $ "https://api.github.com/users/" <> T.unpack login <> "/repos?per_page=5&sort=stars"+    case mbs of+      Nothing -> pure Nothing+      Just bs -> case eitherDecode' bs of+        Right a  -> pure (Just a)+        Left err -> do+          Main.liftIO $ putStrLn $ "  [JSON] decode error: " <> err+          pure Nothing++-- ══════════════════════════════════════════════+-- Instrumented runner+-- ══════════════════════════════════════════════++-- | Run a Fetch computation with detailed round-by-round logging.+runFetchIO :: AppEnv -> Fetch AppM a -> IO a+runFetchIO env action = do+  cRef <- newCacheRef+  totalRoundsRef <- newIORef (0 :: Int)+  totalKeysRef   <- newIORef (0 :: Int)+  totalHitsRef   <- newIORef (0 :: Int)++  let e = FetchEnv+        { fetchCache = cRef+        , fetchLower = runAppM env+        , fetchLift  = Main.liftIO+        }++      withRound n batches exec = do+        let pending  = batchSize batches+            sources  = batchSourceCount batches+        Main.liftIO $ putStrLn $ "  ┌─ Round " <> show n+          <> ": " <> show pending <> " key(s) across "+          <> show sources <> " source(s)"++        stats <- exec++        let dispatched = roundKeys stats - roundCacheHits stats+        Main.liftIO $ do+          when (roundCacheHits stats > 0) $+            putStrLn $ "  │  Cache: " <> show (roundCacheHits stats) <> " hit(s)"+          putStrLn $ "  │  Dispatched: " <> show dispatched <> " key(s) to data sources"+          putStrLn $ "  └─ Round " <> show n <> " complete"+          modifyIORef' totalRoundsRef (+ 1)+          modifyIORef' totalKeysRef (+ roundKeys stats)+          modifyIORef' totalHitsRef (+ roundCacheHits stats)++  a <- runAppM env $ runLoopWith e withRound action++  rounds <- readIORef totalRoundsRef+  keys   <- readIORef totalKeysRef+  hits   <- readIORef totalHitsRef+  putStrLn $ "  ── Summary: " <> show rounds <> " round(s), "+    <> show keys <> " key(s), "+    <> show hits <> " cache hit(s)"+  pure a++-- ══════════════════════════════════════════════+-- Scenarios+-- ══════════════════════════════════════════════++header :: String -> String -> IO ()+header num desc = do+  putStrLn ""+  putStrLn $ "━━━ Scenario " <> num <> ": " <> desc <> " ━━━"++showUser :: GitHubUser -> String+showUser u = T.unpack (ghUserLogin u)+  <> " (" <> maybe "?" T.unpack (ghUserName u)+  <> ", " <> show (ghUserPublicRepos u) <> " repos"+  <> ", " <> show (ghUserFollowers u) <> " followers)"++main :: IO ()+main = do+  mgr <- newManager tlsManagerSettings+  mToken <- lookupEnv "GITHUB_TOKEN"+  let env = AppEnv mgr (T.pack <$> mToken)++  putStrLn "sofetch GitHub Explorer"+  putStrLn $ "Auth: " <> maybe "none (60 req/hour limit)" (const "token (5000 req/hour)") mToken++  -- ── Scenario 1: Concurrent fetches ────────────+  header "1" "Concurrent fetches"+  putStrLn "Fetching two users in parallel (one round, two concurrent HTTP requests)."+  (u1, u2) <- runFetchIO env $+    (,) <$> fetch (UserLogin "haskell") <*> fetch (UserLogin "rust-lang")+  putStrLn $ "  => " <> showUser u1+  putStrLn $ "  => " <> showUser u2++  -- ── Scenario 2: Monadic chain ─────────────────+  header "2" "Monadic chain (2 rounds)"+  putStrLn "Fetching a user, then their repos (data dependency forces 2 rounds)."+  (user, repos) <- runFetchIO env $ do+    u <- fetch (UserLogin "haskell")                 -- round 1+    rs <- fetch (UserRepos (ghUserLogin u))          -- round 2+    pure (u, rs)+  putStrLn $ "  => " <> showUser user+  putStrLn $ "  => Top repos: " <> show (map ghRepoName (take 3 repos))++  -- ── Scenario 3: Fan-out ───────────────────────+  header "3" "Fan-out (fetch many, then fan out)"+  putStrLn "Fetching 3 users in round 1, then all their repos in round 2."+  let logins = [UserLogin "haskell", UserLogin "rust-lang", UserLogin "golang"]+  allRepos <- runFetchIO env $ do+    users <- fetchAll logins                                    -- round 1: 3 concurrent HTTP+    fetchAll (map (UserRepos . ghUserLogin) users)              -- round 2: 3 concurrent HTTP+  putStrLn $ "  => Repo counts: " <> show (map length allRepos)++  -- ── Scenario 4: Deduplication + caching ───────+  header "4" "Deduplication + caching"+  putStrLn "Fetching 'haskell' from TWO code paths. Only ONE HTTP request fires."+  putStrLn "Then fetching 'haskell' again in a later round: cache hit, no HTTP."+  result <- runFetchIO env $ do+    -- Both of these want UserLogin "haskell"; deduplicated into one request+    (a, b) <- (,) <$> fetch (UserLogin "haskell") <*> fetch (UserLogin "haskell")+    -- This monadic bind forces a new round, but the cache has the value+    _ <- fetch (UserLogin "haskell")  -- cache hit+    pure (a, b)+  putStrLn $ "  => Got same user twice: " <> show (fst result == snd result)++  -- ── Scenario 5: Error handling ────────────────+  header "5" "Error handling (tryFetch)"+  putStrLn "Fetching a nonexistent user alongside a real one."+  (good, bad) <- runFetchIO env $+    (,) <$> tryFetch (UserLogin "haskell")+        <*> tryFetch (UserLogin "this-user-definitely-does-not-exist-404-sofetch")+  case good of+    Right u -> putStrLn $ "  => Good: " <> showUser u+    Left  e -> putStrLn $ "  => Good failed: " <> show e+  case bad of+    Right u -> putStrLn $ "  => Bad: " <> showUser u+    Left  e -> putStrLn $ "  => Bad (expected): " <> show (e :: SomeException)++  -- ── Scenario 6: Combinators ───────────────────+  header "6" "Combinators (fetchThrough, fetchMap)"+  putStrLn "Using fetchThrough to pair logins with user profiles:"+  let loginTexts = ["haskell", "rust-lang", "golang"] :: [Text]+  paired <- runFetchIO env $+    fetchThrough UserLogin loginTexts+  mapM_ (\(login, u) -> putStrLn $ "  => " <> T.unpack login+    <> " -> " <> maybe "?" T.unpack (ghUserName u)) paired++  putStrLn ""+  putStrLn "Using fetchMap to extract just follower counts:"+  counts <- runFetchIO env $+    fetchMap UserLogin (\login u -> (login, ghUserFollowers u)) loginTexts+  mapM_ (\(login, n) -> putStrLn $ "  => " <> T.unpack login+    <> ": " <> show n <> " followers") counts++  putStrLn ""+  putStrLn "Done!"
+ examples/SqliteBlog.hs view
@@ -0,0 +1,827 @@+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE RankNTypes #-}++-- | A blog platform backed by an in-memory SQLite database.+--+-- This example demonstrates how sofetch turns N+1 query cascades into+-- batched SQL. Run it with:+--+-- @+-- stack run sqlite-blog+-- @+--+-- Each scenario prints the SQL queries that are executed, so you can+-- see exactly how fetches are batched.+module Main (main) where++import Fetch++import Control.Monad (when)+import Control.Monad.Catch (MonadThrow(..), MonadCatch(..))+import Data.IORef+import Data.List (intercalate)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.HashMap.Strict as HM+import qualified Data.List.NonEmpty as NE+import Database.SQLite.Simple+import GHC.Generics (Generic)++-- ══════════════════════════════════════════════+-- Domain types+-- ══════════════════════════════════════════════++data User = User+  { userId   :: !Int+  , userName :: !Text+  } deriving (Show, Eq, Generic)++instance FromRow User where+  fromRow = User <$> field <*> field++data Post = Post+  { postId       :: !Int+  , postAuthorId :: !Int+  , postTitle    :: !Text+  , postBody     :: !Text+  } deriving (Show, Eq, Generic)++instance FromRow Post where+  fromRow = Post <$> field <*> field <*> field <*> field++data Comment = Comment+  { commentId       :: !Int+  , commentPostId   :: !Int+  , commentAuthorId :: !Int+  , commentBody     :: !Text+  } deriving (Show, Eq, Generic)++instance FromRow Comment where+  fromRow = Comment <$> field <*> field <*> field <*> field++-- ══════════════════════════════════════════════+-- FetchKey types+-- ══════════════════════════════════════════════++newtype UserById = UserById Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey UserById where+  type Result UserById = User++newtype PostById = PostById Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey PostById where+  type Result PostById = Post++newtype PostsByAuthor = PostsByAuthor Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey PostsByAuthor where+  type Result PostsByAuthor = [Post]++newtype CommentsByPost = CommentsByPost Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey CommentsByPost where+  type Result CommentsByPost = [Comment]++-- | Count of comments on a post (facet: lightweight aggregate).+newtype CommentCountByPost = CommentCountByPost Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey CommentCountByPost where+  type Result CommentCountByPost = Int++-- | Most recent comment on a post (facet: preview snippet).+newtype LatestCommentByPost = LatestCommentByPost Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey LatestCommentByPost where+  type Result LatestCommentByPost = Maybe Comment++-- ══════════════════════════════════════════════+-- Application monad+-- ══════════════════════════════════════════════++-- | A thin Reader-over-IO carrying a SQLite connection.+-- No MTL — just manual instances, same style as in the test suite.+newtype AppM a = AppM { unAppM :: Connection -> IO a }++instance Functor AppM where+  fmap f (AppM g) = AppM $ \c -> fmap f (g c)++instance Applicative AppM where+  pure a = AppM $ \_ -> pure a+  AppM ff <*> AppM fx = AppM $ \c -> ff c <*> fx c++instance Monad AppM where+  AppM ma >>= f = AppM $ \c -> do+    a <- ma c+    unAppM (f a) c++askConn :: AppM Connection+askConn = AppM pure++liftIO :: IO a -> AppM a+liftIO io = AppM $ \_ -> io++runAppM :: Connection -> AppM a -> IO a+runAppM conn (AppM f) = f conn++-- ══════════════════════════════════════════════+-- DataSource instances (batched SQL)+-- ══════════════════════════════════════════════++-- | Helper: build a SQL IN clause with the right number of placeholders.+inClause :: Int -> Query+inClause n = Query $ "(" <> T.intercalate "," (replicate n "?") <> ")"++instance DataSource AppM UserById where+  batchFetch keysNE = do+    conn <- askConn+    let keys = NE.toList keysNE+        ids  = [i | UserById i <- keys]+        n    = length ids+        sql  = "SELECT id, name FROM users WHERE id IN " <> inClause n+    liftIO $ putStrLn $ "  [SQL] SELECT id, name FROM users WHERE id IN ("+      <> intercalate ", " (map show ids) <> ")"+    rows <- liftIO $ query conn sql ids+    pure $ HM.fromList [(UserById (userId u), u) | u <- rows]++instance DataSource AppM PostById where+  batchFetch keysNE = do+    conn <- askConn+    let keys = NE.toList keysNE+        ids  = [i | PostById i <- keys]+        n    = length ids+        sql  = "SELECT id, author_id, title, body FROM posts WHERE id IN " <> inClause n+    liftIO $ putStrLn $ "  [SQL] SELECT ... FROM posts WHERE id IN ("+      <> intercalate ", " (map show ids) <> ")"+    rows <- liftIO $ query conn sql ids+    pure $ HM.fromList [(PostById (postId p), p) | p <- rows]++instance DataSource AppM PostsByAuthor where+  batchFetch keysNE = do+    conn <- askConn+    let keys = NE.toList keysNE+        ids  = [i | PostsByAuthor i <- keys]+        n    = length ids+        sql  = "SELECT id, author_id, title, body FROM posts WHERE author_id IN " <> inClause n+    liftIO $ putStrLn $ "  [SQL] SELECT ... FROM posts WHERE author_id IN ("+      <> intercalate ", " (map show ids) <> ")"+    rows <- liftIO $ query conn sql ids+    -- Group posts by author_id+    let grouped = HM.fromListWith (++) [(PostsByAuthor (postAuthorId p), [p]) | p <- rows]+    -- Ensure every requested key has an entry (empty list if no posts)+    pure $ HM.union grouped (HM.fromList [(k, []) | k <- keys])++instance DataSource AppM CommentsByPost where+  batchFetch keysNE = do+    conn <- askConn+    let keys = NE.toList keysNE+        ids  = [i | CommentsByPost i <- keys]+        n    = length ids+        sql  = "SELECT id, post_id, author_id, body FROM comments WHERE post_id IN " <> inClause n+    liftIO $ putStrLn $ "  [SQL] SELECT ... FROM comments WHERE post_id IN ("+      <> intercalate ", " (map show ids) <> ")"+    rows <- liftIO $ query conn sql ids+    let grouped = HM.fromListWith (++) [(CommentsByPost (commentPostId c), [c]) | c <- rows]+    pure $ HM.union grouped (HM.fromList [(k, []) | k <- keys])++instance DataSource AppM CommentCountByPost where+  batchFetch keysNE = do+    conn <- askConn+    let keys = NE.toList keysNE+        ids  = [i | CommentCountByPost i <- keys]+        n    = length ids+        sql  = "SELECT post_id, COUNT(*) FROM comments WHERE post_id IN "+            <> inClause n <> " GROUP BY post_id"+    liftIO $ putStrLn $ "  [SQL] SELECT post_id, COUNT(*) FROM comments WHERE post_id IN ("+      <> intercalate ", " (map show ids) <> ") GROUP BY post_id"+    rows <- liftIO $ query conn sql ids :: AppM [(Int, Int)]+    let counts = HM.fromList [(CommentCountByPost pid, cnt) | (pid, cnt) <- rows]+    -- Posts with no comments won't appear in GROUP BY — default to 0+    pure $ HM.union counts (HM.fromList [(k, 0) | k <- keys])++instance DataSource AppM LatestCommentByPost where+  batchFetch keysNE = do+    conn <- askConn+    let keys = NE.toList keysNE+        ids  = [i | LatestCommentByPost i <- keys]+        n    = length ids+        -- Grab all comments for the requested posts, we'll pick the latest per post in Haskell.+        -- (SQLite doesn't have a clean single-query "latest per group" for batched IN.)+        sql  = "SELECT id, post_id, author_id, body FROM comments WHERE post_id IN "+            <> inClause n <> " ORDER BY id DESC"+    liftIO $ putStrLn $ "  [SQL] SELECT ... FROM comments WHERE post_id IN ("+      <> intercalate ", " (map show ids) <> ") ORDER BY id DESC"+    rows <- liftIO $ query conn sql ids+    -- Take the first (latest by id) comment per post+    let byPost = HM.fromListWith (\_ older -> older)+                   [(LatestCommentByPost (commentPostId c), c) | c <- rows]+        withMaybe = HM.map Just byPost+    pure $ HM.union withMaybe (HM.fromList [(k, Nothing) | k <- keys])++-- | A key type that chunks its SQL IN clauses to avoid oversized queries.+-- Same result as UserById, but the DataSource splits large batches.+newtype UserByIdChunked = UserByIdChunked Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey UserByIdChunked where+  type Result UserByIdChunked = User++instance DataSource AppM UserByIdChunked where+  batchFetch keysNE = do+    conn <- askConn+    let keys = NE.toList keysNE+        ids  = [i | UserByIdChunked i <- keys]+        -- Chunk into groups of 50 to keep IN clauses manageable+        chunks = chunksOf 50 ids+    liftIO $ putStrLn $ "  [SQL] Chunking " <> show (length ids)+      <> " keys into " <> show (length chunks) <> " chunk(s) of <= 50"+    results <- mapM (\chunk -> do+      let n = length chunk+          sql = "SELECT id, name FROM users WHERE id IN " <> inClause n+      liftIO $ putStrLn $ "  [SQL]   chunk: SELECT id, name FROM users WHERE id IN (<"+        <> show n <> " ids>)"+      liftIO $ query conn sql chunk+      ) chunks+    pure $ HM.fromList [(UserByIdChunked (userId u), u) | u <- concat results]++-- ══════════════════════════════════════════════+-- Restricted DB monad (no MonadIO)+-- ══════════════════════════════════════════════++-- | A restricted monad mimicking Mercury's DB type.+--+-- Structurally identical to AppM (ReaderT Connection IO), but+-- deliberately has NO MonadIO instance. Arbitrary IO inside+-- database transactions is forbidden at the type level.+--+-- sofetch works perfectly with this pattern: the unsafe nats+-- needed by the engine are private to the module that defines+-- the runner. Application code never sees them.+newtype DB a = DB { unDB :: Connection -> IO a }++instance Functor DB where+  fmap f (DB g) = DB $ \c -> fmap f (g c)++instance Applicative DB where+  pure a = DB $ \_ -> pure a+  DB ff <*> DB fx = DB $ \c -> ff c <*> fx c++instance Monad DB where+  DB ma >>= f = DB $ \c -> do+    a <- ma c+    unDB (f a) c++instance MonadThrow DB where+  throwM e = DB $ \_ -> throwM e++instance MonadCatch DB where+  catch (DB f) handler = DB $ \c ->+    catch (f c) (\e -> unDB (handler e) c)++-- Intentionally NO MonadIO instance. Any code that tries+-- @liftIO@ in DB gets a compile error: "No instance for MonadIO DB".+-- In production (Mercury), a TypeError instance provides a custom+-- message. Here the missing instance suffices.++-- ── Escape hatches (private to your DB module) ──++-- | Lift IO into DB. Not exported from your DB module in production.+-- sofetch's engine needs this internally for cache and IVar operations.+unsafeLiftIODB :: IO a -> DB a+unsafeLiftIODB io = DB $ \_ -> io++-- | Lower DB to IO, given a connection. Also not exported.+unsafeRunDB :: Connection -> DB a -> IO a+unsafeRunDB conn (DB f) = f conn++-- ── DB-internal helpers (like askConn / liftIO for AppM) ──++askConnDB :: DB Connection+askConnDB = DB pure++liftIODB :: IO a -> DB a+liftIODB = unsafeLiftIODB++-- ── DataSource instances for DB ─────────────++instance DataSource DB UserById where+  batchFetch keysNE = do+    conn <- askConnDB+    let keys = NE.toList keysNE+        ids  = [i | UserById i <- keys]+        n    = length ids+        sql  = "SELECT id, name FROM users WHERE id IN " <> inClause n+    liftIODB $ putStrLn $ "  [SQL] SELECT id, name FROM users WHERE id IN ("+      <> intercalate ", " (map show ids) <> ")"+    rows <- liftIODB $ query conn sql ids+    pure $ HM.fromList [(UserById (userId u), u) | u <- rows]++instance DataSource DB PostsByAuthor where+  batchFetch keysNE = do+    conn <- askConnDB+    let keys = NE.toList keysNE+        ids  = [i | PostsByAuthor i <- keys]+        n    = length ids+        sql  = "SELECT id, author_id, title, body FROM posts WHERE author_id IN " <> inClause n+    liftIODB $ putStrLn $ "  [SQL] SELECT ... FROM posts WHERE author_id IN ("+      <> intercalate ", " (map show ids) <> ")"+    rows <- liftIODB $ query conn sql ids+    let grouped = HM.fromListWith (++) [(PostsByAuthor (postAuthorId p), [p]) | p <- rows]+    pure $ HM.union grouped (HM.fromList [(k, []) | k <- keys])++-- ── The SAFE public runner ──────────────────++-- | Run a Fetch computation inside the restricted DB monad.+--+-- This is the ONLY function your module exports. The unsafe nats+-- (unsafeRunDB, unsafeLiftIODB) stay private. Application code+-- writes against @MonadFetch DB n@ and never touches IO.+--+-- This is the pattern from the sofetch docs:+--+-- @+-- fetchInTransaction :: Fetch Transaction a -> Transaction a+-- fetchInTransaction = runFetchIO unsafeRunTransaction unsafeLiftIO+-- @+fetchInDB :: Fetch DB a -> DB a+fetchInDB action = DB $ \conn ->+  unsafeRunDB conn $ runFetch (fetchConfig (unsafeRunDB conn) unsafeLiftIODB) action++-- ══════════════════════════════════════════════+-- Polymorphic data-access functions+-- ══════════════════════════════════════════════++-- ──────────────────────────────────────────────+-- Faceted search result card+-- ──────────────────────────────────────────────++-- | A search result card assembled from multiple independent facets.+-- Each facet is a different data source; sofetch batches them all.+data SearchCard = SearchCard+  { cardTitle        :: !Text+  , cardAuthor       :: !Text+  , cardCommentCount :: !Int+  , cardPreview      :: !(Maybe Text)  -- latest comment body, if any+  } deriving (Show)++-- | Build a search card for a single post. Four independent facets:+--   1. Post title/body    (PostById)+--   2. Author name        (UserById — depends on post's author_id)+--   3. Comment count       (CommentCountByPost)+--   4. Latest comment      (LatestCommentByPost)+--+-- Facets 1, 3, 4 are independent of each other and batch in one round.+-- Facet 2 depends on knowing the author_id from facet 1, adding a second round.+-- When called via 'traverse' across many post IDs, ALL posts' facets+-- merge — so N posts still need at most 2 rounds, not 4*N queries.+buildSearchCard+  :: ( MonadFetch m n+     , DataSource m PostById+     , DataSource m UserById+     , DataSource m CommentCountByPost+     , DataSource m LatestCommentByPost+     )+  => Int -> n SearchCard+buildSearchCard pid = do+  -- Round 1: three independent facets batch together+  (post, count, latest) <-+    (,,) <$> fetch (PostById pid)+         <*> fetch (CommentCountByPost pid)+         <*> fetch (LatestCommentByPost pid)+  -- Round 2: author lookup depends on the post's author_id+  author <- fetch (UserById (postAuthorId post))+  pure SearchCard+    { cardTitle        = postTitle post+    , cardAuthor       = userName author+    , cardCommentCount = count+    , cardPreview      = fmap commentBody latest+    }++-- | Build search cards for a list of post IDs.+-- All cards' facets batch across the entire list.+buildSearchResults+  :: ( MonadFetch m n+     , DataSource m PostById+     , DataSource m UserById+     , DataSource m CommentCountByPost+     , DataSource m LatestCommentByPost+     )+  => [Int] -> n [SearchCard]+buildSearchResults = traverse buildSearchCard++-- ──────────────────────────────────────────────++-- | Fetch a user and their posts. Polymorphic over the fetch monad,+-- so it works with both Fetch (production) and MockFetch (tests).+getUserFeed+  :: ( MonadFetch m n+     , DataSource m UserById+     , DataSource m PostsByAuthor+     )+  => Int -> n (User, [Post])+getUserFeed uid = (,) <$> fetch (UserById uid) <*> fetch (PostsByAuthor uid)++-- ──────────────────────────────────────────────+-- Deep N+1: functions that compose across levels+-- ──────────────────────────────────────────────++-- Each function below is written independently — it doesn't know+-- whether it will be called once or a thousand times. In a normal+-- monadic framework, calling these in a loop creates an N+1 cascade+-- (one query per iteration). With sofetch, traverse uses Applicative+-- so all iterations at the same depth batch into a single round.++-- | Level 3 (leaf): render one comment -> (body, authorName).+-- Fetches the comment's author.+renderComment+  :: (MonadFetch m n, DataSource m UserById)+  => Comment -> n (Text, Text)+renderComment c = do+  author <- fetch (UserById (commentAuthorId c))+  pure (commentBody c, userName author)++-- | Level 2: render a post with all its comments.+-- Fetches comments, then traverses into renderComment.+renderPostWithComments+  :: (MonadFetch m n, DataSource m UserById, DataSource m CommentsByPost)+  => Post -> n (Text, [(Text, Text)])+renderPostWithComments p = do+  comments <- fetch (CommentsByPost (postId p))+  rendered <- traverse renderComment comments+  pure (postTitle p, rendered)++-- | Level 1: render an author's full profile.+-- Fetches user + posts, then traverses into renderPostWithComments.+renderAuthorProfile+  :: ( MonadFetch m n+     , DataSource m UserById+     , DataSource m PostsByAuthor+     , DataSource m CommentsByPost+     )+  => Int -> n (Text, [(Text, [(Text, Text)])])+renderAuthorProfile uid = do+  user  <- fetch (UserById uid)+  posts <- fetch (PostsByAuthor uid)+  renderedPosts <- traverse renderPostWithComments posts+  pure (userName user, renderedPosts)++-- | Top level: render the blog page for multiple authors.+-- Calls renderAuthorProfile for each — traverse batches them.+renderBlogPage+  :: ( MonadFetch m n+     , DataSource m UserById+     , DataSource m PostsByAuthor+     , DataSource m CommentsByPost+     )+  => [Int] -> n [(Text, [(Text, [(Text, Text)])])]+renderBlogPage = traverse renderAuthorProfile++-- ══════════════════════════════════════════════+-- Helpers+-- ══════════════════════════════════════════════++-- | Split a list into chunks of at most n elements.+chunksOf :: Int -> [a] -> [[a]]+chunksOf _ [] = []+chunksOf n xs = let (chunk, rest) = splitAt n xs in chunk : chunksOf n rest++-- ══════════════════════════════════════════════+-- Database setup+-- ══════════════════════════════════════════════++setupDatabase :: Connection -> IO ()+setupDatabase conn = do+  execute_ conn "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"+  execute_ conn "CREATE TABLE posts (id INTEGER PRIMARY KEY, author_id INTEGER NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL)"+  execute_ conn "CREATE TABLE comments (id INTEGER PRIMARY KEY, post_id INTEGER NOT NULL, author_id INTEGER NOT NULL, body TEXT NOT NULL)"++  -- 5 named users + 200 generated users for the chunking demo+  let namedUsers :: [(Int, Text)]+      namedUsers =+        [ (1, "Alice"),   (2, "Bob"),    (3, "Carol")+        , (4, "Dave"),    (5, "Eve")+        ]+      generatedUsers = [(i, "User_" <> T.pack (show i)) | i <- [6..205]]+  mapM_ (\(i, n) -> execute conn "INSERT INTO users VALUES (?, ?)" (i :: Int, n :: Text))+    (namedUsers ++ generatedUsers)++  -- 8 posts across 3 authors+  let posts :: [(Int, Int, Text, Text)]+      posts =+        [ (1, 1, "Getting Started with Haskell", "Haskell is a purely functional language...")+        , (2, 1, "Type Families Explained",      "Type families let you compute types...")+        , (3, 2, "Why I Love Rust",              "Memory safety without GC...")+        , (4, 2, "Async Rust Patterns",          "Tokio makes async Rust ergonomic...")+        , (5, 3, "Intro to Category Theory",     "A category consists of objects and morphisms...")+        , (6, 1, "Monad Transformers in Practice","Stacking monads with transformers...")+        , (7, 3, "Functors Are Everywhere",      "From Maybe to IO, functors are...")+        , (8, 4, "SQLite Tips and Tricks",        "SQLite is surprisingly powerful...")+        ]+  mapM_ (\(i, a, t, b) -> execute conn "INSERT INTO posts VALUES (?, ?, ?, ?)" (i :: Int, a :: Int, t :: Text, b :: Text)) posts++  -- 12 comments, deliberately with overlapping authors across posts+  let comments :: [(Int, Int, Int, Text)]+      comments =+        [ (1,  1, 2, "Great intro!")+        , (2,  1, 3, "I learned a lot from this.")+        , (3,  2, 2, "Type families are tricky but powerful.")+        , (4,  2, 5, "Can you cover associated types next?")+        , (5,  3, 1, "Interesting perspective on Rust.")+        , (6,  3, 4, "How does it compare to Haskell?")+        , (7,  4, 3, "Tokio is amazing.")+        , (8,  5, 2, "Category theory is fascinating!")+        , (9,  5, 4, "The diagrams really help.")+        , (10, 6, 3, "Monad transformers clicked for me here.")+        , (11, 7, 1, "Functors everywhere indeed!")+        , (12, 8, 5, "I use SQLite for everything.")+        ]+  mapM_ (\(i, p, a, b) -> execute conn "INSERT INTO comments VALUES (?, ?, ?, ?)" (i :: Int, p :: Int, a :: Int, b :: Text)) comments++-- ══════════════════════════════════════════════+-- Instrumented runner+-- ══════════════════════════════════════════════++-- | Run a Fetch computation with detailed round-by-round logging.+-- Shows round boundaries, key counts, sources dispatched, and cache hits.+runFetchIO :: Connection -> Fetch AppM a -> IO a+runFetchIO conn action = do+  cRef <- newCacheRef+  totalRoundsRef <- newIORef (0 :: Int)+  totalKeysRef   <- newIORef (0 :: Int)+  totalHitsRef   <- newIORef (0 :: Int)++  let e = FetchEnv+        { fetchCache = cRef+        , fetchLower = runAppM conn+        , fetchLift  = Main.liftIO+        }++      withRound n batches exec = do+        let pending  = batchSize batches+            sources  = batchSourceCount batches+        Main.liftIO $ putStrLn $ "  \x250c\x2500 Round " <> show n+          <> ": " <> show pending <> " key(s) across "+          <> show sources <> " source(s)"++        stats <- exec++        let dispatched = roundKeys stats - roundCacheHits stats+        Main.liftIO $ do+          when (roundCacheHits stats > 0) $+            putStrLn $ "  \x2502  Cache: " <> show (roundCacheHits stats) <> " hit(s)"+          putStrLn $ "  \x2502  Dispatched: " <> show dispatched <> " key(s) to data sources"+          putStrLn $ "  \x2514\x2500 Round " <> show n <> " complete"+          modifyIORef' totalRoundsRef (+ 1)+          modifyIORef' totalKeysRef (+ roundKeys stats)+          modifyIORef' totalHitsRef (+ roundCacheHits stats)++  a <- runAppM conn $ runLoopWith e withRound action++  rounds <- readIORef totalRoundsRef+  keys   <- readIORef totalKeysRef+  hits   <- readIORef totalHitsRef+  putStrLn $ "  \x2500\x2500 Summary: " <> show rounds <> " round(s), "+    <> show keys <> " key(s), "+    <> show hits <> " cache hit(s)"+  pure a++-- | Like 'runFetchIO' but with an externally-provided cache.+-- Useful for sharing cache across multiple computations.+runFetchIOWithCache :: Connection -> CacheRef -> Fetch AppM a -> IO a+runFetchIOWithCache conn cRef action = do+  let e = FetchEnv+        { fetchCache = cRef+        , fetchLower = runAppM conn+        , fetchLift  = Main.liftIO+        }++      withRound n batches exec = do+        let pending  = batchSize batches+            sources  = batchSourceCount batches+        Main.liftIO $ putStrLn $ "  \x250c\x2500 Round " <> show n+          <> ": " <> show pending <> " key(s) across "+          <> show sources <> " source(s)"++        stats <- exec++        let dispatched = roundKeys stats - roundCacheHits stats+        Main.liftIO $ do+          when (roundCacheHits stats > 0) $+            putStrLn $ "  \x2502  Cache: " <> show (roundCacheHits stats) <> " hit(s), skipped"+          when (dispatched > 0) $+            putStrLn $ "  \x2502  Dispatched: " <> show dispatched <> " key(s) to data sources"+          putStrLn $ "  \x2514\x2500 Round " <> show n <> " complete"++  runAppM conn $ runLoopWith e withRound action++-- ══════════════════════════════════════════════+-- Scenarios+-- ══════════════════════════════════════════════++header :: String -> String -> IO ()+header num desc = do+  putStrLn ""+  putStrLn $ "\x2501\x2501\x2501 Scenario " <> num <> ": " <> desc <> " \x2501\x2501\x2501"++main :: IO ()+main = do+  conn <- open ":memory:"+  setupDatabase conn+  putStrLn "Database seeded with 205 users, 8 posts, 12 comments."++  -- ── Scenario 1: Single fetch ──────────────────+  header "1" "Single fetch"+  putStrLn "Fetching a single user by ID."+  user <- runFetchIO conn $ fetch (UserById 1)+  putStrLn $ "  => " <> show user++  -- ── Scenario 2: Applicative batching ──────────+  header "2" "Applicative batching (two sources, one round)"+  putStrLn "Fetching a user AND their posts in one round (two different sources)."+  (u, ps) <- runFetchIO conn $+    (,) <$> fetch (UserById 1) <*> fetch (PostsByAuthor 1)+  putStrLn $ "  => User: " <> show (userName u)+  putStrLn $ "  => Posts: " <> show (map postTitle ps)++  -- ── Scenario 3: Monadic dependency ────────────+  header "3" "Monadic dependency (2 rounds)"+  putStrLn "Fetching a post, THEN its author (data dependency forces 2 rounds)."+  (post, author) <- runFetchIO conn $ do+    p <- fetch (PostById 3)                   -- round 1+    a <- fetch (UserById (postAuthorId p))    -- round 2 (depends on post)+    pure (p, a)+  putStrLn $ "  => Post: \"" <> T.unpack (postTitle post)+    <> "\" by " <> T.unpack (userName author)++  -- ── Scenario 4: N+1 avoidance ────────────────+  header "4" "N+1 avoidance"+  putStrLn "Fetching Alice's posts, then ALL comments for those posts in ONE batch."+  putStrLn "(Without sofetch, this would be N separate queries.)"+  allComments <- runFetchIO conn $ do+    posts <- fetch (PostsByAuthor 1)                         -- round 1+    fetchAll (map (CommentsByPost . postId) posts)           -- round 2: ONE batch+  putStrLn $ "  => Comment counts per post: " <> show (map length allComments)++  -- ── Scenario 5: Deduplication ────────────────+  header "5" "Deduplication"+  putStrLn "Fetching comments on posts 1 and 2. Bob (id=2) commented on both."+  putStrLn "His user record should only be fetched ONCE."+  authors <- runFetchIO conn $ do+    -- Round 1: both comment fetches batch into ONE SQL query+    (c1, c2) <- (,) <$> fetch (CommentsByPost 1) <*> fetch (CommentsByPost 2)+    -- Round 2: author IDs are deduplicated — Bob only fetched once+    let authorIds = map commentAuthorId (c1 ++ c2)+    liftSource $ Main.liftIO $ putStrLn $ "  \x2502  Raw author IDs: " <> show authorIds+      <> " (4 refs, but Bob=2 appears twice)"+    fetchAll (map UserById authorIds)+  putStrLn $ "  => Authors: " <> show (map userName authors)++  -- ── Scenario 6: Combinators ──────────────────+  header "6" "Combinators (fetchThrough, fetchMap)"+  putStrLn "Using fetchThrough to enrich comments with their authors:"+  enriched <- runFetchIO conn $ do+    comments <- fetch (CommentsByPost 1)+    fetchThrough (UserById . commentAuthorId) comments+  mapM_ (\(c, author') -> putStrLn $ "  => " <> T.unpack (commentBody c)+    <> " -- " <> T.unpack (userName author')) enriched++  putStrLn ""+  putStrLn "Using fetchMap to get (title, authorName) from post IDs:"+  results <- runFetchIO conn $+    fetchMap PostById (\_ p -> (postTitle p, postAuthorId p)) [1, 3, 5]+  mapM_ (\(title, aid) -> putStrLn $ "  => \"" <> T.unpack title+    <> "\" (author_id=" <> show aid <> ")") results++  -- ── Scenario 7: Cache hits in action ──────────+  header "7" "Cache hits in action"+  putStrLn "Sharing a cache across TWO separate computations."+  putStrLn "The second computation benefits from the first's cache."+  cRef <- newCacheRef+  -- First computation: fills the cache+  putStrLn ""+  putStrLn "  First computation (cold cache) -- requesting UserById 1, 2:"+  _ <- runFetchIOWithCache conn cRef $+    (,) <$> fetch (UserById 1) <*> fetch (UserById 2)+  -- Second computation: same cache+  putStrLn ""+  putStrLn "  Second computation (warm cache) -- requesting UserById 1, 2, 3:"+  putStrLn "  Users 1 and 2 resolve instantly from cache. Only user 3 hits SQL."+  _ <- runFetchIOWithCache conn cRef $+    (,,) <$> fetch (UserById 1) <*> fetch (UserById 2) <*> fetch (UserById 3)+  pure ()++  -- ── Scenario 8: MockFetch ────────────────────+  header "8" "MockFetch (same code, no database)"+  putStrLn "Running getUserFeed against REAL SQLite:"+  realResult <- runFetchIO conn $ getUserFeed 1+  putStrLn $ "  => " <> show (userName (fst realResult))+    <> ", " <> show (length (snd realResult)) <> " posts"++  putStrLn ""+  putStrLn "Running the EXACT SAME function against MockFetch (canned data):"+  let mockUser = User 1 "Mock Alice"+      mockPosts = [Post 99 1 "Mock Post" "This is fake data"]+      mocks = mockData @UserById [(UserById 1, mockUser)]+           <> mockData @PostsByAuthor [(PostsByAuthor 1, mockPosts)]+  mockResult <- runMockFetch @AppM mocks (getUserFeed 1)+  putStrLn $ "  => " <> show (userName (fst mockResult))+    <> ", " <> show (length (snd mockResult)) <> " posts"++  -- ── Scenario 9: Deep N+1 across function boundaries ──+  header "9" "Deep N+1 across function boundaries"+  putStrLn "renderBlogPage calls renderAuthorProfile for 3 authors,"+  putStrLn "which calls renderPostWithComments for each post,"+  putStrLn "which calls renderComment for each comment."+  putStrLn ""+  putStrLn "In a for-loop world this would be dozens of queries."+  putStrLn "With sofetch, each depth level batches into ONE round:"+  putStrLn ""+  profiles <- runFetchIO conn $ renderBlogPage [1, 2, 3]+  putStrLn ""+  putStrLn "  Results:"+  mapM_ (\(authorName', postSummaries) -> do+    putStrLn $ "  " <> T.unpack authorName' <> ":"+    mapM_ (\(title, commentSummaries) -> do+      putStrLn $ "    \"" <> T.unpack title <> "\" ("+        <> show (length commentSummaries) <> " comments)"+      ) postSummaries+    ) profiles++  -- ── Scenario 10: Chunked batching for large key sets ──+  header "10" "Chunked batching for large key sets"+  putStrLn "Fetching 200 users at once. The UserByIdChunked data source"+  putStrLn "splits the IN clause into chunks of 50 to avoid oversized SQL."+  putStrLn ""+  users200 <- runFetchIO conn $+    fetchAll (map UserByIdChunked [1..200])+  putStrLn $ "  => Fetched " <> show (length users200) <> " users"+  putStrLn $ "  => First: " <> show (take 1 users200)+  putStrLn $ "  => Last:  " <> show (take 1 (reverse users200))++  -- ── Scenario 11: Faceted queries ────────────────+  header "11" "Faceted queries (search result cards)"+  putStrLn "Building search result cards for 5 posts. Each card needs 4 facets:"+  putStrLn "  - post title/body  (PostById)"+  putStrLn "  - author name      (UserById, depends on post)"+  putStrLn "  - comment count    (CommentCountByPost)"+  putStrLn "  - latest comment   (LatestCommentByPost)"+  putStrLn ""+  putStrLn "For 5 posts, that's 20 potential queries. With sofetch:"+  putStrLn "  Round 1: all PostById + CommentCountByPost + LatestCommentByPost"+  putStrLn "  Round 2: all UserById (depends on author_id from round 1)"+  putStrLn ""+  cards <- runFetchIO conn $ buildSearchResults [1, 2, 3, 5, 8]+  putStrLn ""+  putStrLn "  Results:"+  mapM_ (\c -> putStrLn $ "  \"" <> T.unpack (cardTitle c) <> "\""+    <> " by " <> T.unpack (cardAuthor c)+    <> " (" <> show (cardCommentCount c) <> " comments)"+    <> maybe "" (\p -> " -- latest: \"" <> T.unpack p <> "\"") (cardPreview c)+    ) cards++  -- ── Scenario 12: Restricted DB monad (no MonadIO) ──+  header "12" "Restricted DB monad (no MonadIO)"+  putStrLn "The DB monad has NO MonadIO instance — arbitrary IO inside"+  putStrLn "database transactions is a compile-time error."+  putStrLn ""+  putStrLn "sofetch works via fetchInDB, which hides the unsafe nats."+  putStrLn "The same polymorphic getUserFeed function works in DB:"+  putStrLn ""++  -- getUserFeed is polymorphic: (MonadFetch m n, DataSource m UserById, ...)+  -- It works with both AppM (via runFetchIO) and DB (via fetchInDB).+  let runDB :: DB a -> IO a+      runDB act = unsafeRunDB conn act++  dbResult <- runDB $ fetchInDB $ getUserFeed 1+  putStrLn $ "  => User: " <> T.unpack (userName (fst dbResult))+  putStrLn $ "  => Posts: " <> show (length (snd dbResult))++  -- Prove the TypeError works by showing the error message.+  -- (Uncomment the line below to see the compile-time error:)+  -- _ <- runDB $ Control.Monad.IO.Class.liftIO (putStrLn "this won't compile")++  close conn+  putStrLn ""+  putStrLn "Done!"
+ sofetch.cabal view
@@ -0,0 +1,141 @@+cabal-version: 2.2++-- This file has been generated from package.yaml by hpack version 0.38.1.+--+-- see: https://github.com/sol/hpack++name:           sofetch+version:        0.1.0.0+description:    Please see the README on GitHub at <https://github.com/githubuser/sofetch#readme>+homepage:       https://github.com/iand675/sofetch#readme+bug-reports:    https://github.com/iand675/sofetch/issues+author:         Author name here+maintainer:     example@example.com+copyright:      2026 Author name here+license:        BSD-3-Clause+license-file:   LICENSE+build-type:     Simple+extra-source-files:+    README.md+    CHANGELOG.md++source-repository head+  type: git+  location: https://github.com/iand675/sofetch++flag examples+  description: Build example executables+  manual: True+  default: False++library+  exposed-modules:+      Fetch+      Fetch.Batched+      Fetch.Cache+      Fetch.Class+      Fetch.Combinators+      Fetch.Deriving+      Fetch.Engine+      Fetch.IVar+      Fetch.Memo+      Fetch.Mock+      Fetch.Mutate+      Fetch.Traced+  other-modules:+      Paths_sofetch+  autogen-modules:+      Paths_sofetch+  hs-source-dirs:+      src+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints+  build-depends:+      async+    , base >=4.7 && <5+    , containers+    , exceptions+    , hashable+    , semigroupoids+    , text+    , time+    , transformers+    , unliftio-core+    , unordered-containers+  default-language: Haskell2010++executable github-explorer+  main-is: GitHubExplorer.hs+  hs-source-dirs:+      examples+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded+  build-depends:+      aeson+    , async+    , base >=4.7 && <5+    , bytestring+    , containers+    , exceptions+    , hashable+    , http-client+    , http-client-tls+    , http-types+    , semigroupoids+    , sofetch+    , text+    , time+    , transformers+    , unliftio-core+    , unordered-containers+  default-language: Haskell2010+  if !flag(examples)+    buildable: False++executable sqlite-blog+  main-is: SqliteBlog.hs+  hs-source-dirs:+      examples+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded+  build-depends:+      async+    , base >=4.7 && <5+    , bytestring+    , containers+    , exceptions+    , hashable+    , semigroupoids+    , sofetch+    , sqlite-simple+    , text+    , time+    , transformers+    , unliftio-core+    , unordered-containers+  default-language: Haskell2010+  if !flag(examples)+    buildable: False++test-suite sofetch-test+  type: exitcode-stdio-1.0+  main-is: Spec.hs+  other-modules:+      Paths_sofetch+  autogen-modules:+      Paths_sofetch+  hs-source-dirs:+      test+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N+  build-depends:+      async+    , base >=4.7 && <5+    , containers+    , exceptions+    , hashable+    , hspec+    , semigroupoids+    , sofetch+    , text+    , time+    , transformers+    , unliftio-core+    , unordered-containers+  default-language: Haskell2010
+ src/Fetch.hs view
@@ -0,0 +1,456 @@+{-# LANGUAGE TypeFamilies #-}++-- | Automatic batching and deduplication of concurrent data fetches.+--+-- = Background+--+-- Any service that assembles responses from multiple data sources runs into+-- the same problem: you write sequential-looking code, but the access+-- pattern it produces is terrible. Fetching a user, then their posts, then+-- the author of each post, produces a cascade of round trips: the classic+-- N+1 query problem, generalised across arbitrary backends.+--+-- Facebook's [Haxl](https://github.com/facebook/Haxl) library (Marlow et al.,+-- /\"There is no Fork: an Abstraction for Efficient, Concurrent, and Concise+-- Data Access\"/, ICFP 2014) solved this by exploiting @Applicative@ to+-- detect independent data fetches and batch them into a single round.+-- Code that /looks/ sequential gets automatically optimised into concurrent,+-- batched requests, with request deduplication and caching for free.+--+-- This library keeps Haxl's core idea while simplifying the machinery+-- required to use it:+--+--   * __No GADTs in user code.__ Haxl encodes the request\/response type+--     pairing with a GADT indexed by the result type. Here, an associated+--     type family ('Result') on an ordinary typeclass ('FetchKey') does the+--     same job. Your key types derive 'Eq', 'Hashable', and 'Show' with+--     stock @deriving@.+--+--   * __Data sources run in your monad.__ 'DataSource' is parameterised by+--     a monad @m@, not a concrete environment type. If your data source+--     needs a connection pool, @m@ should be a monad with access to one+--     (e.g. via 'MonadReader'). If the instance doesn't exist, code that+--     tries to @fetch@ a key won't compile. No runtime "missing config"+--     errors.+--+--   * __Monad transformer, not a concrete monad.__ Haxl's @GenHaxl u w@ is+--     a fixed monad. 'Fetch' is a transformer over your source monad @m@.+--     Two natural transformations (@m x -> IO x@ and @IO x -> m x@) provided+--     at the run site bridge the source monad with IO for internal+--     concurrency and caching.+--+--   * __Swappable implementations via 'MonadFetch'.__ Production code,+--     traced instrumentation, and pure mock testing all share the same+--     interface. Application functions are polymorphic over the implementation.+--+-- = How batching works+--+-- 'Fetch' has an 'Applicative' instance that /merges/ the pending fetches+-- from both sides of @\<*\>@ into one round, and a 'Monad' instance where+-- @>>=@ is a round boundary (the right side can't run until the left side's+-- results are available).+--+-- With @ApplicativeDo@ enabled, GHC desugars @do@-blocks into 'Applicative'+-- combinators wherever the data dependencies allow it. Two @fetch@ calls+-- whose results are independent of each other will be combined into a+-- single batch, even though they appear on separate lines:+--+-- @+-- {-# LANGUAGE ApplicativeDo #-}+--+-- getUserFeed :: (MonadFetch m n, DataSource m UserId, DataSource m PostsByAuthor)+--             => UserId -> n Feed+-- getUserFeed uid = do+--   user  <- fetch uid                  -- ─┐+--   posts <- fetch (PostsByAuthor uid)  -- ─┤ same round+--   pure (Feed user posts)+-- @+--+-- If a later fetch /depends/ on an earlier result, @>>=@ forces a round+-- boundary and the fetches run in sequence:+--+-- @+-- getUserThenManager :: (MonadFetch m n, DataSource m UserId)+--                    => UserId -> n (User, User)+-- getUserThenManager uid = do+--   user    <- fetch uid                   -- round 1+--   manager <- fetch (managerId user)      -- round 2 (depends on user)+--   pure (user, manager)+-- @+--+-- Within each round, keys destined for the same data source are grouped and+-- passed to 'batchFetch' together. Keys for /different/ sources run+-- concurrently by default (see 'FetchStrategy'). Duplicate keys are+-- deduplicated across the entire computation.+--+-- = Tutorial+--+-- == Step 1: Define your key types+--+-- Each type of data you want to fetch gets its own key type with a+-- 'FetchKey' instance that declares the result type:+--+-- @+-- newtype UserId = UserId Int+--   deriving (Eq, Hashable, Show)+--+-- instance FetchKey UserId where+--   type Result UserId = User+--+-- newtype PostsByAuthor = PostsByAuthor Int+--   deriving (Eq, Hashable, Show)+--+-- instance FetchKey PostsByAuthor where+--   type Result PostsByAuthor = [Post]+-- @+--+-- Each key type maps to exactly one result type. Separate types per query+-- give you stock @deriving@, first-class 'Data.HashMap.Strict.HashMap' keys, and+-- precise constraints: a function's type signature advertises exactly+-- which data sources it touches.+--+-- == Step 2: Define your data sources+--+-- A 'DataSource' instance tells the engine how to batch-fetch a list of+-- keys. The monad @m@ provides any resources the source needs:+--+-- @+-- data AppEnv = AppEnv+--   { appPool  :: ConnectionPool+--   , appRedis :: RedisConn+--   }+--+-- -- AppM is a ReaderT-like monad carrying the environment.+-- newtype AppM a = AppM (ReaderT AppEnv IO a)+--+-- instance DataSource AppM UserId where+--   batchFetch ids = do+--     pool <- asks appPool+--     liftIO $ withResource pool $ \\conn -> do+--       rows <- query conn \"SELECT id, name FROM users WHERE id = ANY(?)\" (Only ids)+--       pure (HM.fromList [(UserId i, User i n) | (i, n) <- rows])+--+-- instance DataSource AppM PostsByAuthor where+--   batchFetch ks = do+--     pool <- asks appPool+--     liftIO $ withResource pool $ \\conn -> do+--       let authorIds = [aid | PostsByAuthor aid <- ks]+--       rows <- query conn \"SELECT author_id, id, body FROM posts WHERE author_id = ANY(?)\" (Only authorIds)+--       let grouped = HM.fromListWith (<>) [(PostsByAuthor aid, [Post pid body]) | (aid, pid, body) <- rows]+--       pure grouped+-- @+--+-- The return type is @'Data.HashMap.Strict.HashMap' k ('Result' k)@: you must return+-- a result for every key you were given. The engine handles concurrency,+-- caching, and error wrapping around your function.+--+-- If the @DataSource AppM SomeKey@ instance doesn't exist, any code that+-- tries to @fetch@ a @SomeKey@ will fail to compile. There are no runtime+-- \"missing config\" errors.+--+-- == Step 3: Write data-access code+--+-- Program against the 'MonadFetch' constraint. Don't commit to a specific+-- implementation. This is what makes the same code runnable in production+-- and in tests:+--+-- @+-- {-# LANGUAGE ApplicativeDo #-}+--+-- getUserFeed :: (MonadFetch m n, DataSource m UserId, DataSource m PostsByAuthor)+--             => UserId -> n Feed+-- getUserFeed uid = do+--   user  <- fetch uid+--   posts <- fetch (PostsByAuthor uid)+--   pure (Feed user posts)+-- @+--+-- For fetching across collections, use the provided combinators to preserve+-- the container shape without manual destructure\/reconstruct cycles:+--+-- @+-- enrichComments :: (MonadFetch m n, DataSource m CommentAuthor)+--                => [Comment] -> n [(Comment, User)]+-- enrichComments = fetchThrough commentAuthor+-- @+--+-- == Step 4: Run it+--+-- In production, use 'runFetch' with two natural transformations: one+-- to lower @m@ to @IO@, and one to lift @IO@ into @m@:+--+-- @+-- handleRequest :: AppEnv -> UserId -> IO Feed+-- handleRequest env uid =+--   runAppM env $ runFetch (runAppM env) liftIO (getUserFeed uid)+-- @+--+-- For monads that deliberately avoid 'MonadIO' (e.g. a @Transaction@+-- type), export a convenience runner that hides the unsafe nats:+--+-- @+-- fetchInTransaction :: Fetch Transaction a -> Transaction a+-- fetchInTransaction = runFetch unsafeRunTransaction unsafeLiftIO+-- @+--+-- == Step 5: Test it+--+-- Use 'MockFetch' to run the same code against canned data, with no IO,+-- no database, and no cache:+--+-- @+-- testGetUserFeed :: IO ()+-- testGetUserFeed = do+--   let mocks = mockData \@UserId      [(UserId 1, testUser)]+--            <> mockData \@PostsByAuthor [(PostsByAuthor 1, [testPost])]+--   feed <- runMockFetch \@AppM mocks (getUserFeed (UserId 1))+--   assertEqual (feedUser feed) testUser+-- @+--+-- Because @getUserFeed@ is polymorphic in @n@, no code changes are needed+-- to swap between 'Fetch' (production) and 'MockFetch' (tests).+--+-- = Error handling+--+-- If 'batchFetch' throws for a subset of keys, the engine fills unfilled+-- entries with the exception. Callers using 'fetch' see the exception+-- re-thrown; callers using 'tryFetch' receive @Left SomeException@.+-- Failures for one key do not affect other keys in the same batch.+--+-- All monad transformers ('Fetch', 'TracedFetch', 'Mutate',+-- 'MockFetch', 'MockMutate') provide @MonadThrow@ and @MonadCatch@+-- instances from the @exceptions@ package. The 'MonadCatch' instance+-- on 'Fetch' propagates the handler through 'Blocked' continuations,+-- so a @catch@ wrapping a multi-round computation catches exceptions+-- thrown in any round, not just the initial probe.+--+-- @+-- import "Control.Monad.Catch" ('Control.Monad.Catch.catch', 'Control.Monad.Catch.throwM')+--+-- safeFetch :: ('MonadFetch' m n, 'Control.Monad.Catch.MonadCatch' n, DataSource m k, Typeable (Result k))+--           => k -> Result k -> n (Result k)+-- safeFetch k fallback =+--   'Control.Monad.Catch.catch' (fetch k) (\\(_ :: SomeException) -> pure fallback)+-- @+--+-- @MonadMask@ is intentionally not provided: async exception masking+-- across batch round boundaries is not well-defined.+--+-- = Further reading+--+--   * 'FetchStrategy': control whether a source runs concurrently,+--     sequentially, or with eager start.+--   * 'CachePolicy': opt out of caching for mutation-like sources.+--   * 'TracedFetch': round-by-round observability hooks.+--   * 'runLoopWith': build custom instrumented runners (e.g. for+--     OpenTelemetry) by wrapping around each batch round.+--   * 'MemoStore': cache derived computations (not just raw fetches).+--   * The @docs/DESIGN.md@ in the repository covers the full set of design+--     decisions and tradeoffs relative to Haxl.+module Fetch+  ( -- * Defining data sources+    -- | Start here. A 'FetchKey' pairs a key type with its result type;+    -- a 'DataSource' teaches the engine how to batch-fetch those keys.+    FetchKey(..)+  , DataSource(..)+  , FetchStrategy(..)+  , CachePolicy(..)++    -- * Fetching data+    -- | The interface your application code programs against.+    -- Use 'fetch' to request a single key, 'tryFetch' for explicit error+    -- handling, and the combinators below for collections.+  , MonadFetch(..)+  , fetchAll+  , fetchWith+  , fetchThrough+  , fetchMap+  , fetchMaybe+  , fetchMapWith++    -- * Running+    -- | Execute a 'MonadFetch' computation via 'FetchConfig'.+  , Fetch+  , FetchConfig(..)+  , fetchConfig+  , fetchConfigIO+  , liftSource+  , runFetch+  , runFetch'++    -- * Testing+    -- | Swap 'Fetch' for 'MockFetch' to run the same polymorphic code+    -- against canned data: no IO, no database, no cache.+  , MockFetch+  , runMockFetch+  , ResultMap+  , mockData+  , emptyMockData++    -- * Mutations+    -- | Mutations model write operations: creating a row, publishing a+    -- message, calling a side-effecting RPC. Unlike fetches, mutations are+    -- never batched, deduplicated, or cached: each 'mutate' call executes+    -- exactly once, in order.+    --+    -- 'Mutate' layers on top of 'Fetch'. A computation alternates between+    -- __fetch phases__ (where reads batch normally via 'Applicative') and+    -- __mutation steps__ (where writes run sequentially). After each+    -- mutation, 'reconcileCache' lets you evict stale entries or warm+    -- fresh data so that subsequent fetches see the updated state.+    --+    -- __Caveat:__ by mixing reads and writes in the same computation, you+    -- take on the responsibility of keeping the fetch cache coherent.+    -- The engine cannot know which cached entries a mutation invalidates;+    -- that is domain knowledge only you have. If you forget to evict or+    -- re-warm a stale entry in 'reconcileCache', subsequent fetches will+    -- silently return the old value. For many applications the simpler+    -- approach is to keep mutations in plain @IO@ and use 'Fetch' only+    -- for the read path; 'Mutate' is there for cases where interleaved+    -- read-after-write within a single computation is genuinely needed.++    -- ** Defining mutations+  , MutationKey(..)+  , MutationSource(..)++    -- ** Running mutations+  , MonadMutate(..)+  , Mutate+  , runMutate+  , liftFetch++    -- ** Testing mutations+  , MockMutate+  , runMockMutate+  , MutationHandlers+  , mockMutation+  , emptyMutationHandlers+  , RecordedMutation(..)++    -- * Cache management+    -- | Most users never touch the cache directly; the engine manages it.+    -- These are useful for pre-warming from an external store, selective+    -- eviction after mutations, or sharing a cache across sequential phases.+  , CacheRef+  , newCacheRef+  , CacheLookup(..)+  , cacheLookup+  , cacheInsert+  , cacheInsertError+  , cacheEvict+  , cacheEvictSource+  , cacheEvictWhere+  , cacheWarm+  , cacheContents++    -- * Tracing and observability+    -- | 'TracedFetch' is a turnkey wrapper with per-round callbacks.+    -- For richer instrumentation (e.g. OpenTelemetry spans), build a+    -- custom runner using the extension API below.+  , TracedFetch+  , TraceConfig(..)+  , defaultTraceConfig+  , FetchStats(..)+  , runTracedFetch++    -- * Memoization+    -- | Cache derived computations (not just raw fetches) within a request.+  , MemoKey(..)+  , MemoStore+  , newMemoStore+  , memo+  , memoOn++    -- * Errors+  , FetchError(..)++    -- * Extension API+    -- | Building blocks for custom runners and instrumentation.+    -- Application code does not need anything from this section.+    --+    -- The simplest way to add instrumentation is 'runLoopWith', which+    -- lets you wrap each batch round with before\/after logic (e.g.+    -- opening and closing a tracing span):+    --+    -- @+    -- import Fetch.Batched ('FetchEnv'(..), 'runLoopWith')+    -- import Fetch.Engine  ('RoundStats'(..))+    --+    -- myInstrumentedRunner :: Monad m+    --                      => (forall x. m x -> IO x)+    --                      -> (forall x. IO x -> m x)+    --                      -> Fetch m a -> m a+    -- myInstrumentedRunner lower lift action = do+    --   cRef <- lift 'newCacheRef'+    --   let e = 'FetchEnv' cRef lower lift+    --   'runLoopWith' e (\\n batches exec -> do+    --       -- before round+    --       stats <- exec+    --       -- after round, stats :: 'RoundStats'+    --       pure ()+    --     ) action+    -- @+    --+    -- For full control (e.g. running entirely in @IO@ with a single+    -- @lift@ at the boundary), use 'Fetch'\'s constructor, 'FetchEnv',+    -- and 'executeBatches' directly.++    -- ** Loop helpers+  , FetchEnv(..)+  , runLoop+  , runLoopWith+  , RoundStats(..)+  , emptyRoundStats++    -- ** Batch inspection+  , MonadFetchBatch(..)+  , Status(..)+  , Batches(..)+  , batchSize+  , batchSourceCount++    -- ** Engine+  , executeBatches++    -- * Instance helpers+    -- | Combinators for implementing 'DataSource' from simpler primitives.+    -- See also 'fetchOne' (a default method on 'DataSource') and the+    -- "Fetch.Deriving" module for DerivingVia patterns.+  , optionalBatchFetch+  , traverseBatchFetch++    -- * Re-exports+  , Typeable+  , Hashable+  , NonEmpty(..)+  , Proxy(..)+  ) where++import Fetch.Class+import Fetch.Batched+  ( Fetch, FetchConfig(..), fetchConfig, fetchConfigIO, FetchEnv(..), liftSource+  , runFetch, runFetch'+  , runLoop, runLoopWith+  )+import Fetch.Cache+  ( CacheRef, newCacheRef+  , CacheLookup(..), cacheLookup+  , cacheInsert, cacheInsertError+  , cacheEvict, cacheEvictSource, cacheEvictWhere+  , cacheWarm, cacheContents+  )+import Fetch.Combinators+import Fetch.Deriving (optionalBatchFetch, traverseBatchFetch)+import Fetch.Engine (RoundStats(..), emptyRoundStats, executeBatches)+import Fetch.Mutate+  ( MutationSource(..), MonadMutate(..)+  , Mutate, runMutate, liftFetch+  )+import Fetch.Mock+  ( MockFetch, runMockFetch, ResultMap, mockData, emptyMockData+  , MockMutate, runMockMutate, MutationHandlers, mockMutation+  , emptyMutationHandlers, RecordedMutation(..)+  )+import Fetch.Traced (TracedFetch, TraceConfig(..), defaultTraceConfig, FetchStats(..), runTracedFetch)+import Fetch.Memo (MemoKey(..), MemoStore, newMemoStore, memo, memoOn)+import Fetch.IVar (FetchError(..))
+ src/Fetch/Batched.hs view
@@ -0,0 +1,456 @@+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE BangPatterns #-}++module Fetch.Batched+  ( Fetch(..)+  , FetchConfig(..)+  , fetchConfig+  , fetchConfigIO+  , FetchEnv(..)+  , liftSource+  , runFetch+  , runFetch'+  , runLoop+  , runLoopWith+  ) where++import Fetch.Class+import Fetch.Cache+import Fetch.IVar+import Fetch.Engine++import Control.Exception (throwIO, toException)+import Control.Monad.Catch (MonadThrow(..), MonadCatch(..))+import Control.Monad.IO.Unlift (MonadUnliftIO(..), liftIO)+import Data.Functor.Apply (Apply(..))+import Data.Functor.Bind (Bind(..))+import qualified Data.HashMap.Strict as HM++-- | The environment threaded through Fetch.+--+-- Contains the cache and the two natural transformations that+-- bridge the source monad @m@ with @IO@.+data FetchEnv m = FetchEnv+  { fetchCache :: !CacheRef+  , fetchLower :: !(forall x. m x -> IO x)+    -- ^ Run an @m@ action in @IO@. Used by the engine to+    -- dispatch @batchFetch@ calls.+  , fetchLift  :: !(forall x. IO x -> m x)+    -- ^ Lift an @IO@ action into @m@. Used for cache operations+    -- and IVar interactions within @m@.+  }++-- | The core monad transformer. Supports Applicative batching:+-- independent fetches in @\<*\>@ merge into a single round,+-- while @>>=@ introduces a round boundary.+--+-- The @m@ parameter is the /source monad/: the monad that+-- 'DataSource' implementations run in.+--+-- Enable @ApplicativeDo@ for ergonomic batching in do-blocks.+newtype Fetch m a = Fetch+  { unFetch :: FetchEnv m -> m (Status m (Fetch m) a) }++-- | Lift a source-monad action into 'Fetch'.+liftSource :: Monad m => m a -> Fetch m a+liftSource ma = Fetch $ \_ -> Done <$> ma++instance Functor m => Functor (Fetch m) where+  fmap f (Fetch g) = Fetch $ \e -> fmap (fmap f) (g e)++instance Monad m => Applicative (Fetch m) where+  pure a = Fetch $ \_ -> pure (Done a)++  Fetch ff <*> Fetch fx = Fetch $ \e -> do+    sf <- ff e+    sx <- fx e+    pure $ case (sf, sx) of+      (Done f, Done x) ->+        Done (f x)+      (Done f, Blocked bs kx) ->+        Blocked bs (fmap f kx)+      (Blocked bs kf, Done x) ->+        Blocked bs (fmap ($ x) kf)+      (Blocked bs1 kf, Blocked bs2 kx) ->+        Blocked (bs1 <> bs2) (kf <*> kx)++instance Monad m => Monad (Fetch m) where+  Fetch ma >>= f = Fetch $ \e -> do+    sa <- ma e+    case sa of+      Done a       -> unFetch (f a) e+      Blocked bs k -> pure $ Blocked bs (k >>= f)++instance MonadFail m => MonadFail (Fetch m) where+  fail = liftSource . fail++-- ──────────────────────────────────────────────+-- MonadThrow / MonadCatch+-- ──────────────────────────────────────────────++instance MonadThrow m => MonadThrow (Fetch m) where+  throwM = liftSource . throwM++-- | Propagates the handler through 'Blocked' continuations so that+-- a @catch@ wrapping a multi-round computation catches exceptions+-- thrown in any round, not just the initial probe.+instance MonadCatch m => MonadCatch (Fetch m) where+  catch (Fetch f) handler = Fetch $ \e -> do+    status <- catch (f e) (\ex -> unFetch (handler ex) e)+    case status of+      Done a       -> pure (Done a)+      Blocked bs k -> pure (Blocked bs (catch k handler))++-- ──────────────────────────────────────────────+-- Semigroup / Monoid (lifted)+-- ──────────────────────────────────────────────++-- | Combines two fetches applicatively, batching their pending keys.+--+-- @a <> b = liftA2 (<>) a b@+instance (Monad m, Semigroup a) => Semigroup (Fetch m a) where+  (<>) = liftA2 (<>)++-- | @mempty = pure mempty@.+instance (Monad m, Monoid a) => Monoid (Fetch m a) where+  mempty = pure mempty++-- ──────────────────────────────────────────────+-- Semigroupoids (Apply / Bind)+-- ──────────────────────────────────────────────++-- | 'Apply' is 'Applicative' without 'pure'. Same batching semantics.+instance Monad m => Apply (Fetch m) where+  (<.>) = (<*>)++-- | 'Bind' is 'Monad' without 'return'. Same round-boundary semantics.+instance Monad m => Bind (Fetch m) where+  (>>-) = (>>=)++-- ──────────────────────────────────────────────+-- Instances that are NOT provided (and why)+-- ──────────────────────────────────────────────++-- MonadTrans / MonadIO:+--   Intentionally omitted. @lift@ / @liftIO@ would be equivalent to+--   'liftSource', but having them available via the standard typeclasses+--   makes it too easy to accidentally run source-monad actions during+--   the probe phase — bypassing the batching system and potentially+--   introducing writes outside of 'Mutate'\'s cache reconciliation.+--   Use 'liftSource' when you explicitly need to lift an @m@ action.+--+-- MFunctor / hoist (mmorph):+--   NOT possible. 'Batches m' carries existential @DataSource m k@+--   constraints. Changing @m@ to @n@ requires re-proving those+--   constraints for @n@, which cannot be done generically.+--+-- MonadReader r:+--   'ask' would work via @lift ask@, but 'local' cannot propagate+--   through batch dispatch. The 'fetchLower' nat in 'FetchEnv'+--   captures the reader environment at the run site; 'local' inside+--   a 'Fetch' computation would only affect the probe phase, not+--   the 'batchFetch' calls dispatched by the engine. Providing a+--   'MonadReader' instance with broken 'local' would violate the+--   class laws, so we omit it entirely.+--+-- MonadBaseControl / MonadUnliftIO:+--   NOT possible. 'Fetch' is continuation-based: a 'Blocked' status+--   carries thunks that close over the 'FetchEnv' (which contains+--   mutable 'CacheRef' state). There is no way to capture this as a+--   pure @StM@ value and restore it.+--+-- MonadMask:+--   Intentionally omitted. Async exception masking across batch+--   round boundaries is not well-defined. A @mask@ would need to+--   protect both the probe and all subsequent rounds, but rounds+--   execute in IO via 'executeBatches' which uses 'async' internally.++-- ──────────────────────────────────────────────+-- Cache lookup helper+-- ──────────────────────────────────────────────++-- | Look up a key in the cache, awaiting any pending IVar.+-- Returns @Right v@ on hit, @Left ex@ on error/miss.+-- The @onMiss@ fallback is invoked (in @m@) when the key is absent.+lookupOrAwait :: (FetchKey k, Typeable (Result k), Monad m)+              => FetchEnv m -> k -> m (Either SomeException (Result k))+              -> m (Either SomeException (Result k))+lookupOrAwait e k onMiss = do+  hit <- fetchLift e $ cacheLookup (fetchCache e) k+  case hit of+    CacheHitReady v    -> pure (Right v)+    CacheHitPending iv -> fetchLift e $ awaitIVar iv+    CacheMiss          -> onMiss++-- ──────────────────────────────────────────────+-- MonadFetch instance+-- ──────────────────────────────────────────────++instance Monad m => MonadFetch m (Fetch m) where+  fetch (k :: k) = Fetch $ \e ->+    -- NoCaching semantics: skip the cache check entirely and always+    -- return Blocked. This guarantees that every >>= round dispatches+    -- a fresh batch for this key. The engine's dispatchUncached uses+    -- cacheAllocateForce to overwrite any stale IVar from a prior+    -- round, so the continuation below always reads a fresh result.+    --+    -- Within a single applicative round, dedup still works: the+    -- HashSet in SomeBatch merges duplicate keys, and all+    -- continuations from the same round share the one fresh IVar+    -- via lookupOrAwait.+    case cachePolicy @m @k Proxy of+      NoCaching ->+        pure $ Blocked+          (singletonBatch k)+          (Fetch $ \e' -> do+            result <- lookupOrAwait e' k+              (fetchLift e' $ throwIO $ FetchError $+                "Key not found in cache after round: " <> show k)+            case result of+              Right v -> pure (Done v)+              Left ex -> fetchLift e' $ throwIO ex)+      CacheResults -> do+        -- Check cache first+        hit <- fetchLift e $ cacheLookup (fetchCache e) k+        case hit of+          CacheHitReady v  -> pure (Done v)+          CacheHitPending iv -> do+            result <- fetchLift e $ awaitIVar iv+            case result of+              Right v -> pure (Done v)+              Left ex -> fetchLift e $ throwIO ex+          CacheMiss ->+            pure $ Blocked+              (singletonBatch k)+              (Fetch $ \e' -> do+                result <- lookupOrAwait e' k+                  (fetchLift e' $ throwIO $ FetchError $+                    "Key not found in cache after round: " <> show k)+                case result of+                  Right v -> pure (Done v)+                  Left ex -> fetchLift e' $ throwIO ex)++  tryFetch (k :: k) = Fetch $ \e ->+    -- See 'fetch' above for NoCaching semantics.+    case cachePolicy @m @k Proxy of+      NoCaching ->+        pure $ Blocked+          (singletonBatch k)+          (Fetch $ \e' -> do+            result <- lookupOrAwait e' k+              (pure $ Left $ toException $+                FetchError $ "Key not found in cache after round: " <> show k)+            pure (Done result))+      CacheResults -> do+        hit <- fetchLift e $ cacheLookup (fetchCache e) k+        case hit of+          CacheHitReady v  -> pure (Done (Right v))+          CacheHitPending iv -> do+            result <- fetchLift e $ awaitIVar iv+            pure (Done result)+          CacheMiss ->+            pure $ Blocked+              (singletonBatch k)+              (Fetch $ \e' -> do+                result <- lookupOrAwait e' k+                  (pure $ Left $ toException $+                    FetchError $ "Key not found in cache after round: " <> show k)+                pure (Done result))++  primeCache k v = Fetch $ \e -> do+    let cRef = fetchCache e+    hit <- fetchLift e $ cacheLookup cRef k+    case hit of+      CacheHitPending iv -> fetchLift e $ writeIVar iv v+      _                  -> fetchLift e $ cacheWarm cRef (HM.singleton k v)+    pure (Done ())++-- ──────────────────────────────────────────────+-- MonadFetchBatch instance+-- ──────────────────────────────────────────────++instance Monad m => MonadFetchBatch m (Fetch m) where+  probe m = Fetch $ \e -> do+    s <- unFetch m e+    pure (Done s)++  embed (Done a)      = pure a+  embed (Blocked _ k) = k++-- ──────────────────────────────────────────────+-- Config+-- ──────────────────────────────────────────────++-- | Configuration for running a 'Fetch' computation.+--+-- Contains the two natural transformations that bridge the source+-- monad @m@ with @IO@, plus optional settings. Use 'fetchConfig'+-- to construct with sensible defaults, then override fields as needed:+--+-- @+-- let cfg = fetchConfig (runAppM env) liftIO+-- runFetch cfg action+--+-- -- with a shared cache:+-- runFetch cfg { configCache = Just myCache } action+-- @+--+-- For monads that deliberately avoid 'MonadIO' (e.g. a @Transaction@+-- type), the nats are the private escape hatches:+--+-- @+-- fetchInTransaction :: Fetch Transaction a -> Transaction a+-- fetchInTransaction = runFetch (fetchConfig unsafeRunTransaction unsafeLiftIO)+-- @+data FetchConfig m = FetchConfig+  { configLower :: !(forall x. m x -> IO x)+    -- ^ Run an @m@ action in @IO@. Used by the engine to dispatch+    -- @batchFetch@ calls.+  , configLift  :: !(forall x. IO x -> m x)+    -- ^ Lift an @IO@ action into @m@. Used for cache and IVar+    -- operations within @m@.+  , configCache :: !(Maybe CacheRef)+    -- ^ Pre-existing cache. 'Nothing' creates a fresh cache per run.+    -- Set to @Just cRef@ to share or pre-warm a cache.+  }++-- | Construct a 'FetchConfig' with explicit natural transformations.+--+-- Use this for monads that don't have 'MonadUnliftIO' (e.g. a+-- restricted @Transaction@ type). For 'MonadUnliftIO' monads,+-- prefer 'fetchConfigIO' which fills in the nats automatically.+fetchConfig :: (forall x. m x -> IO x)+            -> (forall x. IO x -> m x)+            -> FetchConfig m+fetchConfig lower lift = FetchConfig+  { configLower = lower+  , configLift  = lift+  , configCache = Nothing+  }++-- | Construct a 'FetchConfig' for any 'MonadUnliftIO' monad.+--+-- The natural transformations are derived from the 'MonadUnliftIO'+-- instance: 'withRunInIO' provides @m x -> IO x@, and 'liftIO'+-- provides @IO x -> m x@.+--+-- @+-- cfg <- fetchConfigIO+-- runFetch cfg action+-- @+fetchConfigIO :: MonadUnliftIO m => m (FetchConfig m)+fetchConfigIO = withRunInIO $ \runInIO ->+  pure FetchConfig+    { configLower = runInIO+    , configLift  = liftIO+    , configCache = Nothing+    }++-- ──────────────────────────────────────────────+-- Runners+-- ──────────────────────────────────────────────++-- | Run a 'Fetch' computation.+--+-- @+-- let cfg = fetchConfig (runAppM env) liftIO+-- result <- runFetch cfg action+-- @+--+-- To share a cache across sequential phases:+--+-- @+-- let cfg = (fetchConfig lower lift) { configCache = Just myCache }+-- runFetch cfg action+-- @+runFetch :: Monad m => FetchConfig m -> Fetch m a -> m a+runFetch cfg action = do+  cacheRef <- case configCache cfg of+    Just ref -> pure ref+    Nothing  -> configLift cfg newCacheRef+  let e = FetchEnv+        { fetchCache = cacheRef+        , fetchLower = configLower cfg+        , fetchLift  = configLift cfg+        }+  runLoop e (\_ _ -> pure ()) action++-- | Like 'runFetch', but also returns the 'CacheRef'.+-- This is the @runStateT@-style variant for cache preservation:+--+-- @+-- (result1, cache) <- runFetch' cfg phase1+-- result2 <- runFetch cfg { configCache = Just cache } phase2+-- @+runFetch' :: Monad m => FetchConfig m -> Fetch m a -> m (a, CacheRef)+runFetch' cfg action = do+  cacheRef <- case configCache cfg of+    Just ref -> pure ref+    Nothing  -> configLift cfg newCacheRef+  let e = FetchEnv+        { fetchCache = cacheRef+        , fetchLower = configLower cfg+        , fetchLift  = configLift cfg+        }+  a <- runLoop e (\_ _ -> pure ()) action+  pure (a, cacheRef)++-- | Generalised execution loop with a per-round wrapper.+--+-- The callback receives:+--+-- * The 1-based round number.+-- * The pending 'Batches'.+-- * An action that executes the batches and returns 'RoundStats'.+--+-- The callback /must/ invoke the execution action for the computation+-- to make progress. This design lets instrumentation wrap /around/+-- batch execution (opening a tracing span before and closing it+-- after, for example).+--+-- @+-- runLoopWith env (\\n batches exec -> do+--     openSpan n batches+--     stats <- exec+--     closeSpan stats) action+-- @+runLoopWith :: Monad m+            => FetchEnv m+            -> (Int -> Batches m -> m RoundStats -> m ())+               -- ^ Round wrapper. Must invoke the @m RoundStats@ action.+            -> Fetch m a+            -> m a+runLoopWith e withRound = go 1+  where+    go !n m = do+      status <- unFetch m e+      case status of+        Done a -> pure a+        Blocked batches k -> do+          let exec = fetchLift e $+                executeBatches (fetchLower e) (fetchLift e) (fetchCache e) batches+          withRound n batches exec+          go (n + 1) k++-- | Simplified execution loop with a pre-round callback.+--+-- Equivalent to 'runLoopWith' where the callback fires before batch+-- execution but does not wrap it.+--+-- Exposed for use by custom runners.+runLoop :: Monad m+        => FetchEnv m+        -> (Int -> Batches m -> m ())+           -- ^ Called before each round with round number and batches.+        -> Fetch m a+        -> m a+runLoop e onRound = runLoopWith e $ \n batches exec -> do+  onRound n batches+  _ <- exec+  pure ()
+ src/Fetch/Cache.hs view
@@ -0,0 +1,247 @@+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE FlexibleContexts #-}++module Fetch.Cache+  ( CacheRef+  , newCacheRef+    -- * Lookup+  , CacheLookup(..)+  , cacheLookup+    -- * Allocation & writing+  , cacheAllocate+  , cacheAllocateForce+  , cacheInsert+  , cacheInsertError+    -- * Eviction+  , cacheEvict+  , cacheEvictSource+  , cacheEvictWhere+    -- * Warming & export+  , cacheWarm+  , cacheContents+  ) where++import Fetch.Class+import Fetch.IVar++import Data.Dynamic+import Data.HashMap.Strict (HashMap)+import qualified Data.HashMap.Strict as HM+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.IORef+import Type.Reflection (SomeTypeRep, someTypeRep)++-- | Internal representation: each entry is a Dynamic wrapping+-- @HashMap k (IVar (Result k))@.+type ResultMap = Map SomeTypeRep Dynamic++-- | A mutable, shared cache. Stores IVars so that in-flight+-- requests can be deduplicated and concurrent readers can+-- block on pending results.+newtype CacheRef = CacheRef (IORef ResultMap)++-- | Create an empty cache.+newCacheRef :: IO CacheRef+newCacheRef = CacheRef <$> newIORef Map.empty++-- ──────────────────────────────────────────────+-- Lookup+-- ──────────────────────────────────────────────++-- | Result of looking up a key in the cache.+data CacheLookup a+  = CacheMiss+    -- ^ Key has never been requested.+  | CacheHitPending (IVar a)+    -- ^ Key is being fetched by another round. Wait on the IVar.+  | CacheHitReady a+    -- ^ Key has a resolved value.++-- | Look up a key in the cache. Distinguishes between miss,+-- pending (in-flight), and ready (resolved).+cacheLookup :: forall k. (FetchKey k, Typeable (Result k))+            => CacheRef -> k -> IO (CacheLookup (Result k))+cacheLookup (CacheRef ref) k = do+  cache <- readIORef ref+  let trep = someTypeRep (Proxy @k)+  case Map.lookup trep cache >>= fromDynamic of+    Just (ivars :: HashMap k (IVar (Result k))) ->+      case HM.lookup k ivars of+        Just iv -> do+          mr <- tryReadIVar iv+          case mr of+            Just (Right v) -> pure (CacheHitReady v)+            Just (Left _)  -> pure CacheMiss+              -- Errored IVars are treated as a miss: allow retry.+            Nothing        -> pure (CacheHitPending iv)+        Nothing -> pure CacheMiss+    Nothing -> pure CacheMiss++-- ──────────────────────────────────────────────+-- Allocation+-- ──────────────────────────────────────────────++-- | Atomically allocate IVars for keys not already in the cache.+-- Returns only the newly allocated (key, IVar) pairs; keys that+-- already had IVars (filled or pending) are skipped.+--+-- This is the deduplication point: concurrent calls to+-- @cacheAllocate@ for the same key will only allocate once.+cacheAllocate :: forall k. (FetchKey k, Typeable (Result k))+              => CacheRef -> [k] -> IO [(k, IVar (Result k))]+cacheAllocate (CacheRef ref) keys = do+  -- Allocate IVars in IO before the atomic update. Some may be+  -- wasted if the key is already cached, but this avoids the+  -- unsafePerformIO-inside-atomicModifyIORef pitfall where GHC+  -- optimisations can break sharing of thunks.+  candidates <- mapM (\k -> do iv <- newIVar; pure (k, iv)) keys+  atomicModifyIORef' ref $ \cache ->+    let trep = someTypeRep (Proxy @k)+        existing :: HashMap k (IVar (Result k))+        existing = case Map.lookup trep cache >>= fromDynamic of+          Just m  -> m+          Nothing -> HM.empty++        go [] acc ivMap = (acc, ivMap)+        go ((k, iv):rest) acc ivMap =+          case HM.lookup k ivMap of+            Just _  -> go rest acc ivMap+            Nothing -> go rest ((k, iv) : acc) (HM.insert k iv ivMap)++        (newPairs, updated) = go candidates [] existing+        cache' = Map.insert trep (toDyn updated) cache+    in (cache', newPairs)++-- | Like 'cacheAllocate', but always creates fresh IVars,+-- overwriting any existing entries for the same keys.+--+-- Used by the engine for 'NoCaching' data sources. The old IVar+-- (if any) is replaced atomically, so:+--+-- * Continuations from the /current/ round all share the new IVar+--   (within-round deduplication is preserved).+-- * Continuations from a /prior/ round that already read the old+--   IVar are unaffected (they completed before the overwrite).+-- * The next round will overwrite again, ensuring the source is+--   re-fetched every time.+cacheAllocateForce :: forall k. (FetchKey k, Typeable (Result k))+                   => CacheRef -> [k] -> IO [(k, IVar (Result k))]+cacheAllocateForce (CacheRef ref) keys = do+  candidates <- mapM (\k -> do iv <- newIVar; pure (k, iv)) keys+  atomicModifyIORef' ref $ \cache ->+    let trep = someTypeRep (Proxy @k)+        existing :: HashMap k (IVar (Result k))+        existing = case Map.lookup trep cache >>= fromDynamic of+          Just m  -> m+          Nothing -> HM.empty++        -- Always overwrite: insert every candidate regardless of+        -- whether the key already has an IVar in the cache.+        updated = foldl (\m (k, iv) -> HM.insert k iv m) existing candidates+        cache' = Map.insert trep (toDyn updated) cache+    in (cache', candidates)++-- ──────────────────────────────────────────────+-- Writing+-- ──────────────────────────────────────────────++-- | Look up the IVar for a key and apply an action to it.+-- No-op if the key has no allocated IVar.+withCachedIVar :: forall k. (FetchKey k, Typeable (Result k))+               => CacheRef -> k -> (IVar (Result k) -> IO ()) -> IO ()+withCachedIVar (CacheRef ref) k action = do+  cache <- readIORef ref+  let trep = someTypeRep (Proxy @k)+  case Map.lookup trep cache >>= fromDynamic of+    Just (ivars :: HashMap k (IVar (Result k))) ->+      case HM.lookup k ivars of+        Just iv -> action iv+        Nothing -> pure ()+    Nothing -> pure ()++-- | Write a success result into a previously allocated IVar.+cacheInsert :: forall k. (FetchKey k, Typeable (Result k))+            => CacheRef -> k -> Result k -> IO ()+cacheInsert cRef k v = withCachedIVar cRef k $ \iv -> writeIVar iv v++-- | Write an error into a previously allocated IVar.+cacheInsertError :: forall k. (FetchKey k, Typeable (Result k))+                 => CacheRef -> k -> SomeException -> IO ()+cacheInsertError cRef k e = withCachedIVar cRef k $ \iv -> writeIVarError iv e++-- ──────────────────────────────────────────────+-- Eviction+-- ──────────────────────────────────────────────++-- | Evict a single key.+cacheEvict :: forall k. (FetchKey k, Typeable (Result k))+           => CacheRef -> k -> IO ()+cacheEvict (CacheRef ref) k = do+  let trep = someTypeRep (Proxy @k)+  atomicModifyIORef' ref $ \cache ->+    ( Map.adjust+        (\dyn ->+          let ivars = fromDyn dyn (HM.empty :: HashMap k (IVar (Result k)))+          in toDyn (HM.delete k ivars))+        trep cache+    , () )++-- | Evict all cached results for a data source.+cacheEvictSource :: forall k. (Typeable k)+                 => CacheRef -> Proxy k -> IO ()+cacheEvictSource (CacheRef ref) _ =+  atomicModifyIORef' ref $ \cache ->+    (Map.delete (someTypeRep (Proxy @k)) cache, ())++-- | Evict keys matching a predicate.+cacheEvictWhere :: forall k. (FetchKey k, Typeable (Result k))+                => CacheRef -> Proxy k -> (k -> Bool) -> IO ()+cacheEvictWhere (CacheRef ref) _ predicate = do+  let trep = someTypeRep (Proxy @k)+  atomicModifyIORef' ref $ \cache ->+    ( Map.adjust+        (\dyn ->+          let ivars = fromDyn dyn (HM.empty :: HashMap k (IVar (Result k)))+          in toDyn (HM.filterWithKey (\k' _ -> not (predicate k')) ivars))+        trep cache+    , () )++-- ──────────────────────────────────────────────+-- Warming & export+-- ──────────────────────────────────────────────++-- | Warm the cache with known values. Creates pre-filled IVars.+-- Useful for hydrating from an external cache (Redis, etc.)+-- at request start.+cacheWarm :: forall k. (FetchKey k, Typeable (Result k))+          => CacheRef -> HashMap k (Result k) -> IO ()+cacheWarm (CacheRef ref) values = do+  let trep = someTypeRep (Proxy @k)+  ivars <- HM.traverseWithKey (\_ v -> do+    iv <- newIVar+    writeIVar iv v+    pure iv) values+  atomicModifyIORef' ref $ \cache ->+    let existing :: HashMap k (IVar (Result k))+        existing = case Map.lookup trep cache >>= fromDynamic of+          Just m  -> m+          Nothing -> HM.empty+    in (Map.insert trep (toDyn (HM.union ivars existing)) cache, ())++-- | Read all resolved values for a source (for debugging/export).+cacheContents :: forall k. (FetchKey k, Typeable (Result k))+              => CacheRef -> Proxy k -> IO (HashMap k (Result k))+cacheContents (CacheRef ref) _ = do+  cache <- readIORef ref+  let trep = someTypeRep (Proxy @k)+  case Map.lookup trep cache >>= fromDynamic of+    Just (ivars :: HashMap k (IVar (Result k))) ->+      HM.mapMaybe id <$> traverse (\iv -> do+        mr <- tryReadIVar iv+        pure $ case mr of+          Just (Right v) -> Just v+          _              -> Nothing) ivars+    Nothing -> pure HM.empty
+ src/Fetch/Class.hs view
@@ -0,0 +1,344 @@+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE FunctionalDependencies #-}+{-# LANGUAGE ExistentialQuantification #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE DefaultSignatures #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE RankNTypes #-}++module Fetch.Class+  ( -- * Keys+    FetchKey(..)+    -- * Data sources+  , DataSource(..)+  , FetchStrategy(..)+  , CachePolicy(..)+    -- * Batching protocol+  , Status(..)+  , Batches(..)+  , SomeBatch(..)+  , singletonBatch+  , batchKeys+  , batchSize+  , batchSourceCount+  , mapStatus+    -- * MonadFetch+  , MonadFetch(..)+    -- * MonadFetchBatch+  , MonadFetchBatch(..)+    -- * Mutations+  , MutationKey(..)+    -- * Re-exports+  , Typeable+  , Hashable+  , NonEmpty(..)+  , Proxy(..)+  , SomeException+  ) where++import Control.Exception (SomeException)+import Data.HashMap.Strict (HashMap)+import qualified Data.HashMap.Strict as HM+import Data.HashSet (HashSet)+import qualified Data.HashSet as HS+import Data.List.NonEmpty (NonEmpty(..))+import qualified Data.List.NonEmpty as NE+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Hashable (Hashable)+import Data.Proxy (Proxy(..))+import Data.Typeable (Typeable)+import Type.Reflection (SomeTypeRep, someTypeRep, eqTypeRep, typeRep, (:~~:)(HRefl))++-- ──────────────────────────────────────────────+-- Keys+-- ──────────────────────────────────────────────++-- | A typed key for a data fetch. The associated type family+-- pairs each key type with its result type, replacing Haxl's+-- GADT-based approach.+class (Typeable k, Hashable k, Eq k, Show k) => FetchKey k where+  type Result k++-- ──────────────────────────────────────────────+-- Data sources+-- ──────────────────────────────────────────────++-- | How the engine should schedule this source relative to others.+data FetchStrategy+  = Sequential  -- ^ Block on this source before starting others.+  | Concurrent  -- ^ Run alongside other sources (default).+  | EagerStart  -- ^ Start before sequential sources to overlap latency.+  deriving (Eq, Show)++-- | Whether results should be cached across rounds.+--+-- The default is 'CacheResults', which means a key fetched in round+-- /N/ is remembered: if the same key appears again in round /N+1/+-- (or anywhere later in the computation), the cached value is returned+-- immediately without dispatching a new batch.+--+-- 'NoCaching' opts out of this. Every round that mentions the key+-- dispatches a fresh batch to the data source, even if the key was+-- fetched moments ago. This is the right choice for data sources+-- whose results are non-idempotent or time-sensitive: counters,+-- "current timestamp" endpoints, queue-drain operations, etc.+--+-- __Within a single round__, deduplication still applies regardless+-- of policy: @(\',\') \<$\> fetch k \<*\> fetch k@ hits the source+-- once and both sides see the same value. The "no caching" guarantee+-- is strictly /across/ round boundaries introduced by @(>>=)@.+data CachePolicy+  = CacheResults+    -- ^ Cache results (default). Use for idempotent reads.+    -- A key is fetched at most once per 'CacheRef' lifetime.+  | NoCaching+    -- ^ Do not cache across rounds. The data source is re-fetched+    -- every round the key appears in. Within a single round,+    -- duplicate keys are still deduplicated.+  deriving (Eq, Show)++-- | A data source knows how to batch-fetch keys given capabilities+-- provided by the monad @m@.+--+-- The @m@ parameter replaces both the old @env@ parameter and+-- the concrete @IO@ in @batchFetch@. If your data source needs a+-- connection pool, @m@ should be a monad with access to one+-- (e.g. via 'MonadReader' or a newtype over 'ReaderT').+--+-- Type safety: if there is no @DataSource m k@ instance, code that+-- tries to @fetch@ a @k@ won't compile. No runtime errors from+-- missing config.+--+-- Libraries that use a restricted monad (e.g. a @Transaction@ type+-- that deliberately hides @MonadIO@) should export a convenience+-- runner:+--+-- @+-- fetchInTransaction :: Fetch Transaction a -> Transaction a+-- fetchInTransaction = runFetch unsafeRunTransaction unsafeLiftIO+-- @+--+-- This keeps the unsafe escape hatches private while giving users+-- a safe, typed entry point.+class (FetchKey k, Monad m) => DataSource m k where+  -- | Fetch a batch of keys. Must return a result for every key provided.+  -- The engine handles concurrency, error wrapping, and caching.+  --+  -- The list is guaranteed non-empty by the engine (a batch exists+  -- only because at least one @fetch@ blocked on it).+  --+  -- If your data source has no native batch API, implement 'fetchOne'+  -- instead; the default 'batchFetch' calls it for each key.+  batchFetch :: NonEmpty k -> m (HashMap k (Result k))+  batchFetch keys = HM.fromList . NE.toList <$> traverse (\k -> fmap (\v -> (k, v)) (fetchOne k)) keys++  -- | Fetch a single key. Override this for data sources that don't+  -- support batch operations; the default 'batchFetch' will call it+  -- for each key and assemble the results.+  --+  -- The default implementation delegates to @'batchFetch' (k :| [])@+  -- and extracts the result. You must implement at least one of+  -- 'batchFetch' or 'fetchOne'.+  --+  -- @+  -- instance DataSource AppM UserId where+  --   fetchOne (UserId uid) = lookupUserById uid+  -- @+  fetchOne :: k -> m (Result k)+  fetchOne k = do+    hm <- batchFetch (k :| [])+    case HM.lookup k hm of+      Just v  -> pure v+      Nothing -> error $+        "DataSource.fetchOne: batchFetch did not return key: " <> show k++  -- __Important:__ you must implement at least one of 'batchFetch' or+  -- 'fetchOne'. The defaults are mutually recursive: if neither is+  -- overridden, calls will loop at runtime. The @MINIMAL@ pragma+  -- below emits a compile-time warning, but it is not a hard error+  -- unless @-Werror@ is enabled.+  {-# MINIMAL batchFetch | fetchOne #-}++  -- | Optional: streaming fetch for sources where results arrive+  -- incrementally (gRPC streams, websockets, etc.).+  --+  -- Default delegates to 'batchFetch'.+  streamingFetch :: NonEmpty k -> (k -> Result k -> m ()) -> m ()+  streamingFetch ks callback = do+    results <- batchFetch ks+    _ <- HM.traverseWithKey callback results+    pure ()++  -- | How should the engine schedule this source?+  fetchStrategy :: Proxy k -> FetchStrategy+  fetchStrategy _ = Concurrent++  -- | Should results be cached across rounds?+  --+  -- Override to 'NoCaching' for data sources whose results are+  -- non-idempotent or time-sensitive. With 'NoCaching':+  --+  -- * @fetch@ never returns a cached value; it always blocks and+  --   waits for a fresh batch dispatch.+  -- * Within a single applicative round, duplicate keys are still+  --   deduplicated (one batch call, one result shared by all+  --   continuations).+  -- * Across @(>>=)@ round boundaries, each round dispatches a+  --   fresh batch, and the data source is called again.+  --+  -- @+  -- instance DataSource AppM CurrentTime where+  --   fetchOne _ = liftIO getCurrentTime+  --   cachePolicy _ = NoCaching+  -- @+  cachePolicy :: Proxy k -> CachePolicy+  cachePolicy _ = CacheResults++  -- | Human-readable name for tracing/logging.+  dataSourceName :: Proxy k -> String+  dataSourceName _ = show (someTypeRep (Proxy @k))++-- ──────────────────────────────────────────────+-- Batching protocol+-- ──────────────────────────────────────────────++-- | The result of probing a computation: either a final value+-- or a set of blocked fetches with a continuation.+data Status m f a+  = Done a+  | Blocked (Batches m) (f a)++instance Functor f => Functor (Status m f) where+  fmap f (Done a)       = Done (f a)+  fmap f (Blocked bs k) = Blocked bs (fmap f k)++-- | Transform the continuation type in a 'Status'.+mapStatus :: (forall x. f x -> g x) -> Status m f a -> Status m g a+mapStatus _ (Done a)       = Done a+mapStatus f (Blocked bs k) = Blocked bs (f k)++-- | A collection of pending fetch requests, grouped by key type.+newtype Batches m = Batches (Map SomeTypeRep (SomeBatch m))++instance Semigroup (Batches m) where+  Batches a <> Batches b = Batches (Map.unionWith mergeBatch a b)++instance Monoid (Batches m) where+  mempty = Batches Map.empty++-- | Total number of (deduplicated) keys across all sources.+batchSize :: Batches m -> Int+batchSize (Batches m) = Map.foldl' (\acc b -> acc + someBatchLen b) 0 m+  where+    someBatchLen :: SomeBatch n -> Int+    someBatchLen (SomeBatch ks) = HS.size ks++-- | Number of distinct data sources in this batch.+batchSourceCount :: Batches m -> Int+batchSourceCount (Batches m) = Map.size m++-- | Extract the keys for a specific source type (for testing/tracing).+batchKeys :: forall k m. (Typeable k) => Batches m -> [k]+batchKeys (Batches m) =+  case Map.lookup (someTypeRep (Proxy @k)) m of+    Just (SomeBatch (ks :: HashSet k')) ->+      case eqTypeRep (typeRep @k) (typeRep @k') of+        Just HRefl -> HS.toList ks+        Nothing    -> []+    Nothing -> []++-- | Existentially wraps a batch for a single data source.+-- The @m@ parameter carries the monad needed to dispatch.+data SomeBatch m = forall k.+  (DataSource m k, Typeable k, Typeable (Result k))+  => SomeBatch (HashSet k)++mergeBatch :: SomeBatch m -> SomeBatch m -> SomeBatch m+mergeBatch (SomeBatch (a :: HashSet k1)) (SomeBatch (b :: HashSet k2)) =+  case eqTypeRep (typeRep @k1) (typeRep @k2) of+    Just HRefl -> SomeBatch (HS.union a b)+    Nothing    -> error "Fetch.Class.mergeBatch: impossible type mismatch"++-- | Create a batch containing a single key.+singletonBatch :: forall m k.+  (DataSource m k, Typeable (Result k))+  => k -> Batches m+singletonBatch k = Batches $+  Map.singleton (someTypeRep (Proxy @k)) (SomeBatch (HS.singleton k))++-- ──────────────────────────────────────────────+-- MonadFetch+-- ──────────────────────────────────────────────++-- | The interface application code programs against.+--+-- @m@ is the /source monad/ (the monad that 'DataSource'+-- implementations run in). @n@ is the /fetch monad/: the monad+-- your application code runs in, typically 'Fetch' @m@.+--+-- The functional dependency @n -> m@ means the source monad is+-- determined by the fetch monad.+class Monad n => MonadFetch m n | n -> m where+  -- | Fetch a single key. Throws on error.+  fetch :: (DataSource m k, Typeable (Result k)) => k -> n (Result k)++  -- | Fetch a single key with explicit error handling.+  tryFetch :: (DataSource m k, Typeable (Result k))+           => k -> n (Either SomeException (Result k))++  -- | Seed the cache with a known key\/value pair.+  --+  -- * __Miss__: inserts a new resolved entry.+  -- * __Pending__: fills the in-flight IVar, waking any blocked+  --   continuations immediately. The batch\'s later write is+  --   idempotent and silently ignored.+  -- * __Resolved__: overwrites with the new value.+  --+  -- Use this to prime sub-entities extracted from compound responses:+  --+  -- @+  -- posts <- fetch (PostsFeed feedId)+  -- mapM_ (\\p -> primeCache (PostById (postId p)) p) posts+  -- @+  primeCache :: (FetchKey k, Typeable (Result k)) => k -> Result k -> n ()++-- | The batching protocol. Only needed by implementations and runners,+-- not by application code.+class MonadFetch m n => MonadFetchBatch m n | n -> m where+  -- | Expose the next blocking point of a computation.+  probe :: n a -> n (Status m n a)++  -- | Inject a 'Status' back into the monad.+  --+  -- __Warning:__ for a @'Blocked' batches k@ status, @embed@+  -- discards @batches@ and runs the continuation @k@ directly.+  -- The continuation expects its IVars to have been filled by the+  -- engine. If you call @embed@ on a 'Blocked' status without+  -- first executing the batches (via 'executeBatches' or the+  -- run-loop), every blocked key will error with+  -- @\"Key not found in cache after round\"@.+  --+  -- Typical usage: call 'probe' to inspect the status, execute+  -- the batches yourself, /then/ call @embed@ (or feed the+  -- continuation to the next iteration of your custom loop).+  embed :: Status m n a -> n a+  embed (Done a)      = pure a+  embed (Blocked _ k) = k++-- ──────────────────────────────────────────────+-- Mutations+-- ──────────────────────────────────────────────++-- | A typed key for a mutation operation. The key itself encodes+-- both the identity and the input (e.g., @UpdateUserName uid name@).+--+-- Unlike 'FetchKey', mutation keys only require 'Typeable' (no+-- 'Hashable', 'Eq', or 'Show') because mutations are never+-- deduplicated, cached, or shown in engine error messages.+class Typeable k => MutationKey k where+  type MutationResult k
+ src/Fetch/Combinators.hs view
@@ -0,0 +1,309 @@+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE FlexibleContexts #-}++module Fetch.Combinators+  ( -- * Fetch combinators+    fetchAll+  , fetchWith+  , fetchThrough+  , fetchMap+  , fetchMaybe+  , fetchMapWith+    -- * Lifted operators+    --+    -- | Operators for working with applicative values (e.g. fetched+    -- results) without explicit binding. Prefix @.@ distinguishes+    -- them from their pure counterparts.+    --+    -- @+    -- do userAge <- fetch (UserAge uid)+    --    pure (userAge >= 18)+    -- @+    --+    -- becomes:+    --+    -- @+    -- fetch (UserAge uid) .>= pure 18+    -- @+  , (.>)+  , (.<)+  , (.>=)+  , (.<=)+  , (.==)+  , (./=)+  , (.&&)+  , (.||)+  , (.++)+    -- * Applicative pairing+  , pair+    -- * Parallel short-circuiting+  , biselect+  , pAnd+  , pOr+    -- * Sequencing+  , andThen+    -- * Applicative filter+  , filterA+    -- * Error recovery+  , withDefault+  ) where++import Fetch.Class+import Fetch.Batched (Fetch(..))++import Control.Monad.Catch (MonadCatch, catch)+import Data.Foldable (toList)+import Data.HashMap.Strict (HashMap)+import qualified Data.HashMap.Strict as HM++-- ──────────────────────────────────────────────+-- Fetch combinators+-- ──────────────────────────────────────────────++-- | Fetch all keys, preserving the container shape.+--+-- @fetchAll [k1, k2, k3]@ batches all three keys into one round.+fetchAll :: (MonadFetch m n, DataSource m k, Typeable (Result k), Traversable t)+         => t k -> n (t (Result k))+fetchAll = traverse fetch++-- | Fetch all keys and pair each with its result.+fetchWith :: (MonadFetch m n, DataSource m k, Typeable (Result k), Traversable t)+          => t k -> n (t (k, Result k))+fetchWith = traverse (\k -> (,) k <$> fetch k)++-- | Extract a key from each element, fetch, and pair back.+--+-- @fetchThrough commentAuthor comments@ fetches all authors in one+-- round and pairs each comment with its author.+fetchThrough :: (MonadFetch m n, DataSource m k, Typeable (Result k), Traversable t)+             => (a -> k) -> t a -> n (t (a, Result k))+fetchThrough toKey = traverse (\a -> (,) a <$> fetch (toKey a))++-- | Extract a key from each element, fetch, and transform.+--+-- @fetchMap commentAuthor (\\c u -> CommentView c u) comments@+fetchMap :: (MonadFetch m n, DataSource m k, Typeable (Result k), Traversable t)+         => (a -> k) -> (a -> Result k -> b) -> t a -> n (t b)+fetchMap toKey combine = traverse (\a -> combine a <$> fetch (toKey a))++-- | Fetch a key if present.+fetchMaybe :: (MonadFetch m n, DataSource m k, Typeable (Result k))+           => Maybe k -> n (Maybe (Result k))+fetchMaybe = traverse fetch++-- | Fetch a collection of keys and return a map of results.+-- Duplicates are deduplicated.+fetchMapWith :: (MonadFetch m n, DataSource m k, Typeable (Result k), Foldable f)+             => f k -> n (HashMap k (Result k))+fetchMapWith ks =+  let keys = toList ks+  in HM.fromList <$> traverse (\k -> (,) k <$> fetch k) keys++-- ──────────────────────────────────────────────+-- Lifted operators+-- ──────────────────────────────────────────────++infixr 3 .&&+infixr 2 .||+infix  4 .>, .<, .>=, .<=, .==, ./=++-- | Lifted @(>)@.+(.>) :: (Ord a, Applicative f) => f a -> f a -> f Bool+(.>) = liftA2 (>)++-- | Lifted @(<)@.+(.<) :: (Ord a, Applicative f) => f a -> f a -> f Bool+(.<) = liftA2 (<)++-- | Lifted @(>=)@.+(.>=) :: (Ord a, Applicative f) => f a -> f a -> f Bool+(.>=) = liftA2 (>=)++-- | Lifted @(<=)@.+(.<=) :: (Ord a, Applicative f) => f a -> f a -> f Bool+(.<=) = liftA2 (<=)++-- | Lifted @(==)@.+(.==) :: (Eq a, Applicative f) => f a -> f a -> f Bool+(.==) = liftA2 (==)++-- | Lifted @(/=)@.+(./=) :: (Eq a, Applicative f) => f a -> f a -> f Bool+(./=) = liftA2 (/=)++-- | Short-circuiting lifted @(&&)@. Evaluates the second argument+-- only if the first returns 'True'.+(.&&) :: Monad m => m Bool -> m Bool -> m Bool+ma .&& mb = do a <- ma; if a then mb else pure False++-- | Short-circuiting lifted @(||)@. Evaluates the second argument+-- only if the first returns 'False'.+(.||) :: Monad m => m Bool -> m Bool -> m Bool+ma .|| mb = do a <- ma; if a then pure True else mb++-- | Lifted @(++)@.+(.++) :: Applicative f => f [a] -> f [a] -> f [a]+(.++) = liftA2 (++)++-- ──────────────────────────────────────────────+-- Applicative pairing+-- ──────────────────────────────────────────────++-- | Pair two applicative computations. When used with 'Fetch',+-- both sides are batched into the same round.+--+-- @pair (fetch userKey) (fetch postKey)@+pair :: Applicative f => f a -> f b -> f (a, b)+pair = liftA2 (,)++-- ──────────────────────────────────────────────+-- Parallel short-circuiting+-- ──────────────────────────────────────────────++infixr 5 `pAnd`+infixr 4 `pOr`++-- | Select from two computations that each return @Either a@.+--+-- Both sides are probed in the same round (their fetches are batched+-- together). If either side resolves to @Left a@, the other side is+-- abandoned and the result is @Left a@. Only when both sides resolve+-- to @Right@ are the two values paired.+--+-- After each round, resolved sides are checked for early exit so+-- that multi-round computations can short-circuit as soon as+-- possible.+--+-- This is the fundamental building block for 'pAnd' and 'pOr'.+biselect :: Monad m+         => Fetch m (Either a b)+         -> Fetch m (Either a c)+         -> Fetch m (Either a (b, c))+biselect = go+  where+    go l r = Fetch $ \e -> do+      sl <- unFetch l e+      sr <- unFetch r e+      pure $ case (sl, sr) of+        -- Either side short-circuits+        (Done (Left a), _)                -> Done (Left a)+        (_, Done (Left a))                -> Done (Left a)+        -- Both sides done+        (Done (Right b), Done (Right c))  -> Done (Right (b, c))+        -- One side done, the other blocked. Keep probing the+        -- blocked side each round in case it short-circuits.+        (Done (Right b), Blocked bs kr)   -> Blocked bs (goRight b kr)+        (Blocked bs kl, Done (Right c))   -> Blocked bs (goLeft kl c)+        -- Both blocked: merge batches, recurse next round+        (Blocked bs1 kl, Blocked bs2 kr)  -> Blocked (bs1 <> bs2) (go kl kr)++    -- Left resolved to @Right b@; keep probing right for early exit.+    goRight b r = Fetch $ \e -> do+      sr <- unFetch r e+      pure $ case sr of+        Done (Left a)    -> Done (Left a)+        Done (Right c)   -> Done (Right (b, c))+        Blocked bs kr    -> Blocked bs (goRight b kr)++    -- Right resolved to @Right c@; keep probing left for early exit.+    goLeft l c = Fetch $ \e -> do+      sl <- unFetch l e+      pure $ case sl of+        Done (Left a)    -> Done (Left a)+        Done (Right b)   -> Done (Right (b, c))+        Blocked bs kl    -> Blocked bs (goLeft kl c)++-- | Parallel @(&&)@. Both sides are probed in the same round+-- (batched together). If either side returns 'False' before the+-- other completes, the result is 'False' immediately; the other+-- side's remaining rounds are not evaluated.+--+-- Compare with '.&&' which is sequential short-circuiting+-- (left-to-right), and @liftA2 (&&)@ which is concurrent but+-- never short-circuits.+--+-- @+-- pAnd (fetch (IsActive uid)) (fetch (HasPermission uid "admin"))+-- @+pAnd :: Monad m => Fetch m Bool -> Fetch m Bool -> Fetch m Bool+pAnd x y = fromEither <$> biselect (discrim <$> x) (discrim <$> y)+  where+    discrim False = Left False  -- short-circuit+    discrim True  = Right ()    -- continue+    fromEither (Left b)  = b+    fromEither (Right _) = True++-- | Parallel @(||)@. Both sides are probed in the same round+-- (batched together). If either side returns 'True' before the+-- other completes, the result is 'True' immediately; the other+-- side's remaining rounds are not evaluated.+--+-- Compare with '.||' which is sequential short-circuiting+-- (left-to-right), and @liftA2 (||)@ which is concurrent but+-- never short-circuits.+--+-- @+-- pOr (fetch (IsAdmin uid)) (fetch (IsModerator uid))+-- @+pOr :: Monad m => Fetch m Bool -> Fetch m Bool -> Fetch m Bool+pOr x y = fromEither <$> biselect (discrim <$> x) (discrim <$> y)+  where+    discrim True  = Left True   -- short-circuit+    discrim False = Right ()    -- continue+    fromEither (Left b)  = b+    fromEither (Right _) = False++-- ──────────────────────────────────────────────+-- Sequencing+-- ──────────────────────────────────────────────++-- | Monadic sequencing: run the first computation, discard its+-- result, then run the second.+--+-- In a fetch monad where @('>>')@ equals @('*>')@ (i.e. both sides+-- are batched into one round), 'andThen' forces sequential execution+-- across round boundaries.+--+-- @+-- -- These batch together (one round):+-- fetch keyA *> fetch keyB+--+-- -- This forces two rounds:+-- fetch keyA \`andThen\` fetch keyB+-- @+andThen :: Monad m => m a -> m b -> m b+andThen a b = a >>= \_ -> b++-- ──────────────────────────────────────────────+-- Applicative filter+-- ──────────────────────────────────────────────++-- | Applicative version of 'Control.Monad.filterM'.+--+-- The predicate is applied to all elements via 'traverse', so when+-- used with a fetch monad, all predicate evaluations are batched+-- into the same round.+--+-- @+-- filterA (\\uid -> fetch (IsActive uid)) userIds+-- @+filterA :: Applicative f => (a -> f Bool) -> [a] -> f [a]+filterA predicate xs =+  filt <$> traverse predicate xs+  where+    filt bools = map fst $ filter snd $ zip xs bools++-- ──────────────────────────────────────────────+-- Error recovery+-- ──────────────────────────────────────────────++-- | Run a computation; if it throws any exception, return the+-- supplied default value instead.+--+-- @+-- userName <- withDefault "unknown" (fetch (UserName uid))+-- @+withDefault :: MonadCatch m => a -> m a -> m a+withDefault d a = a `catch` \(_ :: SomeException) -> pure d
+ src/Fetch/Deriving.hs view
@@ -0,0 +1,120 @@+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE FlexibleContexts #-}++-- | Helpers for writing typeclass instances with less boilerplate.+--+-- = DerivingVia for transformer newtypes+--+-- If you define a newtype over 'Fetch', 'MockFetch', 'Mutate',+-- or any of the library's monads, @GeneralizedNewtypeDeriving@ can+-- derive all the standard instances automatically:+--+-- @+-- {-\# LANGUAGE GeneralizedNewtypeDeriving, DerivingStrategies \#-}+--+-- newtype AppFetch a = AppFetch ('Fetch.Batched.Fetch' AppM a)+--   deriving newtype+--     ( Functor, Applicative, Monad+--     , MonadFail, MonadThrow, MonadCatch+--     , 'Fetch.Class.MonadFetch' AppM+--     )+-- @+--+-- For a newtype over 'Mutate' that also supports mutations:+--+-- @+-- newtype AppMutate a = AppMutate ('Fetch.Mutate.Mutate' AppM AppM a)+--   deriving newtype+--     ( Functor, Applicative, Monad+--     , MonadFail, MonadThrow, MonadCatch+--     , 'Fetch.Class.MonadFetch' AppM+--     , 'Fetch.Mutate.MonadMutate' AppM+--     )+-- @+--+-- For a newtype over 'MockFetch' in tests:+--+-- @+-- newtype TestFetch a = TestFetch ('Fetch.Mock.MockFetch' AppM IO a)+--   deriving newtype+--     ( Functor, Applicative, Monad+--     , 'Fetch.Class.MonadFetch' AppM+--     )+-- @+--+-- The library's own 'Fetch.Traced.TracedFetch' uses this exact pattern.+--+-- == Why not FetchKey or DataSource?+--+-- 'FetchKey', 'MutationKey', and 'MemoKey' use associated type families,+-- which @DerivingVia@ cannot handle, so you must write the @type Result@+-- line manually. That said, the instances are minimal (2 lines each).+--+-- 'DataSource' methods mention @HashMap k (Result k)@ in return+-- positions, and @HashMap@'s key role is nominal, preventing coercion.+-- Use 'fetchOne' or the helpers below to reduce the boilerplate instead.+--+-- = Simpler DataSource instances+--+-- For data sources with no native batch API, implement 'fetchOne'+-- instead of 'batchFetch'. The default 'batchFetch' calls 'fetchOne'+-- for each key and assembles the results:+--+-- @+-- instance DataSource AppM UserId where+--   fetchOne (UserId uid) = lookupUserById uid+-- @+--+-- For more control over how missing keys are handled, use+-- 'optionalBatchFetch' or 'traverseBatchFetch' in your 'batchFetch'+-- implementation.+module Fetch.Deriving+  ( -- * DataSource helpers+    optionalBatchFetch+  , traverseBatchFetch+  ) where++import Fetch.Class++import Data.HashMap.Strict (HashMap)+import qualified Data.HashMap.Strict as HM+import qualified Data.List.NonEmpty as NE+import Data.Maybe (mapMaybe)++-- | Build a 'batchFetch' from a per-key lookup that may not find a result.+--+-- Missing keys are silently omitted from the 'HashMap'. The engine+-- fills them with an error via @fillUnfilled@, so callers using+-- 'fetch' see an exception and callers using 'tryFetch' see @Left@.+--+-- @+-- instance DataSource AppM UserId where+--   batchFetch = optionalBatchFetch $ \\(UserId uid) ->+--     lookupUserMaybe uid+-- @+optionalBatchFetch :: (FetchKey k, Monad m)+                   => (k -> m (Maybe (Result k)))+                   -> NonEmpty k -> m (HashMap k (Result k))+optionalBatchFetch f keys = do+  results <- traverse (\k -> fmap (\mv -> (k, mv)) (f k)) keys+  pure $ HM.fromList (mapMaybe (\(k, mv) -> fmap (\v -> (k, v)) mv) (NE.toList results))++-- | Build a 'batchFetch' from a per-key action that always succeeds.+--+-- Equivalent to the default 'batchFetch' when only 'fetchOne' is+-- implemented, but as a standalone combinator for use in custom+-- 'batchFetch' implementations (e.g. to add logging around each+-- individual fetch).+--+-- @+-- instance DataSource AppM UserId where+--   batchFetch keys = do+--     logBatchStart (length keys)+--     traverseBatchFetch lookupUser keys+-- @+traverseBatchFetch :: (FetchKey k, Monad m)+                   => (k -> m (Result k))+                   -> NonEmpty k -> m (HashMap k (Result k))+traverseBatchFetch f keys =+  HM.fromList . NE.toList <$> traverse (\k -> fmap (\v -> (k, v)) (f k)) keys
+ src/Fetch/Engine.hs view
@@ -0,0 +1,172 @@+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE ExistentialQuantification #-}+{-# LANGUAGE RankNTypes #-}++module Fetch.Engine+  ( executeBatches+  , RoundStats(..)+  , emptyRoundStats+  ) where++import Fetch.Class+import Fetch.Cache+import Fetch.IVar++import Control.Concurrent.Async (async, wait)+import Control.Exception (try, toException)+import Control.Monad (unless)+import Data.HashMap.Strict (HashMap)+import qualified Data.HashMap.Strict as HM+import Data.HashSet (HashSet)+import qualified Data.HashSet as HS+import qualified Data.Map.Strict as Map++-- | Stats for a single round of fetching.+data RoundStats = RoundStats+  { roundSources   :: !Int  -- ^ Number of distinct data sources+  , roundKeys      :: !Int  -- ^ Total (deduplicated) keys fetched+  , roundCacheHits :: !Int  -- ^ Keys found in cache (skipped)+  } deriving (Eq, Show)++emptyRoundStats :: RoundStats+emptyRoundStats = RoundStats 0 0 0++-- | Execute all batches in a round. Respects FetchStrategy ordering:+--+-- 1. EagerStart sources dispatched first (async, high-latency head start)+-- 2. Sequential sources dispatched one at a time (blocking)+-- 3. Concurrent sources dispatched in parallel (async)+-- 4. All async handles awaited+--+-- The two natural transformations bridge the source monad @m@ with @IO@:+--+-- * @lower@ runs @m@ actions in @IO@ (for dispatching @batchFetch@).+-- * @liftM@ lifts @IO@ actions into @m@ (for IVar writes inside+--   streaming callbacks).+executeBatches :: forall m.+                  (forall x. m x -> IO x)+               -> (forall x. IO x -> m x)+               -> CacheRef+               -> Batches m+               -> IO RoundStats+executeBatches lower liftM cacheRef (Batches batchMap) = do+  let batches = Map.elems batchMap+      (eager, sequential, concurrent) = partitionBatches batches++  -- Phase 1: Start eager sources first (async, high-latency head start)+  eagerHandles <- mapM (async . dispatchBatch lower liftM cacheRef) eager++  -- Phase 2: Run sequential sources synchronously+  seqHits <- sum <$> mapM (dispatchBatch lower liftM cacheRef) sequential++  -- Phase 3: Start concurrent sources in parallel+  concurrentHandles <- mapM (async . dispatchBatch lower liftM cacheRef) concurrent++  -- Phase 4: Wait for all async work+  eagerHits <- sum <$> mapM wait eagerHandles+  concurrentHits <- sum <$> mapM wait concurrentHandles++  let totalKeys = sum (fmap someBatchSize batches)+      hits = eagerHits + seqHits + concurrentHits+  pure RoundStats+    { roundSources   = length batches+    , roundKeys      = totalKeys+    , roundCacheHits = hits+    }++-- | Number of keys in a single existentially-wrapped batch.+someBatchSize :: SomeBatch m -> Int+someBatchSize (SomeBatch ks) = HS.size ks++-- | Partition batches by their fetch strategy.+partitionBatches :: forall m. [SomeBatch m] -> ([SomeBatch m], [SomeBatch m], [SomeBatch m])+partitionBatches = go [] [] []+  where+    go e s c [] = (reverse e, reverse s, reverse c)+    go e s c (b@(SomeBatch (_ :: HashSet k)) : rest) =+      case fetchStrategy @m @k Proxy of+        EagerStart  -> go (b : e) s c rest+        Sequential  -> go e (b : s) c rest+        Concurrent  -> go e s (b : c) rest++-- | Dispatch a single batch: allocate IVars for new keys, call+-- streamingFetch, and fill IVars with results. If the source throws,+-- fill all unfilled IVars with the exception.+-- Returns the number of cache hits (keys already in cache).+dispatchBatch :: forall m. (forall x. m x -> IO x) -> (forall x. IO x -> m x) -> CacheRef -> SomeBatch m -> IO Int+dispatchBatch lower liftM cacheRef (SomeBatch (ks :: HashSet k)) = do+  let policy = cachePolicy @m @k Proxy+  case policy of+    CacheResults -> dispatchCached lower liftM cacheRef ks+    NoCaching    -> dispatchUncached lower liftM cacheRef ks++-- | Normal path: allocate IVars, skip already-cached keys, fetch the rest.+-- Returns the number of cache hits (keys that were already cached).+dispatchCached :: forall m k. (DataSource m k, Typeable (Result k))+               => (forall x. m x -> IO x) -> (forall x. IO x -> m x)+               -> CacheRef -> HashSet k -> IO Int+dispatchCached lower liftM cacheRef ks = do+  let totalRequested = HS.size ks+  newPairs <- cacheAllocate cacheRef (HS.toList ks)+  let hits = totalRequested - length newPairs+  runStreamingFetch lower liftM newPairs+  pure hits++-- | Uncached path for 'NoCaching' data sources.+--+-- IVars are still placed in the cache for result delivery /within/+-- this round (so that deduplicated continuations from @\<*\>@ can+-- all read the same value via 'lookupOrAwait'). However, results+-- do not persist across rounds:+--+-- * 'cacheAllocateForce' overwrites any stale IVars left by a+--   prior round, so the continuation always reads a fresh result.+-- * @fetch@\/@tryFetch@ skip the cache check for 'NoCaching' keys,+--   so a subsequent round always returns 'Blocked' and re-enters+--   this dispatch path.+--+-- Together, these two mechanisms guarantee that the data source is+-- called once per round the key appears in, while within-round+-- deduplication is preserved.+dispatchUncached :: forall m k. (DataSource m k, Typeable (Result k))+                 => (forall x. m x -> IO x) -> (forall x. IO x -> m x)+                 -> CacheRef -> HashSet k -> IO Int+dispatchUncached lower liftM cacheRef ks = do+  newPairs <- cacheAllocateForce cacheRef (HS.toList ks)+  runStreamingFetch lower liftM newPairs+  pure 0++-- | Run streamingFetch for newly allocated IVars and fill any+-- missing or errored ones. No-op when the pair list is empty+-- (all keys were already cached or in-flight).+runStreamingFetch :: forall m k. DataSource m k+                  => (forall x. m x -> IO x)+                  -> (forall x. IO x -> m x)+                  -> [(k, IVar (Result k))]+                  -> IO ()+runStreamingFetch _ _ [] = pure ()+runStreamingFetch lower liftM (p : ps) = do+  let newPairs = p : ps+      ivarMap = HM.fromList newPairs+      newKeys = fst p :| map fst ps+  -- streamingFetch runs in m; the callback lifts IO IVar writes into m.+  -- We lower the whole m action to IO.+  result <- try $ lower $ streamingFetch @m @k newKeys $ \k v ->+    case HM.lookup k ivarMap of+      Just iv -> liftM $ writeIVar iv v+      Nothing -> pure ()+  case result of+    Right () -> fillUnfilled missingKeyError ivarMap+    Left ex  -> fillUnfilled ex ivarMap++-- | Fill all unfilled IVars with an error.+fillUnfilled :: SomeException -> HashMap k (IVar a) -> IO ()+fillUnfilled ex = mapM_ $ \iv -> do+  filled <- isIVarFilled iv+  unless filled $ writeIVarError iv ex++missingKeyError :: SomeException+missingKeyError = toException $ FetchError "Key not returned by data source"
+ src/Fetch/IVar.hs view
@@ -0,0 +1,56 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DerivingStrategies #-}++module Fetch.IVar+  ( IVar+  , newIVar+  , writeIVar+  , writeIVarError+  , tryReadIVar+  , awaitIVar+  , isIVarFilled+  , FetchError(..)+  ) where++import Control.Concurrent.MVar+import Control.Exception (Exception, SomeException)+import Control.Monad (void)++-- | A write-once variable with error support.+--+-- Used internally by the cache to track in-flight fetches.+-- Reading blocks until the IVar is filled. Writing is idempotent:+-- only the first write takes effect.+newtype IVar a = IVar+  { ivarResult :: MVar (Either SomeException a)+    -- ^ Blocks readers until filled.+  }++-- | Create an empty IVar.+newIVar :: IO (IVar a)+newIVar = IVar <$> newEmptyMVar++-- | Non-blocking: is this IVar already resolved?+isIVarFilled :: IVar a -> IO Bool+isIVarFilled = fmap not . isEmptyMVar . ivarResult++-- | Non-blocking read. Returns 'Nothing' if not yet filled.+tryReadIVar :: IVar a -> IO (Maybe (Either SomeException a))+tryReadIVar = tryReadMVar . ivarResult++-- | Blocking read. Waits until the IVar is filled.+awaitIVar :: IVar a -> IO (Either SomeException a)+awaitIVar = readMVar . ivarResult++-- | Fill with a success value. Only the first write wins.+writeIVar :: IVar a -> a -> IO ()+writeIVar iv a = void $ tryPutMVar (ivarResult iv) (Right a)++-- | Fill with an error. Only the first write wins.+writeIVarError :: IVar a -> SomeException -> IO ()+writeIVarError iv e = void $ tryPutMVar (ivarResult iv) (Left e)++-- | Errors produced by the fetch engine itself (not by data sources).+newtype FetchError = FetchError String+  deriving stock (Eq, Show)+  deriving anyclass (Exception)
+ src/Fetch/Memo.hs view
@@ -0,0 +1,103 @@+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE RankNTypes #-}++module Fetch.Memo+  ( MemoKey(..)+  , MemoStore+  , newMemoStore+  , memo+  , memoOn+  ) where++import Data.Dynamic+import Data.HashMap.Strict (HashMap)+import qualified Data.HashMap.Strict as HM+import Data.Hashable (Hashable)+import Data.IORef+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Proxy+import Type.Reflection (SomeTypeRep, someTypeRep)++-- | A typed key for memoized computations.+class (Typeable k, Hashable k, Eq k) => MemoKey k where+  type MemoResult k++-- | A mutable store for memoized computation results.+-- Separate from the fetch cache (different lifetime, different concerns).+newtype MemoStore = MemoStore (IORef (Map SomeTypeRep Dynamic))++-- | Create an empty memo store.+newMemoStore :: IO MemoStore+newMemoStore = MemoStore <$> newIORef Map.empty++-- | Memoize a computation by key. If the key has been computed before+-- (in this MemoStore), return the cached result. Otherwise, run the+-- computation and cache it.+--+-- __Concurrency note:__ if two threads call @memo@ with the same key+-- concurrently and no cached value exists yet, both may execute the+-- action. One result will be stored (last writer wins); the other is+-- discarded. This is safe when the action is idempotent or pure, but+-- means @memo@ is /not/ a once-only guarantee; it is a best-effort+-- deduplication. If you need exactly-once semantics, synchronise+-- externally (e.g. with an 'MVar' per key).+memo :: forall k m. (MemoKey k, Typeable (MemoResult k), Monad m)+     => MemoStore+     -> (forall x. IO x -> m x)+     -> k+     -> m (MemoResult k)+     -> m (MemoResult k)+memo store toIO k = memoImpl store toIO (someTypeRep (Proxy @k)) k++-- | Memoize with an inline key type. Convenient for one-off+-- memoization without declaring a MemoKey instance.+--+-- Uses the result type for disambiguation, so beware of+-- ambiguous types.+--+-- Same concurrency caveat as 'memo': concurrent calls for the+-- same key may execute the action more than once.+memoOn :: forall k a m. (Typeable a, Typeable k, Hashable k, Monad m)+       => MemoStore+       -> (forall x. IO x -> m x)+       -> k+       -> m a+       -> m a+memoOn store toIO k =+  -- Key the store entry by (key type, result type) to disambiguate+  -- different result types sharing the same key type.+  memoImpl store toIO (someTypeRep (Proxy @(k, a))) k++-- | Shared memoization logic. Looks up @trep@ in the store,+-- returning the cached value if found, otherwise runs the action+-- and atomically inserts the result.+memoImpl :: forall k v m.+            (Typeable k, Typeable v, Hashable k, Monad m)+         => MemoStore+         -> (forall x. IO x -> m x)+         -> SomeTypeRep+         -> k+         -> m v+         -> m v+memoImpl (MemoStore ref) toIO trep k action = do+  existing <- toIO $ atomicModifyIORef' ref $ \store ->+    case Map.lookup trep store >>= fromDynamic of+      Just (hm :: HashMap k v) -> (store, HM.lookup k hm)+      Nothing                  -> (store, Nothing)+  case existing of+    Just v  -> pure v+    Nothing -> do+      v <- action+      -- Atomic insert: re-reads the current map to avoid clobbering+      -- concurrent writes to other keys.+      toIO $ atomicModifyIORef' ref $ \store ->+        let hm :: HashMap k v+            hm = case Map.lookup trep store >>= fromDynamic of+              Just m  -> m+              Nothing -> HM.empty+        in (Map.insert trep (toDyn (HM.insert k v hm)) store, ())+      pure v
+ src/Fetch/Mock.hs view
@@ -0,0 +1,264 @@+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE ExistentialQuantification #-}+{-# LANGUAGE FunctionalDependencies #-}+{-# LANGUAGE TypeOperators #-}+{-# LANGUAGE KindSignatures #-}++module Fetch.Mock+  ( -- * Mock fetch+    MockFetch+  , runMockFetch+  , ResultMap+  , mockData+  , emptyMockData+    -- * Mock mutations+  , MockMutate+  , runMockMutate+  , MutationHandlers+  , mockMutation+  , emptyMutationHandlers+  , RecordedMutation(..)+  ) where++import Data.Kind (Type)++import Fetch.Class+import Fetch.Mutate (MonadMutate(..))+import Fetch.IVar (FetchError(..))++import Control.Exception (toException, try, evaluate, throw, throwIO)+import Control.Monad.Catch (MonadThrow(..), MonadCatch(..))+import Data.Dynamic+import Data.HashMap.Strict (HashMap)+import qualified Data.HashMap.Strict as HM+import Data.IORef+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Type.Reflection (SomeTypeRep(..), someTypeRep, typeOf)++-- | Pre-built result data for testing. Each entry wraps a+-- @HashMap k (Result k)@ as a Dynamic, keyed by source type.+newtype ResultMap = ResultMap (Map SomeTypeRep Dynamic)++instance Semigroup ResultMap where+  ResultMap a <> ResultMap b = ResultMap (Map.union a b)++instance Monoid ResultMap where+  mempty = ResultMap Map.empty++-- | Create mock data for a specific key type.+--+-- @mockData \@UserId [(UserId 1, testUser), (UserId 2, otherUser)]@+mockData :: forall k. (FetchKey k, Typeable (Result k))+         => [(k, Result k)] -> ResultMap+mockData pairs = ResultMap $+  Map.singleton+    (someTypeRep (Proxy @k))+    (toDyn (HM.fromList pairs :: HashMap k (Result k)))++-- | Empty mock data.+emptyMockData :: ResultMap+emptyMockData = mempty++-- | Look up a key in the mock data.+lookupMock :: forall k. (FetchKey k, Typeable (Result k))+           => ResultMap -> k -> Either SomeException (Result k)+lookupMock (ResultMap m) k =+  case Map.lookup (someTypeRep (Proxy @k)) m >>= fromDynamic of+    Just (hm :: HashMap k (Result k)) ->+      case HM.lookup k hm of+        Just v  -> Right v+        Nothing -> Left $ toException $ FetchError $+          "Mock data missing key: " <> show k+    Nothing -> Left $ toException $ FetchError $+      "No mock data for source: " <> show (someTypeRep (Proxy @k))++-- | A test-oriented MonadFetch that reads from a pre-built result map.+-- No IO, no batching, no caching. Just direct lookups.+--+-- The @m@ phantom type parameter means the same @MonadFetch m n@+-- constraints work in both production and test code. Users specify+-- @m@ at the call site (e.g. @runMockFetch \@AppM mocks action@).+-- | @m@ is a phantom type representing the source monad (for instance selection).+newtype MockFetch (m :: Type -> Type) n a = MockFetch { unMockFetch :: ResultMap -> n a }++instance Functor n => Functor (MockFetch m n) where+  fmap f (MockFetch g) = MockFetch (fmap f . g)++instance Applicative n => Applicative (MockFetch m n) where+  pure a = MockFetch $ \_ -> pure a+  MockFetch ff <*> MockFetch fx = MockFetch $ \rm ->+    ff rm <*> fx rm++instance Monad n => Monad (MockFetch m n) where+  MockFetch ma >>= f = MockFetch $ \rm -> do+    a <- ma rm+    unMockFetch (f a) rm++-- | Note: the 'DataSource m k' constraint is still required for+-- type inference, but the batchFetch is never used.+-- Only the ResultMap is consulted.+instance Monad n => MonadFetch m (MockFetch m n) where+  fetch k = MockFetch $ \rm ->+    case lookupMock rm k of+      Right v -> pure v+      Left ex -> throw ex++  tryFetch k = MockFetch $ \rm ->+    pure (lookupMock rm k)++  primeCache _ _ = pure ()++instance MonadFail n => MonadFail (MockFetch m n) where+  fail msg = MockFetch $ \_ -> fail msg++instance MonadThrow n => MonadThrow (MockFetch m n) where+  throwM e = MockFetch $ \_ -> throwM e++instance MonadCatch n => MonadCatch (MockFetch m n) where+  catch (MockFetch f) handler = MockFetch $ \rm ->+    catch (f rm) (\e -> unMockFetch (handler e) rm)++-- | Run a computation against mock data.+--+-- @+-- testGetUserFeed :: IO ()+-- testGetUserFeed = do+--   let mocks = mockData \@UserId [(UserId 1, testUser)]+--            <> mockData \@PostsByAuthor [(PostsByAuthor 1, [testPost])]+--   feed <- runMockFetch \@AppM mocks (getUserFeed (UserId 1))+--   assertEqual (feedUser feed) testUser+-- @+runMockFetch :: ResultMap -> MockFetch m n a -> n a+runMockFetch rm (MockFetch f) = f rm++-- ──────────────────────────────────────────────+-- Mock mutation support+-- ──────────────────────────────────────────────++-- | A map of mutation handlers, keyed by mutation key type.+-- Each handler is a @Dynamic@-wrapped @k -> MutationResult k@.+newtype MutationHandlers = MutationHandlers (Map SomeTypeRep Dynamic)++instance Semigroup MutationHandlers where+  MutationHandlers a <> MutationHandlers b = MutationHandlers (Map.union a b)++instance Monoid MutationHandlers where+  mempty = MutationHandlers Map.empty++-- | Empty mutation handlers.+emptyMutationHandlers :: MutationHandlers+emptyMutationHandlers = mempty++-- | Register a mock handler for a mutation key type.+--+-- @mockMutation \@UpdateUser (\\(UpdateUser uid name) -> User uid name)@+mockMutation :: forall k. (MutationKey k, Typeable (MutationResult k))+             => (k -> MutationResult k) -> MutationHandlers+mockMutation handler = MutationHandlers $+  Map.singleton+    (someTypeRep (Proxy @k))+    (toDyn handler)++-- | Look up a mutation handler.+lookupMutationHandler :: forall k. (MutationKey k, Typeable (MutationResult k))+                      => MutationHandlers -> k -> Either SomeException (MutationResult k)+lookupMutationHandler (MutationHandlers m) k =+  case Map.lookup (someTypeRep (Proxy @k)) m >>= fromDynamic of+    Just (handler :: k -> MutationResult k) -> Right (handler k)+    Nothing -> Left $ toException $ FetchError $+      "No mock mutation handler for: " <> show (someTypeRep (Proxy @k))++-- | A recorded mutation: captures the type and the existentially-wrapped+-- key for assertions. Pattern match on 'recordedMutationKey' when you+-- need to inspect the concrete key value.+data RecordedMutation = forall k. MutationKey k+  => RecordedMutation+       { recordedMutationType :: !SomeTypeRep+       , recordedMutationKey  :: !k+       }++instance Show RecordedMutation where+  show (RecordedMutation tr _) =+    "RecordedMutation " <> show tr++-- | A test-oriented monad that supports both fetches (from 'ResultMap')+-- and mutations (from 'MutationHandlers'), recording all mutations+-- for assertions.+-- | @m@ is a phantom type representing the source monad (for instance selection).+newtype MockMutate (m :: Type -> Type) n a = MockMutate+  { unMockMutate :: ResultMap -> MutationHandlers -> IORef [RecordedMutation] -> n a }++instance Functor n => Functor (MockMutate m n) where+  fmap f (MockMutate g) = MockMutate $ \rm mh ref -> fmap f (g rm mh ref)++instance Applicative n => Applicative (MockMutate m n) where+  pure a = MockMutate $ \_ _ _ -> pure a+  MockMutate ff <*> MockMutate fx = MockMutate $ \rm mh ref ->+    ff rm mh ref <*> fx rm mh ref++instance Monad n => Monad (MockMutate m n) where+  MockMutate ma >>= f = MockMutate $ \rm mh ref -> do+    a <- ma rm mh ref+    unMockMutate (f a) rm mh ref++instance (Monad n, n ~ IO) => MonadFetch m (MockMutate m n) where+  fetch k = MockMutate $ \rm _ _ ->+    case lookupMock rm k of+      Right v -> pure v+      Left ex -> throwIO ex+  tryFetch k = MockMutate $ \rm _ _ ->+    pure (lookupMock rm k)+  primeCache _ _ = pure ()++instance (Monad n, n ~ IO) => MonadMutate m (MockMutate m n) where+  mutate k = MockMutate $ \_ mh ref ->+    case lookupMutationHandler mh k of+      Right v -> do+        let tr = SomeTypeRep (typeOf k)+        atomicModifyIORef' ref $ \recs ->+          (recs <> [RecordedMutation tr k], ())+        pure v+      Left ex -> throwIO ex++  tryMutate k = MockMutate $ \_ mh ref -> do+    result <- try $ evaluate $ lookupMutationHandler mh k+    case result of+      Right (Right v) -> do+        let tr = SomeTypeRep (typeOf k)+        atomicModifyIORef' ref $ \recs ->+          (recs <> [RecordedMutation tr k], ())+        pure (Right v)+      Right (Left ex) -> pure (Left ex)+      Left ex -> pure (Left ex)++instance MonadFail n => MonadFail (MockMutate m n) where+  fail msg = MockMutate $ \_ _ _ -> fail msg++instance MonadThrow n => MonadThrow (MockMutate m n) where+  throwM e = MockMutate $ \_ _ _ -> throwM e++instance MonadCatch n => MonadCatch (MockMutate m n) where+  catch (MockMutate f) handler = MockMutate $ \rm mh ref ->+    catch (f rm mh ref) (\e -> unMockMutate (handler e) rm mh ref)++-- | Run a computation against mock data and mutation handlers.+-- Returns the result and a list of recorded mutations.+--+-- @+-- let mocks = mockData \@UserId [(UserId 1, User 1 "alice")]+--     handlers = mockMutation \@UpdateUser (\\(UpdateUser uid name) -> User uid name)+-- (result, mutations) <- runMockMutate \@AppM mocks handlers myAction+-- length mutations \`shouldBe\` 1+-- @+runMockMutate :: ResultMap -> MutationHandlers -> MockMutate m IO a -> IO (a, [RecordedMutation])+runMockMutate rm mh (MockMutate f) = do+  ref <- newIORef []+  a <- f rm mh ref+  mutations <- readIORef ref+  pure (a, mutations)
+ src/Fetch/Mutate.hs view
@@ -0,0 +1,253 @@+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE FunctionalDependencies #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE ExistentialQuantification #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE TypeApplications #-}++-- | Principled mutation support for sofetch.+--+-- 'Mutate' is a free-monad-like transformer layered on top of 'Fetch'.+-- A computation is a sequence of __fetch phases__ (batched reads via+-- 'Fetch') interleaved with __mutation steps__ (sequential writes).+--+-- Mutations are inert data during 'Fetch' probing; they only+-- execute when the runner processes them. This guarantees:+--+-- * Within a fetch phase: all fetches batch via 'Fetch'\'s 'Applicative'.+-- * Between phases: mutations execute one at a time, sequentially.+-- * In @\<*\>@: fetches run first (batched), then mutations left-to-right.+-- * Cache consistency: 'reconcileCache' runs after each mutation,+--   before any subsequent fetch phase sees the cache.+module Fetch.Mutate+  ( -- * Mutation classes+    MutationSource(..)+  , MonadMutate(..)+    -- * Mutate transformer+  , Mutate(..)+  , Step(..)+  , liftFetch+    -- * Runners+  , runMutate+  ) where++import Fetch.Class+import Fetch.Cache (CacheRef, newCacheRef)+import Fetch.Batched (Fetch, FetchConfig(..), runFetch)++import Control.Exception (try)+import Control.Monad.Catch (MonadThrow(..), MonadCatch(..))++-- ──────────────────────────────────────────────+-- MutationSource+-- ──────────────────────────────────────────────++-- | How to execute a mutation in the source monad @m@.+--+-- The @m@ parameter replaces the old @env@ parameter, just as+-- in 'DataSource'. The monad @m@ provides access to any needed+-- resources (database connections, etc.).+--+-- @+-- instance MutationSource AppM UpdateUserName where+--   executeMutation (UpdateUserName uid name) =+--     updateUserInDB uid name+--+--   reconcileCache (UpdateUserName uid _) result cRef =+--     cacheWarm cRef (HM.singleton (UserId uid) result)+-- @+class (MutationKey k, Typeable (MutationResult k)) => MutationSource m k where+  -- | Execute the mutation. Called by the runner, never during+  -- 'Fetch' probing.+  executeMutation :: k -> m (MutationResult k)++  -- | Reconcile the cache after a successful mutation.+  -- Use this to evict stale entries or warm the cache with+  -- fresh data from the mutation response.+  --+  -- Note: 'reconcileCache' runs in @IO@ because cache operations+  -- are inherently @IO@-based ('CacheRef' is an 'IORef').+  --+  -- Default: no-op.+  reconcileCache :: k -> MutationResult k -> CacheRef -> IO ()+  reconcileCache _ _ _ = pure ()++-- ──────────────────────────────────────────────+-- MonadMutate+-- ──────────────────────────────────────────────++-- | The mutation interface. Extends 'MonadFetch' with write operations.+--+-- @mutate@ ends the current fetch phase, executes the mutation via+-- the runner, reconciles the cache, and returns the result. Subsequent+-- fetches see the reconciled cache.+class MonadFetch m n => MonadMutate m n | n -> m where+  -- | Execute a mutation. Throws on error.+  mutate :: MutationSource m k => k -> n (MutationResult k)++  -- | Execute a mutation with explicit error handling.+  tryMutate :: MutationSource m k+            => k -> n (Either SomeException (MutationResult k))++-- ──────────────────────────────────────────────+-- Step+-- ──────────────────────────────────────────────++-- | The result of a fetch phase: either a final value or a mutation+-- boundary with a continuation.+data Step m n a+  = StepDone a+  | forall k. MutationSource m k+    => StepMutate k (MutationResult k -> Mutate m n a)+  | forall k. MutationSource m k+    => StepTryMutate k (Either SomeException (MutationResult k) -> Mutate m n a)++-- | Map a function over the final value of a 'Step'.+mapStep :: Monad n => (a -> b) -> Step m n a -> Step m n b+mapStep f (StepDone a)          = StepDone (f a)+mapStep f (StepMutate k cont)   = StepMutate k (fmap f . cont)+mapStep f (StepTryMutate k cont) = StepTryMutate k (fmap f . cont)++-- | Combine two 'Step' values applicatively.+-- Fetch-phase results combine directly; mutations sequence left-to-right.+apStep :: Monad n => Step m n (a -> b) -> Step m n a -> Step m n b+apStep (StepDone f) (StepDone x) =+  StepDone (f x)+apStep (StepDone f) (StepMutate k cont) =+  StepMutate k (fmap f . cont)+apStep (StepDone f) (StepTryMutate k cont) =+  StepTryMutate k (fmap f . cont)+apStep (StepMutate k cont) (StepDone x) =+  StepMutate k (fmap ($ x) . cont)+apStep (StepTryMutate k cont) (StepDone x) =+  StepTryMutate k (fmap ($ x) . cont)+-- Two mutations: sequence left first, then embed the right step+-- into the left's continuation.+apStep (StepMutate k1 cont1) step2 =+  StepMutate k1 $ \r1 ->+    cont1 r1 <*> embedStep step2+apStep (StepTryMutate k1 cont1) step2 =+  StepTryMutate k1 $ \r1 ->+    cont1 r1 <*> embedStep step2++-- | Inject a 'Step' into 'Mutate' as a trivial fetch phase.+embedStep :: Monad n => Step m n a -> Mutate m n a+embedStep (StepDone a)          = Mutate (pure (StepDone a))+embedStep (StepMutate k cont)   = Mutate (pure (StepMutate k cont))+embedStep (StepTryMutate k cont) = Mutate (pure (StepTryMutate k cont))++-- ──────────────────────────────────────────────+-- Mutate+-- ──────────────────────────────────────────────++-- | A computation that interleaves batched fetch phases with+-- sequential mutations.+--+-- @m@ is the source monad (same as in 'DataSource' and 'Fetch').+-- @n@ is the base monad for 'Fetch'.+--+-- In practice, @n@ is always @m@ and 'Mutate m m' is layered on+-- 'Fetch m'.+--+-- @+-- do (user, posts) <- (,) \<$\> fetch uid \<*\> fetch pid  -- batched+--    updated <- mutate (UpdateUserName uid "new")        -- mutation boundary+--    fetch uid                                            -- cache hit+-- @+newtype Mutate m n a = Mutate+  { unMutate :: Fetch n (Step m n a) }++instance Monad n => Functor (Mutate m n) where+  fmap f (Mutate inner) = Mutate (fmap (mapStep f) inner)++instance Monad n => Applicative (Mutate m n) where+  pure a = Mutate (pure (StepDone a))++  Mutate ff <*> Mutate fx = Mutate $+    -- Delegate to Fetch's Applicative for batching, then combine Steps.+    liftA2 apStep ff fx++instance Monad n => Monad (Mutate m n) where+  Mutate ma >>= f = Mutate $ do+    step <- ma  -- runs in Fetch+    case step of+      StepDone a -> unMutate (f a)  -- continue in same Fetch phase+      StepMutate k cont ->+        pure $ StepMutate k (\r -> cont r >>= f)+      StepTryMutate k cont ->+        pure $ StepTryMutate k (\r -> cont r >>= f)++-- ──────────────────────────────────────────────+-- Instances+-- ──────────────────────────────────────────────++instance Monad m => MonadFetch m (Mutate m m) where+  fetch k      = Mutate (StepDone <$> fetch k)+  tryFetch k   = Mutate (StepDone <$> tryFetch k)+  primeCache k v = Mutate (StepDone <$> primeCache k v)++instance Monad m => MonadMutate m (Mutate m m) where+  mutate k    = Mutate (pure (StepMutate k pure))+  tryMutate k = Mutate (pure (StepTryMutate k pure))++instance MonadFail m => MonadFail (Mutate m m) where+  fail msg = Mutate (fail msg)++instance MonadThrow m => MonadThrow (Mutate m m) where+  throwM e = Mutate (throwM e)++-- | Propagates the handler through both 'Fetch' round continuations+-- (handled by 'Fetch'\'s 'MonadCatch') and 'Step' mutation+-- continuations.+instance MonadCatch m => MonadCatch (Mutate m m) where+  catch (Mutate inner) handler = Mutate $+    fmap catchStep $+      catch inner (\ex -> unMutate (handler ex))+    where+      catchStep (StepDone a)           = StepDone a+      catchStep (StepMutate k cont)    = StepMutate k (\r -> catch (cont r) handler)+      catchStep (StepTryMutate k cont) = StepTryMutate k (\r -> catch (cont r) handler)++-- | Lift a 'Fetch' computation into 'Mutate'.+-- The entire 'Fetch' runs as a single fetch phase.+liftFetch :: Monad m => Fetch m a -> Mutate m m a+liftFetch action = Mutate (StepDone <$> action)++-- ──────────────────────────────────────────────+-- Runners+-- ──────────────────────────────────────────────++-- | Run a 'Mutate' computation.+--+-- @+-- let cfg = fetchConfig (runAppM env) liftIO+-- runMutate cfg action+-- @+runMutate :: forall m a. Monad m => FetchConfig m -> Mutate m m a -> m a+runMutate cfg action = do+  cRef <- case configCache cfg of+    Just ref -> pure ref+    Nothing  -> configLift cfg newCacheRef+  let fetchCfg = cfg { configCache = Just cRef }+      go :: Mutate m m a -> m a+      go (Mutate fetchPhase) = do+        step <- runFetch fetchCfg fetchPhase+        case step of+          StepDone a -> pure a+          StepMutate k cont -> do+            result <- executeMutation k+            configLift cfg $ reconcileCache @m k result cRef+            go (cont result)+          StepTryMutate k cont -> do+            result <- configLift cfg $ try $ configLower cfg $ executeMutation k+            case result of+              Right v -> do+                configLift cfg $ reconcileCache @m k v cRef+                go (cont (Right v))+              Left ex ->+                go (cont (Left ex))+  go action
+ src/Fetch/Traced.hs view
@@ -0,0 +1,118 @@+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE DerivingStrategies #-}++module Fetch.Traced+  ( TracedFetch+  , TraceConfig(..)+  , defaultTraceConfig+  , FetchStats(..)+  , runTracedFetch+  ) where++import Fetch.Class+import Fetch.Cache+import Fetch.Batched (Fetch(..), FetchConfig(..), FetchEnv(..), runLoopWith)+import Fetch.Engine (RoundStats(..))++import Control.Monad.Catch (MonadThrow(..), MonadCatch(..))+import Data.IORef+import Data.Time.Clock (NominalDiffTime, getCurrentTime, diffUTCTime)++-- | Callbacks for observing the batching process.+data TraceConfig m = TraceConfig+  { onRoundStart    :: Int -> Batches m -> m ()+    -- ^ Called before each round with round number and pending batches.+  , onRoundComplete :: Int -> RoundStats -> m ()+    -- ^ Called after each round.+  , onFetchComplete :: FetchStats -> m ()+    -- ^ Called when the entire computation finishes.+  }++-- | No-op trace config.+defaultTraceConfig :: Applicative m => TraceConfig m+defaultTraceConfig = TraceConfig+  { onRoundStart    = \_ _ -> pure ()+  , onRoundComplete = \_ _ -> pure ()+  , onFetchComplete = \_   -> pure ()+  }++-- | Aggregate stats for an entire Fetch computation.+data FetchStats = FetchStats+  { totalRounds        :: !Int+  , totalKeys          :: !Int+  , maxSourcesPerRound :: !Int+    -- ^ Peak number of distinct data sources dispatched in any single round.+  , totalTime          :: !NominalDiffTime+  } deriving (Eq, Show)++-- | A traced variant of Fetch. Same batching\/caching behavior,+-- just adds observability hooks.+--+-- This is a newtype over 'Fetch' so it shares the exact same+-- Applicative batching. The tracing happens at the runner level.+--+-- All instances are derived via @GeneralizedNewtypeDeriving@.+-- If you define your own newtype over 'Fetch', you can use the+-- same pattern:+--+-- @+-- {-\# LANGUAGE GeneralizedNewtypeDeriving, DerivingStrategies \#-}+--+-- newtype MyFetch m a = MyFetch (Fetch m a)+--   deriving newtype+--     ( Functor, Applicative, Monad+--     , MonadFail, MonadThrow, MonadCatch+--     , MonadFetch m+--     )+-- @+newtype TracedFetch m a = TracedFetch (Fetch m a)+  deriving newtype+    ( Functor, Applicative, Monad+    , MonadFail, MonadThrow, MonadCatch+    , MonadFetch m+    )++-- | Run with tracing. Fires callbacks at each round boundary.+runTracedFetch :: Monad m+               => FetchConfig m+               -> TraceConfig m+               -> TracedFetch m a+               -> m (a, FetchStats)+runTracedFetch cfg tc (TracedFetch action) = do+  cacheRef <- case configCache cfg of+    Just ref -> pure ref+    Nothing  -> configLift cfg newCacheRef+  startTime <- configLift cfg getCurrentTime+  statsRef <- configLift cfg $ newIORef (FetchStats 0 0 0 0)++  let e = FetchEnv+        { fetchCache = cacheRef+        , fetchLower = configLower cfg+        , fetchLift  = configLift cfg+        }++      withRound n batches exec = do+        onRoundStart tc n batches+        rs <- exec+        configLift cfg $ modifyIORef' statsRef $ \s -> s+          { totalRounds  = totalRounds s + 1+          , totalKeys    = totalKeys s + roundKeys rs+          , maxSourcesPerRound = max (maxSourcesPerRound s) (roundSources rs)+          }+        onRoundComplete tc n rs++  a <- runLoopWith e withRound action++  endTime <- configLift cfg getCurrentTime+  configLift cfg $ modifyIORef' statsRef $ \s ->+    s { totalTime = diffUTCTime endTime startTime }+  stats <- configLift cfg $ readIORef statsRef++  onFetchComplete tc stats+  pure (a, stats)
+ test/Spec.hs view
@@ -0,0 +1,3215 @@+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}++module Main (main) where++import Fetch+import Fetch.Batched (Fetch(..))+import Fetch.Class (singletonBatch, batchKeys)+import Fetch.Combinators (biselect, pAnd, pOr)+import Fetch.IVar+import Fetch.Cache++import Control.Concurrent (forkIO)+import Control.Concurrent.Async (async, wait, waitCatch, cancel, replicateConcurrently_)+import Control.Concurrent.MVar+import Control.Exception (SomeException, toException, try, throwTo)+import qualified Control.Monad.Catch as MC+import Data.HashMap.Strict (HashMap)+import qualified Data.HashMap.Strict as HM+import Data.IORef+import qualified Data.List.NonEmpty as NE+import Data.Maybe (mapMaybe)+import GHC.Generics (Generic)+import Test.Hspec++-- ══════════════════════════════════════════════+-- Test key types and data sources+-- ══════════════════════════════════════════════++newtype UserId = UserId Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey UserId where+  type Result UserId = String++newtype PostId = PostId Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey PostId where+  type Result PostId = String++-- | Key type with Sequential fetch strategy.+newtype SeqKey = SeqKey Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey SeqKey where+  type Result SeqKey = String++-- | Key type with EagerStart fetch strategy.+newtype EagerKey = EagerKey Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey EagerKey where+  type Result EagerKey = String++-- | Key type whose data source always throws.+newtype FailKey = FailKey Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey FailKey where+  type Result FailKey = String++-- | Key type with NoCaching policy.+newtype MutKey = MutKey Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey MutKey where+  type Result MutKey = Int++-- | Key type whose data source blocks on a barrier before returning.+newtype SlowKey = SlowKey Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey SlowKey where+  type Result SlowKey = String++-- | Key type that always returns a value for any Int key.+-- Used for high-fan-out stress tests.+newtype RangeKey = RangeKey Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey RangeKey where+  type Result RangeKey = String++-- | Key type whose source only returns results for even-numbered keys.+-- Odd keys are silently omitted, triggering fillUnfilled.+newtype PartialKey = PartialKey Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey PartialKey where+  type Result PartialKey = String++-- | Second Sequential-strategy source for ordering tests.+newtype SeqKey2 = SeqKey2 Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey SeqKey2 where+  type Result SeqKey2 = String++-- | Sequential strategy + always throws.+newtype FailSeqKey = FailSeqKey Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey FailSeqKey where+  type Result FailSeqKey = String++-- | EagerStart strategy + always throws.+newtype FailEagerKey = FailEagerKey Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey FailEagerKey where+  type Result FailEagerKey = String++-- | Key whose batchFetch signals on a barrier before blocking.+-- Used for async exception tests.+newtype BlockingKey = BlockingKey Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance FetchKey BlockingKey where+  type Result BlockingKey = String++-- ══════════════════════════════════════════════+-- Test environment and monad+-- ══════════════════════════════════════════════++data TestEnv = TestEnv+  { envUsers        :: HashMap UserId String+  , envUserLog      :: IORef [[UserId]]+  , envPosts        :: HashMap PostId String+  , envPostLog      :: IORef [[PostId]]+  , envMutLog       :: IORef [[MutKey]]+  , envMutCount     :: IORef Int+  , envSlowBarrier  :: MVar ()+  , envDispatchLog  :: IORef [String]+    -- ^ Each source's batchFetch atomically appends its type name.+  , envAsyncStarted :: MVar ()+    -- ^ BlockingKey's batchFetch signals here when it enters.+  , envAsyncProceed :: MVar ()+    -- ^ BlockingKey's batchFetch blocks here until released.+  }++mkTestEnv :: IO TestEnv+mkTestEnv = TestEnv+  <$> pure defaultUsers+  <*> newIORef []+  <*> pure defaultPosts+  <*> newIORef []+  <*> newIORef []+  <*> newIORef 0+  <*> newMVar ()  -- starts full so non-SlowKey tests are unaffected+  <*> newIORef []+  <*> newEmptyMVar  -- envAsyncStarted: empty until BlockingKey signals+  <*> newEmptyMVar  -- envAsyncProceed: empty until test releases++defaultUsers :: HashMap UserId String+defaultUsers = HM.fromList+  [ (UserId 1, "Alice")+  , (UserId 2, "Bob")+  , (UserId 3, "Carol")+  ]++defaultPosts :: HashMap PostId String+defaultPosts = HM.fromList+  [ (PostId 10, "Hello World")+  , (PostId 20, "Haskell Tips")+  , (PostId 30, "Type Families")+  ]++-- | The test monad. A thin Reader over IO carrying 'TestEnv'.+-- This is what 'DataSource' instances run in.+newtype TestM a = TestM { unTestM :: TestEnv -> IO a }++instance Functor TestM where+  fmap f (TestM g) = TestM $ \env -> fmap f (g env)++instance Applicative TestM where+  pure a = TestM $ \_ -> pure a+  TestM ff <*> TestM fx = TestM $ \env -> ff env <*> fx env++instance Monad TestM where+  TestM ma >>= f = TestM $ \env -> do+    a <- ma env+    unTestM (f a) env++askTestEnv :: TestM TestEnv+askTestEnv = TestM pure++-- | Lift an IO action into TestM.+testLiftIO :: IO a -> TestM a+testLiftIO io = TestM $ \_ -> io++-- | Run a TestM action in IO, given the environment.+runTestM :: TestEnv -> TestM a -> IO a+runTestM env (TestM f) = f env++-- ══════════════════════════════════════════════+-- DataSource instances+-- ══════════════════════════════════════════════++instance DataSource TestM UserId where+  batchFetch keysNE = do+    let keys = NE.toList keysNE+    env <- askTestEnv+    testLiftIO $ modifyIORef' (envUserLog env) (keys :)+    testLiftIO $ atomicModifyIORef' (envDispatchLog env) (\l -> (l ++ ["UserId"], ()))+    pure $ HM.fromList+      (mapMaybe (\k -> fmap (\v -> (k, v)) (HM.lookup k (envUsers env))) keys)++instance DataSource TestM PostId where+  batchFetch keysNE = do+    let keys = NE.toList keysNE+    env <- askTestEnv+    testLiftIO $ modifyIORef' (envPostLog env) (keys :)+    testLiftIO $ atomicModifyIORef' (envDispatchLog env) (\l -> (l ++ ["PostId"], ()))+    pure $ HM.fromList+      (mapMaybe (\k -> fmap (\v -> (k, v)) (HM.lookup k (envPosts env))) keys)++instance DataSource TestM SeqKey where+  batchFetch keysNE = do+    env <- askTestEnv+    testLiftIO $ atomicModifyIORef' (envDispatchLog env) (\l -> (l ++ ["SeqKey"], ()))+    pure $ HM.fromList+      (map (\k@(SeqKey n) -> (k, "seq-" <> show n)) (NE.toList keysNE))+  fetchStrategy _ = Sequential++instance DataSource TestM EagerKey where+  batchFetch keysNE = do+    env <- askTestEnv+    testLiftIO $ atomicModifyIORef' (envDispatchLog env) (\l -> (l ++ ["EagerKey"], ()))+    pure $ HM.fromList+      (map (\k@(EagerKey n) -> (k, "eager-" <> show n)) (NE.toList keysNE))+  fetchStrategy _ = EagerStart++instance DataSource TestM FailKey where+  batchFetch _ = do+    env <- askTestEnv+    testLiftIO $ atomicModifyIORef' (envDispatchLog env) (\l -> (l ++ ["FailKey"], ()))+    error "FailKey data source exploded"++instance DataSource TestM MutKey where+  batchFetch keysNE = do+    let keys = NE.toList keysNE+    env <- askTestEnv+    testLiftIO $ modifyIORef' (envMutLog env) (keys :)+    n <- testLiftIO $ atomicModifyIORef' (envMutCount env) (\c -> (c + 1, c))+    pure $ HM.fromList (map (\k -> (k, n)) keys)+  cachePolicy _ = NoCaching++instance DataSource TestM SlowKey where+  batchFetch keysNE = do+    env <- askTestEnv+    testLiftIO $ takeMVar (envSlowBarrier env)  -- block until test releases+    pure $ HM.fromList+      (map (\k@(SlowKey n) -> (k, "slow-" <> show n)) (NE.toList keysNE))++instance DataSource TestM RangeKey where+  batchFetch keysNE =+    pure $ HM.fromList+      (map (\k@(RangeKey n) -> (k, "range-" <> show n)) (NE.toList keysNE))++instance DataSource TestM PartialKey where+  batchFetch keysNE = do+    env <- askTestEnv+    testLiftIO $ atomicModifyIORef' (envDispatchLog env) (\l -> (l ++ ["PartialKey"], ()))+    -- Only return results for even-numbered keys; odd keys are silently omitted.+    pure $ HM.fromList+      (mapMaybe (\k@(PartialKey n) ->+        if even n then Just (k, "partial-" <> show n) else Nothing)+        (NE.toList keysNE))++instance DataSource TestM SeqKey2 where+  batchFetch keysNE = do+    env <- askTestEnv+    testLiftIO $ atomicModifyIORef' (envDispatchLog env) (\l -> (l ++ ["SeqKey2"], ()))+    pure $ HM.fromList+      (map (\k@(SeqKey2 n) -> (k, "seq2-" <> show n)) (NE.toList keysNE))+  fetchStrategy _ = Sequential++instance DataSource TestM FailSeqKey where+  batchFetch _ = do+    env <- askTestEnv+    testLiftIO $ atomicModifyIORef' (envDispatchLog env) (\l -> (l ++ ["FailSeqKey"], ()))+    error "FailSeqKey data source exploded"+  fetchStrategy _ = Sequential++instance DataSource TestM FailEagerKey where+  batchFetch _ = do+    env <- askTestEnv+    testLiftIO $ atomicModifyIORef' (envDispatchLog env) (\l -> (l ++ ["FailEagerKey"], ()))+    error "FailEagerKey data source exploded"+  fetchStrategy _ = EagerStart++instance DataSource TestM BlockingKey where+  batchFetch keysNE = do+    env <- askTestEnv+    -- Signal that the batch has entered+    testLiftIO $ putMVar (envAsyncStarted env) ()+    -- Block until the test releases+    testLiftIO $ takeMVar (envAsyncProceed env)+    pure $ HM.fromList+      (map (\k@(BlockingKey n) -> (k, "blocking-" <> show n)) (NE.toList keysNE))++-- ══════════════════════════════════════════════+-- Helpers+-- ══════════════════════════════════════════════++-- | Run a Fetch computation over TestM in IO.+runTest :: TestEnv -> Fetch TestM a -> IO a+runTest env = runTestM env . runFetch (fetchConfig (runTestM env) testLiftIO)++-- | Run a Fetch computation with an externally-provided cache.+runTestWithCache :: TestEnv -> CacheRef -> Fetch TestM a -> IO a+runTestWithCache env cRef = runTestM env . runFetch ((fetchConfig (runTestM env) testLiftIO) { configCache = Just cRef })++-- | Run a Fetch computation and capture per-round (roundNumber, batchSize, sourceCount).+runTestWithRoundLog :: TestEnv -> Fetch TestM a -> IO (a, [(Int, Int, Int)])+runTestWithRoundLog env action = do+  logRef <- newIORef ([] :: [(Int, Int, Int)])+  cRef <- newCacheRef+  let e = FetchEnv+        { fetchCache = cRef+        , fetchLower = runTestM env+        , fetchLift  = testLiftIO+        }+  a <- runTestM env $ runLoopWith e (\n batches exec -> do+    testLiftIO $ modifyIORef' logRef+      (\l -> l ++ [(n, batchSize batches, batchSourceCount batches)])+    _ <- exec+    pure ()+    ) action+  lg <- readIORef logRef+  pure (a, lg)++instance MC.MonadThrow TestM where+  throwM = testLiftIO . MC.throwM++instance MC.MonadCatch TestM where+  catch (TestM f) handler = TestM $ \env ->+    MC.catch (f env) (\e -> unTestM (handler e) env)++-- ══════════════════════════════════════════════+-- Main+-- ══════════════════════════════════════════════++main :: IO ()+main = hspec $ do+  ivarSpec+  cacheSpec+  batchesSpec+  batchedSpec+  primeCacheSpec+  engineSpec+  combinatorSpec+  biselectSpec+  mockSpec+  tracedSpec+  memoSpec+  raceSpec+  mutateSpec+  applicativeErrorSpec+  sourceIsolationSpec+  partialBatchSpec+  strategyIsolationSpec+  complexPatternSpec+  liftSourceSpec+  noCachingSpec+  roundStatsSpec+  throwCatchSpec+  asyncExceptionSpec++-- ══════════════════════════════════════════════+-- IVar tests+-- ══════════════════════════════════════════════++ivarSpec :: Spec+ivarSpec = describe "Fetch.IVar" $ do++  it "newIVar starts empty" $ do+    iv <- newIVar @Int+    filled <- isIVarFilled iv+    filled `shouldBe` False++  it "tryReadIVar on empty returns Nothing" $ do+    iv <- newIVar @Int+    mr <- tryReadIVar iv+    case mr of+      Nothing -> pure ()+      Just _  -> expectationFailure "Expected Nothing"++  it "writeIVar then awaitIVar returns Right value" $ do+    iv <- newIVar+    writeIVar iv (42 :: Int)+    result <- awaitIVar iv+    case result of+      Right v -> v `shouldBe` 42+      Left _  -> expectationFailure "Expected Right"++  it "writeIVarError then awaitIVar returns Left" $ do+    iv <- newIVar @Int+    let ex = toException (FetchError "test error")+    writeIVarError iv ex+    result <- awaitIVar iv+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left"++  it "isIVarFilled returns True after write" $ do+    iv <- newIVar+    writeIVar iv (99 :: Int)+    filled <- isIVarFilled iv+    filled `shouldBe` True++  it "tryReadIVar on filled returns Just (Right value)" $ do+    iv <- newIVar+    writeIVar iv ("hello" :: String)+    mr <- tryReadIVar iv+    case mr of+      Just (Right v) -> v `shouldBe` "hello"+      _              -> expectationFailure "Expected Just (Right ...)"++  it "second write is ignored (idempotent)" $ do+    iv <- newIVar+    writeIVar iv (1 :: Int)+    writeIVar iv 2+    result <- awaitIVar iv+    case result of+      Right v -> v `shouldBe` 1+      Left _  -> expectationFailure "Expected Right"++  it "awaitIVar blocks until written" $ do+    -- Coordinate with MVar, no threadDelay+    iv <- newIVar+    resultVar <- newEmptyMVar+    _ <- forkIO $ do+      v <- awaitIVar iv+      putMVar resultVar v+    -- The forked thread is now blocked on awaitIVar.+    -- Write to unblock it.+    writeIVar iv (42 :: Int)+    result <- takeMVar resultVar+    case result of+      Right v -> v `shouldBe` 42+      Left _  -> expectationFailure "Expected Right"++  it "isIVarFilled returns True after error write" $ do+    iv <- newIVar @Int+    writeIVarError iv (toException (FetchError "boom"))+    filled <- isIVarFilled iv+    filled `shouldBe` True++  it "writeIVarError then writeIVar is ignored (error wins)" $ do+    iv <- newIVar+    writeIVarError iv (toException (FetchError "first"))+    writeIVar iv (99 :: Int)+    result <- awaitIVar iv+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left (error should win)"++  it "writeIVar then writeIVarError is ignored (value wins)" $ do+    iv <- newIVar+    writeIVar iv (1 :: Int)+    writeIVarError iv (toException (FetchError "late"))+    result <- awaitIVar iv+    case result of+      Right v -> v `shouldBe` 1+      Left _  -> expectationFailure "Expected Right (value should win)"++  it "multiple concurrent readers all get same value" $ do+    iv <- newIVar+    vars <- mapM (\_ -> do+      v <- newEmptyMVar+      _ <- forkIO $ awaitIVar iv >>= putMVar v+      pure v) [1 :: Int .. 5]+    writeIVar iv (42 :: Int)+    results <- mapM takeMVar vars+    mapM_ (\r -> case r of+      Right v -> v `shouldBe` 42+      Left _  -> expectationFailure "Expected Right") results++-- ══════════════════════════════════════════════+-- Cache tests+-- ══════════════════════════════════════════════++cacheSpec :: Spec+cacheSpec = describe "Fetch.Cache" $ do++  it "cacheLookup on empty returns CacheMiss" $ do+    cRef <- newCacheRef+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheMiss -> pure ()+      _         -> expectationFailure "Expected CacheMiss"++  it "cacheAllocate + write + lookup returns CacheHitReady" $ do+    cRef <- newCacheRef+    pairs <- cacheAllocate @UserId cRef [UserId 1]+    case pairs of+      [(_, iv)] -> do+        writeIVar iv "Alice"+        hit <- cacheLookup cRef (UserId 1)+        case hit of+          CacheHitReady v -> v `shouldBe` "Alice"+          _               -> expectationFailure "Expected CacheHitReady"+      _ -> expectationFailure "Expected one allocated pair"++  it "cacheAllocate without write returns CacheHitPending" $ do+    cRef <- newCacheRef+    _ <- cacheAllocate @UserId cRef [UserId 1]+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheHitPending _ -> pure ()+      _                 -> expectationFailure "Expected CacheHitPending"++  it "cacheAllocate deduplicates" $ do+    cRef <- newCacheRef+    pairs1 <- cacheAllocate @UserId cRef [UserId 1, UserId 2]+    length pairs1 `shouldBe` 2+    pairs2 <- cacheAllocate @UserId cRef [UserId 1, UserId 3]+    -- UserId 1 already allocated, only UserId 3 is new+    length pairs2 `shouldBe` 1+    case pairs2 of+      [(k, _)] -> k `shouldBe` UserId 3+      _        -> expectationFailure "Expected exactly one new pair"++  it "cacheEvict removes a key" $ do+    cRef <- newCacheRef+    cacheWarm @UserId cRef (HM.singleton (UserId 1) "Alice")+    cacheEvict cRef (UserId 1)+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheMiss -> pure ()+      _         -> expectationFailure "Expected CacheMiss after eviction"++  it "cacheEvictSource removes all keys for a source" $ do+    cRef <- newCacheRef+    cacheWarm @UserId cRef (HM.fromList [(UserId 1, "Alice"), (UserId 2, "Bob")])+    cacheEvictSource @UserId cRef Proxy+    hit1 <- cacheLookup cRef (UserId 1)+    hit2 <- cacheLookup cRef (UserId 2)+    case (hit1, hit2) of+      (CacheMiss, CacheMiss) -> pure ()+      _ -> expectationFailure "Expected CacheMiss for both after evictSource"++  it "cacheEvictWhere removes matching keys" $ do+    cRef <- newCacheRef+    cacheWarm @UserId cRef (HM.fromList [(UserId 1, "Alice"), (UserId 2, "Bob")])+    cacheEvictWhere @UserId cRef Proxy (\(UserId n) -> n == 1)+    hit1 <- cacheLookup cRef (UserId 1)+    hit2 <- cacheLookup cRef (UserId 2)+    case hit1 of+      CacheMiss -> pure ()+      _ -> expectationFailure "Expected CacheMiss for evicted key"+    case hit2 of+      CacheHitReady v -> v `shouldBe` "Bob"+      _ -> expectationFailure "Expected CacheHitReady for non-evicted key"++  it "cacheWarm pre-fills values" $ do+    cRef <- newCacheRef+    cacheWarm @UserId cRef (HM.fromList [(UserId 1, "Alice"), (UserId 2, "Bob")])+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheHitReady v -> v `shouldBe` "Alice"+      _               -> expectationFailure "Expected CacheHitReady"++  it "cacheContents returns all resolved values" $ do+    cRef <- newCacheRef+    cacheWarm @UserId cRef (HM.fromList [(UserId 1, "Alice"), (UserId 2, "Bob")])+    contents <- cacheContents @UserId cRef Proxy+    contents `shouldBe` HM.fromList [(UserId 1, "Alice"), (UserId 2, "Bob")]++  it "errored IVars treated as CacheMiss on re-lookup" $ do+    cRef <- newCacheRef+    pairs <- cacheAllocate @UserId cRef [UserId 1]+    case pairs of+      [(_, iv)] -> do+        writeIVarError iv (toException (FetchError "boom"))+        hit <- cacheLookup cRef (UserId 1)+        case hit of+          CacheMiss -> pure ()+          _         -> expectationFailure "Expected CacheMiss for errored IVar"+      _ -> expectationFailure "Expected one allocated pair"++  it "cacheAllocate with empty key list returns []" $ do+    cRef <- newCacheRef+    pairs <- cacheAllocate @UserId cRef []+    length pairs `shouldBe` 0++  it "cacheAllocate across different key types is independent" $ do+    cRef <- newCacheRef+    pairsU <- cacheAllocate @UserId cRef [UserId 1]+    pairsP <- cacheAllocate @PostId cRef [PostId 1]+    length pairsU `shouldBe` 1+    length pairsP `shouldBe` 1++  it "cacheWarm overwrites a previously resolved entry" $ do+    cRef <- newCacheRef+    cacheWarm @UserId cRef (HM.singleton (UserId 1) "Alice")+    cacheWarm @UserId cRef (HM.singleton (UserId 1) "Alice2")+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheHitReady v -> v `shouldBe` "Alice2"+      _               -> expectationFailure "Expected CacheHitReady with overwritten value"++  it "cacheContents on empty cache returns HM.empty" $ do+    cRef <- newCacheRef+    contents <- cacheContents @UserId cRef Proxy+    contents `shouldBe` HM.empty++  it "cacheContents excludes pending (unfilled) IVars" $ do+    cRef <- newCacheRef+    _ <- cacheAllocate @UserId cRef [UserId 1]+    cacheWarm @UserId cRef (HM.singleton (UserId 2) "Bob")+    contents <- cacheContents @UserId cRef Proxy+    contents `shouldBe` HM.singleton (UserId 2) "Bob"++  it "cacheContents excludes errored IVars" $ do+    cRef <- newCacheRef+    pairs <- cacheAllocate @UserId cRef [UserId 1]+    case pairs of+      [(_, iv)] -> writeIVarError iv (toException (FetchError "boom"))+      _         -> expectationFailure "Expected one pair"+    cacheWarm @UserId cRef (HM.singleton (UserId 2) "Bob")+    contents <- cacheContents @UserId cRef Proxy+    contents `shouldBe` HM.singleton (UserId 2) "Bob"++  it "cacheEvict on non-existent key is a no-op" $ do+    cRef <- newCacheRef+    cacheEvict cRef (UserId 999)+    hit <- cacheLookup cRef (UserId 999)+    case hit of+      CacheMiss -> pure ()+      _         -> expectationFailure "Expected CacheMiss"++  it "cacheEvictSource on non-existent source type is a no-op" $ do+    cRef <- newCacheRef+    cacheWarm @UserId cRef (HM.singleton (UserId 1) "Alice")+    cacheEvictSource @PostId cRef Proxy+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheHitReady v -> v `shouldBe` "Alice"+      _               -> expectationFailure "Expected CacheHitReady, PostId eviction shouldn't touch UserId"++  it "cacheEvictWhere with always-False predicate removes nothing" $ do+    cRef <- newCacheRef+    cacheWarm @UserId cRef (HM.fromList [(UserId 1, "Alice"), (UserId 2, "Bob")])+    cacheEvictWhere @UserId cRef Proxy (const False)+    contents <- cacheContents @UserId cRef Proxy+    HM.size contents `shouldBe` 2++  it "cacheInsert writes into a previously allocated IVar" $ do+    cRef <- newCacheRef+    _ <- cacheAllocate @UserId cRef [UserId 1]+    cacheInsert cRef (UserId 1) "Alice"+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheHitReady v -> v `shouldBe` "Alice"+      _               -> expectationFailure "Expected CacheHitReady"++-- ══════════════════════════════════════════════+-- Batches data type tests+-- ══════════════════════════════════════════════++batchesSpec :: Spec+batchesSpec = describe "Fetch.Class Batches" $ do++  it "mempty has size 0 and source count 0" $ do+    let b = mempty :: Batches TestM+    batchSize b `shouldBe` 0+    batchSourceCount b `shouldBe` 0++  it "singletonBatch <> singletonBatch same source deduplicates keys" $ do+    let b1 = singletonBatch @TestM (UserId 1)+        b2 = singletonBatch @TestM (UserId 1)+        merged = b1 <> b2+    batchSourceCount merged `shouldBe` 1+    -- Duplicate key is deduplicated+    batchSize merged `shouldBe` 1++  it "singletonBatch <> singletonBatch different sources yields source count 2" $ do+    let b1 = singletonBatch @TestM (UserId 1)+        b2 = singletonBatch @TestM (PostId 1)+        merged = b1 <> b2+    batchSourceCount merged `shouldBe` 2++  it "batchKeys @UserId extracts the correct keys" $ do+    let b = singletonBatch @TestM (UserId 1)+         <> singletonBatch @TestM (UserId 2)+        keys = batchKeys @UserId b+    length keys `shouldBe` 2+    keys `shouldSatisfy` elem (UserId 1)+    keys `shouldSatisfy` elem (UserId 2)++  it "batchKeys for absent source type returns []" $ do+    let b = singletonBatch @TestM (UserId 1)+        keys = batchKeys @PostId b+    keys `shouldBe` []++-- ══════════════════════════════════════════════+-- Fetch / Batched tests+-- ══════════════════════════════════════════════++batchedSpec :: Spec+batchedSpec = describe "Fetch.Batched" $ do++  it "simple single fetch returns correct value" $ do+    env <- mkTestEnv+    result <- runTest env $ fetch (UserId 1)+    result `shouldBe` "Alice"++  it "applicative <*> batches independent fetches into one round" $ do+    env <- mkTestEnv+    (a, b) <- runTest env $+      (,) <$> fetch (UserId 1) <*> fetch (UserId 2)+    a `shouldBe` "Alice"+    b `shouldBe` "Bob"+    batches <- readIORef (envUserLog env)+    -- Both keys in a single batch (one round)+    length batches `shouldBe` 1++  it "monadic >>= creates separate rounds" $ do+    env <- mkTestEnv+    _ <- runTest env $ do+      _ <- fetch (UserId 1)+      fetch (UserId 2)+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 2++  it "same key fetched twice in applicative is deduplicated" $ do+    env <- mkTestEnv+    (a, b) <- runTest env $+      (,) <$> fetch (UserId 1) <*> fetch (UserId 1)+    a `shouldBe` "Alice"+    b `shouldBe` "Alice"+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 1++  it "second fetch of same key hits cache (no second batch)" $ do+    env <- mkTestEnv+    _ <- runTest env $ do+      _ <- fetch (UserId 1)+      fetch (UserId 1)+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 1++  it "tryFetch returns Right on success" $ do+    env <- mkTestEnv+    result <- runTest env $ tryFetch (UserId 1)+    case result of+      Right v -> v `shouldBe` "Alice"+      Left _  -> expectationFailure "Expected Right"++  it "tryFetch returns Left on missing key" $ do+    env <- mkTestEnv+    result <- runTest env $ tryFetch (UserId 999)+    case result of+      Left _ -> pure ()+      Right _ -> expectationFailure "Expected Left for missing key"++  it "data source exception is caught by tryFetch" $ do+    env <- mkTestEnv+    result <- runTest env $ tryFetch (FailKey 1)+    case result of+      Left _ -> pure ()+      Right _ -> expectationFailure "Expected Left for failed source"++  it "multi-source batching (UserId + PostId in same round)" $ do+    env <- mkTestEnv+    (user, post) <- runTest env $+      (,) <$> fetch (UserId 1) <*> fetch (PostId 10)+    user `shouldBe` "Alice"+    post `shouldBe` "Hello World"+    userBatches <- readIORef (envUserLog env)+    postBatches <- readIORef (envPostLog env)+    -- Each source got exactly one batch call+    length userBatches `shouldBe` 1+    length postBatches `shouldBe` 1++  it "runFetchWithCache shares cache across runs" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    _ <- runTestWithCache env cRef $ fetch (UserId 1)+    -- Second run should hit cache+    _ <- runTestWithCache env cRef $ fetch (UserId 1)+    batches <- readIORef (envUserLog env)+    -- Only one batch was issued (first run); second run hit cache+    length batches `shouldBe` 1++  it "NoCaching sources don't persist in cache across rounds" $ do+    env <- mkTestEnv+    (a, b) <- runTest env $ do+      x <- fetch (MutKey 1)+      y <- fetch (MutKey 1)+      pure (x, y)+    mutBatches <- readIORef (envMutLog env)+    -- Must dispatch exactly twice, once per round+    length mutBatches `shouldBe` 2+    -- Counter-based source returns different values across rounds+    a `shouldSatisfy` (/= b)++  it "fetch throws on missing key" $ do+    env <- mkTestEnv+    result <- try @SomeException $ runTest env $ fetch (UserId 999)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception for missing key"++  it "fetch throws on data source exception" $ do+    env <- mkTestEnv+    result <- try @SomeException $ runTest env $ fetch (FailKey 1)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception for FailKey"++  it "pure with no fetches completes with zero rounds" $ do+    env <- mkTestEnv+    result <- runTest env $ pure (42 :: Int)+    result `shouldBe` 42+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 0++  it "fmap over a fetch transforms the result" $ do+    env <- mkTestEnv+    result <- runTest env $ fmap (++ "!") (fetch (UserId 1))+    result `shouldBe` "Alice!"++  it "three-way applicative batches in one round" $ do+    env <- mkTestEnv+    (a, b, c) <- runTest env $+      (,,) <$> fetch (UserId 1) <*> fetch (UserId 2) <*> fetch (UserId 3)+    a `shouldBe` "Alice"+    b `shouldBe` "Bob"+    c `shouldBe` "Carol"+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 1++  it "mixed monadic + applicative: 2 rounds, second round batches 2 keys" $ do+    env <- mkTestEnv+    (_, (b, c)) <- runTest env $ do+      a <- fetch (UserId 1)+      bc <- (,) <$> fetch (UserId 2) <*> fetch (UserId 3)+      pure (a, bc)+    b `shouldBe` "Bob"+    c `shouldBe` "Carol"+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 2+    case batches of+      (lastRound : _) -> length lastRound `shouldBe` 2+      _               -> expectationFailure "Expected at least one batch"++  it "pre-warmed cache is hit without issuing a batch" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    cacheWarm @UserId cRef (HM.singleton (UserId 1) "Cached-Alice")+    result <- runTestWithCache env cRef $ fetch (UserId 1)+    result `shouldBe` "Cached-Alice"+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 0++  it "tryFetch after a failed key retries on the next round" $ do+    env <- mkTestEnv+    (first, second) <- runTest env $ do+      r1 <- tryFetch (FailKey 1)+      r2 <- tryFetch (FailKey 1)+      pure (r1, r2)+    case first of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for first tryFetch"+    case second of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for second tryFetch"++-- ══════════════════════════════════════════════+-- primeCache tests+-- ══════════════════════════════════════════════++primeCacheSpec :: Spec+primeCacheSpec = describe "MonadFetch.primeCache" $ do++  it "primed value is returned by subsequent fetch without a batch" $ do+    env <- mkTestEnv+    result <- runTest env $ do+      primeCache (UserId 1) "Primed-Alice"+      fetch (UserId 1)+    result `shouldBe` "Primed-Alice"+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 0++  it "overwrites a resolved cache entry" $ do+    env <- mkTestEnv+    result <- runTest env $ do+      _ <- fetch (UserId 1)           -- fetches "Alice" from source+      primeCache (UserId 1) "Updated" -- overwrites+      fetch (UserId 1)                -- should return the primed value+    result `shouldBe` "Updated"++  it "is a no-op in MockFetch" $ do+    let mocks = mockData @UserId [(UserId 1, "Alice")]+    result <- runMockFetch @TestM mocks $ do+      primeCache (UserId 2) "Ghost"+      fetch (UserId 1)+    result `shouldBe` "Alice"++  it "fills a pending IVar" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    _ <- cacheAllocate @UserId cRef [UserId 1]+    _ <- runTestWithCache env cRef $ do+      primeCache (UserId 1) "Primed"+      fetch (UserId 1)+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 0++  it "primeCache multiple keys, all subsequently fetched from cache" $ do+    env <- mkTestEnv+    (a, b, c) <- runTest env $ do+      primeCache (UserId 1) "P-Alice"+      primeCache (UserId 2) "P-Bob"+      primeCache (UserId 3) "P-Carol"+      (,,) <$> fetch (UserId 1) <*> fetch (UserId 2) <*> fetch (UserId 3)+    a `shouldBe` "P-Alice"+    b `shouldBe` "P-Bob"+    c `shouldBe` "P-Carol"+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 0++  it "primeCache works through TracedFetch" $ do+    env <- mkTestEnv+    (result, _) <- runTestM env $+      runTracedFetch (fetchConfig (runTestM env) testLiftIO) defaultTraceConfig $ do+        primeCache (UserId 1) "Traced-Primed"+        fetch (UserId 1)+    result `shouldBe` "Traced-Primed"+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 0++-- ══════════════════════════════════════════════+-- Engine tests+-- ══════════════════════════════════════════════++engineSpec :: Spec+engineSpec = describe "Fetch.Engine" $ do++  it "executeBatches returns RoundStats" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    let batches = singletonBatch @TestM (UserId 1)+                  <> singletonBatch @TestM (PostId 10)+    stats <- executeBatches (runTestM env) testLiftIO cRef batches+    roundSources stats `shouldBe` 2+    roundKeys stats `shouldBe` 2++  it "FetchStrategy ordering: Eager starts before Sequential" $ do+    env <- mkTestEnv+    (a, b, c) <- runTest env $+      (,,) <$> fetch (SeqKey 1) <*> fetch (EagerKey 1) <*> fetch (UserId 1)+    a `shouldBe` "seq-1"+    b `shouldBe` "eager-1"+    c `shouldBe` "Alice"++  it "fillMissing fills unfilled IVars with FetchError" $ do+    env <- mkTestEnv+    result <- runTest env $ tryFetch (UserId 999)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for missing key"++-- ══════════════════════════════════════════════+-- Combinator tests+-- ══════════════════════════════════════════════++combinatorSpec :: Spec+combinatorSpec = describe "Fetch.Combinators" $ do++  it "fetchAll over a list" $ do+    env <- mkTestEnv+    results <- runTest env $ fetchAll [UserId 1, UserId 2, UserId 3]+    results `shouldBe` ["Alice", "Bob", "Carol"]+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 1++  it "fetchWith pairs keys with results" $ do+    env <- mkTestEnv+    results <- runTest env $ fetchWith [UserId 1, UserId 2]+    results `shouldBe` [(UserId 1, "Alice"), (UserId 2, "Bob")]++  it "fetchThrough extracts key, fetches, and pairs back" $ do+    env <- mkTestEnv+    let items = [(10 :: Int, UserId 1), (20, UserId 2)]+    results <- runTest env $ fetchThrough snd items+    results `shouldBe` [((10, UserId 1), "Alice"), ((20, UserId 2), "Bob")]++  it "fetchMap transforms results" $ do+    env <- mkTestEnv+    let items = [UserId 1, UserId 2]+    results <- runTest env $+      fetchMap id (\(UserId n) name -> show n <> ":" <> name) items+    results `shouldBe` ["1:Alice", "2:Bob"]++  it "fetchMaybe Nothing returns Nothing" $ do+    env <- mkTestEnv+    result <- runTest env $ fetchMaybe (Nothing :: Maybe UserId)+    result `shouldBe` Nothing++  it "fetchMaybe Just returns Just result" $ do+    env <- mkTestEnv+    result <- runTest env $ fetchMaybe (Just (UserId 1))+    result `shouldBe` Just "Alice"++  it "fetchMapWith returns HashMap" $ do+    env <- mkTestEnv+    result <- runTest env $ fetchMapWith [UserId 1, UserId 2]+    result `shouldBe` HM.fromList [(UserId 1, "Alice"), (UserId 2, "Bob")]++  it "fetchAll with empty list returns []" $ do+    env <- mkTestEnv+    results <- runTest env $ fetchAll ([] :: [UserId])+    results `shouldBe` []+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 0++  it "fetchWith with empty list returns []" $ do+    env <- mkTestEnv+    results <- runTest env $ fetchWith ([] :: [UserId])+    results `shouldBe` []++  it "fetchMapWith with duplicate keys deduplicates in result map" $ do+    env <- mkTestEnv+    result <- runTest env $ fetchMapWith [UserId 1, UserId 1, UserId 2]+    HM.size result `shouldBe` 2+    HM.lookup (UserId 1) result `shouldBe` Just "Alice"+    HM.lookup (UserId 2) result `shouldBe` Just "Bob"++  it "fetchMaybe batches with other applicative fetches in same round" $ do+    env <- mkTestEnv+    (mVal, val) <- runTest env $+      (,) <$> fetchMaybe (Just (UserId 1)) <*> fetch (UserId 2)+    mVal `shouldBe` Just "Alice"+    val `shouldBe` "Bob"+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 1++-- ══════════════════════════════════════════════+-- biselect / pAnd / pOr tests+-- ══════════════════════════════════════════════++biselectSpec :: Spec+biselectSpec = describe "biselect / pAnd / pOr" $ do++  -- ── biselect ──────────────────────────────────++  describe "biselect" $ do++    it "both pure Right → pairs values" $ do+      env <- mkTestEnv+      result <- runTest env $+        biselect+          (pure (Right "a") :: Fetch TestM (Either () String))+          (pure (Right "b") :: Fetch TestM (Either () String))+      result `shouldBe` Right ("a", "b")++    it "left pure Left → short-circuits immediately" $ do+      env <- mkTestEnv+      result <- runTest env $+        biselect+          (pure (Left "stop") :: Fetch TestM (Either String String))+          (pure (Right "b") :: Fetch TestM (Either String String))+      result `shouldBe` Left "stop"++    it "right pure Left → short-circuits immediately" $ do+      env <- mkTestEnv+      result <- runTest env $+        biselect+          (pure (Right "a") :: Fetch TestM (Either String String))+          (pure (Left "stop") :: Fetch TestM (Either String String))+      result `shouldBe` Left "stop"++    it "both Left → picks the left one" $ do+      env <- mkTestEnv+      result <- runTest env $+        biselect+          (pure (Left "first") :: Fetch TestM (Either String String))+          (pure (Left "second") :: Fetch TestM (Either String String))+      result `shouldBe` Left "first"++    it "both blocked, both Right → pairs values in one round" $ do+      env <- mkTestEnv+      (result, rounds) <- runTestWithRoundLog env $+        biselect+          (Right <$> fetch (UserId 1) :: Fetch TestM (Either () String))+          (Right <$> fetch (PostId 10))+      result `shouldBe` Right ("Alice", "Hello World")+      length rounds `shouldBe` 1+      -- Both sources dispatched in the same round+      dispLog <- readIORef (envDispatchLog env)+      dispLog `shouldContain` ["UserId"]+      dispLog `shouldContain` ["PostId"]++    it "left immediate Left, right blocked → batch never executed (MVar proof)" $ do+      env <- mkTestEnv+      -- BlockingKey's batchFetch signals envAsyncStarted then blocks on envAsyncProceed.+      -- If biselect short-circuits, that batchFetch is never called.+      result <- runTest env $+        biselect+          (pure (Left "short") :: Fetch TestM (Either String String))+          (Right <$> fetch (BlockingKey 1))+      result `shouldBe` Left "short"+      -- Prove the blocked side's batch was never entered+      started <- tryTakeMVar (envAsyncStarted env)+      started `shouldBe` Nothing++    it "right immediate Left, left blocked → batch never executed (MVar proof)" $ do+      env <- mkTestEnv+      result <- runTest env $+        biselect+          (Right <$> fetch (BlockingKey 1) :: Fetch TestM (Either String String))+          (pure (Left "short") :: Fetch TestM (Either String String))+      result `shouldBe` Left "short"+      started <- tryTakeMVar (envAsyncStarted env)+      started `shouldBe` Nothing++    it "both blocked, left resolves Left → right continuation abandoned (MVar proof)" $ do+      env <- mkTestEnv+      -- Round 1: fetch UserId and PostId (both fast).+      -- After round 1: left produces Left, right would need BlockingKey (never reached).+      (result, rounds) <- runTestWithRoundLog env $+        biselect+          (do name <- fetch (UserId 1)+              pure (Left name) :: Fetch TestM (Either String ()))+          (do _ <- fetch (PostId 10)+              v <- fetch (BlockingKey 1)  -- would block forever+              pure (Right v))+      result `shouldBe` Left "Alice"+      -- Only one round of batch execution (UserId + PostId)+      length rounds `shouldBe` 1+      -- BlockingKey's batchFetch was never entered+      started <- tryTakeMVar (envAsyncStarted env)+      started `shouldBe` Nothing++  -- ── pOr ───────────────────────────────────────++  describe "pOr" $ do++    it "True || False → True" $ do+      env <- mkTestEnv+      result <- runTest env $ pOr (pure True) (pure False)+      result `shouldBe` True++    it "False || True → True" $ do+      env <- mkTestEnv+      result <- runTest env $ pOr (pure False) (pure True)+      result `shouldBe` True++    it "False || False → False" $ do+      env <- mkTestEnv+      result <- runTest env $ pOr (pure False) (pure False)+      result `shouldBe` False++    it "left pure True → right fetch never executed (MVar proof)" $ do+      env <- mkTestEnv+      result <- runTest env $+        pOr (pure True) (const False <$> fetch (BlockingKey 1))+      result `shouldBe` True+      started <- tryTakeMVar (envAsyncStarted env)+      started `shouldBe` Nothing++    it "right pure True → left fetch never executed (MVar proof)" $ do+      env <- mkTestEnv+      result <- runTest env $+        pOr (const False <$> fetch (BlockingKey 1)) (pure True)+      result `shouldBe` True+      started <- tryTakeMVar (envAsyncStarted env)+      started `shouldBe` Nothing++    it "both fetched, left True → True in one round" $ do+      env <- mkTestEnv+      (result, rounds) <- runTestWithRoundLog env $+        pOr+          ((== "Alice") <$> fetch (UserId 1))+          ((== "nonexistent") <$> fetch (PostId 10))+      result `shouldBe` True+      length rounds `shouldBe` 1++    it "both fetched, both False → False in one round" $ do+      env <- mkTestEnv+      (result, rounds) <- runTestWithRoundLog env $+        pOr+          ((== "nonexistent") <$> fetch (UserId 1))+          ((== "nonexistent") <$> fetch (PostId 10))+      result `shouldBe` False+      length rounds `shouldBe` 1++    it "multi-round: left True after round 1, right's round 2 abandoned (MVar proof)" $ do+      env <- mkTestEnv+      (result, rounds) <- runTestWithRoundLog env $+        pOr+          -- Left: fetches UserId in round 1, resolves True+          (do name <- fetch (UserId 1)+              pure (name == "Alice"))+          -- Right: fetches PostId in round 1, then would need BlockingKey in round 2+          (do _ <- fetch (PostId 10)+              _ <- fetch (BlockingKey 1)  -- never reached+              pure False)+      result `shouldBe` True+      -- Only one batch round was executed+      length rounds `shouldBe` 1+      -- BlockingKey's batchFetch was never entered+      started <- tryTakeMVar (envAsyncStarted env)+      started `shouldBe` Nothing++  -- ── pAnd ──────────────────────────────────────++  describe "pAnd" $ do++    it "True && True → True" $ do+      env <- mkTestEnv+      result <- runTest env $ pAnd (pure True) (pure True)+      result `shouldBe` True++    it "True && False → False" $ do+      env <- mkTestEnv+      result <- runTest env $ pAnd (pure True) (pure False)+      result `shouldBe` False++    it "False && True → False" $ do+      env <- mkTestEnv+      result <- runTest env $ pAnd (pure False) (pure True)+      result `shouldBe` False++    it "left pure False → right fetch never executed (MVar proof)" $ do+      env <- mkTestEnv+      result <- runTest env $+        pAnd (pure False) (const True <$> fetch (BlockingKey 1))+      result `shouldBe` False+      started <- tryTakeMVar (envAsyncStarted env)+      started `shouldBe` Nothing++    it "right pure False → left fetch never executed (MVar proof)" $ do+      env <- mkTestEnv+      result <- runTest env $+        pAnd (const True <$> fetch (BlockingKey 1)) (pure False)+      result `shouldBe` False+      started <- tryTakeMVar (envAsyncStarted env)+      started `shouldBe` Nothing++    it "both fetched, both True → True in one round" $ do+      env <- mkTestEnv+      (result, rounds) <- runTestWithRoundLog env $+        pAnd+          ((== "Alice") <$> fetch (UserId 1))+          ((== "Hello World") <$> fetch (PostId 10))+      result `shouldBe` True+      length rounds `shouldBe` 1++    it "both fetched, one False → False in one round" $ do+      env <- mkTestEnv+      (result, rounds) <- runTestWithRoundLog env $+        pAnd+          ((== "Alice") <$> fetch (UserId 1))+          ((== "nonexistent") <$> fetch (PostId 10))+      result `shouldBe` False+      length rounds `shouldBe` 1++    it "multi-round: left False after round 1, right's round 2 abandoned (MVar proof)" $ do+      env <- mkTestEnv+      (result, rounds) <- runTestWithRoundLog env $+        pAnd+          -- Left: fetches UserId in round 1, resolves False+          (do name <- fetch (UserId 1)+              pure (name == "nonexistent"))+          -- Right: fetches PostId in round 1, then would need BlockingKey in round 2+          (do _ <- fetch (PostId 10)+              _ <- fetch (BlockingKey 1)  -- never reached+              pure True)+      result `shouldBe` False+      length rounds `shouldBe` 1+      started <- tryTakeMVar (envAsyncStarted env)+      started `shouldBe` Nothing++-- ══════════════════════════════════════════════+-- Mock tests+-- ══════════════════════════════════════════════++mockSpec :: Spec+mockSpec = describe "Fetch.Mock" $ do++  it "runMockFetch with matching data returns value" $ do+    let mocks = mockData @UserId [(UserId 1, "Alice")]+    result <- runMockFetch @TestM mocks $ fetch (UserId 1)+    result `shouldBe` "Alice"++  it "fetch with missing key throws" $ do+    let mocks = mockData @UserId [(UserId 1, "Alice")]+    result <- try @SomeException $+      runMockFetch @TestM mocks $ fetch (UserId 999)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception for missing key"++  it "tryFetch with missing key returns Left" $ do+    let mocks = mockData @UserId [(UserId 1, "Alice")]+    result <- runMockFetch @TestM mocks $ tryFetch (UserId 999)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for missing key"++  it "multiple source types in one ResultMap" $ do+    let mocks = mockData @UserId [(UserId 1, "Alice")]+             <> mockData @PostId [(PostId 10, "Hello")]+    (user, post) <- runMockFetch @TestM mocks $+      (,) <$> fetch (UserId 1) <*> fetch (PostId 10)+    user `shouldBe` "Alice"+    post `shouldBe` "Hello"++  it "emptyMockData causes tryFetch to return Left" $ do+    result <- runMockFetch @TestM emptyMockData $ tryFetch (UserId 1)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for empty mock data"++  it "mock applicative: two fetches from different sources both succeed" $ do+    let mocks = mockData @UserId [(UserId 1, "Alice")]+             <> mockData @PostId [(PostId 10, "Post")]+    (u, p) <- runMockFetch @TestM mocks $+      (,) <$> fetch (UserId 1) <*> fetch (PostId 10)+    u `shouldBe` "Alice"+    p `shouldBe` "Post"++  it "mock tryFetch returns Right on success" $ do+    let mocks = mockData @UserId [(UserId 1, "Alice")]+    result <- runMockFetch @TestM mocks $ tryFetch (UserId 1)+    case result of+      Right v -> v `shouldBe` "Alice"+      Left _  -> expectationFailure "Expected Right"++  it "mock fetch with no data for that source type returns error" $ do+    let mocks = mockData @PostId [(PostId 10, "Post")]+    result <- try @SomeException $+      runMockFetch @TestM mocks $ fetch (UserId 1)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception for missing source type"++  it "mock fetch missing key throws FetchError (not ErrorCall)" $ do+    let mocks = mockData @UserId [(UserId 1, "Alice")]+    result <- try @FetchError $+      runMockFetch @TestM mocks $ fetch (UserId 999)+    case result of+      Left (FetchError _) -> pure ()+      Right _ -> expectationFailure "Expected FetchError for missing key"++  it "MockMutate fetch missing key throws FetchError (not ErrorCall)" $ do+    let mocks = mockData @UserId [(UserId 1, "Alice")]+        handlers = emptyMutationHandlers+    result <- try @FetchError $ do+      (v, _) <- runMockMutate @TestM mocks handlers $ fetch (UserId 999)+      pure v+    case result of+      Left (FetchError _) -> pure ()+      Right _ -> expectationFailure "Expected FetchError for missing key"++-- ══════════════════════════════════════════════+-- Traced tests+-- ══════════════════════════════════════════════++tracedSpec :: Spec+tracedSpec = describe "Fetch.Traced" $ do++  it "callbacks fire and FetchStats reports correct counts" $ do+    env <- mkTestEnv+    roundStartRef <- newIORef (0 :: Int)+    roundCompleteRef <- newIORef (0 :: Int)+    let tc = TraceConfig+          { onRoundStart    = \_ _ -> testLiftIO $ modifyIORef' roundStartRef (+ 1)+          , onRoundComplete = \_ _ -> testLiftIO $ modifyIORef' roundCompleteRef (+ 1)+          , onFetchComplete = \_ -> pure ()+          }+    (result, stats) <- runTestM env $+      runTracedFetch (fetchConfig (runTestM env) testLiftIO) tc $ do+        (,) <$> fetch (UserId 1) <*> fetch (UserId 2)+    fst result `shouldBe` "Alice"+    snd result `shouldBe` "Bob"+    totalRounds stats `shouldBe` 1+    totalKeys stats `shouldBe` 2+    starts <- readIORef roundStartRef+    starts `shouldBe` 1+    completes <- readIORef roundCompleteRef+    completes `shouldBe` 1++  it "multiple rounds tracked correctly" $ do+    env <- mkTestEnv+    (_, stats) <- runTestM env $+      runTracedFetch (fetchConfig (runTestM env) testLiftIO) defaultTraceConfig $ do+        _ <- fetch (UserId 1)+        fetch (UserId 2)+    totalRounds stats `shouldBe` 2+    totalKeys stats `shouldBe` 2++  it "same batching behavior as Fetch" $ do+    env <- mkTestEnv+    ((a, b), _) <- runTestM env $+      runTracedFetch (fetchConfig (runTestM env) testLiftIO) defaultTraceConfig $+        (,) <$> fetch (UserId 1) <*> fetch (PostId 10)+    a `shouldBe` "Alice"+    b `shouldBe` "Hello World"++  it "onFetchComplete callback fires and receives stats" $ do+    env <- mkTestEnv+    statsRef <- newIORef Nothing+    let tc = TraceConfig+          { onRoundStart    = \_ _ -> pure ()+          , onRoundComplete = \_ _ -> pure ()+          , onFetchComplete = \s -> testLiftIO $ writeIORef statsRef (Just s)+          }+    _ <- runTestM env $+      runTracedFetch (fetchConfig (runTestM env) testLiftIO) tc $ fetch (UserId 1)+    ms <- readIORef statsRef+    case ms of+      Just s  -> totalRounds s `shouldBe` 1+      Nothing -> expectationFailure "onFetchComplete was not called"++  it "FetchStats.totalTime is non-negative" $ do+    env <- mkTestEnv+    (_, stats) <- runTestM env $+      runTracedFetch (fetchConfig (runTestM env) testLiftIO) defaultTraceConfig $+        fetch (UserId 1)+    totalTime stats `shouldSatisfy` (>= 0)++  it "FetchStats.maxSourcesPerRound reports correct max" $ do+    env <- mkTestEnv+    (_, stats) <- runTestM env $+      runTracedFetch (fetchConfig (runTestM env) testLiftIO) defaultTraceConfig $+        (,) <$> fetch (UserId 1) <*> fetch (PostId 10)+    maxSourcesPerRound stats `shouldBe` 2++  it "primeCache through TracedFetch works" $ do+    env <- mkTestEnv+    (result, stats) <- runTestM env $+      runTracedFetch (fetchConfig (runTestM env) testLiftIO) defaultTraceConfig $ do+        primeCache (UserId 1) "Traced-Prime"+        fetch (UserId 1)+    result `shouldBe` "Traced-Prime"+    totalRounds stats `shouldBe` 0++  it "tryFetch returns Left for missing key through TracedFetch" $ do+    env <- mkTestEnv+    (result, _) <- runTestM env $+      runTracedFetch (fetchConfig (runTestM env) testLiftIO) defaultTraceConfig $+        tryFetch (UserId 999)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for missing key"++  it "round numbers passed to onRoundStart are sequential starting at 1" $ do+    env <- mkTestEnv+    roundNums <- newIORef ([] :: [Int])+    let tc = TraceConfig+          { onRoundStart    = \n _ -> testLiftIO $ modifyIORef' roundNums (++ [n])+          , onRoundComplete = \_ _ -> pure ()+          , onFetchComplete = \_ -> pure ()+          }+    _ <- runTestM env $+      runTracedFetch (fetchConfig (runTestM env) testLiftIO) tc $ do+        _ <- fetch (UserId 1)+        _ <- fetch (UserId 2)+        fetch (UserId 3)+    nums <- readIORef roundNums+    nums `shouldBe` [1, 2, 3]++-- ══════════════════════════════════════════════+-- Memo tests+-- ══════════════════════════════════════════════++newtype ComputeKey = ComputeKey Int+  deriving stock (Eq, Ord, Show, Generic)+  deriving anyclass (Hashable)++instance MemoKey ComputeKey where+  type MemoResult ComputeKey = String++memoSpec :: Spec+memoSpec = describe "Fetch.Memo" $ do++  it "memo caches computation (action runs once)" $ do+    store <- newMemoStore+    callCount <- newIORef (0 :: Int)+    let action :: IO String+        action = do+          modifyIORef' callCount (+ 1)+          pure "computed"+    v1 <- memo store id (ComputeKey 1) action+    v2 <- memo store id (ComputeKey 1) action+    v1 `shouldBe` "computed"+    v2 `shouldBe` "computed"+    count <- readIORef callCount+    count `shouldBe` 1++  it "memo with different keys runs action for each" $ do+    store <- newMemoStore+    callCount <- newIORef (0 :: Int)+    let action :: IO String+        action = do+          n <- atomicModifyIORef' callCount (\c -> (c + 1, c))+          pure ("result-" <> show n)+    v1 <- memo store id (ComputeKey 1) action+    v2 <- memo store id (ComputeKey 2) action+    v1 `shouldBe` "result-0"+    v2 `shouldBe` "result-1"+    count <- readIORef callCount+    count `shouldBe` 2++  it "memoOn works without MemoKey instance" $ do+    store <- newMemoStore+    callCount <- newIORef (0 :: Int)+    let action :: IO Int+        action = do+          modifyIORef' callCount (+ 1)+          pure 42+    v1 <- memoOn store id ("key1" :: String) action+    v2 <- memoOn store id ("key1" :: String) action+    v1 `shouldBe` (42 :: Int)+    v2 `shouldBe` 42+    count <- readIORef callCount+    count `shouldBe` 1++  it "memoOn with different result types distinguished" $ do+    store <- newMemoStore+    v1 <- memoOn store id ("key" :: String) (pure (42 :: Int))+    v2 <- memoOn store id ("key" :: String) (pure ("hello" :: String))+    v1 `shouldBe` (42 :: Int)+    v2 `shouldBe` "hello"++  it "two separate MemoStores are independent" $ do+    store1 <- newMemoStore+    store2 <- newMemoStore+    count1 <- newIORef (0 :: Int)+    count2 <- newIORef (0 :: Int)+    let action1 :: IO String+        action1 = modifyIORef' count1 (+ 1) >> pure "store1"+        action2 :: IO String+        action2 = modifyIORef' count2 (+ 1) >> pure "store2"+    v1 <- memo store1 id (ComputeKey 1) action1+    v2 <- memo store2 id (ComputeKey 1) action2+    v1 `shouldBe` "store1"+    v2 `shouldBe` "store2"+    c1 <- readIORef count1+    c2 <- readIORef count2+    c1 `shouldBe` 1+    c2 `shouldBe` 1++  it "memoOn with same key and same result type returns cached value" $ do+    store <- newMemoStore+    callCount <- newIORef (0 :: Int)+    let action :: IO Int+        action = do+          modifyIORef' callCount (+ 1)+          pure 100+    v1 <- memoOn store id ("same" :: String) action+    v2 <- memoOn store id ("same" :: String) (pure (999 :: Int))+    v1 `shouldBe` (100 :: Int)+    v2 `shouldBe` (100 :: Int)+    count <- readIORef callCount+    count `shouldBe` 1++  it "memo after an errored first attempt re-runs" $ do+    store <- newMemoStore+    callCount <- newIORef (0 :: Int)+    let action :: IO String+        action = do+          n <- atomicModifyIORef' callCount (\c -> (c + 1, c))+          if n == 0+            then error "first attempt fails"+            else pure "success"+    r1 <- try @SomeException $ memo store id (ComputeKey 1) action+    case r1 of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception on first attempt"+    v2 <- memo store id (ComputeKey 1) action+    v2 `shouldBe` "success"+    count <- readIORef callCount+    count `shouldBe` 2++-- ══════════════════════════════════════════════+-- Race condition tests+-- ══════════════════════════════════════════════++raceSpec :: Spec+raceSpec = describe "Race conditions" $ do+  ivarRaceSpec+  cacheRaceSpec+  engineRaceSpec+  fetchTRaceSpec+  memoRaceSpec++-- ──────────────────────────────────────────────+-- IVar races+-- ──────────────────────────────────────────────++ivarRaceSpec :: Spec+ivarRaceSpec = describe "IVar" $ do++  it "concurrent write storm: exactly one winner across 100 threads" $ do+    iv <- newIVar+    doneVars <- mapM (\_ -> newEmptyMVar) [1 :: Int .. 100]+    barrier <- newEmptyMVar+    mapM_ (\(i, done) -> forkIO $ do+      readMVar barrier+      writeIVar iv i+      putMVar done ()+      ) (zip [1 :: Int .. 100] doneVars)+    putMVar barrier ()+    mapM_ takeMVar doneVars+    result <- awaitIVar iv+    case result of+      Right v -> v `shouldSatisfy` (\x -> x >= 1 && x <= 100)+      Left _  -> expectationFailure "Expected Right"++  it "concurrent error + value writes: one winner, all readers agree" $ do+    iv <- newIVar+    let n = 100+    doneVars <- mapM (\_ -> newEmptyMVar) [1 :: Int .. n]+    barrier <- newEmptyMVar+    mapM_ (\(i, done) -> forkIO $ do+      readMVar barrier+      if i <= 50+        then writeIVar iv (i :: Int)+        else writeIVarError iv (toException (FetchError ("err-" <> show i)))+      putMVar done ()+      ) (zip [1..n] doneVars)+    putMVar barrier ()+    mapM_ takeMVar doneVars+    results <- mapM (\_ -> awaitIVar iv) [1 :: Int .. 10]+    case results of+      [] -> expectationFailure "No results"+      (first : rest) -> case first of+        Right winner -> mapM_ (\r -> case r of+          Right v -> v `shouldBe` winner+          Left _  -> expectationFailure "Inconsistent: first was Right, got Left") rest+        Left _ -> mapM_ (\r -> case r of+          Left _  -> pure ()+          Right _ -> expectationFailure "Inconsistent: first was Left, got Right") rest++  it "reader-writer interleave: N readers unblocked by single write" $ do+    iv <- newIVar+    let numReaders = 50+    resultVars <- mapM (\_ -> newEmptyMVar) [1 :: Int .. numReaders]+    mapM_ (\rv -> forkIO (awaitIVar iv >>= putMVar rv)) resultVars+    writeIVar iv (42 :: Int)+    results <- mapM takeMVar resultVars+    mapM_ (\r -> case r of+      Right v -> v `shouldBe` 42+      Left _  -> expectationFailure "Expected Right") results++  it "rapid alloc-write-read cycle stress (1000 iterations)" $ do+    mapM_ (\i -> do+      iv <- newIVar+      writeIVar iv (i :: Int)+      result <- awaitIVar iv+      case result of+        Right v -> v `shouldBe` i+        Left _  -> expectationFailure "Expected Right"+      ) [1 :: Int .. 1000]++-- ──────────────────────────────────────────────+-- Cache races+-- ──────────────────────────────────────────────++cacheRaceSpec :: Spec+cacheRaceSpec = describe "Cache" $ do++  it "concurrent cacheAllocate same key: exactly one allocator wins" $ do+    cRef <- newCacheRef+    resultsVar <- newIORef ([] :: [Int])+    barrier <- newEmptyMVar+    let n = 100+    doneVars <- mapM (\_ -> newEmptyMVar) [1 :: Int .. n]+    mapM_ (\(_, done) -> forkIO $ do+      readMVar barrier+      pairs <- cacheAllocate @UserId cRef [UserId 1]+      atomicModifyIORef' resultsVar (\rs -> (length pairs : rs, ()))+      putMVar done ()+      ) (zip [1 :: Int .. n] doneVars)+    putMVar barrier ()+    mapM_ takeMVar doneVars+    results <- readIORef resultsVar+    let allocators = filter (> 0) results+    length allocators `shouldBe` 1++  it "cacheAllocate + cacheEvict interleave: no corruption" $ do+    cRef <- newCacheRef+    barrier <- newEmptyMVar+    h1 <- async $ do+      readMVar barrier+      pairs <- cacheAllocate @UserId cRef [UserId 1]+      case pairs of+        [(_, iv)] -> writeIVar iv "value"+        _         -> pure ()+    h2 <- async $ do+      readMVar barrier+      cacheEvict cRef (UserId 1)+    putMVar barrier ()+    wait h1+    wait h2+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheMiss       -> pure ()+      CacheHitReady v -> v `shouldBe` "value"+      CacheHitPending _ -> pure ()++  it "cacheWarm + cacheLookup concurrent: never corrupt state" $ do+    cRef <- newCacheRef+    let warmMap = HM.fromList [ (UserId i, "user-" <> show i)+                               | i <- [1..100] ]+    barrier <- newEmptyMVar+    h1 <- async $ do+      readMVar barrier+      cacheWarm @UserId cRef warmMap+    badRef <- newIORef False+    h2 <- async $ do+      readMVar barrier+      mapM_ (\i -> do+        hit <- cacheLookup cRef (UserId i)+        case hit of+          CacheMiss         -> pure ()+          CacheHitReady _   -> pure ()+          CacheHitPending _ -> pure ()+        ) [1..100]+    putMVar barrier ()+    wait h1+    wait h2+    bad <- readIORef badRef+    bad `shouldBe` False++  it "cacheInsert after concurrent evict: no crash" $ do+    cRef <- newCacheRef+    _ <- cacheAllocate @UserId cRef [UserId 1]+    barrier <- newEmptyMVar+    h1 <- async $ do+      readMVar barrier+      cacheInsert cRef (UserId 1) "value"+    h2 <- async $ do+      readMVar barrier+      cacheEvict cRef (UserId 1)+    putMVar barrier ()+    wait h1+    wait h2+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheMiss       -> pure ()+      CacheHitReady _ -> pure ()+      CacheHitPending _ -> pure ()++  it "concurrent cacheWarm different keys: both sets present" $ do+    cRef <- newCacheRef+    let set1 = HM.fromList [ (UserId i, "a-" <> show i) | i <- [1..50] ]+        set2 = HM.fromList [ (UserId i, "b-" <> show i) | i <- [51..100] ]+    h1 <- async $ cacheWarm @UserId cRef set1+    h2 <- async $ cacheWarm @UserId cRef set2+    wait h1+    wait h2+    contents <- cacheContents @UserId cRef Proxy+    mapM_ (\i -> HM.member (UserId i) contents `shouldBe` True) [1..100]++  it "cacheContents during concurrent writes: internally consistent" $ do+    cRef <- newCacheRef+    barrier <- newEmptyMVar+    h1 <- async $ do+      readMVar barrier+      mapM_ (\i -> do+        cacheWarm @UserId cRef (HM.singleton (UserId i) ("val-" <> show i))+        ) [1 :: Int .. 50]+    h2 <- async $ do+      readMVar barrier+      mapM_ (\_ -> do+        contents <- cacheContents @UserId cRef Proxy+        mapM_ (\(_, v) ->+          length v `shouldSatisfy` (> 0)) (HM.toList contents)+        ) [1 :: Int .. 50]+    putMVar barrier ()+    wait h1+    wait h2++-- ──────────────────────────────────────────────+-- Engine / dispatch races+-- ──────────────────────────────────────────────++engineRaceSpec :: Spec+engineRaceSpec = describe "Engine dispatch" $ do++  it "concurrent executeBatches on same CacheRef: no crash, all IVars filled" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    let b1 = singletonBatch @TestM (UserId 1) <> singletonBatch @TestM (UserId 2)+        b2 = singletonBatch @TestM (PostId 10) <> singletonBatch @TestM (PostId 20)+    h1 <- async $ executeBatches (runTestM env) testLiftIO cRef b1+    h2 <- async $ executeBatches (runTestM env) testLiftIO cRef b2+    _ <- wait h1+    _ <- wait h2+    u1 <- cacheLookup cRef (UserId 1)+    u2 <- cacheLookup cRef (UserId 2)+    p1 <- cacheLookup cRef (PostId 10)+    p2 <- cacheLookup cRef (PostId 20)+    case (u1, u2, p1, p2) of+      (CacheHitReady a, CacheHitReady b, CacheHitReady c, CacheHitReady d) -> do+        a `shouldBe` "Alice"+        b `shouldBe` "Bob"+        c `shouldBe` "Hello World"+        d `shouldBe` "Haskell Tips"+      _ -> expectationFailure "Expected all CacheHitReady"++  it "all three strategies in one round: Eager + Sequential + Concurrent" $ do+    env <- mkTestEnv+    (a, b, c) <- runTest env $+      (,,) <$> fetch (EagerKey 1) <*> fetch (SeqKey 1) <*> fetch (UserId 1)+    a `shouldBe` "eager-1"+    b `shouldBe` "seq-1"+    c `shouldBe` "Alice"++  it "high-fan-out: 100 distinct keys in one applicative round" $ do+    env <- mkTestEnv+    let keys = map RangeKey [1..100]+    results <- runTest env $ fetchAll keys+    length results `shouldBe` 100+    results `shouldBe` ["range-" <> show i | i <- [1 :: Int .. 100]]++  it "concurrent runFetchWithCache from multiple threads: no crash" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    barrier <- newEmptyMVar+    let n = 20+    doneVars <- mapM (\_ -> newEmptyMVar) [1 :: Int .. n]+    mapM_ (\(i, done) -> forkIO $ do+      readMVar barrier+      result <- runTestWithCache env cRef $ fetch (UserId (1 + i `mod` 3))+      length result `shouldSatisfy` (> 0)+      putMVar done ()+      ) (zip [0 :: Int .. n - 1] doneVars)+    putMVar barrier ()+    mapM_ takeMVar doneVars++-- ──────────────────────────────────────────────+-- Fetch / primeCache races+-- ──────────────────────────────────────────────++fetchTRaceSpec :: Spec+fetchTRaceSpec = describe "Fetch / primeCache" $ do++  it "concurrent primeCache + fetch for same key: no corruption" $ do+    mapM_ (\_ -> do+      env <- mkTestEnv+      cRef <- newCacheRef+      barrier <- newEmptyMVar+      resultVar <- newEmptyMVar+      _ <- forkIO $ do+        readMVar barrier+        runTestWithCache env cRef $ primeCache (UserId 1) "primed"+        pure ()+      _ <- forkIO $ do+        readMVar barrier+        r <- runTestWithCache env cRef $ fetch (UserId 1)+        putMVar resultVar r+      putMVar barrier ()+      result <- takeMVar resultVar+      result `shouldSatisfy` (\v -> v == "primed" || v == "Alice")+      ) [1 :: Int .. 50]++  it "primeCache into pending IVar while batch in flight" $ do+    env0 <- mkTestEnv+    slowBarrier <- newEmptyMVar+    let env' = env0 { envSlowBarrier = slowBarrier }+    cRef <- newCacheRef+    fetchDone <- newEmptyMVar+    _ <- forkIO $ do+      r <- runTestWithCache env' cRef $ fetch (SlowKey 1)+      putMVar fetchDone r+    let waitForPending = do+          hit <- cacheLookup cRef (SlowKey 1)+          case hit of+            CacheHitPending _ -> pure ()+            _                 -> waitForPending+    waitForPending+    runTestWithCache env' cRef $ primeCache (SlowKey 1) "primed-value"+    putMVar slowBarrier ()+    result <- takeMVar fetchDone+    result `shouldBe` "primed-value"++  it "concurrent primeCache storm: one value wins" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    barrier <- newEmptyMVar+    let n = 50+    doneVars <- mapM (\_ -> newEmptyMVar) [1 :: Int .. n]+    mapM_ (\(i, done) -> forkIO $ do+      readMVar barrier+      runTestWithCache env cRef $+        primeCache (UserId 1) ("prime-" <> show i)+      putMVar done ()+      ) (zip [1 :: Int .. n] doneVars)+    putMVar barrier ()+    mapM_ takeMVar doneVars+    result <- runTestWithCache env cRef $ fetch (UserId 1)+    let hasPrimePrefix v = take 6 v == "prime-"+    result `shouldSatisfy` (\v -> hasPrimePrefix v || v == "Alice")++  it "primeCache + cacheEvict race: no crash" $ do+    mapM_ (\_ -> do+      env <- mkTestEnv+      cRef <- newCacheRef+      barrier <- newEmptyMVar+      h1 <- async $ do+        readMVar barrier+        runTestWithCache env cRef $ primeCache (UserId 1) "primed"+      h2 <- async $ do+        readMVar barrier+        cacheEvict cRef (UserId 1)+      putMVar barrier ()+      wait h1+      wait h2+      hit <- cacheLookup cRef (UserId 1)+      case hit of+        CacheMiss       -> pure ()+        CacheHitReady _ -> pure ()+        CacheHitPending _ -> pure ()+      ) [1 :: Int .. 50]++-- ──────────────────────────────────────────────+-- Memo races+-- ──────────────────────────────────────────────++memoRaceSpec :: Spec+memoRaceSpec = describe "Memo" $ do++  it "concurrent memo same key: action runs at most a few times" $ do+    store <- newMemoStore+    callCount <- newIORef (0 :: Int)+    barrier <- newEmptyMVar+    let n = 100+    resultVars <- mapM (\_ -> newEmptyMVar) [1 :: Int .. n]+    mapM_ (\(_, rv) -> forkIO $ do+      readMVar barrier+      v <- memo store id (ComputeKey 1) $ do+        atomicModifyIORef' callCount (\c -> (c + 1, ()))+        pure "computed"+      putMVar rv v+      ) (zip [1 :: Int .. n] resultVars)+    putMVar barrier ()+    results <- mapM takeMVar resultVars+    mapM_ (\v -> v `shouldBe` "computed") results+    count <- readIORef callCount+    count `shouldSatisfy` (< n)++  it "concurrent memoOn same key: action runs at most a few times" $ do+    store <- newMemoStore+    callCount <- newIORef (0 :: Int)+    barrier <- newEmptyMVar+    let n = 100+    resultVars <- mapM (\_ -> newEmptyMVar) [1 :: Int .. n]+    mapM_ (\(_, rv) -> forkIO $ do+      readMVar barrier+      v <- memoOn store id ("shared-key" :: String) $ do+        atomicModifyIORef' callCount (\c -> (c + 1, ()))+        pure (42 :: Int)+      putMVar rv v+      ) (zip [1 :: Int .. n] resultVars)+    putMVar barrier ()+    results <- mapM takeMVar resultVars+    mapM_ (\v -> v `shouldBe` (42 :: Int)) results+    count <- readIORef callCount+    count `shouldSatisfy` (< n)++  it "concurrent memo different keys: each runs exactly once" $ do+    store <- newMemoStore+    callCount <- newIORef (0 :: Int)+    let n = 100+    replicateConcurrently_ n $ do+      myKey <- atomicModifyIORef' callCount (\c -> (c + 1, c))+      v <- memo store id (ComputeKey myKey) (pure ("result-" <> show myKey))+      v `shouldBe` ("result-" <> show myKey)+    count <- readIORef callCount+    count `shouldBe` n++  it "memo + error race: no deadlock, valid results or rethrown exceptions" $ do+    store <- newMemoStore+    let n = 50+    resultVars <- mapM (\_ -> newEmptyMVar) [1 :: Int .. n]+    barrier <- newEmptyMVar+    callCount <- newIORef (0 :: Int)+    mapM_ (\(_, rv) -> forkIO $ do+      readMVar barrier+      r <- try @SomeException $ memo store id (ComputeKey 1) $ do+        myCall <- atomicModifyIORef' callCount (\c -> (c + 1, c))+        if myCall == 0+          then error "first call fails"+          else pure "success"+      putMVar rv r+      ) (zip [1 :: Int .. n] resultVars)+    putMVar barrier ()+    results <- mapM takeMVar resultVars+    mapM_ (\r -> case r of+      Left _  -> pure ()+      Right v -> v `shouldBe` "success"+      ) results++-- ══════════════════════════════════════════════+-- Mutation key types+-- ══════════════════════════════════════════════++data UpdateUser = UpdateUser UserId String+  deriving stock (Show)++instance MutationKey UpdateUser where+  type MutationResult UpdateUser = String  -- returns updated name++data DeleteUser = DeleteUser UserId+  deriving stock (Show)++instance MutationKey DeleteUser where+  type MutationResult DeleteUser = ()++data FailMutation = FailMutation+  deriving stock (Show)++instance MutationKey FailMutation where+  type MutationResult FailMutation = ()++-- ══════════════════════════════════════════════+-- MutationSource instances for TestM+-- ══════════════════════════════════════════════++instance MutationSource TestM UpdateUser where+  executeMutation (UpdateUser (UserId n) newName) =+    pure $ "updated-" <> newName <> "-" <> show n++  reconcileCache (UpdateUser uid _) result cRef =+    cacheWarm @UserId cRef (HM.singleton uid result)++instance MutationSource TestM DeleteUser where+  executeMutation (DeleteUser _) = pure ()++  reconcileCache (DeleteUser uid) _ cRef =+    cacheEvict cRef uid++instance MutationSource TestM FailMutation where+  executeMutation FailMutation =+    error "FailMutation always throws"++-- ══════════════════════════════════════════════+-- Mutate tests+-- ══════════════════════════════════════════════++-- | Run a Mutate computation over TestM in IO.+runMutateTest :: TestEnv -> Mutate TestM TestM a -> IO a+runMutateTest env = runTestM env . runMutate (fetchConfig (runTestM env) testLiftIO)++-- | Run a Mutate computation with an externally-provided cache.+runMutateTestWithCache :: TestEnv -> CacheRef -> Mutate TestM TestM a -> IO a+runMutateTestWithCache env cRef = runTestM env . runMutate ((fetchConfig (runTestM env) testLiftIO) { configCache = Just cRef })++mutateSpec :: Spec+mutateSpec = describe "Fetch.Mutate (Mutate)" $ do+  mutateBasicSpec+  mutateFetchInteractionSpec+  mutateApplicativeSpec+  mutateMonadicSpec+  mutateMockSpec+  mutateCacheReconcileSpec++mutateBasicSpec :: Spec+mutateBasicSpec = describe "basic mutations" $ do++  it "mutate returns correct result" $ do+    env <- mkTestEnv+    result <- runMutateTest env $ mutate (UpdateUser (UserId 1) "NewAlice")+    result `shouldBe` "updated-NewAlice-1"++  it "tryMutate returns Right on success" $ do+    env <- mkTestEnv+    result <- runMutateTest env $ tryMutate (UpdateUser (UserId 1) "NewAlice")+    case result of+      Right v -> v `shouldBe` "updated-NewAlice-1"+      Left _  -> expectationFailure "Expected Right"++  it "tryMutate catches exception" $ do+    env <- mkTestEnv+    result <- runMutateTest env $ tryMutate FailMutation+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left"++  it "mutate throws on exception" $ do+    env <- mkTestEnv+    result <- try @SomeException $ runMutateTest env $ mutate FailMutation+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception"++  it "pure with no mutations completes immediately" $ do+    env <- mkTestEnv+    result <- runMutateTest env $ pure (42 :: Int)+    result `shouldBe` 42++mutateFetchInteractionSpec :: Spec+mutateFetchInteractionSpec = describe "fetch-mutate-fetch interaction" $ do++  it "fetch works within Mutate" $ do+    env <- mkTestEnv+    result <- runMutateTest env $ fetch (UserId 1)+    result `shouldBe` "Alice"++  it "tryFetch works within Mutate" $ do+    env <- mkTestEnv+    result <- runMutateTest env $ tryFetch (UserId 1)+    case result of+      Right v -> v `shouldBe` "Alice"+      Left _  -> expectationFailure "Expected Right"++  it "fetch-mutate-fetch: second fetch sees primed cache from reconcileCache" $ do+    env <- mkTestEnv+    (valBefore, valAfter) <- runMutateTest env $ do+      b <- fetch (UserId 1)+      _ <- mutate (UpdateUser (UserId 1) "NewAlice")+      a <- fetch (UserId 1)+      pure (b, a)+    valBefore `shouldBe` "Alice"+    valAfter `shouldBe` "updated-NewAlice-1"+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 1++  it "fetch after delete mutation misses cache" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    _ <- runMutateTestWithCache env cRef $ do+      _ <- fetch (UserId 1)+      _ <- mutate (DeleteUser (UserId 1))+      tryFetch (UserId 1)+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 2++  it "multiple fetches batch in a single round within Mutate" $ do+    env <- mkTestEnv+    (a, b) <- runMutateTest env $+      (,) <$> fetch (UserId 1) <*> fetch (UserId 2)+    a `shouldBe` "Alice"+    b `shouldBe` "Bob"+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 1++  it "primeCache works within Mutate" $ do+    env <- mkTestEnv+    result <- runMutateTest env $ do+      primeCache (UserId 1) "Primed"+      fetch (UserId 1)+    result `shouldBe` "Primed"+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 0++mutateApplicativeSpec :: Spec+mutateApplicativeSpec = describe "applicative behavior" $ do++  it "fetches batch, mutation fires only after all fetches" $ do+    env <- mkTestEnv+    (user, updated, post) <- runMutateTest env $+      (,,)+        <$> fetch (UserId 1)+        <*> mutate (UpdateUser (UserId 2) "NewBob")+        <*> fetch (PostId 10)+    user `shouldBe` "Alice"+    updated `shouldBe` "updated-NewBob-2"+    post `shouldBe` "Hello World"+    userBatches <- readIORef (envUserLog env)+    postBatches <- readIORef (envPostLog env)+    length userBatches `shouldBe` 1+    length postBatches `shouldBe` 1++  it "two mutations in <*>: both execute sequentially (left then right)" $ do+    env <- mkTestEnv+    (r1, r2) <- runMutateTest env $+      (,) <$> mutate (UpdateUser (UserId 1) "First")+          <*> mutate (UpdateUser (UserId 2) "Second")+    r1 `shouldBe` "updated-First-1"+    r2 `shouldBe` "updated-Second-2"++  it "fmap over mutation result transforms it" $ do+    env <- mkTestEnv+    result <- runMutateTest env $+      fmap (++ "!") (mutate (UpdateUser (UserId 1) "Bang"))+    result `shouldBe` "updated-Bang-1!"++  it "three-way applicative: fetch + mutation + fetch" $ do+    env <- mkTestEnv+    (a, b, c) <- runMutateTest env $+      (,,) <$> fetch (UserId 1)+           <*> mutate (UpdateUser (UserId 2) "M")+           <*> fetch (UserId 3)+    a `shouldBe` "Alice"+    b `shouldBe` "updated-M-2"+    c `shouldBe` "Carol"++mutateMonadicSpec :: Spec+mutateMonadicSpec = describe "monadic behavior" $ do++  it "fetch >>= mutate >>= fetch: correct sequencing" $ do+    env <- mkTestEnv+    (valBefore, result, valAfter) <- runMutateTest env $ do+      b <- fetch (UserId 1)+      r <- mutate (UpdateUser (UserId 1) "Updated")+      a <- fetch (UserId 1)+      pure (b, r, a)+    valBefore `shouldBe` "Alice"+    result `shouldBe` "updated-Updated-1"+    valAfter `shouldBe` "updated-Updated-1"++  it "mutation result used in subsequent fetch key" $ do+    env <- mkTestEnv+    result <- runMutateTest env $ do+      _ <- mutate (UpdateUser (UserId 1) "Dynamic")+      fetch (UserId 2)+    result `shouldBe` "Bob"++  it "two sequential mutations" $ do+    env <- mkTestEnv+    (r1, r2) <- runMutateTest env $ do+      a <- mutate (UpdateUser (UserId 1) "First")+      b <- mutate (UpdateUser (UserId 2) "Second")+      pure (a, b)+    r1 `shouldBe` "updated-First-1"+    r2 `shouldBe` "updated-Second-2"++  it "conditional mutation based on fetch result" $ do+    env <- mkTestEnv+    result <- runMutateTest env $ do+      name <- fetch (UserId 1)+      if name == "Alice"+        then mutate (UpdateUser (UserId 1) "ConditionalUpdate")+        else pure name+    result `shouldBe` "updated-ConditionalUpdate-1"++  it "tryMutate failure doesn't prevent subsequent operations" $ do+    env <- mkTestEnv+    (err, val) <- runMutateTest env $ do+      e <- tryMutate FailMutation+      v <- fetch (UserId 1)+      pure (e, v)+    case err of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left"+    val `shouldBe` "Alice"++mutateMockSpec :: Spec+mutateMockSpec = describe "MockMutate" $ do++  it "mock mutation returns handler result" $ do+    let mocks = mockData @UserId [(UserId 1, "Alice")]+        handlers = mockMutation @UpdateUser (\(UpdateUser _ n) -> "mock-" <> n)+    (result, _) <- runMockMutate @TestM mocks handlers $ mutate (UpdateUser (UserId 1) "Test")+    result `shouldBe` "mock-Test"++  it "mock mutation records the mutation" $ do+    let mocks = emptyMockData+        handlers = mockMutation @UpdateUser (\(UpdateUser _ n) -> "mock-" <> n)+    (_, mutations) <- runMockMutate @TestM mocks handlers $+      mutate (UpdateUser (UserId 1) "Recorded")+    length mutations `shouldBe` 1++  it "mock fetch works alongside mock mutations" $ do+    let mocks = mockData @UserId [(UserId 1, "Alice")]+        handlers = mockMutation @UpdateUser (\(UpdateUser _ n) -> "mock-" <> n)+    ((user, updated), mutations) <- runMockMutate @TestM mocks handlers $ do+      u <- fetch (UserId 1)+      r <- mutate (UpdateUser (UserId 1) "NewName")+      pure (u, r)+    user `shouldBe` "Alice"+    updated `shouldBe` "mock-NewName"+    length mutations `shouldBe` 1++  it "mock tryMutate with no handler returns Left" $ do+    let mocks = emptyMockData+        handlers = emptyMutationHandlers+    (result, _) <- runMockMutate @TestM mocks handlers $+      tryMutate (UpdateUser (UserId 1) "NoHandler")+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for missing handler"++  it "multiple mock mutations recorded in order" $ do+    let mocks = emptyMockData+        handlers = mockMutation @UpdateUser (\(UpdateUser _ n) -> "mock-" <> n)+                <> mockMutation @DeleteUser (\_ -> ())+    (_, mutations) <- runMockMutate @TestM mocks handlers $ do+      _ <- mutate (UpdateUser (UserId 1) "First")+      _ <- mutate (DeleteUser (UserId 2))+      _ <- mutate (UpdateUser (UserId 3) "Third")+      pure ()+    length mutations `shouldBe` 3++mutateCacheReconcileSpec :: Spec+mutateCacheReconcileSpec = describe "cache reconciliation" $ do++  it "reconcileCache evicts stale keys after DeleteUser" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    _ <- runMutateTestWithCache env cRef $ do+      _ <- fetch (UserId 1)+      mutate (DeleteUser (UserId 1))+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheMiss -> pure ()+      _         -> expectationFailure "Expected CacheMiss after DeleteUser"++  it "reconcileCache primes fresh values after UpdateUser" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    _ <- runMutateTestWithCache env cRef $+      mutate (UpdateUser (UserId 1) "Fresh")+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheHitReady v -> v `shouldBe` "updated-Fresh-1"+      _               -> expectationFailure "Expected CacheHitReady with fresh value"++  it "cache shared across runMutateWithCache: mutation effects persist" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    _ <- runMutateTestWithCache env cRef $+      mutate (UpdateUser (UserId 1) "Shared")+    result <- runMutateTestWithCache env cRef $+      fetch (UserId 1)+    result `shouldBe` "updated-Shared-1"+    batches <- readIORef (envUserLog env)+    length batches `shouldBe` 0++-- ══════════════════════════════════════════════+-- Applicative error propagation+-- ══════════════════════════════════════════════++applicativeErrorSpec :: Spec+applicativeErrorSpec = describe "Applicative error propagation" $ do++  it "<*> left fails, right succeeds: whole expression throws" $ do+    env <- mkTestEnv+    result <- try @SomeException $ runTest env $+      (,) <$> fetch (FailKey 1) <*> fetch (UserId 1)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception"+    -- UserId source was still dispatched+    userBatches <- readIORef (envUserLog env)+    length userBatches `shouldBe` 1++  it "<*> right fails, left succeeds: whole expression throws" $ do+    env <- mkTestEnv+    result <- try @SomeException $ runTest env $+      (,) <$> fetch (UserId 1) <*> fetch (FailKey 1)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception"+    userBatches <- readIORef (envUserLog env)+    length userBatches `shouldBe` 1++  it "<*> both sides fail: whole expression throws" $ do+    env <- mkTestEnv+    result <- try @SomeException $ runTest env $+      (,) <$> fetch (FailKey 1) <*> fetch (FailKey 2)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception"++  it "tryFetch <*> tryFetch: left Left, right Right" $ do+    env <- mkTestEnv+    (left', right') <- runTest env $+      (,) <$> tryFetch (FailKey 1) <*> tryFetch (UserId 1)+    case left' of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for FailKey"+    case right' of+      Right v -> v `shouldBe` "Alice"+      Left _  -> expectationFailure "Expected Right for UserId"++  it "tryFetch <*> tryFetch: both Left" $ do+    env <- mkTestEnv+    (left', right') <- runTest env $+      (,) <$> tryFetch (FailKey 1) <*> tryFetch (FailKey 2)+    case left' of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left"+    case right' of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left"++  it "mixed: tryFetch (fail) <*> fetch (ok) succeeds overall" $ do+    env <- mkTestEnv+    (left', right') <- runTest env $+      (,) <$> tryFetch (FailKey 1) <*> fetch (UserId 1)+    case left' of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for FailKey"+    right' `shouldBe` "Alice"++  it "mixed: fetch (fail) <*> tryFetch (ok) throws overall" $ do+    env <- mkTestEnv+    result <- try @SomeException $ runTest env $+      (,) <$> fetch (FailKey 1) <*> tryFetch (UserId 1)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception from fetch side"++  it "three-way: middle fails, all three sources dispatched" $ do+    env <- mkTestEnv+    result <- try @SomeException $ runTest env $+      (,,) <$> fetch (UserId 1) <*> fetch (FailKey 1) <*> fetch (PostId 10)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception"+    userBatches <- readIORef (envUserLog env)+    postBatches <- readIORef (envPostLog env)+    length userBatches `shouldBe` 1+    length postBatches `shouldBe` 1++  it "fmap over failing fetch throws" $ do+    env <- mkTestEnv+    result <- try @SomeException $ runTest env $+      fmap (++ "!") (fetch (FailKey 1))+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception"++  it "successful sources still dispatched when co-batched source fails" $ do+    env <- mkTestEnv+    _ <- runTest env $+      (,) <$> fetch (UserId 1) <*> tryFetch (FailKey 1)+    userBatches <- readIORef (envUserLog env)+    length userBatches `shouldBe` 1+    dispatched <- readIORef (envDispatchLog env)+    dispatched `shouldSatisfy` elem "UserId"+    dispatched `shouldSatisfy` elem "FailKey"++-- ══════════════════════════════════════════════+-- Multi-source failure isolation+-- ══════════════════════════════════════════════++sourceIsolationSpec :: Spec+sourceIsolationSpec = describe "Multi-source failure isolation" $ do++  it "UserId succeeds while FailKey throws in same round" $ do+    env <- mkTestEnv+    (user, failResult) <- runTest env $+      (,) <$> fetch (UserId 1) <*> tryFetch (FailKey 1)+    user `shouldBe` "Alice"+    case failResult of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for FailKey"++  it "three sources, middle fails, first and third succeed" $ do+    env <- mkTestEnv+    (user, failResult, post) <- runTest env $+      (,,) <$> fetch (UserId 1) <*> tryFetch (FailKey 1) <*> fetch (PostId 10)+    user `shouldBe` "Alice"+    post `shouldBe` "Hello World"+    case failResult of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for FailKey"++  it "source-level vs key-level failures: both Left with different errors" $ do+    env <- mkTestEnv+    (failResult, missingResult) <- runTest env $+      (,) <$> tryFetch (FailKey 1) <*> tryFetch (UserId 999)+    case failResult of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for FailKey"+    case missingResult of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for missing UserId"++  it "source B results cached despite source A failure in same round" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    _ <- runTestWithCache env cRef $+      (,) <$> tryFetch (FailKey 1) <*> fetch (UserId 1)+    -- Second run: UserId should be cached+    _ <- runTestWithCache env cRef $ fetch (UserId 1)+    userBatches <- readIORef (envUserLog env)+    length userBatches `shouldBe` 1  -- only one batch, second run hit cache++  it "round 1 mixed success/failure, round 2 uses successful results" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    (_, _, user2) <- runTestWithCache env cRef $ do+      (failResult, user) <- (,) <$> tryFetch (FailKey 1) <*> fetch (UserId 1)+      user2 <- fetch (UserId 1)  -- round 2: should hit cache+      pure (failResult, user, user2)+    user2 `shouldBe` "Alice"+    userBatches <- readIORef (envUserLog env)+    length userBatches `shouldBe` 1++  it "all sources dispatched even when one throws (via dispatch log)" $ do+    env <- mkTestEnv+    _ <- runTest env $+      (,,) <$> tryFetch (FailKey 1) <*> fetch (UserId 1) <*> fetch (PostId 10)+    dispatched <- readIORef (envDispatchLog env)+    dispatched `shouldSatisfy` elem "FailKey"+    dispatched `shouldSatisfy` elem "UserId"+    dispatched `shouldSatisfy` elem "PostId"++-- ══════════════════════════════════════════════+-- Partial batch failures+-- ══════════════════════════════════════════════++partialBatchSpec :: Spec+partialBatchSpec = describe "Partial batch failures" $ do++  it "even key succeeds, odd key fails with FetchError" $ do+    env <- mkTestEnv+    (evenResult, oddResult) <- runTest env $+      (,) <$> tryFetch (PartialKey 2) <*> tryFetch (PartialKey 3)+    case evenResult of+      Right v -> v `shouldBe` "partial-2"+      Left _  -> expectationFailure "Expected Right for even key"+    case oddResult of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for odd key"++  it "fetch on missing partial key throws FetchError" $ do+    env <- mkTestEnv+    result <- try @SomeException $ runTest env $ fetch (PartialKey 3)+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception for odd PartialKey"++  it "even-key results cached despite odd-key failures" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    _ <- runTestWithCache env cRef $+      (,) <$> tryFetch (PartialKey 2) <*> tryFetch (PartialKey 3)+    -- Even key should be in cache+    hit <- cacheLookup cRef (PartialKey 2)+    case hit of+      CacheHitReady v -> v `shouldBe` "partial-2"+      _               -> expectationFailure "Expected CacheHitReady for even key"++  it "mixed partial and full sources in same round" $ do+    env <- mkTestEnv+    (user, partialResult) <- runTest env $+      (,) <$> fetch (UserId 1) <*> tryFetch (PartialKey 3)+    user `shouldBe` "Alice"+    case partialResult of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for odd PartialKey"++  it "multiple partial keys: some succeed, some fail" $ do+    env <- mkTestEnv+    results <- runTest env $+      mapM tryFetch [PartialKey 1, PartialKey 2, PartialKey 3, PartialKey 4]+    case results of+      [r1, r2, r3, r4] -> do+        case r1 of { Left _ -> pure (); Right _ -> expectationFailure "Expected Left for 1" }+        case r2 of { Right v -> v `shouldBe` "partial-2"; Left _ -> expectationFailure "Expected Right for 2" }+        case r3 of { Left _ -> pure (); Right _ -> expectationFailure "Expected Left for 3" }+        case r4 of { Right v -> v `shouldBe` "partial-4"; Left _ -> expectationFailure "Expected Right for 4" }+      _ -> expectationFailure "Expected 4 results"++-- ══════════════════════════════════════════════+-- Strategy failure isolation+-- ══════════════════════════════════════════════++strategyIsolationSpec :: Spec+strategyIsolationSpec = describe "Strategy failure isolation" $ do++  it "eager fails, sequential and concurrent succeed" $ do+    env <- mkTestEnv+    (eagerResult, seqVal, userVal) <- runTest env $+      (,,) <$> tryFetch (FailEagerKey 1) <*> fetch (SeqKey 1) <*> fetch (UserId 1)+    case eagerResult of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for FailEagerKey"+    seqVal `shouldBe` "seq-1"+    userVal `shouldBe` "Alice"++  it "sequential fails, concurrent still succeeds" $ do+    env <- mkTestEnv+    (seqResult, userVal) <- runTest env $+      (,) <$> tryFetch (FailSeqKey 1) <*> fetch (UserId 1)+    case seqResult of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for FailSeqKey"+    userVal `shouldBe` "Alice"++  it "first sequential fails, second sequential still runs" $ do+    env <- mkTestEnv+    (failResult, seqVal) <- runTest env $+      (,) <$> tryFetch (FailSeqKey 1) <*> fetch (SeqKey 1)+    case failResult of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected Left for FailSeqKey"+    seqVal `shouldBe` "seq-1"++  it "all three strategies succeed: correct results" $ do+    env <- mkTestEnv+    (eager, seq', user) <- runTest env $+      (,,) <$> fetch (EagerKey 1) <*> fetch (SeqKey 1) <*> fetch (UserId 1)+    eager `shouldBe` "eager-1"+    seq' `shouldBe` "seq-1"+    user `shouldBe` "Alice"+    dispatched <- readIORef (envDispatchLog env)+    dispatched `shouldSatisfy` elem "EagerKey"+    dispatched `shouldSatisfy` elem "SeqKey"+    dispatched `shouldSatisfy` elem "UserId"++  it "sequential sources dispatched even if eager failed" $ do+    env <- mkTestEnv+    _ <- runTest env $+      (,) <$> tryFetch (FailEagerKey 1) <*> fetch (SeqKey 1)+    dispatched <- readIORef (envDispatchLog env)+    dispatched `shouldSatisfy` elem "FailEagerKey"+    dispatched `shouldSatisfy` elem "SeqKey"++  it "two sequential sources both produce correct results" $ do+    env <- mkTestEnv+    (s1, s2) <- runTest env $+      (,) <$> fetch (SeqKey 1) <*> fetch (SeqKey2 1)+    s1 `shouldBe` "seq-1"+    s2 `shouldBe` "seq2-1"+    dispatched <- readIORef (envDispatchLog env)+    dispatched `shouldSatisfy` elem "SeqKey"+    dispatched `shouldSatisfy` elem "SeqKey2"++-- ══════════════════════════════════════════════+-- Complex dependency patterns+-- ══════════════════════════════════════════════++complexPatternSpec :: Spec+complexPatternSpec = describe "Complex dependency patterns" $ do++  it "deep chain: 4 rounds, 1 key each" $ do+    env <- mkTestEnv+    (_, roundLog) <- runTestWithRoundLog env $ do+      _ <- fetch (RangeKey 1)+      _ <- fetch (RangeKey 2)+      _ <- fetch (RangeKey 3)+      fetch (RangeKey 4)+    length roundLog `shouldBe` 4+    mapM_ (\(_, keys, _) -> keys `shouldBe` 1) roundLog++  it "diamond: 3 rounds (1 key, 2 keys, 1 key)" $ do+    env <- mkTestEnv+    (_, roundLog) <- runTestWithRoundLog env $ do+      _ <- fetch (RangeKey 1)+      _ <- (,) <$> fetch (RangeKey 2) <*> fetch (RangeKey 3)+      fetch (RangeKey 4)+    length roundLog `shouldBe` 3+    case roundLog of+      [(_, k1, _), (_, k2, _), (_, k3, _)] -> do+        k1 `shouldBe` 1+        k2 `shouldBe` 2+        k3 `shouldBe` 1+      _ -> expectationFailure "Expected 3 rounds"++  it "fan-out-fan-in: 2 rounds (10 keys, 1 key)" $ do+    env <- mkTestEnv+    (_, roundLog) <- runTestWithRoundLog env $ do+      _ <- fetchAll (map RangeKey [1..10])+      fetch (RangeKey 99)+    length roundLog `shouldBe` 2+    case roundLog of+      [(_, k1, _), (_, k2, _)] -> do+        k1 `shouldBe` 10+        k2 `shouldBe` 1+      _ -> expectationFailure "Expected 2 rounds"++  it "monadic-applicative-monadic: 3 rounds" $ do+    env <- mkTestEnv+    (_, roundLog) <- runTestWithRoundLog env $ do+      _ <- fetch (RangeKey 1)               -- round 1+      _ <- (,) <$> fetch (RangeKey 2)       -- round 2 (applicative)+               <*> fetch (RangeKey 3)+      fetch (RangeKey 4)                     -- round 3+    length roundLog `shouldBe` 3++  it "nested applicative: all keys in one round" $ do+    env <- mkTestEnv+    (_, roundLog) <- runTestWithRoundLog env $+      (,) <$> ((,) <$> fetch (RangeKey 1) <*> fetch (RangeKey 2))+          <*> fetch (RangeKey 3)+    length roundLog `shouldBe` 1+    case roundLog of+      [(_, keys, _)] -> keys `shouldBe` 3+      _              -> expectationFailure "Expected 1 round"++  it "applicative with pure: fetch happens, pure doesn't create round" $ do+    env <- mkTestEnv+    (result, roundLog) <- runTestWithRoundLog env $+      (,) <$> fetch (RangeKey 1) <*> pure (42 :: Int)+    fst result `shouldBe` "range-1"+    snd result `shouldBe` 42+    length roundLog `shouldBe` 1++  it "pure >>= fetch: single round" $ do+    env <- mkTestEnv+    (_, roundLog) <- runTestWithRoundLog env $+      pure 1 >>= \x -> fetch (RangeKey x)+    length roundLog `shouldBe` 1++  it "round content matches expected key sets" $ do+    env <- mkTestEnv+    roundKeysRef <- newIORef ([] :: [[UserId]])+    cRef <- newCacheRef+    let e = FetchEnv+          { fetchCache = cRef+          , fetchLower = runTestM env+          , fetchLift  = testLiftIO+          }+    _ <- runTestM env $ runLoopWith e (\_ batches exec -> do+      let ks = batchKeys @UserId batches+      testLiftIO $ modifyIORef' roundKeysRef (\l -> l ++ [ks])+      _ <- exec+      pure ()+      ) $ do+        _ <- fetch (UserId 1)+        (,) <$> fetch (UserId 2) <*> fetch (UserId 3)+    rounds <- readIORef roundKeysRef+    length rounds `shouldBe` 2+    case rounds of+      [r1, r2] -> do+        r1 `shouldSatisfy` elem (UserId 1)+        length r1 `shouldBe` 1+        r2 `shouldSatisfy` elem (UserId 2)+        r2 `shouldSatisfy` elem (UserId 3)+        length r2 `shouldBe` 2+      _ -> expectationFailure "Expected 2 rounds"++-- ══════════════════════════════════════════════+-- liftSource tests+-- ══════════════════════════════════════════════++liftSourceSpec :: Spec+liftSourceSpec = describe "liftSource" $ do++  it "liftSource (pure 42) returns 42 with zero rounds" $ do+    env <- mkTestEnv+    (result, roundLog) <- runTestWithRoundLog env $+      liftSource (pure (42 :: Int))+    result `shouldBe` 42+    length roundLog `shouldBe` 0++  it "liftSource combined applicatively with fetch: fetch still batches" $ do+    env <- mkTestEnv+    (result, roundLog) <- runTestWithRoundLog env $+      (,) <$> liftSource (pure (42 :: Int)) <*> fetch (UserId 1)+    fst result `shouldBe` 42+    snd result `shouldBe` "Alice"+    length roundLog `shouldBe` 1++  it "liftSource in monadic bind does NOT create a round boundary" $ do+    env <- mkTestEnv+    (_, roundLog) <- runTestWithRoundLog env $ do+      x <- liftSource (pure (1 :: Int))+      fetch (RangeKey x)+    -- liftSource returns Done immediately, so bind proceeds to fetch.+    -- Only 1 round for the fetch.+    length roundLog `shouldBe` 1++  it "liftSource performs IO side effects" $ do+    env <- mkTestEnv+    ref <- newIORef False+    _ <- runTest env $ liftSource $ TestM $ \_ -> do+      writeIORef ref True+      pure ()+    val <- readIORef ref+    val `shouldBe` True++  it "liftSource interleaved with fetches in applicative doesn't disrupt batching" $ do+    env <- mkTestEnv+    (_, roundLog) <- runTestWithRoundLog env $+      (,,) <$> fetch (UserId 1)+           <*> liftSource (pure ("static" :: String))+           <*> fetch (UserId 2)+    length roundLog `shouldBe` 1+    userBatches <- readIORef (envUserLog env)+    length userBatches `shouldBe` 1++-- ══════════════════════════════════════════════+-- NoCaching detailed behavior+-- ══════════════════════════════════════════════++noCachingSpec :: Spec+noCachingSpec = describe "NoCaching detailed behavior" $ do++  it "same NoCaching key in two sequential rounds dispatches twice" $ do+    env <- mkTestEnv+    (a, b) <- runTest env $ do+      x <- fetch (MutKey 1)+      y <- fetch (MutKey 1)+      pure (x, y)+    mutBatches <- readIORef (envMutLog env)+    -- Must dispatch exactly twice, once per round+    length mutBatches `shouldBe` 2+    -- Counter-based source returns different values across rounds+    a `shouldSatisfy` (/= b)++  it "NoCaching key in applicative with CacheResults key: both dispatched" $ do+    env <- mkTestEnv+    (mutVal, userVal) <- runTest env $+      (,) <$> fetch (MutKey 1) <*> fetch (UserId 1)+    userVal `shouldBe` "Alice"+    mutVal `shouldSatisfy` const True+    mutBatches <- readIORef (envMutLog env)+    length mutBatches `shouldBe` 1++  it "NoCaching key twice in same applicative: deduplicated within round" $ do+    env <- mkTestEnv+    (a, b) <- runTest env $+      (,) <$> fetch (MutKey 1) <*> fetch (MutKey 1)+    -- Same value from same round+    a `shouldBe` b+    mutBatches <- readIORef (envMutLog env)+    -- Only one batch call for the round+    length mutBatches `shouldBe` 1++  it "counter-based MutKey source increments across rounds" $ do+    env <- mkTestEnv+    (a, b) <- runTest env $ do+      x <- fetch (MutKey 1)+      y <- fetch (MutKey 2)+      pure (x, y)+    -- Each round gets a different counter value+    a `shouldSatisfy` (/= b)+    mutCount <- readIORef (envMutCount env)+    mutCount `shouldBe` 2++  it "NoCaching: same key across >>= rounds dispatches fresh batch each time" $ do+    env <- mkTestEnv+    (a, b) <- runTest env $ do+      x <- fetch (MutKey 1)+      y <- fetch (MutKey 1)+      pure (x, y)+    mutBatches <- readIORef (envMutLog env)+    -- Must dispatch exactly twice, once per round+    length mutBatches `shouldBe` 2+    -- Counter-based source returns different values across rounds+    a `shouldSatisfy` (/= b)++-- ══════════════════════════════════════════════+-- Round stats and probe assertions+-- ══════════════════════════════════════════════++roundStatsSpec :: Spec+roundStatsSpec = describe "Round stats and probe" $ do++  it "RoundStats.roundSources counts distinct sources" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    let batches = singletonBatch @TestM (UserId 1)+               <> singletonBatch @TestM (PostId 10)+               <> singletonBatch @TestM (UserId 2)+    stats <- executeBatches (runTestM env) testLiftIO cRef batches+    roundSources stats `shouldBe` 2  -- UserId and PostId++  it "RoundStats.roundKeys counts deduplicated keys" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    let batches = singletonBatch @TestM (UserId 1)+               <> singletonBatch @TestM (UserId 1)  -- duplicate+               <> singletonBatch @TestM (PostId 10)+    stats <- executeBatches (runTestM env) testLiftIO cRef batches+    roundKeys stats `shouldBe` 2  -- UserId 1 (deduped) + PostId 10++  it "RoundStats.roundCacheHits counts already-cached keys" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    cacheWarm @UserId cRef (HM.singleton (UserId 1) "Alice")+    let batches = singletonBatch @TestM (UserId 1)+               <> singletonBatch @TestM (UserId 2)+    stats <- executeBatches (runTestM env) testLiftIO cRef batches+    roundCacheHits stats `shouldBe` 1  -- UserId 1 was cached+    roundKeys stats `shouldBe` 2       -- total keys in batch++  it "probe on blocked computation returns Blocked with correct batch info" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    let e = FetchEnv+          { fetchCache = cRef+          , fetchLower = runTestM env+          , fetchLift  = testLiftIO+          }+    status <- runTestM env $ unFetch+      ((,) <$> fetch (UserId 1) <*> fetch (PostId 10)) e+    case status of+      Done _       -> expectationFailure "Expected Blocked"+      Blocked bs _ -> do+        batchSize bs `shouldBe` 2+        batchSourceCount bs `shouldBe` 2++-- ══════════════════════════════════════════════+-- MonadThrow / MonadCatch tests+-- ══════════════════════════════════════════════++-- | A custom test exception for MonadThrow/MonadCatch tests.+newtype TestException = TestException String+  deriving stock (Show, Eq)++instance MC.Exception TestException++throwCatchSpec :: Spec+throwCatchSpec = describe "MonadThrow / MonadCatch" $ do++  it "throwM in Fetch produces exception catchable at IO level" $ do+    env <- mkTestEnv+    result <- try @SomeException $ runTest env $+      MC.throwM (TestException "boom")+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception"++  it "catch in Fetch catches throwM" $ do+    env <- mkTestEnv+    result <- runTest env $+      MC.catch+        (MC.throwM (TestException "caught") :: Fetch TestM String)+        (\(TestException msg) -> pure ("recovered: " <> msg))+    result `shouldBe` "recovered: caught"++  it "catch in Fetch across round boundary catches later-round exception" $ do+    env <- mkTestEnv+    result <- runTest env $+      MC.catch+        (do _ <- fetch (UserId 1)  -- round 1+            MC.throwM (TestException "round2") :: Fetch TestM String)+        (\(TestException msg) -> pure ("caught: " <> msg))+    result `shouldBe` "caught: round2"++  it "catch wrapping fetch of missing key catches FetchError" $ do+    env <- mkTestEnv+    result <- runTest env $+      MC.catch+        (fetch (UserId 999))+        (\(_ :: SomeException) -> pure "fallback")+    result `shouldBe` "fallback"++  it "throwM/catch in Mutate works" $ do+    env <- mkTestEnv+    result <- runMutateTest env $+      MC.catch+        (MC.throwM (TestException "mut") :: Mutate TestM TestM String)+        (\(TestException msg) -> pure ("caught: " <> msg))+    result `shouldBe` "caught: mut"++  it "throwM/catch in MockFetch works via delegation" $ do+    let mocks = mockData @UserId [(UserId 1, "Alice")]+    result <- runMockFetch @TestM mocks $+      MC.catch+        (MC.throwM (TestException "mock") :: MockFetch TestM IO String)+        (\(TestException msg) -> pure ("caught: " <> msg))+    result `shouldBe` "caught: mock"++-- ══════════════════════════════════════════════+-- Async exception safety tests+-- ══════════════════════════════════════════════++asyncExceptionSpec :: Spec+asyncExceptionSpec = describe "Async exception safety" $ do+  asyncIVarSpec+  asyncFetchSpec+  asyncMutateSpec++asyncIVarSpec :: Spec+asyncIVarSpec = describe "IVar" $ do++  it "awaitIVar is interruptible by throwTo" $ do+    iv <- newIVar @Int+    started <- newEmptyMVar+    resultVar <- newEmptyMVar+    tid <- forkIO $ do+      putMVar started ()+      r <- try @SomeException (awaitIVar iv)+      putMVar resultVar r+    -- Wait for the thread to be ready (about to enter readMVar)+    takeMVar started+    -- Deliver async exception; throwTo blocks until delivered+    throwTo tid (toException (TestException "killed"))+    result <- takeMVar resultVar+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected async exception from throwTo"++  it "IVar writable after reader is killed; new reader sees value" $ do+    iv <- newIVar @Int+    started <- newEmptyMVar+    done <- newEmptyMVar+    tid <- forkIO $ do+      putMVar started ()+      _ <- try @SomeException (awaitIVar iv)+      putMVar done ()+    takeMVar started+    throwTo tid (toException (TestException "killed"))+    -- Wait for the killed thread to finish its exception handler+    takeMVar done+    -- IVar should still be writable+    writeIVar iv 42+    result <- awaitIVar iv+    case result of+      Right v -> v `shouldBe` 42+      Left _  -> expectationFailure "Expected Right after writeIVar"++  it "multiple readers: kill one, others still see value when written" $ do+    iv <- newIVar @Int+    started1 <- newEmptyMVar+    started2 <- newEmptyMVar+    resultVar <- newEmptyMVar+    tid1 <- forkIO $ do+      putMVar started1 ()+      _ <- try @SomeException (awaitIVar iv)+      pure ()+    _ <- forkIO $ do+      putMVar started2 ()+      r <- awaitIVar iv+      putMVar resultVar r+    takeMVar started1+    takeMVar started2+    -- Kill reader 1+    throwTo tid1 (toException (TestException "killed"))+    -- Write value; reader 2 should see it+    writeIVar iv 99+    result <- takeMVar resultVar+    case result of+      Right v -> v `shouldBe` 99+      Left _  -> expectationFailure "Expected Right from surviving reader"++asyncFetchSpec :: Spec+asyncFetchSpec = describe "Fetch" $ do++  it "cancel during batch execution propagates to caller" $ do+    env <- mkTestEnv+    handle <- async $ runTest env $ fetch (BlockingKey 1)+    -- Wait until the batch is in flight+    takeMVar (envAsyncStarted env)+    cancel handle+    result <- waitCatch handle+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected async exception"+    -- Cleanup: release the blocking batch thread so it doesn't leak+    _ <- tryPutMVar (envAsyncProceed env) ()+    pure ()++  it "completed results remain cached after later round is cancelled" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    handle <- async $ runTestWithCache env cRef $ do+      _ <- fetch (UserId 1)     -- round 1: succeeds+      fetch (BlockingKey 1)     -- round 2: blocks+    -- Round 2's batch is in flight+    takeMVar (envAsyncStarted env)+    cancel handle+    _ <- tryPutMVar (envAsyncProceed env) ()+    -- UserId 1 was cached in round 1 and should still be valid+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheHitReady v -> v `shouldBe` "Alice"+      _               -> expectationFailure "Expected CacheHitReady for UserId 1"++  it "concurrent threads sharing cache: cancel one, other completes" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    -- Thread A: fetches UserId 1, then blocks on BlockingKey+    handleA <- async $ runTestWithCache env cRef $ do+      _ <- fetch (UserId 1)+      fetch (BlockingKey 1)+    -- Wait for A to reach the blocking batch+    takeMVar (envAsyncStarted env)+    -- Thread B: uses the same cache, fetches UserId 2+    resultB <- runTestWithCache env cRef $ fetch (UserId 2)+    resultB `shouldBe` "Bob"+    -- Cancel A and cleanup+    cancel handleA+    _ <- tryPutMVar (envAsyncProceed env) ()+    pure ()++  it "background batch thread fills IVars after parent is cancelled" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    handle <- async $ runTestWithCache env cRef $ do+      _ <- fetch (UserId 1)+      fetch (BlockingKey 1)+    takeMVar (envAsyncStarted env)+    cancel handle+    -- Release the batch thread; it should complete and fill the IVar+    putMVar (envAsyncProceed env) ()+    -- The batch thread is an orphaned `async` child; it will fill the IVar+    hit <- cacheLookup cRef (BlockingKey 1)+    case hit of+      CacheHitPending iv -> do+        result <- awaitIVar iv+        case result of+          Right v -> v `shouldBe` "blocking-1"+          Left _  -> expectationFailure "Expected Right from background batch"+      CacheHitReady v -> v `shouldBe` "blocking-1"+      CacheMiss -> expectationFailure "Expected cache entry for BlockingKey 1"++  it "MonadCatch handler is NOT invoked for async exceptions during batch execution" $ do+    -- Fetch's catch wraps the probe phase, not batch execution.+    -- Async exceptions during executeBatches bypass MonadCatch.+    env <- mkTestEnv+    handlerCalled <- newIORef False+    handle <- async $ runTest env $+      MC.catch+        (do _ <- fetch (UserId 1)+            fetch (BlockingKey 1))+        (\(_ :: SomeException) -> do+            liftSource $ testLiftIO $ writeIORef handlerCalled True+            pure "caught")+    takeMVar (envAsyncStarted env)+    cancel handle+    _ <- tryPutMVar (envAsyncProceed env) ()+    result <- waitCatch handle+    case result of+      Left _        -> pure ()  -- exception propagated, not caught by handler+      Right "caught" -> expectationFailure+        "MonadCatch handler should not catch async exceptions during batch execution"+      Right _        -> expectationFailure "Unexpected success"+    called <- readIORef handlerCalled+    called `shouldBe` False++  it "throwTo with custom exception reaches caller via try" $ do+    env <- mkTestEnv+    started <- newEmptyMVar+    resultVar <- newEmptyMVar+    tid <- forkIO $ do+      putMVar started ()+      r <- try @SomeException $ runTest env $ fetch (BlockingKey 1)+      putMVar resultVar r+    takeMVar started+    -- Wait for the batch to start so we know the thread is deep in execution+    takeMVar (envAsyncStarted env)+    throwTo tid (toException (TestException "async-kill"))+    result <- takeMVar resultVar+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected exception from throwTo"+    _ <- tryPutMVar (envAsyncProceed env) ()+    pure ()++  it "cache not corrupted by cancelled computation; fresh run succeeds" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    -- First run: fetch user, then block and get cancelled+    handle <- async $ runTestWithCache env cRef $ do+      _ <- fetch (UserId 1)+      fetch (BlockingKey 1)+    takeMVar (envAsyncStarted env)+    cancel handle+    _ <- tryPutMVar (envAsyncProceed env) ()+    -- Second run: fresh computation on the same cache+    -- UserId 1 should be cached; UserId 2 should be fetchable+    env2 <- mkTestEnv  -- fresh env so BlockingKey barriers are reset+    result <- runTestWithCache env2 cRef $+      (,) <$> fetch (UserId 1) <*> fetch (UserId 2)+    result `shouldBe` ("Alice", "Bob")+    -- Verify UserId 1 came from cache (no second batchFetch for it)+    userLog <- readIORef (envUserLog env2)+    let allFetchedUsers = concat userLog+    allFetchedUsers `shouldNotContain` [UserId 1]++asyncMutateSpec :: Spec+asyncMutateSpec = describe "Mutate" $ do++  it "cancel before mutation: mutation never executes" $ do+    env <- mkTestEnv+    cRef <- newCacheRef+    handle <- async $ runMutateTestWithCache env cRef $ do+      _ <- fetch (UserId 1)       -- round 1: succeeds+      _ <- fetch (BlockingKey 1)  -- round 2: blocks, gets cancelled+      mutate (UpdateUser (UserId 1) "ShouldNotHappen")+    takeMVar (envAsyncStarted env)+    cancel handle+    _ <- tryPutMVar (envAsyncProceed env) ()+    -- If the mutation had run, reconcileCache would have overwritten+    -- UserId 1 with "updated-ShouldNotHappen-1". Check it's still "Alice".+    hit <- cacheLookup cRef (UserId 1)+    case hit of+      CacheHitReady v -> v `shouldBe` "Alice"+      _               -> expectationFailure "Expected CacheHitReady for UserId 1"++  it "cancel during fetch phase of Mutate propagates exception" $ do+    env <- mkTestEnv+    handle <- async $ runMutateTest env $ do+      fetch (BlockingKey 1)  -- blocks, gets cancelled+    takeMVar (envAsyncStarted env)+    cancel handle+    result <- waitCatch handle+    case result of+      Left _  -> pure ()+      Right _ -> expectationFailure "Expected async exception"+    _ <- tryPutMVar (envAsyncProceed env) ()+    pure ()