packages feed

kiroku-metrics 0.3.0.1 → 0.4.0.0

raw patch · 35 files changed

+4322/−4045 lines, 35 filesdep ~kiroku-clidep ~kiroku-storePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: kiroku-cli, kiroku-store

API changes (from Hackage documentation)

+ Kiroku.Metrics.Standalone: BindFailure :: InspectFailure
+ Kiroku.Metrics.Standalone: DatabaseFailure :: InspectFailure
+ Kiroku.Metrics.Standalone: InspectBindError :: IOException -> InspectBindError
+ Kiroku.Metrics.Standalone: OtherFailure :: InspectFailure
+ Kiroku.Metrics.Standalone: SchemaFailure :: InspectFailure
+ Kiroku.Metrics.Standalone: classifyInspectFailure :: SomeException -> InspectFailure
+ Kiroku.Metrics.Standalone: data InspectFailure
+ Kiroku.Metrics.Standalone: instance GHC.Classes.Eq Kiroku.Metrics.Standalone.InspectFailure
+ Kiroku.Metrics.Standalone: instance GHC.Internal.Exception.Type.Exception Kiroku.Metrics.Standalone.InspectBindError
+ Kiroku.Metrics.Standalone: instance GHC.Internal.Show.Show Kiroku.Metrics.Standalone.InspectBindError
+ Kiroku.Metrics.Standalone: instance GHC.Internal.Show.Show Kiroku.Metrics.Standalone.InspectFailure
+ Kiroku.Metrics.Standalone: newtype InspectBindError
+ Kiroku.Metrics.Standalone: renderInspectFailure :: InspectFailure -> Text

Files

CHANGELOG.md view
@@ -1,5 +1,31 @@ # Revision history for kiroku-metrics +## 0.4.0.0 — 2026-10-26++### Breaking Changes++- With `corsDisabled`, WebSocket upgrades carrying any `Origin` header now receive+  HTTP 403 `origin_not_allowed`. Configure `--cors-origin <public-page-origin>` or+  `corsAllowOrigins` to restore browser access, including behind a same-origin+  proxy that preserves the browser's Origin header. HTTP and origin-less WebSocket+  clients retain their behavior.+- Require `kiroku-store ^>=0.12.0.0` for its expanded operational event surface.++### Bug Fixes++- Handle target-mismatch detail events without duplicating the subsequent+  crashed-stop counter, including startup refusals without `Started`.+- `kiroku-inspect` reports redacted `bind`, `database`, `schema`, or `other`+  startup failures. A first SIGINT/SIGTERM shuts down gracefully; a second,+  including mixed signals, forces exit 130.++### Other Changes++- Document loopback-only `--port 0` and all-interface fixed ports.+- Require `kiroku-cli ^>=0.2.0.11` in library and test dependencies.+- Correct the 0.3.0.0 notes to describe its public collector and standalone API+  changes.+ ## 0.3.0.1 — 2026-10-20  ### Bug Fixes@@ -45,10 +71,23 @@  * `Kiroku.Metrics.Cors` provides validated `AllowedOrigin`, `CorsPolicy` and   `corsMiddleware`: explicit default-off browser access with cache-correct-  HTTP/preflight handling and WebSocket origin refusal before upgrade.+  HTTP/preflight handling and WebSocket origin refusal before upgrade only when+  CORS is enabled. * Shared `errorEnvelope`, `errorResponse` and sanitized `storeErrorResponse`   helpers for new inspection routes. CORS refusals use `origin_not_allowed`,   `cors_method_not_allowed` and `invalid_cors_request` codes.++### Erratum — 2026-10-25++The original 0.3.0.0 notes omitted that WebSocket origin refusal required enabled+CORS; disabled-policy upgrades were open. This repository correction cannot update+the published Hackage copy. See the Unreleased breaking migration note above.++The same release also added the `effectful-core` library dependency. Store-backed+starters automatically enable browse, dead-letter and checkpoint routes on+upgrade; this is a change to their default route surface, not merely an opt-in+API addition. Hosts that need selected providers must use explicit+`ServerProviders` composition through the `...WithProviders` functions.  ### Other Changes 
+ app-inspect/InspectSignals.hs view
@@ -0,0 +1,20 @@+-- | Shared only by the executable and its subprocess test fixture.+module InspectSignals (withInspectSignals) where++import Control.Exception (bracket)+import Control.Monad (void)+import Data.IORef (atomicModifyIORef', newIORef)+import System.Exit (ExitCode (..))+import System.Posix.Process (exitImmediately)+import System.Posix.Signals (Handler (..), installHandler, sigINT, sigTERM)++-- | The shutdown state persists even after the caller consumes its notification.+-- Nested brackets also restore SIGINT if installing SIGTERM fails.+withInspectSignals :: IO () -> IO a -> IO a+withInspectSignals requestShutdown action = do+  requested <- newIORef False+  let handler = Catch $ do+        alreadyRequested <- atomicModifyIORef' requested (\previous -> (True, previous))+        if alreadyRequested then exitImmediately (ExitFailure 130) else requestShutdown+      withHandler signal = bracket (installHandler signal handler Nothing) (\previous -> void (installHandler signal previous Nothing))+  withHandler sigINT $ \_ -> withHandler sigTERM $ \_ -> action
app-inspect/Main.hs view
@@ -2,37 +2,35 @@  import Control.Concurrent.MVar (newEmptyMVar, takeMVar, tryPutMVar) import Control.Exception (SomeAsyncException, SomeException, fromException, tryJust)-import Control.Monad (forM_, void)+import Control.Monad (void) import Data.Text.IO qualified as TIO+import InspectSignals (withInspectSignals) import Options.Applicative (execParser) import System.Environment (getEnvironment) import System.Exit (ExitCode (..), exitFailure, exitWith) import System.IO (hFlush, stderr, stdout)-import System.Posix.Signals (Handler (..), installHandler, sigINT, sigTERM)  import Kiroku.Metrics.Standalone  main :: IO () main = do-    opts <- execParser inspectParserInfo-    env <- getEnvironment-    case resolveInspectOptions env opts of-        Left err -> TIO.hPutStrLn stderr err >> exitWith (ExitFailure 2)-        Right rt -> do-            done <- newEmptyMVar-            forM_ [sigINT, sigTERM] $ \signal ->-                void (installHandler signal (Catch (void (tryPutMVar done ()))) Nothing)-            let hooks =-                    InspectHooks-                        { onListening = \port caps -> mapM_ TIO.putStrLn (renderStartupBanner rt port caps) >> hFlush stdout-                        , waitForShutdown = takeMVar done >> TIO.putStrLn "kiroku-inspect: shutting down"-                        }-            result <- tryJust synchronousException (runInspect hooks rt)-            case result of-                Left _ -> TIO.hPutStrLn stderr "kiroku-inspect: startup or server failure (details redacted)" >> exitFailure-                Right () -> pure ()+  opts <- execParser inspectParserInfo+  env <- getEnvironment+  case resolveInspectOptions env opts of+    Left err -> TIO.hPutStrLn stderr err >> exitWith (ExitFailure 2)+    Right rt -> do+      done <- newEmptyMVar+      let hooks =+            InspectHooks+              { onListening = \port caps -> mapM_ TIO.putStrLn (renderStartupBanner rt port caps) >> hFlush stdout,+                waitForShutdown = takeMVar done >> TIO.putStrLn "kiroku-inspect: shutting down"+              }+      result <- tryJust synchronousException (withInspectSignals (void (tryPutMVar done ())) (runInspect hooks rt))+      case result of+        Left err -> TIO.hPutStrLn stderr (renderInspectFailure (classifyInspectFailure err)) >> exitFailure+        Right () -> pure ()  synchronousException :: SomeException -> Maybe SomeException synchronousException err = case fromException err :: Maybe SomeAsyncException of-    Just _ -> Nothing-    Nothing -> Just err+  Just _ -> Nothing+  Nothing -> Just err
bench/WebSocketTail.hs view
@@ -24,83 +24,84 @@  main :: IO () main = withMigratedTestDatabase $ \conn -> withStore (defaultConnectionSettings conn) $ \store -> do-    -- Setup and publication complete before the fixed comparison.-    mapM_ (\n -> append store (StreamName ("names-" <> T.pack (show n))) 1) [1 .. 500 :: Int]-    append store (StreamName "appender") 1-    bounded (atomically (Pub.publisherPosition store.publisher >>= check . (>= GlobalPosition 501)))-    Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 500)-    putStrLn "Focused local tail delivery: 500 distinct names, 50 batches/trial, 5 paired rounds."-    putStrLn "Each case concurrently appends 50 batches of 10 events to the same existing stream."-    putStrLn "Control uses frozen encoder, warm caches pre-resolve, cold starts one cache per trial. No remote acceptance inference."-    samples <- forM [0 .. 4 :: Int] $ \roundNumber -> do-        let modes = if even roundNumber then ["control", "warm", "cold"] else ["cold", "warm", "control"]-        measured <- traverse (runCase store events) modes-        pure [(mode, result) | (mode, result) <- zip modes measured]-    mapM_-        ( \name -> do-            let xs = map (fromJust . lookup name) samples-            putStrLn (name <> " raw (tail ms, append ms, lookups, retained map/FIFO): " <> show xs)-            putStrLn (name <> " medians tail/append ms: " <> show (median [t | (t, _, _, _) <- xs], median [a | (_, a, _, _) <- xs]))-        )-        ["control", "warm", "cold"]-    finalHead <- runStoreIO store visibleGlobalHeadPosition-    case finalHead of-        Right (GlobalPosition 8001) -> putStrLn "Verified 501 seeded + 7500 concurrently appended events; final visible head 8001."-        other -> fail ("unexpected final head: " <> show other)+  -- Setup and publication complete before the fixed comparison.+  mapM_ (\n -> append store (StreamName ("names-" <> T.pack (show n))) 1) [1 .. 500 :: Int]+  append store (StreamName "appender") 1+  bounded (atomically (Pub.publisherPosition store.publisher >>= check . (>= GlobalPosition 501)))+  Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 500)+  putStrLn "Focused local tail delivery: 500 distinct names, 50 batches/trial, 5 paired rounds."+  putStrLn "Each case concurrently appends 50 batches of 10 events to the same existing stream."+  putStrLn "Control uses frozen encoder, warm caches pre-resolve, cold starts one cache per trial. No remote acceptance inference."+  samples <- forM [0 .. 4 :: Int] $ \roundNumber -> do+    let modes = if even roundNumber then ["control", "warm", "cold"] else ["cold", "warm", "control"]+    measured <- traverse (runCase store events) modes+    pure [(mode, result) | (mode, result) <- zip modes measured]+  mapM_+    ( \name -> do+        let xs = map (fromJust . lookup name) samples+        putStrLn (name <> " raw (tail ms, append ms, lookups, retained map/FIFO): " <> show xs)+        putStrLn (name <> " medians tail/append ms: " <> show (median [t | (t, _, _, _) <- xs], median [a | (_, a, _, _) <- xs]))+    )+    ["control", "warm", "cold"]+  finalHead <- runStoreIO store visibleGlobalHeadPosition+  case finalHead of+    Right (GlobalPosition 8001) -> putStrLn "Verified 501 seeded + 7500 concurrently appended events; final visible head 8001."+    other -> fail ("unexpected final head: " <> show other)  runCase :: KirokuStore -> V.Vector RecordedEvent -> String -> IO (Double, Double, Int, (Int, Int)) runCase store events mode = do-    cache <- newStreamNameCache-    calls <- newIORef (0 :: Int)-    count <- newTVarIO (0 :: Int)-    sub <- Pub.PublisherSubscription <$> newTBQueueIO 1 <*> newTVarIO Pub.Active <*> newTVarIO 0 <*> pure (pure ())-    let lookupBatch ids = do-            modifyIORef' calls (+ 1)-            either (error . show) id <$> runStoreIO store (lookupStreamNames ids)-        send msg = do-            _ <- evaluate (LBS.length (encode msg))-            case msg of-                Event _ -> atomically (modifyTVar' count (+ 1))-                _ -> fail "unexpected loss/error"-        worker =-            if mode == "control"-                then replicateM_ 50 $ do-                    UnchangedBatch batch <- atomically (readTBQueue sub.subscriptionQueue)-                    V.mapM_ (send . Event . recordedEventToJSON) (V.filter (const True) batch)-                    -- Reproduce the original loop's post-delivery status sample.-                    _ <- atomically (readTVar sub.subscriptionStatus)-                    pure ()-                else broadcastEventsWith send cache lookupBatch sub (const True)-    if mode == "warm" then resolveEventNames cache lookupBatch events >> writeIORef calls 0 else pure ()-    withAsync worker $ \_ -> do-        (tailMs, appendMs) <--            concurrently-                ( timed $ do-                    replicateM_ 50 (atomically (writeTBQueue sub.subscriptionQueue (UnchangedBatch events)))-                    bounded (atomically (readTVar count >>= check . (== 25000)))-                )-                (timed $ replicateM_ 50 (append store (StreamName "appender") 10))-        lookups <- readIORef calls-        retained <- streamNameCacheSize cache+  cache <- newStreamNameCache+  calls <- newIORef (0 :: Int)+  count <- newTVarIO (0 :: Int)+  sub <- Pub.PublisherSubscription <$> newTBQueueIO 1 <*> newTVarIO Pub.Active <*> newTVarIO 0 <*> pure (pure ())+  let lookupBatch ids = do+        modifyIORef' calls (+ 1)+        either (error . show) id <$> runStoreIO store (lookupStreamNames ids)+      send msg = do+        _ <- evaluate (LBS.length (encode msg))+        case msg of+          Event _ -> atomically (modifyTVar' count (+ 1))+          _ -> fail "unexpected loss/error"+      worker =         if mode == "control"-            then pure ()-            else do-                if lookups == (if mode == "warm" then 0 else 1) && retained == (500, 500) then pure () else fail "cache invariant failed"-        pure (tailMs, appendMs, lookups, retained)+          then replicateM_ 50 $ do+            UnchangedBatch batch <- atomically (readTBQueue sub.subscriptionQueue)+            V.mapM_ (send . Event . recordedEventToJSON) (V.filter (const True) batch)+            -- Reproduce the original loop's post-delivery status sample.+            _ <- atomically (readTVar sub.subscriptionStatus)+            pure ()+          else broadcastEventsWith send cache lookupBatch sub (const True)+  if mode == "warm" then resolveEventNames cache lookupBatch events >> writeIORef calls 0 else pure ()+  withAsync worker $ \_ -> do+    (tailMs, appendMs) <-+      concurrently+        ( timed $ do+            replicateM_ 50 (atomically (writeTBQueue sub.subscriptionQueue (UnchangedBatch events)))+            bounded (atomically (readTVar count >>= check . (== 25000)))+        )+        (timed $ replicateM_ 50 (append store (StreamName "appender") 10))+    lookups <- readIORef calls+    retained <- streamNameCacheSize cache+    if mode == "control"+      then pure ()+      else do+        if lookups == (if mode == "warm" then 0 else 1) && retained == (500, 500) then pure () else fail "cache invariant failed"+    pure (tailMs, appendMs, lookups, retained)  append :: KirokuStore -> StreamName -> Int -> IO () append store name n = do-    result <- runStoreIO store (appendToStream name AnyVersion (replicate n (EventData Nothing (EventType "Bench") (object []) Nothing Nothing Nothing)))-    either (fail . show) (const (pure ())) result+  result <- runStoreIO store (appendToStream name AnyVersion (replicate n (EventData Nothing (EventType "Bench") (object []) Nothing Nothing Nothing)))+  either (fail . show) (const (pure ())) result  timed :: IO a -> IO Double timed action = do-    t0 <- getCurrentTime-    _ <- action-    t1 <- getCurrentTime-    pure (realToFrac (diffUTCTime t1 t0) * 1000 :: Double)+  t0 <- getCurrentTime+  _ <- action+  t1 <- getCurrentTime+  pure (realToFrac (diffUTCTime t1 t0) * 1000 :: Double)  bounded :: IO a -> IO a bounded action = timeout 30_000_000 action >>= maybe (fail "focused tail timeout") pure+ median :: [Double] -> Double median xs = sort xs !! (length xs `div` 2)
example/Main.hs view
@@ -1,18 +1,17 @@ {-# LANGUAGE OverloadedLabels #-} {-# LANGUAGE ScopedTypeVariables #-} -{- | A self-verifying runnable demo of @kiroku-metrics@ (EP-4).--Boots an ephemeral, migrated PostgreSQL, opens a store with the metrics collector-wired into its callbacks /before/ @withStore@, starts the combined HTTP+WebSocket-server, appends a few events, then checks its own endpoints over real HTTP and a-real WebSocket. Prints a step transcript and exits non-zero if any check fails, so-running it is a test that the documented behavior holds:--@-cabal run kiroku-metrics-example-@--}+-- | A self-verifying runnable demo of @kiroku-metrics@ (EP-4).+--+-- Boots an ephemeral, migrated PostgreSQL, opens a store with the metrics collector+-- wired into its callbacks /before/ @withStore@, starts the combined HTTP+WebSocket+-- server, appends a few events, then checks its own endpoints over real HTTP and a+-- real WebSocket. Prints a step transcript and exits non-zero if any check fails, so+-- running it is a test that the documented behavior holds:+--+-- @+-- cabal run kiroku-metrics-example+-- @ module Main (main) where  import Control.Concurrent (threadDelay)@@ -30,8 +29,8 @@ import Data.Text (Text) import Data.Text qualified as T import Data.Text.Encoding qualified as TE-import Network.HTTP.Client (-    Manager,+import Network.HTTP.Client+  ( Manager,     defaultManagerSettings,     httpLbs,     method,@@ -41,15 +40,15 @@     responseBody,     responseHeaders,     responseStatus,- )+  ) import Network.HTTP.Types (statusCode) import Network.WebSockets qualified as WS import System.Exit (exitFailure) import System.IO (hPutStrLn, stderr) import System.Timeout (timeout) -import Kiroku.Metrics (-    Capabilities (..),+import Kiroku.Metrics+  ( Capabilities (..),     CheckpointInventoryResponse (..),     MetricsServer (..),     MetricsSnapshot (..),@@ -64,10 +63,10 @@     postgresPing,     snapshotMetrics,     withMetricsServerWithStore,- )+  ) import Kiroku.Metrics.Config (MetricsServerConfig (..))-import Kiroku.Store (-    EventData (..),+import Kiroku.Store+  ( EventData (..),     EventType (..),     ExpectedVersion (..),     KirokuStore (..),@@ -76,128 +75,128 @@     defaultConnectionSettings,     runStoreIO,     withStore,- )+  ) import Kiroku.Store.Subscription.EventPublisher (EventPublisher (..), publisherPosition) import Kiroku.Store.Types (GlobalPosition (..)) import Kiroku.Test.Postgres (withMigratedTestDatabase)  main :: IO () main = withMigratedTestDatabase $ \connStr -> do-    step "[1/11] ephemeral postgres ready"+  step "[1/11] ephemeral postgres ready" -    -- The collector must observe events from the first append, so its callbacks-    -- go on ConnectionSettings BEFORE withStore. But snapshots read store-level-    -- gauges from the live handle — so the collector is built from STM readers-    -- over a TVar that holds the store once it is open (the deferral pattern).-    storeVar <- newTVarIO Nothing-    metrics <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)-    let settings =-            defaultConnectionSettings connStr-                & #eventHandler .~ Just (metricsEventHandler metrics Nothing)-                & #observationHandler .~ Just (metricsObservationHandler metrics Nothing)+  -- The collector must observe events from the first append, so its callbacks+  -- go on ConnectionSettings BEFORE withStore. But snapshots read store-level+  -- gauges from the live handle — so the collector is built from STM readers+  -- over a TVar that holds the store once it is open (the deferral pattern).+  storeVar <- newTVarIO Nothing+  metrics <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)+  let settings =+        defaultConnectionSettings connStr+          & #eventHandler .~ Just (metricsEventHandler metrics Nothing)+          & #observationHandler .~ Just (metricsObservationHandler metrics Nothing) -    withStore settings $ \store -> do-        atomically (writeTVar storeVar (Just store))-        origin <- either (fail . show) pure (allowedOrigin "https://ops.example.com")-        withMetricsServerWithStore (defaultConfig{port = 0, cors = corsAllowOrigins [origin]}) metrics store [postgresPing store] $ \srv -> do-            threadDelay 300_000-            let port = srv.serverPort-                base = "http://127.0.0.1:" <> show port-            step ("[2/11] store + collector + metrics server on port " <> show port)+  withStore settings $ \store -> do+    atomically (writeTVar storeVar (Just store))+    origin <- either (fail . show) pure (allowedOrigin "https://ops.example.com")+    withMetricsServerWithStore (defaultConfig {port = 0, cors = corsAllowOrigins [origin]}) metrics store [postgresPing store] $ \srv -> do+      threadDelay 300_000+      let port = srv.serverPort+          base = "http://127.0.0.1:" <> show port+      step ("[2/11] store + collector + metrics server on port " <> show port) -            appendEvents store (StreamName "orders-1") ["OrderCreated", "OrderPaid", "OrderShipped"]-            step "[3/11] appended 3 events to orders-1"+      appendEvents store (StreamName "orders-1") ["OrderCreated", "OrderPaid", "OrderShipped"]+      step "[3/11] appended 3 events to orders-1" -            mgr <- newManager defaultManagerSettings+      mgr <- newManager defaultManagerSettings -            (sMetrics, bMetrics) <- httpGet mgr (base <> "/metrics")-            check "GET /metrics is 200" (sMetrics == 200)-            let gpos = globalPositionOf bMetrics-            check ("GET /metrics store.global_position >= 3 (got " <> show gpos <> ")") (gpos >= 3)+      (sMetrics, bMetrics) <- httpGet mgr (base <> "/metrics")+      check "GET /metrics is 200" (sMetrics == 200)+      let gpos = globalPositionOf bMetrics+      check ("GET /metrics store.global_position >= 3 (got " <> show gpos <> ")") (gpos >= 3) -            (sProm, bProm) <- httpGet mgr (base <> "/metrics/prometheus")-            check "GET /metrics/prometheus is 200" (sProm == 200)-            check-                "Prometheus body contains kiroku_events_appended_total"-                (T.isInfixOf "kiroku_events_appended_total" (bodyText bProm))+      (sProm, bProm) <- httpGet mgr (base <> "/metrics/prometheus")+      check "GET /metrics/prometheus is 200" (sProm == 200)+      check+        "Prometheus body contains kiroku_events_appended_total"+        (T.isInfixOf "kiroku_events_appended_total" (bodyText bProm)) -            (sLive, _) <- httpGet mgr (base <> "/health/live")-            check "GET /health/live is 200" (sLive == 200)-            (sReady, _) <- httpGet mgr (base <> "/health/ready")-            check "GET /health/ready is 200" (sReady == 200)-            step "[4/11] HTTP /metrics, /prometheus, /health/live, /health/ready all OK"+      (sLive, _) <- httpGet mgr (base <> "/health/live")+      check "GET /health/live is 200" (sLive == 200)+      (sReady, _) <- httpGet mgr (base <> "/health/ready")+      check "GET /health/ready is 200" (sReady == 200)+      step "[4/11] HTTP /metrics, /prometheus, /health/live, /health/ready all OK" -            -- Browser preflight and ordinary reads share the host's allowlist.-            request <- parseRequest (base <> "/metrics")-            let ops = "https://ops.example.com"-                evil = "https://evil.example.com"-            preflight <- httpLbs (request{method = "OPTIONS", requestHeaders = [("Origin", ops), ("Access-Control-Request-Method", "GET")]}) mgr-            check "CORS preflight is 204" (statusCode (responseStatus preflight) == 204)-            check "CORS preflight echoes origin" (lookup "Access-Control-Allow-Origin" (responseHeaders preflight) == Just ops)-            allowed <- httpLbs (request{requestHeaders = [("Origin", ops)]}) mgr-            check "CORS GET echoes origin" (lookup "Access-Control-Allow-Origin" (responseHeaders allowed) == Just ops)-            denied <- httpLbs (request{requestHeaders = [("Origin", evil)]}) mgr-            check "CORS denied GET is unchanged" (statusCode (responseStatus denied) == 200)-            check "CORS denied GET grants nothing" (all (\(name, _) -> name `notElem` ["Access-Control-Allow-Origin", "Access-Control-Allow-Credentials", "Access-Control-Allow-Methods", "Access-Control-Allow-Headers", "Access-Control-Max-Age"]) (responseHeaders denied))-            step "[5/11] CORS: preflight and GET from https://ops.example.com allowed; https://evil.example.com undecorated"+      -- Browser preflight and ordinary reads share the host's allowlist.+      request <- parseRequest (base <> "/metrics")+      let ops = "https://ops.example.com"+          evil = "https://evil.example.com"+      preflight <- httpLbs (request {method = "OPTIONS", requestHeaders = [("Origin", ops), ("Access-Control-Request-Method", "GET")]}) mgr+      check "CORS preflight is 204" (statusCode (responseStatus preflight) == 204)+      check "CORS preflight echoes origin" (lookup "Access-Control-Allow-Origin" (responseHeaders preflight) == Just ops)+      allowed <- httpLbs (request {requestHeaders = [("Origin", ops)]}) mgr+      check "CORS GET echoes origin" (lookup "Access-Control-Allow-Origin" (responseHeaders allowed) == Just ops)+      denied <- httpLbs (request {requestHeaders = [("Origin", evil)]}) mgr+      check "CORS denied GET is unchanged" (statusCode (responseStatus denied) == 200)+      check "CORS denied GET grants nothing" (all (\(name, _) -> name `notElem` ["Access-Control-Allow-Origin", "Access-Control-Allow-Credentials", "Access-Control-Allow-Methods", "Access-Control-Allow-Headers", "Access-Control-Max-Age"]) (responseHeaders denied))+      step "[5/11] CORS: preflight and GET from https://ops.example.com allowed; https://evil.example.com undecorated" -            (sCheckpoints, bCheckpoints) <- httpGet mgr (base <> "/subscription-checkpoints")-            check "GET /subscription-checkpoints is 200" (sCheckpoints == 200)-            case decode bCheckpoints of-                Just (CheckpointInventoryResponse position rows) -> do-                    check "durable store_position >= 3" (position >= 3)-                    check "no durable checkpoints without subscriptions" (null rows)-                    step ("[6/11] GET /subscription-checkpoints store_position=" <> show position <> " with no durable checkpoints (this example runs no subscription)")-                Nothing -> check "durable inventory decodes" False+      (sCheckpoints, bCheckpoints) <- httpGet mgr (base <> "/subscription-checkpoints")+      check "GET /subscription-checkpoints is 200" (sCheckpoints == 200)+      case decode bCheckpoints of+        Just (CheckpointInventoryResponse position rows) -> do+          check "durable store_position >= 3" (position >= 3)+          check "no durable checkpoints without subscriptions" (null rows)+          step ("[6/11] GET /subscription-checkpoints store_position=" <> show position <> " with no durable checkpoints (this example runs no subscription)")+        Nothing -> check "durable inventory decodes" False -            (sStreams, bStreams) <- httpGet mgr (base <> "/streams?category=orders")-            check "GET /streams is 200" (sStreams == 200)-            check "stream page has items" (maybe False (\v -> case lookKey ["items"] v of Just (Array rows) -> not (null rows); _ -> False) (decode bStreams))-            (sEvents, bEvents) <- httpGet mgr (base <> "/events?from=0&limit=10")-            check "GET /events is 200" (sEvents == 200)-            case decode bEvents of-                Just value | Just (Array rows) <- lookKey ["items"] value -> do-                    check "event page includes original names" (all (\row -> lookKey ["original_stream_name"] row == Just (String "orders-1")) rows)-                    case foldr (:) [] rows of-                        first : _ | Just (String uuid) <- lookKey ["eventId"] first -> do-                            (status, _) <- httpGet mgr (base <> "/events/" <> T.unpack uuid)-                            check "GET event by id is 200" (status == 200)-                        _ -> check "event page is nonempty" False-                _ -> check "event page decodes" False-            step "[7/11] Stream browsing and historical events resolve original stream names"+      (sStreams, bStreams) <- httpGet mgr (base <> "/streams?category=orders")+      check "GET /streams is 200" (sStreams == 200)+      check "stream page has items" (maybe False (\v -> case lookKey ["items"] v of Just (Array rows) -> not (null rows); _ -> False) (decode bStreams))+      (sEvents, bEvents) <- httpGet mgr (base <> "/events?from=0&limit=10")+      check "GET /events is 200" (sEvents == 200)+      case decode bEvents of+        Just value | Just (Array rows) <- lookKey ["items"] value -> do+          check "event page includes original names" (all (\row -> lookKey ["original_stream_name"] row == Just (String "orders-1")) rows)+          case foldr (:) [] rows of+            first : _ | Just (String uuid) <- lookKey ["eventId"] first -> do+              (status, _) <- httpGet mgr (base <> "/events/" <> T.unpack uuid)+              check "GET event by id is 200" (status == 200)+            _ -> check "event page is nonempty" False+        _ -> check "event page decodes" False+      step "[7/11] Stream browsing and historical events resolve original stream names" -            (sDeadLetters, bDeadLetters) <- httpGet mgr (base <> "/subscriptions/example/dead-letters?limit=10")-            check "dead-letter page status" (sDeadLetters == 200)-            check "dead-letter page is empty without a cursor" (decode bDeadLetters == Just (object ["items" .= ([] :: [Value])]))-            step "[8/11] GET /subscriptions/example/dead-letters returned an empty page (this example runs no subscription)"+      (sDeadLetters, bDeadLetters) <- httpGet mgr (base <> "/subscriptions/example/dead-letters?limit=10")+      check "dead-letter page status" (sDeadLetters == 200)+      check "dead-letter page is empty without a cursor" (decode bDeadLetters == Just (object ["items" .= ([] :: [Value])]))+      step "[8/11] GET /subscriptions/example/dead-letters returned an empty page (this example runs no subscription)" -            (sCaps, bCaps) <- httpGet mgr (base <> "/capabilities")-            check "discovery status" (sCaps == 200)-            case decode bCaps of-                Just (caps :: Capabilities) ->-                    check-                        "discovery reflects legacy store wiring"-                        (caps.routes.browse && caps.routes.subscriptionsCheckpoints && caps.routes.deadLetters && caps.routes.websocketEvents && not caps.routes.subscriptionsLive)-                Nothing -> check "discovery decodes" False-            step "[9/11] GET /capabilities reports browse, checkpoints, dead letters, and the event tail"+      (sCaps, bCaps) <- httpGet mgr (base <> "/capabilities")+      check "discovery status" (sCaps == 200)+      case decode bCaps of+        Just (caps :: Capabilities) ->+          check+            "discovery reflects legacy store wiring"+            (caps.routes.browse && caps.routes.subscriptionsCheckpoints && caps.routes.deadLetters && caps.routes.websocketEvents && not caps.routes.subscriptionsLive)+        Nothing -> check "discovery decodes" False+      step "[9/11] GET /capabilities reports browse, checkpoints, dead letters, and the event tail" -            -- WebSocket: subscribe to the live event tail, then append one more-            -- event and assert it arrives over the socket as a JSON event message.-            evType <--                requireJust "WebSocket /ws/events round-trip timed out" $-                    timeout 15_000_000 $-                        WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do-                            WS.sendTextData conn (encode (object ["type" .= ("subscribe_events" :: Text)]))-                            _ <- waitForType conn "event_stream_started"-                            appendThread <- async (appendEvents store (StreamName "orders-2") ["OrderRefunded"])-                            ev <- waitForType conn "event"-                            wait appendThread-                            pure (eventTypeOf ev)-            check ("WebSocket event eventType == OrderRefunded (got " <> T.unpack evType <> ")") (evType == "OrderRefunded")-            step ("[10/11] WebSocket /ws/events received event eventType=" <> T.unpack evType)+      -- WebSocket: subscribe to the live event tail, then append one more+      -- event and assert it arrives over the socket as a JSON event message.+      evType <-+        requireJust "WebSocket /ws/events round-trip timed out" $+          timeout 15_000_000 $+            WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do+              WS.sendTextData conn (encode (object ["type" .= ("subscribe_events" :: Text)]))+              _ <- waitForType conn "event_stream_started"+              appendThread <- async (appendEvents store (StreamName "orders-2") ["OrderRefunded"])+              ev <- waitForType conn "event"+              wait appendThread+              pure (eventTypeOf ev)+      check ("WebSocket event eventType == OrderRefunded (got " <> T.unpack evType <> ")") (evType == "OrderRefunded")+      step ("[10/11] WebSocket /ws/events received event eventType=" <> T.unpack evType) -            snap <- snapshotMetrics metrics-            step ("[11/11] kiroku-metrics-example: all checks passed (snapshot global position = " <> show snap.store.globalPosition <> ")")+      snap <- snapshotMetrics metrics+      step ("[11/11] kiroku-metrics-example: all checks passed (snapshot global position = " <> show snap.store.globalPosition <> ")")  -------------------------------------------------------------------------------- -- Helpers@@ -210,47 +209,47 @@ -- | Assert a condition; on failure print to stderr and exit non-zero. check :: String -> Bool -> IO () check label ok =-    unless ok $ do-        hPutStrLn stderr ("kiroku-metrics-example: FAILED: " <> label)-        exitFailure+  unless ok $ do+    hPutStrLn stderr ("kiroku-metrics-example: FAILED: " <> label)+    exitFailure  -- | Require a timed action to have produced a result; else fail the example. requireJust :: String -> IO (Maybe a) -> IO a requireJust label act =-    act >>= \case-        Just a -> pure a-        Nothing -> do-            hPutStrLn stderr ("kiroku-metrics-example: FAILED: " <> label)-            exitFailure+  act >>= \case+    Just a -> pure a+    Nothing -> do+      hPutStrLn stderr ("kiroku-metrics-example: FAILED: " <> label)+      exitFailure  -- | GET a URL, returning the status code and the body (no throwing on non-2xx). httpGet :: Manager -> String -> IO (Int, ByteString) httpGet mgr url = do-    req <- parseRequest url-    resp <- httpLbs req mgr-    pure (statusCode (responseStatus resp), responseBody resp)+  req <- parseRequest url+  resp <- httpLbs req mgr+  pure (statusCode (responseStatus resp), responseBody resp)  -- | Read server messages until one with the given @"type"@ arrives; return it. waitForType :: WS.Connection -> Text -> IO Value waitForType conn want = go   where     go = do-        raw <- WS.receiveData conn :: IO ByteString-        case decode raw of-            Just v | lookKey ["type"] v == Just (String want) -> pure v-            _ -> go+      raw <- WS.receiveData conn :: IO ByteString+      case decode raw of+        Just v | lookKey ["type"] v == Just (String want) -> pure v+        _ -> go  -- | Extract @event.eventType@ from an @event@ message ("" if absent). eventTypeOf :: Value -> Text eventTypeOf v = case lookKey ["event", "eventType"] v of-    Just (String t) -> t-    _ -> ""+  Just (String t) -> t+  _ -> ""  -- | Extract @store.global_position@ from a @/metrics@ JSON body. globalPositionOf :: ByteString -> Int globalPositionOf body = case decode body of-    Just v | Just (Number n) <- lookKey ["store", "global_position"] v -> truncate (toRealFloat n :: Double)-    _ -> -1+  Just v | Just (Number n) <- lookKey ["store", "global_position"] v -> truncate (toRealFloat n :: Double)+  _ -> -1  -- | Navigate nested object keys. lookKey :: [Text] -> Value -> Maybe Value@@ -263,28 +262,28 @@  appendEvents :: KirokuStore -> StreamName -> [Text] -> IO () appendEvents store stream types = do-    let events =-            [ EventData-                { eventId = Nothing-                , eventType = EventType t-                , payload = Null-                , metadata = Nothing-                , causationId = Nothing-                , correlationId = Nothing-                }-            | t <- types-            ]-    result <- runStoreIO store (appendToStream stream NoStream events)-    case result of-        Right _ -> pure ()-        Left err -> do-            hPutStrLn stderr ("kiroku-metrics-example: append failed: " <> show err)-            exitFailure+  let events =+        [ EventData+            { eventId = Nothing,+              eventType = EventType t,+              payload = Null,+              metadata = Nothing,+              causationId = Nothing,+              correlationId = Nothing+            }+        | t <- types+        ]+  result <- runStoreIO store (appendToStream stream NoStream events)+  case result of+    Right _ -> pure ()+    Left err -> do+      hPutStrLn stderr ("kiroku-metrics-example: append failed: " <> show err)+      exitFailure  readPosition :: TVar (Maybe KirokuStore) -> STM GlobalPosition readPosition storeVar =-    readTVar storeVar >>= maybe (pure (GlobalPosition 0)) (publisherPosition . (.publisher))+  readTVar storeVar >>= maybe (pure (GlobalPosition 0)) (publisherPosition . (.publisher))  readSubscribers :: TVar (Maybe KirokuStore) -> STM Int readSubscribers storeVar =-    readTVar storeVar >>= maybe (pure 0) (\s -> IntMap.size <$> readTVar (subscribers s.publisher))+  readTVar storeVar >>= maybe (pure 0) (\s -> IntMap.size <$> readTVar (subscribers s.publisher))
kiroku-metrics.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            kiroku-metrics-version:         0.3.0.1+version:         0.4.0.0 synopsis:   Metrics, health, and event-streaming HTTP endpoints for Kiroku @@ -69,8 +69,8 @@     , hasql                 >=1.10      && <1.11     , hasql-pool            >=1.2       && <1.5     , http-types            >=0.12      && <0.13-    , kiroku-cli            ^>=0.2.0.10-    , kiroku-store          ^>=0.11.0.0+    , kiroku-cli            ^>=0.2.0.11+    , kiroku-store          ^>=0.12.0.0     , network               >=3.1       && <3.3     , optparse-applicative  >=0.19      && <0.20     , stm                   >=2.5       && <2.6@@ -89,6 +89,7 @@   import:         common   main-is:        Main.hs   hs-source-dirs: app-inspect+  other-modules:  InspectSignals   ghc-options:    -threaded -rtsopts -with-rtsopts=-N   build-depends:     , base                  >=4.18 && <5@@ -126,7 +127,7 @@     , http-client          >=0.7       && <0.8     , http-types           >=0.12      && <0.13     , kiroku-metrics-    , kiroku-store         ^>=0.11.0.0+    , kiroku-store         ^>=0.12.0.0     , kiroku-test-support  ^>=0.1     , lens                 >=5.2       && <5.4     , scientific           >=0.3       && <0.4@@ -140,6 +141,7 @@   build-tool-depends: kiroku-metrics:kiroku-inspect   main-is:            Main.hs   other-modules:+    InspectSignals     Test.BrowseSpec     Test.CapabilitiesSpec     Test.CheckpointsSpec@@ -153,7 +155,7 @@     Test.WebSocketConvergenceSpec     Test.WebSocketSpec -  hs-source-dirs:     test+  hs-source-dirs:     test app-inspect   ghc-options:        -threaded -rtsopts -with-rtsopts=-N   build-depends:     , aeson                 >=2.1       && <2.3@@ -170,9 +172,9 @@     , hspec                 >=2.10      && <2.12     , http-client           >=0.7       && <0.8     , http-types            >=0.12      && <0.13-    , kiroku-cli            ^>=0.2.0.10+    , kiroku-cli            ^>=0.2.0.11     , kiroku-metrics-    , kiroku-store          ^>=0.11.0.0+    , kiroku-store          ^>=0.12.0.0     , kiroku-test-support     , lens                  >=5.2       && <5.4     , network               >=3.1       && <3.3@@ -196,14 +198,14 @@   hs-source-dirs: bench   ghc-options:    -threaded -rtsopts "-with-rtsopts=-N -A32m"   build-depends:-    , aeson                >=2.1  && <2.3-    , async                >=2.2  && <2.3-    , base                 >=4.18 && <5-    , bytestring           >=0.11 && <0.13+    , aeson                >=2.1       && <2.3+    , async                >=2.2       && <2.3+    , base                 >=4.18      && <5+    , bytestring           >=0.11      && <0.13     , kiroku-metrics-    , kiroku-store+    , kiroku-store         ^>=0.12.0.0     , kiroku-test-support-    , stm                  >=2.5  && <2.6-    , text                 >=2.0  && <2.2-    , time                 >=1.12 && <1.15-    , vector               >=0.13 && <0.14+    , stm                  >=2.5       && <2.6+    , text                 >=2.0       && <2.2+    , time                 >=1.12      && <1.15+    , vector               >=0.13      && <0.14
src/Kiroku/Metrics.hs view
@@ -1,10 +1,9 @@-{- | Umbrella re-export for the @kiroku-metrics@ package. Importing-@Kiroku.Metrics@ brings the in-process metrics collector, the JSON-encodable-snapshot type, the server configuration and lifecycle, and the health-check-types into scope.--}-module Kiroku.Metrics (-    module Kiroku.Metrics.Browse,+-- | Umbrella re-export for the @kiroku-metrics@ package. Importing+-- @Kiroku.Metrics@ brings the in-process metrics collector, the JSON-encodable+-- snapshot type, the server configuration and lifecycle, and the health-check+-- types into scope.+module Kiroku.Metrics+  ( module Kiroku.Metrics.Browse,     module Kiroku.Metrics.Capabilities,     module Kiroku.Metrics.Types,     module Kiroku.Metrics.Collector,@@ -17,7 +16,8 @@     module Kiroku.Metrics.Standalone,     module Kiroku.Metrics.Subscriptions,     module Kiroku.Metrics.WebSocket,-) where+  )+where  import Kiroku.Metrics.Browse import Kiroku.Metrics.Capabilities
src/Kiroku/Metrics/Browse.hs view
@@ -1,9 +1,8 @@-{- | Bounded inspection reads. Stream names use stable UTF-8 byte order;-category enumeration retains the database's category collation. Cursors are-exclusive and describe live pages rather than a cross-request snapshot.--}-module Kiroku.Metrics.Browse (-    StoreBrowser (..),+-- | Bounded inspection reads. Stream names use stable UTF-8 byte order;+-- category enumeration retains the database's category collation. Cursors are+-- exclusive and describe live pages rather than a cross-request snapshot.+module Kiroku.Metrics.Browse+  ( StoreBrowser (..),     storeBrowser,     storeBrowserWith,     BrowseLimits,@@ -16,7 +15,8 @@     browseApp,     browseNotConfiguredApp,     streamInfoToJSON,-) where+  )+where  import Data.Aeson (Value, encode, object, toJSON, (.=)) import Data.ByteString qualified as BS@@ -40,207 +40,223 @@ import Text.Read (readMaybe)  data StoreBrowser = StoreBrowser-    { runStoreRead :: forall a. Eff '[Store, Error StoreError, IOE] a -> IO (Either StoreError a)-    , limits :: !BrowseLimits-    }+  { runStoreRead :: forall a. Eff '[Store, Error StoreError, IOE] a -> IO (Either StoreError a),+    limits :: !BrowseLimits+  }  data BrowseLimits = BrowseLimits {defaultLimit :: !Int, maxLimit :: !Int}-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)+ data BrowseLimitsError = InvalidBrowseLimits !Int !Int deriving stock (Eq, Show)+ mkBrowseLimits :: Int -> Int -> Either BrowseLimitsError BrowseLimits mkBrowseLimits def cap-    | 1 <= def && def <= cap && cap <= 1000 = Right (BrowseLimits def cap)-    | otherwise = Left (InvalidBrowseLimits def cap)+  | 1 <= def && def <= cap && cap <= 1000 = Right (BrowseLimits def cap)+  | otherwise = Left (InvalidBrowseLimits def cap)+ defaultBrowseLimits :: BrowseLimits defaultBrowseLimits = BrowseLimits 100 1000+ storeBrowser :: KirokuStore -> StoreBrowser storeBrowser = storeBrowserWith defaultBrowseLimits+ storeBrowserWith :: BrowseLimits -> KirokuStore -> StoreBrowser storeBrowserWith lims store = StoreBrowser (runStoreIO store) lims  data ReadDirection = ReadForward | ReadBackward deriving stock (Eq, Show)+ type ReadProgram a = Eff '[Store, Error StoreError, IOE] a+ type Failure = (Status, Text, Text, Maybe Value)  -- | Preserve GET status/headers for HEAD. Other methods never run a read. readMethods :: (Request -> IO Response) -> Application readMethods action req respond-    | requestMethod req == "GET" = action req >>= respond-    | requestMethod req == "HEAD" = do-        response <- action req-        respond (responseLBS (responseStatus response) (responseHeaders response) "")-    | otherwise =-        respond $-            mapResponseHeaders (("Allow", "GET, HEAD") :) $-                errorResponse status405 "method_not_allowed" "Only GET and HEAD are supported." Nothing+  | requestMethod req == "GET" = action req >>= respond+  | requestMethod req == "HEAD" = do+      response <- action req+      respond (responseLBS (responseStatus response) (responseHeaders response) "")+  | otherwise =+      respond $+        mapResponseHeaders (("Allow", "GET, HEAD") :) $+          errorResponse status405 "method_not_allowed" "Only GET and HEAD are supported." Nothing  browseNotConfiguredApp :: Application browseNotConfiguredApp = readMethods $ \_ -> pure (errorResponse status404 "store_browsing_not_configured" "Store browsing is not configured." Nothing)  browseApp :: StoreBrowser -> Application browseApp browser = readMethods $ \req -> case route browser req of-    Left (status, code, message, details) -> pure (errorResponse status code message details)-    Right program ->-        runStoreRead browser program >>= \case-            Left err -> pure (storeErrorResponse "store_unavailable" err)-            Right (Left (status, code, message, details)) -> pure (errorResponse status code message details)-            Right (Right value) -> pure (jsonResponse status200 (encode value))+  Left (status, code, message, details) -> pure (errorResponse status code message details)+  Right program ->+    runStoreRead browser program >>= \case+      Left err -> pure (storeErrorResponse "store_unavailable" err)+      Right (Left (status, code, message, details)) -> pure (errorResponse status code message details)+      Right (Right value) -> pure (jsonResponse status200 (encode value))  route :: StoreBrowser -> Request -> Either Failure (ReadProgram (Either Failure Value)) route browser req = case pathInfo req of-    ["streams"] -> do-        limit <- pageLimit-        category <- fmap CategoryName <$> textParam "category"-        prefix <- textParam "prefix"-        cursor <- fmap StreamName <$> textParam "from"-        size <- browseSize limit-        pure $ do-            rows <- listStreams category prefix cursor size-            pure $ Right $ pageJSON limit streamInfoToJSON (\row -> let StreamName name = row.name in AesonText name) rows-    ["streams", name] -> do-        stream <- validStream name-        pure $ getStream stream >>= pure . maybe (Left (missing "stream_not_found" "Stream not found.")) (Right . streamInfoToJSON)-    ["streams", name, "events"] -> do-        stream <- validStream name-        limit <- pageLimit-        cursor <- position-        direction <- readDirection-        pure $-            getStream stream >>= \case-                Nothing -> pure (Left (missing "stream_not_found" "Stream not found."))-                Just _ ->-                    Right-                        <$> eventPage-                            limit-                            (\event -> let StreamVersion v = event.streamVersion in AesonNumber v)-                            (case direction of ReadForward -> readStreamForward stream (StreamVersion cursor) (overfetch limit); ReadBackward -> readStreamBackward stream (StreamVersion cursor) (overfetch limit))-    ["categories"] -> do-        limit <- pageLimit-        cursor <- fmap CategoryName <$> textParam "from"-        size <- browseSize limit-        pure $ do-            rows <- listCategories cursor size-            pure $ Right $ pageJSON limit (\(CategoryName name) -> object ["name" .= name]) (\(CategoryName name) -> AesonText name) rows-    ["categories", name, "events"] -> do-        validText "category" name-        limit <- pageLimit-        cursor <- position-        -- Category reads have one supported direction; do not silently ignore it.-        direction <- readDirection-        if direction == ReadBackward-            then Left (invalid "direction" "backward" "Category events support forward reads.")-            else-                pure $ Right <$> eventPage limit globalCursor (readCategory (CategoryName name) (GlobalPosition cursor) (overfetch limit))-    ["events"] -> do-        limit <- pageLimit-        cursor <- position-        direction <- readDirection-        pure $-            Right-                <$> eventPage-                    limit-                    globalCursor-                    (case direction of ReadForward -> readAllForward (GlobalPosition cursor) (overfetch limit); ReadBackward -> readAllBackward (GlobalPosition cursor) (overfetch limit))-    ["events", rawId] -> case UUID.fromText rawId of-        Nothing -> Left (status400, "invalid_event_id", "The event id must be a UUID.", Nothing)-        Just uuid ->-            pure $-                getEvent (EventId uuid) >>= \case-                    Nothing -> pure (Left (missing "event_not_found" "Event not found."))-                    Just event -> do-                        mapping <- lookupStreamNames [event.originalStreamId]-                        pure (Right (recordedEventToJSONResolved mapping event))-    _ -> Left (missing "not_found" "Not found.")+  ["streams"] -> do+    limit <- pageLimit+    category <- fmap CategoryName <$> textParam "category"+    prefix <- textParam "prefix"+    cursor <- fmap StreamName <$> textParam "from"+    size <- browseSize limit+    pure $ do+      rows <- listStreams category prefix cursor size+      pure $ Right $ pageJSON limit streamInfoToJSON (\row -> let StreamName name = row.name in AesonText name) rows+  ["streams", name] -> do+    stream <- validStream name+    pure $ getStream stream >>= pure . maybe (Left (missing "stream_not_found" "Stream not found.")) (Right . streamInfoToJSON)+  ["streams", name, "events"] -> do+    stream <- validStream name+    limit <- pageLimit+    cursor <- position+    direction <- readDirection+    pure $+      getStream stream >>= \case+        Nothing -> pure (Left (missing "stream_not_found" "Stream not found."))+        Just _ ->+          Right+            <$> eventPage+              limit+              (\event -> let StreamVersion v = event.streamVersion in AesonNumber v)+              (case direction of ReadForward -> readStreamForward stream (StreamVersion cursor) (overfetch limit); ReadBackward -> readStreamBackward stream (StreamVersion cursor) (overfetch limit))+  ["categories"] -> do+    limit <- pageLimit+    cursor <- fmap CategoryName <$> textParam "from"+    size <- browseSize limit+    pure $ do+      rows <- listCategories cursor size+      pure $ Right $ pageJSON limit (\(CategoryName name) -> object ["name" .= name]) (\(CategoryName name) -> AesonText name) rows+  ["categories", name, "events"] -> do+    validText "category" name+    limit <- pageLimit+    cursor <- position+    -- Category reads have one supported direction; do not silently ignore it.+    direction <- readDirection+    if direction == ReadBackward+      then Left (invalid "direction" "backward" "Category events support forward reads.")+      else+        pure $ Right <$> eventPage limit globalCursor (readCategory (CategoryName name) (GlobalPosition cursor) (overfetch limit))+  ["events"] -> do+    limit <- pageLimit+    cursor <- position+    direction <- readDirection+    pure $+      Right+        <$> eventPage+          limit+          globalCursor+          (case direction of ReadForward -> readAllForward (GlobalPosition cursor) (overfetch limit); ReadBackward -> readAllBackward (GlobalPosition cursor) (overfetch limit))+  ["events", rawId] -> case UUID.fromText rawId of+    Nothing -> Left (status400, "invalid_event_id", "The event id must be a UUID.", Nothing)+    Just uuid ->+      pure $+        getEvent (EventId uuid) >>= \case+          Nothing -> pure (Left (missing "event_not_found" "Event not found."))+          Just event -> do+            mapping <- lookupStreamNames [event.originalStreamId]+            pure (Right (recordedEventToJSONResolved mapping event))+  _ -> Left (missing "not_found" "Not found.")   where     pageLimit = do-        value <- rawParam req "limit"-        case value of-            Nothing -> Right browser.limits.defaultLimit-            Just raw -> case decimal raw of-                Just n | 1 <= n && n <= toInteger browser.limits.maxLimit -> Right (fromInteger n)-                _ -> Left (invalid "limit" raw "Expected a decimal integer within the configured page limit.")+      value <- rawParam req "limit"+      case value of+        Nothing -> Right browser.limits.defaultLimit+        Just raw -> case decimal raw of+          Just n | 1 <= n && n <= toInteger browser.limits.maxLimit -> Right (fromInteger n)+          _ -> Left (invalid "limit" raw "Expected a decimal integer within the configured page limit.")     textParam key = do-        value <- rawParam req key-        mapM_ (validText key) value-        pure value+      value <- rawParam req key+      mapM_ (validText key) value+      pure value     position = do-        value <- rawParam req "from"-        case value of-            Nothing -> Right 0-            Just raw -> case decimal raw of-                Just n | n <= toInteger (maxBound :: Int64) -> Right (fromInteger n)-                _ -> Left (invalid "from" raw "Expected a non-negative Int64 decimal integer.")+      value <- rawParam req "from"+      case value of+        Nothing -> Right 0+        Just raw -> case decimal raw of+          Just n | n <= toInteger (maxBound :: Int64) -> Right (fromInteger n)+          _ -> Left (invalid "from" raw "Expected a non-negative Int64 decimal integer.")     readDirection =-        rawParam req "direction" >>= \case-            Nothing -> Right ReadForward-            Just "forward" -> Right ReadForward-            Just "backward" -> Right ReadBackward-            Just raw -> Left (invalid "direction" raw "Expected forward or backward.")+      rawParam req "direction" >>= \case+        Nothing -> Right ReadForward+        Just "forward" -> Right ReadForward+        Just "backward" -> Right ReadBackward+        Just raw -> Left (invalid "direction" raw "Expected forward or backward.")  -- Closed bounds make over-fetch safe before conversion to the store's Int32. overfetch :: Int -> Int32 overfetch limit = fromIntegral (limit + 1)+ browseSize :: Int -> Either Failure BrowsePageSize browseSize limit = case mkBrowsePageSize (limit + 1) of-    Right size -> Right size-    Left _ -> Left (invalid "limit" (T.pack (show limit)) "Invalid page limit.")+  Right size -> Right size+  Left _ -> Left (invalid "limit" (T.pack (show limit)) "Invalid page limit.")  data Cursor = AesonText Text | AesonNumber Int64+ cursorJSON :: Cursor -> Value cursorJSON (AesonText text) = toJSON text cursorJSON (AesonNumber n) = toJSON n  pageJSON :: Int -> (a -> Value) -> (a -> Cursor) -> Vector a -> Value pageJSON limit encodeItem cursor rows =-    object $ ["items" .= V.map encodeItem items] <> ["next_cursor" .= cursorJSON (cursor (V.last items)) | V.length rows > limit && not (V.null items)]+  object $ ["items" .= V.map encodeItem items] <> ["next_cursor" .= cursorJSON (cursor (V.last items)) | V.length rows > limit && not (V.null items)]   where     items = V.take limit rows  eventPage :: Int -> (RecordedEvent -> Cursor) -> ReadProgram (Vector RecordedEvent) -> ReadProgram Value eventPage limit cursor readPage = do-    rows <- readPage-    mapping <- resolveNames (V.take limit rows)-    pure (pageJSON limit (recordedEventToJSONResolved mapping) cursor rows)+  rows <- readPage+  mapping <- resolveNames (V.take limit rows)+  pure (pageJSON limit (recordedEventToJSONResolved mapping) cursor rows)+ resolveNames :: Vector RecordedEvent -> ReadProgram (Map StreamId StreamName) resolveNames rows-    | V.null rows = pure Map.empty-    | otherwise = lookupStreamNames (Set.toList (Set.fromList (map (\event -> event.originalStreamId) (V.toList rows))))+  | V.null rows = pure Map.empty+  | otherwise = lookupStreamNames (Set.toList (Set.fromList (map (\event -> event.originalStreamId) (V.toList rows))))+ globalCursor :: RecordedEvent -> Cursor globalCursor event = let GlobalPosition n = event.globalPosition in AesonNumber n  streamInfoToJSON :: StreamInfo -> Value streamInfoToJSON stream =-    object-        [ "stream_id" .= (let StreamId n = stream.id in n)-        , "name" .= (let StreamName name = stream.name in name)-        , "category" .= (let CategoryName category = categoryName stream.name in category)-        , "version" .= (let StreamVersion n = stream.version in n)-        , "created_at" .= stream.createdAt-        , "deleted_at" .= stream.deletedAt-        , "truncate_before" .= (let StreamVersion n = stream.truncateBefore in n)-        ]+  object+    [ "stream_id" .= (let StreamId n = stream.id in n),+      "name" .= (let StreamName name = stream.name in name),+      "category" .= (let CategoryName category = categoryName stream.name in category),+      "version" .= (let StreamVersion n = stream.version in n),+      "created_at" .= stream.createdAt,+      "deleted_at" .= stream.deletedAt,+      "truncate_before" .= (let StreamVersion n = stream.truncateBefore in n)+    ]  missing :: Text -> Text -> Failure missing code message = (status404, code, message, Nothing)+ invalid :: Text -> Text -> Text -> Failure invalid key raw reason = (status400, "invalid_query_parameter", "Invalid query parameter.", Just (object ["parameter" .= key, "value" .= raw, "reason" .= reason]))+ rawParam :: Request -> BS.ByteString -> Either Failure (Maybe Text) rawParam req key = case lookup key (queryString req) of-    Nothing -> Right Nothing-    Just Nothing -> Right (Just "")-    Just (Just bytes) -> case TE.decodeUtf8' bytes of-        Right value -> Right (Just value)-        Left _ -> Left (invalid (TE.decodeUtf8 key) "<invalid UTF-8>" "Expected valid UTF-8.")+  Nothing -> Right Nothing+  Just Nothing -> Right (Just "")+  Just (Just bytes) -> case TE.decodeUtf8' bytes of+    Right value -> Right (Just value)+    Left _ -> Left (invalid (TE.decodeUtf8 key) "<invalid UTF-8>" "Expected valid UTF-8.")+ validText :: BS.ByteString -> Text -> Either Failure () validText key value-    | T.any (== '\0') value || BS.length (TE.encodeUtf8 value) > 512 = Left (invalid (TE.decodeUtf8 key) value "Expected at most 512 UTF-8 bytes without NUL.")-    | otherwise = Right ()+  | T.any (== '\0') value || BS.length (TE.encodeUtf8 value) > 512 = Left (invalid (TE.decodeUtf8 key) value "Expected at most 512 UTF-8 bytes without NUL.")+  | otherwise = Right ()+ validStream :: Text -> Either Failure StreamName validStream value = case validateStreamName (StreamName value) of-    Right () | not (T.any (== '\0') value) -> Right (StreamName value)-    _ -> Left (status400, "invalid_stream_name", "Invalid stream name.", Just (object ["stream_name" .= value]))+  Right () | not (T.any (== '\0') value) -> Right (StreamName value)+  _ -> Left (status400, "invalid_stream_name", "Invalid stream name.", Just (object ["stream_name" .= value]))+ decimal :: Text -> Maybe Integer decimal value-    | T.null value || not (T.all (\c -> '0' <= c && c <= '9') value) = Nothing-    | T.length value > 20 = Nothing-    | otherwise = readMaybe (T.unpack value)+  | T.null value || not (T.all (\c -> '0' <= c && c <= '9') value) = Nothing+  | T.length value > 20 = Nothing+  | otherwise = readMaybe (T.unpack value)
src/Kiroku/Metrics/Capabilities.hs view
@@ -1,8 +1,8 @@ {-# LANGUAGE NoFieldSelectors #-}  -- | Mount-relative discovery, computed from configuration and actual provider wiring.-module Kiroku.Metrics.Capabilities (-    WebSocketChannels (..),+module Kiroku.Metrics.Capabilities+  ( WebSocketChannels (..),     noWebSocketChannels,     storeWebSocketChannels,     ProviderPresence (..),@@ -13,7 +13,8 @@     processLocalRoutes,     capabilitiesApp,     capabilitiesPath,-) where+  )+where  import Data.Aeson (FromJSON (..), ToJSON (..), encode, object, withObject, (.:), (.=)) import Data.Text (Text)@@ -29,43 +30,43 @@  -- | Declaration by the host choosing the opaque WebSocket application. data WebSocketChannels = WebSocketChannels-    {metricsChannel :: !Bool, eventsChannel :: !Bool}-    deriving stock (Eq, Show)+  {metricsChannel :: !Bool, eventsChannel :: !Bool}+  deriving stock (Eq, Show)  noWebSocketChannels, storeWebSocketChannels :: WebSocketChannels noWebSocketChannels = WebSocketChannels False False storeWebSocketChannels = WebSocketChannels True True  data ProviderPresence = ProviderPresence-    { hasSubscriptionStatus :: !Bool-    , hasCheckpointInventory :: !Bool-    , hasBrowser :: !Bool-    , hasDeadLetters :: !Bool-    , presentWebSocketChannels :: !WebSocketChannels-    }-    deriving stock (Eq, Show)+  { hasSubscriptionStatus :: !Bool,+    hasCheckpointInventory :: !Bool,+    hasBrowser :: !Bool,+    hasDeadLetters :: !Bool,+    presentWebSocketChannels :: !WebSocketChannels+  }+  deriving stock (Eq, Show)  data RouteAvailability = RouteAvailability-    { metrics :: !Bool-    , prometheus :: !Bool-    , health :: !Bool-    , subscriptionsLive :: !Bool-    , subscriptionsCheckpoints :: !Bool-    , deadLetters :: !Bool-    , browse :: !Bool-    , websocketMetrics :: !Bool-    , websocketEvents :: !Bool-    }-    deriving stock (Eq, Show)+  { metrics :: !Bool,+    prometheus :: !Bool,+    health :: !Bool,+    subscriptionsLive :: !Bool,+    subscriptionsCheckpoints :: !Bool,+    deadLetters :: !Bool,+    browse :: !Bool,+    websocketMetrics :: !Bool,+    websocketEvents :: !Bool+  }+  deriving stock (Eq, Show)  data Capabilities = Capabilities-    { package :: !Text-    , version :: !Text-    , routes :: !RouteAvailability-    , corsIsEnabled :: !Bool-    , processLocal :: ![Text]-    }-    deriving stock (Eq, Show)+  { package :: !Text,+    version :: !Text,+    routes :: !RouteAvailability,+    corsIsEnabled :: !Bool,+    processLocal :: ![Text]+  }+  deriving stock (Eq, Show)  kirokuMetricsVersion :: Text kirokuMetricsVersion = T.pack (showVersion Paths.version)@@ -78,59 +79,59 @@ capabilitiesFor cfg presence = Capabilities "kiroku-metrics" kirokuMetricsVersion availability (Cors.corsEnabled cfg.cors) processLocalRoutes   where     availability =-        RouteAvailability-            cfg.enableJSON-            cfg.enablePrometheus-            cfg.enableJSON-            presence.hasSubscriptionStatus-            presence.hasCheckpointInventory-            presence.hasDeadLetters-            presence.hasBrowser-            (cfg.enableWebSocket && presence.presentWebSocketChannels.metricsChannel)-            (cfg.enableWebSocket && presence.presentWebSocketChannels.eventsChannel)+      RouteAvailability+        cfg.enableJSON+        cfg.enablePrometheus+        cfg.enableJSON+        presence.hasSubscriptionStatus+        presence.hasCheckpointInventory+        presence.hasDeadLetters+        presence.hasBrowser+        (cfg.enableWebSocket && presence.presentWebSocketChannels.metricsChannel)+        (cfg.enableWebSocket && presence.presentWebSocketChannels.eventsChannel)  instance ToJSON RouteAvailability where-    toJSON r =-        object-            [ "metrics" .= r.metrics-            , "prometheus" .= r.prometheus-            , "health" .= r.health-            , "subscriptions_live" .= r.subscriptionsLive-            , "subscriptions_checkpoints" .= r.subscriptionsCheckpoints-            , "dead_letters" .= r.deadLetters-            , "browse" .= r.browse-            , "websocket_metrics" .= r.websocketMetrics-            , "websocket_events" .= r.websocketEvents-            ]+  toJSON r =+    object+      [ "metrics" .= r.metrics,+        "prometheus" .= r.prometheus,+        "health" .= r.health,+        "subscriptions_live" .= r.subscriptionsLive,+        "subscriptions_checkpoints" .= r.subscriptionsCheckpoints,+        "dead_letters" .= r.deadLetters,+        "browse" .= r.browse,+        "websocket_metrics" .= r.websocketMetrics,+        "websocket_events" .= r.websocketEvents+      ]  instance FromJSON RouteAvailability where-    parseJSON = withObject "RouteAvailability" $ \o ->-        RouteAvailability-            <$> o .: "metrics"-            <*> o .: "prometheus"-            <*> o .: "health"-            <*> o .: "subscriptions_live"-            <*> o .: "subscriptions_checkpoints"-            <*> o .: "dead_letters"-            <*> o .: "browse"-            <*> o .: "websocket_metrics"-            <*> o .: "websocket_events"+  parseJSON = withObject "RouteAvailability" $ \o ->+    RouteAvailability+      <$> o .: "metrics"+      <*> o .: "prometheus"+      <*> o .: "health"+      <*> o .: "subscriptions_live"+      <*> o .: "subscriptions_checkpoints"+      <*> o .: "dead_letters"+      <*> o .: "browse"+      <*> o .: "websocket_metrics"+      <*> o .: "websocket_events"  instance ToJSON Capabilities where-    toJSON c =-        object-            [ "package" .= c.package-            , "version" .= c.version-            , "routes" .= c.routes-            , "cors" .= object ["enabled" .= c.corsIsEnabled]-            , "process_local" .= c.processLocal-            ]+  toJSON c =+    object+      [ "package" .= c.package,+        "version" .= c.version,+        "routes" .= c.routes,+        "cors" .= object ["enabled" .= c.corsIsEnabled],+        "process_local" .= c.processLocal+      ]  instance FromJSON Capabilities where-    parseJSON = withObject "Capabilities" $ \o -> do-        corsObject <- o .: "cors"-        enabled <- withObject "cors" (.: "enabled") corsObject-        Capabilities <$> o .: "package" <*> o .: "version" <*> o .: "routes" <*> pure enabled <*> o .: "process_local"+  parseJSON = withObject "Capabilities" $ \o -> do+    corsObject <- o .: "cors"+    enabled <- withObject "cors" (.: "enabled") corsObject+    Capabilities <$> o .: "package" <*> o .: "version" <*> o .: "routes" <*> pure enabled <*> o .: "process_local"  capabilitiesPath :: [Text] capabilitiesPath = ["capabilities"]@@ -138,15 +139,15 @@ -- | No store access; the encoded body is shared by requests to this application. capabilitiesApp :: Capabilities -> Application capabilitiesApp caps = \req respond -> do-    let response-            | pathInfo req /= capabilitiesPath = errorResponse status404 "not_found" "Not found" Nothing-            | requestMethod req == "GET" || requestMethod req == "HEAD" = jsonResponse status200 body-            | otherwise =-                mapResponseHeaders (("Allow", "GET, HEAD") :) $-                    errorResponse status405 "method_not_allowed" "Use GET or HEAD." Nothing-    respond $-        if requestMethod req == "HEAD"-            then responseLBS (responseStatus response) (responseHeaders response) ""-            else response+  let response+        | pathInfo req /= capabilitiesPath = errorResponse status404 "not_found" "Not found" Nothing+        | requestMethod req == "GET" || requestMethod req == "HEAD" = jsonResponse status200 body+        | otherwise =+            mapResponseHeaders (("Allow", "GET, HEAD") :) $+              errorResponse status405 "method_not_allowed" "Use GET or HEAD." Nothing+  respond $+    if requestMethod req == "HEAD"+      then responseLBS (responseStatus response) (responseHeaders response) ""+      else response   where     body = encode caps
src/Kiroku/Metrics/Checkpoints.hs view
@@ -1,6 +1,6 @@ -- | Read-only durable checkpoint inventory, independent of the live registry.-module Kiroku.Metrics.Checkpoints (-    CheckpointInventoryProvider,+module Kiroku.Metrics.Checkpoints+  ( CheckpointInventoryProvider,     storeCheckpointInventory,     CheckpointInventoryResponse (..),     CheckpointRow (..),@@ -8,7 +8,8 @@     checkpointsPath,     checkpointsApp,     checkpointsNotConfiguredApp,-) where+  )+where  import Data.Aeson (FromJSON (..), ToJSON (..), encode, object, withObject, (.:), (.=)) import Data.Int (Int32, Int64)@@ -21,52 +22,50 @@ import Kiroku.Metrics.JSON (errorResponse, jsonResponse, storeErrorResponse) import Kiroku.Store (GlobalPosition (..), KirokuStore, StoreError, SubscriptionCheckpoint (..), SubscriptionCheckpointInventory (..), SubscriptionName (..), runStoreIO, subscriptionCheckpointInventory) -{- | One call reads the frontier and all checkpoint rows in one SQL snapshot.-Inventory work is proportional to row count; clients should not overlap polls.--}+-- | One call reads the frontier and all checkpoint rows in one SQL snapshot.+-- Inventory work is proportional to row count; clients should not overlap polls. type CheckpointInventoryProvider = IO (Either StoreError SubscriptionCheckpointInventory)  storeCheckpointInventory :: KirokuStore -> CheckpointInventoryProvider storeCheckpointInventory store = runStoreIO store subscriptionCheckpointInventory  data CheckpointRow = CheckpointRow-    { subscription :: !Text-    , member :: !Int32-    , checkpointPosition :: !Int64-    , updatedAt :: !UTCTime-    }-    deriving stock (Eq, Show)+  { subscription :: !Text,+    member :: !Int32,+    checkpointPosition :: !Int64,+    updatedAt :: !UTCTime+  }+  deriving stock (Eq, Show)  data CheckpointInventoryResponse = CheckpointInventoryResponse-    { storePosition :: !Int64-    , checkpoints :: ![CheckpointRow]-    }-    deriving stock (Eq, Show)+  { storePosition :: !Int64,+    checkpoints :: ![CheckpointRow]+  }+  deriving stock (Eq, Show)  instance ToJSON CheckpointRow where-    toJSON row = object ["subscription" .= row.subscription, "member" .= row.member, "checkpoint_position" .= row.checkpointPosition, "updated_at" .= row.updatedAt]+  toJSON row = object ["subscription" .= row.subscription, "member" .= row.member, "checkpoint_position" .= row.checkpointPosition, "updated_at" .= row.updatedAt]  instance FromJSON CheckpointRow where-    parseJSON = withObject "CheckpointRow" $ \o -> CheckpointRow <$> o .: "subscription" <*> o .: "member" <*> o .: "checkpoint_position" <*> o .: "updated_at"+  parseJSON = withObject "CheckpointRow" $ \o -> CheckpointRow <$> o .: "subscription" <*> o .: "member" <*> o .: "checkpoint_position" <*> o .: "updated_at"  instance ToJSON CheckpointInventoryResponse where-    toJSON inventory = object ["store_position" .= inventory.storePosition, "checkpoints" .= inventory.checkpoints]+  toJSON inventory = object ["store_position" .= inventory.storePosition, "checkpoints" .= inventory.checkpoints]  instance FromJSON CheckpointInventoryResponse where-    parseJSON = withObject "CheckpointInventoryResponse" $ \o -> CheckpointInventoryResponse <$> o .: "store_position" <*> o .: "checkpoints"+  parseJSON = withObject "CheckpointInventoryResponse" $ \o -> CheckpointInventoryResponse <$> o .: "store_position" <*> o .: "checkpoints"  checkpointInventoryResponse :: SubscriptionCheckpointInventory -> CheckpointInventoryResponse checkpointInventoryResponse (SubscriptionCheckpointInventory (GlobalPosition position) rows) =-    CheckpointInventoryResponse position (map toRow (V.toList rows))+  CheckpointInventoryResponse position (map toRow (V.toList rows))   where     toRow (SubscriptionCheckpoint (SubscriptionName name) index (GlobalPosition cp) updated) = CheckpointRow name index cp updated  checkpointsPath :: [Text] checkpointsPath = ["subscription-checkpoints"] -{- | GET and HEAD only. Unknown query parameters are ignored. Expected store-failures are sanitized; thrown exceptions (including cancellation) propagate.--}+-- | GET and HEAD only. Unknown query parameters are ignored. Expected store+-- failures are sanitized; thrown exceptions (including cancellation) propagate. checkpointsApp :: CheckpointInventoryProvider -> Application checkpointsApp provider = checkpointsResponseApp $ either (storeErrorResponse "checkpoint_inventory_unavailable") (jsonResponse status200 . encode . checkpointInventoryResponse) <$> provider @@ -76,14 +75,14 @@  checkpointsResponseApp :: IO Response -> Application checkpointsResponseApp readResponse req respond = do-    response <--        if pathInfo req /= checkpointsPath-            then pure $ errorResponse status404 "not_found" "Not found" Nothing-            else-                if requestMethod req `notElem` ["GET", "HEAD"]-                    then pure $ mapResponseHeaders (("Allow", "GET, HEAD") :) $ errorResponse status405 "method_not_allowed" "Use GET or HEAD." Nothing-                    else readResponse-    respond $-        if requestMethod req == "HEAD"-            then responseLBS (responseStatus response) (responseHeaders response) ""-            else response+  response <-+    if pathInfo req /= checkpointsPath+      then pure $ errorResponse status404 "not_found" "Not found" Nothing+      else+        if requestMethod req `notElem` ["GET", "HEAD"]+          then pure $ mapResponseHeaders (("Allow", "GET, HEAD") :) $ errorResponse status405 "method_not_allowed" "Use GET or HEAD." Nothing+          else readResponse+  respond $+    if requestMethod req == "HEAD"+      then responseLBS (responseStatus response) (responseHeaders response) ""+      else response
src/Kiroku/Metrics/Collector.hs view
@@ -1,27 +1,27 @@-{- | In-process metrics collector for a running Kiroku store.--A 'KirokuMetrics' is an opaque handle holding STM counters, a pool-connection-status map, and a per-subscription accumulation map. It is driven-entirely by the store's existing public callback seams — wrap-'metricsEventHandler' and 'metricsObservationHandler' into the-'Kiroku.Store.Connection.ConnectionSettings' @eventHandler@/@observationHandler@-fields /before/ opening the store, and the collector will see every event. Call-'snapshotMetrics' at any time for an immutable, JSON-encodable-'MetricsSnapshot'.--All callback updates are plain non-blocking STM ('modifyTVar''); they perform no-I/O. This matters because the store invokes the callbacks synchronously on the-emit-site thread (notifier loop, publisher loop, subscription worker, store-interpreter), so a slow callback would stall that loop.--}-module Kiroku.Metrics.Collector (-    KirokuMetrics,+-- | In-process metrics collector for a running Kiroku store.+--+-- A 'KirokuMetrics' is an opaque handle holding STM counters, a pool+-- connection-status map, and a per-subscription accumulation map. It is driven+-- entirely by the store's existing public callback seams — wrap+-- 'metricsEventHandler' and 'metricsObservationHandler' into the+-- 'Kiroku.Store.Connection.ConnectionSettings' @eventHandler@/@observationHandler@+-- fields /before/ opening the store, and the collector will see every event. Call+-- 'snapshotMetrics' at any time for an immutable, JSON-encodable+-- 'MetricsSnapshot'.+--+-- All callback updates are plain non-blocking STM ('modifyTVar''); they perform no+-- I/O. This matters because the store invokes the callbacks synchronously on the+-- emit-site thread (notifier loop, publisher loop, subscription worker, store+-- interpreter), so a slow callback would stall that loop.+module Kiroku.Metrics.Collector+  ( KirokuMetrics,     newKirokuMetrics,     newKirokuMetricsWith,     metricsEventHandler,     metricsObservationHandler,     snapshotMetrics,-) where+  )+where  import Control.Concurrent.STM (STM, TVar, atomically, modifyTVar', newTVarIO, readTVar) import Data.Foldable (for_)@@ -31,253 +31,252 @@ import Data.Map.Strict qualified as Map import Data.Text (Text) import Data.UUID (UUID)-import Kiroku.Metrics.Types (-    LifecycleCounters (..),+import Kiroku.Metrics.Types+  ( LifecycleCounters (..),     MetricsSnapshot (..),     StoreGauges (..),     SubscriptionMetrics (..),- )-import Kiroku.Store (-    ConnectionReadyForUseReason (..),+  )+import Kiroku.Store+  ( ConnectionReadyForUseReason (..),     ConnectionStatus (..),     KirokuEvent (..),     KirokuStore (..),     Observation (..),     SubscriptionDbPhase (..),     SubscriptionStopReason (..),- )-import Kiroku.Store.Subscription.EventPublisher (-    EventPublisher (..),+  )+import Kiroku.Store.Subscription.EventPublisher+  ( EventPublisher (..),     publisherPosition,- )-import Kiroku.Store.Subscription.Types (-    CheckpointInitialization (..),+  )+import Kiroku.Store.Subscription.Types+  ( CheckpointInitialization (..),     ConsumerGroupSizeMismatch (..),     SubscriptionCheckpointKey (..),     SubscriptionCheckpointMissing (..),     SubscriptionName (..),- )+    SubscriptionTargetMismatch (..),+  ) import Kiroku.Store.Types (GlobalPosition (..))  -- | Opaque collector handle. Construct with 'newKirokuMetrics'. data KirokuMetrics = KirokuMetrics-    { kmCounters :: !(TVar LifecycleCounters)-    , kmPool :: !(TVar PoolMutable)-    , kmSubs :: !(TVar (Map Text SubMutable))-    , kmPosition :: !(STM GlobalPosition)-    -- ^ Reads the store's global position at snapshot time.-    , kmSubscribers :: !(STM Int)-    -- ^ Reads the store's active-subscriber count at snapshot time.-    }+  { kmCounters :: !(TVar LifecycleCounters),+    kmPool :: !(TVar PoolMutable),+    kmSubs :: !(TVar (Map Text SubMutable)),+    -- | Reads the store's global position at snapshot time.+    kmPosition :: !(STM GlobalPosition),+    -- | Reads the store's active-subscriber count at snapshot time.+    kmSubscribers :: !(STM Int)+  }  -- | Mutable pool state: per-connection status plus cumulative lifecycle counts. data PoolMutable = PoolMutable-    { pmStatuses :: !(Map UUID ConnectionStatus)-    , pmEstablishedTotal :: !Int64-    , pmTerminatedTotal :: !Int64-    }+  { pmStatuses :: !(Map UUID ConnectionStatus),+    pmEstablishedTotal :: !Int64,+    pmTerminatedTotal :: !Int64+  }  -- | Mutable per-subscription accumulation. data SubMutable = SubMutable-    { smLastKnownPosition :: !Int64-    , smDbErrorCount :: !Int64-    , smLastStopReason :: !(Maybe Text)-    }+  { smLastKnownPosition :: !Int64,+    smDbErrorCount :: !Int64,+    smLastStopReason :: !(Maybe Text)+  }  emptyCounters :: LifecycleCounters emptyCounters =-    LifecycleCounters-        { notifierReconnecting = 0-        , notifierReconnected = 0-        , publisherPoolErrors = 0-        , publisherLoopErrors = 0-        , publisherDecodeFailures = 0-        , subscriptionHandlerStalls = 0-        , subscriptionDbErrorsLoad = 0-        , subscriptionDbErrorsFetch = 0-        , subscriptionDbErrorsSave = 0-        , subscriptionsStarted = 0-        , subscriptionsCaughtUp = 0-        , subscriptionsPaused = 0-        , subscriptionsResumed = 0-        , subscriptionsReconnecting = 0-        , subscriptionsRetrying = 0-        , subscriptionsDeadLettered = 0-        , subscriptionsStoppedHandler = 0-        , subscriptionsStoppedCancelled = 0-        , subscriptionsStoppedOverflow = 0-        , subscriptionsStoppedCrashed = 0-        , subscriptionsStoppedUndecodable = 0-        , liveFetches = 0-        , batchesDelivered = 0-        , eventsDelivered = 0-        , hardDeletesIssued = 0-        }+  LifecycleCounters+    { notifierReconnecting = 0,+      notifierReconnected = 0,+      publisherPoolErrors = 0,+      publisherLoopErrors = 0,+      publisherDecodeFailures = 0,+      subscriptionHandlerStalls = 0,+      subscriptionDbErrorsLoad = 0,+      subscriptionDbErrorsFetch = 0,+      subscriptionDbErrorsSave = 0,+      subscriptionsStarted = 0,+      subscriptionsCaughtUp = 0,+      subscriptionsPaused = 0,+      subscriptionsResumed = 0,+      subscriptionsReconnecting = 0,+      subscriptionsRetrying = 0,+      subscriptionsDeadLettered = 0,+      subscriptionsStoppedHandler = 0,+      subscriptionsStoppedCancelled = 0,+      subscriptionsStoppedOverflow = 0,+      subscriptionsStoppedCrashed = 0,+      subscriptionsStoppedUndecodable = 0,+      liveFetches = 0,+      batchesDelivered = 0,+      eventsDelivered = 0,+      hardDeletesIssued = 0+    }  emptySub :: SubMutable-emptySub = SubMutable{smLastKnownPosition = 0, smDbErrorCount = 0, smLastStopReason = Nothing}+emptySub = SubMutable {smLastKnownPosition = 0, smDbErrorCount = 0, smLastStopReason = Nothing} -{- | Construct a collector for a live store. The two store-level gauges-(global position and active-subscriber count) are read from the store's-'EventPublisher' at snapshot time.--}+-- | Construct a collector for a live store. The two store-level gauges+-- (global position and active-subscriber count) are read from the store's+-- 'EventPublisher' at snapshot time. newKirokuMetrics :: KirokuStore -> IO KirokuMetrics newKirokuMetrics store =-    newKirokuMetricsWith-        (publisherPosition store.publisher)-        (IntMap.size <$> readTVar (subscribers store.publisher))+  newKirokuMetricsWith+    (publisherPosition store.publisher)+    (IntMap.size <$> readTVar (subscribers store.publisher)) -{- | Construct a collector from explicit STM readers for the two store-level-gauges. This is the test seam: a unit test passes @pure (GlobalPosition n)@ and-@pure k@ to avoid needing a real store, and an integration test can defer the-store with a @TVar (Maybe KirokuStore)@ (STM readers cannot read an 'IORef').--}+-- | Construct a collector from explicit STM readers for the two store-level+-- gauges. This is the test seam: a unit test passes @pure (GlobalPosition n)@ and+-- @pure k@ to avoid needing a real store, and an integration test can defer the+-- store with a @TVar (Maybe KirokuStore)@ (STM readers cannot read an 'IORef'). newKirokuMetricsWith :: STM GlobalPosition -> STM Int -> IO KirokuMetrics newKirokuMetricsWith readPosition readSubscribers = do-    kmCounters <- newTVarIO emptyCounters-    kmPool <- newTVarIO (PoolMutable Map.empty 0 0)-    kmSubs <- newTVarIO Map.empty-    pure-        KirokuMetrics-            { kmCounters-            , kmPool-            , kmSubs-            , kmPosition = readPosition-            , kmSubscribers = readSubscribers-            }+  kmCounters <- newTVarIO emptyCounters+  kmPool <- newTVarIO (PoolMutable Map.empty 0 0)+  kmSubs <- newTVarIO Map.empty+  pure+    KirokuMetrics+      { kmCounters,+        kmPool,+        kmSubs,+        kmPosition = readPosition,+        kmSubscribers = readSubscribers+      } -{- | Wrap the collector into the store's @eventHandler@ seam, composing with an-optional user passthrough. The collector's STM update runs first, then the-passthrough is invoked with the same event.--}+-- | Wrap the collector into the store's @eventHandler@ seam, composing with an+-- optional user passthrough. The collector's STM update runs first, then the+-- passthrough is invoked with the same event. metricsEventHandler :: KirokuMetrics -> Maybe (KirokuEvent -> IO ()) -> (KirokuEvent -> IO ()) metricsEventHandler km mPassthrough event = do-    atomically (applyEvent km event)-    for_ mPassthrough ($ event)+  atomically (applyEvent km event)+  for_ mPassthrough ($ event) -{- | Wrap the collector into the store's @observationHandler@ seam, composing-with an optional user passthrough. The collector's STM update runs first.--}+-- | Wrap the collector into the store's @observationHandler@ seam, composing+-- with an optional user passthrough. The collector's STM update runs first. metricsObservationHandler :: KirokuMetrics -> Maybe (Observation -> IO ()) -> (Observation -> IO ()) metricsObservationHandler km mPassthrough obs = do-    atomically (applyObservation km obs)-    for_ mPassthrough ($ obs)+  atomically (applyObservation km obs)+  for_ mPassthrough ($ obs)  -- | Apply a single 'KirokuEvent' to the collector state. applyEvent :: KirokuMetrics -> KirokuEvent -> STM () applyEvent km = \case-    KirokuEventNotifierReconnecting _ _ ->-        bumpCounters km (\c -> c{notifierReconnecting = c.notifierReconnecting + 1})-    KirokuEventNotifierReconnected ->-        bumpCounters km (\c -> c{notifierReconnected = c.notifierReconnected + 1})-    KirokuEventPublisherPoolError _ ->-        bumpCounters km (\c -> c{publisherPoolErrors = c.publisherPoolErrors + 1})-    KirokuEventSubscriptionHandlerStalled{} ->-        bumpCounters km (\c -> c{subscriptionHandlerStalls = c.subscriptionHandlerStalls + 1})-    KirokuEventPublisherDecodeFailed{} ->-        bumpCounters km (\c -> c{publisherDecodeFailures = c.publisherDecodeFailures + 1})-    KirokuEventPublisherLoopError _ ->-        bumpCounters km (\c -> c{publisherLoopErrors = c.publisherLoopErrors + 1})-    KirokuEventSubscriptionDbError name phase _ _ -> do-        bumpCounters km (bumpDbPhase phase)-        touchSub km name (\s -> s{smDbErrorCount = s.smDbErrorCount + 1})-    KirokuEventSubscriptionCheckpointResolved initialization _ ->-        case initialization of-            ExistingCheckpoint (SubscriptionCheckpointKey name _) pos -> recordPosition km name pos-            InitializedCheckpoint _ (SubscriptionCheckpointKey name _) pos -> recordPosition km name pos-    KirokuEventSubscriptionCheckpointMissing (SubscriptionCheckpointMissing (SubscriptionCheckpointKey name _)) _ ->-        touchSub km name id-    KirokuEventSubscriptionTargetBound name _ _ -> touchSub km name id-    KirokuEventSubscriptionGroupSizeMismatch (ConsumerGroupSizeMismatch name _ _) _ ->-        touchSub km name id-    KirokuEventSubscriptionStarted name pos _ -> do-        bumpCounters km (\c -> c{subscriptionsStarted = c.subscriptionsStarted + 1})-        recordPosition km name pos-    KirokuEventSubscriptionCaughtUp name pos _ -> do-        bumpCounters km (\c -> c{subscriptionsCaughtUp = c.subscriptionsCaughtUp + 1})-        recordPosition km name pos-    KirokuEventSubscriptionStopped name pos reason _ -> do-        bumpCounters km (bumpStopReason reason)-        recordPosition km name pos-        touchSub km name (\s -> s{smLastStopReason = Just (stopReasonText reason)})-    KirokuEventSubscriptionPaused name pos _ -> do-        bumpCounters km (\c -> c{subscriptionsPaused = c.subscriptionsPaused + 1})-        recordPosition km name pos-    KirokuEventSubscriptionResumed name pos _ -> do-        bumpCounters km (\c -> c{subscriptionsResumed = c.subscriptionsResumed + 1})-        recordPosition km name pos-    KirokuEventSubscriptionReconnecting name _ _ -> do-        bumpCounters km (\c -> c{subscriptionsReconnecting = c.subscriptionsReconnecting + 1})-        touchSub km name id-    KirokuEventSubscriptionFetched name _ _ -> do-        bumpCounters km (\c -> c{liveFetches = c.liveFetches + 1})-        touchSub km name id-    KirokuEventSubscriptionDelivered name n _ _ -> do-        bumpCounters-            km-            ( \c ->-                c-                    { batchesDelivered = c.batchesDelivered + 1-                    , eventsDelivered = c.eventsDelivered + fromIntegral n-                    }-            )-        touchSub km name id-    KirokuEventSubscriptionRetrying name pos _ _ -> do-        bumpCounters km (\c -> c{subscriptionsRetrying = c.subscriptionsRetrying + 1})-        recordPosition km name pos-    KirokuEventSubscriptionDeadLettered name pos _ _ -> do-        bumpCounters km (\c -> c{subscriptionsDeadLettered = c.subscriptionsDeadLettered + 1})-        recordPosition km name pos-    KirokuEventHardDeleteIssued _ _ ->-        bumpCounters km (\c -> c{hardDeletesIssued = c.hardDeletesIssued + 1})-    -- Retention transitions are deliberately passed through without being-    -- converted into the subscription-focused fixed metrics schema. Callers-    -- can consume them from the composed eventHandler.-    KirokuEventHistoryRetentionLeaseAcquired{} -> pure ()-    KirokuEventHistoryRetentionLeaseRenewed{} -> pure ()-    KirokuEventHistoryRetentionLeaseReleased{} -> pure ()-    KirokuEventHistoryRetentionLeasesPruned{} -> pure ()-    KirokuEventHardDeleteHistoryRetentionConflict{} -> pure ()+  KirokuEventNotifierReconnecting _ _ ->+    bumpCounters km (\c -> c {notifierReconnecting = c.notifierReconnecting + 1})+  KirokuEventNotifierReconnected ->+    bumpCounters km (\c -> c {notifierReconnected = c.notifierReconnected + 1})+  KirokuEventPublisherPoolError _ ->+    bumpCounters km (\c -> c {publisherPoolErrors = c.publisherPoolErrors + 1})+  KirokuEventSubscriptionHandlerStalled {} ->+    bumpCounters km (\c -> c {subscriptionHandlerStalls = c.subscriptionHandlerStalls + 1})+  KirokuEventPublisherDecodeFailed {} ->+    bumpCounters km (\c -> c {publisherDecodeFailures = c.publisherDecodeFailures + 1})+  KirokuEventPublisherLoopError _ ->+    bumpCounters km (\c -> c {publisherLoopErrors = c.publisherLoopErrors + 1})+  KirokuEventSubscriptionDbError name phase _ _ -> do+    bumpCounters km (bumpDbPhase phase)+    touchSub km name (\s -> s {smDbErrorCount = s.smDbErrorCount + 1})+  KirokuEventSubscriptionCheckpointResolved initialization _ ->+    case initialization of+      ExistingCheckpoint (SubscriptionCheckpointKey name _) pos -> recordPosition km name pos+      InitializedCheckpoint _ (SubscriptionCheckpointKey name _) pos -> recordPosition km name pos+  KirokuEventSubscriptionCheckpointMissing (SubscriptionCheckpointMissing (SubscriptionCheckpointKey name _)) _ ->+    touchSub km name id+  KirokuEventSubscriptionTargetBound name _ _ -> touchSub km name id+  KirokuEventSubscriptionGroupSizeMismatch (ConsumerGroupSizeMismatch name _ _) _ ->+    touchSub km name id+  KirokuEventSubscriptionTargetMismatch (SubscriptionTargetMismatch name _ _) _ ->+    touchSub km name id+  KirokuEventSubscriptionStarted name pos _ -> do+    bumpCounters km (\c -> c {subscriptionsStarted = c.subscriptionsStarted + 1})+    recordPosition km name pos+  KirokuEventSubscriptionCaughtUp name pos _ -> do+    bumpCounters km (\c -> c {subscriptionsCaughtUp = c.subscriptionsCaughtUp + 1})+    recordPosition km name pos+  KirokuEventSubscriptionStopped name pos reason _ -> do+    bumpCounters km (bumpStopReason reason)+    recordPosition km name pos+    touchSub km name (\s -> s {smLastStopReason = Just (stopReasonText reason)})+  KirokuEventSubscriptionPaused name pos _ -> do+    bumpCounters km (\c -> c {subscriptionsPaused = c.subscriptionsPaused + 1})+    recordPosition km name pos+  KirokuEventSubscriptionResumed name pos _ -> do+    bumpCounters km (\c -> c {subscriptionsResumed = c.subscriptionsResumed + 1})+    recordPosition km name pos+  KirokuEventSubscriptionReconnecting name _ _ -> do+    bumpCounters km (\c -> c {subscriptionsReconnecting = c.subscriptionsReconnecting + 1})+    touchSub km name id+  KirokuEventSubscriptionFetched name _ _ -> do+    bumpCounters km (\c -> c {liveFetches = c.liveFetches + 1})+    touchSub km name id+  KirokuEventSubscriptionDelivered name n _ _ -> do+    bumpCounters+      km+      ( \c ->+          c+            { batchesDelivered = c.batchesDelivered + 1,+              eventsDelivered = c.eventsDelivered + fromIntegral n+            }+      )+    touchSub km name id+  KirokuEventSubscriptionRetrying name pos _ _ -> do+    bumpCounters km (\c -> c {subscriptionsRetrying = c.subscriptionsRetrying + 1})+    recordPosition km name pos+  KirokuEventSubscriptionDeadLettered name pos _ _ -> do+    bumpCounters km (\c -> c {subscriptionsDeadLettered = c.subscriptionsDeadLettered + 1})+    recordPosition km name pos+  KirokuEventHardDeleteIssued _ _ ->+    bumpCounters km (\c -> c {hardDeletesIssued = c.hardDeletesIssued + 1})+  -- Retention transitions are deliberately passed through without being+  -- converted into the subscription-focused fixed metrics schema. Callers+  -- can consume them from the composed eventHandler.+  KirokuEventHistoryRetentionLeaseAcquired {} -> pure ()+  KirokuEventHistoryRetentionLeaseRenewed {} -> pure ()+  KirokuEventHistoryRetentionLeaseReleased {} -> pure ()+  KirokuEventHistoryRetentionLeasesPruned {} -> pure ()+  KirokuEventHardDeleteHistoryRetentionConflict {} -> pure ()  bumpDbPhase :: SubscriptionDbPhase -> LifecycleCounters -> LifecycleCounters bumpDbPhase phase c = case phase of-    LoadCheckpoint -> c{subscriptionDbErrorsLoad = c.subscriptionDbErrorsLoad + 1}-    FetchBatch -> c{subscriptionDbErrorsFetch = c.subscriptionDbErrorsFetch + 1}-    SaveCheckpoint -> c{subscriptionDbErrorsSave = c.subscriptionDbErrorsSave + 1}+  LoadCheckpoint -> c {subscriptionDbErrorsLoad = c.subscriptionDbErrorsLoad + 1}+  FetchBatch -> c {subscriptionDbErrorsFetch = c.subscriptionDbErrorsFetch + 1}+  SaveCheckpoint -> c {subscriptionDbErrorsSave = c.subscriptionDbErrorsSave + 1}  bumpStopReason :: SubscriptionStopReason -> LifecycleCounters -> LifecycleCounters bumpStopReason reason c = case reason of-    StopHandlerRequested -> c{subscriptionsStoppedHandler = c.subscriptionsStoppedHandler + 1}-    StopCancelled -> c{subscriptionsStoppedCancelled = c.subscriptionsStoppedCancelled + 1}-    StopOverflowed -> c{subscriptionsStoppedOverflow = c.subscriptionsStoppedOverflow + 1}-    StopWorkerCrashed _ -> c{subscriptionsStoppedCrashed = c.subscriptionsStoppedCrashed + 1}-    StopUndecodable _ -> c{subscriptionsStoppedUndecodable = c.subscriptionsStoppedUndecodable + 1}+  StopHandlerRequested -> c {subscriptionsStoppedHandler = c.subscriptionsStoppedHandler + 1}+  StopCancelled -> c {subscriptionsStoppedCancelled = c.subscriptionsStoppedCancelled + 1}+  StopOverflowed -> c {subscriptionsStoppedOverflow = c.subscriptionsStoppedOverflow + 1}+  StopWorkerCrashed _ -> c {subscriptionsStoppedCrashed = c.subscriptionsStoppedCrashed + 1}+  StopUndecodable _ -> c {subscriptionsStoppedUndecodable = c.subscriptionsStoppedUndecodable + 1}  stopReasonText :: SubscriptionStopReason -> Text stopReasonText = \case-    StopHandlerRequested -> "handler"-    StopCancelled -> "cancelled"-    StopOverflowed -> "overflow"-    StopWorkerCrashed _ -> "crashed"-    StopUndecodable _ -> "undecodable"+  StopHandlerRequested -> "handler"+  StopCancelled -> "cancelled"+  StopOverflowed -> "overflow"+  StopWorkerCrashed _ -> "crashed"+  StopUndecodable _ -> "undecodable"  -- | Apply a single pool 'Observation' to the collector state. applyObservation :: KirokuMetrics -> Observation -> STM () applyObservation km (ConnectionObservation connId status) =-    modifyTVar' (kmPool km) update+  modifyTVar' (kmPool km) update   where     update p = case status of-        TerminatedConnectionStatus _ ->-            p-                { pmStatuses = Map.delete connId p.pmStatuses-                , pmTerminatedTotal = p.pmTerminatedTotal + 1-                }-        ReadyForUseConnectionStatus EstablishedConnectionReadyForUseReason ->-            p-                { pmStatuses = Map.insert connId status p.pmStatuses-                , pmEstablishedTotal = p.pmEstablishedTotal + 1-                }-        _ -> p{pmStatuses = Map.insert connId status p.pmStatuses}+      TerminatedConnectionStatus _ ->+        p+          { pmStatuses = Map.delete connId p.pmStatuses,+            pmTerminatedTotal = p.pmTerminatedTotal + 1+          }+      ReadyForUseConnectionStatus EstablishedConnectionReadyForUseReason ->+        p+          { pmStatuses = Map.insert connId status p.pmStatuses,+            pmEstablishedTotal = p.pmEstablishedTotal + 1+          }+      _ -> p {pmStatuses = Map.insert connId status p.pmStatuses}  bumpCounters :: KirokuMetrics -> (LifecycleCounters -> LifecycleCounters) -> STM () bumpCounters km f = modifyTVar' (kmCounters km) f@@ -285,40 +284,39 @@ -- | Update a subscription's position to the max of its current and the new one. recordPosition :: KirokuMetrics -> SubscriptionName -> GlobalPosition -> STM () recordPosition km name (GlobalPosition pos) =-    touchSub km name (\s -> s{smLastKnownPosition = max s.smLastKnownPosition pos})+  touchSub km name (\s -> s {smLastKnownPosition = max s.smLastKnownPosition pos})  -- | Apply @f@ to the named subscription's mutable entry, creating it if absent. touchSub :: KirokuMetrics -> SubscriptionName -> (SubMutable -> SubMutable) -> STM () touchSub km (SubscriptionName name) f =-    modifyTVar' (kmSubs km) (Map.insertWith (\_new old -> f old) name (f emptySub))+  modifyTVar' (kmSubs km) (Map.insertWith (\_new old -> f old) name (f emptySub)) -{- | Read an atomic, immutable snapshot. Reads the collector's own @TVar@s /and/-the two store readers in a single STM transaction so the result is a coherent-point-in-time view.--}+-- | Read an atomic, immutable snapshot. Reads the collector's own @TVar@s /and/+-- the two store readers in a single STM transaction so the result is a coherent+-- point-in-time view. snapshotMetrics :: KirokuMetrics -> IO MetricsSnapshot snapshotMetrics km = atomically $ do-    counters <- readTVar (kmCounters km)-    pool <- readTVar (kmPool km)-    subsMap <- readTVar (kmSubs km)-    GlobalPosition gpos <- kmPosition km-    nSubscribers <- kmSubscribers km-    let gauges =-            StoreGauges-                { globalPosition = gpos-                , activeSubscribers = nSubscribers-                , poolConnecting = countStatuses isConnecting pool-                , poolReady = countStatuses isReady pool-                , poolInUse = countStatuses isInUse pool-                , poolEstablishedTotal = pool.pmEstablishedTotal-                , poolTerminatedTotal = pool.pmTerminatedTotal-                }-    pure-        MetricsSnapshot-            { store = gauges-            , counters-            , subscriptions = Map.map (toSubMetrics gpos) subsMap-            }+  counters <- readTVar (kmCounters km)+  pool <- readTVar (kmPool km)+  subsMap <- readTVar (kmSubs km)+  GlobalPosition gpos <- kmPosition km+  nSubscribers <- kmSubscribers km+  let gauges =+        StoreGauges+          { globalPosition = gpos,+            activeSubscribers = nSubscribers,+            poolConnecting = countStatuses isConnecting pool,+            poolReady = countStatuses isReady pool,+            poolInUse = countStatuses isInUse pool,+            poolEstablishedTotal = pool.pmEstablishedTotal,+            poolTerminatedTotal = pool.pmTerminatedTotal+          }+  pure+    MetricsSnapshot+      { store = gauges,+        counters,+        subscriptions = Map.map (toSubMetrics gpos) subsMap+      }  countStatuses :: (ConnectionStatus -> Bool) -> PoolMutable -> Int countStatuses p pool = Map.size (Map.filter p pool.pmStatuses)@@ -334,9 +332,9 @@  toSubMetrics :: Int64 -> SubMutable -> SubscriptionMetrics toSubMetrics gpos s =-    SubscriptionMetrics-        { lastKnownPosition = s.smLastKnownPosition-        , lag = max 0 (gpos - s.smLastKnownPosition)-        , dbErrorCount = s.smDbErrorCount-        , lastStopReason = s.smLastStopReason-        }+  SubscriptionMetrics+    { lastKnownPosition = s.smLastKnownPosition,+      lag = max 0 (gpos - s.smLastKnownPosition),+      dbErrorCount = s.smDbErrorCount,+      lastStopReason = s.smLastStopReason+    }
src/Kiroku/Metrics/Config.hs view
@@ -1,60 +1,59 @@ -- | Configuration for the Kiroku metrics web server.-module Kiroku.Metrics.Config (-    MetricsServerConfig (..),+module Kiroku.Metrics.Config+  ( MetricsServerConfig (..),     defaultConfig,-) where+  )+where  import Data.Int (Int64) import Numeric.Natural (Natural)  import Kiroku.Metrics.Cors (CorsPolicy, corsDisabled) -{- | Configuration for the metrics web server. The @ws*@ fields are consumed by-the WebSocket endpoint (EP-3); they exist here so that plan needs no config-change. @readinessMaxLag@ is the Kiroku analogue of Marten's @maxEventLag@; it-defaults higher than Marten's 100 because Kiroku's lag is an /upper bound/ (see-the collector's lag limitation).--}+-- | Configuration for the metrics web server. The @ws*@ fields are consumed by+-- the WebSocket endpoint (EP-3); they exist here so that plan needs no config+-- change. @readinessMaxLag@ is the Kiroku analogue of Marten's @maxEventLag@; it+-- defaults higher than Marten's 100 because Kiroku's lag is an /upper bound/ (see+-- the collector's lag limitation). data MetricsServerConfig = MetricsServerConfig-    { port :: !Int-    -- ^ Port to listen on (default: 9091).-    , enableJSON :: !Bool-    -- ^ Enable the JSON metrics and health endpoints (default: True).-    , enablePrometheus :: !Bool-    -- ^ Enable the Prometheus text-exposition endpoint (default: True).-    , enableWebSocket :: !Bool-    -- ^ Enable the WebSocket upgrade path (default: True; EP-3 makes it functional).-    , wsPushIntervalUs :: !Int-    -- ^ WebSocket live-push interval in microseconds (default: 1_000_000 = 1s).-    , wsMaxConnections :: !Int-    -- ^ Maximum concurrent WebSocket connections (default: 100).-    , wsEventQueueCap :: !Natural-    {- ^ Per-connection broadcast queue capacity, in batches, for the event-    stream tail (default: 256). Additive field introduced by EP-3; under-    'Kiroku.Store.Subscription.Types.DropOldest' a slow client drops the-    oldest undelivered batches rather than stalling the publisher.-    -}-    , readinessMaxLag :: !Int64-    -- ^ A subscription lagging beyond this fails readiness (default: 10_000).-    , livenessTimeoutUs :: !Int-    -- ^ Timeout for the liveness snapshot in microseconds (default: 1_000_000 = 1s).-    , cors :: !CorsPolicy-    -- ^ Explicit browser origins; disabled by default for HTTP and WebSocket alike.-    }-    deriving stock (Eq, Show)+  { -- | Port to listen on (default: 9091).+    port :: !Int,+    -- | Enable the JSON metrics and health endpoints (default: True).+    enableJSON :: !Bool,+    -- | Enable the Prometheus text-exposition endpoint (default: True).+    enablePrometheus :: !Bool,+    -- | Enable the WebSocket upgrade path (default: True; EP-3 makes it functional).+    enableWebSocket :: !Bool,+    -- | WebSocket live-push interval in microseconds (default: 1_000_000 = 1s).+    wsPushIntervalUs :: !Int,+    -- | Maximum concurrent WebSocket connections (default: 100).+    wsMaxConnections :: !Int,+    -- | Per-connection broadcast queue capacity, in batches, for the event+    --     stream tail (default: 256). Additive field introduced by EP-3; under+    --     'Kiroku.Store.Subscription.Types.DropOldest' a slow client drops the+    --     oldest undelivered batches rather than stalling the publisher.+    wsEventQueueCap :: !Natural,+    -- | A subscription lagging beyond this fails readiness (default: 10_000).+    readinessMaxLag :: !Int64,+    -- | Timeout for the liveness snapshot in microseconds (default: 1_000_000 = 1s).+    livenessTimeoutUs :: !Int,+    -- | Explicit browser origins; disabled by default for HTTP and WebSocket alike.+    cors :: !CorsPolicy+  }+  deriving stock (Eq, Show)  -- | Default configuration: port 9091, all endpoints enabled. defaultConfig :: MetricsServerConfig defaultConfig =-    MetricsServerConfig-        { port = 9091-        , enableJSON = True-        , enablePrometheus = True-        , enableWebSocket = True-        , wsPushIntervalUs = 1_000_000-        , wsMaxConnections = 100-        , wsEventQueueCap = 256-        , readinessMaxLag = 10_000-        , livenessTimeoutUs = 1_000_000-        , cors = corsDisabled-        }+  MetricsServerConfig+    { port = 9091,+      enableJSON = True,+      enablePrometheus = True,+      enableWebSocket = True,+      wsPushIntervalUs = 1_000_000,+      wsMaxConnections = 100,+      wsEventQueueCap = 256,+      readinessMaxLag = 10_000,+      livenessTimeoutUs = 1_000_000,+      cors = corsDisabled+    }
src/Kiroku/Metrics/Cors.hs view
@@ -1,8 +1,8 @@ {-# LANGUAGE BangPatterns #-}  -- | Default-off browser access for HTTP and WebSocket inspection surfaces.-module Kiroku.Metrics.Cors (-    AllowedOrigin,+module Kiroku.Metrics.Cors+  ( AllowedOrigin,     OriginError (..),     allowedOrigin,     renderAllowedOrigin,@@ -14,7 +14,8 @@     originAllowed,     isPreflight,     corsMiddleware,-) where+  )+where  import Control.Monad (guard) import Data.ByteString (ByteString)@@ -35,21 +36,20 @@  -- | An explicit normalized HTTP(S) origin. Construct with 'allowedOrigin'. newtype AllowedOrigin = AllowedOrigin Text-    deriving stock (Eq, Ord, Show)+  deriving stock (Eq, Ord, Show)  -- | Configuration errors; input is retained only for the host's diagnostic use. data OriginError-    = WildcardOrigin-    | OpaqueOrigin-    | MissingScheme Text-    | EmptyHost Text-    | HasPathQueryOrFragment Text-    | NotAnOrigin Text-    deriving stock (Eq, Show)+  = WildcardOrigin+  | OpaqueOrigin+  | MissingScheme Text+  | EmptyHost Text+  | HasPathQueryOrFragment Text+  | NotAnOrigin Text+  deriving stock (Eq, Show) -{- | Validate configuration, trimming whitespace and tolerating one final slash.-DNS hosts must be ASCII; use the ASCII form of internationalized names.--}+-- | Validate configuration, trimming whitespace and tolerating one final slash.+-- DNS hosts must be ASCII; use the ASCII form of internationalized names. allowedOrigin :: Text -> Either OriginError AllowedOrigin allowedOrigin input = parseOrigin (maybe trimmed id (T.stripSuffix "/" trimmed))   where@@ -60,19 +60,19 @@  parseOrigin :: Text -> Either OriginError AllowedOrigin parseOrigin input-    | input == "*" = Left WildcardOrigin-    | input == "null" = Left OpaqueOrigin-    | T.any (\c -> not (isAscii c) || ord c <= 32 || ord c == 127) input = invalid-    | T.null rest = Left (MissingScheme input)-    | scheme /= "http" && scheme /= "https" = invalid-    | T.null authority = Left (EmptyHost input)-    | T.any (`elem` ("/?#" :: String)) authority = Left (HasPathQueryOrFragment input)-    | otherwise = case normalizeAuthority authority of-        Nothing -> invalid-        Just (host, mPort) ->-            let defaultPort = if scheme == "http" then 80 else 443-                suffix = maybe "" (\p -> if p == defaultPort then "" else ":" <> T.pack (show p)) mPort-             in Right (AllowedOrigin (scheme <> "://" <> host <> suffix))+  | input == "*" = Left WildcardOrigin+  | input == "null" = Left OpaqueOrigin+  | T.any (\c -> not (isAscii c) || ord c <= 32 || ord c == 127) input = invalid+  | T.null rest = Left (MissingScheme input)+  | scheme /= "http" && scheme /= "https" = invalid+  | T.null authority = Left (EmptyHost input)+  | T.any (`elem` ("/?#" :: String)) authority = Left (HasPathQueryOrFragment input)+  | otherwise = case normalizeAuthority authority of+      Nothing -> invalid+      Just (host, mPort) ->+        let defaultPort = if scheme == "http" then 80 else 443+            suffix = maybe "" (\p -> if p == defaultPort then "" else ":" <> T.pack (show p)) mPort+         in Right (AllowedOrigin (scheme <> "://" <> host <> suffix))   where     (rawScheme, rest) = T.breakOn "://" input     scheme = T.map toLower rawScheme@@ -81,109 +81,110 @@  normalizeAuthority :: Text -> Maybe (Text, Maybe Int) normalizeAuthority authority = do-    guard (not (T.any (`elem` ("@%*\\" :: String)) authority))-    if T.isPrefixOf "[" authority-        then do-            let (literal, close) = T.breakOn "]" (T.drop 1 authority)-            guard (not (T.null close))-            host <- ipv6 literal-            p <- portSuffix (T.drop 1 close)-            pure ("[" <> host <> "]", p)-        else do-            let (host, suffix) = T.breakOn ":" authority-            guard (validHost host)-            p <- portSuffix suffix-            pure (T.map toLower host, p)+  guard (not (T.any (`elem` ("@%*\\" :: String)) authority))+  if T.isPrefixOf "[" authority+    then do+      let (literal, close) = T.breakOn "]" (T.drop 1 authority)+      guard (not (T.null close))+      host <- ipv6 literal+      p <- portSuffix (T.drop 1 close)+      pure ("[" <> host <> "]", p)+    else do+      let (host, suffix) = T.breakOn ":" authority+      guard (validHost host)+      p <- portSuffix suffix+      pure (T.map toLower host, p)  portSuffix :: Text -> Maybe (Maybe Int) portSuffix "" = Just Nothing portSuffix suffix = do-    digits <- T.stripPrefix ":" suffix-    n <- decimal digits-    guard (n <= 65535)-    pure (Just (fromInteger n))+  digits <- T.stripPrefix ":" suffix+  n <- decimal digits+  guard (n <= 65535)+  pure (Just (fromInteger n))  decimal :: Text -> Maybe Integer decimal digits = do-    guard (not (T.null digits) && T.all (\c -> c >= '0' && c <= '9') digits)-    -- Both uses are bounded (ports and IPv4 octets). Stop growing the number-    -- once it cannot be a port, even for a very long untrusted header.-    T.foldl' step (Just 0) digits+  guard (not (T.null digits) && T.all (\c -> c >= '0' && c <= '9') digits)+  -- Both uses are bounded (ports and IPv4 octets). Stop growing the number+  -- once it cannot be a port, even for a very long untrusted header.+  T.foldl' step (Just 0) digits   where     step number c = do-        n <- number-        let next = n * 10 + fromIntegral (ord c - ord '0')-        guard (next <= 65535)-        pure next+      n <- number+      let next = n * 10 + fromIntegral (ord c - ord '0')+      guard (next <= 65535)+      pure next  validHost :: Text -> Bool validHost host-    | T.null host || T.length host > 253 = False-    | T.all (\c -> c == '.' || (c >= '0' && c <= '9')) host = isJust (ipv4 host)-    | otherwise = all validLabel (T.splitOn "." host)+  | T.null host || T.length host > 253 = False+  | T.all (\c -> c == '.' || (c >= '0' && c <= '9')) host = isJust (ipv4 host)+  | otherwise = all validLabel (T.splitOn "." host)   where     validLabel label =-        not (T.null label)-            && T.length label <= 63-            && T.head label /= '-'-            && T.last label /= '-'-            && T.all (\c -> isAsciiLower c || isAsciiUpper c || (c >= '0' && c <= '9') || c == '-') label+      not (T.null label)+        && T.length label <= 63+        && T.head label /= '-'+        && T.last label /= '-'+        && T.all (\c -> isAsciiLower c || isAsciiUpper c || (c >= '0' && c <= '9') || c == '-') label  ipv4 :: Text -> Maybe [Int] ipv4 host = do-    let parts = T.splitOn "." host-    guard (length parts == 4)-    traverse octet parts+  let parts = T.splitOn "." host+  guard (length parts == 4)+  traverse octet parts   where     octet part = do-        guard (T.length part <= 3 && (T.length part == 1 || not (T.isPrefixOf "0" part)))-        n <- decimal part-        guard (n <= 255)-        pure (fromInteger n)+      guard (T.length part <= 3 && (T.length part == 1 || not (T.isPrefixOf "0" part)))+      n <- decimal part+      guard (n <= 255)+      pure (fromInteger n)  -- Normalize IPv6 by expanding the groups; compressed and embedded-IPv4 forms -- compare by address, without DNS lookup or platform-dependent parsing. ipv6 :: Text -> Maybe Text ipv6 literal = do-    expanded <- case T.splitOn "::" literal of-        [whole] -> do-            groups <- side whole-            guard (length groups == 8)-            pure groups-        [left, right] -> do-            -- An embedded IPv4 address must occupy the final two groups.-            guard (not (T.any (== '.') left))-            l <- side left-            r <- side right-            guard (length l + length r < 8)-            pure (l <> replicate (8 - length l - length r) 0 <> r)-        _ -> Nothing-    pure (T.intercalate ":" (map (\n -> T.pack (showHex n "")) expanded))+  expanded <- case T.splitOn "::" literal of+    [whole] -> do+      groups <- side whole+      guard (length groups == 8)+      pure groups+    [left, right] -> do+      -- An embedded IPv4 address must occupy the final two groups.+      guard (not (T.any (== '.') left))+      l <- side left+      r <- side right+      guard (length l + length r < 8)+      pure (l <> replicate (8 - length l - length r) 0 <> r)+    _ -> Nothing+  pure (T.intercalate ":" (map (\n -> T.pack (showHex n "")) expanded))   where     side "" = Just []     side value = do-        let parts = T.splitOn ":" value-        case reverse parts of-            final : remaining | T.any (== '.') final -> do-                octets <- ipv4 final-                case octets of-                    [a, b, c, d] -> do-                        preceding <- traverse hexGroup (reverse remaining)-                        pure (preceding <> [a * 256 + b, c * 256 + d])-                    _ -> Nothing-            _ -> traverse hexGroup parts+      let parts = T.splitOn ":" value+      case reverse parts of+        final : remaining | T.any (== '.') final -> do+          octets <- ipv4 final+          case octets of+            [a, b, c, d] -> do+              preceding <- traverse hexGroup (reverse remaining)+              pure (preceding <> [a * 256 + b, c * 256 + d])+            _ -> Nothing+        _ -> traverse hexGroup parts     hexGroup value = do-        guard (not (T.null value) && T.length value <= 4 && T.all isHexDigit value)-        pure (T.foldl' (\n c -> n * 16 + digitToInt c) 0 value)+      guard (not (T.null value) && T.length value <= 4 && T.all isHexDigit value)+      pure (T.foldl' (\n c -> n * 16 + digitToInt c) 0 value)  -- | Empty origins disable the middleware. Negative max age is treated as absent. data CorsPolicy = CorsPolicy-    { allowedOrigins :: ![AllowedOrigin]-    , allowCredentials :: !Bool-    , maxAgeSeconds :: !(Maybe Int)-    }-    deriving stock (Eq, Show)+  { allowedOrigins :: ![AllowedOrigin],+    allowCredentials :: !Bool,+    maxAgeSeconds :: !(Maybe Int)+  }+  deriving stock (Eq, Show) +-- | No HTTP grants; refuse WebSocket upgrades carrying any Origin header. corsDisabled :: CorsPolicy corsDisabled = CorsPolicy [] False Nothing @@ -217,51 +218,54 @@  isPreflight :: Request -> Bool isPreflight req =-    requestMethod req == methodOptions-        && not (null (headerValues originHeader (requestHeaders req)))-        && not (null (headerValues "Access-Control-Request-Method" (requestHeaders req)))+  requestMethod req == methodOptions+    && not (null (headerValues originHeader (requestHeaders req)))+    && not (null (headerValues "Access-Control-Request-Method" (requestHeaders req))) -{- | Disabled policy returns the application itself. Enabled policy captures one-normalized set, protects upgrades before dispatch, and varies all HTTP responses.-Raw WebSocket responses are preserved by WAI's 'mapResponseHeaders'.--}+-- | Disabled policy leaves HTTP and origin-less upgrades unchanged, but refuses+-- upgrades carrying any Origin header. Enabled policy captures one+-- normalized set, protects upgrades before dispatch, and varies all HTTP responses.+-- Raw WebSocket responses are preserved by WAI's 'mapResponseHeaders'. corsMiddleware :: CorsPolicy -> Middleware corsMiddleware policy-    | not (corsEnabled policy) = id-    | otherwise =-        let !origins = originSet policy-         in \app req respond ->-                let values = headerValues originHeader (requestHeaders req)-                    grant = case values of-                        [raw] | maybe False (`Set.member` origins) (requestOrigin raw) -> Just raw-                        _ -> Nothing-                    varyKeys = if isPreflight req then ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"] else ["Origin"]-                    decorate = mapResponseHeaders (decorateHeaders policy grant varyKeys)-                    reply = respond . decorate-                 in if isWebSocketsReq req-                        then case (values, grant) of-                            ([], _) -> app req reply-                            (_, Just _) -> app req reply-                            _ -> reply (errorResponse status403 "origin_not_allowed" "The request origin is not allowed." Nothing)-                        else case (isPreflight req, grant) of-                            (True, Just _) -> case headerValues "Access-Control-Request-Method" (requestHeaders req) of-                                [method] | method == "GET" || method == "HEAD" ->-                                    case requestedHeaders (requestHeaders req) of-                                        Nothing -> reply (errorResponse status400 "invalid_cors_request" "Requested header names must be HTTP tokens." Nothing)-                                        Just headers ->-                                            reply (responseLBS status204 (preflightHeaders policy headers) "")-                                [_] -> reply (errorResponse status403 "cors_method_not_allowed" "The requested method is not allowed." Nothing)-                                _ -> reply (errorResponse status400 "invalid_cors_request" "A single requested method is required." Nothing)-                            _ -> app req reply+  | not (corsEnabled policy) = \app req respond ->+      if isWebSocketsReq req && not (null (headerValues originHeader (requestHeaders req)))+        then respond (errorResponse status403 "origin_not_allowed" "The request origin is not allowed." Nothing)+        else app req respond+  | otherwise =+      let !origins = originSet policy+       in \app req respond ->+            let values = headerValues originHeader (requestHeaders req)+                grant = case values of+                  [raw] | maybe False (`Set.member` origins) (requestOrigin raw) -> Just raw+                  _ -> Nothing+                varyKeys = if isPreflight req then ["Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"] else ["Origin"]+                decorate = mapResponseHeaders (decorateHeaders policy grant varyKeys)+                reply = respond . decorate+             in if isWebSocketsReq req+                  then case (values, grant) of+                    ([], _) -> app req reply+                    (_, Just _) -> app req reply+                    _ -> reply (errorResponse status403 "origin_not_allowed" "The request origin is not allowed." Nothing)+                  else case (isPreflight req, grant) of+                    (True, Just _) -> case headerValues "Access-Control-Request-Method" (requestHeaders req) of+                      [method] | method == "GET" || method == "HEAD" ->+                        case requestedHeaders (requestHeaders req) of+                          Nothing -> reply (errorResponse status400 "invalid_cors_request" "Requested header names must be HTTP tokens." Nothing)+                          Just headers ->+                            reply (responseLBS status204 (preflightHeaders policy headers) "")+                      [_] -> reply (errorResponse status403 "cors_method_not_allowed" "The requested method is not allowed." Nothing)+                      _ -> reply (errorResponse status400 "invalid_cors_request" "A single requested method is required." Nothing)+                    _ -> app req reply  requestedHeaders :: RequestHeaders -> Maybe (Maybe ByteString) requestedHeaders headers = case headerValues "Access-Control-Request-Headers" headers of-    [] -> Just Nothing-    values -> do-        guard (all (not . BS.null . trimOWS) values)-        let tokens = map trimOWS (concatMap (BS.split ',') values)-        guard (not (null tokens) && all (\t -> not (BS.null t) && BS.all tokenChar t) tokens)-        pure (Just (BS.intercalate ", " tokens))+  [] -> Just Nothing+  values -> do+    guard (all (not . BS.null . trimOWS) values)+    let tokens = map trimOWS (concatMap (BS.split ',') values)+    guard (not (null tokens) && all (\t -> not (BS.null t) && BS.all tokenChar t) tokens)+    pure (Just (BS.intercalate ", " tokens))   where     tokenChar c = isAsciiLower c || isAsciiUpper c || (c >= '0' && c <= '9') || c `elem` ("!#$%&'*+-.^_`|~" :: String) @@ -272,24 +276,24 @@  preflightHeaders :: CorsPolicy -> Maybe ByteString -> ResponseHeaders preflightHeaders policy headers =-    [("Access-Control-Allow-Methods", corsAllowedMethods)]-        <> maybe [] (\v -> [("Access-Control-Allow-Headers", v)]) headers-        <> case policy.maxAgeSeconds of-            Just n | n >= 0 -> [("Access-Control-Max-Age", BS.pack (show n))]-            _ -> []+  [("Access-Control-Allow-Methods", corsAllowedMethods)]+    <> maybe [] (\v -> [("Access-Control-Allow-Headers", v)]) headers+    <> case policy.maxAgeSeconds of+      Just n | n >= 0 -> [("Access-Control-Max-Age", BS.pack (show n))]+      _ -> []  decorateHeaders :: CorsPolicy -> Maybe ByteString -> [ByteString] -> ResponseHeaders -> ResponseHeaders decorateHeaders policy grant keys headers =-    mergeVary keys (filter (not . isGrant . fst) headers)-        <> maybe [] (\raw -> [("Access-Control-Allow-Origin", raw)] <> [("Access-Control-Allow-Credentials", "true") | policy.allowCredentials]) grant+  mergeVary keys (filter (not . isGrant . fst) headers)+    <> maybe [] (\raw -> [("Access-Control-Allow-Origin", raw)] <> [("Access-Control-Allow-Credentials", "true") | policy.allowCredentials]) grant   where     isGrant name = name == "Access-Control-Allow-Origin" || name == "Access-Control-Allow-Credentials"  mergeVary :: [ByteString] -> ResponseHeaders -> ResponseHeaders mergeVary keys headers =-    (varyHeader, BS.intercalate ", " tokens) : filter ((/= varyHeader) . fst) headers+  (varyHeader, BS.intercalate ", " tokens) : filter ((/= varyHeader) . fst) headers   where     existing = filter (not . BS.null) (map trimOWS (concatMap (BS.split ',' . snd) (filter ((== varyHeader) . fst) headers)))     tokens-        | "*" `elem` existing = ["*"]-        | otherwise = nubBy (\a b -> BS.map toLower a == BS.map toLower b) (existing <> keys)+      | "*" `elem` existing = ["*"]+      | otherwise = nubBy (\a b -> BS.map toLower a == BS.map toLower b) (existing <> keys)
src/Kiroku/Metrics/DeadLetters.hs view
@@ -1,6 +1,6 @@ -- | Read-only subscription dead-letter inspection with exclusive keyset pages.-module Kiroku.Metrics.DeadLetters (-    DeadLetterProvider,+module Kiroku.Metrics.DeadLetters+  ( DeadLetterProvider,     storeDeadLetters,     DeadLetterItem (..),     DeadLetterPageResponse (..),@@ -11,7 +11,8 @@     parseDeadLetterRequest,     deadLettersApp,     deadLettersNotConfiguredApp,-) where+  )+where  import Data.Aeson (FromJSON (..), ToJSON (..), Value, encode, object, withObject, (.:), (.:?), (.=)) import Data.ByteString qualified as BS@@ -29,34 +30,41 @@ import Text.Read (readMaybe)  type DeadLetterProvider = Store.SubscriptionDeadLetterQuery -> IO (Either Store.StoreError Store.SubscriptionDeadLetterPage)+ storeDeadLetters :: Store.KirokuStore -> DeadLetterProvider storeDeadLetters store = Store.runStoreIO store . Store.subscriptionDeadLetters  data DeadLetterItem = DeadLetterItem-    { deadLetterId :: !Int64-    , subscription :: !Text-    , member :: !Int32-    , globalPosition :: !Int64-    , eventId :: !UUID-    , reason :: !Value-    , reasonSummary :: !Text-    , attemptCount :: !Int32-    , createdAt :: !UTCTime-    }-    deriving stock (Eq, Show)+  { deadLetterId :: !Int64,+    subscription :: !Text,+    member :: !Int32,+    globalPosition :: !Int64,+    eventId :: !UUID,+    reason :: !Value,+    reasonSummary :: !Text,+    attemptCount :: !Int32,+    createdAt :: !UTCTime+  }+  deriving stock (Eq, Show)+ data DeadLetterPageResponse = DeadLetterPageResponse-    { items :: ![DeadLetterItem]-    , nextCursor :: !(Maybe Text)-    }-    deriving stock (Eq, Show)+  { items :: ![DeadLetterItem],+    nextCursor :: !(Maybe Text)+  }+  deriving stock (Eq, Show)+ instance ToJSON DeadLetterItem where-    toJSON row = object ["dead_letter_id" .= row.deadLetterId, "subscription" .= row.subscription, "member" .= row.member, "global_position" .= row.globalPosition, "event_id" .= row.eventId, "reason" .= row.reason, "reason_summary" .= row.reasonSummary, "attempt_count" .= row.attemptCount, "created_at" .= row.createdAt]+  toJSON row = object ["dead_letter_id" .= row.deadLetterId, "subscription" .= row.subscription, "member" .= row.member, "global_position" .= row.globalPosition, "event_id" .= row.eventId, "reason" .= row.reason, "reason_summary" .= row.reasonSummary, "attempt_count" .= row.attemptCount, "created_at" .= row.createdAt]+ instance FromJSON DeadLetterItem where-    parseJSON = withObject "DeadLetterItem" $ \o -> DeadLetterItem <$> o .: "dead_letter_id" <*> o .: "subscription" <*> o .: "member" <*> o .: "global_position" <*> o .: "event_id" <*> o .: "reason" <*> o .: "reason_summary" <*> o .: "attempt_count" <*> o .: "created_at"+  parseJSON = withObject "DeadLetterItem" $ \o -> DeadLetterItem <$> o .: "dead_letter_id" <*> o .: "subscription" <*> o .: "member" <*> o .: "global_position" <*> o .: "event_id" <*> o .: "reason" <*> o .: "reason_summary" <*> o .: "attempt_count" <*> o .: "created_at"+ instance ToJSON DeadLetterPageResponse where-    toJSON page = object (["items" .= page.items] <> maybe [] (\cursor -> ["next_cursor" .= cursor]) page.nextCursor)+  toJSON page = object (["items" .= page.items] <> maybe [] (\cursor -> ["next_cursor" .= cursor]) page.nextCursor)+ instance FromJSON DeadLetterPageResponse where-    parseJSON = withObject "DeadLetterPageResponse" $ \o -> DeadLetterPageResponse <$> o .: "items" <*> o .:? "next_cursor"+  parseJSON = withObject "DeadLetterPageResponse" $ \o -> DeadLetterPageResponse <$> o .: "items" <*> o .:? "next_cursor"+ deadLetterPageResponse :: Store.SubscriptionDeadLetterPage -> DeadLetterPageResponse deadLetterPageResponse (Store.SubscriptionDeadLetterPage rows cursor) = DeadLetterPageResponse (map item (V.toList rows)) (renderDeadLetterCursor <$> cursor)   where@@ -64,49 +72,54 @@  renderDeadLetterCursor :: Store.SubscriptionDeadLetterCursor -> Text renderDeadLetterCursor (Store.SubscriptionDeadLetterCursor (Store.GlobalPosition position) ident) = T.pack (show position) <> ":" <> T.pack (show ident)+ parseDeadLetterCursor :: Text -> Maybe Store.SubscriptionDeadLetterCursor parseDeadLetterCursor raw = case T.splitOn ":" raw of-    [p, i] -> Store.SubscriptionDeadLetterCursor . Store.GlobalPosition <$> unsigned p <*> unsigned i-    _ -> Nothing+  [p, i] -> Store.SubscriptionDeadLetterCursor . Store.GlobalPosition <$> unsigned p <*> unsigned i+  _ -> Nothing+ unsigned :: forall a. (Integral a, Bounded a) => Text -> Maybe a unsigned raw-    | T.null raw || T.length raw > 20 || not (T.all (\c -> c >= '0' && c <= '9') raw) = Nothing-    | otherwise = do-        value <- readMaybe (T.unpack raw) :: Maybe Integer-        if value <= toInteger (maxBound @a) then Just (fromInteger value) else Nothing+  | T.null raw || T.length raw > 20 || not (T.all (\c -> c >= '0' && c <= '9') raw) = Nothing+  | otherwise = do+      value <- readMaybe (T.unpack raw) :: Maybe Integer+      if value <= toInteger (maxBound @a) then Just (fromInteger value) else Nothing  data DeadLetterRequest = DeadLetterRequest-    { requestMember :: !(Maybe Int32)-    , requestAfter :: !(Maybe Store.SubscriptionDeadLetterCursor)-    , requestLimit :: !Store.SubscriptionDeadLetterLimit-    }-    deriving stock (Eq, Show)+  { requestMember :: !(Maybe Int32),+    requestAfter :: !(Maybe Store.SubscriptionDeadLetterCursor),+    requestLimit :: !Store.SubscriptionDeadLetterLimit+  }+  deriving stock (Eq, Show)+ type Failure = (Status, Text, Text, Maybe Value)+ invalid :: Text -> Text -> Text -> Failure invalid parameter value reason = (status400, "invalid_query_parameter", "Invalid query parameter.", Just (object ["parameter" .= parameter, "value" .= value, "reason" .= reason]))+ parseDeadLetterRequest :: Query -> Either Failure DeadLetterRequest parseDeadLetterRequest query = do-    member <- parameter "member" unsigned "Expected a non-negative Int32 decimal integer."-    cursor <- parameter "from" parseDeadLetterCursor "Expected an opaque position:id cursor with non-negative Int64 components."-    size <- parameter "limit" validLimit "Expected a decimal page size from 1 through 1000."-    pure (DeadLetterRequest member cursor (maybe Store.defaultSubscriptionDeadLetterLimit id size))+  member <- parameter "member" unsigned "Expected a non-negative Int32 decimal integer."+  cursor <- parameter "from" parseDeadLetterCursor "Expected an opaque position:id cursor with non-negative Int64 components."+  size <- parameter "limit" validLimit "Expected a decimal page size from 1 through 1000."+  pure (DeadLetterRequest member cursor (maybe Store.defaultSubscriptionDeadLetterLimit id size))   where     validLimit raw = unsigned raw >>= either (const Nothing) Just . Store.mkSubscriptionDeadLetterLimit     parameter :: Text -> (Text -> Maybe a) -> Text -> Either Failure (Maybe a)     parameter key parser reason = case [v | (k, v) <- query, k == TE.encodeUtf8 key] of-        [] -> Right Nothing-        [Just bytes] -> case TE.decodeUtf8' bytes of-            Left _ -> Left (invalid key "<invalid UTF-8>" "Expected valid UTF-8.")-            Right raw -> maybe (Left (invalid key raw reason)) (Right . Just) (parser raw)-        [Nothing] -> Left (invalid key "" reason)-        _ -> Left (invalid key "<duplicate>" "Parameter must occur at most once.")+      [] -> Right Nothing+      [Just bytes] -> case TE.decodeUtf8' bytes of+        Left _ -> Left (invalid key "<invalid UTF-8>" "Expected valid UTF-8.")+        Right raw -> maybe (Left (invalid key raw reason)) (Right . Just) (parser raw)+      [Nothing] -> Left (invalid key "" reason)+      _ -> Left (invalid key "<duplicate>" "Parameter must occur at most once.")  -- | Applies GET/HEAD/405 at WAI level, including when mounted without Warp. readMethods :: IO Response -> Application readMethods action req respond-    | requestMethod req == "GET" = action >>= respond-    | requestMethod req == "HEAD" = action >>= \response -> respond (responseLBS (responseStatus response) (responseHeaders response) "")-    | otherwise = respond $ mapResponseHeaders (("Allow", "GET, HEAD") :) $ errorResponse status405 "method_not_allowed" "Only GET and HEAD are supported." Nothing+  | requestMethod req == "GET" = action >>= respond+  | requestMethod req == "HEAD" = action >>= \response -> respond (responseLBS (responseStatus response) (responseHeaders response) "")+  | otherwise = respond $ mapResponseHeaders (("Allow", "GET, HEAD") :) $ errorResponse status405 "method_not_allowed" "Only GET and HEAD are supported." Nothing  deadLettersNotConfiguredApp :: Application deadLettersNotConfiguredApp req = readMethods (pure (errorResponse status404 "dead_letters_not_configured" "Dead letters are not configured." Nothing)) req@@ -115,13 +128,13 @@ deadLettersApp provider req = readMethods action req   where     action = case pathInfo req of-        ["subscriptions", name, "dead-letters"]-            | T.any (== '\0') name || BS.length (TE.encodeUtf8 name) > 512 -> pure $ failure (invalid "subscription" name "Expected at most 512 UTF-8 bytes without NUL.")-            | otherwise -> case parseDeadLetterRequest (queryString req) of-                Left err -> pure (failure err)-                Right (DeadLetterRequest member cursor size) ->-                    provider (Store.SubscriptionDeadLetterQuery (Store.SubscriptionName name) member cursor size) >>= \case-                        Left err -> pure (storeErrorResponse "dead_letters_unavailable" err)-                        Right page -> pure (jsonResponse status200 (encode (deadLetterPageResponse page)))-        _ -> pure (errorResponse status404 "not_found" "Not found" Nothing)+      ["subscriptions", name, "dead-letters"]+        | T.any (== '\0') name || BS.length (TE.encodeUtf8 name) > 512 -> pure $ failure (invalid "subscription" name "Expected at most 512 UTF-8 bytes without NUL.")+        | otherwise -> case parseDeadLetterRequest (queryString req) of+            Left err -> pure (failure err)+            Right (DeadLetterRequest member cursor size) ->+              provider (Store.SubscriptionDeadLetterQuery (Store.SubscriptionName name) member cursor size) >>= \case+                Left err -> pure (storeErrorResponse "dead_letters_unavailable" err)+                Right page -> pure (jsonResponse status200 (encode (deadLetterPageResponse page)))+      _ -> pure (errorResponse status404 "not_found" "Not found" Nothing)     failure (status, code, message, details) = errorResponse status code message details
src/Kiroku/Metrics/Health.hs view
@@ -1,23 +1,22 @@-{- | Kubernetes-style health checks derived from the metrics snapshot.--* Liveness — can we read a snapshot within a time budget? (Is the process and-  collector responsive?)-* Readiness — is the store ready to serve: no subscription overflow-stopped, no-  subscription lagging beyond 'Kiroku.Metrics.Config.readinessMaxLag', and all-  configured 'DependencyCheck's passing?-* Detailed health — the readiness verdict plus the full snapshot, for humans.--The built-in 'postgresPing' dependency check issues @SELECT 1@ through the-store's pool. Other dependency checks are arbitrary @IO 'DependencyStatus'@-actions the caller supplies.--The lag signal is an /upper bound/: the collector observes a subscription's-position only at lifecycle callback points (see the collector's limitation), so-readiness may briefly report a caught-up subscription as lagging until its next-lifecycle event.--}-module Kiroku.Metrics.Health (-    DependencyCheck,+-- | Kubernetes-style health checks derived from the metrics snapshot.+--+-- * Liveness — can we read a snapshot within a time budget? (Is the process and+--   collector responsive?)+-- * Readiness — is the store ready to serve: no subscription overflow-stopped, no+--   subscription lagging beyond 'Kiroku.Metrics.Config.readinessMaxLag', and all+--   configured 'DependencyCheck's passing?+-- * Detailed health — the readiness verdict plus the full snapshot, for humans.+--+-- The built-in 'postgresPing' dependency check issues @SELECT 1@ through the+-- store's pool. Other dependency checks are arbitrary @IO 'DependencyStatus'@+-- actions the caller supplies.+--+-- The lag signal is an /upper bound/: the collector observes a subscription's+-- position only at lifecycle callback points (see the collector's limitation), so+-- readiness may briefly report a caught-up subscription as lagging until its next+-- lifecycle event.+module Kiroku.Metrics.Health+  ( DependencyCheck,     DependencyStatus (..),     LivenessStatus (..),     ReadinessStatus (..),@@ -25,7 +24,8 @@     checkReadiness,     checkDetailedHealth,     postgresPing,-) where+  )+where  import Data.Aeson (ToJSON (..), object, (.=)) import Data.Map.Strict qualified as Map@@ -39,10 +39,10 @@  import Kiroku.Metrics.Collector (KirokuMetrics, snapshotMetrics) import Kiroku.Metrics.Config (MetricsServerConfig (..))-import Kiroku.Metrics.Types (-    MetricsSnapshot (..),+import Kiroku.Metrics.Types+  ( MetricsSnapshot (..),     SubscriptionMetrics (..),- )+  ) import Kiroku.Store (KirokuStore (..))  -- | A dependency check is an IO action returning that dependency's status.@@ -50,95 +50,92 @@  -- | Status of one external dependency. data DependencyStatus = DependencyStatus-    { name :: !Text-    , healthy :: !Bool-    , latencyMs :: !(Maybe Int)-    , errorMsg :: !(Maybe Text)-    }-    deriving stock (Eq, Show)+  { name :: !Text,+    healthy :: !Bool,+    latencyMs :: !(Maybe Int),+    errorMsg :: !(Maybe Text)+  }+  deriving stock (Eq, Show)  instance ToJSON DependencyStatus where-    toJSON ds =-        object-            [ "name" .= ds.name-            , "healthy" .= ds.healthy-            , "latency_ms" .= ds.latencyMs-            , "error" .= ds.errorMsg-            ]+  toJSON ds =+    object+      [ "name" .= ds.name,+        "healthy" .= ds.healthy,+        "latency_ms" .= ds.latencyMs,+        "error" .= ds.errorMsg+      ]  -- | Liveness verdict. newtype LivenessStatus = LivenessStatus {alive :: Bool}-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  instance ToJSON LivenessStatus where-    toJSON s = object ["alive" .= s.alive]+  toJSON s = object ["alive" .= s.alive]  -- | Readiness verdict and its components. data ReadinessStatus = ReadinessStatus-    { ready :: !Bool-    , lagOk :: !Bool-    , noOverflow :: !Bool-    , dependencies :: ![DependencyStatus]-    }-    deriving stock (Eq, Show)+  { ready :: !Bool,+    lagOk :: !Bool,+    noOverflow :: !Bool,+    dependencies :: ![DependencyStatus]+  }+  deriving stock (Eq, Show)  instance ToJSON ReadinessStatus where-    toJSON s =-        object-            [ "ready" .= s.ready-            , "lag_ok" .= s.lagOk-            , "no_overflow" .= s.noOverflow-            , "dependencies" .= s.dependencies-            ]+  toJSON s =+    object+      [ "ready" .= s.ready,+        "lag_ok" .= s.lagOk,+        "no_overflow" .= s.noOverflow,+        "dependencies" .= s.dependencies+      ] -{- | Liveness: can we take a snapshot within 'livenessTimeoutUs'? Proves the-collector and store @TVar@s are reachable within the budget.--}+-- | Liveness: can we take a snapshot within 'livenessTimeoutUs'? Proves the+-- collector and store @TVar@s are reachable within the budget. checkLiveness :: MetricsServerConfig -> KirokuMetrics -> IO LivenessStatus checkLiveness cfg m = do-    result <- timeout cfg.livenessTimeoutUs (snapshotMetrics m)-    pure (LivenessStatus{alive = isJust result})+  result <- timeout cfg.livenessTimeoutUs (snapshotMetrics m)+  pure (LivenessStatus {alive = isJust result}) -{- | Readiness: no subscription overflow-stopped, none lagging beyond-'readinessMaxLag', and all dependency checks healthy.--}+-- | Readiness: no subscription overflow-stopped, none lagging beyond+-- 'readinessMaxLag', and all dependency checks healthy. checkReadiness :: MetricsServerConfig -> KirokuMetrics -> [DependencyCheck] -> IO ReadinessStatus checkReadiness cfg m depChecks = do-    snap <- snapshotMetrics m-    deps <- sequence depChecks-    let subs = Map.elems snap.subscriptions-        noOverflow = not (any ((== Just "overflow") . (.lastStopReason)) subs)-        lagOk = all ((<= cfg.readinessMaxLag) . (.lag)) subs-        depsOk = all (.healthy) deps-    pure-        ReadinessStatus-            { ready = noOverflow && lagOk && depsOk-            , lagOk-            , noOverflow-            , dependencies = deps-            }+  snap <- snapshotMetrics m+  deps <- sequence depChecks+  let subs = Map.elems snap.subscriptions+      noOverflow = not (any ((== Just "overflow") . (.lastStopReason)) subs)+      lagOk = all ((<= cfg.readinessMaxLag) . (.lag)) subs+      depsOk = all (.healthy) deps+  pure+    ReadinessStatus+      { ready = noOverflow && lagOk && depsOk,+        lagOk,+        noOverflow,+        dependencies = deps+      }  -- | Detailed health: the readiness verdict plus the full snapshot. checkDetailedHealth ::-    MetricsServerConfig ->-    KirokuMetrics ->-    [DependencyCheck] ->-    IO (ReadinessStatus, MetricsSnapshot)+  MetricsServerConfig ->+  KirokuMetrics ->+  [DependencyCheck] ->+  IO (ReadinessStatus, MetricsSnapshot) checkDetailedHealth cfg m depChecks = do-    readiness <- checkReadiness cfg m depChecks-    snap <- snapshotMetrics m-    pure (readiness, snap)+  readiness <- checkReadiness cfg m depChecks+  snap <- snapshotMetrics m+  pure (readiness, snap) -{- | Built-in dependency check: time a @SELECT 1@ through the store's pool. A-@Right@ result is healthy; a @Left@ 'Pool.UsageError' is unhealthy with the-error rendered into 'errorMsg'.--}+-- | Built-in dependency check: time a @SELECT 1@ through the store's pool. A+-- @Right@ result is healthy; a @Left@ 'Pool.UsageError' is unhealthy with the+-- error rendered into 'errorMsg'. postgresPing :: KirokuStore -> DependencyCheck postgresPing store = do-    t0 <- getMonotonicTimeNSec-    result <- Pool.use store.pool (Session.script "SELECT 1")-    t1 <- getMonotonicTimeNSec-    let ms = fromIntegral ((t1 - t0) `div` 1_000_000)-    pure $ case result of-        Right () -> DependencyStatus "postgres" True (Just ms) Nothing-        Left e -> DependencyStatus "postgres" False (Just ms) (Just (T.pack (show e)))+  t0 <- getMonotonicTimeNSec+  result <- Pool.use store.pool (Session.script "SELECT 1")+  t1 <- getMonotonicTimeNSec+  let ms = fromIntegral ((t1 - t0) `div` 1_000_000)+  pure $ case result of+    Right () -> DependencyStatus "postgres" True (Just ms) Nothing+    Left e -> DependencyStatus "postgres" False (Just ms) (Just (T.pack (show e)))
src/Kiroku/Metrics/JSON.hs view
@@ -1,13 +1,13 @@-{- | JSON HTTP endpoints: @GET /metrics@ (the full snapshot) and-@GET /metrics/\<name\>@ (one subscription's metrics, or 404).--}-module Kiroku.Metrics.JSON (-    jsonApp,+-- | JSON HTTP endpoints: @GET /metrics@ (the full snapshot) and+-- @GET /metrics/\<name\>@ (one subscription's metrics, or 404).+module Kiroku.Metrics.JSON+  ( jsonApp,     jsonResponse,     errorEnvelope,     errorResponse,     storeErrorResponse,-) where+  )+where  import Data.Aeson (Value, encode, object, (.=)) import Data.ByteString.Lazy qualified as LBS@@ -20,28 +20,27 @@ import Kiroku.Metrics.Types (MetricsSnapshot (..)) import Kiroku.Store.Error (StoreError (..)) -{- | WAI application for the JSON metrics endpoints. Routes @/metrics@ and-@/metrics/\<name\>@; any other path returns a 404 JSON body.--}+-- | WAI application for the JSON metrics endpoints. Routes @/metrics@ and+-- @/metrics/\<name\>@; any other path returns a 404 JSON body. jsonApp :: KirokuMetrics -> Application jsonApp m req respond = do-    resp <- case pathInfo req of-        ["metrics"] -> do-            snap <- snapshotMetrics m-            pure (jsonResponse status200 (encode snap))-        ["metrics", name] -> do-            snap <- snapshotMetrics m-            pure $ case Map.lookup name snap.subscriptions of-                Just sm -> jsonResponse status200 (encode sm)-                Nothing ->-                    jsonResponse status404 $-                        encode $-                            object-                                [ "error" .= ("subscription not found" :: Text)-                                , "subscription" .= name-                                ]-        _ -> pure (jsonResponse status404 (encode (object ["error" .= ("Not found" :: Text)])))-    respond resp+  resp <- case pathInfo req of+    ["metrics"] -> do+      snap <- snapshotMetrics m+      pure (jsonResponse status200 (encode snap))+    ["metrics", name] -> do+      snap <- snapshotMetrics m+      pure $ case Map.lookup name snap.subscriptions of+        Just sm -> jsonResponse status200 (encode sm)+        Nothing ->+          jsonResponse status404 $+            encode $+              object+                [ "error" .= ("subscription not found" :: Text),+                  "subscription" .= name+                ]+    _ -> pure (jsonResponse status404 (encode (object ["error" .= ("Not found" :: Text)])))+  respond resp  -- | Build an @application/json@ response with the given status and body. jsonResponse :: Status -> LBS.ByteString -> Response@@ -50,20 +49,19 @@ -- | Shared structured error for new inspection routes; legacy errors stay unchanged. errorEnvelope :: Text -> Text -> Maybe Value -> Value errorEnvelope code message details =-    object ["error" .= object (["code" .= code, "message" .= message] <> maybe [] (\v -> ["details" .= v]) details)]+  object ["error" .= object (["code" .= code, "message" .= message] <> maybe [] (\v -> ["details" .= v]) details)]  -- | Build a JSON error, omitting @details@ when absent. errorResponse :: Status -> Text -> Text -> Maybe Value -> Response errorResponse status code message = jsonResponse status . encode . errorEnvelope code message -{- | Sanitize store failures without exposing connection strings or event payloads.-This function handles values only and never catches asynchronous exceptions.--}+-- | Sanitize store failures without exposing connection strings or event payloads.+-- This function handles values only and never catches asynchronous exceptions. storeErrorResponse :: Text -> StoreError -> Response storeErrorResponse unavailableCode = \case-    ConnectionError _ -> errorResponse status503 unavailableCode "The event store is unavailable." Nothing-    ConnectionLost _ -> errorResponse status503 unavailableCode "The event store is unavailable." Nothing-    PoolAcquisitionTimeout -> errorResponse status503 unavailableCode "The event store is unavailable." Nothing-    TransientTransactionFailure _ _ -> errorResponse status503 unavailableCode "The event store is unavailable." Nothing-    EventDecodeFailed _ -> errorResponse status500 "event_decode_failed" "An event could not be decoded." Nothing-    _ -> errorResponse status500 "store_error" "The event store operation failed." Nothing+  ConnectionError _ -> errorResponse status503 unavailableCode "The event store is unavailable." Nothing+  ConnectionLost _ -> errorResponse status503 unavailableCode "The event store is unavailable." Nothing+  PoolAcquisitionTimeout -> errorResponse status503 unavailableCode "The event store is unavailable." Nothing+  TransientTransactionFailure _ _ -> errorResponse status503 unavailableCode "The event store is unavailable." Nothing+  EventDecodeFailed _ -> errorResponse status500 "event_decode_failed" "An event could not be decoded." Nothing+  _ -> errorResponse status500 "store_error" "The event store operation failed." Nothing
src/Kiroku/Metrics/Prometheus.hs view
@@ -1,15 +1,15 @@-{- | Prometheus text-exposition endpoint (@GET /metrics/prometheus@).--Hand-rolled (no @prometheus-client@ dependency, no global registry): the-snapshot is the single source of truth and is rendered straight to the-text-exposition format that @promtool check metrics@ accepts. Metric names are a-public contract for dashboards — keep them stable once shipped (EP-4 documents-the full list).--}-module Kiroku.Metrics.Prometheus (-    prometheusApp,+-- | Prometheus text-exposition endpoint (@GET /metrics/prometheus@).+--+-- Hand-rolled (no @prometheus-client@ dependency, no global registry): the+-- snapshot is the single source of truth and is rendered straight to the+-- text-exposition format that @promtool check metrics@ accepts. Metric names are a+-- public contract for dashboards — keep them stable once shipped (EP-4 documents+-- the full list).+module Kiroku.Metrics.Prometheus+  ( prometheusApp,     renderPrometheus,-) where+  )+where  import Data.ByteString.Builder qualified as B import Data.ByteString.Lazy qualified as LBS@@ -23,22 +23,22 @@ import Network.Wai (Application, responseLBS)  import Kiroku.Metrics.Collector (KirokuMetrics, snapshotMetrics)-import Kiroku.Metrics.Types (-    LifecycleCounters (..),+import Kiroku.Metrics.Types+  ( LifecycleCounters (..),     MetricsSnapshot (..),     StoreGauges (..),     SubscriptionMetrics (..),- )+  )  -- | WAI application for the Prometheus endpoint. prometheusApp :: KirokuMetrics -> Application prometheusApp m _req respond = do-    snap <- snapshotMetrics m-    respond $-        responseLBS-            status200-            [(hContentType, "text/plain; version=0.0.4; charset=utf-8")]-            (renderPrometheus snap)+  snap <- snapshotMetrics m+  respond $+    responseLBS+      status200+      [(hContentType, "text/plain; version=0.0.4; charset=utf-8")]+      (renderPrometheus snap)  -- | Render a snapshot as Prometheus text-exposition format. renderPrometheus :: MetricsSnapshot -> LBS.ByteString@@ -46,65 +46,65 @@  storeSection :: StoreGauges -> B.Builder storeSection g =-    mconcat-        [ metric "kiroku_events_appended_total" "counter" "Current store global position (opaque; not guaranteed dense)." (g.globalPosition)-        , metricI "kiroku_active_subscribers" "gauge" "Currently registered subscribers." g.activeSubscribers-        , help "kiroku_pool_connections" "Pool connections by state."-        , typ "kiroku_pool_connections" "gauge"-        , labelledI "kiroku_pool_connections" "state" "connecting" g.poolConnecting-        , labelledI "kiroku_pool_connections" "state" "ready" g.poolReady-        , labelledI "kiroku_pool_connections" "state" "in_use" g.poolInUse-        , metric "kiroku_pool_established_total" "counter" "Pool connections established." (g.poolEstablishedTotal)-        , metric "kiroku_pool_terminated_total" "counter" "Pool connections terminated." (g.poolTerminatedTotal)-        ]+  mconcat+    [ metric "kiroku_events_appended_total" "counter" "Current store global position (opaque; not guaranteed dense)." (g.globalPosition),+      metricI "kiroku_active_subscribers" "gauge" "Currently registered subscribers." g.activeSubscribers,+      help "kiroku_pool_connections" "Pool connections by state.",+      typ "kiroku_pool_connections" "gauge",+      labelledI "kiroku_pool_connections" "state" "connecting" g.poolConnecting,+      labelledI "kiroku_pool_connections" "state" "ready" g.poolReady,+      labelledI "kiroku_pool_connections" "state" "in_use" g.poolInUse,+      metric "kiroku_pool_established_total" "counter" "Pool connections established." (g.poolEstablishedTotal),+      metric "kiroku_pool_terminated_total" "counter" "Pool connections terminated." (g.poolTerminatedTotal)+    ]  counterSection :: LifecycleCounters -> B.Builder counterSection c =-    mconcat-        [ metric "kiroku_notifier_reconnecting_total" "counter" "Notifier reconnection attempts started." c.notifierReconnecting-        , metric "kiroku_notifier_reconnected_total" "counter" "Notifier reconnections completed." c.notifierReconnected-        , metric "kiroku_publisher_pool_errors_total" "counter" "EventPublisher read-query pool errors." c.publisherPoolErrors-        , metric "kiroku_subscription_handler_stalls_total" "counter" "Advisory handler stall warnings." c.subscriptionHandlerStalls-        , metric "kiroku_publisher_decode_failures_total" "counter" "Typed undecodable events broadcast by the publisher." c.publisherDecodeFailures-        , metric "kiroku_publisher_loop_errors_total" "counter" "EventPublisher callback or broadcast loop errors." c.publisherLoopErrors-        , help "kiroku_subscription_db_errors_by_phase_total" "Subscription database errors by phase."-        , typ "kiroku_subscription_db_errors_by_phase_total" "counter"-        , labelled "kiroku_subscription_db_errors_by_phase_total" "phase" "load" c.subscriptionDbErrorsLoad-        , labelled "kiroku_subscription_db_errors_by_phase_total" "phase" "fetch" c.subscriptionDbErrorsFetch-        , labelled "kiroku_subscription_db_errors_by_phase_total" "phase" "save" c.subscriptionDbErrorsSave-        , metric "kiroku_subscriptions_started_total" "counter" "Subscription workers started." c.subscriptionsStarted-        , metric "kiroku_subscriptions_caught_up_total" "counter" "Subscriptions that reached live mode." c.subscriptionsCaughtUp-        , metric "kiroku_subscriptions_paused_total" "counter" "Subscription pauses (backpressure)." c.subscriptionsPaused-        , metric "kiroku_subscriptions_resumed_total" "counter" "Subscription resumes after pause." c.subscriptionsResumed-        , metric "kiroku_subscriptions_reconnecting_total" "counter" "Subscription live-fetch reconnects." c.subscriptionsReconnecting-        , metric "kiroku_subscriptions_retrying_total" "counter" "Subscription event redeliveries." c.subscriptionsRetrying-        , metric "kiroku_subscriptions_dead_lettered_total" "counter" "Events written to dead letters." c.subscriptionsDeadLettered-        , help "kiroku_subscriptions_stopped_total" "Subscription stops by reason."-        , typ "kiroku_subscriptions_stopped_total" "counter"-        , labelled "kiroku_subscriptions_stopped_total" "reason" "handler" c.subscriptionsStoppedHandler-        , labelled "kiroku_subscriptions_stopped_total" "reason" "cancelled" c.subscriptionsStoppedCancelled-        , labelled "kiroku_subscriptions_stopped_total" "reason" "overflow" c.subscriptionsStoppedOverflow-        , labelled "kiroku_subscriptions_stopped_total" "reason" "crashed" c.subscriptionsStoppedCrashed-        , labelled "kiroku_subscriptions_stopped_total" "reason" "undecodable" c.subscriptionsStoppedUndecodable-        , metric "kiroku_live_fetches_total" "counter" "Live-mode database fetches." c.liveFetches-        , metric "kiroku_batches_delivered_total" "counter" "Non-empty batches delivered to handlers." c.batchesDelivered-        , metric "kiroku_events_delivered_total" "counter" "Events delivered to handlers." c.eventsDelivered-        , metric "kiroku_hard_deletes_total" "counter" "Hard-delete transactions issued." c.hardDeletesIssued-        ]+  mconcat+    [ metric "kiroku_notifier_reconnecting_total" "counter" "Notifier reconnection attempts started." c.notifierReconnecting,+      metric "kiroku_notifier_reconnected_total" "counter" "Notifier reconnections completed." c.notifierReconnected,+      metric "kiroku_publisher_pool_errors_total" "counter" "EventPublisher read-query pool errors." c.publisherPoolErrors,+      metric "kiroku_subscription_handler_stalls_total" "counter" "Advisory handler stall warnings." c.subscriptionHandlerStalls,+      metric "kiroku_publisher_decode_failures_total" "counter" "Typed undecodable events broadcast by the publisher." c.publisherDecodeFailures,+      metric "kiroku_publisher_loop_errors_total" "counter" "EventPublisher callback or broadcast loop errors." c.publisherLoopErrors,+      help "kiroku_subscription_db_errors_by_phase_total" "Subscription database errors by phase.",+      typ "kiroku_subscription_db_errors_by_phase_total" "counter",+      labelled "kiroku_subscription_db_errors_by_phase_total" "phase" "load" c.subscriptionDbErrorsLoad,+      labelled "kiroku_subscription_db_errors_by_phase_total" "phase" "fetch" c.subscriptionDbErrorsFetch,+      labelled "kiroku_subscription_db_errors_by_phase_total" "phase" "save" c.subscriptionDbErrorsSave,+      metric "kiroku_subscriptions_started_total" "counter" "Subscription workers started." c.subscriptionsStarted,+      metric "kiroku_subscriptions_caught_up_total" "counter" "Subscriptions that reached live mode." c.subscriptionsCaughtUp,+      metric "kiroku_subscriptions_paused_total" "counter" "Subscription pauses (backpressure)." c.subscriptionsPaused,+      metric "kiroku_subscriptions_resumed_total" "counter" "Subscription resumes after pause." c.subscriptionsResumed,+      metric "kiroku_subscriptions_reconnecting_total" "counter" "Subscription live-fetch reconnects." c.subscriptionsReconnecting,+      metric "kiroku_subscriptions_retrying_total" "counter" "Subscription event redeliveries." c.subscriptionsRetrying,+      metric "kiroku_subscriptions_dead_lettered_total" "counter" "Events written to dead letters." c.subscriptionsDeadLettered,+      help "kiroku_subscriptions_stopped_total" "Subscription stops by reason.",+      typ "kiroku_subscriptions_stopped_total" "counter",+      labelled "kiroku_subscriptions_stopped_total" "reason" "handler" c.subscriptionsStoppedHandler,+      labelled "kiroku_subscriptions_stopped_total" "reason" "cancelled" c.subscriptionsStoppedCancelled,+      labelled "kiroku_subscriptions_stopped_total" "reason" "overflow" c.subscriptionsStoppedOverflow,+      labelled "kiroku_subscriptions_stopped_total" "reason" "crashed" c.subscriptionsStoppedCrashed,+      labelled "kiroku_subscriptions_stopped_total" "reason" "undecodable" c.subscriptionsStoppedUndecodable,+      metric "kiroku_live_fetches_total" "counter" "Live-mode database fetches." c.liveFetches,+      metric "kiroku_batches_delivered_total" "counter" "Non-empty batches delivered to handlers." c.batchesDelivered,+      metric "kiroku_events_delivered_total" "counter" "Events delivered to handlers." c.eventsDelivered,+      metric "kiroku_hard_deletes_total" "counter" "Hard-delete transactions issued." c.hardDeletesIssued+    ]  subSection :: Map Text SubscriptionMetrics -> B.Builder subSection subs =-    mconcat-        [ help "kiroku_subscription_position" "Last-known global position per subscription."-        , typ "kiroku_subscription_position" "gauge"-        , Map.foldMapWithKey (\n sm -> labelled "kiroku_subscription_position" "subscription" n sm.lastKnownPosition) subs-        , help "kiroku_subscription_lag" "Lag behind the global position per subscription (upper bound)."-        , typ "kiroku_subscription_lag" "gauge"-        , Map.foldMapWithKey (\n sm -> labelled "kiroku_subscription_lag" "subscription" n sm.lag) subs-        , help "kiroku_subscription_db_errors_total" "Database errors per subscription."-        , typ "kiroku_subscription_db_errors_total" "counter"-        , Map.foldMapWithKey (\n sm -> labelled "kiroku_subscription_db_errors_total" "subscription" n sm.dbErrorCount) subs-        ]+  mconcat+    [ help "kiroku_subscription_position" "Last-known global position per subscription.",+      typ "kiroku_subscription_position" "gauge",+      Map.foldMapWithKey (\n sm -> labelled "kiroku_subscription_position" "subscription" n sm.lastKnownPosition) subs,+      help "kiroku_subscription_lag" "Lag behind the global position per subscription (upper bound).",+      typ "kiroku_subscription_lag" "gauge",+      Map.foldMapWithKey (\n sm -> labelled "kiroku_subscription_lag" "subscription" n sm.lag) subs,+      help "kiroku_subscription_db_errors_total" "Database errors per subscription.",+      typ "kiroku_subscription_db_errors_total" "counter",+      Map.foldMapWithKey (\n sm -> labelled "kiroku_subscription_db_errors_total" "subscription" n sm.dbErrorCount) subs+    ]  -- | A single unlabelled metric: HELP, TYPE, and one sample (Int64-valued). metric :: Text -> Text -> Text -> Int64 -> B.Builder@@ -123,14 +123,14 @@ -- | A labelled sample line (Int64-valued). labelled :: Text -> Text -> Text -> Int64 -> B.Builder labelled n labelKey labelVal v =-    name n-        <> B.char8 '{'-        <> text labelKey-        <> "=\""-        <> text (escapeLabel labelVal)-        <> "\"} "-        <> B.int64Dec v-        <> B.char8 '\n'+  name n+    <> B.char8 '{'+    <> text labelKey+    <> "=\""+    <> text (escapeLabel labelVal)+    <> "\"} "+    <> B.int64Dec v+    <> B.char8 '\n'  -- | A labelled sample line (Int-valued). labelledI :: Text -> Text -> Text -> Int -> B.Builder@@ -145,7 +145,7 @@ -- | Escape a Prometheus label value: backslash, double-quote, and newline. escapeLabel :: Text -> Text escapeLabel = T.concatMap $ \case-    '\\' -> "\\\\"-    '"' -> "\\\""-    '\n' -> "\\n"-    ch -> T.singleton ch+  '\\' -> "\\\\"+  '"' -> "\\\""+  '\n' -> "\\n"+  ch -> T.singleton ch
src/Kiroku/Metrics/Server.hs view
@@ -1,10 +1,9 @@-{- | The shared inspection composition. Store-backed behavior enters through-provider closures; HTTP and WebSocket dispatch share a mount-relative path and-one outer CORS policy. /capabilities reports this wiring without store access.-Legacy starters retain their signatures.--}-module Kiroku.Metrics.Server (-    MetricsServer (..),+-- | The shared inspection composition. Store-backed behavior enters through+-- provider closures; HTTP and WebSocket dispatch share a mount-relative path and+-- one outer CORS policy. /capabilities reports this wiring without store access.+-- Legacy starters retain their signatures.+module Kiroku.Metrics.Server+  ( MetricsServer (..),     ServerProviders (..),     defaultServerProviders,     providerPresence,@@ -24,7 +23,8 @@     combinedApp,     httpApp,     stubWebSocketApp,-) where+  )+where  import Control.Concurrent.Async (Async, asyncWithUnmask, cancel, race, wait, waitCatchSTM) import Control.Concurrent.STM (atomically, newEmptyTMVarIO, orElse, putTMVar, readTMVar)@@ -49,14 +49,14 @@ import Kiroku.Metrics.Config (MetricsServerConfig (..)) import Kiroku.Metrics.Cors (corsMiddleware) import Kiroku.Metrics.DeadLetters (DeadLetterProvider, deadLettersApp, deadLettersNotConfiguredApp, storeDeadLetters)-import Kiroku.Metrics.Health (-    DependencyCheck,+import Kiroku.Metrics.Health+  ( DependencyCheck,     LivenessStatus (..),     ReadinessStatus (..),     checkDetailedHealth,     checkLiveness,     checkReadiness,- )+  ) import Kiroku.Metrics.JSON (jsonApp, jsonResponse) import Kiroku.Metrics.Prometheus (prometheusApp) import Kiroku.Metrics.Subscriptions (SubscriptionStatusProvider, storeSubscriptionStatus, subscriptionsApp)@@ -65,69 +65,66 @@  -- | A running metrics server: the Warp thread and the port it bound. data MetricsServer = MetricsServer-    { serverThread :: !(Async ())-    , serverPort :: !Int-    }+  { serverThread :: !(Async ()),+    serverPort :: !Int+  }  -- | Optional data sources for the shared inspection application. data ServerProviders = ServerProviders-    { webSocketServer :: !WS.ServerApp-    , subscriptionStatus :: !(Maybe SubscriptionStatusProvider)-    , checkpointInventory :: !(Maybe CheckpointInventoryProvider)-    , storeBrowsing :: !(Maybe StoreBrowser)-    -- ^ Backs the stream, category and event inspection routes.-    , deadLetters :: !(Maybe DeadLetterProvider)-    -- ^ Backs GET /subscriptions/<name>/dead-letters.-    , webSocketChannels :: !WebSocketChannels-    -- ^ Channels served by the opaque WebSocket app, declared for /capabilities.-    }+  { webSocketServer :: !WS.ServerApp,+    subscriptionStatus :: !(Maybe SubscriptionStatusProvider),+    checkpointInventory :: !(Maybe CheckpointInventoryProvider),+    -- | Backs the stream, category and event inspection routes.+    storeBrowsing :: !(Maybe StoreBrowser),+    -- | Backs GET /subscriptions/<name>/dead-letters.+    deadLetters :: !(Maybe DeadLetterProvider),+    -- | Channels served by the opaque WebSocket app, declared for /capabilities.+    webSocketChannels :: !WebSocketChannels+  }  -- | Reject upgrades and leave all optional providers unconfigured. defaultServerProviders :: ServerProviders defaultServerProviders = ServerProviders stubWebSocketApp Nothing Nothing Nothing Nothing noWebSocketChannels -{- | Build every store-backed provider, including the process-local live registry.-Bind this action first, then use 'withMetricsServerWithProviders'.--}+-- | Build every store-backed provider, including the process-local live registry.+-- Bind this action first, then use 'withMetricsServerWithProviders'. storeServerProviders :: MetricsServerConfig -> KirokuMetrics -> KirokuStore -> IO ServerProviders storeServerProviders cfg m store = do-    wsState <- newWebSocketState cfg.wsMaxConnections-    pure $ ServerProviders (websocketApp cfg m store wsState) (Just (storeSubscriptionStatus store)) (Just (storeCheckpointInventory store)) (Just (storeBrowser store)) (Just (storeDeadLetters store)) storeWebSocketChannels+  wsState <- newWebSocketState cfg.wsMaxConnections+  pure $ ServerProviders (websocketApp cfg m store wsState) (Just (storeSubscriptionStatus store)) (Just (storeCheckpointInventory store)) (Just (storeBrowser store)) (Just (storeDeadLetters store)) storeWebSocketChannels -{- | Return only after Warp is ready; bind/setup failures are rethrown.-Ephemeral sockets are explicitly closed on every exit, including cancellation.--}+-- | Return only after Warp is ready; bind/setup failures are rethrown.+-- Ephemeral sockets are explicitly closed on every exit, including cancellation. startMetricsServerWithProviders :: MetricsServerConfig -> KirokuMetrics -> [DependencyCheck] -> ServerProviders -> IO MetricsServer startMetricsServerWithProviders cfg m deps providers = mask $ \restore -> do-    ready <- newEmptyTMVarIO-    let app = combinedAppWithProviders cfg m deps providers-        settings port = Warp.setBeforeMainLoop (atomically $ putTMVar ready ()) $ Warp.setHost "*" $ Warp.setPort port Warp.defaultSettings-        await thread port = do-            result <- restore (atomically $ (Left <$> waitCatchSTM thread) `orElse` (Right <$> readTMVar ready)) `onException` cancel thread-            case result of-                Left (Left err) -> throwIO err-                Left (Right ()) -> fail "Metrics server terminated before readiness."-                Right () -> pure (MetricsServer thread port)-    if cfg.port == 0-        then do-            (port, sock) <- Warp.openFreePort-            thread <- asyncWithUnmask (\unmask -> unmask (Warp.runSettingsSocket (settings port) sock app) `finally` Socket.close sock) `onException` Socket.close sock-            await thread port-        else do-            thread <- asyncWithUnmask (\unmask -> unmask $ Warp.runSettings (settings cfg.port) app)-            await thread cfg.port+  ready <- newEmptyTMVarIO+  let app = combinedAppWithProviders cfg m deps providers+      settings port = Warp.setBeforeMainLoop (atomically $ putTMVar ready ()) $ Warp.setHost "*" $ Warp.setPort port Warp.defaultSettings+      await thread port = do+        result <- restore (atomically $ (Left <$> waitCatchSTM thread) `orElse` (Right <$> readTMVar ready)) `onException` cancel thread+        case result of+          Left (Left err) -> throwIO err+          Left (Right ()) -> fail "Metrics server terminated before readiness."+          Right () -> pure (MetricsServer thread port)+  if cfg.port == 0+    then do+      (port, sock) <- Warp.openFreePort+      thread <- asyncWithUnmask (\unmask -> unmask (Warp.runSettingsSocket (settings port) sock app) `finally` Socket.close sock) `onException` Socket.close sock+      await thread port+    else do+      thread <- asyncWithUnmask (\unmask -> unmask $ Warp.runSettings (settings cfg.port) app)+      await thread cfg.port -{- | Supervise the callback and the server together, then release both.-Unexpected server termination cancels the callback and is rethrown to the owner.--}+-- | Supervise the callback and the server together, then release both.+-- Unexpected server termination cancels the callback and is rethrown to the owner. withMetricsServerWithProviders :: MetricsServerConfig -> KirokuMetrics -> [DependencyCheck] -> ServerProviders -> (MetricsServer -> IO a) -> IO a withMetricsServerWithProviders cfg m deps providers action =-    withRunningServer (startMetricsServerWithProviders cfg m deps providers) action+  withRunningServer (startMetricsServerWithProviders cfg m deps providers) action  withRunningServer :: IO MetricsServer -> (MetricsServer -> IO a) -> IO a withRunningServer acquire action = bracket acquire stopMetricsServer $ \server -> do-    result <- race (wait server.serverThread) (action server)-    either (\() -> fail "Metrics server terminated unexpectedly.") pure result+  result <- race (wait server.serverThread) (action server)+  either (\() -> fail "Metrics server terminated unexpectedly.") pure result  -- | Start with the rejecting WebSocket stub and no optional providers. startMetricsServer :: MetricsServerConfig -> KirokuMetrics -> [DependencyCheck] -> IO MetricsServer@@ -140,15 +137,14 @@ -- | Legacy binding of a WebSocket app and optional live status provider. startMetricsServerWith' :: MetricsServerConfig -> KirokuMetrics -> [DependencyCheck] -> Maybe SubscriptionStatusProvider -> WS.ServerApp -> IO MetricsServer startMetricsServerWith' cfg m deps mProvider wsApp =-    startMetricsServerWithProviders cfg m deps defaultServerProviders{webSocketServer = wsApp, subscriptionStatus = mProvider}+  startMetricsServerWithProviders cfg m deps defaultServerProviders {webSocketServer = wsApp, subscriptionStatus = mProvider} -{- | Serve the real WebSocket and durable inventory; the legacy live route stays-unconfigured. New hosts wanting every provider use 'storeServerProviders'.--}+-- | Serve the real WebSocket and durable inventory; the legacy live route stays+-- unconfigured. New hosts wanting every provider use 'storeServerProviders'. startMetricsServerWithStore :: MetricsServerConfig -> KirokuMetrics -> KirokuStore -> [DependencyCheck] -> IO MetricsServer startMetricsServerWithStore cfg m store deps = do-    providers <- storeServerProviders cfg m store-    startMetricsServerWithProviders cfg m deps providers{subscriptionStatus = Nothing}+  providers <- storeServerProviders cfg m store+  startMetricsServerWithProviders cfg m deps providers {subscriptionStatus = Nothing}  stopMetricsServer :: MetricsServer -> IO () stopMetricsServer server = cancel server.serverThread@@ -161,32 +157,31 @@  withMetricsServerSubscriptions :: MetricsServerConfig -> KirokuMetrics -> [DependencyCheck] -> SubscriptionStatusProvider -> (MetricsServer -> IO a) -> IO a withMetricsServerSubscriptions cfg m deps provider =-    withMetricsServerWithProviders cfg m deps defaultServerProviders{subscriptionStatus = Just provider}+  withMetricsServerWithProviders cfg m deps defaultServerProviders {subscriptionStatus = Just provider}  -- | Legacy composition binding. CORS is applied once by the general composition. combinedApp :: MetricsServerConfig -> KirokuMetrics -> [DependencyCheck] -> Maybe SubscriptionStatusProvider -> WS.ServerApp -> Application combinedApp cfg m deps mProvider wsApp =-    combinedAppWithProviders cfg m deps defaultServerProviders{webSocketServer = wsApp, subscriptionStatus = mProvider}+  combinedAppWithProviders cfg m deps defaultServerProviders {webSocketServer = wsApp, subscriptionStatus = mProvider} -{- | Mountable composition. Respect the WebSocket switch before upgrade dispatch.-Normalize only the dispatch copy's raw path; keep its original query string.--}+-- | Mountable composition. Respect the WebSocket switch before upgrade dispatch.+-- Normalize only the dispatch copy's raw path; keep its original query string. combinedAppWithProviders :: MetricsServerConfig -> KirokuMetrics -> [DependencyCheck] -> ServerProviders -> Application combinedAppWithProviders cfg m deps providers = corsMiddleware cfg.cors dispatch   where     http = httpAppWithProviders cfg m deps providers     dispatch req respond-        | cfg.enableWebSocket =-            let relativePath = LBS.toStrict $ toLazyByteString $ encodePathSegments (pathInfo req)-             in WaiWS.websocketsOr WS.defaultConnectionOptions providers.webSocketServer http (req{rawPathInfo = relativePath}) respond-        | otherwise = http req respond+      | cfg.enableWebSocket =+          let relativePath = LBS.toStrict $ toLazyByteString $ encodePathSegments (pathInfo req)+           in WaiWS.websocketsOr WS.defaultConnectionOptions providers.webSocketServer http (req {rawPathInfo = relativePath}) respond+      | otherwise = http req respond  stubWebSocketApp :: WS.ServerApp stubWebSocketApp pending = WS.rejectRequest pending "WebSocket endpoint not yet implemented"  -- | Legacy unwrapped HTTP binding. httpApp :: MetricsServerConfig -> KirokuMetrics -> [DependencyCheck] -> Maybe SubscriptionStatusProvider -> Application-httpApp cfg m deps mProvider = httpAppWithProviders cfg m deps defaultServerProviders{subscriptionStatus = mProvider}+httpApp cfg m deps mProvider = httpAppWithProviders cfg m deps defaultServerProviders {subscriptionStatus = mProvider}  -- | Unwrapped HTTP router, matching paths relative to the host's mount. httpAppWithProviders :: MetricsServerConfig -> KirokuMetrics -> [DependencyCheck] -> ServerProviders -> Application@@ -194,57 +189,57 @@   where     discovery = capabilitiesApp (capabilitiesFor cfg (providerPresence providers))     dispatch req respond =-        case pathInfo req of-            ["capabilities"] -> discovery req respond-            ["metrics", "prometheus"] | cfg.enablePrometheus -> prometheusApp m req respond-            ["metrics"] | cfg.enableJSON -> jsonApp m req respond-            ["metrics", _] | cfg.enableJSON -> jsonApp m req respond-            prefix : _ | prefix `elem` ["streams", "categories", "events"] -> browseRoute-            ["subscription-checkpoints"] -> checkpointsRoute-            ["subscriptions", _, "dead-letters"] -> deadLettersRoute-            ["subscriptions"] -> subscriptionsRoute-            ["subscriptions", _] -> subscriptionsRoute-            ["health"] | cfg.enableJSON -> do-                (readiness, snap) <- checkDetailedHealth cfg m deps-                respond $-                    jsonResponse-                        (statusFor readiness.ready)-                        (encode (object ["status" .= readiness, "metrics" .= snap]))-            ["health", "live"] | cfg.enableJSON -> do-                liveness <- checkLiveness cfg m-                respond (jsonResponse (statusFor liveness.alive) (encode liveness))-            ["health", "ready"] | cfg.enableJSON -> do-                readiness <- checkReadiness cfg m deps-                respond (jsonResponse (statusFor readiness.ready) (encode readiness))-            ["ws"]-                | cfg.enableWebSocket ->-                    respond $-                        jsonResponse-                            status404-                            (encode (object ["error" .= ("WebSocket endpoint - use ws:// protocol" :: Text)]))-            _ ->-                respond (jsonResponse status404 (encode (object ["error" .= ("Not found" :: Text)])))+      case pathInfo req of+        ["capabilities"] -> discovery req respond+        ["metrics", "prometheus"] | cfg.enablePrometheus -> prometheusApp m req respond+        ["metrics"] | cfg.enableJSON -> jsonApp m req respond+        ["metrics", _] | cfg.enableJSON -> jsonApp m req respond+        prefix : _ | prefix `elem` ["streams", "categories", "events"] -> browseRoute+        ["subscription-checkpoints"] -> checkpointsRoute+        ["subscriptions", _, "dead-letters"] -> deadLettersRoute+        ["subscriptions"] -> subscriptionsRoute+        ["subscriptions", _] -> subscriptionsRoute+        ["health"] | cfg.enableJSON -> do+          (readiness, snap) <- checkDetailedHealth cfg m deps+          respond $+            jsonResponse+              (statusFor readiness.ready)+              (encode (object ["status" .= readiness, "metrics" .= snap]))+        ["health", "live"] | cfg.enableJSON -> do+          liveness <- checkLiveness cfg m+          respond (jsonResponse (statusFor liveness.alive) (encode liveness))+        ["health", "ready"] | cfg.enableJSON -> do+          readiness <- checkReadiness cfg m deps+          respond (jsonResponse (statusFor readiness.ready) (encode readiness))+        ["ws"]+          | cfg.enableWebSocket ->+              respond $+                jsonResponse+                  status404+                  (encode (object ["error" .= ("WebSocket endpoint - use ws:// protocol" :: Text)]))+        _ ->+          respond (jsonResponse status404 (encode (object ["error" .= ("Not found" :: Text)])))       where         statusFor ok = if ok then status200 else status503         deadLettersRoute = maybe deadLettersNotConfiguredApp deadLettersApp providers.deadLetters req respond         browseRoute = maybe browseNotConfiguredApp browseApp providers.storeBrowsing req respond         checkpointsRoute = case providers.checkpointInventory of-            Just provider -> checkpointsApp provider req respond-            Nothing -> checkpointsNotConfiguredApp req respond+          Just provider -> checkpointsApp provider req respond+          Nothing -> checkpointsNotConfiguredApp req respond         subscriptionsRoute = case providers.subscriptionStatus of-            Just provider -> subscriptionsApp provider req respond-            Nothing ->-                respond $-                    jsonResponse-                        status404-                        (encode (object ["error" .= ("subscription status not configured" :: Text)]))+          Just provider -> subscriptionsApp provider req respond+          Nothing ->+            respond $+              jsonResponse+                status404+                (encode (object ["error" .= ("subscription status not configured" :: Text)]))  -- | Pure wiring summary; evaluating it never invokes a provider. providerPresence :: ServerProviders -> ProviderPresence providerPresence providers =-    ProviderPresence-        (isJust providers.subscriptionStatus)-        (isJust providers.checkpointInventory)-        (isJust providers.storeBrowsing)-        (isJust providers.deadLetters)-        providers.webSocketChannels+  ProviderPresence+    (isJust providers.subscriptionStatus)+    (isJust providers.checkpointInventory)+    (isJust providers.storeBrowsing)+    (isJust providers.deadLetters)+    providers.webSocketChannels
src/Kiroku/Metrics/Standalone.hs view
@@ -1,22 +1,31 @@+{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE NoFieldSelectors #-}  -- | Open a store and serve its inspection surface without a Haskell host program.-module Kiroku.Metrics.Standalone (-    InspectOptions (..),+module Kiroku.Metrics.Standalone+  ( InspectOptions (..),     inspectOptionsParser,     inspectParserInfo,     InspectRuntime (..),     resolveInspectOptions,     InspectHooks (..),     runInspect,+    InspectFailure (..),+    InspectBindError (..),+    classifyInspectFailure,+    renderInspectFailure,     renderStartupBanner,-) where+  )+where  import Control.Applicative (many, optional, (<|>)) import Control.Concurrent.STM (STM, TVar, atomically, newTVarIO, readTVar, writeTVar)+import Control.Exception (Exception, IOException, SomeException, catch, fromException, throwIO)+import Data.IORef (newIORef, readIORef, writeIORef) import Data.IntMap.Strict qualified as IntMap import Data.Text (Text) import Data.Text qualified as T+import Hasql.Pool qualified as Pool import Options.Applicative qualified as O  import Kiroku.Metrics.Capabilities@@ -27,65 +36,68 @@ import Kiroku.Metrics.Health (postgresPing) import Kiroku.Metrics.Server import Kiroku.Store qualified as Store+import Kiroku.Store.Error (StoreError (..), mapGenericUsageError)+import Kiroku.Store.Notification (NotifierStartError (..)) import Kiroku.Store.Subscription.EventPublisher (EventPublisher (..), publisherPosition)  -- No Show instances: a database URL can contain credentials. data InspectOptions = InspectOptions-    { databaseUrl :: !(Maybe Text)-    , schema :: !(Maybe Text)-    , poolSize :: !(Maybe Int)-    , port :: !(Maybe Int)-    , corsOrigins :: ![Text]-    , corsAllowCredentials :: !(Maybe Bool)-    , wsMaxConnections :: !(Maybe Int)-    }-    deriving stock (Eq)+  { databaseUrl :: !(Maybe Text),+    schema :: !(Maybe Text),+    poolSize :: !(Maybe Int),+    port :: !(Maybe Int),+    corsOrigins :: ![Text],+    corsAllowCredentials :: !(Maybe Bool),+    wsMaxConnections :: !(Maybe Int)+  }+  deriving stock (Eq)  data InspectRuntime = InspectRuntime-    { databaseUrl :: !Text-    , schema :: !Text-    , poolSize :: !Int-    , port :: !Int-    , cors :: !CorsPolicy-    , wsMaxConnections :: !Int-    }-    deriving stock (Eq)+  { databaseUrl :: !Text,+    schema :: !Text,+    poolSize :: !Int,+    port :: !Int,+    cors :: !CorsPolicy,+    wsMaxConnections :: !Int+  }+  deriving stock (Eq)  data InspectHooks = InspectHooks-    { onListening :: !(Int -> Capabilities -> IO ())-    -- ^ Called once after successful binding, with the actual port and wiring.-    , waitForShutdown :: !(IO ())-    }+  { -- | Called once after successful binding, with the actual port and wiring.+    onListening :: !(Int -> Capabilities -> IO ()),+    waitForShutdown :: !(IO ())+  }  inspectOptionsParser :: O.Parser InspectOptions inspectOptionsParser =-    InspectOptions-        <$> optional (textOption "database-url" "URL" "Database connection string (or DATABASE_URL)")-        <*> optional (textOption "schema" "NAME" "Migrated schema (default kiroku)")-        <*> optional (numberOption "pool-size" 1 intMaximum "Connection pool size (default 10)")-        <*> optional (numberOption "port" 0 65535 "Listen port (default 9091; 0 selects a free port)")-        <*> many (textOption "cors-origin" "ORIGIN" "Allowed HTTP(S) browser origin; repeatable")-        <*> optional-            ( O.flag' True (O.long "cors-allow-credentials" <> O.help "Allow credentials for explicit origins")-                <|> O.flag' False (O.long "no-cors-allow-credentials" <> O.help "Disable credentials, overriding the environment")-            )-        <*> optional (numberOption "ws-max-connections" 1 intMaximum "Maximum WebSocket connections (default 100)")+  InspectOptions+    <$> optional (textOption "database-url" "URL" "Database connection string (or DATABASE_URL)")+    <*> optional (textOption "schema" "NAME" "Migrated schema (default kiroku)")+    <*> optional (numberOption "pool-size" 1 intMaximum "Connection pool size (default 10)")+    <*> optional (numberOption "port" 0 65535 "Listen port (default 9091, all interfaces; 0 selects a loopback-only free port)")+    <*> many (textOption "cors-origin" "ORIGIN" "Allowed HTTP(S) browser origin; repeatable")+    <*> optional+      ( O.flag' True (O.long "cors-allow-credentials" <> O.help "Allow credentials for explicit origins")+          <|> O.flag' False (O.long "no-cors-allow-credentials" <> O.help "Disable credentials, overriding the environment")+      )+    <*> optional (numberOption "ws-max-connections" 1 intMaximum "Maximum WebSocket connections (default 100)")   where     textOption name metavar help = O.strOption (O.long name <> O.metavar metavar <> O.help help)     numberOption name lower upper help =-        O.option-            (O.eitherReader (either (Left . T.unpack) Right . boundedDecimal lower upper . T.pack))-            (O.long name <> O.metavar "N" <> O.help help)+      O.option+        (O.eitherReader (either (Left . T.unpack) Right . boundedDecimal lower upper . T.pack))+        (O.long name <> O.metavar "N" <> O.help help)  inspectParserInfo :: O.ParserInfo InspectOptions inspectParserInfo =-    O.info-        (inspectOptionsParser O.<**> O.helper)-        ( O.failureCode 2-            <> O.fullDesc-            <> O.header "kiroku-inspect - standalone inspection server"-            <> O.progDesc "Serve the read-only inspection API from a migrated Kiroku database. Runs no subscriptions."-        )+  O.info+    (inspectOptionsParser O.<**> O.helper)+    ( O.failureCode 2+        <> O.fullDesc+        <> O.header "kiroku-inspect - standalone inspection server"+        <> O.footer "First SIGINT/SIGTERM shuts down gracefully; a second signal forces immediate exit 130."+        <> O.progDesc "Serve the read-only inspection API from a migrated Kiroku database. Runs no subscriptions."+    )  intMaximum :: Integer intMaximum = toInteger (maxBound :: Int)@@ -93,76 +105,122 @@ -- Parse into Integer and stop growing at the upper bound, before narrowing to Int. boundedDecimal :: Integer -> Integer -> Text -> Either Text Int boundedDecimal lower upper value-    | T.null value || not (T.all (\c -> c >= '0' && c <= '9') value) = Left "expected ASCII decimal digits"-    | otherwise = do-        n <- T.foldl' step (Right 0) value-        if n < lower then Left "value is below the allowed range" else Right (fromInteger n)+  | T.null value || not (T.all (\c -> c >= '0' && c <= '9') value) = Left "expected ASCII decimal digits"+  | otherwise = do+      n <- T.foldl' step (Right 0) value+      if n < lower then Left "value is below the allowed range" else Right (fromInteger n)   where     step result c = do-        n <- result-        let next = n * 10 + toInteger (fromEnum c - fromEnum '0')-        if next > upper then Left "value exceeds the allowed range" else Right next+      n <- result+      let next = n * 10 + toInteger (fromEnum c - fromEnum '0')+      if next > upper then Left "value exceeds the allowed range" else Right next  resolveInspectOptions :: [(String, String)] -> InspectOptions -> Either Text InspectRuntime resolveInspectOptions env opts = do-    url <- maybe (Left "kiroku-inspect: no database; pass --database-url or set DATABASE_URL (a libpq URI such as postgresql://user@host/db)") Right (opts.databaseUrl <|> variable "DATABASE_URL")-    nonempty "--database-url" url-    let schemaName = maybe "kiroku" id (opts.schema <|> variable "KIROKU_INSPECT_SCHEMA")-    nonempty "--schema" schemaName-    pool <- number opts.poolSize "KIROKU_INSPECT_POOL_SIZE" 1 intMaximum (Store.defaultConnectionSettings url).poolSize-    listenPort <- number opts.port "KIROKU_INSPECT_PORT" 0 65535 defaultConfig.port-    maxConnections <- number opts.wsMaxConnections "KIROKU_INSPECT_WS_MAX_CONNECTIONS" 1 intMaximum defaultConfig.wsMaxConnections-    credentials <- case opts.corsAllowCredentials of-        Just b -> Right b-        Nothing -> case variable "KIROKU_INSPECT_CORS_ALLOW_CREDENTIALS" of-            Nothing -> Right False-            Just "true" -> Right True-            Just "1" -> Right True-            Just "false" -> Right False-            Just "0" -> Right False-            Just _ -> Left "kiroku-inspect: KIROKU_INSPECT_CORS_ALLOW_CREDENTIALS must be true, false, 1 or 0"-    let originTexts = if null opts.corsOrigins then maybe [] (map T.strip . T.splitOn ",") (variable "KIROKU_INSPECT_CORS_ORIGINS") else opts.corsOrigins-    origins <--        traverse-            ( \origin -> case allowedOrigin origin of-                Right o -> Right o-                Left WildcardOrigin -> Left "kiroku-inspect: wildcard '*' is not an allowed CORS origin"-                Left _ -> Left "kiroku-inspect: invalid CORS origin; use an explicit HTTP(S) origin"-            )-            originTexts-    let policy = if null origins then corsDisabled else (corsAllowOrigins origins){allowCredentials = credentials}-    pure (InspectRuntime url schemaName pool listenPort policy maxConnections)+  url <- maybe (Left "kiroku-inspect: no database; pass --database-url or set DATABASE_URL (a libpq URI such as postgresql://user@host/db)") Right (opts.databaseUrl <|> variable "DATABASE_URL")+  nonempty "--database-url" url+  let schemaName = maybe "kiroku" id (opts.schema <|> variable "KIROKU_INSPECT_SCHEMA")+  nonempty "--schema" schemaName+  pool <- number opts.poolSize "KIROKU_INSPECT_POOL_SIZE" 1 intMaximum (Store.defaultConnectionSettings url).poolSize+  listenPort <- number opts.port "KIROKU_INSPECT_PORT" 0 65535 defaultConfig.port+  maxConnections <- number opts.wsMaxConnections "KIROKU_INSPECT_WS_MAX_CONNECTIONS" 1 intMaximum defaultConfig.wsMaxConnections+  credentials <- case opts.corsAllowCredentials of+    Just b -> Right b+    Nothing -> case variable "KIROKU_INSPECT_CORS_ALLOW_CREDENTIALS" of+      Nothing -> Right False+      Just "true" -> Right True+      Just "1" -> Right True+      Just "false" -> Right False+      Just "0" -> Right False+      Just _ -> Left "kiroku-inspect: KIROKU_INSPECT_CORS_ALLOW_CREDENTIALS must be true, false, 1 or 0"+  let originTexts = if null opts.corsOrigins then maybe [] (map T.strip . T.splitOn ",") (variable "KIROKU_INSPECT_CORS_ORIGINS") else opts.corsOrigins+  origins <-+    traverse+      ( \origin -> case allowedOrigin origin of+          Right o -> Right o+          Left WildcardOrigin -> Left "kiroku-inspect: wildcard '*' is not an allowed CORS origin"+          Left _ -> Left "kiroku-inspect: invalid CORS origin; use an explicit HTTP(S) origin"+      )+      originTexts+  let policy = if null origins then corsDisabled else (corsAllowOrigins origins) {allowCredentials = credentials}+  pure (InspectRuntime url schemaName pool listenPort policy maxConnections)   where     variable name = case T.pack <$> lookup name env of-        Just value | not (T.null value) -> Just value-        _ -> Nothing+      Just value | not (T.null value) -> Just value+      _ -> Nothing     nonempty name value = if T.null (T.strip value) then Left ("kiroku-inspect: " <> name <> " must not be empty") else Right ()     number explicit name lower upper fallback = case explicit of-        Just value | toInteger value >= lower && toInteger value <= upper -> Right value-        Just _ -> Left ("kiroku-inspect: " <> T.pack name <> " flag is outside the allowed range")-        Nothing -> case variable name of-            Nothing -> Right fallback-            Just value -> either (\message -> Left ("kiroku-inspect: " <> T.pack name <> ": " <> message)) Right (boundedDecimal lower upper value)+      Just value | toInteger value >= lower && toInteger value <= upper -> Right value+      Just _ -> Left ("kiroku-inspect: " <> T.pack name <> " flag is outside the allowed range")+      Nothing -> case variable name of+        Nothing -> Right fallback+        Just value -> either (\message -> Left ("kiroku-inspect: " <> T.pack name <> ": " <> message)) Right (boundedDecimal lower upper value)  runInspect :: InspectHooks -> InspectRuntime -> IO () runInspect hooks rt = do-    storeVar <- newTVarIO Nothing-    metrics <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)-    let settings =-            (Store.defaultConnectionSettings rt.databaseUrl)-                { Store.schema = rt.schema-                , Store.poolSize = rt.poolSize-                , Store.eventHandler = Just (metricsEventHandler metrics Nothing)-                , Store.observationHandler = Just (metricsObservationHandler metrics Nothing)-                }-        cfg = defaultConfig{Config.port = rt.port, Config.cors = rt.cors, Config.wsMaxConnections = rt.wsMaxConnections}-    Store.withStore settings $ \store -> do-        atomically (writeTVar storeVar (Just store))-        providers <- storeServerProviders cfg metrics store-        withMetricsServerWithProviders cfg metrics [postgresPing store] providers $ \server -> do-            hooks.onListening server.serverPort (capabilitiesFor cfg (providerPresence providers))-            hooks.waitForShutdown+  storeVar <- newTVarIO Nothing+  metrics <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)+  let settings =+        (Store.defaultConnectionSettings rt.databaseUrl)+          { Store.schema = rt.schema,+            Store.poolSize = rt.poolSize,+            Store.eventHandler = Just (metricsEventHandler metrics Nothing),+            Store.observationHandler = Just (metricsObservationHandler metrics Nothing)+          }+      cfg = defaultConfig {Config.port = rt.port, Config.cors = rt.cors, Config.wsMaxConnections = rt.wsMaxConnections}+  Store.withStore settings $ \store -> do+    atomically (writeTVar storeVar (Just store))+    providers <- storeServerProviders cfg metrics store+    listening <- newIORef False+    let serve = withMetricsServerWithProviders cfg metrics [postgresPing store] providers $ \server -> do+          writeIORef listening True+          hooks.onListening server.serverPort (capabilitiesFor cfg (providerPresence providers))+          hooks.waitForShutdown+    serve `catch` \err -> do+      started <- readIORef listening+      if started then throwIO (err :: IOException) else throwIO (InspectBindError err) +-- | Only listener setup IO failures carry this marker. Store acquisition and+-- user hooks remain outside that boundary; asynchronous exceptions pass through.+newtype InspectBindError = InspectBindError IOException+  deriving stock (Show)+  deriving anyclass (Exception)++-- | Sanitized diagnostic classes, independent of driver messages and SQL text.+data InspectFailure = BindFailure | DatabaseFailure | SchemaFailure | OtherFailure+  deriving stock (Eq, Show)++classifyInspectFailure :: SomeException -> InspectFailure+classifyInspectFailure err+  | Just (_ :: InspectBindError) <- fromException err = BindFailure+  | Just notifier <- fromException err = case notifier of+      NotifierConnectError _ -> DatabaseFailure+      NotifierListenError nested -> case fromException nested :: Maybe Pool.UsageError of+        Just usage -> classifyStoreFailure (mapGenericUsageError usage)+        Nothing -> maybe DatabaseFailure classifyStoreFailure (fromException nested)+  | Just usage <- fromException err = classifyStoreFailure (mapGenericUsageError usage)+  | Just storeError <- fromException err = classifyStoreFailure storeError+  | otherwise = OtherFailure++classifyStoreFailure :: StoreError -> InspectFailure+classifyStoreFailure = \case+  UnexpectedServerError code _ | code == "42P01" || code == "3F000" -> SchemaFailure+  UnexpectedServerError _ _ -> DatabaseFailure+  ConnectionError _ -> DatabaseFailure+  ConnectionLost _ -> DatabaseFailure+  PoolAcquisitionTimeout -> DatabaseFailure+  TransientTransactionFailure _ _ -> DatabaseFailure+  _ -> OtherFailure++renderInspectFailure :: InspectFailure -> Text+renderInspectFailure failure = "kiroku-inspect: " <> label <> " failure (details redacted)"+  where+    label = case failure of+      BindFailure -> "bind"+      DatabaseFailure -> "database"+      SchemaFailure -> "schema"+      OtherFailure -> "other"+ readPosition :: TVar (Maybe Store.KirokuStore) -> STM Store.GlobalPosition readPosition storeVar = readTVar storeVar >>= maybe (pure (Store.GlobalPosition 0)) (publisherPosition . (.publisher)) @@ -172,21 +230,21 @@ -- | Never includes the database URL. The port is the actual bound port. renderStartupBanner :: InspectRuntime -> Int -> Capabilities -> [Text] renderStartupBanner rt boundPort caps =-    [ "kiroku-inspect: connected to schema " <> T.pack (show rt.schema) <> "; listening on port " <> T.pack (show boundPort)-    , "kiroku-inspect: routes browse="-        <> enabled caps.routes.browse-        <> " subscriptions_checkpoints="-        <> enabled caps.routes.subscriptionsCheckpoints-        <> " dead_letters="-        <> enabled caps.routes.deadLetters-        <> " subscriptions_live="-        <> enabled caps.routes.subscriptionsLive-        <> " websocket_events="-        <> enabled caps.routes.websocketEvents-        <> " cors="-        <> enabled caps.corsIsEnabled-    , "kiroku-inspect: this process runs no subscriptions; /subscriptions, /metrics, and /health reflect only this process"-    ]+  [ "kiroku-inspect: connected to schema " <> T.pack (show rt.schema) <> "; listening on port " <> T.pack (show boundPort),+    "kiroku-inspect: routes browse="+      <> enabled caps.routes.browse+      <> " subscriptions_checkpoints="+      <> enabled caps.routes.subscriptionsCheckpoints+      <> " dead_letters="+      <> enabled caps.routes.deadLetters+      <> " subscriptions_live="+      <> enabled caps.routes.subscriptionsLive+      <> " websocket_events="+      <> enabled caps.routes.websocketEvents+      <> " cors="+      <> enabled caps.corsIsEnabled,+    "kiroku-inspect: this process runs no subscriptions; /subscriptions, /metrics, and /health reflect only this process"+  ]   where     enabled True = "on"     enabled False = "off"
src/Kiroku/Metrics/Subscriptions.hs view
@@ -1,22 +1,22 @@-{- | The @GET /subscriptions@ HTTP endpoint (EP-5 / IP-5).--Serves a worker's __live subscription registry__ as JSON — each running-subscription's name, consumer-group member, current FSM phase, and current global-cursor. The data comes from a caller-supplied 'SubscriptionStatusProvider' closure-(the server stays store-agnostic, exactly as EP-2 keeps 'KirokuStore' out of its-signature). The canonical provider, 'storeSubscriptionStatus', reads the live-registry through the public 'Kiroku.Store.Subscription.subscriptionStates'.--The wire shape is the CLI's 'SubscriptionStatusRow' JSON (a JSON array of-@{subscription, member, phase, global_position}@), reused via a @build-depends@ on-@kiroku-cli@ so the server encoder and the CLI remote-client decoder share one-codec — the IP-5 contract.--}-module Kiroku.Metrics.Subscriptions (-    SubscriptionStatusProvider,+-- | The @GET /subscriptions@ HTTP endpoint (EP-5 / IP-5).+--+-- Serves a worker's __live subscription registry__ as JSON — each running+-- subscription's name, consumer-group member, current FSM phase, and current global+-- cursor. The data comes from a caller-supplied 'SubscriptionStatusProvider' closure+-- (the server stays store-agnostic, exactly as EP-2 keeps 'KirokuStore' out of its+-- signature). The canonical provider, 'storeSubscriptionStatus', reads the live+-- registry through the public 'Kiroku.Store.Subscription.subscriptionStates'.+--+-- The wire shape is the CLI's 'SubscriptionStatusRow' JSON (a JSON array of+-- @{subscription, member, phase, global_position}@), reused via a @build-depends@ on+-- @kiroku-cli@ so the server encoder and the CLI remote-client decoder share one+-- codec — the IP-5 contract.+module Kiroku.Metrics.Subscriptions+  ( SubscriptionStatusProvider,     storeSubscriptionStatus,     subscriptionsApp,-) where+  )+where  import Data.Aeson (encode, object, (.=)) import Data.Text (Text)@@ -28,34 +28,31 @@ import Kiroku.Store (KirokuStore) import Kiroku.Store.Subscription (subscriptionStates) -{- | A closure the caller supplies; it reads the live subscription registry on-demand and returns the rows to serve.--}+-- | A closure the caller supplies; it reads the live subscription registry on+-- demand and returns the rows to serve. type SubscriptionStatusProvider = IO [SubscriptionStatusRow] -{- | The canonical provider, built by a caller who owns the 'KirokuStore':-reads the live registry through 'subscriptionStates' and maps it to rows.--}+-- | The canonical provider, built by a caller who owns the 'KirokuStore':+-- reads the live registry through 'subscriptionStates' and maps it to rows. storeSubscriptionStatus :: KirokuStore -> SubscriptionStatusProvider storeSubscriptionStatus store = subscriptionStatusRows <$> subscriptionStates store -{- | WAI app for the subscription-status endpoint. Routes:--  * @GET /subscriptions@ — 200, JSON array of all rows.-  * @GET /subscriptions/\<name\>@ — 200, JSON array of rows for that name-    (possibly empty).--Any other path returns a 404 with body @{"error":"Not found"}@. This app is mounted by-'Kiroku.Metrics.Server.httpApp' only when a provider is configured.--}+-- | WAI app for the subscription-status endpoint. Routes:+--+--   * @GET /subscriptions@ — 200, JSON array of all rows.+--   * @GET /subscriptions/\<name\>@ — 200, JSON array of rows for that name+--     (possibly empty).+--+-- Any other path returns a 404 with body @{"error":"Not found"}@. This app is mounted by+-- 'Kiroku.Metrics.Server.httpApp' only when a provider is configured. subscriptionsApp :: SubscriptionStatusProvider -> Application subscriptionsApp provider req respond =-    case pathInfo req of-        ["subscriptions"] -> do-            rows <- provider-            respond (jsonResponse status200 (encode rows))-        ["subscriptions", name] -> do-            rows <- provider-            let filtered = filter (\row -> row.subscription == name) rows-            respond (jsonResponse status200 (encode filtered))-        _ -> respond (jsonResponse status404 (encode (object ["error" .= ("Not found" :: Text)])))+  case pathInfo req of+    ["subscriptions"] -> do+      rows <- provider+      respond (jsonResponse status200 (encode rows))+    ["subscriptions", name] -> do+      rows <- provider+      let filtered = filter (\row -> row.subscription == name) rows+      respond (jsonResponse status200 (encode filtered))+    _ -> respond (jsonResponse status404 (encode (object ["error" .= ("Not found" :: Text)])))
src/Kiroku/Metrics/Types.hs view
@@ -1,30 +1,30 @@-{- | Immutable, JSON-encodable snapshot of a running Kiroku store's operational-metrics.--A 'MetricsSnapshot' is produced by-'Kiroku.Metrics.Collector.snapshotMetrics' and is the single source of truth-every endpoint renders. It carries three parts:--* 'StoreGauges' — point-in-time gauges read from the live store handle at-  snapshot time: the gap-free global position (which doubles as the total-  number of events appended store-wide and the high-water mark), the active-  subscriber count, and pool connection gauges/counters.-* 'LifecycleCounters' — monotonic counters accumulated from the store's-  'Kiroku.Store.Observability.KirokuEvent' callback stream.-* a per-subscription map ('SubscriptionMetrics') keyed by subscription name,-  carrying each subscription's last-known position, derived lag, database-error-  count, and last stop reason.--The 'ToJSON' instances are written by hand (not derived) so the wire shape is-stable and documented for downstream consumers (EP-2's JSON/Prometheus-renderers and EP-4's user guide).--}-module Kiroku.Metrics.Types (-    MetricsSnapshot (..),+-- | Immutable, JSON-encodable snapshot of a running Kiroku store's operational+-- metrics.+--+-- A 'MetricsSnapshot' is produced by+-- 'Kiroku.Metrics.Collector.snapshotMetrics' and is the single source of truth+-- every endpoint renders. It carries three parts:+--+-- * 'StoreGauges' — point-in-time gauges read from the live store handle at+--   snapshot time: the gap-free global position (which doubles as the total+--   number of events appended store-wide and the high-water mark), the active+--   subscriber count, and pool connection gauges/counters.+-- * 'LifecycleCounters' — monotonic counters accumulated from the store's+--   'Kiroku.Store.Observability.KirokuEvent' callback stream.+-- * a per-subscription map ('SubscriptionMetrics') keyed by subscription name,+--   carrying each subscription's last-known position, derived lag, database-error+--   count, and last stop reason.+--+-- The 'ToJSON' instances are written by hand (not derived) so the wire shape is+-- stable and documented for downstream consumers (EP-2's JSON/Prometheus+-- renderers and EP-4's user guide).+module Kiroku.Metrics.Types+  ( MetricsSnapshot (..),     StoreGauges (..),     LifecycleCounters (..),     SubscriptionMetrics (..),-) where+  )+where  import Data.Aeson (ToJSON (..), object, (.=)) import Data.Int (Int64)@@ -33,133 +33,132 @@  -- | A coherent point-in-time view of the store's metrics. data MetricsSnapshot = MetricsSnapshot-    { store :: !StoreGauges-    , counters :: !LifecycleCounters-    , subscriptions :: !(Map Text SubscriptionMetrics)-    -- ^ Keyed by 'Kiroku.Store.Subscription.Types.SubscriptionName' text.-    }-    deriving stock (Eq, Show)+  { store :: !StoreGauges,+    counters :: !LifecycleCounters,+    -- | Keyed by 'Kiroku.Store.Subscription.Types.SubscriptionName' text.+    subscriptions :: !(Map Text SubscriptionMetrics)+  }+  deriving stock (Eq, Show)  -- | Gauges read from the live store handle at snapshot time. data StoreGauges = StoreGauges-    { globalPosition :: !Int64-    -- ^ Total events appended store-wide (gap-free) == high-water mark.-    , activeSubscribers :: !Int-    , poolConnecting :: !Int-    , poolReady :: !Int-    , poolInUse :: !Int-    , poolEstablishedTotal :: !Int64-    , poolTerminatedTotal :: !Int64-    }-    deriving stock (Eq, Show)--{- | Monotonic counters accumulated from the 'KirokuEvent' callback stream.+  { -- | Total events appended store-wide (gap-free) == high-water mark.+    globalPosition :: !Int64,+    activeSubscribers :: !Int,+    poolConnecting :: !Int,+    poolReady :: !Int,+    poolInUse :: !Int,+    poolEstablishedTotal :: !Int64,+    poolTerminatedTotal :: !Int64+  }+  deriving stock (Eq, Show) -The constructor set the store emits is richer than counters here name one-to-one:-subscription lifecycle events ('Started', 'CaughtUp', 'Paused', 'Resumed',-'Reconnecting', 'Retrying', 'DeadLettered', 'Stopped' by reason), per-batch-delivery, live fetches, per-phase database errors, notifier reconnects,-publisher pool and loop errors, and hard deletes.--}+-- | Monotonic counters accumulated from the 'KirokuEvent' callback stream.+--+-- The constructor set the store emits is richer than counters here name one-to-one:+-- subscription lifecycle events ('Started', 'CaughtUp', 'Paused', 'Resumed',+-- 'Reconnecting', 'Retrying', 'DeadLettered', 'Stopped' by reason), per-batch+-- delivery, live fetches, per-phase database errors, notifier reconnects,+-- publisher pool and loop errors, and hard deletes. data LifecycleCounters = LifecycleCounters-    { notifierReconnecting :: !Int64-    , notifierReconnected :: !Int64-    , publisherPoolErrors :: !Int64-    , publisherLoopErrors :: !Int64-    , publisherDecodeFailures :: !Int64-    , subscriptionHandlerStalls :: !Int64-    , subscriptionDbErrorsLoad :: !Int64-    , subscriptionDbErrorsFetch :: !Int64-    , subscriptionDbErrorsSave :: !Int64-    , subscriptionsStarted :: !Int64-    , subscriptionsCaughtUp :: !Int64-    , subscriptionsPaused :: !Int64-    , subscriptionsResumed :: !Int64-    , subscriptionsReconnecting :: !Int64-    , subscriptionsRetrying :: !Int64-    , subscriptionsDeadLettered :: !Int64-    , subscriptionsStoppedHandler :: !Int64-    , subscriptionsStoppedCancelled :: !Int64-    , subscriptionsStoppedOverflow :: !Int64-    , subscriptionsStoppedCrashed :: !Int64-    , subscriptionsStoppedUndecodable :: !Int64-    , liveFetches :: !Int64-    -- ^ Count of live-mode DB fetches ('KirokuEventSubscriptionFetched').-    , batchesDelivered :: !Int64-    -- ^ Count of non-empty batches delivered ('KirokuEventSubscriptionDelivered').-    , eventsDelivered :: !Int64-    -- ^ Sum of batch row counts across all deliveries.-    , hardDeletesIssued :: !Int64-    }-    deriving stock (Eq, Show)+  { notifierReconnecting :: !Int64,+    notifierReconnected :: !Int64,+    publisherPoolErrors :: !Int64,+    publisherLoopErrors :: !Int64,+    publisherDecodeFailures :: !Int64,+    subscriptionHandlerStalls :: !Int64,+    subscriptionDbErrorsLoad :: !Int64,+    subscriptionDbErrorsFetch :: !Int64,+    subscriptionDbErrorsSave :: !Int64,+    subscriptionsStarted :: !Int64,+    subscriptionsCaughtUp :: !Int64,+    subscriptionsPaused :: !Int64,+    subscriptionsResumed :: !Int64,+    subscriptionsReconnecting :: !Int64,+    subscriptionsRetrying :: !Int64,+    subscriptionsDeadLettered :: !Int64,+    subscriptionsStoppedHandler :: !Int64,+    subscriptionsStoppedCancelled :: !Int64,+    subscriptionsStoppedOverflow :: !Int64,+    subscriptionsStoppedCrashed :: !Int64,+    subscriptionsStoppedUndecodable :: !Int64,+    -- | Count of live-mode DB fetches ('KirokuEventSubscriptionFetched').+    liveFetches :: !Int64,+    -- | Count of non-empty batches delivered ('KirokuEventSubscriptionDelivered').+    batchesDelivered :: !Int64,+    -- | Sum of batch row counts across all deliveries.+    eventsDelivered :: !Int64,+    hardDeletesIssued :: !Int64+  }+  deriving stock (Eq, Show)  -- | Per-subscription accumulated metrics. data SubscriptionMetrics = SubscriptionMetrics-    { lastKnownPosition :: !Int64-    -- ^ Most recent position seen at a lifecycle event (lower bound on true position).-    , lag :: !Int64-    -- ^ @max 0 (globalPosition - lastKnownPosition)@ (upper bound on true lag).-    , dbErrorCount :: !Int64-    , lastStopReason :: !(Maybe Text)-    -- ^ @"handler" | "cancelled" | "overflow" | "crashed"@.-    }-    deriving stock (Eq, Show)+  { -- | Most recent position seen at a lifecycle event (lower bound on true position).+    lastKnownPosition :: !Int64,+    -- | @max 0 (globalPosition - lastKnownPosition)@ (upper bound on true lag).+    lag :: !Int64,+    dbErrorCount :: !Int64,+    -- | @"handler" | "cancelled" | "overflow" | "crashed"@.+    lastStopReason :: !(Maybe Text)+  }+  deriving stock (Eq, Show)  instance ToJSON MetricsSnapshot where-    toJSON s =-        object-            [ "store" .= s.store-            , "counters" .= s.counters-            , "subscriptions" .= s.subscriptions-            ]+  toJSON s =+    object+      [ "store" .= s.store,+        "counters" .= s.counters,+        "subscriptions" .= s.subscriptions+      ]  instance ToJSON StoreGauges where-    toJSON g =-        object-            [ "global_position" .= g.globalPosition-            , "active_subscribers" .= g.activeSubscribers-            , "pool_connecting" .= g.poolConnecting-            , "pool_ready" .= g.poolReady-            , "pool_in_use" .= g.poolInUse-            , "pool_established_total" .= g.poolEstablishedTotal-            , "pool_terminated_total" .= g.poolTerminatedTotal-            ]+  toJSON g =+    object+      [ "global_position" .= g.globalPosition,+        "active_subscribers" .= g.activeSubscribers,+        "pool_connecting" .= g.poolConnecting,+        "pool_ready" .= g.poolReady,+        "pool_in_use" .= g.poolInUse,+        "pool_established_total" .= g.poolEstablishedTotal,+        "pool_terminated_total" .= g.poolTerminatedTotal+      ]  instance ToJSON LifecycleCounters where-    toJSON c =-        object-            [ "notifier_reconnecting" .= c.notifierReconnecting-            , "notifier_reconnected" .= c.notifierReconnected-            , "publisher_pool_errors" .= c.publisherPoolErrors-            , "publisher_loop_errors" .= c.publisherLoopErrors-            , "publisher_decode_failures" .= c.publisherDecodeFailures-            , "subscription_handler_stalls" .= c.subscriptionHandlerStalls-            , "subscription_db_errors_load" .= c.subscriptionDbErrorsLoad-            , "subscription_db_errors_fetch" .= c.subscriptionDbErrorsFetch-            , "subscription_db_errors_save" .= c.subscriptionDbErrorsSave-            , "subscriptions_started" .= c.subscriptionsStarted-            , "subscriptions_caught_up" .= c.subscriptionsCaughtUp-            , "subscriptions_paused" .= c.subscriptionsPaused-            , "subscriptions_resumed" .= c.subscriptionsResumed-            , "subscriptions_reconnecting" .= c.subscriptionsReconnecting-            , "subscriptions_retrying" .= c.subscriptionsRetrying-            , "subscriptions_dead_lettered" .= c.subscriptionsDeadLettered-            , "subscriptions_stopped_handler" .= c.subscriptionsStoppedHandler-            , "subscriptions_stopped_cancelled" .= c.subscriptionsStoppedCancelled-            , "subscriptions_stopped_overflow" .= c.subscriptionsStoppedOverflow-            , "subscriptions_stopped_crashed" .= c.subscriptionsStoppedCrashed-            , "subscriptions_stopped_undecodable" .= c.subscriptionsStoppedUndecodable-            , "live_fetches" .= c.liveFetches-            , "batches_delivered" .= c.batchesDelivered-            , "events_delivered" .= c.eventsDelivered-            , "hard_deletes_issued" .= c.hardDeletesIssued-            ]+  toJSON c =+    object+      [ "notifier_reconnecting" .= c.notifierReconnecting,+        "notifier_reconnected" .= c.notifierReconnected,+        "publisher_pool_errors" .= c.publisherPoolErrors,+        "publisher_loop_errors" .= c.publisherLoopErrors,+        "publisher_decode_failures" .= c.publisherDecodeFailures,+        "subscription_handler_stalls" .= c.subscriptionHandlerStalls,+        "subscription_db_errors_load" .= c.subscriptionDbErrorsLoad,+        "subscription_db_errors_fetch" .= c.subscriptionDbErrorsFetch,+        "subscription_db_errors_save" .= c.subscriptionDbErrorsSave,+        "subscriptions_started" .= c.subscriptionsStarted,+        "subscriptions_caught_up" .= c.subscriptionsCaughtUp,+        "subscriptions_paused" .= c.subscriptionsPaused,+        "subscriptions_resumed" .= c.subscriptionsResumed,+        "subscriptions_reconnecting" .= c.subscriptionsReconnecting,+        "subscriptions_retrying" .= c.subscriptionsRetrying,+        "subscriptions_dead_lettered" .= c.subscriptionsDeadLettered,+        "subscriptions_stopped_handler" .= c.subscriptionsStoppedHandler,+        "subscriptions_stopped_cancelled" .= c.subscriptionsStoppedCancelled,+        "subscriptions_stopped_overflow" .= c.subscriptionsStoppedOverflow,+        "subscriptions_stopped_crashed" .= c.subscriptionsStoppedCrashed,+        "subscriptions_stopped_undecodable" .= c.subscriptionsStoppedUndecodable,+        "live_fetches" .= c.liveFetches,+        "batches_delivered" .= c.batchesDelivered,+        "events_delivered" .= c.eventsDelivered,+        "hard_deletes_issued" .= c.hardDeletesIssued+      ]  instance ToJSON SubscriptionMetrics where-    toJSON m =-        object-            [ "last_known_position" .= m.lastKnownPosition-            , "lag" .= m.lag-            , "db_error_count" .= m.dbErrorCount-            , "last_stop_reason" .= m.lastStopReason-            ]+  toJSON m =+    object+      [ "last_known_position" .= m.lastKnownPosition,+        "lag" .= m.lag,+        "db_error_count" .= m.dbErrorCount,+        "last_stop_reason" .= m.lastStopReason+      ]
src/Kiroku/Metrics/WebSocket.hs view
@@ -1,31 +1,30 @@ {-# LANGUAGE ScopedTypeVariables #-} -{- | The WebSocket endpoint for the Kiroku metrics server.--Fills the IP-3 seam EP-2 left stubbed ('Kiroku.Metrics.Server.stubWebSocketApp')-with a real 'WS.ServerApp' that dispatches on the request path:--  * @\/ws\/metrics@ — the /metrics channel/: a 'MetricsSnapshot' on connect,-    then a fresh snapshot every @wsPushIntervalUs@ microseconds; @ping@ → @pong@.-  * @\/ws\/events@ — the /event channel/: after a @subscribe_events@ message, a-    JSON message per appended 'RecordedEvent' in global-position order, live.-    Optionally replays history from a chosen @from_position@ and/or restricts to-    a single @category@.--The event tail is built on the /public/ 'EventPublisher' broadcast-('subscribePublisher') for live "from-now" delivery and the public effectful-reads ('readAllForward' / 'readCategory' via 'runStoreIO') for replay and-category filtering. It creates /no persistent subscription/ and writes nothing to-the @subscriptions@ checkpoint table — transient watchers leave no trace. See the-plan's Decision Log.--This module owns IP-4: the 'ClientMessage' / 'ServerMessage' protocol and the-explicit 'recordedEventToJSON' encoder (an orphan-free function, not a @ToJSON@-instance — 'RecordedEvent' has none and @kiroku-store@ keeps its @Types@ module-instance-light).--}-module Kiroku.Metrics.WebSocket (-    -- * Protocol+-- | The WebSocket endpoint for the Kiroku metrics server.+--+-- Fills the IP-3 seam EP-2 left stubbed ('Kiroku.Metrics.Server.stubWebSocketApp')+-- with a real 'WS.ServerApp' that dispatches on the request path:+--+--   * @\/ws\/metrics@ — the /metrics channel/: a 'MetricsSnapshot' on connect,+--     then a fresh snapshot every @wsPushIntervalUs@ microseconds; @ping@ → @pong@.+--   * @\/ws\/events@ — the /event channel/: after a @subscribe_events@ message, a+--     JSON message per appended 'RecordedEvent' in global-position order, live.+--     Optionally replays history from a chosen @from_position@ and/or restricts to+--     a single @category@.+--+-- The event tail is built on the /public/ 'EventPublisher' broadcast+-- ('subscribePublisher') for live "from-now" delivery and the public effectful+-- reads ('readAllForward' / 'readCategory' via 'runStoreIO') for replay and+-- category filtering. It creates /no persistent subscription/ and writes nothing to+-- the @subscriptions@ checkpoint table — transient watchers leave no trace. See the+-- plan's Decision Log.+--+-- This module owns IP-4: the 'ClientMessage' / 'ServerMessage' protocol and the+-- explicit 'recordedEventToJSON' encoder (an orphan-free function, not a @ToJSON@+-- instance — 'RecordedEvent' has none and @kiroku-store@ keeps its @Types@ module+-- instance-light).+module Kiroku.Metrics.WebSocket+  ( -- * Protocol     ClientMessage (..),     ServerMessage (..),     recordedEventToJSON,@@ -50,12 +49,13 @@      -- * The WebSocket application (the IP-3 seam)     websocketApp,-) where+  )+where  import Control.Concurrent (threadDelay) import Control.Concurrent.Async (asyncWithUnmask, link, uninterruptibleCancel)-import Control.Concurrent.STM (-    STM,+import Control.Concurrent.STM+  ( STM,     TVar,     atomically,     check,@@ -64,11 +64,11 @@     readTBQueue,     readTVar,     writeTVar,- )+  ) import Control.Exception (bracket, catch, finally, mask, mask_) import Control.Monad (forever)-import Data.Aeson (-    FromJSON (..),+import Data.Aeson+  ( FromJSON (..),     ToJSON (..),     Value (..),     eitherDecode',@@ -78,7 +78,7 @@     (.:),     (.:?),     (.=),- )+  ) import Data.Aeson.KeyMap qualified as KeyMap import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as LBS@@ -100,8 +100,8 @@ import Kiroku.Metrics.Collector (KirokuMetrics, snapshotMetrics) import Kiroku.Metrics.Config (MetricsServerConfig (..)) import Kiroku.Metrics.Types (MetricsSnapshot)-import Kiroku.Store (-    CategoryName (..),+import Kiroku.Store+  ( CategoryName (..),     GlobalPosition (..),     KirokuStore (..),     RecordedEvent (..),@@ -109,90 +109,87 @@     readAllForward,     readCategory,     runStoreIO,- )+  ) import Kiroku.Store.Settings (DecodedBatch (..), DecodedEvent (..), decodedEventRecorded)-import Kiroku.Store.Subscription.EventPublisher (-    PublisherSubscription (..),+import Kiroku.Store.Subscription.EventPublisher+  ( PublisherSubscription (..),     SubscriberStatus (..),     publisherPosition,     subscribePublisherWith,- )+  ) import Kiroku.Store.Subscription.Types (OverflowPolicy (..))-import Kiroku.Store.Types (-    EventId (..),+import Kiroku.Store.Types+  ( EventId (..),     EventType (..),     StreamId (..),     StreamName (..),     StreamVersion (..),- )+  )  -------------------------------------------------------------------------------- -- Protocol (IP-4) -------------------------------------------------------------------------------- -{- | Messages from a WebSocket client to the server (tagged on a @"type"@ field).--The constructors are field-less (positional) on purpose: 'SubscribeEvents' and-'ServerMessage'\'s 'EventStreamStarted' would otherwise both define a-@fromPosition@ record selector, which collides when @Kiroku.Metrics@ re-exports-both @(..)@ lists.--}+-- | Messages from a WebSocket client to the server (tagged on a @"type"@ field).+--+-- The constructors are field-less (positional) on purpose: 'SubscribeEvents' and+-- 'ServerMessage'\'s 'EventStreamStarted' would otherwise both define a+-- @fromPosition@ record selector, which collides when @Kiroku.Metrics@ re-exports+-- both @(..)@ lists. data ClientMessage-    = -- | Keepalive request; answered with 'Pong'.-      Ping-    | -- | (Metrics channel) request a fresh snapshot now.-      SubscribeMetrics-    | {- | (Event channel) start streaming. The first field is @from_position@-      ('Nothing' = "from now"); the second is @category@ ('Nothing' = all streams).-      -}-      SubscribeEvents !(Maybe Int64) !(Maybe Text)-    | -- | (Event channel) stop the current tail.-      UnsubscribeEvents-    | -- | Stop periodic metrics snapshots; subscribe_metrics resumes them.-      UnsubscribeMetrics-    deriving stock (Eq, Show)+  = -- | Keepalive request; answered with 'Pong'.+    Ping+  | -- | (Metrics channel) request a fresh snapshot now.+    SubscribeMetrics+  | -- | (Event channel) start streaming. The first field is @from_position@+    --       ('Nothing' = "from now"); the second is @category@ ('Nothing' = all streams).+    SubscribeEvents !(Maybe Int64) !(Maybe Text)+  | -- | (Event channel) stop the current tail.+    UnsubscribeEvents+  | -- | Stop periodic metrics snapshots; subscribe_metrics resumes them.+    UnsubscribeMetrics+  deriving stock (Eq, Show)  -- | Messages from the server to a WebSocket client (tagged on a @"type"@ field). data ServerMessage-    = -- | Answer to 'Ping'.-      Pong-    | -- | (Metrics channel) a metrics snapshot, embedded under @"metrics"@.-      Snapshot !MetricsSnapshot-    | -- | (Event channel) one appended event, embedded under @"event"@.-      Event !Value-    | {- | (Event channel) acknowledgement that streaming has begun from a-      given global position (the @from_position@ field on the wire).-      -}-      EventStreamStarted !Int64-    | -- | The connection is being torn down.-      Goodbye-    | -- | A non-fatal error message for the client.-      ErrorMsg !Text-    | -- | An error with a stable machine-readable code.-      CodedError !Text !Text-    deriving stock (Eq, Show)+  = -- | Answer to 'Ping'.+    Pong+  | -- | (Metrics channel) a metrics snapshot, embedded under @"metrics"@.+    Snapshot !MetricsSnapshot+  | -- | (Event channel) one appended event, embedded under @"event"@.+    Event !Value+  | -- | (Event channel) acknowledgement that streaming has begun from a+    --       given global position (the @from_position@ field on the wire).+    EventStreamStarted !Int64+  | -- | The connection is being torn down.+    Goodbye+  | -- | A non-fatal error message for the client.+    ErrorMsg !Text+  | -- | An error with a stable machine-readable code.+    CodedError !Text !Text+  deriving stock (Eq, Show)  instance FromJSON ClientMessage where-    parseJSON = withObject "ClientMessage" $ \v -> do-        msgType <- v .: "type"-        case msgType :: Text of-            "ping" -> pure Ping-            "subscribe_metrics" -> pure SubscribeMetrics-            "unsubscribe_metrics" -> pure UnsubscribeMetrics-            "subscribe_events" ->-                SubscribeEvents <$> v .:? "from_position" <*> v .:? "category"-            "unsubscribe_events" -> pure UnsubscribeEvents-            other -> fail ("Unknown client message type: " <> T.unpack other)+  parseJSON = withObject "ClientMessage" $ \v -> do+    msgType <- v .: "type"+    case msgType :: Text of+      "ping" -> pure Ping+      "subscribe_metrics" -> pure SubscribeMetrics+      "unsubscribe_metrics" -> pure UnsubscribeMetrics+      "subscribe_events" ->+        SubscribeEvents <$> v .:? "from_position" <*> v .:? "category"+      "unsubscribe_events" -> pure UnsubscribeEvents+      other -> fail ("Unknown client message type: " <> T.unpack other)  instance ToJSON ServerMessage where-    toJSON Pong = object ["type" .= ("pong" :: Text)]-    toJSON (Snapshot snap) = object ["type" .= ("snapshot" :: Text), "metrics" .= snap]-    toJSON (Event ev) = object ["type" .= ("event" :: Text), "event" .= ev]-    toJSON (EventStreamStarted p) =-        object ["type" .= ("event_stream_started" :: Text), "from_position" .= p]-    toJSON Goodbye = object ["type" .= ("goodbye" :: Text)]-    toJSON (ErrorMsg msg) = object ["type" .= ("error" :: Text), "message" .= msg]-    toJSON (CodedError code msg) = object ["type" .= ("error" :: Text), "code" .= code, "message" .= msg]+  toJSON Pong = object ["type" .= ("pong" :: Text)]+  toJSON (Snapshot snap) = object ["type" .= ("snapshot" :: Text), "metrics" .= snap]+  toJSON (Event ev) = object ["type" .= ("event" :: Text), "event" .= ev]+  toJSON (EventStreamStarted p) =+    object ["type" .= ("event_stream_started" :: Text), "from_position" .= p]+  toJSON Goodbye = object ["type" .= ("goodbye" :: Text)]+  toJSON (ErrorMsg msg) = object ["type" .= ("error" :: Text), "message" .= msg]+  toJSON (CodedError code msg) = object ["type" .= ("error" :: Text), "code" .= code, "message" .= msg]  -- | Published error codes (human messages may change). errorCodeReplayFailed, errorCodeCategoryReadFailed, errorCodeEventStreamOverflowed, errorCodeLiveDecodeFailed :: Text@@ -204,36 +201,35 @@ -- | A notice on any counter change, including a Word64 wrap (modular subtraction). overflowNotice :: Word64 -> Word64 -> Maybe ServerMessage overflowNotice previous current-    | previous /= current =-        Just-            ( CodedError-                errorCodeEventStreamOverflowed-                ( "event stream overflowed; "-                    <> T.pack (show (current - previous))-                    <> " undelivered batch(es) dropped since the last notice; re-read from your last position"-                )+  | previous /= current =+      Just+        ( CodedError+            errorCodeEventStreamOverflowed+            ( "event stream overflowed; "+                <> T.pack (show (current - previous))+                <> " undelivered batch(es) dropped since the last notice; re-read from your last position"             )-    | otherwise = Nothing+        )+  | otherwise = Nothing -{- | Encode a 'RecordedEvent' to a JSON 'Value' (IP-4). An explicit function-rather than a @ToJSON@ instance: 'RecordedEvent' has no instance today and a-library-level orphan is undesirable. EP-4's user guide documents this shape.--}+-- | Encode a 'RecordedEvent' to a JSON 'Value' (IP-4). An explicit function+-- rather than a @ToJSON@ instance: 'RecordedEvent' has no instance today and a+-- library-level orphan is undesirable. EP-4's user guide documents this shape. recordedEventToJSON :: RecordedEvent -> Value recordedEventToJSON e =-    object-        [ "eventId" .= unEventId e.eventId-        , "eventType" .= unEventType e.eventType-        , "streamVersion" .= unStreamVersion e.streamVersion-        , "globalPosition" .= unGlobalPosition e.globalPosition-        , "originalStreamId" .= unStreamId e.originalStreamId-        , "originalVersion" .= unStreamVersion e.originalVersion-        , "payload" .= e.payload-        , "metadata" .= e.metadata-        , "causationId" .= e.causationId-        , "correlationId" .= e.correlationId-        , "createdAt" .= e.createdAt-        ]+  object+    [ "eventId" .= unEventId e.eventId,+      "eventType" .= unEventType e.eventType,+      "streamVersion" .= unStreamVersion e.streamVersion,+      "globalPosition" .= unGlobalPosition e.globalPosition,+      "originalStreamId" .= unStreamId e.originalStreamId,+      "originalVersion" .= unStreamVersion e.originalVersion,+      "payload" .= e.payload,+      "metadata" .= e.metadata,+      "causationId" .= e.causationId,+      "correlationId" .= e.correlationId,+      "createdAt" .= e.createdAt+    ]   where     unEventId (EventId u) = u     unEventType (EventType t) = t@@ -245,28 +241,27 @@ -- Connection limiting (mirrors shibuya-metrics) -------------------------------------------------------------------------------- -{- | Shared state bounding the number of concurrent WebSocket connections.-Allocated once per server in 'Kiroku.Metrics.Server.startMetricsServerWithStore'-and captured by 'websocketApp', so the bound is shared across connections.--}+-- | Shared state bounding the number of concurrent WebSocket connections.+-- Allocated once per server in 'Kiroku.Metrics.Server.startMetricsServerWithStore'+-- and captured by 'websocketApp', so the bound is shared across connections. data WebSocketState = WebSocketState-    { connectionCount :: !(TVar Int)-    , maxConnections :: !Int-    }+  { connectionCount :: !(TVar Int),+    maxConnections :: !Int+  }  -- | Create a 'WebSocketState' bounding connections at @maxConns@. newWebSocketState :: Int -> IO WebSocketState newWebSocketState maxConns = do-    countVar <- newTVarIO 0-    pure WebSocketState{connectionCount = countVar, maxConnections = maxConns}+  countVar <- newTVarIO 0+  pure WebSocketState {connectionCount = countVar, maxConnections = maxConns}  -- | Try to claim a connection slot; 'True' on success. acquireConnection :: WebSocketState -> STM Bool acquireConnection st = do-    count <- readTVar st.connectionCount-    if count < st.maxConnections-        then writeTVar st.connectionCount (count + 1) >> pure True-        else pure False+  count <- readTVar st.connectionCount+  if count < st.maxConnections+    then writeTVar st.connectionCount (count + 1) >> pure True+    else pure False  -- | Release a connection slot. releaseConnection :: WebSocketState -> STM ()@@ -281,41 +276,40 @@ -- | Dispatch on the request path, ignoring any query string. dispatchPath :: BS.ByteString -> WsPath dispatchPath raw =-    case BS.takeWhile (/= 0x3f) raw of -- 0x3f = '?'-        p-            | p == "/ws/metrics" -> MetricsPath-            | p == "/ws/events" -> EventsPath-            | otherwise -> UnknownPath+  case BS.takeWhile (/= 0x3f) raw of -- 0x3f = '?'+    p+      | p == "/ws/metrics" -> MetricsPath+      | p == "/ws/events" -> EventsPath+      | otherwise -> UnknownPath  -------------------------------------------------------------------------------- -- The WebSocket application -------------------------------------------------------------------------------- -{- | The real WebSocket app filling the IP-3 seam. Closes over the config, the-collector, the store (for event streaming — EP-2 deliberately kept the store out-of the /server/ signature, so it enters here), and the shared connection-limiting-state. Each upgrade is dispatched by path; an over-capacity upgrade is rejected.--}+-- | The real WebSocket app filling the IP-3 seam. Closes over the config, the+-- collector, the store (for event streaming — EP-2 deliberately kept the store out+-- of the /server/ signature, so it enters here), and the shared connection-limiting+-- state. Each upgrade is dispatched by path; an over-capacity upgrade is rejected. websocketApp ::-    MetricsServerConfig ->-    KirokuMetrics ->-    KirokuStore ->-    WebSocketState ->-    WS.ServerApp+  MetricsServerConfig ->+  KirokuMetrics ->+  KirokuStore ->+  WebSocketState ->+  WS.ServerApp websocketApp cfg m store st pending =-    case dispatchPath (WS.requestPath (WS.pendingRequest pending)) of-        MetricsPath -> guarded (handleMetrics cfg m)-        EventsPath -> guarded (handleEvents cfg store)-        UnknownPath ->-            WS.rejectRequest pending "Unknown WebSocket path; use /ws/metrics or /ws/events"+  case dispatchPath (WS.requestPath (WS.pendingRequest pending)) of+    MetricsPath -> guarded (handleMetrics cfg m)+    EventsPath -> guarded (handleEvents cfg store)+    UnknownPath ->+      WS.rejectRequest pending "Unknown WebSocket path; use /ws/metrics or /ws/events"   where     guarded run = do-        acquired <- atomically (acquireConnection st)-        if not acquired-            then WS.rejectRequest pending "Too many connections"-            else-                (run pending `catch` ignoreClosed)-                    `finally` atomically (releaseConnection st)+      acquired <- atomically (acquireConnection st)+      if not acquired+        then WS.rejectRequest pending "Too many connections"+        else+          (run pending `catch` ignoreClosed)+            `finally` atomically (releaseConnection st)     -- A normal client disconnect surfaces as a 'WS.ConnectionException' from the     -- receive loop; treat it as a clean end-of-connection rather than letting it     -- escape to the server's exception reporter.@@ -328,218 +322,212 @@ -- | Handle a @/ws/metrics@ connection: snapshot on connect, periodic push, ping/pong. handleMetrics :: MetricsServerConfig -> KirokuMetrics -> WS.PendingConnection -> IO () handleMetrics cfg m pending = do-    conn <- WS.acceptRequest pending-    WS.withPingThread conn 30 (pure ()) $-        withWorkerSlot $ \start stop -> do-            sendMsg conn . Snapshot =<< snapshotMetrics m-            start (metricsPushLoop cfg m conn)-            metricsReceiveLoop m conn (start (metricsPushLoop cfg m conn)) stop-                `finally` (stop >> sendMsg conn Goodbye)+  conn <- WS.acceptRequest pending+  WS.withPingThread conn 30 (pure ()) $+    withWorkerSlot $ \start stop -> do+      sendMsg conn . Snapshot =<< snapshotMetrics m+      start (metricsPushLoop cfg m conn)+      metricsReceiveLoop m conn (start (metricsPushLoop cfg m conn)) stop+        `finally` (stop >> sendMsg conn Goodbye) -{- | Scope one linked worker. Creation and registration are masked, the worker-body is unmasked, and cleanup cancels and joins even during acquisition. Starting-an already occupied slot is a no-op. The receive loop is the sole slot owner.--}+-- | Scope one linked worker. Creation and registration are masked, the worker+-- body is unmasked, and cleanup cancels and joins even during acquisition. Starting+-- an already occupied slot is a no-op. The receive loop is the sole slot owner. withWorkerSlot :: ((IO () -> IO ()) -> IO () -> IO a) -> IO a withWorkerSlot body = mask $ \restore -> do-    workerVar <- newTVarIO Nothing-    let stop = mask_ $ do-            worker <- atomically (readTVar workerVar)-            for_ worker uninterruptibleCancel-            atomically (writeTVar workerVar Nothing)-        start action = mask_ $ do-            worker <- atomically (readTVar workerVar)-            case worker of-                Just _ -> pure ()-                Nothing -> do-                    child <- asyncWithUnmask (\unmask -> unmask action)-                    atomically (writeTVar workerVar (Just child))-                    link child-    restore (body start stop) `finally` stop+  workerVar <- newTVarIO Nothing+  let stop = mask_ $ do+        worker <- atomically (readTVar workerVar)+        for_ worker uninterruptibleCancel+        atomically (writeTVar workerVar Nothing)+      start action = mask_ $ do+        worker <- atomically (readTVar workerVar)+        case worker of+          Just _ -> pure ()+          Nothing -> do+            child <- asyncWithUnmask (\unmask -> unmask action)+            atomically (writeTVar workerVar (Just child))+            link child+  restore (body start stop) `finally` stop  -- | Periodically push a fresh snapshot every @wsPushIntervalUs@. metricsPushLoop :: MetricsServerConfig -> KirokuMetrics -> WS.Connection -> IO () metricsPushLoop cfg m conn = forever $ do-    threadDelay cfg.wsPushIntervalUs-    sendMsg conn . Snapshot =<< snapshotMetrics m+  threadDelay cfg.wsPushIntervalUs+  sendMsg conn . Snapshot =<< snapshotMetrics m  -- | Answer @ping@ with @pong@ and @subscribe_metrics@ with a fresh snapshot. metricsReceiveLoop :: KirokuMetrics -> WS.Connection -> IO () -> IO () -> IO () metricsReceiveLoop m conn start stop = forever $ do-    cmd <- recvMsg conn-    case cmd of-        Just Ping -> sendMsg conn Pong-        Just SubscribeMetrics -> do-            sendMsg conn . Snapshot =<< snapshotMetrics m-            start-        Just UnsubscribeMetrics -> stop-        _ -> pure ()+  cmd <- recvMsg conn+  case cmd of+    Just Ping -> sendMsg conn Pong+    Just SubscribeMetrics -> do+      sendMsg conn . Snapshot =<< snapshotMetrics m+      start+    Just UnsubscribeMetrics -> stop+    _ -> pure ()  -------------------------------------------------------------------------------- -- Event channel -------------------------------------------------------------------------------- -{- | Handle a @/ws/events@ connection. The connection starts idle; a-@subscribe_events@ message starts (or restarts) a tail, @unsubscribe_events@-stops it, and @ping@ → @pong@. The tail runs in a child thread tracked in a-'TVar' so the receive loop can cancel and replace it; disconnect tears it down.--}+-- | Handle a @/ws/events@ connection. The connection starts idle; a+-- @subscribe_events@ message starts (or restarts) a tail, @unsubscribe_events@+-- stops it, and @ping@ → @pong@. The tail runs in a child thread tracked in a+-- 'TVar' so the receive loop can cancel and replace it; disconnect tears it down. handleEvents :: MetricsServerConfig -> KirokuStore -> WS.PendingConnection -> IO () handleEvents cfg store pending = do-    conn <- WS.acceptRequest pending-    WS.withPingThread conn 30 (pure ()) $-        withWorkerSlot $ \start stop ->-            finally-                ( forever $ do-                    cmd <- recvMsg conn-                    case cmd of-                        Just Ping -> sendMsg conn Pong-                        Just (SubscribeEvents from cat) -> stop >> start (eventTail cfg store conn from cat)-                        Just UnsubscribeEvents -> stop-                        _ -> pure ()-                )-                (stop >> sendMsg conn Goodbye)+  conn <- WS.acceptRequest pending+  WS.withPingThread conn 30 (pure ()) $+    withWorkerSlot $ \start stop ->+      finally+        ( forever $ do+            cmd <- recvMsg conn+            case cmd of+              Just Ping -> sendMsg conn Pong+              Just (SubscribeEvents from cat) -> stop >> start (eventTail cfg store conn from cat)+              Just UnsubscribeEvents -> stop+              _ -> pure ()+        )+        (stop >> sendMsg conn Goodbye)  -- | A reasonable replay/category page size. eventReadLimit :: Int eventReadLimit = 500 -{- | Stream events to the client. Dispatches on the request shape:--  * no @from@, no @category@: live "from-now" tail via the broadcast.-  * @from@, no @category@: replay history from @from@, tracking the highest-    delivered position, then live with the broadcast filtered to positions above-    that covered boundary. A replay read error sends an @error@ frame and ends-    the tail instead of entering live mode with a gap.-  * any @category@: a DB-driven loop over 'readCategory' gated on the publisher-    position (the broadcast carries no stream names, so it cannot be filtered by-    category in-process — see the plan).--}+-- | Stream events to the client. Dispatches on the request shape:+--+--   * no @from@, no @category@: live "from-now" tail via the broadcast.+--   * @from@, no @category@: replay history from @from@, tracking the highest+--     delivered position, then live with the broadcast filtered to positions above+--     that covered boundary. A replay read error sends an @error@ frame and ends+--     the tail instead of entering live mode with a gap.+--   * any @category@: a DB-driven loop over 'readCategory' gated on the publisher+--     position (the broadcast carries no stream names, so it cannot be filtered by+--     category in-process — see the plan). eventTail ::-    MetricsServerConfig ->-    KirokuStore ->-    WS.Connection ->-    Maybe Int64 ->-    Maybe Text ->-    IO ()+  MetricsServerConfig ->+  KirokuStore ->+  WS.Connection ->+  Maybe Int64 ->+  Maybe Text ->+  IO () eventTail cfg store conn mFrom mCategory = do-    cache <- newStreamNameCache-    case mCategory of-        Just cat -> do-            start <- case mFrom of-                Just p -> pure p-                Nothing -> unGP <$> atomically (publisherPosition store.publisher)-            sendMsg conn (EventStreamStarted start)-            categoryLoop store cache conn (CategoryName cat) start-        Nothing ->-            bracket-                ( atomically $ do-                    sub <- subscribePublisherWith store.publisher cfg.wsEventQueueCap DropOldest-                    attachPos <- unGP <$> publisherPosition store.publisher-                    pure (sub, attachPos)-                )-                (unsubscribe . fst)-                $ \(sub, attachPos) ->-                    case mFrom of-                        Nothing -> do-                            sendMsg conn (EventStreamStarted attachPos)-                            broadcastEventsWith (sendMsg conn) cache (lookupNames store) sub (const True)-                        Just p -> do-                            sendMsg conn (EventStreamStarted p)-                            mCovered <- replayHistory store cache conn p attachPos-                            for_ mCovered $ \covered ->-                                broadcastEventsWith (sendMsg conn) cache (lookupNames store) sub (\e -> unGP e.globalPosition > covered)+  cache <- newStreamNameCache+  case mCategory of+    Just cat -> do+      start <- case mFrom of+        Just p -> pure p+        Nothing -> unGP <$> atomically (publisherPosition store.publisher)+      sendMsg conn (EventStreamStarted start)+      categoryLoop store cache conn (CategoryName cat) start+    Nothing ->+      bracket+        ( atomically $ do+            sub <- subscribePublisherWith store.publisher cfg.wsEventQueueCap DropOldest+            attachPos <- unGP <$> publisherPosition store.publisher+            pure (sub, attachPos)+        )+        (unsubscribe . fst)+        $ \(sub, attachPos) ->+          case mFrom of+            Nothing -> do+              sendMsg conn (EventStreamStarted attachPos)+              broadcastEventsWith (sendMsg conn) cache (lookupNames store) sub (const True)+            Just p -> do+              sendMsg conn (EventStreamStarted p)+              mCovered <- replayHistory store cache conn p attachPos+              for_ mCovered $ \covered ->+                broadcastEventsWith (sendMsg conn) cache (lookupNames store) sub (\e -> unGP e.globalPosition > covered) -{- | Page history from the requested position up to @attachPos@ with-'readAllForward'. Returns @Just covered@, the highest global position the-client is now guaranteed to have received (at least @attachPos@; more when the-final page read past it), or @Nothing@ after a read error, which has already-been surfaced to the client as an 'ErrorMsg'. The caller must terminate the-tail on @Nothing@ rather than continue live with a gap.--}+-- | Page history from the requested position up to @attachPos@ with+-- 'readAllForward'. Returns @Just covered@, the highest global position the+-- client is now guaranteed to have received (at least @attachPos@; more when the+-- final page read past it), or @Nothing@ after a read error, which has already+-- been surfaced to the client as an 'ErrorMsg'. The caller must terminate the+-- tail on @Nothing@ rather than continue live with a gap. replayHistory :: KirokuStore -> StreamNameCache -> WS.Connection -> Int64 -> Int64 -> IO (Maybe Int64) replayHistory store cache conn from attachPos = go from attachPos   where     go cursor covered-        | cursor >= attachPos = pure (Just covered)-        | otherwise = do-            res <- runStoreIO store (readAllForward (GlobalPosition cursor) (fromIntegral eventReadLimit))-            case res of-                Left _ -> do-                    sendMsg conn (CodedError errorCodeReplayFailed "replay error: history unavailable")-                    pure Nothing-                Right evs-                    | V.null evs -> pure (Just covered)-                    | otherwise -> do-                        sendEvents store cache conn evs-                        let lastPos = unGP (V.last evs).globalPosition-                        go lastPos (max covered lastPos)+      | cursor >= attachPos = pure (Just covered)+      | otherwise = do+          res <- runStoreIO store (readAllForward (GlobalPosition cursor) (fromIntegral eventReadLimit))+          case res of+            Left _ -> do+              sendMsg conn (CodedError errorCodeReplayFailed "replay error: history unavailable")+              pure Nothing+            Right evs+              | V.null evs -> pure (Just covered)+              | otherwise -> do+                  sendEvents store cache conn evs+                  let lastPos = unGP (V.last evs).globalPosition+                  go lastPos (max covered lastPos) -{- | Production live delivery with an injectable frame writer and name lookup.-Queue, status and counter are sampled atomically. Loss is signalled before any-surviving event, preserving the client's last contiguous recovery cursor.--}+-- | Production live delivery with an injectable frame writer and name lookup.+-- Queue, status and counter are sampled atomically. Loss is signalled before any+-- surviving event, preserving the client's last contiguous recovery cursor. broadcastEventsWith ::-    (ServerMessage -> IO ()) ->-    StreamNameCache ->-    ([StreamId] -> IO (Map StreamId StreamName)) ->-    PublisherSubscription ->-    (RecordedEvent -> Bool) ->-    IO ()+  (ServerMessage -> IO ()) ->+  StreamNameCache ->+  ([StreamId] -> IO (Map StreamId StreamName)) ->+  PublisherSubscription ->+  (RecordedEvent -> Bool) ->+  IO () broadcastEventsWith send cache lookupBatch sub keep = go 0 False   where     go previous warned = do-        (batch, status, dropped) <--            atomically $-                (,,)-                    <$> readTBQueue sub.subscriptionQueue-                    <*> readTVar sub.subscriptionStatus-                    <*> readTVar sub.subscriptionDropped-        let notice = case overflowNotice previous dropped of-                Just msg -> Just msg-                Nothing | status == Overflowed && not warned -> Just (CodedError errorCodeEventStreamOverflowed "event stream overflowed; some events dropped")-                Nothing -> Nothing-        for_ notice send-        let decoded = case batch of-                UnchangedBatch events -> Right (V.filter keep events)-                TransformedBatch events -> V.mapM unwrap (V.filter (keep . decodedEventRecorded) events)-            unwrap (Decoded event) = Right event-            unwrap (Undecodable _ failure) = Left failure-        case decoded of-            Left _ -> send (CodedError errorCodeLiveDecodeFailed "live event decoding failed")-            Right events -> do-                names <- resolveEventNames cache lookupBatch events-                V.mapM_ (send . Event . recordedEventToJSONResolved names) events-                go dropped (status == Overflowed)+      (batch, status, dropped) <-+        atomically $+          (,,)+            <$> readTBQueue sub.subscriptionQueue+            <*> readTVar sub.subscriptionStatus+            <*> readTVar sub.subscriptionDropped+      let notice = case overflowNotice previous dropped of+            Just msg -> Just msg+            Nothing | status == Overflowed && not warned -> Just (CodedError errorCodeEventStreamOverflowed "event stream overflowed; some events dropped")+            Nothing -> Nothing+      for_ notice send+      let decoded = case batch of+            UnchangedBatch events -> Right (V.filter keep events)+            TransformedBatch events -> V.mapM unwrap (V.filter (keep . decodedEventRecorded) events)+          unwrap (Decoded event) = Right event+          unwrap (Undecodable _ failure) = Left failure+      case decoded of+        Left _ -> send (CodedError errorCodeLiveDecodeFailed "live event decoding failed")+        Right events -> do+          names <- resolveEventNames cache lookupBatch events+          V.mapM_ (send . Event . recordedEventToJSONResolved names) events+          go dropped (status == Overflowed) -{- | DB-driven category live loop. Mirrors the subscription worker's-@liveLoopDbDriven@: gate on the publisher advancing past the /last observed/-position (not the cursor) so an unmatched category does not busy-spin, then drain-the category to empty before waiting again.--}+-- | DB-driven category live loop. Mirrors the subscription worker's+-- @liveLoopDbDriven@: gate on the publisher advancing past the /last observed/+-- position (not the cursor) so an unmatched category does not busy-spin, then drain+-- the category to empty before waiting again. categoryLoop :: KirokuStore -> StreamNameCache -> WS.Connection -> CategoryName -> Int64 -> IO () categoryLoop store cache conn cat startPos = go startPos 0   where     go cursor waitFrom = do-        pubPos <- atomically $ do-            GlobalPosition p <- publisherPosition store.publisher-            check (p > waitFrom)-            pure p-        drained <- drainTo cursor-        case drained of-            Nothing -> pure () -- a DB error already surfaced; stop the tail-            Just cursor' -> go cursor' pubPos+      pubPos <- atomically $ do+        GlobalPosition p <- publisherPosition store.publisher+        check (p > waitFrom)+        pure p+      drained <- drainTo cursor+      case drained of+        Nothing -> pure () -- a DB error already surfaced; stop the tail+        Just cursor' -> go cursor' pubPos     drainTo cursor = do-        res <- runStoreIO store (readCategory cat (GlobalPosition cursor) (fromIntegral eventReadLimit))-        case res of-            Left _ -> do-                sendMsg conn (CodedError errorCodeCategoryReadFailed "category read error: events unavailable")-                pure Nothing-            Right evs-                | V.null evs -> pure (Just cursor)-                | otherwise -> do-                    sendEvents store cache conn evs-                    drainTo (unGP (V.last evs).globalPosition)+      res <- runStoreIO store (readCategory cat (GlobalPosition cursor) (fromIntegral eventReadLimit))+      case res of+        Left _ -> do+          sendMsg conn (CodedError errorCodeCategoryReadFailed "category read error: events unavailable")+          pure Nothing+        Right evs+          | V.null evs -> pure (Just cursor)+          | otherwise -> do+              sendEvents store cache conn evs+              drainTo (unGP (V.last evs).globalPosition)  -------------------------------------------------------------------------------- -- Send / receive helpers@@ -548,8 +536,8 @@ -- | Send each event in a batch as an 'Event' message. sendEvents :: KirokuStore -> StreamNameCache -> WS.Connection -> Vector RecordedEvent -> IO () sendEvents store cache conn events = do-    names <- resolveEventNames cache (lookupNames store) events-    V.mapM_ (sendMsg conn . Event . recordedEventToJSONResolved names) events+  names <- resolveEventNames cache (lookupNames store) events+  V.mapM_ (sendMsg conn . Event . recordedEventToJSONResolved names) events  -- Typed lookup failures fall back to null names; thrown exceptions propagate. lookupNames :: KirokuStore -> [StreamId] -> IO (Map StreamId StreamName)@@ -567,48 +555,45 @@ -- | Retained map and FIFO sizes (both bounded by 4096). streamNameCacheSize :: StreamNameCache -> IO (Int, Int) streamNameCacheSize (StreamNameCache ref) = do-    NameCache names order <- readIORef ref-    pure (Map.size names, Seq.length order)+  NameCache names order <- readIORef ref+  pure (Map.size names, Seq.length order) -{- | Resolve distinct misses in at most one lookup. The temporary encoding map-contains all current-batch names even when that batch exceeds cache capacity.-The tail owns the cache; calls on one cache must be serialized.--}+-- | Resolve distinct misses in at most one lookup. The temporary encoding map+-- contains all current-batch names even when that batch exceeds cache capacity.+-- The tail owns the cache; calls on one cache must be serialized. resolveEventNames :: StreamNameCache -> ([StreamId] -> IO (Map StreamId StreamName)) -> Vector RecordedEvent -> IO (Map StreamId StreamName) resolveEventNames (StreamNameCache ref) lookupBatch events = do-    NameCache cached order <- readIORef ref-    let wanted = Set.fromList (V.toList (V.map (.originalStreamId) events))-        -- Inspect the requested ids rather than materializing all retained keys-        -- for each small batch after the cache has filled.-        missing = Set.filter (`Map.notMember` cached) wanted-    found <- if Set.null missing then pure Map.empty else Map.restrictKeys <$> lookupBatch (Set.toList missing) <*> pure missing-    let current = Map.union cached found-        inserted = foldl' (|>) order (Map.keys found)-        trimmed = evict current inserted-    trimmed `seq` writeIORef ref trimmed-    pure (Map.restrictKeys current wanted)+  NameCache cached order <- readIORef ref+  let wanted = Set.fromList (V.toList (V.map (.originalStreamId) events))+      -- Inspect the requested ids rather than materializing all retained keys+      -- for each small batch after the cache has filled.+      missing = Set.filter (`Map.notMember` cached) wanted+  found <- if Set.null missing then pure Map.empty else Map.restrictKeys <$> lookupBatch (Set.toList missing) <*> pure missing+  let current = Map.union cached found+      inserted = foldl' (|>) order (Map.keys found)+      trimmed = evict current inserted+  trimmed `seq` writeIORef ref trimmed+  pure (Map.restrictKeys current wanted)   where     evict names order-        | Map.size names <= 4096 = NameCache names order-        | otherwise = case Seq.viewl order of-            oldest :< rest -> evict (Map.delete oldest names) rest-            EmptyL -> NameCache Map.empty Seq.empty+      | Map.size names <= 4096 = NameCache names order+      | otherwise = case Seq.viewl order of+          oldest :< rest -> evict (Map.delete oldest names) rest+          EmptyL -> NameCache Map.empty Seq.empty -{- | Send a 'ServerMessage', swallowing a closed-connection exception so cleanup-in a @finally@ never re-throws on an already-dead socket.--}+-- | Send a 'ServerMessage', swallowing a closed-connection exception so cleanup+-- in a @finally@ never re-throws on an already-dead socket. sendMsg :: WS.Connection -> ServerMessage -> IO () sendMsg conn msg =-    WS.sendTextData conn (encode msg)-        `catch` \(_ :: WS.ConnectionException) -> pure ()+  WS.sendTextData conn (encode msg)+    `catch` \(_ :: WS.ConnectionException) -> pure () -{- | Receive and decode one 'ClientMessage'. 'Nothing' on an undecodable frame-(ignored by the caller).--}+-- | Receive and decode one 'ClientMessage'. 'Nothing' on an undecodable frame+-- (ignored by the caller). recvMsg :: WS.Connection -> IO (Maybe ClientMessage) recvMsg conn = do-    raw <- WS.receiveData conn :: IO LBS.ByteString-    pure (either (const Nothing) Just (eitherDecode' raw))+  raw <- WS.receiveData conn :: IO LBS.ByteString+  pure (either (const Nothing) Just (eitherDecode' raw))  -- | Unwrap a 'GlobalPosition' to its underlying 'Int64'. unGP :: GlobalPosition -> Int64@@ -617,5 +602,5 @@ -- | The frozen event shape plus the resolved original name (or null). recordedEventToJSONResolved :: Map StreamId StreamName -> RecordedEvent -> Value recordedEventToJSONResolved names event = case recordedEventToJSON event of-    Object fields -> Object (KeyMap.insert "original_stream_name" (toJSON (fmap (\(StreamName name) -> name) (Map.lookup event.originalStreamId names))) fields)-    _ -> error "recordedEventToJSON must return an object"+  Object fields -> Object (KeyMap.insert "original_stream_name" (toJSON (fmap (\(StreamName name) -> name) (Map.lookup event.originalStreamId names))) fields)+  _ -> error "recordedEventToJSON must return an object"
test/Main.hs view
@@ -1,5 +1,11 @@ module Main (main) where +import Control.Concurrent.MVar (newEmptyMVar, takeMVar, tryPutMVar)+import Control.Exception (bracket)+import Control.Monad (void)+import InspectSignals (withInspectSignals)+import System.Environment (getArgs)+import System.IO (hFlush, stdout) import Test.Hspec (hspec)  import Kiroku.Test.Postgres (withSharedMigratedPostgres)@@ -17,16 +23,31 @@ import Test.WebSocketSpec qualified as WebSocketSpec  main :: IO ()-main = withSharedMigratedPostgres $ hspec $ do-    StandaloneSpec.spec-    CapabilitiesSpec.spec-    BrowseSpec.spec-    CheckpointsSpec.spec-    DeadLettersSpec.spec-    CorsSpec.spec-    CollectorSpec.spec-    IntegrationSpec.spec-    ServerSpec.spec-    WebSocketConvergenceSpec.spec-    WebSocketSpec.spec-    SubscriptionsSpec.spec+main = do+  args <- getArgs+  if args == ["--inspect-signal-fixture"] then signalFixture else runSpecs++signalFixture :: IO ()+signalFixture = do+  done <- newEmptyMVar+  never <- newEmptyMVar+  withInspectSignals (void (tryPutMVar done ())) $+    bracket+      (putStrLn "READY" >> hFlush stdout)+      (\() -> putStrLn "SHUTTING_DOWN" >> hFlush stdout >> takeMVar never)+      (\() -> takeMVar done)++runSpecs :: IO ()+runSpecs = withSharedMigratedPostgres $ hspec $ do+  StandaloneSpec.spec+  CapabilitiesSpec.spec+  BrowseSpec.spec+  CheckpointsSpec.spec+  DeadLettersSpec.spec+  CorsSpec.spec+  CollectorSpec.spec+  IntegrationSpec.spec+  ServerSpec.spec+  WebSocketConvergenceSpec.spec+  WebSocketSpec.spec+  SubscriptionsSpec.spec
test/Test/BrowseSpec.hs view
@@ -33,150 +33,157 @@  spec :: Spec spec = describe "Kiroku.Metrics.Browse" $ do-    it "validates page limits before construction" $ do-        forM_ [(0, 10), (10, 1), (1, 1001)] $ \(def, cap) -> mkBrowseLimits def cap `shouldBe` Left (InvalidBrowseLimits def cap)-        fmap maxLimit (mkBrowseLimits 1 1000) `shouldBe` Right 1000-    it "rejects malformed queries before invoking the store" $ do-        calls <- newIORef (0 :: Int)-        let browser = StoreBrowser (\_ -> modifyIORef' calls (+ 1) >> pure (Left (Store.ConnectionError "secret"))) defaultBrowseLimits-        Warp.testWithApplication (pure (browseApp browser)) $ \port -> do-            forM_ ["/streams?limit=0", "/streams?limit=1001", "/streams?limit=abc", "/streams?prefix=%00", "/streams?from=%FF", "/events?from=-1", "/events?from=9223372036854775808", "/events?direction=sideways", "/streams/$all/events", "/events/not-a-uuid"] $ \path -> do-                response <- get port path-                HTTP.responseStatus response `shouldBe` status400-            response <- get port "/streams?limit=1"-            HTTP.responseStatus response `shouldBe` status503-            code (HTTP.responseBody response) `shouldBe` Just "store_unavailable"-        readIORef calls `shouldReturn` 1-    it "preserves HEAD status/headers, refuses mutations and sanitizes errors" $ do-        let app = browseApp (StoreBrowser (\_ -> pure (Left (Store.ConnectionError "postgres://secret"))) defaultBrowseLimits)-        (status, headers, responseBody) <- capture app "GET" ["events"]-        status `shouldBe` status503-        code responseBody `shouldBe` Just "store_unavailable"-        capture app "HEAD" ["events"] `shouldReturn` (status, headers, "")-        forM_ ["POST", "PUT", "DELETE", "OPTIONS"] $ \method -> do-            (actual, responseHeaders, _) <- capture app method ["events"]-            actual `shouldBe` status405-            lookup "Allow" responseHeaders `shouldBe` Just "GET, HEAD"-        (disabled, _, disabledBody) <- capture browseNotConfiguredApp "GET" ["streams"]-        disabled `shouldBe` status404-        code disabledBody `shouldBe` Just "store_browsing_not_configured"-    it "sanitizes typed transient and permanent failures on browse routes" $ do-        forM_ [(Store.ConnectionLost "secret", status503, "store_unavailable"), (Store.PoolAcquisitionTimeout, status503, "store_unavailable"), (Store.TransientTransactionFailure "40001" "secret", status503, "store_unavailable"), (Store.TransientTransactionFailure "40P01" "secret", status503, "store_unavailable"), (Store.UnexpectedServerError "42P01" "secret SQL", status500, "store_error")] $ \(err, expected, expectedCode) -> do-            let app = browseApp (StoreBrowser (\_ -> pure (Left err)) defaultBrowseLimits)-            (status, headers, responseBody) <- capture app "GET" ["events"]-            status `shouldBe` expected-            code responseBody `shouldBe` Just expectedCode-            responseBody `shouldSatisfy` (not . BS.isInfixOf "secret" . LBS.toStrict)-            capture app "HEAD" ["events"] `shouldReturn` (status, headers, "")-    it "serves all browse routes, names and exclusive pages through the shared server" $ withTestStore $ \store -> do-        append store "orders-1" 3-        append store "orders-2" 1-        append store "shipments-1" 1-        metrics <- newKirokuMetrics store-        providers <- storeServerProviders defaultConfig metrics store-        withMetricsServerWithProviders defaultConfig{Config.port = 0} metrics [] providers $ \server -> do-            let fetch = get server.serverPort-            first <- fetch "/streams?category=orders&limit=1"-            field "next_cursor" (body first) `shouldBe` Just (String "orders-1")-            second <- fetch "/streams?category=orders&prefix=orders-&from=orders-1&limit=1"-            map (field "name") (items (body second)) `shouldBe` [Just (String "orders-2")]-            field "next_cursor" (body second) `shouldBe` Nothing-            summary <- fetch "/streams/orders-1"-            field "category" (body summary) `shouldBe` Just (String "orders")-            streamPage <- fetch "/streams/orders-1/events?limit=2"-            map (field "streamVersion") (items (body streamPage)) `shouldBe` [Just (Number 1), Just (Number 2)]-            field "next_cursor" (body streamPage) `shouldBe` Just (Number 2)-            continuation <- fetch "/streams/orders-1/events?from=2&limit=2"-            map (field "streamVersion") (items (body continuation)) `shouldBe` [Just (Number 3)]-            backward <- fetch "/streams/orders-1/events?direction=backward"-            map (field "streamVersion") (items (body backward)) `shouldBe` map (Just . Number) [3, 2, 1]-            cats <- fetch "/categories"-            map (field "name") (items (body cats)) `shouldBe` [Just (String "orders"), Just (String "shipments")]-            categoryEvents <- fetch "/categories/orders/events"-            length (items (body categoryEvents)) `shouldBe` 4-            globals <- fetch "/events?from=3"-            map (field "globalPosition") (items (body globals)) `shouldBe` [Just (Number 4), Just (Number 5)]-            map (field "original_stream_name") (items (body globals)) `shouldBe` [Just (String "orders-2"), Just (String "shipments-1")]-            reverseGlobal <- fetch "/events?direction=backward&limit=2"-            map (field "globalPosition") (items (body reverseGlobal)) `shouldBe` [Just (Number 5), Just (Number 4)]-            Right recorded <- Store.runStoreIO store $ Store.readAllForward (Store.GlobalPosition 0) 10-            let Store.EventId uuid = V.head recorded ^. #eventId-            event <- fetch ("/events/" <> show uuid)-            HTTP.responseStatus event `shouldBe` status200-            field "original_stream_name" (body event) `shouldBe` Just (String "orders-1")-            forM_ ["/streams/missing", "/streams/missing/events", "/events/00000000-0000-0000-0000-000000000000", "/streams/unknown/path"] $ \path -> do-                response <- fetch path-                HTTP.responseStatus response `shouldBe` status404-            -- Summary JSON and legacy endpoints remain independently mounted.-            legacy <- fetch "/nope"-            body legacy `shouldBe` object ["error" .= ("Not found" :: Text)]-    it "keeps prefix-mounted browse routes and CORS active when legacy JSON and WebSockets are disabled" $ do-        metrics <- newKirokuMetricsWith (pure (Store.GlobalPosition 0)) (pure 0)-        let origin = either (error . show) (\value -> value) (allowedOrigin "https://ops.example.com")-            cors = corsAllowOrigins [origin]-            browser = StoreBrowser (\_ -> pure (Left (Store.ConnectionError "secret"))) defaultBrowseLimits-            config = defaultConfig{cors, enableJSON = False, enableWebSocket = False}-            providers = defaultServerProviders{storeBrowsing = Just browser}-            mounted req respond = combinedAppWithProviders config metrics [] providers (req{Wai.pathInfo = drop 1 (Wai.pathInfo req)}) respond-        Warp.testWithApplication (pure mounted) $ \port -> do-            manager <- HTTP.newManager HTTP.defaultManagerSettings-            request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/inspect/events?from=0")-            response <- HTTP.httpLbs request{HTTP.requestHeaders = [("Origin", "https://ops.example.com")]} manager-            HTTP.responseStatus response `shouldBe` status503-            lookup "Access-Control-Allow-Origin" (HTTP.responseHeaders response) `shouldBe` Just "https://ops.example.com"-            code (HTTP.responseBody response) `shouldBe` Just "store_unavailable"-    it "trims over-fetch before one distinct-name lookup and skips it on empty pages" $ withTestStore $ \store -> do-        append store "orders-1" 2-        append store "orders-2" 1-        Right events <- Store.runStoreIO store $ Store.readAllForward (Store.GlobalPosition 0) 10-        calls <- newIORef ([] :: [[Store.StreamId]])-        let interpreter :: forall a. Eff '[Store, Error Store.StoreError, IOE] a -> Eff '[Error Store.StoreError, IOE] a-            interpreter = interpret_ $ \case-                ReadAllForward (Store.GlobalPosition cursor) limit -> pure $ V.take (fromIntegral limit) $ V.filter (\event -> let Store.GlobalPosition n = event ^. #globalPosition in n > cursor) events-                LookupStreamNames ids -> liftIO (modifyIORef' calls (<> [ids])) >> pure Map.empty-                _ -> error "unexpected browse mock operation"-            browser = StoreBrowser (runEff . runErrorNoCallStack . interpreter) defaultBrowseLimits-        Warp.testWithApplication (pure (browseApp browser)) $ \port -> do-            response <- get port "/events?limit=2"-            map (field "original_stream_name") (items (body response)) `shouldBe` [Just Null, Just Null]-            _ <- get port "/events?from=3"-            pure ()-        readIORef calls `shouldReturn` [[V.head events ^. #originalStreamId]]+  it "validates page limits before construction" $ do+    forM_ [(0, 10), (10, 1), (1, 1001)] $ \(def, cap) -> mkBrowseLimits def cap `shouldBe` Left (InvalidBrowseLimits def cap)+    fmap maxLimit (mkBrowseLimits 1 1000) `shouldBe` Right 1000+  it "rejects malformed queries before invoking the store" $ do+    calls <- newIORef (0 :: Int)+    let browser = StoreBrowser (\_ -> modifyIORef' calls (+ 1) >> pure (Left (Store.ConnectionError "secret"))) defaultBrowseLimits+    Warp.testWithApplication (pure (browseApp browser)) $ \port -> do+      forM_ ["/streams?limit=0", "/streams?limit=1001", "/streams?limit=abc", "/streams?prefix=%00", "/streams?from=%FF", "/events?from=-1", "/events?from=9223372036854775808", "/events?direction=sideways", "/streams/$all/events", "/events/not-a-uuid"] $ \path -> do+        response <- get port path+        HTTP.responseStatus response `shouldBe` status400+      response <- get port "/streams?limit=1"+      HTTP.responseStatus response `shouldBe` status503+      code (HTTP.responseBody response) `shouldBe` Just "store_unavailable"+    readIORef calls `shouldReturn` 1+  it "preserves HEAD status/headers, refuses mutations and sanitizes errors" $ do+    let app = browseApp (StoreBrowser (\_ -> pure (Left (Store.ConnectionError "postgres://secret"))) defaultBrowseLimits)+    (status, headers, responseBody) <- capture app "GET" ["events"]+    status `shouldBe` status503+    code responseBody `shouldBe` Just "store_unavailable"+    capture app "HEAD" ["events"] `shouldReturn` (status, headers, "")+    forM_ ["POST", "PUT", "DELETE", "OPTIONS"] $ \method -> do+      (actual, responseHeaders, _) <- capture app method ["events"]+      actual `shouldBe` status405+      lookup "Allow" responseHeaders `shouldBe` Just "GET, HEAD"+    (disabled, _, disabledBody) <- capture browseNotConfiguredApp "GET" ["streams"]+    disabled `shouldBe` status404+    code disabledBody `shouldBe` Just "store_browsing_not_configured"+  it "sanitizes typed transient and permanent failures on browse routes" $ do+    forM_ [(Store.ConnectionLost "secret", status503, "store_unavailable"), (Store.PoolAcquisitionTimeout, status503, "store_unavailable"), (Store.TransientTransactionFailure "40001" "secret", status503, "store_unavailable"), (Store.TransientTransactionFailure "40P01" "secret", status503, "store_unavailable"), (Store.UnexpectedServerError "42P01" "secret SQL", status500, "store_error")] $ \(err, expected, expectedCode) -> do+      let app = browseApp (StoreBrowser (\_ -> pure (Left err)) defaultBrowseLimits)+      (status, headers, responseBody) <- capture app "GET" ["events"]+      status `shouldBe` expected+      code responseBody `shouldBe` Just expectedCode+      responseBody `shouldSatisfy` (not . BS.isInfixOf "secret" . LBS.toStrict)+      capture app "HEAD" ["events"] `shouldReturn` (status, headers, "")+  it "serves all browse routes, names and exclusive pages through the shared server" $ withTestStore $ \store -> do+    append store "orders-1" 3+    append store "orders-2" 1+    append store "shipments-1" 1+    metrics <- newKirokuMetrics store+    providers <- storeServerProviders defaultConfig metrics store+    withMetricsServerWithProviders defaultConfig {Config.port = 0} metrics [] providers $ \server -> do+      let fetch = get server.serverPort+      first <- fetch "/streams?category=orders&limit=1"+      field "next_cursor" (body first) `shouldBe` Just (String "orders-1")+      second <- fetch "/streams?category=orders&prefix=orders-&from=orders-1&limit=1"+      map (field "name") (items (body second)) `shouldBe` [Just (String "orders-2")]+      field "next_cursor" (body second) `shouldBe` Nothing+      summary <- fetch "/streams/orders-1"+      field "category" (body summary) `shouldBe` Just (String "orders")+      streamPage <- fetch "/streams/orders-1/events?limit=2"+      map (field "streamVersion") (items (body streamPage)) `shouldBe` [Just (Number 1), Just (Number 2)]+      field "next_cursor" (body streamPage) `shouldBe` Just (Number 2)+      continuation <- fetch "/streams/orders-1/events?from=2&limit=2"+      map (field "streamVersion") (items (body continuation)) `shouldBe` [Just (Number 3)]+      backward <- fetch "/streams/orders-1/events?direction=backward"+      map (field "streamVersion") (items (body backward)) `shouldBe` map (Just . Number) [3, 2, 1]+      cats <- fetch "/categories"+      map (field "name") (items (body cats)) `shouldBe` [Just (String "orders"), Just (String "shipments")]+      categoryEvents <- fetch "/categories/orders/events"+      length (items (body categoryEvents)) `shouldBe` 4+      globals <- fetch "/events?from=3"+      map (field "globalPosition") (items (body globals)) `shouldBe` [Just (Number 4), Just (Number 5)]+      map (field "original_stream_name") (items (body globals)) `shouldBe` [Just (String "orders-2"), Just (String "shipments-1")]+      reverseGlobal <- fetch "/events?direction=backward&limit=2"+      map (field "globalPosition") (items (body reverseGlobal)) `shouldBe` [Just (Number 5), Just (Number 4)]+      Right recorded <- Store.runStoreIO store $ Store.readAllForward (Store.GlobalPosition 0) 10+      let Store.EventId uuid = V.head recorded ^. #eventId+      event <- fetch ("/events/" <> show uuid)+      HTTP.responseStatus event `shouldBe` status200+      field "original_stream_name" (body event) `shouldBe` Just (String "orders-1")+      forM_ ["/streams/missing", "/streams/missing/events", "/events/00000000-0000-0000-0000-000000000000", "/streams/unknown/path"] $ \path -> do+        response <- fetch path+        HTTP.responseStatus response `shouldBe` status404+      -- Summary JSON and legacy endpoints remain independently mounted.+      legacy <- fetch "/nope"+      body legacy `shouldBe` object ["error" .= ("Not found" :: Text)]+  it "keeps prefix-mounted browse routes and CORS active when legacy JSON and WebSockets are disabled" $ do+    metrics <- newKirokuMetricsWith (pure (Store.GlobalPosition 0)) (pure 0)+    let origin = either (error . show) (\value -> value) (allowedOrigin "https://ops.example.com")+        cors = corsAllowOrigins [origin]+        browser = StoreBrowser (\_ -> pure (Left (Store.ConnectionError "secret"))) defaultBrowseLimits+        config = defaultConfig {cors, enableJSON = False, enableWebSocket = False}+        providers = defaultServerProviders {storeBrowsing = Just browser}+        mounted req respond = combinedAppWithProviders config metrics [] providers (req {Wai.pathInfo = drop 1 (Wai.pathInfo req)}) respond+    Warp.testWithApplication (pure mounted) $ \port -> do+      manager <- HTTP.newManager HTTP.defaultManagerSettings+      request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/inspect/events?from=0")+      response <- HTTP.httpLbs request {HTTP.requestHeaders = [("Origin", "https://ops.example.com")]} manager+      HTTP.responseStatus response `shouldBe` status503+      lookup "Access-Control-Allow-Origin" (HTTP.responseHeaders response) `shouldBe` Just "https://ops.example.com"+      code (HTTP.responseBody response) `shouldBe` Just "store_unavailable"+  it "trims over-fetch before one distinct-name lookup and skips it on empty pages" $ withTestStore $ \store -> do+    append store "orders-1" 2+    append store "orders-2" 1+    Right events <- Store.runStoreIO store $ Store.readAllForward (Store.GlobalPosition 0) 10+    calls <- newIORef ([] :: [[Store.StreamId]])+    let interpreter :: forall a. Eff '[Store, Error Store.StoreError, IOE] a -> Eff '[Error Store.StoreError, IOE] a+        interpreter = interpret_ $ \case+          ReadAllForward (Store.GlobalPosition cursor) limit -> pure $ V.take (fromIntegral limit) $ V.filter (\event -> let Store.GlobalPosition n = event ^. #globalPosition in n > cursor) events+          LookupStreamNames ids -> liftIO (modifyIORef' calls (<> [ids])) >> pure Map.empty+          _ -> error "unexpected browse mock operation"+        browser = StoreBrowser (runEff . runErrorNoCallStack . interpreter) defaultBrowseLimits+    Warp.testWithApplication (pure (browseApp browser)) $ \port -> do+      response <- get port "/events?limit=2"+      map (field "original_stream_name") (items (body response)) `shouldBe` [Just Null, Just Null]+      _ <- get port "/events?from=3"+      pure ()+    readIORef calls `shouldReturn` [[V.head events ^. #originalStreamId]]  withTestStore :: (Store.KirokuStore -> IO ()) -> IO () withTestStore action = withMigratedTestDatabase $ \connection -> Store.withStore (Store.defaultConnectionSettings connection) action+ append :: Store.KirokuStore -> Text -> Int -> IO () append store name count = do-    let event = Store.EventData Nothing (Store.EventType "Created") (object []) Nothing Nothing Nothing-    result <- Store.runStoreIO store $ Store.appendToStream (Store.StreamName name) Store.NoStream (replicate count event)-    result `shouldSatisfy` either (const False) (const True)+  let event = Store.EventData Nothing (Store.EventType "Created") (object []) Nothing Nothing Nothing+  result <- Store.runStoreIO store $ Store.appendToStream (Store.StreamName name) Store.NoStream (replicate count event)+  result `shouldSatisfy` either (const False) (const True)+ get :: Int -> String -> IO (HTTP.Response LBS.ByteString) get port path = do-    manager <- HTTP.newManager HTTP.defaultManagerSettings-    request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> path)-    HTTP.httpLbs request manager+  manager <- HTTP.newManager HTTP.defaultManagerSettings+  request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> path)+  HTTP.httpLbs request manager+ body :: HTTP.Response LBS.ByteString -> Value body response = maybe (error "Invalid JSON response") (\value -> value) (Aeson.decode (HTTP.responseBody response))+ field :: Aeson.Key -> Value -> Maybe Value field key (Object objectValue) = KM.lookup key objectValue field _ _ = Nothing+ items :: Value -> [Value] items value = case field "items" value of Just (Array rows) -> V.toList rows; _ -> error "Expected items"+ code :: LBS.ByteString -> Maybe Text code bytes = do-    value <- Aeson.decode bytes-    err <- field "error" value-    String actual <- field "code" err-    pure actual+  value <- Aeson.decode bytes+  err <- field "error" value+  String actual <- field "code" err+  pure actual+ capture :: Wai.Application -> Method -> [Text] -> IO (Status, ResponseHeaders, LBS.ByteString) capture app method path = do-    result <- newIORef Nothing-    _ <- app Wai.defaultRequest{Wai.requestMethod = method, Wai.pathInfo = path} $ \response -> do-        let (status, headers, stream) = Wai.responseToStream response-        stream $ \send -> do-            chunks <- newIORef []-            send (\chunk -> modifyIORef' chunks (<> [toLazyByteString chunk])) (pure ())-            bytes <- mconcat <$> readIORef chunks-            modifyIORef' result (const (Just (status, headers, bytes)))-        pure ResponseReceived-    maybe (error "No response") (\value -> pure value) =<< readIORef result+  result <- newIORef Nothing+  _ <- app Wai.defaultRequest {Wai.requestMethod = method, Wai.pathInfo = path} $ \response -> do+    let (status, headers, stream) = Wai.responseToStream response+    stream $ \send -> do+      chunks <- newIORef []+      send (\chunk -> modifyIORef' chunks (<> [toLazyByteString chunk])) (pure ())+      bytes <- mconcat <$> readIORef chunks+      modifyIORef' result (const (Just (status, headers, bytes)))+    pure ResponseReceived+  maybe (error "No response") (\value -> pure value) =<< readIORef result
test/Test/CapabilitiesSpec.hs view
@@ -30,138 +30,138 @@  spec :: Spec spec = describe "Kiroku.Metrics.Capabilities" $ do-    it "pins every wire key, includes Prometheus as process-local, and round-trips" $ do-        let caps = capabilitiesFor defaultConfig allPresent-            keys = ["metrics", "prometheus", "health", "subscriptions_live", "subscriptions_checkpoints", "dead_letters", "browse", "websocket_metrics", "websocket_events"]-        A.toJSON caps-            `shouldBe` A.object-                [ "package" A..= ("kiroku-metrics" :: Text)-                , "version" A..= kirokuMetricsVersion-                , "routes" A..= A.object [key A..= True | key <- keys]-                , "cors" A..= A.object ["enabled" A..= False]-                , "process_local" A..= (["metrics", "prometheus", "health", "subscriptions_live", "websocket_metrics"] :: [Text])-                ]-        A.eitherDecode (A.encode caps) `shouldBe` Right caps-        kirokuMetricsVersion `shouldSatisfy` (T.isInfixOf ".")-    it "derives availability from wiring and switches without invoking any provider" $ do-        let plain = capabilitiesFor defaultConfig none-            off = capabilitiesFor defaultConfig{enableJSON = False, enablePrometheus = False, enableWebSocket = False} allPresent-        plain.routes `shouldBe` RouteAvailability True True True False False False False False False-        off.routes `shouldBe` RouteAvailability False False False True True True True False False-        origin <- either (fail . show) pure (allowedOrigin "http://a")-        (capabilitiesFor defaultConfig{cors = corsAllowOrigins [origin]} none).corsIsEnabled `shouldBe` True-        let providers =-                defaultServerProviders-                    { subscriptionStatus = Just (fail "Discovery invoked live provider")-                    , checkpointInventory = Just (fail "Discovery invoked checkpoint provider")-                    , storeBrowsing = Just (StoreBrowser (\_ -> fail "Discovery invoked browse provider") defaultBrowseLimits)-                    , deadLetters = Just (\_ -> fail "Discovery invoked dead-letter provider")-                    }-        m <- emptyMetrics-        Warp.testWithApplication (pure (httpAppWithProviders defaultConfig m [] providers)) $ \port -> do-            caps <- getCaps port-            caps.routes.subscriptionsLive `shouldBe` True-            caps.routes.subscriptionsCheckpoints `shouldBe` True-            caps.routes.browse `shouldBe` True-            caps.routes.deadLetters `shouldBe` True-    it "implements GET, bodyless HEAD, exact path and 405 in direct WAI" $ do-        let app = capabilitiesApp (capabilitiesFor defaultConfig none)-        (s, h, b) <- capture app "GET" ["capabilities"]-        s `shouldBe` status200-        capture app "HEAD" ["capabilities"] `shouldReturn` (s, h, "")-        b `shouldBe` A.encode (capabilitiesFor defaultConfig none)-        forM_ ["POST", "PUT", "DELETE", "OPTIONS"] $ \method -> do-            (status, headers, errorBody) <- capture app method ["capabilities"]-            status `shouldBe` status405-            errorCode errorBody `shouldBe` Just "method_not_allowed"-            lookup "Allow" headers `shouldBe` Just "GET, HEAD"-        (unknown, unknownHeaders, unknownBody) <- capture app "GET" ["capabilities", "x"]-        unknown `shouldBe` status404-        errorCode unknownBody `shouldBe` Just "not_found"-        capture app "HEAD" ["capabilities", "x"] `shouldReturn` (unknown, unknownHeaders, "")-    it "reports plain/stub servers honestly and remains reachable with every switch off" $ do-        m <- emptyMetrics-        forM_ [defaultConfig{port = 0}, defaultConfig{port = 0, enableJSON = False, enablePrometheus = False, enableWebSocket = False}] $ \cfg ->-            withMetricsServer cfg m [] $ \server -> do-                caps <- getCaps server.serverPort-                caps `shouldBe` capabilitiesFor cfg none-    it "reports all store providers and the legacy store starter's absent live registry" $-        withMigratedTestDatabase $ \url -> Store.withStore (Store.defaultConnectionSettings url) $ \store -> do-            m <- emptyMetrics-            let cfg = defaultConfig{port = 0}-            providers <- storeServerProviders cfg m store-            withMetricsServerWithProviders cfg m [] providers $ \server -> do-                getCaps server.serverPort `shouldReturn` capabilitiesFor cfg allPresent-                forM_ ["/metrics", "/metrics/prometheus", "/health", "/subscriptions", "/subscription-checkpoints", "/streams", "/subscriptions/missing/dead-letters"] $ \path ->-                    HTTP.responseStatus <$> get server.serverPort path `shouldReturn` status200-                forM_ ["/ws/metrics", "/ws/events"] $ \path ->-                    bounded (WS.runClient "127.0.0.1" server.serverPort path (\conn -> WS.sendClose conn ("done" :: Text)))-            withMetricsServerWithStore cfg m store [] $ \server -> do-                caps <- getCaps server.serverPort-                caps.routes.subscriptionsLive `shouldBe` False-                caps.routes.websocketMetrics `shouldBe` True-                caps.routes.websocketEvents `shouldBe` True-    it "honours custom declarations, refuses disabled upgrades and keeps legacy opaque apps conservative" $ do-        m <- emptyMetrics-        let ws connectionRequest = WS.acceptRequest connectionRequest >>= \conn -> WS.sendTextData conn ("ok" :: Text)-            providers = defaultServerProviders{webSocketServer = ws, webSocketChannels = WebSocketChannels False True}-        forM_ [True, False] $ \enabled -> do-            let cfg = defaultConfig{port = 0, enableWebSocket = enabled}-            withMetricsServerWithProviders cfg m [] providers $ \server -> do-                caps <- getCaps server.serverPort-                caps.routes.websocketEvents `shouldBe` enabled-                result <- bounded (try (WS.runClient "127.0.0.1" server.serverPort "/ws/events" (\conn -> WS.receiveData conn :: IO Text)) :: IO (Either SomeException Text))-                if enabled then either (fail . show) (`shouldBe` "ok") result else result `shouldSatisfy` isLeft-        let cfg = defaultConfig{port = 0}-        Warp.testWithApplication (pure (combinedApp cfg m [] Nothing ws)) $ \port -> do-            caps <- getCaps port-            caps.routes.websocketEvents `shouldBe` False-    it "serves discovery behind a mount prefix and preserves the configured CORS policy" $ do-        m <- emptyMetrics-        origin <- either (fail . show) pure (allowedOrigin "http://a")-        let cfg = defaultConfig{cors = corsAllowOrigins [origin]}-            mounted req = combinedAppWithProviders cfg m [] defaultServerProviders req{Wai.pathInfo = drop 1 (Wai.pathInfo req)}-        Warp.testWithApplication (pure mounted) $ \port -> do-            manager <- HTTP.newManager HTTP.defaultManagerSettings-            req <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/kiroku/capabilities")-            response <- HTTP.httpLbs req{HTTP.requestHeaders = [("Origin", "http://a")]} manager-            HTTP.responseStatus response `shouldBe` status200-            lookup "Access-Control-Allow-Origin" (HTTP.responseHeaders response) `shouldBe` Just "http://a"-            A.eitherDecode (HTTP.responseBody response) `shouldBe` Right (capabilitiesFor cfg none)+  it "pins every wire key, includes Prometheus as process-local, and round-trips" $ do+    let caps = capabilitiesFor defaultConfig allPresent+        keys = ["metrics", "prometheus", "health", "subscriptions_live", "subscriptions_checkpoints", "dead_letters", "browse", "websocket_metrics", "websocket_events"]+    A.toJSON caps+      `shouldBe` A.object+        [ "package" A..= ("kiroku-metrics" :: Text),+          "version" A..= kirokuMetricsVersion,+          "routes" A..= A.object [key A..= True | key <- keys],+          "cors" A..= A.object ["enabled" A..= False],+          "process_local" A..= (["metrics", "prometheus", "health", "subscriptions_live", "websocket_metrics"] :: [Text])+        ]+    A.eitherDecode (A.encode caps) `shouldBe` Right caps+    kirokuMetricsVersion `shouldSatisfy` (T.isInfixOf ".")+  it "derives availability from wiring and switches without invoking any provider" $ do+    let plain = capabilitiesFor defaultConfig none+        off = capabilitiesFor defaultConfig {enableJSON = False, enablePrometheus = False, enableWebSocket = False} allPresent+    plain.routes `shouldBe` RouteAvailability True True True False False False False False False+    off.routes `shouldBe` RouteAvailability False False False True True True True False False+    origin <- either (fail . show) pure (allowedOrigin "http://a")+    (capabilitiesFor defaultConfig {cors = corsAllowOrigins [origin]} none).corsIsEnabled `shouldBe` True+    let providers =+          defaultServerProviders+            { subscriptionStatus = Just (fail "Discovery invoked live provider"),+              checkpointInventory = Just (fail "Discovery invoked checkpoint provider"),+              storeBrowsing = Just (StoreBrowser (\_ -> fail "Discovery invoked browse provider") defaultBrowseLimits),+              deadLetters = Just (\_ -> fail "Discovery invoked dead-letter provider")+            }+    m <- emptyMetrics+    Warp.testWithApplication (pure (httpAppWithProviders defaultConfig m [] providers)) $ \port -> do+      caps <- getCaps port+      caps.routes.subscriptionsLive `shouldBe` True+      caps.routes.subscriptionsCheckpoints `shouldBe` True+      caps.routes.browse `shouldBe` True+      caps.routes.deadLetters `shouldBe` True+  it "implements GET, bodyless HEAD, exact path and 405 in direct WAI" $ do+    let app = capabilitiesApp (capabilitiesFor defaultConfig none)+    (s, h, b) <- capture app "GET" ["capabilities"]+    s `shouldBe` status200+    capture app "HEAD" ["capabilities"] `shouldReturn` (s, h, "")+    b `shouldBe` A.encode (capabilitiesFor defaultConfig none)+    forM_ ["POST", "PUT", "DELETE", "OPTIONS"] $ \method -> do+      (status, headers, errorBody) <- capture app method ["capabilities"]+      status `shouldBe` status405+      errorCode errorBody `shouldBe` Just "method_not_allowed"+      lookup "Allow" headers `shouldBe` Just "GET, HEAD"+    (unknown, unknownHeaders, unknownBody) <- capture app "GET" ["capabilities", "x"]+    unknown `shouldBe` status404+    errorCode unknownBody `shouldBe` Just "not_found"+    capture app "HEAD" ["capabilities", "x"] `shouldReturn` (unknown, unknownHeaders, "")+  it "reports plain/stub servers honestly and remains reachable with every switch off" $ do+    m <- emptyMetrics+    forM_ [defaultConfig {port = 0}, defaultConfig {port = 0, enableJSON = False, enablePrometheus = False, enableWebSocket = False}] $ \cfg ->+      withMetricsServer cfg m [] $ \server -> do+        caps <- getCaps server.serverPort+        caps `shouldBe` capabilitiesFor cfg none+  it "reports all store providers and the legacy store starter's absent live registry" $+    withMigratedTestDatabase $ \url -> Store.withStore (Store.defaultConnectionSettings url) $ \store -> do+      m <- emptyMetrics+      let cfg = defaultConfig {port = 0}+      providers <- storeServerProviders cfg m store+      withMetricsServerWithProviders cfg m [] providers $ \server -> do+        getCaps server.serverPort `shouldReturn` capabilitiesFor cfg allPresent+        forM_ ["/metrics", "/metrics/prometheus", "/health", "/subscriptions", "/subscription-checkpoints", "/streams", "/subscriptions/missing/dead-letters"] $ \path ->+          HTTP.responseStatus <$> get server.serverPort path `shouldReturn` status200+        forM_ ["/ws/metrics", "/ws/events"] $ \path ->+          bounded (WS.runClient "127.0.0.1" server.serverPort path (\conn -> WS.sendClose conn ("done" :: Text)))+      withMetricsServerWithStore cfg m store [] $ \server -> do+        caps <- getCaps server.serverPort+        caps.routes.subscriptionsLive `shouldBe` False+        caps.routes.websocketMetrics `shouldBe` True+        caps.routes.websocketEvents `shouldBe` True+  it "honours custom declarations, refuses disabled upgrades and keeps legacy opaque apps conservative" $ do+    m <- emptyMetrics+    let ws connectionRequest = WS.acceptRequest connectionRequest >>= \conn -> WS.sendTextData conn ("ok" :: Text)+        providers = defaultServerProviders {webSocketServer = ws, webSocketChannels = WebSocketChannels False True}+    forM_ [True, False] $ \enabled -> do+      let cfg = defaultConfig {port = 0, enableWebSocket = enabled}+      withMetricsServerWithProviders cfg m [] providers $ \server -> do+        caps <- getCaps server.serverPort+        caps.routes.websocketEvents `shouldBe` enabled+        result <- bounded (try (WS.runClient "127.0.0.1" server.serverPort "/ws/events" (\conn -> WS.receiveData conn :: IO Text)) :: IO (Either SomeException Text))+        if enabled then either (fail . show) (`shouldBe` "ok") result else result `shouldSatisfy` isLeft+    let cfg = defaultConfig {port = 0}+    Warp.testWithApplication (pure (combinedApp cfg m [] Nothing ws)) $ \port -> do+      caps <- getCaps port+      caps.routes.websocketEvents `shouldBe` False+  it "serves discovery behind a mount prefix and preserves the configured CORS policy" $ do+    m <- emptyMetrics+    origin <- either (fail . show) pure (allowedOrigin "http://a")+    let cfg = defaultConfig {cors = corsAllowOrigins [origin]}+        mounted req = combinedAppWithProviders cfg m [] defaultServerProviders req {Wai.pathInfo = drop 1 (Wai.pathInfo req)}+    Warp.testWithApplication (pure mounted) $ \port -> do+      manager <- HTTP.newManager HTTP.defaultManagerSettings+      req <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/kiroku/capabilities")+      response <- HTTP.httpLbs req {HTTP.requestHeaders = [("Origin", "http://a")]} manager+      HTTP.responseStatus response `shouldBe` status200+      lookup "Access-Control-Allow-Origin" (HTTP.responseHeaders response) `shouldBe` Just "http://a"+      A.eitherDecode (HTTP.responseBody response) `shouldBe` Right (capabilitiesFor cfg none)  emptyMetrics :: IO KirokuMetrics emptyMetrics = newKirokuMetricsWith (pure (Store.GlobalPosition 0)) (pure 0)  get :: Int -> String -> IO (HTTP.Response LBS.ByteString) get port path = do-    manager <- HTTP.newManager HTTP.defaultManagerSettings-    req <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> path)-    HTTP.httpLbs req manager+  manager <- HTTP.newManager HTTP.defaultManagerSettings+  req <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> path)+  HTTP.httpLbs req manager  getCaps :: Int -> IO Capabilities getCaps port = do-    response <- get port "/capabilities"-    HTTP.responseStatus response `shouldBe` status200-    either fail pure (A.eitherDecode (HTTP.responseBody response))+  response <- get port "/capabilities"+  HTTP.responseStatus response `shouldBe` status200+  either fail pure (A.eitherDecode (HTTP.responseBody response))  capture :: Wai.Application -> Method -> [Text] -> IO (Status, ResponseHeaders, LBS.ByteString) capture app method path = do-    result <- newIORef Nothing-    _ <- app Wai.defaultRequest{Wai.requestMethod = method, Wai.pathInfo = path} $ \response -> do-        let (status, headers, stream) = Wai.responseToStream response-        chunks <- newIORef mempty-        stream $ \body -> body (\builder -> modifyIORef' chunks (<> builder)) (pure ())-        bytes <- toLazyByteString <$> readIORef chunks-        writeIORef result (Just (status, headers, bytes))-        pure ResponseReceived-    readIORef result >>= maybe (fail "No response") pure+  result <- newIORef Nothing+  _ <- app Wai.defaultRequest {Wai.requestMethod = method, Wai.pathInfo = path} $ \response -> do+    let (status, headers, stream) = Wai.responseToStream response+    chunks <- newIORef mempty+    stream $ \body -> body (\builder -> modifyIORef' chunks (<> builder)) (pure ())+    bytes <- toLazyByteString <$> readIORef chunks+    writeIORef result (Just (status, headers, bytes))+    pure ResponseReceived+  readIORef result >>= maybe (fail "No response") pure  bounded :: IO a -> IO a bounded action = timeout 15_000_000 action >>= maybe (fail "Timed out") pure  errorCode :: LBS.ByteString -> Maybe Text errorCode body = do-    A.Object root <- A.decode body-    A.Object err <- KM.lookup "error" root-    A.String code <- KM.lookup "code" err-    pure code+  A.Object root <- A.decode body+  A.Object err <- KM.lookup "error" root+  A.String code <- KM.lookup "code" err+  pure code
test/Test/CheckpointsSpec.hs view
@@ -53,268 +53,268 @@  spec :: Spec spec = describe "Kiroku.Metrics.Checkpoints (/subscription-checkpoints)" $ do-    it "pins exact snake_case keys, preserves row order and round-trips lossless Int64 positions" $ do-        let response = checkpointInventoryResponse fixture-            row name index cp = Aeson.object ["subscription" Aeson..= (name :: Text), "member" Aeson..= (index :: Int32), "checkpoint_position" Aeson..= (cp :: Int64), "updated_at" Aeson..= ("2026-09-10T00:00:00Z" :: Text)]-        Aeson.toJSON response `shouldBe` Aeson.object ["store_position" Aeson..= (17 :: Int64), "checkpoints" Aeson..= [row "beta" 1 11, row "alpha" 0 7]]-        Aeson.eitherDecode (Aeson.encode response) `shouldBe` Right response-        let large = CheckpointInventoryResponse maxBound [CheckpointRow "large" 0 9007199254740993 (UTCTime (fromGregorian 2026 9 10) 0)]-        Aeson.eitherDecode (Aeson.encode large) `shouldBe` Right large-    it "serves an inventory and a structured standalone 404" $-        Warp.testWithApplication (pure (checkpointsApp (pure (Right fixture)))) $ \port -> do-            manager <- HTTP.newManager HTTP.defaultManagerSettings-            request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/subscription-checkpoints")-            response <- HTTP.httpLbs request manager-            HTTP.responseStatus response `shouldBe` status200-            Aeson.eitherDecode (HTTP.responseBody response) `shouldBe` Right (checkpointInventoryResponse fixture)-            unknown <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/unknown") >>= flip HTTP.httpLbs manager-            HTTP.responseStatus unknown `shouldBe` status404-            Aeson.decode (HTTP.responseBody unknown) `shouldBe` Just (Aeson.object ["error" Aeson..= Aeson.object ["code" Aeson..= ("not_found" :: Text), "message" Aeson..= ("Not found" :: Text)]])--    it "implements HEAD and 405 directly in WAI and calls the provider once per read" $ do-        calls <- newIORef (0 :: Int)-        let app = checkpointsApp (modifyIORef' calls (+ 1) >> pure (Right fixture))-        getResponse <- capture app "GET"-        (headStatus, headHeaders, headBody) <- capture app "HEAD"-        let (getStatus, getHeaders, _) = getResponse-        headStatus `shouldBe` getStatus-        headHeaders `shouldBe` getHeaders-        headBody `shouldBe` ""-        forM_ ["POST", "PUT", "DELETE", "OPTIONS"] $ \method -> do-            (status, headers, body) <- capture app method-            status `shouldBe` status405-            lookup "Allow" headers `shouldBe` Just "GET, HEAD"-            code body `shouldBe` Just "method_not_allowed"-        readIORef calls `shouldReturn` 2+  it "pins exact snake_case keys, preserves row order and round-trips lossless Int64 positions" $ do+    let response = checkpointInventoryResponse fixture+        row name index cp = Aeson.object ["subscription" Aeson..= (name :: Text), "member" Aeson..= (index :: Int32), "checkpoint_position" Aeson..= (cp :: Int64), "updated_at" Aeson..= ("2026-09-10T00:00:00Z" :: Text)]+    Aeson.toJSON response `shouldBe` Aeson.object ["store_position" Aeson..= (17 :: Int64), "checkpoints" Aeson..= [row "beta" 1 11, row "alpha" 0 7]]+    Aeson.eitherDecode (Aeson.encode response) `shouldBe` Right response+    let large = CheckpointInventoryResponse maxBound [CheckpointRow "large" 0 9007199254740993 (UTCTime (fromGregorian 2026 9 10) 0)]+    Aeson.eitherDecode (Aeson.encode large) `shouldBe` Right large+  it "serves an inventory and a structured standalone 404" $+    Warp.testWithApplication (pure (checkpointsApp (pure (Right fixture)))) $ \port -> do+      manager <- HTTP.newManager HTTP.defaultManagerSettings+      request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/subscription-checkpoints")+      response <- HTTP.httpLbs request manager+      HTTP.responseStatus response `shouldBe` status200+      Aeson.eitherDecode (HTTP.responseBody response) `shouldBe` Right (checkpointInventoryResponse fixture)+      unknown <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/unknown") >>= flip HTTP.httpLbs manager+      HTTP.responseStatus unknown `shouldBe` status404+      Aeson.decode (HTTP.responseBody unknown) `shouldBe` Just (Aeson.object ["error" Aeson..= Aeson.object ["code" Aeson..= ("not_found" :: Text), "message" Aeson..= ("Not found" :: Text)]]) -    it "sanitizes typed failures and preserves HEAD error status and headers" $ do-        let failures = [(Store.ConnectionError "postgres://secret", status503, "checkpoint_inventory_unavailable"), (Store.ConnectionLost "secret", status503, "checkpoint_inventory_unavailable"), (Store.PoolAcquisitionTimeout, status503, "checkpoint_inventory_unavailable"), (Store.TransientTransactionFailure "40001" "secret", status503, "checkpoint_inventory_unavailable"), (Store.TransientTransactionFailure "40P01" "secret", status503, "checkpoint_inventory_unavailable"), (Store.UnexpectedServerError "42P01" "secret SQL", status500, "store_error"), (Store.StreamNotFound (Store.StreamName "secret"), status500, "store_error"), (Store.EventDecodeFailed (DecodeFailure (Store.EventId UUID.nil) "secret"), status500, "event_decode_failed")]-        forM_ failures $ \(err, expected, expectedCode) -> do-            let app = checkpointsApp (pure (Left err))-            (status, headers, body) <- capture app "GET"-            status `shouldBe` expected-            code body `shouldBe` Just expectedCode-            body `shouldSatisfy` (not . T.isInfixOf "secret" . T.pack . LBSC.unpack)-            capture app "HEAD" `shouldReturn` (status, headers, "")+  it "implements HEAD and 405 directly in WAI and calls the provider once per read" $ do+    calls <- newIORef (0 :: Int)+    let app = checkpointsApp (modifyIORef' calls (+ 1) >> pure (Right fixture))+    getResponse <- capture app "GET"+    (headStatus, headHeaders, headBody) <- capture app "HEAD"+    let (getStatus, getHeaders, _) = getResponse+    headStatus `shouldBe` getStatus+    headHeaders `shouldBe` getHeaders+    headBody `shouldBe` ""+    forM_ ["POST", "PUT", "DELETE", "OPTIONS"] $ \method -> do+      (status, headers, body) <- capture app method+      status `shouldBe` status405+      lookup "Allow" headers `shouldBe` Just "GET, HEAD"+      code body `shouldBe` Just "method_not_allowed"+    readIORef calls `shouldReturn` 2 -    it "does not disguise thrown provider exceptions or cancellation as store errors" $ do-        capture (checkpointsApp (throwIO (userError "provider failed"))) "GET" `shouldThrow` anyIOException-        capture (checkpointsApp (throwIO Async.AsyncCancelled)) "GET" `shouldThrow` (\(_ :: Async.AsyncCancelled) -> True)+  it "sanitizes typed failures and preserves HEAD error status and headers" $ do+    let failures = [(Store.ConnectionError "postgres://secret", status503, "checkpoint_inventory_unavailable"), (Store.ConnectionLost "secret", status503, "checkpoint_inventory_unavailable"), (Store.PoolAcquisitionTimeout, status503, "checkpoint_inventory_unavailable"), (Store.TransientTransactionFailure "40001" "secret", status503, "checkpoint_inventory_unavailable"), (Store.TransientTransactionFailure "40P01" "secret", status503, "checkpoint_inventory_unavailable"), (Store.UnexpectedServerError "42P01" "secret SQL", status500, "store_error"), (Store.StreamNotFound (Store.StreamName "secret"), status500, "store_error"), (Store.EventDecodeFailed (DecodeFailure (Store.EventId UUID.nil) "secret"), status500, "event_decode_failed")]+    forM_ failures $ \(err, expected, expectedCode) -> do+      let app = checkpointsApp (pure (Left err))+      (status, headers, body) <- capture app "GET"+      status `shouldBe` expected+      code body `shouldBe` Just expectedCode+      body `shouldSatisfy` (not . T.isInfixOf "secret" . T.pack . LBSC.unpack)+      capture app "HEAD" `shouldReturn` (status, headers, "") -    it "keeps a stopped worker durable after its live registry entry disappears" $-        withTestStore $ \store -> do-            append store 3-            let name = Store.SubscriptionName "durable-vs-live"-            handle <- Store.subscribe store (Store.defaultSubscriptionConfig name Store.AllStreams (\_ -> pure Store.Continue))-            awaitLive store name-            Store.cancel handle-            stopped <- timeout 10_000_000 (Store.wait handle)-            stopped `shouldSatisfy` isJust-            states <- Store.subscriptionStates store-            Map.member (name, 0) states `shouldBe` False-            metrics <- newKirokuMetrics store-            let cfg = defaultConfig{port = 0}-            providers <- storeServerProviders cfg metrics store-            withMetricsServerWithProviders cfg metrics [] providers $ \server -> do-                live <- get server.serverPort "/subscriptions"-                HTTP.responseStatus live `shouldBe` status200-                Aeson.decode (HTTP.responseBody live) `shouldBe` Just ([] :: [SubscriptionStatusRow])-                inventory <- getInventory server.serverPort-                inventory.storePosition `shouldBe` 3-                triples inventory `shouldBe` [("durable-vs-live", 0, 3)]+  it "does not disguise thrown provider exceptions or cancellation as store errors" $ do+    capture (checkpointsApp (throwIO (userError "provider failed"))) "GET" `shouldThrow` anyIOException+    capture (checkpointsApp (throwIO Async.AsyncCancelled)) "GET" `shouldThrow` (\(_ :: Async.AsyncCancelled) -> True) -    it "preserves SQL name and numeric member ordering with the captured frontier" $-        withTestStore $ \store -> do-            append store 20-            seed store "zeta" 2 7-            seed store "alpha" 10 3-            seed store "alpha" 2 5-            metrics <- newKirokuMetrics store-            withMetricsServerWithStore defaultConfig{port = 0} metrics store [] $ \server -> do-                inventory <- getInventory server.serverPort-                inventory.storePosition `shouldBe` 20-                triples inventory `shouldBe` [("alpha", 2, 5), ("alpha", 10, 3), ("zeta", 2, 7)]+  it "keeps a stopped worker durable after its live registry entry disappears" $+    withTestStore $ \store -> do+      append store 3+      let name = Store.SubscriptionName "durable-vs-live"+      handle <- Store.subscribe store (Store.defaultSubscriptionConfig name Store.AllStreams (\_ -> pure Store.Continue))+      awaitLive store name+      Store.cancel handle+      stopped <- timeout 10_000_000 (Store.wait handle)+      stopped `shouldSatisfy` isJust+      states <- Store.subscriptionStates store+      Map.member (name, 0) states `shouldBe` False+      metrics <- newKirokuMetrics store+      let cfg = defaultConfig {port = 0}+      providers <- storeServerProviders cfg metrics store+      withMetricsServerWithProviders cfg metrics [] providers $ \server -> do+        live <- get server.serverPort "/subscriptions"+        HTTP.responseStatus live `shouldBe` status200+        Aeson.decode (HTTP.responseBody live) `shouldBe` Just ([] :: [SubscriptionStatusRow])+        inventory <- getInventory server.serverPort+        inventory.storePosition `shouldBe` 3+        triples inventory `shouldBe` [("durable-vs-live", 0, 3)] -    it "returns equal quiescent inventories across handles with different live providers" $-        withMigratedTestDatabase $ \connStr ->-            Store.withStore (Store.defaultConnectionSettings connStr) $ \storeA ->-                Store.withStore (Store.defaultConnectionSettings connStr) $ \storeB -> do-                    append storeA 3-                    let name = Store.SubscriptionName "worker-a"-                    handle <- Store.subscribe storeA (Store.defaultSubscriptionConfig name Store.AllStreams (\_ -> pure Store.Continue))-                    awaitLive storeA name-                    seed storeB "worker-b" 0 2-                    metricsA <- newKirokuMetrics storeA-                    metricsB <- newKirokuMetrics storeB-                    let cfg = defaultConfig{port = 0}-                    providersA <- storeServerProviders cfg metricsA storeA-                    providersB <- storeServerProviders cfg metricsB storeB-                    withMetricsServerWithProviders cfg metricsA [] providersA $ \serverA ->-                        withMetricsServerWithProviders cfg metricsB [] providersB $ \serverB -> do-                            liveBodyA <- HTTP.responseBody <$> get serverA.serverPort "/subscriptions"-                            liveBodyB <- HTTP.responseBody <$> get serverB.serverPort "/subscriptions"-                            liveBodyA `shouldNotBe` liveBodyB-                            -- Stop the worker before comparing separate SQL snapshots.-                            Store.cancel handle-                            _ <- Store.wait handle-                            a <- getInventory serverA.serverPort-                            b <- getInventory serverB.serverPort-                            a `shouldBe` b+  it "preserves SQL name and numeric member ordering with the captured frontier" $+    withTestStore $ \store -> do+      append store 20+      seed store "zeta" 2 7+      seed store "alpha" 10 3+      seed store "alpha" 2 5+      metrics <- newKirokuMetrics store+      withMetricsServerWithStore defaultConfig {port = 0} metrics store [] $ \server -> do+        inventory <- getInventory server.serverPort+        inventory.storePosition `shouldBe` 20+        triples inventory `shouldBe` [("alpha", 2, 5), ("alpha", 10, 3), ("zeta", 2, 7)] -    it "serves an empty store without changing the legacy unconfigured live response" $-        withTestStore $ \store -> do-            metrics <- newKirokuMetrics store-            withMetricsServerWithStore defaultConfig{port = 0} metrics store [] $ \server -> do-                getInventory server.serverPort `shouldReturn` CheckpointInventoryResponse 0 []-                live <- get server.serverPort "/subscriptions"-                HTTP.responseStatus live `shouldBe` status404-                Aeson.decode (HTTP.responseBody live) `shouldBe` Just (Aeson.object ["error" Aeson..= ("subscription status not configured" :: Text)])+  it "returns equal quiescent inventories across handles with different live providers" $+    withMigratedTestDatabase $ \connStr ->+      Store.withStore (Store.defaultConnectionSettings connStr) $ \storeA ->+        Store.withStore (Store.defaultConnectionSettings connStr) $ \storeB -> do+          append storeA 3+          let name = Store.SubscriptionName "worker-a"+          handle <- Store.subscribe storeA (Store.defaultSubscriptionConfig name Store.AllStreams (\_ -> pure Store.Continue))+          awaitLive storeA name+          seed storeB "worker-b" 0 2+          metricsA <- newKirokuMetrics storeA+          metricsB <- newKirokuMetrics storeB+          let cfg = defaultConfig {port = 0}+          providersA <- storeServerProviders cfg metricsA storeA+          providersB <- storeServerProviders cfg metricsB storeB+          withMetricsServerWithProviders cfg metricsA [] providersA $ \serverA ->+            withMetricsServerWithProviders cfg metricsB [] providersB $ \serverB -> do+              liveBodyA <- HTTP.responseBody <$> get serverA.serverPort "/subscriptions"+              liveBodyB <- HTTP.responseBody <$> get serverB.serverPort "/subscriptions"+              liveBodyA `shouldNotBe` liveBodyB+              -- Stop the worker before comparing separate SQL snapshots.+              Store.cancel handle+              _ <- Store.wait handle+              a <- getInventory serverA.serverPort+              b <- getInventory serverB.serverPort+              a `shouldBe` b -    it "returns structured not-configured errors without shadowing a live name checkpoints" $ do-        metrics <- emptyMetrics-        let live = pure [SubscriptionStatusRow "checkpoints" 0 "live" 7]-        withMetricsServerSubscriptions defaultConfig{port = 0} metrics [] live $ \server -> do-            inventory <- get server.serverPort "/subscription-checkpoints"-            HTTP.responseStatus inventory `shouldBe` status404-            code (HTTP.responseBody inventory) `shouldBe` Just "checkpoint_inventory_not_configured"-            named <- get server.serverPort "/subscriptions/checkpoints"-            HTTP.responseStatus named `shouldBe` status200-            Aeson.decode (HTTP.responseBody named) `shouldBe` Just [SubscriptionStatusRow "checkpoints" 0 "live" 7]-        let app = httpAppWithProviders defaultConfig metrics [] defaultServerProviders-        (status, headers, _) <- capture app "GET"-        capture app "HEAD" `shouldReturn` (status, headers, "")-        (methodStatus, methodHeaders, _) <- capture app "POST"-        methodStatus `shouldBe` status405-        lookup "Allow" methodHeaders `shouldBe` Just "GET, HEAD"+  it "serves an empty store without changing the legacy unconfigured live response" $+    withTestStore $ \store -> do+      metrics <- newKirokuMetrics store+      withMetricsServerWithStore defaultConfig {port = 0} metrics store [] $ \server -> do+        getInventory server.serverPort `shouldReturn` CheckpointInventoryResponse 0 []+        live <- get server.serverPort "/subscriptions"+        HTTP.responseStatus live `shouldBe` status404+        Aeson.decode (HTTP.responseBody live) `shouldBe` Just (Aeson.object ["error" Aeson..= ("subscription status not configured" :: Text)]) -    it "mounts HTTP and the real event WebSocket when the host strips only pathInfo" $-        withTestStore $ \store -> do-            metrics <- newKirokuMetrics store-            providers <- storeServerProviders defaultConfig metrics store-            let composed = combinedAppWithProviders defaultConfig metrics [] providers-                mounted req respond = composed req{Wai.pathInfo = drop 1 (Wai.pathInfo req)} respond-            Warp.testWithApplication (pure mounted) $ \port -> do-                response <- get port "/kiroku/subscription-checkpoints"-                HTTP.responseStatus response `shouldBe` status200-                bounded $ WS.runClient "127.0.0.1" port "/kiroku/ws/events" $ \conn -> do-                    WS.sendTextData conn (Aeson.encode $ Aeson.object ["type" Aeson..= ("subscribe_events" :: Text)])-                    raw <- WS.receiveData conn :: IO LBS.ByteString-                    frameType raw `shouldBe` Just "event_stream_started"+  it "returns structured not-configured errors without shadowing a live name checkpoints" $ do+    metrics <- emptyMetrics+    let live = pure [SubscriptionStatusRow "checkpoints" 0 "live" 7]+    withMetricsServerSubscriptions defaultConfig {port = 0} metrics [] live $ \server -> do+      inventory <- get server.serverPort "/subscription-checkpoints"+      HTTP.responseStatus inventory `shouldBe` status404+      code (HTTP.responseBody inventory) `shouldBe` Just "checkpoint_inventory_not_configured"+      named <- get server.serverPort "/subscriptions/checkpoints"+      HTTP.responseStatus named `shouldBe` status200+      Aeson.decode (HTTP.responseBody named) `shouldBe` Just [SubscriptionStatusRow "checkpoints" 0 "live" 7]+    let app = httpAppWithProviders defaultConfig metrics [] defaultServerProviders+    (status, headers, _) <- capture app "GET"+    capture app "HEAD" `shouldReturn` (status, headers, "")+    (methodStatus, methodHeaders, _) <- capture app "POST"+    methodStatus `shouldBe` status405+    lookup "Allow" methodHeaders `shouldBe` Just "GET, HEAD" -    it "escapes mounted WebSocket segments and retains the raw query string" $ do-        metrics <- emptyMetrics-        observed <- newEmptyMVar-        let ws pending = do-                putMVar observed (WS.requestPath (WS.pendingRequest pending))-                conn <- WS.acceptRequest pending-                WS.sendTextData conn ("ok" :: Text)-            app = combinedAppWithProviders defaultConfig metrics [] defaultServerProviders{webSocketServer = ws}-            mounted req respond = app req{Wai.pathInfo = drop 1 (Wai.pathInfo req)} respond-        Warp.testWithApplication (pure mounted) $ \port -> do-            bounded $ WS.runClient "127.0.0.1" port "/kiroku/ws/a%20b%2Fc?token=a%2Fb" $ \conn -> do-                WS.receiveData conn `shouldReturn` ("ok" :: Text)-            takeMVar observed `shouldReturn` "/ws/a%20b%2Fc?token=a%2Fb"+  it "mounts HTTP and the real event WebSocket when the host strips only pathInfo" $+    withTestStore $ \store -> do+      metrics <- newKirokuMetrics store+      providers <- storeServerProviders defaultConfig metrics store+      let composed = combinedAppWithProviders defaultConfig metrics [] providers+          mounted req respond = composed req {Wai.pathInfo = drop 1 (Wai.pathInfo req)} respond+      Warp.testWithApplication (pure mounted) $ \port -> do+        response <- get port "/kiroku/subscription-checkpoints"+        HTTP.responseStatus response `shouldBe` status200+        bounded $ WS.runClient "127.0.0.1" port "/kiroku/ws/events" $ \conn -> do+          WS.sendTextData conn (Aeson.encode $ Aeson.object ["type" Aeson..= ("subscribe_events" :: Text)])+          raw <- WS.receiveData conn :: IO LBS.ByteString+          frameType raw `shouldBe` Just "event_stream_started" -    it "enforces disabled WebSockets through both legacy and general composition" $ do-        metrics <- emptyMetrics-        calls <- newIORef (0 :: Int)-        let ws pending = modifyIORef' calls (+ 1) >> WS.rejectRequest pending "called"-            cfg = defaultConfig{enableWebSocket = False}-        forM_ [combinedApp cfg metrics [] Nothing ws, combinedAppWithProviders cfg metrics [] defaultServerProviders{webSocketServer = ws}] $ \app ->-            Warp.testWithApplication (pure app) $ \port -> do-                result <- bounded $ try (WS.runClient "127.0.0.1" port "/ws/metrics" (\_ -> pure ()))-                (result :: Either WS.HandshakeException ()) `shouldSatisfy` isLeft-        readIORef calls `shouldReturn` 0+  it "escapes mounted WebSocket segments and retains the raw query string" $ do+    metrics <- emptyMetrics+    observed <- newEmptyMVar+    let ws pending = do+          putMVar observed (WS.requestPath (WS.pendingRequest pending))+          conn <- WS.acceptRequest pending+          WS.sendTextData conn ("ok" :: Text)+        app = combinedAppWithProviders defaultConfig metrics [] defaultServerProviders {webSocketServer = ws}+        mounted req respond = app req {Wai.pathInfo = drop 1 (Wai.pathInfo req)} respond+    Warp.testWithApplication (pure mounted) $ \port -> do+      bounded $ WS.runClient "127.0.0.1" port "/kiroku/ws/a%20b%2Fc?token=a%2Fb" $ \conn -> do+        WS.receiveData conn `shouldReturn` ("ok" :: Text)+      takeMVar observed `shouldReturn` "/ws/a%20b%2Fc?token=a%2Fb" -    it "does no checkpoint work on legacy metrics reads and invokes inventory only on demand" $ do-        metrics <- emptyMetrics-        calls <- newIORef (0 :: Int)-        let provider = modifyIORef' calls (+ 1) >> pure (Right fixture)-            providers = defaultServerProviders{checkpointInventory = Just provider}-            app = httpAppWithProviders defaultConfig metrics [] providers-            metricsApp req respond = app req{Wai.pathInfo = ["metrics"]} respond-        forM_ [1 .. 20 :: Int] $ \_ -> do-            (status, _, _) <- capture metricsApp "GET"-            status `shouldBe` status200-        readIORef calls `shouldReturn` 0-        _ <- capture app "GET"-        readIORef calls `shouldReturn` 1+  it "enforces disabled WebSockets through both legacy and general composition" $ do+    metrics <- emptyMetrics+    calls <- newIORef (0 :: Int)+    let ws pending = modifyIORef' calls (+ 1) >> WS.rejectRequest pending "called"+        cfg = defaultConfig {enableWebSocket = False}+    forM_ [combinedApp cfg metrics [] Nothing ws, combinedAppWithProviders cfg metrics [] defaultServerProviders {webSocketServer = ws}] $ \app ->+      Warp.testWithApplication (pure app) $ \port -> do+        result <- bounded $ try (WS.runClient "127.0.0.1" port "/ws/metrics" (\_ -> pure ()))+        (result :: Either WS.HandshakeException ()) `shouldSatisfy` isLeft+    readIORef calls `shouldReturn` 0 -    it "inherits exactly one CORS wrap on the provider composition" $ do-        metrics <- emptyMetrics-        let origin = either (error . show) id (allowedOrigin "https://ops.example.com")-            cfg = defaultConfig{cors = corsAllowOrigins [origin]}-            app = combinedAppWithProviders cfg metrics [] defaultServerProviders{checkpointInventory = Just (pure (Right fixture))}-        Warp.testWithApplication (pure app) $ \port -> do-            manager <- HTTP.newManager HTTP.defaultManagerSettings-            req <- HTTP.parseRequest (url port "/subscription-checkpoints")-            response <- HTTP.httpLbs req{HTTP.requestHeaders = [("Origin", "https://ops.example.com")]} manager-            HTTP.responseStatus response `shouldBe` status200-            filter ((== "Access-Control-Allow-Origin") . fst) (HTTP.responseHeaders response) `shouldBe` [("Access-Control-Allow-Origin", "https://ops.example.com")]+  it "does no checkpoint work on legacy metrics reads and invokes inventory only on demand" $ do+    metrics <- emptyMetrics+    calls <- newIORef (0 :: Int)+    let provider = modifyIORef' calls (+ 1) >> pure (Right fixture)+        providers = defaultServerProviders {checkpointInventory = Just provider}+        app = httpAppWithProviders defaultConfig metrics [] providers+        metricsApp req respond = app req {Wai.pathInfo = ["metrics"]} respond+    forM_ [1 .. 20 :: Int] $ \_ -> do+      (status, _, _) <- capture metricsApp "GET"+      status `shouldBe` status200+    readIORef calls `shouldReturn` 0+    _ <- capture app "GET"+    readIORef calls `shouldReturn` 1 -    it "is immediately ready on ephemeral and fixed ports and releases each socket" $ do-        metrics <- emptyMetrics-        withMetricsServer defaultConfig{port = 0} metrics [] $ \server -> do-            HTTP.responseStatus <$> get server.serverPort "/metrics" `shouldReturn` status200-        (port, socket) <- Warp.openFreePort-        Socket.close socket-        withMetricsServer defaultConfig{port = port} metrics [] $ \server ->-            HTTP.responseStatus <$> get server.serverPort "/metrics" `shouldReturn` status200-        assertPortReleased port+  it "inherits exactly one CORS wrap on the provider composition" $ do+    metrics <- emptyMetrics+    let origin = either (error . show) id (allowedOrigin "https://ops.example.com")+        cfg = defaultConfig {cors = corsAllowOrigins [origin]}+        app = combinedAppWithProviders cfg metrics [] defaultServerProviders {checkpointInventory = Just (pure (Right fixture))}+    Warp.testWithApplication (pure app) $ \port -> do+      manager <- HTTP.newManager HTTP.defaultManagerSettings+      req <- HTTP.parseRequest (url port "/subscription-checkpoints")+      response <- HTTP.httpLbs req {HTTP.requestHeaders = [("Origin", "https://ops.example.com")]} manager+      HTTP.responseStatus response `shouldBe` status200+      filter ((== "Access-Control-Allow-Origin") . fst) (HTTP.responseHeaders response) `shouldBe` [("Access-Control-Allow-Origin", "https://ops.example.com")] -    it "reports an occupied-port failure to acquisition without invoking the callback" $ do-        metrics <- emptyMetrics-        (port, reserved) <- Warp.openFreePort-        Socket.close reserved-        bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \ipv4 ->-            bracket (Socket.socket Socket.AF_INET6 Socket.Stream Socket.defaultProtocol) Socket.close $ \ipv6 -> do-                -- On macOS a loopback listener can coexist with a wildcard listener.-                -- Occupy both wildcard addresses, as the server binds host "*".-                Socket.bind ipv4 (Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (0, 0, 0, 0)))-                Socket.listen ipv4 1-                Socket.setSocketOption ipv6 Socket.IPv6Only 1-                Socket.bind ipv6 (Socket.SockAddrInet6 (fromIntegral port) 0 (0, 0, 0, 0) 0)-                Socket.listen ipv6 1-                called <- newIORef False-                result <- bounded $ try $ withMetricsServer defaultConfig{port = port} metrics [] (\_ -> writeIORef called True)-                (result :: Either SomeException ()) `shouldSatisfy` isLeft-                readIORef called `shouldReturn` False+  it "is immediately ready on ephemeral and fixed ports and releases each socket" $ do+    metrics <- emptyMetrics+    withMetricsServer defaultConfig {port = 0} metrics [] $ \server -> do+      HTTP.responseStatus <$> get server.serverPort "/metrics" `shouldReturn` status200+    (port, socket) <- Warp.openFreePort+    Socket.close socket+    withMetricsServer defaultConfig {port = port} metrics [] $ \server ->+      HTTP.responseStatus <$> get server.serverPort "/metrics" `shouldReturn` status200+    assertPortReleased port -    it "releases the server after callback failure and propagates unexpected termination" $ do-        metrics <- emptyMetrics-        portRef <- newIORef 0-        result <- bounded $ try $ withMetricsServer defaultConfig{port = 0} metrics [] $ \server -> do-            writeIORef portRef server.serverPort-            throwIO (userError "callback failed")+  it "reports an occupied-port failure to acquisition without invoking the callback" $ do+    metrics <- emptyMetrics+    (port, reserved) <- Warp.openFreePort+    Socket.close reserved+    bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \ipv4 ->+      bracket (Socket.socket Socket.AF_INET6 Socket.Stream Socket.defaultProtocol) Socket.close $ \ipv6 -> do+        -- On macOS a loopback listener can coexist with a wildcard listener.+        -- Occupy both wildcard addresses, as the server binds host "*".+        Socket.bind ipv4 (Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (0, 0, 0, 0)))+        Socket.listen ipv4 1+        Socket.setSocketOption ipv6 Socket.IPv6Only 1+        Socket.bind ipv6 (Socket.SockAddrInet6 (fromIntegral port) 0 (0, 0, 0, 0) 0)+        Socket.listen ipv6 1+        called <- newIORef False+        result <- bounded $ try $ withMetricsServer defaultConfig {port = port} metrics [] (\_ -> writeIORef called True)         (result :: Either SomeException ()) `shouldSatisfy` isLeft-        readIORef portRef >>= assertPortReleased-        callbackStopped <- newEmptyMVar-        result2 <- bounded $ try $ withMetricsServer defaultConfig{port = 0} metrics [] $ \server ->-            ( do-                writeIORef portRef server.serverPort-                Async.cancel server.serverThread-                threadDelay 10_000_000-            )-                `finally` putMVar callbackStopped ()-        (result2 :: Either SomeException ()) `shouldSatisfy` isLeft-        bounded (takeMVar callbackStopped)-        readIORef portRef >>= assertPortReleased+        readIORef called `shouldReturn` False -    it "cancels acquisition or its returned lifetime without leaking a fixed-port listener" $ do-        metrics <- emptyMetrics-        (port, socket) <- Warp.openFreePort-        Socket.close socket-        -- Exercise the acquisition boundary and the readiness/callback boundary.-        forM_ [0, 100, 1000] $ \delay -> do-            entered <- newEmptyMVar-            thread <- Async.async $ do-                putMVar entered ()-                withMetricsServer defaultConfig{port = port} metrics [] (\_ -> threadDelay 10_000_000)-            takeMVar entered-            threadDelay delay-            bounded (Async.cancel thread)-            assertPortReleased port+  it "releases the server after callback failure and propagates unexpected termination" $ do+    metrics <- emptyMetrics+    portRef <- newIORef 0+    result <- bounded $ try $ withMetricsServer defaultConfig {port = 0} metrics [] $ \server -> do+      writeIORef portRef server.serverPort+      throwIO (userError "callback failed")+    (result :: Either SomeException ()) `shouldSatisfy` isLeft+    readIORef portRef >>= assertPortReleased+    callbackStopped <- newEmptyMVar+    result2 <- bounded $ try $ withMetricsServer defaultConfig {port = 0} metrics [] $ \server ->+      ( do+          writeIORef portRef server.serverPort+          Async.cancel server.serverThread+          threadDelay 10_000_000+      )+        `finally` putMVar callbackStopped ()+    (result2 :: Either SomeException ()) `shouldSatisfy` isLeft+    bounded (takeMVar callbackStopped)+    readIORef portRef >>= assertPortReleased +  it "cancels acquisition or its returned lifetime without leaking a fixed-port listener" $ do+    metrics <- emptyMetrics+    (port, socket) <- Warp.openFreePort+    Socket.close socket+    -- Exercise the acquisition boundary and the readiness/callback boundary.+    forM_ [0, 100, 1000] $ \delay -> do+      entered <- newEmptyMVar+      thread <- Async.async $ do+        putMVar entered ()+        withMetricsServer defaultConfig {port = port} metrics [] (\_ -> threadDelay 10_000_000)+      takeMVar entered+      threadDelay delay+      bounded (Async.cancel thread)+      assertPortReleased port+ emptyMetrics :: IO KirokuMetrics emptyMetrics = newKirokuMetricsWith (pure (GlobalPosition 0)) (pure 0) @@ -323,23 +323,23 @@  append :: Store.KirokuStore -> Int -> IO () append store count = do-    let ev = Store.EventData Nothing (Store.EventType "E") Aeson.Null Nothing Nothing Nothing-    result <- Store.runStoreIO store $ Store.appendToStream (Store.StreamName "orders-1") Store.NoStream (replicate count ev)-    result `shouldSatisfy` either (const False) (const True)+  let ev = Store.EventData Nothing (Store.EventType "E") Aeson.Null Nothing Nothing Nothing+  result <- Store.runStoreIO store $ Store.appendToStream (Store.StreamName "orders-1") Store.NoStream (replicate count ev)+  result `shouldSatisfy` either (const False) (const True)  seed :: Store.KirokuStore -> Text -> Int32 -> Int64 -> IO () seed store name index position = do-    result <- Pool.use store.pool $ Session.statement (name, index, position, max 1 (index + 1), "unbound", Nothing) SQL.saveCheckpointMemberStmt-    result `shouldBe` Right ()+  result <- Pool.use store.pool $ Session.statement (name, index, position, max 1 (index + 1), "unbound", Nothing) SQL.saveCheckpointMemberStmt+  result `shouldBe` Right ()  awaitLive :: Store.KirokuStore -> Store.SubscriptionName -> IO () awaitLive store name = bounded loop   where     loop = do-        states <- Store.subscriptionStates store-        case Map.lookup (name, 0) states of-            Just view | view.statePhase == "live" -> pure ()-            _ -> threadDelay 20_000 >> loop+      states <- Store.subscriptionStates store+      case Map.lookup (name, 0) states of+        Just view | view.statePhase == "live" -> pure ()+        _ -> threadDelay 20_000 >> loop  triples :: CheckpointInventoryResponse -> [(Text, Int32, Int64)] triples response = [(row.subscription, row.member, row.checkpointPosition) | row <- response.checkpoints]@@ -349,54 +349,54 @@  get :: Int -> String -> IO (HTTP.Response LBS.ByteString) get port path = do-    manager <- HTTP.newManager HTTP.defaultManagerSettings-    request <- HTTP.parseRequest (url port path)-    HTTP.httpLbs request manager+  manager <- HTTP.newManager HTTP.defaultManagerSettings+  request <- HTTP.parseRequest (url port path)+  HTTP.httpLbs request manager  getInventory :: Int -> IO CheckpointInventoryResponse getInventory port = do-    response <- get port "/subscription-checkpoints"-    HTTP.responseStatus response `shouldBe` status200-    either fail pure (Aeson.eitherDecode (HTTP.responseBody response))+  response <- get port "/subscription-checkpoints"+  HTTP.responseStatus response `shouldBe` status200+  either fail pure (Aeson.eitherDecode (HTTP.responseBody response))  code :: LBS.ByteString -> Maybe Text code bytes = do-    Aeson.Object root <- Aeson.decode bytes-    Aeson.Object err <- KM.lookup "error" root-    Aeson.String value <- KM.lookup "code" err-    pure value+  Aeson.Object root <- Aeson.decode bytes+  Aeson.Object err <- KM.lookup "error" root+  Aeson.String value <- KM.lookup "code" err+  pure value  frameType :: LBS.ByteString -> Maybe Text frameType bytes = do-    Aeson.Object root <- Aeson.decode bytes-    Aeson.String value <- KM.lookup "type" root-    pure value+  Aeson.Object root <- Aeson.decode bytes+  Aeson.String value <- KM.lookup "type" root+  pure value  capture :: Wai.Application -> Method -> IO (Status, ResponseHeaders, LBS.ByteString) capture app method = do-    result <- newIORef Nothing-    _ <- app Wai.defaultRequest{Wai.requestMethod = method, Wai.pathInfo = checkpointsPath} $ \response -> do-        let (status, headers, stream) = Wai.responseToStream response-        chunks <- newIORef mempty-        stream $ \body -> body (\builder -> modifyIORef' chunks (<> builder)) (pure ())-        bytes <- toLazyByteString <$> readIORef chunks-        writeIORef result (Just (status, headers, bytes))-        pure ResponseReceived-    readIORef result >>= maybe (fail "No WAI response") pure+  result <- newIORef Nothing+  _ <- app Wai.defaultRequest {Wai.requestMethod = method, Wai.pathInfo = checkpointsPath} $ \response -> do+    let (status, headers, stream) = Wai.responseToStream response+    chunks <- newIORef mempty+    stream $ \body -> body (\builder -> modifyIORef' chunks (<> builder)) (pure ())+    bytes <- toLazyByteString <$> readIORef chunks+    writeIORef result (Just (status, headers, bytes))+    pure ResponseReceived+  readIORef result >>= maybe (fail "No WAI response") pure  bounded :: IO a -> IO a bounded action = timeout 15_000_000 action >>= maybe (fail "Timed out") pure  assertPortReleased :: Int -> IO () assertPortReleased port = forM_ addresses $ \(family, address) ->-    bracket (Socket.socket family Socket.Stream Socket.defaultProtocol) Socket.close $ \sock -> do-        Socket.setSocketOption sock Socket.ReuseAddr 1-        if family == Socket.AF_INET6 then Socket.setSocketOption sock Socket.IPv6Only 1 else pure ()-        Socket.bind sock address-        Socket.listen sock 1+  bracket (Socket.socket family Socket.Stream Socket.defaultProtocol) Socket.close $ \sock -> do+    Socket.setSocketOption sock Socket.ReuseAddr 1+    if family == Socket.AF_INET6 then Socket.setSocketOption sock Socket.IPv6Only 1 else pure ()+    Socket.bind sock address+    Socket.listen sock 1   where     addresses =-        [ (Socket.AF_INET, Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (127, 0, 0, 1)))-        , (Socket.AF_INET, Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (0, 0, 0, 0)))-        , (Socket.AF_INET6, Socket.SockAddrInet6 (fromIntegral port) 0 (0, 0, 0, 0) 0)-        ]+      [ (Socket.AF_INET, Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (127, 0, 0, 1))),+        (Socket.AF_INET, Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (0, 0, 0, 0))),+        (Socket.AF_INET6, Socket.SockAddrInet6 (fromIntegral port) 0 (0, 0, 0, 0) 0)+      ]
test/Test/CollectorSpec.hs view
@@ -1,8 +1,7 @@-{- | Pure unit tests for the metrics collector: feed scripted 'KirokuEvent' and-'Observation' values through the callback wrappers and assert the resulting-snapshot. No store is opened — the two store-level gauges are supplied by fake-STM readers via 'newKirokuMetricsWith'.--}+-- | Pure unit tests for the metrics collector: feed scripted 'KirokuEvent' and+-- 'Observation' values through the callback wrappers and assert the resulting+-- snapshot. No store is opened — the two store-level gauges are supplied by fake+-- STM readers via 'newKirokuMetricsWith'. module Test.CollectorSpec (spec) where  import Control.Concurrent.STM (STM)@@ -18,8 +17,8 @@ import Kiroku.Metrics.Prometheus (renderPrometheus) import Test.Hspec -import Kiroku.Metrics (-    LifecycleCounters (..),+import Kiroku.Metrics+  ( LifecycleCounters (..),     MetricsSnapshot (..),     StoreGauges (..),     SubscriptionMetrics (..),@@ -27,9 +26,9 @@     metricsObservationHandler,     newKirokuMetricsWith,     snapshotMetrics,- )-import Kiroku.Store (-    ConnectionReadyForUseReason (..),+  )+import Kiroku.Store+  ( ConnectionReadyForUseReason (..),     ConnectionStatus (..),     ConnectionTerminationReason (..),     KirokuEvent (..),@@ -37,31 +36,33 @@     SubscriptionDbPhase (..),     SubscriptionGroupContext (..),     SubscriptionStopReason (..),- )+    SubscriptionTarget (..),+  ) import Kiroku.Store.Observability (SubscriptionDeliveryPhase (..)) import Kiroku.Store.Settings (DecodeFailure (..))-import Kiroku.Store.Subscription.Types (-    CheckpointInitialization (..),+import Kiroku.Store.Subscription.Types+  ( CheckpointInitialization (..),+    InvalidHandlerStallWarnAfter (..),     MissingCheckpointPolicy (..),     SubscriptionCheckpointKey (..),     SubscriptionName (..),- )+    SubscriptionTargetMismatch (..),+  ) import Kiroku.Store.Types (EventId (..), GlobalPosition (..)) -{- | A collector whose fake store readers report the given global position and-active-subscriber count, fed the given scripted events and observations.--}+-- | A collector whose fake store readers report the given global position and+-- active-subscriber count, fed the given scripted events and observations. runScript ::-    STM GlobalPosition ->-    STM Int ->-    [KirokuEvent] ->-    [Observation] ->-    IO MetricsSnapshot+  STM GlobalPosition ->+  STM Int ->+  [KirokuEvent] ->+  [Observation] ->+  IO MetricsSnapshot runScript readPos readSubs events obs = do-    km <- newKirokuMetricsWith readPos readSubs-    forM_ events (metricsEventHandler km Nothing)-    forM_ obs (metricsObservationHandler km Nothing)-    snapshotMetrics km+  km <- newKirokuMetricsWith readPos readSubs+  forM_ events (metricsEventHandler km Nothing)+  forM_ obs (metricsObservationHandler km Nothing)+  snapshotMetrics km  sub :: SubscriptionName sub = SubscriptionName "p"@@ -79,188 +80,204 @@  spec :: Spec spec = describe "Kiroku.Metrics.Collector" $ do-    it "counts notifier reconnect events" $ do-        snap <--            runScript-                (pure (GlobalPosition 0))-                (pure 0)-                [ KirokuEventNotifierReconnecting 1 someExc-                , KirokuEventNotifierReconnecting 2 someExc-                , KirokuEventNotifierReconnected-                ]-                []-        snap.counters.notifierReconnecting `shouldBe` 2-        snap.counters.notifierReconnected `shouldBe` 1+  forM_ [False, True] $ \targetRefusal ->+    it ("counts one startup crash without Started for target refusal=" <> show targetRefusal) $ do+      let mismatch = SubscriptionTargetMismatch sub AllStreams (pure Nothing)+          err = if targetRefusal then toException mismatch else toException (InvalidHandlerStallWarnAfter 0)+          prefix = if targetRefusal then [KirokuEventSubscriptionTargetMismatch mismatch NonGroup] else []+      snap <-+        runScript+          (pure (GlobalPosition 12))+          (pure 0)+          (prefix <> [KirokuEventSubscriptionStopped sub (GlobalPosition 0) (StopWorkerCrashed err) NonGroup])+          []+      snap.counters.subscriptionsStarted `shouldBe` 0+      snap.counters.subscriptionsStoppedCrashed `shouldBe` 1+      (Map.lookup "p" snap.subscriptions >>= (.lastStopReason)) `shouldBe` Just "crashed"+      (Map.lookup "p" snap.subscriptions >>= Just . (.lastKnownPosition)) `shouldBe` Just 0 -    it "counts publisher pool and loop errors separately" $ do-        snap <--            runScript-                (pure (GlobalPosition 0))-                (pure 0)-                [ KirokuEventPublisherPoolError dbErr-                , KirokuEventPublisherLoopError someExc-                , KirokuEventPublisherLoopError someExc-                ]-                []-        snap.counters.publisherPoolErrors `shouldBe` 1-        snap.counters.publisherLoopErrors `shouldBe` 2+  it "counts notifier reconnect events" $ do+    snap <-+      runScript+        (pure (GlobalPosition 0))+        (pure 0)+        [ KirokuEventNotifierReconnecting 1 someExc,+          KirokuEventNotifierReconnecting 2 someExc,+          KirokuEventNotifierReconnected+        ]+        []+    snap.counters.notifierReconnecting `shouldBe` 2+    snap.counters.notifierReconnected `shouldBe` 1 -    it "counts typed decode failures and undecodable stops separately from programming errors" $ do-        let eid = EventId (uuidN 3)-            failure = DecodeFailure eid "cannot decrypt"-        snap <--            runScript-                (pure (GlobalPosition 4))-                (pure 0)-                [ KirokuEventPublisherDecodeFailed (GlobalPosition 3) eid failure-                , KirokuEventSubscriptionStopped sub (GlobalPosition 2) (StopUndecodable failure) NonGroup-                ]-                []-        case toJSON snap.counters of-            Object fields -> do-                KM.lookup "publisher_decode_failures" fields `shouldBe` Just (Number 1)-                KM.lookup "subscriptions_stopped_undecodable" fields `shouldBe` Just (Number 1)-            other -> expectationFailure ("expected lifecycle object, got " <> show other)-        let exposition = LBS.unpack (renderPrometheus snap)-        exposition `shouldSatisfy` isInfixOf "kiroku_publisher_decode_failures_total 1"-        exposition `shouldSatisfy` isInfixOf "kiroku_subscriptions_stopped_total{reason=\"undecodable\"} 1"-        snap.counters.publisherDecodeFailures `shouldBe` 1-        snap.counters.publisherLoopErrors `shouldBe` 0-        snap.counters.subscriptionsStoppedUndecodable `shouldBe` 1-        snap.counters.subscriptionsStoppedCrashed `shouldBe` 0-        (Map.lookup "p" snap.subscriptions >>= (.lastStopReason)) `shouldBe` Just "undecodable"+  it "counts publisher pool and loop errors separately" $ do+    snap <-+      runScript+        (pure (GlobalPosition 0))+        (pure 0)+        [ KirokuEventPublisherPoolError dbErr,+          KirokuEventPublisherLoopError someExc,+          KirokuEventPublisherLoopError someExc+        ]+        []+    snap.counters.publisherPoolErrors `shouldBe` 1+    snap.counters.publisherLoopErrors `shouldBe` 2 -    it "counts advisory stalls without advancing the subscription position" $ do-        snap <--            runScript-                (pure (GlobalPosition 12))-                (pure 1)-                [ KirokuEventSubscriptionStarted sub (GlobalPosition 2) NonGroup-                , KirokuEventSubscriptionHandlerStalled sub (GlobalPosition 3) (EventId (uuidN 3)) 0.2 NonGroup-                ]-                []-        snap.counters.subscriptionHandlerStalls `shouldBe` 1-        (Map.lookup "p" snap.subscriptions >>= Just . (.lastKnownPosition)) `shouldBe` Just 2-        case toJSON snap.counters of-            Object fields -> KM.lookup "subscription_handler_stalls" fields `shouldBe` Just (Number 1)-            other -> expectationFailure ("expected lifecycle object, got " <> show other)-        LBS.unpack (renderPrometheus snap) `shouldSatisfy` isInfixOf "kiroku_subscription_handler_stalls_total 1"+  it "counts typed decode failures and undecodable stops separately from programming errors" $ do+    let eid = EventId (uuidN 3)+        failure = DecodeFailure eid "cannot decrypt"+    snap <-+      runScript+        (pure (GlobalPosition 4))+        (pure 0)+        [ KirokuEventPublisherDecodeFailed (GlobalPosition 3) eid failure,+          KirokuEventSubscriptionStopped sub (GlobalPosition 2) (StopUndecodable failure) NonGroup+        ]+        []+    case toJSON snap.counters of+      Object fields -> do+        KM.lookup "publisher_decode_failures" fields `shouldBe` Just (Number 1)+        KM.lookup "subscriptions_stopped_undecodable" fields `shouldBe` Just (Number 1)+      other -> expectationFailure ("expected lifecycle object, got " <> show other)+    let exposition = LBS.unpack (renderPrometheus snap)+    exposition `shouldSatisfy` isInfixOf "kiroku_publisher_decode_failures_total 1"+    exposition `shouldSatisfy` isInfixOf "kiroku_subscriptions_stopped_total{reason=\"undecodable\"} 1"+    snap.counters.publisherDecodeFailures `shouldBe` 1+    snap.counters.publisherLoopErrors `shouldBe` 0+    snap.counters.subscriptionsStoppedUndecodable `shouldBe` 1+    snap.counters.subscriptionsStoppedCrashed `shouldBe` 0+    (Map.lookup "p" snap.subscriptions >>= (.lastStopReason)) `shouldBe` Just "undecodable" -    it "records subscription position and derives lag from the global position" $ do-        snap <--            runScript-                (pure (GlobalPosition 12))-                (pure 3)-                [KirokuEventSubscriptionStarted sub (GlobalPosition 5) NonGroup]-                []-        snap.store.globalPosition `shouldBe` 12-        snap.store.activeSubscribers `shouldBe` 3-        snap.counters.subscriptionsStarted `shouldBe` 1-        case Map.lookup "p" snap.subscriptions of-            Nothing -> expectationFailure "expected subscription \"p\" in snapshot"-            Just m -> do-                m.lastKnownPosition `shouldBe` 5-                m.lag `shouldBe` 7+  it "counts advisory stalls without advancing the subscription position" $ do+    snap <-+      runScript+        (pure (GlobalPosition 12))+        (pure 1)+        [ KirokuEventSubscriptionStarted sub (GlobalPosition 2) NonGroup,+          KirokuEventSubscriptionHandlerStalled sub (GlobalPosition 3) (EventId (uuidN 3)) 0.2 NonGroup+        ]+        []+    snap.counters.subscriptionHandlerStalls `shouldBe` 1+    (Map.lookup "p" snap.subscriptions >>= Just . (.lastKnownPosition)) `shouldBe` Just 2+    case toJSON snap.counters of+      Object fields -> KM.lookup "subscription_handler_stalls" fields `shouldBe` Just (Number 1)+      other -> expectationFailure ("expected lifecycle object, got " <> show other)+    LBS.unpack (renderPrometheus snap) `shouldSatisfy` isInfixOf "kiroku_subscription_handler_stalls_total 1" -    it "records the durable position from checkpoint resolution telemetry" $ do-        let key = SubscriptionCheckpointKey sub 0-        snap <--            runScript-                (pure (GlobalPosition 12))-                (pure 0)-                [ KirokuEventSubscriptionCheckpointResolved-                    (InitializedCheckpoint FromCurrentHead key (GlobalPosition 9))-                    NonGroup-                ]-                []-        case Map.lookup "p" snap.subscriptions of-            Nothing -> expectationFailure "expected resolved subscription in snapshot"-            Just metrics -> do-                metrics.lastKnownPosition `shouldBe` 9-                metrics.lag `shouldBe` 3+  it "records subscription position and derives lag from the global position" $ do+    snap <-+      runScript+        (pure (GlobalPosition 12))+        (pure 3)+        [KirokuEventSubscriptionStarted sub (GlobalPosition 5) NonGroup]+        []+    snap.store.globalPosition `shouldBe` 12+    snap.store.activeSubscribers `shouldBe` 3+    snap.counters.subscriptionsStarted `shouldBe` 1+    case Map.lookup "p" snap.subscriptions of+      Nothing -> expectationFailure "expected subscription \"p\" in snapshot"+      Just m -> do+        m.lastKnownPosition `shouldBe` 5+        m.lag `shouldBe` 7 -    it "advances last-known position monotonically and never reports negative lag" $ do-        snap <--            runScript-                (pure (GlobalPosition 8))-                (pure 1)-                [ KirokuEventSubscriptionStarted sub (GlobalPosition 5) NonGroup-                , KirokuEventSubscriptionCaughtUp sub (GlobalPosition 10) NonGroup-                ]-                []-        case Map.lookup "p" snap.subscriptions of-            Nothing -> expectationFailure "expected subscription \"p\""-            Just m -> do-                m.lastKnownPosition `shouldBe` 10-                m.lag `shouldBe` 0 -- max 0 (8 - 10)-    it "tallies stop reasons and records the last one per subscription" $ do-        snap <--            runScript-                (pure (GlobalPosition 20))-                (pure 0)-                [ KirokuEventSubscriptionStarted sub (GlobalPosition 0) NonGroup-                , KirokuEventSubscriptionStopped sub (GlobalPosition 20) StopHandlerRequested NonGroup-                ]-                []-        snap.counters.subscriptionsStoppedHandler `shouldBe` 1-        (Map.lookup "p" snap.subscriptions >>= (.lastStopReason)) `shouldBe` Just "handler"+  it "records the durable position from checkpoint resolution telemetry" $ do+    let key = SubscriptionCheckpointKey sub 0+    snap <-+      runScript+        (pure (GlobalPosition 12))+        (pure 0)+        [ KirokuEventSubscriptionCheckpointResolved+            (InitializedCheckpoint FromCurrentHead key (GlobalPosition 9))+            NonGroup+        ]+        []+    case Map.lookup "p" snap.subscriptions of+      Nothing -> expectationFailure "expected resolved subscription in snapshot"+      Just metrics -> do+        metrics.lastKnownPosition `shouldBe` 9+        metrics.lag `shouldBe` 3 -    it "tallies per-phase database errors and the per-subscription error count" $ do-        snap <--            runScript-                (pure (GlobalPosition 0))-                (pure 0)-                [ KirokuEventSubscriptionDbError sub LoadCheckpoint dbErr NonGroup-                , KirokuEventSubscriptionDbError sub FetchBatch dbErr NonGroup-                , KirokuEventSubscriptionDbError sub FetchBatch dbErr NonGroup-                ]-                []-        snap.counters.subscriptionDbErrorsLoad `shouldBe` 1-        snap.counters.subscriptionDbErrorsFetch `shouldBe` 2-        (Map.lookup "p" snap.subscriptions >>= Just . (.dbErrorCount)) `shouldBe` Just 3+  it "advances last-known position monotonically and never reports negative lag" $ do+    snap <-+      runScript+        (pure (GlobalPosition 8))+        (pure 1)+        [ KirokuEventSubscriptionStarted sub (GlobalPosition 5) NonGroup,+          KirokuEventSubscriptionCaughtUp sub (GlobalPosition 10) NonGroup+        ]+        []+    case Map.lookup "p" snap.subscriptions of+      Nothing -> expectationFailure "expected subscription \"p\""+      Just m -> do+        m.lastKnownPosition `shouldBe` 10+        m.lag `shouldBe` 0 -- max 0 (8 - 10)+  it "tallies stop reasons and records the last one per subscription" $ do+    snap <-+      runScript+        (pure (GlobalPosition 20))+        (pure 0)+        [ KirokuEventSubscriptionStarted sub (GlobalPosition 0) NonGroup,+          KirokuEventSubscriptionStopped sub (GlobalPosition 20) StopHandlerRequested NonGroup+        ]+        []+    snap.counters.subscriptionsStoppedHandler `shouldBe` 1+    (Map.lookup "p" snap.subscriptions >>= (.lastStopReason)) `shouldBe` Just "handler" -    it "counts delivered batches and sums delivered events" $ do-        snap <--            runScript-                (pure (GlobalPosition 0))-                (pure 0)-                [ KirokuEventSubscriptionDelivered sub 3 DeliveredCatchUp NonGroup-                , KirokuEventSubscriptionDelivered sub 2 DeliveredLive NonGroup-                , KirokuEventSubscriptionFetched sub 2 NonGroup-                ]-                []-        snap.counters.batchesDelivered `shouldBe` 2-        snap.counters.eventsDelivered `shouldBe` 5-        snap.counters.liveFetches `shouldBe` 1+  it "tallies per-phase database errors and the per-subscription error count" $ do+    snap <-+      runScript+        (pure (GlobalPosition 0))+        (pure 0)+        [ KirokuEventSubscriptionDbError sub LoadCheckpoint dbErr NonGroup,+          KirokuEventSubscriptionDbError sub FetchBatch dbErr NonGroup,+          KirokuEventSubscriptionDbError sub FetchBatch dbErr NonGroup+        ]+        []+    snap.counters.subscriptionDbErrorsLoad `shouldBe` 1+    snap.counters.subscriptionDbErrorsFetch `shouldBe` 2+    (Map.lookup "p" snap.subscriptions >>= Just . (.dbErrorCount)) `shouldBe` Just 3 -    it "tracks pool connection gauges and cumulative counters from observations" $ do-        let u = uuidN 1-        snap <--            runScript-                (pure (GlobalPosition 0))-                (pure 0)-                []-                [ ConnectionObservation u ConnectingConnectionStatus-                , ConnectionObservation u (ReadyForUseConnectionStatus EstablishedConnectionReadyForUseReason)-                , ConnectionObservation u InUseConnectionStatus-                ]-        snap.store.poolConnecting `shouldBe` 0-        snap.store.poolReady `shouldBe` 0-        snap.store.poolInUse `shouldBe` 1-        snap.store.poolEstablishedTotal `shouldBe` 1-        snap.store.poolTerminatedTotal `shouldBe` 0+  it "counts delivered batches and sums delivered events" $ do+    snap <-+      runScript+        (pure (GlobalPosition 0))+        (pure 0)+        [ KirokuEventSubscriptionDelivered sub 3 DeliveredCatchUp NonGroup,+          KirokuEventSubscriptionDelivered sub 2 DeliveredLive NonGroup,+          KirokuEventSubscriptionFetched sub 2 NonGroup+        ]+        []+    snap.counters.batchesDelivered `shouldBe` 2+    snap.counters.eventsDelivered `shouldBe` 5+    snap.counters.liveFetches `shouldBe` 1 -    it "removes terminated connections from the gauge and bumps the terminated counter" $ do-        let u = uuidN 2-        snap <--            runScript-                (pure (GlobalPosition 0))-                (pure 0)-                []-                [ ConnectionObservation u (ReadyForUseConnectionStatus EstablishedConnectionReadyForUseReason)-                , ConnectionObservation u (TerminatedConnectionStatus ReleaseConnectionTerminationReason)-                ]-        snap.store.poolReady `shouldBe` 0-        snap.store.poolInUse `shouldBe` 0-        snap.store.poolEstablishedTotal `shouldBe` 1-        snap.store.poolTerminatedTotal `shouldBe` 1+  it "tracks pool connection gauges and cumulative counters from observations" $ do+    let u = uuidN 1+    snap <-+      runScript+        (pure (GlobalPosition 0))+        (pure 0)+        []+        [ ConnectionObservation u ConnectingConnectionStatus,+          ConnectionObservation u (ReadyForUseConnectionStatus EstablishedConnectionReadyForUseReason),+          ConnectionObservation u InUseConnectionStatus+        ]+    snap.store.poolConnecting `shouldBe` 0+    snap.store.poolReady `shouldBe` 0+    snap.store.poolInUse `shouldBe` 1+    snap.store.poolEstablishedTotal `shouldBe` 1+    snap.store.poolTerminatedTotal `shouldBe` 0++  it "removes terminated connections from the gauge and bumps the terminated counter" $ do+    let u = uuidN 2+    snap <-+      runScript+        (pure (GlobalPosition 0))+        (pure 0)+        []+        [ ConnectionObservation u (ReadyForUseConnectionStatus EstablishedConnectionReadyForUseReason),+          ConnectionObservation u (TerminatedConnectionStatus ReleaseConnectionTerminationReason)+        ]+    snap.store.poolReady `shouldBe` 0+    snap.store.poolInUse `shouldBe` 0+    snap.store.poolEstablishedTotal `shouldBe` 1+    snap.store.poolTerminatedTotal `shouldBe` 1
test/Test/CorsSpec.hs view
@@ -47,161 +47,187 @@  spec :: Spec spec = do-    describe "Kiroku.Metrics.Cors (configuration)" $ do-        it "defaults to disabled and validates normalized HTTP(S) origins" $ do-            cors defaultConfig `shouldBe` corsDisabled-            corsEnabled corsDisabled `shouldBe` False-            allowedOrigin " HTTPS://OPS.Example.Com:443/ " `shouldBe` allowedOrigin "https://ops.example.com"-            allowedOrigin "http://localhost:80" `shouldBe` allowedOrigin "http://localhost"-            fmap renderAllowedOrigin (allowedOrigin "http://127.0.0.1:5173") `shouldBe` Right "http://127.0.0.1:5173"-            originAllowed policy "HTTPS://OPS.EXAMPLE.COM:443" `shouldBe` True-            originAllowed policy "https://ops.example.com:8443" `shouldBe` False-        it "normalizes IPv6 literals including embedded IPv4" $ do-            allowedOrigin "http://[::1]" `shouldBe` allowedOrigin "http://[0:0:0:0:0:0:0:1]:80"-            allowedOrigin "https://[2001:DB8::1]:8443" `shouldBe` allowedOrigin "https://[2001:db8:0:0:0:0:0:1]:8443"-            allowedOrigin "http://[::ffff:192.0.2.1]" `shouldBe` allowedOrigin "http://[0:0:0:0:0:ffff:c000:201]"-        it "rejects wildcards, opaque origins, malformed authorities, paths and Unicode" $-            forM_ ["*", "null", "", "example.com", "://example.com", "ftp://example.com", "http://", "http:///", "https://a/path", "https://a//", "https://a?x", "https://a#x", "https://a b", "https://a\nb", "https://user@a", "https://*.a", "https://a%2eb", "https://a\\b", "https://é.example", "http://:80", "http://a:", "http://a:-1", "http://a:+80", "http://a:65536", "http://a:999999999999999999999999", "http://a:80:90", "http://-a", "http://a-", "http://a..b", "http://256.1.2.3", "http://01.2.3.4", "http://[:::1]", "http://[::1", "http://[1:2:3:4:5:6:7]", "http://[1:2:3:4:5:6:7:8:9]", "http://[::1]:", "http://[::1]x", "http://[fe80::1%eth0]", "http://::1", "http://[192.0.2.1::]"] $ \input ->-                allowedOrigin input `shouldSatisfy` isLeft-        it "tolerates configuration slash/whitespace but refuses them in request origins" $ do-            allowedOrigin " https://ops.example.com/ " `shouldBe` allowedOrigin "https://ops.example.com"-            forM_ ["https://ops.example.com/", " https://ops.example.com", "https://ops.example.com ", "https://ops.example.com https://evil.example.com", "https://ops.example.com,https://evil.example.com", "\xff"] $ \raw ->-                originAllowed policy raw `shouldBe` False+  describe "Kiroku.Metrics.Cors (configuration)" $ do+    it "defaults to disabled and validates normalized HTTP(S) origins" $ do+      cors defaultConfig `shouldBe` corsDisabled+      corsEnabled corsDisabled `shouldBe` False+      allowedOrigin " HTTPS://OPS.Example.Com:443/ " `shouldBe` allowedOrigin "https://ops.example.com"+      allowedOrigin "http://localhost:80" `shouldBe` allowedOrigin "http://localhost"+      fmap renderAllowedOrigin (allowedOrigin "http://127.0.0.1:5173") `shouldBe` Right "http://127.0.0.1:5173"+      originAllowed policy "HTTPS://OPS.EXAMPLE.COM:443" `shouldBe` True+      originAllowed policy "https://ops.example.com:8443" `shouldBe` False+    it "normalizes IPv6 literals including embedded IPv4" $ do+      allowedOrigin "http://[::1]" `shouldBe` allowedOrigin "http://[0:0:0:0:0:0:0:1]:80"+      allowedOrigin "https://[2001:DB8::1]:8443" `shouldBe` allowedOrigin "https://[2001:db8:0:0:0:0:0:1]:8443"+      allowedOrigin "http://[::ffff:192.0.2.1]" `shouldBe` allowedOrigin "http://[0:0:0:0:0:ffff:c000:201]"+    it "rejects wildcards, opaque origins, malformed authorities, paths and Unicode" $+      forM_ ["*", "null", "", "example.com", "://example.com", "ftp://example.com", "http://", "http:///", "https://a/path", "https://a//", "https://a?x", "https://a#x", "https://a b", "https://a\nb", "https://user@a", "https://*.a", "https://a%2eb", "https://a\\b", "https://é.example", "http://:80", "http://a:", "http://a:-1", "http://a:+80", "http://a:65536", "http://a:999999999999999999999999", "http://a:80:90", "http://-a", "http://a-", "http://a..b", "http://256.1.2.3", "http://01.2.3.4", "http://[:::1]", "http://[::1", "http://[1:2:3:4:5:6:7]", "http://[1:2:3:4:5:6:7:8:9]", "http://[::1]:", "http://[::1]x", "http://[fe80::1%eth0]", "http://::1", "http://[192.0.2.1::]"] $ \input ->+        allowedOrigin input `shouldSatisfy` isLeft+    it "tolerates configuration slash/whitespace but refuses them in request origins" $ do+      allowedOrigin " https://ops.example.com/ " `shouldBe` allowedOrigin "https://ops.example.com"+      forM_ ["https://ops.example.com/", " https://ops.example.com", "https://ops.example.com ", "https://ops.example.com https://evil.example.com", "https://ops.example.com,https://evil.example.com", "\xff"] $ \raw ->+        originAllowed policy raw `shouldBe` False -    describe "Kiroku.Metrics.Cors (middleware, standalone)" $ do-        it "preserves every response byte and header when disabled, including upgrades and preflights" $ do-            let original = Wai.responseLBS status201 [("Vary", "Accept"), ("X-Custom", "yes")] "unchanged"-                app _ respond = respond original-            forM_ [[], [(hOrigin, ops)], [(hOrigin, evil), ("Upgrade", "websocket")], preflight ops "DELETE"] $ \headers -> do-                unwrapped <- capture app "OPTIONS" headers-                wrapped <- capture (corsMiddleware corsDisabled app) "OPTIONS" headers-                wrapped `shouldBe` unwrapped-        it "decorates GET and HEAD and varies allowed, disallowed, absent, malformed and duplicate origins" $ do-            forM_ ["GET", "HEAD"] $ \method -> do-                (_, headers, body) <- capture (corsMiddleware policy baseApp) method [(hOrigin, ops)]-                lookup "Access-Control-Allow-Origin" headers `shouldBe` Just ops-                lookup hVary headers `shouldBe` Just "Origin"-                body `shouldBe` "legacy"-            forM_ [[], [(hOrigin, evil)], [(hOrigin, "null")], [(hOrigin, ops <> "/")], [(hOrigin, ops), (hOrigin, ops)]] $ \headers -> do-                (status, hs, body) <- capture (corsMiddleware policy baseApp) "GET" headers-                status `shouldBe` status200-                body `shouldBe` "legacy"-                grants hs `shouldBe` []-                lookup hVary hs `shouldBe` Just "Origin"-        it "answers GET/HEAD preflights, reflects validated tokens, and varies on all inputs" $ do-            forM_ ["GET", "HEAD"] $ \method -> do-                (status, headers, body) <- capture (corsMiddleware policy baseApp) "OPTIONS" (preflight ops method <> [("Access-Control-Request-Headers", "Authorization, X-Trace")])-                status `shouldBe` status204-                body `shouldBe` ""-                lookup "Access-Control-Allow-Methods" headers `shouldBe` Just "GET, HEAD, OPTIONS"-                lookup "Access-Control-Allow-Headers" headers `shouldBe` Just "Authorization, X-Trace"-                lookup hVary headers `shouldBe` Just "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"-        it "rejects unsupported/duplicate methods and malformed requested header tokens" $ do-            (status, _, body) <- capture (corsMiddleware policy baseApp) "OPTIONS" (preflight ops "POST")-            status `shouldBe` status403-            errorCode body `shouldBe` Just (String "cors_method_not_allowed")-            forM_ ["", "X Header", "X:Header", "X-Good,", "X-Good, \xff", "X\r\nInjected"] $ \header -> do-                (s, _, b) <- capture (corsMiddleware policy baseApp) "OPTIONS" (preflight ops "GET" <> [("Access-Control-Request-Headers", header)])-                s `shouldBe` status400-                errorCode b `shouldBe` Just (String "invalid_cors_request")-            (emptyStatus, _, _) <- capture (corsMiddleware policy baseApp) "OPTIONS" (preflight ops "GET" <> [("Access-Control-Request-Headers", ""), ("Access-Control-Request-Headers", "Authorization")])-            emptyStatus `shouldBe` status400-            (s, _, _) <- capture (corsMiddleware policy baseApp) "OPTIONS" (preflight ops "GET" <> [("Access-Control-Request-Method", "HEAD")])-            s `shouldBe` status400-        it "passes plain OPTIONS and disallowed preflights through without grants" $ do-            forM_ [[], [(hOrigin, ops)], preflight evil "GET", [(hOrigin, ops), (hOrigin, ops), ("Access-Control-Request-Method", "GET")]] $ \headers -> do-                (s, hs, body) <- capture (corsMiddleware policy baseApp) "OPTIONS" headers-                s `shouldBe` status200-                body `shouldBe` "legacy"-                if headers == [(hOrigin, ops)] then lookup "Access-Control-Allow-Origin" hs `shouldBe` Just ops else grants hs `shouldBe` []-        it "merges existing Vary case-insensitively and preserves wildcard variation" $ do-            let app headers _ respond = respond (Wai.responseLBS status200 headers "")-            (_, hs, _) <- capture (corsMiddleware policy (app [(hVary, "Accept, origin"), (hVary, "ACCEPT, X-Foo")])) "GET" []-            lookup hVary hs `shouldBe` Just "Accept, origin, X-Foo"-            (_, wildcard, _) <- capture (corsMiddleware policy (app [(hVary, "Accept, *")])) "GET" [(hOrigin, ops)]-            lookup hVary wildcard `shouldBe` Just "*"-        it "owns grants once, adds optional credentials and ignores negative max age" $ do-            let app _ respond = respond (Wai.responseLBS status200 [("Access-Control-Allow-Origin", "*"), ("Access-Control-Allow-Origin", evil), ("Access-Control-Allow-Credentials", "true")] "")-            (_, hs, _) <- capture (corsMiddleware policy app) "GET" [(hOrigin, ops)]-            grants hs `shouldBe` [("Access-Control-Allow-Origin", ops)]-            (_, denied, _) <- capture (corsMiddleware policy app) "GET" [(hOrigin, evil)]-            grants denied `shouldBe` []-            forM_ [Nothing, Just (-1), Just 0, Just 3600] $ \age -> do-                (_, headers, _) <- capture (corsMiddleware (policy{allowCredentials = True, maxAgeSeconds = age}) baseApp) "OPTIONS" (preflight ops "GET")-                lookup "Access-Control-Allow-Credentials" headers `shouldBe` Just "true"-                lookup "Access-Control-Max-Age" headers `shouldBe` case age of-                    Just n | n >= 0 -> Just (fromStringInt n)-                    _ -> Nothing-        it "refuses malformed/duplicate/disallowed upgrade origins before invoking the inner app" $ do-            calls <- newIORef (0 :: Int)-            let app req respond = modifyIORef' calls (+ 1) >> baseApp req respond-            forM_ [[(hOrigin, evil)], [(hOrigin, "null")], [(hOrigin, ops <> "/")], [(hOrigin, ops), (hOrigin, ops)]] $ \headers -> do-                (s, hs, body) <- capture (corsMiddleware policy app) "GET" (("Upgrade", "websocket") : headers)-                s `shouldBe` status403-                lookup hContentType hs `shouldBe` Just "application/json"-                errorCode body `shouldBe` Just (String "origin_not_allowed")-            readIORef calls `shouldReturn` 0-            forM_ [[], [(hOrigin, ops)]] $ \headers -> do-                _ <- capture (corsMiddleware policy app) "GET" (("Upgrade", "websocket") : headers)-                pure ()-            readIORef calls `shouldReturn` 2-        it "never reuses grants between sequential requests with different origins" $ do-            forM_ [[], [(hOrigin, ops)], [(hOrigin, evil)], [(hOrigin, ops)], []] $ \headers -> do-                (_, hs, _) <- capture (corsMiddleware policy baseApp) "GET" headers-                lookup hVary hs `shouldBe` Just "Origin"-                lookup "Access-Control-Allow-Origin" hs `shouldBe` if headers == [(hOrigin, ops)] then Just ops else Nothing-        it "leaves raw upgrade responses untouched" $ do-            let original = Wai.responseRaw (\_ _ -> pure ()) (Wai.responseLBS status500 [] "fallback")-                app _ respond = respond original-            ref <- newIORef Nothing-            _ <- corsMiddleware policy app (Wai.defaultRequest{Wai.requestHeaders = [("Upgrade", "websocket"), (hOrigin, ops)]}) (\r -> writeIORef ref (Just r) >> pure ResponseReceived)-            response <- readIORef ref-            case response of-                Just (ResponseRaw _ _) -> pure ()-                _ -> expectationFailure "raw response was rewritten"-        it "strips HEAD bodies on a real Warp server while preserving grants and status" $-            Warp.testWithApplication (pure (corsMiddleware policy baseApp)) $ \port -> do-                manager <- HTTP.newManager HTTP.defaultManagerSettings-                get <- networkRequest manager port "GET" [(hOrigin, ops)]-                headResponse <- networkRequest manager port "HEAD" [(hOrigin, ops)]-                HTTP.responseStatus headResponse `shouldBe` HTTP.responseStatus get-                lookup "Access-Control-Allow-Origin" (HTTP.responseHeaders headResponse) `shouldBe` Just ops-                HTTP.responseBody headResponse `shouldBe` ""+  describe "Kiroku.Metrics.Cors (middleware, standalone)" $ do+    it "preserves every response byte and header when disabled, including origin-less upgrades and preflights" $ do+      let original = Wai.responseLBS status201 [("Vary", "Accept"), ("X-Custom", "yes")] "unchanged"+          app _ respond = respond original+      forM_ [[], [(hOrigin, ops)], [("Upgrade", "websocket")], preflight ops "DELETE"] $ \headers -> do+        unwrapped <- capture app "OPTIONS" headers+        wrapped <- capture (corsMiddleware corsDisabled app) "OPTIONS" headers+        wrapped `shouldBe` unwrapped+    it "refuses every Origin-bearing upgrade when disabled without calling the app" $ do+      calls <- newIORef (0 :: Int)+      let app req respond = modifyIORef' calls (+ 1) >> baseApp req respond+      forM_ [[(hOrigin, ops)], [(hOrigin, evil)], [(hOrigin, "")], [(hOrigin, "null")], [(hOrigin, ops), (hOrigin, ops)]] $ \headers -> do+        (status, hs, body) <- capture (corsMiddleware corsDisabled app) "GET" (("Upgrade", "websocket") : headers)+        status `shouldBe` status403+        lookup hContentType hs `shouldBe` Just "application/json"+        decode body `shouldBe` Just (errorEnvelope "origin_not_allowed" "The request origin is not allowed." Nothing)+        grants hs `shouldBe` []+      readIORef calls `shouldReturn` 0+    it "decorates GET and HEAD and varies allowed, disallowed, absent, malformed and duplicate origins" $ do+      forM_ ["GET", "HEAD"] $ \method -> do+        (_, headers, body) <- capture (corsMiddleware policy baseApp) method [(hOrigin, ops)]+        lookup "Access-Control-Allow-Origin" headers `shouldBe` Just ops+        lookup hVary headers `shouldBe` Just "Origin"+        body `shouldBe` "legacy"+      forM_ [[], [(hOrigin, evil)], [(hOrigin, "null")], [(hOrigin, ops <> "/")], [(hOrigin, ops), (hOrigin, ops)]] $ \headers -> do+        (status, hs, body) <- capture (corsMiddleware policy baseApp) "GET" headers+        status `shouldBe` status200+        body `shouldBe` "legacy"+        grants hs `shouldBe` []+        lookup hVary hs `shouldBe` Just "Origin"+    it "answers GET/HEAD preflights, reflects validated tokens, and varies on all inputs" $ do+      forM_ ["GET", "HEAD"] $ \method -> do+        (status, headers, body) <- capture (corsMiddleware policy baseApp) "OPTIONS" (preflight ops method <> [("Access-Control-Request-Headers", "Authorization, X-Trace")])+        status `shouldBe` status204+        body `shouldBe` ""+        lookup "Access-Control-Allow-Methods" headers `shouldBe` Just "GET, HEAD, OPTIONS"+        lookup "Access-Control-Allow-Headers" headers `shouldBe` Just "Authorization, X-Trace"+        lookup hVary headers `shouldBe` Just "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"+    it "rejects unsupported/duplicate methods and malformed requested header tokens" $ do+      (status, _, body) <- capture (corsMiddleware policy baseApp) "OPTIONS" (preflight ops "POST")+      status `shouldBe` status403+      errorCode body `shouldBe` Just (String "cors_method_not_allowed")+      forM_ ["", "X Header", "X:Header", "X-Good,", "X-Good, \xff", "X\r\nInjected"] $ \header -> do+        (s, _, b) <- capture (corsMiddleware policy baseApp) "OPTIONS" (preflight ops "GET" <> [("Access-Control-Request-Headers", header)])+        s `shouldBe` status400+        errorCode b `shouldBe` Just (String "invalid_cors_request")+      (emptyStatus, _, _) <- capture (corsMiddleware policy baseApp) "OPTIONS" (preflight ops "GET" <> [("Access-Control-Request-Headers", ""), ("Access-Control-Request-Headers", "Authorization")])+      emptyStatus `shouldBe` status400+      (s, _, _) <- capture (corsMiddleware policy baseApp) "OPTIONS" (preflight ops "GET" <> [("Access-Control-Request-Method", "HEAD")])+      s `shouldBe` status400+    it "passes plain OPTIONS and disallowed preflights through without grants" $ do+      forM_ [[], [(hOrigin, ops)], preflight evil "GET", [(hOrigin, ops), (hOrigin, ops), ("Access-Control-Request-Method", "GET")]] $ \headers -> do+        (s, hs, body) <- capture (corsMiddleware policy baseApp) "OPTIONS" headers+        s `shouldBe` status200+        body `shouldBe` "legacy"+        if headers == [(hOrigin, ops)] then lookup "Access-Control-Allow-Origin" hs `shouldBe` Just ops else grants hs `shouldBe` []+    it "merges existing Vary case-insensitively and preserves wildcard variation" $ do+      let app headers _ respond = respond (Wai.responseLBS status200 headers "")+      (_, hs, _) <- capture (corsMiddleware policy (app [(hVary, "Accept, origin"), (hVary, "ACCEPT, X-Foo")])) "GET" []+      lookup hVary hs `shouldBe` Just "Accept, origin, X-Foo"+      (_, wildcard, _) <- capture (corsMiddleware policy (app [(hVary, "Accept, *")])) "GET" [(hOrigin, ops)]+      lookup hVary wildcard `shouldBe` Just "*"+    it "owns grants once, adds optional credentials and ignores negative max age" $ do+      let app _ respond = respond (Wai.responseLBS status200 [("Access-Control-Allow-Origin", "*"), ("Access-Control-Allow-Origin", evil), ("Access-Control-Allow-Credentials", "true")] "")+      (_, hs, _) <- capture (corsMiddleware policy app) "GET" [(hOrigin, ops)]+      grants hs `shouldBe` [("Access-Control-Allow-Origin", ops)]+      (_, denied, _) <- capture (corsMiddleware policy app) "GET" [(hOrigin, evil)]+      grants denied `shouldBe` []+      forM_ [Nothing, Just (-1), Just 0, Just 3600] $ \age -> do+        (_, headers, _) <- capture (corsMiddleware (policy {allowCredentials = True, maxAgeSeconds = age}) baseApp) "OPTIONS" (preflight ops "GET")+        lookup "Access-Control-Allow-Credentials" headers `shouldBe` Just "true"+        lookup "Access-Control-Max-Age" headers `shouldBe` case age of+          Just n | n >= 0 -> Just (fromStringInt n)+          _ -> Nothing+    it "refuses malformed/duplicate/disallowed upgrade origins before invoking the inner app" $ do+      calls <- newIORef (0 :: Int)+      let app req respond = modifyIORef' calls (+ 1) >> baseApp req respond+      forM_ [[(hOrigin, evil)], [(hOrigin, "null")], [(hOrigin, ops <> "/")], [(hOrigin, ops), (hOrigin, ops)]] $ \headers -> do+        (s, hs, body) <- capture (corsMiddleware policy app) "GET" (("Upgrade", "websocket") : headers)+        s `shouldBe` status403+        lookup hContentType hs `shouldBe` Just "application/json"+        errorCode body `shouldBe` Just (String "origin_not_allowed")+      readIORef calls `shouldReturn` 0+      forM_ [[], [(hOrigin, ops)]] $ \headers -> do+        _ <- capture (corsMiddleware policy app) "GET" (("Upgrade", "websocket") : headers)+        pure ()+      readIORef calls `shouldReturn` 2+    it "never reuses grants between sequential requests with different origins" $ do+      forM_ [[], [(hOrigin, ops)], [(hOrigin, evil)], [(hOrigin, ops)], []] $ \headers -> do+        (_, hs, _) <- capture (corsMiddleware policy baseApp) "GET" headers+        lookup hVary hs `shouldBe` Just "Origin"+        lookup "Access-Control-Allow-Origin" hs `shouldBe` if headers == [(hOrigin, ops)] then Just ops else Nothing+    it "leaves raw upgrade responses untouched" $ do+      let original = Wai.responseRaw (\_ _ -> pure ()) (Wai.responseLBS status500 [] "fallback")+          app _ respond = respond original+      ref <- newIORef Nothing+      _ <- corsMiddleware policy app (Wai.defaultRequest {Wai.requestHeaders = [("Upgrade", "websocket"), (hOrigin, ops)]}) (\r -> writeIORef ref (Just r) >> pure ResponseReceived)+      response <- readIORef ref+      case response of+        Just (ResponseRaw _ _) -> pure ()+        _ -> expectationFailure "raw response was rewritten"+    it "strips HEAD bodies on a real Warp server while preserving grants and status" $+      Warp.testWithApplication (pure (corsMiddleware policy baseApp)) $ \port -> do+        manager <- HTTP.newManager HTTP.defaultManagerSettings+        get <- networkRequest manager port "GET" [(hOrigin, ops)]+        headResponse <- networkRequest manager port "HEAD" [(hOrigin, ops)]+        HTTP.responseStatus headResponse `shouldBe` HTTP.responseStatus get+        lookup "Access-Control-Allow-Origin" (HTTP.responseHeaders headResponse) `shouldBe` Just ops+        HTTP.responseBody headResponse `shouldBe` "" -    describe "Kiroku.Metrics.JSON (shared inspection errors)" $ do-        it "pins envelope keys and omits optional details" $ do-            errorEnvelope "origin_not_allowed" "Denied." Nothing `shouldBe` object ["error" .= object ["code" .= ("origin_not_allowed" :: Text), "message" .= ("Denied." :: Text)]]-            errorEnvelope "invalid_query_parameter" "Invalid." (Just (object ["parameter" .= ("limit" :: Text)])) `shouldBe` object ["error" .= object ["code" .= ("invalid_query_parameter" :: Text), "message" .= ("Invalid." :: Text), "details" .= object ["parameter" .= ("limit" :: Text)]]]-        it "sanitizes unavailable and other store errors" $ do-            forM_ [(ConnectionError "postgres://secret", status503, "store_unavailable"), (ConnectionLost "secret", status503, "store_unavailable"), (PoolAcquisitionTimeout, status503, "store_unavailable"), (TransientTransactionFailure "40001" "secret", status503, "store_unavailable"), (TransientTransactionFailure "40P01" "secret", status503, "store_unavailable"), (UnexpectedServerError "42P01" "secret SQL", status500, "store_error"), (StreamNotFound (StreamName "private"), status500, "store_error"), (EventDecodeFailed (DecodeFailure (EventId UUID.nil) "secret payload"), status500, "event_decode_failed")] $ \(err, expected, code) -> do-                (status, _, body) <- capture (\_ respond -> respond (storeErrorResponse "store_unavailable" err)) "GET" []-                status `shouldBe` expected-                errorCode body `shouldBe` Just (String code)-                body `shouldSatisfy` (not . BS.isInfixOf "secret" . LBS.toStrict)+  describe "Kiroku.Metrics.JSON (shared inspection errors)" $ do+    it "pins envelope keys and omits optional details" $ do+      errorEnvelope "origin_not_allowed" "Denied." Nothing `shouldBe` object ["error" .= object ["code" .= ("origin_not_allowed" :: Text), "message" .= ("Denied." :: Text)]]+      errorEnvelope "invalid_query_parameter" "Invalid." (Just (object ["parameter" .= ("limit" :: Text)])) `shouldBe` object ["error" .= object ["code" .= ("invalid_query_parameter" :: Text), "message" .= ("Invalid." :: Text), "details" .= object ["parameter" .= ("limit" :: Text)]]]+    it "sanitizes unavailable and other store errors" $ do+      forM_ [(ConnectionError "postgres://secret", status503, "store_unavailable"), (ConnectionLost "secret", status503, "store_unavailable"), (PoolAcquisitionTimeout, status503, "store_unavailable"), (TransientTransactionFailure "40001" "secret", status503, "store_unavailable"), (TransientTransactionFailure "40P01" "secret", status503, "store_unavailable"), (UnexpectedServerError "42P01" "secret SQL", status500, "store_error"), (StreamNotFound (StreamName "private"), status500, "store_error"), (EventDecodeFailed (DecodeFailure (EventId UUID.nil) "secret payload"), status500, "event_decode_failed")] $ \(err, expected, code) -> do+        (status, _, body) <- capture (\_ respond -> respond (storeErrorResponse "store_unavailable" err)) "GET" []+        status `shouldBe` expected+        errorCode body `shouldBe` Just (String code)+        body `shouldSatisfy` (not . BS.isInfixOf "secret" . LBS.toStrict) -    describe "Kiroku.Metrics.Cors (real server)" $ do-        it "decorates store-backed metrics, answers preflights and keeps denied bodies unchanged" $-            withInspection policy $ \port -> do-                manager <- HTTP.newManager HTTP.defaultManagerSettings-                allowed <- networkRequest manager port "GET" [(hOrigin, ops)]-                HTTP.responseStatus allowed `shouldBe` status200-                lookup "Access-Control-Allow-Origin" (HTTP.responseHeaders allowed) `shouldBe` Just ops-                denied <- networkRequest manager port "GET" [(hOrigin, evil)]-                HTTP.responseStatus denied `shouldBe` status200-                grants (HTTP.responseHeaders denied) `shouldBe` []-                pre <- networkRequest manager port "OPTIONS" (preflight ops "GET")-                HTTP.responseStatus pre `shouldBe` status204-        it "upgrades allowed and absent origins but refuses an unlisted browser origin" $-            withInspection policy $ \port -> do-                assertSnapshot port [(hOrigin, ops)]-                refused <- wsSnapshot port [(hOrigin, evil)]-                case refused of-                    Left (WS.MalformedResponse _ _) -> pure ()-                    other -> expectationFailure ("expected 403 MalformedResponse, got " <> show other)-                assertSnapshot port []-        it "keeps upgrades open to any origin under the default disabled policy" $-            withInspection corsDisabled $ \port ->-                assertSnapshot port [(hOrigin, evil)]+  describe "Kiroku.Metrics.Cors (real server)" $ do+    it "decorates store-backed metrics, answers preflights and keeps denied bodies unchanged" $+      withInspection policy $ \port -> do+        manager <- HTTP.newManager HTTP.defaultManagerSettings+        allowed <- networkRequest manager port "GET" [(hOrigin, ops)]+        HTTP.responseStatus allowed `shouldBe` status200+        lookup "Access-Control-Allow-Origin" (HTTP.responseHeaders allowed) `shouldBe` Just ops+        denied <- networkRequest manager port "GET" [(hOrigin, evil)]+        HTTP.responseStatus denied `shouldBe` status200+        grants (HTTP.responseHeaders denied) `shouldBe` []+        pre <- networkRequest manager port "OPTIONS" (preflight ops "GET")+        HTTP.responseStatus pre `shouldBe` status204+    it "upgrades allowed and absent origins but refuses an unlisted browser origin" $+      withInspection policy $ \port -> do+        assertSnapshot port [(hOrigin, ops)]+        assertEventUpgrade port [(hOrigin, ops)]+        refused <- wsSnapshot port [(hOrigin, evil)]+        case refused of+          Left (WS.MalformedResponse _ _) -> pure ()+          other -> expectationFailure ("expected 403 MalformedResponse, got " <> show other)+        assertSnapshot port []+    it "refuses browser event upgrades by default, preserving HTTP and origin-less clients" $+      withInspection corsDisabled $ \port -> do+        assertSnapshot port []+        assertEventUpgrade port []+        refused <- timeout 15_000_000 $ try $ WS.runClientWith "127.0.0.1" port "/ws/events" WS.defaultConnectionOptions [(hOrigin, evil)] (\_ -> pure ())+        case refused of+          Just (Left (WS.MalformedResponse _ _)) -> pure ()+          other -> expectationFailure ("expected failed handshake, got " <> show other)+        manager <- HTTP.newManager HTTP.defaultManagerSettings+        req <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/ws/events")+        response <- HTTP.httpLbs req {HTTP.requestHeaders = [(hOrigin, evil), ("Connection", "Upgrade"), ("Upgrade", "websocket"), ("Sec-WebSocket-Version", "13"), ("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")]} manager+        HTTP.responseStatus response `shouldBe` status403+        lookup hContentType (HTTP.responseHeaders response) `shouldBe` Just "application/json"+        decode (HTTP.responseBody response) `shouldBe` Just (errorEnvelope "origin_not_allowed" "The request origin is not allowed." Nothing)+        grants (HTTP.responseHeaders response) `shouldBe` []+        http <- networkRequest manager port "GET" [(hOrigin, evil)]+        HTTP.responseStatus http `shouldBe` status200+        grants (HTTP.responseHeaders http) `shouldBe` []  baseApp :: Wai.Application baseApp _ respond = respond (Wai.responseLBS status200 [("X-Legacy", "kept")] "legacy")@@ -214,52 +240,61 @@  capture :: Wai.Application -> Method -> RequestHeaders -> IO (Status, ResponseHeaders, LBS.ByteString) capture app method headers = do-    ref <- newIORef Nothing-    _ <- app (Wai.defaultRequest{Wai.requestMethod = method, Wai.requestHeaders = headers}) $ \response -> do-        let (status, hs, stream) = Wai.responseToStream response-        chunks <- newIORef mempty-        stream $ \body -> body (\builder -> modifyIORef' chunks (<> builder)) (pure ())-        bytes <- toLazyByteString <$> readIORef chunks-        writeIORef ref (Just (status, hs, bytes))-        pure ResponseReceived-    readIORef ref >>= maybe (fail "application did not respond") pure+  ref <- newIORef Nothing+  _ <- app (Wai.defaultRequest {Wai.requestMethod = method, Wai.requestHeaders = headers}) $ \response -> do+    let (status, hs, stream) = Wai.responseToStream response+    chunks <- newIORef mempty+    stream $ \body -> body (\builder -> modifyIORef' chunks (<> builder)) (pure ())+    bytes <- toLazyByteString <$> readIORef chunks+    writeIORef ref (Just (status, hs, bytes))+    pure ResponseReceived+  readIORef ref >>= maybe (fail "application did not respond") pure  errorCode :: LBS.ByteString -> Maybe Value errorCode bytes = do-    Object root <- decode bytes-    Object err <- KM.lookup "error" root-    KM.lookup "code" err+  Object root <- decode bytes+  Object err <- KM.lookup "error" root+  KM.lookup "code" err  networkRequest :: HTTP.Manager -> Int -> Method -> RequestHeaders -> IO (HTTP.Response LBS.ByteString) networkRequest manager port method headers = do-    request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/metrics")-    HTTP.httpLbs (request{HTTP.method = method, HTTP.requestHeaders = headers}) manager+  request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/metrics")+  HTTP.httpLbs (request {HTTP.method = method, HTTP.requestHeaders = headers}) manager  withInspection :: CorsPolicy -> (Int -> IO a) -> IO a withInspection cors action = withMigratedTestDatabase $ \connStr -> do-    metrics <- newKirokuMetricsWith (pure (GlobalPosition 0)) (pure 0)-    withStore (defaultConnectionSettings connStr) $ \store ->-        withMetricsServerWithStore (defaultConfig{Config.port = 0, Config.cors = cors}) metrics store [] $ \server -> do-            threadDelay 300_000-            action server.serverPort+  metrics <- newKirokuMetricsWith (pure (GlobalPosition 0)) (pure 0)+  withStore (defaultConnectionSettings connStr) $ \store ->+    withMetricsServerWithStore (defaultConfig {Config.port = 0, Config.cors = cors}) metrics store [] $ \server -> do+      threadDelay 300_000+      action server.serverPort  wsSnapshot :: Int -> RequestHeaders -> IO (Either WS.HandshakeException (Maybe Value)) wsSnapshot port headers = do-    result <- timeout 15_000_000 $-        try $-            WS.runClientWith "127.0.0.1" port "/ws/metrics" WS.defaultConnectionOptions headers $ \conn -> do-                raw <- WS.receiveData conn :: IO LBS.ByteString-                pure $ do-                    Object fields <- decode raw-                    KM.lookup "type" fields-    maybe (fail "WebSocket snapshot timed out") pure result+  result <- timeout 15_000_000 $+    try $+      WS.runClientWith "127.0.0.1" port "/ws/metrics" WS.defaultConnectionOptions headers $ \conn -> do+        raw <- WS.receiveData conn :: IO LBS.ByteString+        pure $ do+          Object fields <- decode raw+          KM.lookup "type" fields+  maybe (fail "WebSocket snapshot timed out") pure result  fromStringInt :: Int -> ByteString fromStringInt = BS.pack . show  assertSnapshot :: Int -> RequestHeaders -> Expectation assertSnapshot port headers = do-    result <- wsSnapshot port headers-    case result of-        Right value -> value `shouldBe` Just (String "snapshot")-        Left err -> expectationFailure (show err)+  result <- wsSnapshot port headers+  case result of+    Right value -> value `shouldBe` Just (String "snapshot")+    Left err -> expectationFailure (show err)++-- Exercise the sensitive event channel's successful 101 handshake as well as+-- metrics snapshots, for configured browser and default origin-less clients.+assertEventUpgrade :: Int -> RequestHeaders -> Expectation+assertEventUpgrade port headers = do+  result <- timeout 15_000_000 $+    WS.runClientWith "127.0.0.1" port "/ws/events" WS.defaultConnectionOptions headers $ \conn ->+      WS.sendClose conn ("done" :: Text)+  result `shouldBe` Just ()
test/Test/DeadLettersSpec.hs view
@@ -42,149 +42,156 @@  spec :: Spec spec = describe "DeadLetters" $ do-    it "pins exact snake_case keys, preserves reason JSON and round-trips lossless positions" $ do-        let response = deadLetterPageResponse fixture-            item = object ["dead_letter_id" .= (7 :: Int), "subscription" .= ("orders" :: Text), "member" .= (9 :: Int), "global_position" .= (9007199254740993 :: Integer), "event_id" .= UUID.nil, "reason" .= object ["kind" .= ("poison" :: Text), "detail" .= ("unknown SKU" :: Text)], "reason_summary" .= ("poison: unknown SKU" :: Text), "attempt_count" .= (1 :: Int), "created_at" .= ("2026-09-10T00:00:00Z" :: Text)]-        Aeson.toJSON response `shouldBe` object ["items" .= [item], "next_cursor" .= ("9007199254740993:7" :: Text)]-        Aeson.eitherDecode (Aeson.encode response) `shouldBe` Right response-        Aeson.toJSON (deadLetterPageResponse (Store.SubscriptionDeadLetterPage V.empty Nothing)) `shouldBe` object ["items" .= ([] :: [Value])]-    it "validates decimal cursor components without signed or overflow narrowing" $ do-        forM_ ["", "1", "1:", ":1", "1:2:3", "-1:2", "1:-2", "1.5:2", "18446744073709551616:1", "9223372036854775808:1", "1:18446744073709551616", "+1:2", "1:2"] $ \raw -> parseDeadLetterCursor raw `shouldBe` Nothing-        forM_ [Store.SubscriptionDeadLetterCursor (Store.GlobalPosition 0) 0, Store.SubscriptionDeadLetterCursor (Store.GlobalPosition maxBound) maxBound] $ \cursor -> parseDeadLetterCursor (renderDeadLetterCursor cursor) `shouldBe` Just cursor-    it "passes the complete query once and uses the documented defaults" $ do-        calls <- newIORef []-        let provider query = modifyIORef' calls (<> [query]) >> pure (Right fixture)-        void $ capture (deadLettersApp provider) "GET" ["subscriptions", "orders", "dead-letters"] [("member", Just "9"), ("from", Just "4211:7"), ("limit", Just "5")]-        void $ capture (deadLettersApp provider) "GET" ["subscriptions", "orders", "dead-letters"] []-        recorded <- readIORef calls-        recorded `shouldBe` [Store.SubscriptionDeadLetterQuery (Store.SubscriptionName "orders") (Just 9) (Just (Store.SubscriptionDeadLetterCursor (Store.GlobalPosition 4211) 7)) (either (error . show) (\v -> v) (Store.mkSubscriptionDeadLetterLimit 5)), Store.defaultSubscriptionDeadLetterQuery (Store.SubscriptionName "orders")]-    it "rejects invalid, duplicate, missing and malformed UTF-8 values before calling a provider" $ do-        calls <- newIORef (0 :: Int)-        let app = deadLettersApp (\_ -> modifyIORef' calls (+ 1) >> pure (Right fixture))-        forM_ [[("limit", Just raw)] | raw <- ["0", "1001", "abc", "", "-1", "18446744073709551616"]] $ \query -> invalidRequest calls app query-        forM_ [[("member", Just "2147483648")], [("member", Just "-1")], [("from", Just "1:9223372036854775808")], [("limit", Nothing)], [("limit", Just "2"), ("limit", Just "3")], [("member", Just "\255")]] $ invalidRequest calls app-        readIORef calls `shouldReturn` 0-        (status, _, _) <- capture app "GET" ["subscriptions", "orders", "dead-letters"] [("unknown", Nothing)]-        status `shouldBe` status200-    it "implements HEAD and 405 in WAI, including unavailable and unconfigured responses" $ do-        calls <- newIORef (0 :: Int)-        let app = deadLettersApp (\_ -> modifyIORef' calls (+ 1) >> pure (Right fixture))-        (status, headers, _) <- capture app "GET" route []-        capture app "HEAD" route [] `shouldReturn` (status, headers, "")-        forM_ ["POST", "PUT", "DELETE", "OPTIONS"] $ \method -> do-            (actual, hs, body) <- capture app method route []-            actual `shouldBe` status405-            lookup "Allow" hs `shouldBe` Just "GET, HEAD"-            code body `shouldBe` Just "method_not_allowed"-        readIORef calls `shouldReturn` 2-        (missing, _, body) <- capture deadLettersNotConfiguredApp "GET" route []-        missing `shouldBe` status404-        code body `shouldBe` Just "dead_letters_not_configured"-    it "sanitizes typed store failures and preserves HEAD error headers" $ do-        forM_ [(Store.ConnectionError "postgres://secret", status503, "dead_letters_unavailable"), (Store.ConnectionLost "secret", status503, "dead_letters_unavailable"), (Store.PoolAcquisitionTimeout, status503, "dead_letters_unavailable"), (Store.TransientTransactionFailure "40001" "secret", status503, "dead_letters_unavailable"), (Store.TransientTransactionFailure "40P01" "secret", status503, "dead_letters_unavailable"), (Store.UnexpectedServerError "42P01" "secret SQL", status500, "store_error"), (Store.StreamNotFound (Store.StreamName "secret"), status500, "store_error"), (Store.EventDecodeFailed (Store.DecodeFailure (Store.EventId UUID.nil) "secret"), status500, "event_decode_failed")] $ \(err, expected, expectedCode) -> do-            let app = deadLettersApp (\_ -> pure (Left err))-            (status, headers, body) <- capture app "GET" route []-            status `shouldBe` expected-            code body `shouldBe` Just expectedCode-            body `shouldSatisfy` (not . T.isInfixOf "secret" . T.pack . LBSC.unpack)-            capture app "HEAD" route [] `shouldReturn` (status, headers, "")-    it "propagates thrown provider exceptions and serves structured unknown-path errors" $ do-        result <- try @SomeException $ capture (deadLettersApp (\_ -> throwIO (userError "failure"))) "GET" route []-        result `shouldSatisfy` either (const True) (const False)-        (status, _, body) <- capture (deadLettersApp (\_ -> error "must not run")) "GET" ["unknown"] []-        status `shouldBe` status404-        code body `shouldBe` Just "not_found"-    it "serves real pages, member filters and legacy behavior from the store-aware server" $ withTestStore $ \store -> do-        let event = Store.EventData Nothing (Store.EventType "Created") (object []) Nothing Nothing Nothing-        Right _ <- Store.runStoreIO store $ Store.appendToStream (Store.StreamName "orders-1") Store.NoStream (replicate 5 event)-        Right events <- Store.runStoreIO store $ Store.readAllForward (Store.GlobalPosition 0) 10-        forM_ (V.toList events) $ \recorded -> seed store "paged/name" 0 recorded-        seed store "other" 0 (V.head events)-        handle <- Store.subscribe store $ Store.defaultSubscriptionConfig (Store.SubscriptionName "worker") Store.AllStreams $ \recorded -> pure $ case recorded ^. #globalPosition of-            Store.GlobalPosition 2 -> Store.DeadLetter (Store.DeadLetterPoison "unknown SKU")-            Store.GlobalPosition 5 -> Store.Stop-            _ -> Store.Continue-        stopped <- timeout 10_000_000 (Store.wait handle)-        case stopped of-            Just (Right ()) -> pure ()-            other -> Store.cancel handle >> expectationFailure (show other)-        metrics <- newKirokuMetrics store-        withMetricsServerWithStore defaultConfig{port = 0} metrics store [] $ \server -> do-            workerPage <- get server.serverPort "/subscriptions/worker/dead-letters" >>= decodePage-            map (.globalPosition) workerPage.items `shouldBe` [2]-            map (.reason) workerPage.items `shouldBe` [object ["kind" .= ("poison" :: Text), "detail" .= ("unknown SKU" :: Text)]]-            putStrLn ("Dead-letter worker response: " <> LBSC.unpack (Aeson.encode workerPage))-            first <- get server.serverPort "/subscriptions/paged%2Fname/dead-letters?limit=2"-            p1 <- decodePage first-            map (.globalPosition) p1.items `shouldBe` [5, 4]-            p2 <- get server.serverPort ("/subscriptions/paged%2Fname/dead-letters?limit=2&from=" <> maybe "" T.unpack p1.nextCursor) >>= decodePage-            p3 <- get server.serverPort ("/subscriptions/paged%2Fname/dead-letters?limit=2&from=" <> maybe "" T.unpack p2.nextCursor) >>= decodePage-            map (.globalPosition) p2.items `shouldBe` [3, 2]-            map (.globalPosition) p3.items `shouldBe` [1]-            p3.nextCursor `shouldBe` Nothing-            empty <- get server.serverPort "/subscriptions/never/dead-letters" >>= decodePage-            empty `shouldBe` DeadLetterPageResponse [] Nothing-            filtered <- get server.serverPort "/subscriptions/paged%2Fname/dead-letters?member=9" >>= decodePage-            filtered `shouldBe` DeadLetterPageResponse [] Nothing-            old <- get server.serverPort "/subscriptions"-            Aeson.decode (HTTP.responseBody old) `shouldBe` Just (object ["error" .= ("subscription status not configured" :: Text)])-            unknown <- get server.serverPort "/nope"-            Aeson.decode (HTTP.responseBody unknown) `shouldBe` Just (object ["error" .= ("Not found" :: Text)])-        providers <- storeServerProviders defaultConfig metrics store-        withMetricsServerWithProviders defaultConfig{port = 0} metrics [] providers $ \server -> do-            live <- get server.serverPort "/subscriptions"-            HTTP.responseStatus live `shouldBe` status200-            void $ get server.serverPort "/subscriptions/paged%2Fname/dead-letters" >>= decodePage-    it "inherits mount-relative routing and CORS with legacy switches disabled" $ do-        metrics <- newKirokuMetricsWith (pure (Store.GlobalPosition 0)) (pure 0)-        let origin = either (error . show) (\v -> v) (allowedOrigin "https://ops.example.com")-            config = defaultConfig{cors = corsAllowOrigins [origin], enableJSON = False, enableWebSocket = False}-            providers = defaultServerProviders{deadLetters = Just (\_ -> pure (Right fixture))}-            mounted req respond = combinedAppWithProviders config metrics [] providers (req{Wai.pathInfo = drop 1 (Wai.pathInfo req)}) respond-        Warp.testWithApplication (pure mounted) $ \port -> do-            manager <- HTTP.newManager HTTP.defaultManagerSettings-            request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/inspect/subscriptions/orders/dead-letters")-            response <- HTTP.httpLbs request{HTTP.requestHeaders = [("Origin", "https://ops.example.com")]} manager-            HTTP.responseStatus response `shouldBe` status200-            lookup "Access-Control-Allow-Origin" (HTTP.responseHeaders response) `shouldBe` Just "https://ops.example.com"+  it "pins exact snake_case keys, preserves reason JSON and round-trips lossless positions" $ do+    let response = deadLetterPageResponse fixture+        item = object ["dead_letter_id" .= (7 :: Int), "subscription" .= ("orders" :: Text), "member" .= (9 :: Int), "global_position" .= (9007199254740993 :: Integer), "event_id" .= UUID.nil, "reason" .= object ["kind" .= ("poison" :: Text), "detail" .= ("unknown SKU" :: Text)], "reason_summary" .= ("poison: unknown SKU" :: Text), "attempt_count" .= (1 :: Int), "created_at" .= ("2026-09-10T00:00:00Z" :: Text)]+    Aeson.toJSON response `shouldBe` object ["items" .= [item], "next_cursor" .= ("9007199254740993:7" :: Text)]+    Aeson.eitherDecode (Aeson.encode response) `shouldBe` Right response+    Aeson.toJSON (deadLetterPageResponse (Store.SubscriptionDeadLetterPage V.empty Nothing)) `shouldBe` object ["items" .= ([] :: [Value])]+  it "validates decimal cursor components without signed or overflow narrowing" $ do+    forM_ ["", "1", "1:", ":1", "1:2:3", "-1:2", "1:-2", "1.5:2", "18446744073709551616:1", "9223372036854775808:1", "1:18446744073709551616", "+1:2", "1:2"] $ \raw -> parseDeadLetterCursor raw `shouldBe` Nothing+    forM_ [Store.SubscriptionDeadLetterCursor (Store.GlobalPosition 0) 0, Store.SubscriptionDeadLetterCursor (Store.GlobalPosition maxBound) maxBound] $ \cursor -> parseDeadLetterCursor (renderDeadLetterCursor cursor) `shouldBe` Just cursor+  it "passes the complete query once and uses the documented defaults" $ do+    calls <- newIORef []+    let provider query = modifyIORef' calls (<> [query]) >> pure (Right fixture)+    void $ capture (deadLettersApp provider) "GET" ["subscriptions", "orders", "dead-letters"] [("member", Just "9"), ("from", Just "4211:7"), ("limit", Just "5")]+    void $ capture (deadLettersApp provider) "GET" ["subscriptions", "orders", "dead-letters"] []+    recorded <- readIORef calls+    recorded `shouldBe` [Store.SubscriptionDeadLetterQuery (Store.SubscriptionName "orders") (Just 9) (Just (Store.SubscriptionDeadLetterCursor (Store.GlobalPosition 4211) 7)) (either (error . show) (\v -> v) (Store.mkSubscriptionDeadLetterLimit 5)), Store.defaultSubscriptionDeadLetterQuery (Store.SubscriptionName "orders")]+  it "rejects invalid, duplicate, missing and malformed UTF-8 values before calling a provider" $ do+    calls <- newIORef (0 :: Int)+    let app = deadLettersApp (\_ -> modifyIORef' calls (+ 1) >> pure (Right fixture))+    forM_ [[("limit", Just raw)] | raw <- ["0", "1001", "abc", "", "-1", "18446744073709551616"]] $ \query -> invalidRequest calls app query+    forM_ [[("member", Just "2147483648")], [("member", Just "-1")], [("from", Just "1:9223372036854775808")], [("limit", Nothing)], [("limit", Just "2"), ("limit", Just "3")], [("member", Just "\255")]] $ invalidRequest calls app+    readIORef calls `shouldReturn` 0+    (status, _, _) <- capture app "GET" ["subscriptions", "orders", "dead-letters"] [("unknown", Nothing)]+    status `shouldBe` status200+  it "implements HEAD and 405 in WAI, including unavailable and unconfigured responses" $ do+    calls <- newIORef (0 :: Int)+    let app = deadLettersApp (\_ -> modifyIORef' calls (+ 1) >> pure (Right fixture))+    (status, headers, _) <- capture app "GET" route []+    capture app "HEAD" route [] `shouldReturn` (status, headers, "")+    forM_ ["POST", "PUT", "DELETE", "OPTIONS"] $ \method -> do+      (actual, hs, body) <- capture app method route []+      actual `shouldBe` status405+      lookup "Allow" hs `shouldBe` Just "GET, HEAD"+      code body `shouldBe` Just "method_not_allowed"+    readIORef calls `shouldReturn` 2+    (missing, _, body) <- capture deadLettersNotConfiguredApp "GET" route []+    missing `shouldBe` status404+    code body `shouldBe` Just "dead_letters_not_configured"+  it "sanitizes typed store failures and preserves HEAD error headers" $ do+    forM_ [(Store.ConnectionError "postgres://secret", status503, "dead_letters_unavailable"), (Store.ConnectionLost "secret", status503, "dead_letters_unavailable"), (Store.PoolAcquisitionTimeout, status503, "dead_letters_unavailable"), (Store.TransientTransactionFailure "40001" "secret", status503, "dead_letters_unavailable"), (Store.TransientTransactionFailure "40P01" "secret", status503, "dead_letters_unavailable"), (Store.UnexpectedServerError "42P01" "secret SQL", status500, "store_error"), (Store.StreamNotFound (Store.StreamName "secret"), status500, "store_error"), (Store.EventDecodeFailed (Store.DecodeFailure (Store.EventId UUID.nil) "secret"), status500, "event_decode_failed")] $ \(err, expected, expectedCode) -> do+      let app = deadLettersApp (\_ -> pure (Left err))+      (status, headers, body) <- capture app "GET" route []+      status `shouldBe` expected+      code body `shouldBe` Just expectedCode+      body `shouldSatisfy` (not . T.isInfixOf "secret" . T.pack . LBSC.unpack)+      capture app "HEAD" route [] `shouldReturn` (status, headers, "")+  it "propagates thrown provider exceptions and serves structured unknown-path errors" $ do+    result <- try @SomeException $ capture (deadLettersApp (\_ -> throwIO (userError "failure"))) "GET" route []+    result `shouldSatisfy` either (const True) (const False)+    (status, _, body) <- capture (deadLettersApp (\_ -> error "must not run")) "GET" ["unknown"] []+    status `shouldBe` status404+    code body `shouldBe` Just "not_found"+  it "serves real pages, member filters and legacy behavior from the store-aware server" $ withTestStore $ \store -> do+    let event = Store.EventData Nothing (Store.EventType "Created") (object []) Nothing Nothing Nothing+    Right _ <- Store.runStoreIO store $ Store.appendToStream (Store.StreamName "orders-1") Store.NoStream (replicate 5 event)+    Right events <- Store.runStoreIO store $ Store.readAllForward (Store.GlobalPosition 0) 10+    forM_ (V.toList events) $ \recorded -> seed store "paged/name" 0 recorded+    seed store "other" 0 (V.head events)+    handle <- Store.subscribe store $ Store.defaultSubscriptionConfig (Store.SubscriptionName "worker") Store.AllStreams $ \recorded -> pure $ case recorded ^. #globalPosition of+      Store.GlobalPosition 2 -> Store.DeadLetter (Store.DeadLetterPoison "unknown SKU")+      Store.GlobalPosition 5 -> Store.Stop+      _ -> Store.Continue+    stopped <- timeout 10_000_000 (Store.wait handle)+    case stopped of+      Just (Right ()) -> pure ()+      other -> Store.cancel handle >> expectationFailure (show other)+    metrics <- newKirokuMetrics store+    withMetricsServerWithStore defaultConfig {port = 0} metrics store [] $ \server -> do+      workerPage <- get server.serverPort "/subscriptions/worker/dead-letters" >>= decodePage+      map (.globalPosition) workerPage.items `shouldBe` [2]+      map (.reason) workerPage.items `shouldBe` [object ["kind" .= ("poison" :: Text), "detail" .= ("unknown SKU" :: Text)]]+      putStrLn ("Dead-letter worker response: " <> LBSC.unpack (Aeson.encode workerPage))+      first <- get server.serverPort "/subscriptions/paged%2Fname/dead-letters?limit=2"+      p1 <- decodePage first+      map (.globalPosition) p1.items `shouldBe` [5, 4]+      p2 <- get server.serverPort ("/subscriptions/paged%2Fname/dead-letters?limit=2&from=" <> maybe "" T.unpack p1.nextCursor) >>= decodePage+      p3 <- get server.serverPort ("/subscriptions/paged%2Fname/dead-letters?limit=2&from=" <> maybe "" T.unpack p2.nextCursor) >>= decodePage+      map (.globalPosition) p2.items `shouldBe` [3, 2]+      map (.globalPosition) p3.items `shouldBe` [1]+      p3.nextCursor `shouldBe` Nothing+      empty <- get server.serverPort "/subscriptions/never/dead-letters" >>= decodePage+      empty `shouldBe` DeadLetterPageResponse [] Nothing+      filtered <- get server.serverPort "/subscriptions/paged%2Fname/dead-letters?member=9" >>= decodePage+      filtered `shouldBe` DeadLetterPageResponse [] Nothing+      old <- get server.serverPort "/subscriptions"+      Aeson.decode (HTTP.responseBody old) `shouldBe` Just (object ["error" .= ("subscription status not configured" :: Text)])+      unknown <- get server.serverPort "/nope"+      Aeson.decode (HTTP.responseBody unknown) `shouldBe` Just (object ["error" .= ("Not found" :: Text)])+    providers <- storeServerProviders defaultConfig metrics store+    withMetricsServerWithProviders defaultConfig {port = 0} metrics [] providers $ \server -> do+      live <- get server.serverPort "/subscriptions"+      HTTP.responseStatus live `shouldBe` status200+      void $ get server.serverPort "/subscriptions/paged%2Fname/dead-letters" >>= decodePage+  it "inherits mount-relative routing and CORS with legacy switches disabled" $ do+    metrics <- newKirokuMetricsWith (pure (Store.GlobalPosition 0)) (pure 0)+    let origin = either (error . show) (\v -> v) (allowedOrigin "https://ops.example.com")+        config = defaultConfig {cors = corsAllowOrigins [origin], enableJSON = False, enableWebSocket = False}+        providers = defaultServerProviders {deadLetters = Just (\_ -> pure (Right fixture))}+        mounted req respond = combinedAppWithProviders config metrics [] providers (req {Wai.pathInfo = drop 1 (Wai.pathInfo req)}) respond+    Warp.testWithApplication (pure mounted) $ \port -> do+      manager <- HTTP.newManager HTTP.defaultManagerSettings+      request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> "/inspect/subscriptions/orders/dead-letters")+      response <- HTTP.httpLbs request {HTTP.requestHeaders = [("Origin", "https://ops.example.com")]} manager+      HTTP.responseStatus response `shouldBe` status200+      lookup "Access-Control-Allow-Origin" (HTTP.responseHeaders response) `shouldBe` Just "https://ops.example.com"  route :: [Text] route = ["subscriptions", "orders", "dead-letters"]+ invalidRequest :: IORef Int -> Wai.Application -> Query -> IO () invalidRequest calls app query = do-    priorCalls <- readIORef calls-    (status, _, body) <- capture app "GET" route query-    status `shouldBe` status400-    code body `shouldBe` Just "invalid_query_parameter"-    readIORef calls `shouldReturn` priorCalls+  priorCalls <- readIORef calls+  (status, _, body) <- capture app "GET" route query+  status `shouldBe` status400+  code body `shouldBe` Just "invalid_query_parameter"+  readIORef calls `shouldReturn` priorCalls+ code :: LBS.ByteString -> Maybe Text code body = case Aeson.decode body of Just (Object fields) | Just (Object err) <- KM.lookup "error" fields, Just (String value) <- KM.lookup "code" err -> Just value; _ -> Nothing+ capture :: Wai.Application -> Method -> [Text] -> Query -> IO (Status, ResponseHeaders, LBS.ByteString) capture app method path query = do-    result <- newIORef Nothing-    _ <- app Wai.defaultRequest{Wai.requestMethod = method, Wai.pathInfo = path, Wai.queryString = query} $ \response -> do-        let (status, headers, stream) = Wai.responseToStream response-        stream $ \send -> do-            chunks <- newIORef []-            send (\chunk -> modifyIORef' chunks (<> [toLazyByteString chunk])) (pure ())-            bytes <- mconcat <$> readIORef chunks-            writeIORef result (Just (status, headers, bytes))-        pure ResponseReceived-    maybe (fail "No response") pure =<< readIORef result+  result <- newIORef Nothing+  _ <- app Wai.defaultRequest {Wai.requestMethod = method, Wai.pathInfo = path, Wai.queryString = query} $ \response -> do+    let (status, headers, stream) = Wai.responseToStream response+    stream $ \send -> do+      chunks <- newIORef []+      send (\chunk -> modifyIORef' chunks (<> [toLazyByteString chunk])) (pure ())+      bytes <- mconcat <$> readIORef chunks+      writeIORef result (Just (status, headers, bytes))+    pure ResponseReceived+  maybe (fail "No response") pure =<< readIORef result+ withTestStore :: (Store.KirokuStore -> IO ()) -> IO () withTestStore action = withMigratedTestDatabase $ \connection -> Store.withStore (Store.defaultConnectionSettings connection) action+ get :: Int -> String -> IO (HTTP.Response LBS.ByteString) get port path = do-    manager <- HTTP.newManager HTTP.defaultManagerSettings-    request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> path)-    HTTP.httpLbs request manager+  manager <- HTTP.newManager HTTP.defaultManagerSettings+  request <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> path)+  HTTP.httpLbs request manager+ decodePage :: HTTP.Response LBS.ByteString -> IO DeadLetterPageResponse decodePage response = do-    HTTP.responseStatus response `shouldBe` status200-    either fail pure (Aeson.eitherDecode (HTTP.responseBody response))+  HTTP.responseStatus response `shouldBe` status200+  either fail pure (Aeson.eitherDecode (HTTP.responseBody response))+ seed :: Store.KirokuStore -> Text -> Int32 -> Store.RecordedEvent -> IO () seed store name member recorded = do-    let Store.EventId eid = recorded ^. #eventId-        Store.GlobalPosition position = recorded ^. #globalPosition-        params = SQL.DeadLetterParams name member "unbound" Nothing (member + 1) position eid (object ["kind" .= ("poison" :: Text), "detail" .= ("unknown SKU" :: Text)]) "poison: unknown SKU" 1-    Pool.use (store ^. #pool) (Session.statement params SQL.insertDeadLetterAndCheckpointStmt) >>= either (fail . show) pure+  let Store.EventId eid = recorded ^. #eventId+      Store.GlobalPosition position = recorded ^. #globalPosition+      params = SQL.DeadLetterParams name member "unbound" Nothing (member + 1) position eid (object ["kind" .= ("poison" :: Text), "detail" .= ("unknown SKU" :: Text)]) "poison: unknown SKU" 1+  Pool.use (store ^. #pool) (Session.statement params SQL.insertDeadLetterAndCheckpointStmt) >>= either (fail . show) pure
test/Test/IntegrationSpec.hs view
@@ -1,19 +1,18 @@ {-# LANGUAGE OverloadedLabels #-} -{- | Integration test: wire the collector into a real ephemeral-PostgreSQL-backed store, run a live @$all@ subscription, append events, and-assert the snapshot reflects real store activity (not just scripted inputs).--The ordering wrinkle is that 'newKirokuMetrics' needs the 'KirokuStore' but the-callbacks must be installed on 'ConnectionSettings' before 'withStore' creates-it. We resolve it with a @TVar (Maybe KirokuStore)@ the snapshot-time STM-readers consult; it is filled inside 'withStore'. (A 'TVar', not an 'IORef',-because the readers are 'STM'.)--}+-- | Integration test: wire the collector into a real ephemeral+-- PostgreSQL-backed store, run a live @$all@ subscription, append events, and+-- assert the snapshot reflects real store activity (not just scripted inputs).+--+-- The ordering wrinkle is that 'newKirokuMetrics' needs the 'KirokuStore' but the+-- callbacks must be installed on 'ConnectionSettings' before 'withStore' creates+-- it. We resolve it with a @TVar (Maybe KirokuStore)@ the snapshot-time STM+-- readers consult; it is filled inside 'withStore'. (A 'TVar', not an 'IORef',+-- because the readers are 'STM'.) module Test.IntegrationSpec (spec) where -import Control.Concurrent.STM (-    STM,+import Control.Concurrent.STM+  ( STM,     TVar,     atomically,     check,@@ -22,7 +21,7 @@     readTVar,     registerDelay,     writeTVar,- )+  ) import Control.Lens ((&), (.~)) import Control.Monad (unless) import Data.Aeson qualified as Aeson@@ -32,8 +31,8 @@ import Data.Text qualified as T import Test.Hspec -import Kiroku.Metrics (-    LifecycleCounters (..),+import Kiroku.Metrics+  ( LifecycleCounters (..),     MetricsSnapshot (..),     StoreGauges (..),     SubscriptionMetrics (..),@@ -41,9 +40,9 @@     metricsObservationHandler,     newKirokuMetricsWith,     snapshotMetrics,- )-import Kiroku.Store (-    EventData (..),+  )+import Kiroku.Store+  ( EventData (..),     EventType (..),     ExpectedVersion (..),     KirokuStore (..),@@ -58,7 +57,7 @@     subscribe,     wait,     withStore,- )+  ) import Kiroku.Store.Subscription.EventPublisher (EventPublisher (..), publisherPosition) import Kiroku.Store.Subscription.Types (SubscriptionName (..)) import Kiroku.Store.Types (GlobalPosition (..))@@ -69,58 +68,57 @@  spec :: Spec spec = describe "Kiroku.Metrics.Collector (integration)" $ do-    it "observes real store activity: appends, a running subscription, and lag" $-        withMigratedTestDatabase $ \connStr -> do-            -- Deferred store handle for the snapshot-time STM readers.-            storeVar <- newTVarIO Nothing-            km <--                newKirokuMetricsWith-                    (readPosition storeVar)-                    (readSubscribers storeVar)-            let settings =-                    defaultConnectionSettings connStr-                        & #eventHandler .~ Just (metricsEventHandler km Nothing)-                        & #observationHandler .~ Just (metricsObservationHandler km Nothing)-            withStore settings $ \store -> do-                atomically (writeTVar storeVar (Just store))+  it "observes real store activity: appends, a running subscription, and lag" $+    withMigratedTestDatabase $ \connStr -> do+      -- Deferred store handle for the snapshot-time STM readers.+      storeVar <- newTVarIO Nothing+      km <-+        newKirokuMetricsWith+          (readPosition storeVar)+          (readSubscribers storeVar)+      let settings =+            defaultConnectionSettings connStr+              & #eventHandler .~ Just (metricsEventHandler km Nothing)+              & #observationHandler .~ Just (metricsObservationHandler km Nothing)+      withStore settings $ \store -> do+        atomically (writeTVar storeVar (Just store)) -                -- Subscribe to $all on an empty store: the worker goes live-                -- immediately, so the 5 events we append next flow through the-                -- live publisher path and advance publisherPosition.-                delivered <- newTVarIO (0 :: Int)-                let cfg =-                        defaultSubscriptionConfig subName AllStreams $ \_event -> do-                            atomically (modifyCount delivered)-                            pure Continue-                handle <- subscribe store cfg+        -- Subscribe to $all on an empty store: the worker goes live+        -- immediately, so the 5 events we append next flow through the+        -- live publisher path and advance publisherPosition.+        delivered <- newTVarIO (0 :: Int)+        let cfg =+              defaultSubscriptionConfig subName AllStreams $ \_event -> do+                atomically (modifyCount delivered)+                pure Continue+        handle <- subscribe store cfg -                appendStoreEvents store (StreamName "metrics-it-stream") 5-                waitForCount delivered 5 10_000_000+        appendStoreEvents store (StreamName "metrics-it-stream") 5+        waitForCount delivered 5 10_000_000 -                cancel handle-                _ <- wait handle+        cancel handle+        _ <- wait handle -                snap <- snapshotMetrics km-                snap.store.globalPosition `shouldSatisfy` (>= 5)-                snap.counters.subscriptionsStarted `shouldBe` 1-                snap.counters.subscriptionsStoppedCancelled `shouldBe` 1-                snap.counters.eventsDelivered `shouldSatisfy` (>= 5)-                case Map.lookup "metrics-it" snap.subscriptions of-                    Nothing -> expectationFailure "expected subscription \"metrics-it\" in snapshot"-                    Just m -> m.lag `shouldSatisfy` (>= 0)+        snap <- snapshotMetrics km+        snap.store.globalPosition `shouldSatisfy` (>= 5)+        snap.counters.subscriptionsStarted `shouldBe` 1+        snap.counters.subscriptionsStoppedCancelled `shouldBe` 1+        snap.counters.eventsDelivered `shouldSatisfy` (>= 5)+        case Map.lookup "metrics-it" snap.subscriptions of+          Nothing -> expectationFailure "expected subscription \"metrics-it\" in snapshot"+          Just m -> m.lag `shouldSatisfy` (>= 0) -{- | Snapshot-time reader for the store global position, defaulting to 0 until-the store is filled in.--}+-- | Snapshot-time reader for the store global position, defaulting to 0 until+-- the store is filled in. readPosition :: TVar (Maybe KirokuStore) -> STM GlobalPosition readPosition storeVar =-    readTVar storeVar >>= maybe (pure (GlobalPosition 0)) (publisherPosition . (.publisher))+  readTVar storeVar >>= maybe (pure (GlobalPosition 0)) (publisherPosition . (.publisher))  -- | Snapshot-time reader for the active-subscriber count. readSubscribers :: TVar (Maybe KirokuStore) -> STM Int readSubscribers storeVar =-    readTVar storeVar-        >>= maybe (pure 0) (\s -> IntMap.size <$> readTVar (subscribers s.publisher))+  readTVar storeVar+    >>= maybe (pure 0) (\s -> IntMap.size <$> readTVar (subscribers s.publisher))  modifyCount :: TVar Int -> STM () modifyCount v = readTVar v >>= \c -> writeTVar v (c + 1)@@ -128,30 +126,30 @@ -- | Append @n@ trivial events to a fresh stream via the IO store interpreter. appendStoreEvents :: KirokuStore -> StreamName -> Int -> IO () appendStoreEvents store stream n = do-    let events =-            [ EventData-                { eventId = Nothing-                , eventType = EventType ("E" <> T.pack (show i))-                , payload = Aeson.Null-                , metadata = Nothing-                , causationId = Nothing-                , correlationId = Nothing-                }-            | i <- [1 .. n]-            ]-    result <- runStoreIO store (appendToStream stream NoStream events)-    case result of-        Right _ -> pure ()-        Left err -> expectationFailure ("appendStoreEvents failed: " <> show err)+  let events =+        [ EventData+            { eventId = Nothing,+              eventType = EventType ("E" <> T.pack (show i)),+              payload = Aeson.Null,+              metadata = Nothing,+              causationId = Nothing,+              correlationId = Nothing+            }+        | i <- [1 .. n]+        ]+  result <- runStoreIO store (appendToStream stream NoStream events)+  case result of+    Right _ -> pure ()+    Left err -> expectationFailure ("appendStoreEvents failed: " <> show err)  -- | Wait until the counter reaches @target@ or the timeout (micros) fires. waitForCount :: TVar Int -> Int -> Int -> IO () waitForCount countVar target timeoutMicros = do-    timeoutVar <- registerDelay timeoutMicros-    ok <--        atomically $-            (do c <- readTVar countVar; check (c >= target); pure True)-                `orElse` (do t <- readTVar timeoutVar; check t; pure False)-    unless ok $ do-        actual <- atomically (readTVar countVar)-        expectationFailure ("Timed out waiting for " <> show target <> ", got " <> show actual)+  timeoutVar <- registerDelay timeoutMicros+  ok <-+    atomically $+      (do c <- readTVar countVar; check (c >= target); pure True)+        `orElse` (do t <- readTVar timeoutVar; check t; pure False)+  unless ok $ do+    actual <- atomically (readTVar countVar)+    expectationFailure ("Timed out waiting for " <> show target <> ", got " <> show actual)
test/Test/ServerSpec.hs view
@@ -1,14 +1,13 @@ {-# LANGUAGE OverloadedLabels #-} -{- | End-to-end test of the HTTP endpoints: boot a real store with the collector-wired in, run a live subscription, start the server on an OS-assigned port, and-hit every endpoint with an HTTP client asserting status codes and bodies.--}+-- | End-to-end test of the HTTP endpoints: boot a real store with the collector+-- wired in, run a live subscription, start the server on an OS-assigned port, and+-- hit every endpoint with an HTTP client asserting status codes and bodies. module Test.ServerSpec (spec) where  import Control.Concurrent (threadDelay)-import Control.Concurrent.STM (-    STM,+import Control.Concurrent.STM+  ( STM,     TVar,     atomically,     check,@@ -17,7 +16,7 @@     readTVar,     registerDelay,     writeTVar,- )+  ) import Control.Lens ((&), (.~)) import Control.Monad (unless) import Data.Aeson (Value (..), decode)@@ -29,20 +28,20 @@ import Data.Scientific (toRealFloat) import Data.Text qualified as T import Data.Text.Encoding qualified as TE-import Network.HTTP.Client (-    Manager,+import Network.HTTP.Client+  ( Manager,     defaultManagerSettings,     httpLbs,     newManager,     parseRequest,     responseBody,     responseStatus,- )+  ) import Network.HTTP.Types (statusCode) import Test.Hspec -import Kiroku.Metrics (-    DependencyStatus (..),+import Kiroku.Metrics+  ( DependencyStatus (..),     MetricsServer (..),     defaultConfig,     metricsEventHandler,@@ -50,10 +49,10 @@     newKirokuMetricsWith,     startMetricsServer,     stopMetricsServer,- )+  ) import Kiroku.Metrics.Config (MetricsServerConfig (..))-import Kiroku.Store (-    EventData (..),+import Kiroku.Store+  ( EventData (..),     EventType (..),     ExpectedVersion (..),     KirokuStore (..),@@ -68,7 +67,7 @@     subscribe,     wait,     withStore,- )+  ) import Kiroku.Store.Subscription.EventPublisher (EventPublisher (..), publisherPosition) import Kiroku.Store.Subscription.Types (SubscriptionName (..)) import Kiroku.Store.Types (GlobalPosition (..))@@ -79,131 +78,131 @@  spec :: Spec spec = describe "Kiroku.Metrics.Server (endpoints)" $ do-    it "serves JSON, Prometheus, and health endpoints over HTTP" $-        withMigratedTestDatabase $ \connStr -> do-            storeVar <- newTVarIO Nothing-            km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)-            let settings =-                    defaultConnectionSettings connStr-                        & #eventHandler .~ Just (metricsEventHandler km Nothing)-                        & #observationHandler .~ Just (metricsObservationHandler km Nothing)-            withStore settings $ \store -> do-                atomically (writeTVar storeVar (Just store))-                delivered <- newTVarIO (0 :: Int)-                let cfg =-                        defaultSubscriptionConfig subName AllStreams $ \_event -> do-                            atomically (modifyCount delivered)-                            pure Continue-                handle <- subscribe store cfg-                appendStoreEvents store (StreamName "metrics-server-stream") 3-                waitForCount delivered 3 10_000_000+  it "serves JSON, Prometheus, and health endpoints over HTTP" $+    withMigratedTestDatabase $ \connStr -> do+      storeVar <- newTVarIO Nothing+      km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)+      let settings =+            defaultConnectionSettings connStr+              & #eventHandler .~ Just (metricsEventHandler km Nothing)+              & #observationHandler .~ Just (metricsObservationHandler km Nothing)+      withStore settings $ \store -> do+        atomically (writeTVar storeVar (Just store))+        delivered <- newTVarIO (0 :: Int)+        let cfg =+              defaultSubscriptionConfig subName AllStreams $ \_event -> do+                atomically (modifyCount delivered)+                pure Continue+        handle <- subscribe store cfg+        appendStoreEvents store (StreamName "metrics-server-stream") 3+        waitForCount delivered 3 10_000_000 -                mgr <- newManager defaultManagerSettings+        mgr <- newManager defaultManagerSettings -                -- Healthy server (no dependency checks).-                let serverCfg = defaultConfig{port = 0}-                srv <- startMetricsServer serverCfg km []-                threadDelay 200_000-                let base = "http://127.0.0.1:" <> show srv.serverPort+        -- Healthy server (no dependency checks).+        let serverCfg = defaultConfig {port = 0}+        srv <- startMetricsServer serverCfg km []+        threadDelay 200_000+        let base = "http://127.0.0.1:" <> show srv.serverPort -                (sMetrics, bMetrics) <- get mgr (base <> "/metrics")-                sMetrics `shouldBe` 200-                globalPositionOf bMetrics `shouldSatisfy` (>= 3)+        (sMetrics, bMetrics) <- get mgr (base <> "/metrics")+        sMetrics `shouldBe` 200+        globalPositionOf bMetrics `shouldSatisfy` (>= 3) -                (sOne, _) <- get mgr (base <> "/metrics/metrics-server-it")-                sOne `shouldBe` 200+        (sOne, _) <- get mgr (base <> "/metrics/metrics-server-it")+        sOne `shouldBe` 200 -                (sUnknown, _) <- get mgr (base <> "/metrics/does-not-exist")-                sUnknown `shouldBe` 404+        (sUnknown, _) <- get mgr (base <> "/metrics/does-not-exist")+        sUnknown `shouldBe` 404 -                (sProm, bProm) <- get mgr (base <> "/metrics/prometheus")-                sProm `shouldBe` 200-                bodyText bProm `shouldSatisfy` T.isInfixOf "kiroku_events_appended_total"+        (sProm, bProm) <- get mgr (base <> "/metrics/prometheus")+        sProm `shouldBe` 200+        bodyText bProm `shouldSatisfy` T.isInfixOf "kiroku_events_appended_total" -                (sLive, _) <- get mgr (base <> "/health/live")-                sLive `shouldBe` 200+        (sLive, _) <- get mgr (base <> "/health/live")+        sLive `shouldBe` 200 -                (sReady, _) <- get mgr (base <> "/health/ready")-                sReady `shouldBe` 200+        (sReady, _) <- get mgr (base <> "/health/ready")+        sReady `shouldBe` 200 -                (sWs, _) <- get mgr (base <> "/ws")-                sWs `shouldBe` 404+        (sWs, _) <- get mgr (base <> "/ws")+        sWs `shouldBe` 404 -                (sNope, _) <- get mgr (base <> "/nope")-                sNope `shouldBe` 404+        (sNope, _) <- get mgr (base <> "/nope")+        sNope `shouldBe` 404 -                stopMetricsServer srv+        stopMetricsServer srv -                -- A failing dependency check flips readiness to 503.-                let failing = pure (DependencyStatus "fake" False Nothing (Just "down"))-                srv2 <- startMetricsServer serverCfg km [failing]-                threadDelay 200_000-                let base2 = "http://127.0.0.1:" <> show srv2.serverPort-                (sReady2, _) <- get mgr (base2 <> "/health/ready")-                sReady2 `shouldBe` 503-                stopMetricsServer srv2+        -- A failing dependency check flips readiness to 503.+        let failing = pure (DependencyStatus "fake" False Nothing (Just "down"))+        srv2 <- startMetricsServer serverCfg km [failing]+        threadDelay 200_000+        let base2 = "http://127.0.0.1:" <> show srv2.serverPort+        (sReady2, _) <- get mgr (base2 <> "/health/ready")+        sReady2 `shouldBe` 503+        stopMetricsServer srv2 -                cancel handle-                _ <- wait handle-                pure ()+        cancel handle+        _ <- wait handle+        pure ()  -- | GET a URL, returning the status code and the body, without throwing on non-2xx. get :: Manager -> String -> IO (Int, ByteString) get mgr url = do-    req <- parseRequest url-    resp <- httpLbs req mgr-    pure (statusCode (responseStatus resp), responseBody resp)+  req <- parseRequest url+  resp <- httpLbs req mgr+  pure (statusCode (responseStatus resp), responseBody resp)  -- | Extract @store.global_position@ from a @/metrics@ JSON body. globalPositionOf :: ByteString -> Int globalPositionOf body =-    case decode body of-        Just (Object o)-            | Just (Object store) <- KM.lookup (Key.fromText "store") o-            , Just (Number n) <- KM.lookup (Key.fromText "global_position") store ->-                truncate (toRealFloat n :: Double)-        _ -> -1+  case decode body of+    Just (Object o)+      | Just (Object store) <- KM.lookup (Key.fromText "store") o,+        Just (Number n) <- KM.lookup (Key.fromText "global_position") store ->+          truncate (toRealFloat n :: Double)+    _ -> -1  bodyText :: ByteString -> T.Text bodyText = TE.decodeUtf8 . LBS.toStrict  readPosition :: TVar (Maybe KirokuStore) -> STM GlobalPosition readPosition storeVar =-    readTVar storeVar >>= maybe (pure (GlobalPosition 0)) (publisherPosition . (.publisher))+  readTVar storeVar >>= maybe (pure (GlobalPosition 0)) (publisherPosition . (.publisher))  readSubscribers :: TVar (Maybe KirokuStore) -> STM Int readSubscribers storeVar =-    readTVar storeVar-        >>= maybe (pure 0) (\s -> IntMap.size <$> readTVar (subscribers s.publisher))+  readTVar storeVar+    >>= maybe (pure 0) (\s -> IntMap.size <$> readTVar (subscribers s.publisher))  modifyCount :: TVar Int -> STM () modifyCount v = readTVar v >>= \c -> writeTVar v (c + 1)  appendStoreEvents :: KirokuStore -> StreamName -> Int -> IO () appendStoreEvents store stream n = do-    let events =-            [ EventData-                { eventId = Nothing-                , eventType = EventType ("E" <> T.pack (show i))-                , payload = Null-                , metadata = Nothing-                , causationId = Nothing-                , correlationId = Nothing-                }-            | i <- [1 .. n]-            ]-    result <- runStoreIO store (appendToStream stream NoStream events)-    case result of-        Right _ -> pure ()-        Left err -> expectationFailure ("appendStoreEvents failed: " <> show err)+  let events =+        [ EventData+            { eventId = Nothing,+              eventType = EventType ("E" <> T.pack (show i)),+              payload = Null,+              metadata = Nothing,+              causationId = Nothing,+              correlationId = Nothing+            }+        | i <- [1 .. n]+        ]+  result <- runStoreIO store (appendToStream stream NoStream events)+  case result of+    Right _ -> pure ()+    Left err -> expectationFailure ("appendStoreEvents failed: " <> show err)  waitForCount :: TVar Int -> Int -> Int -> IO () waitForCount countVar target timeoutMicros = do-    timeoutVar <- registerDelay timeoutMicros-    ok <--        atomically $-            (do c <- readTVar countVar; check (c >= target); pure True)-                `orElse` (do t <- readTVar timeoutVar; check t; pure False)-    unless ok $ do-        actual <- atomically (readTVar countVar)-        expectationFailure ("Timed out waiting for " <> show target <> ", got " <> show actual)+  timeoutVar <- registerDelay timeoutMicros+  ok <-+    atomically $+      (do c <- readTVar countVar; check (c >= target); pure True)+        `orElse` (do t <- readTVar timeoutVar; check t; pure False)+  unless ok $ do+    actual <- atomically (readTVar countVar)+    expectationFailure ("Timed out waiting for " <> show target <> ", got " <> show actual)
test/Test/StandaloneSpec.hs view
@@ -4,7 +4,7 @@  import Control.Concurrent.Async qualified as Async import Control.Concurrent.MVar-import Control.Exception (SomeException, bracket, throwIO, try)+import Control.Exception (IOException, SomeException, bracket, catch, finally, throwIO, toException, try) import Control.Monad (forM_, void) import Data.Aeson qualified as A import Data.Aeson.Key qualified as K@@ -22,169 +22,234 @@ import Network.WebSockets qualified as WS import Options.Applicative qualified as O import System.Directory (findExecutable)-import System.Environment (getEnvironment)+import System.Environment (getEnvironment, getExecutablePath) import System.Exit (ExitCode (..))-import System.IO (hGetLine)-import System.Posix.Signals (sigINT, sigTERM, signalProcess)+import System.IO (Handle, hGetLine)+import System.Posix.Signals (sigINT, sigKILL, sigTERM, signalProcess) import System.Process qualified as Process import System.Timeout (timeout) import Test.Hspec +import Hasql.Errors qualified as Errors+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+ import Kiroku.Metrics import Kiroku.Store qualified as Store+import Kiroku.Store.Error (StoreError (..))+import Kiroku.Store.Notification (NotifierStartError (..)) import Kiroku.Test.Postgres (withMigratedTestDatabase)  spec :: Spec spec = do-    describe "Kiroku.Metrics.Standalone (options)" $ do-        it "parses defaults, repeated origins, and all options" $ do-            isRight (parsed []) `shouldBe` True-            opts <- requireParsed ["--database-url", "postgresql://x", "--schema", "tenant", "--pool-size", "2", "--port", "0", "--cors-origin", "http://a", "--cors-origin", "http://b", "--no-cors-allow-credentials", "--ws-max-connections", "3"]-            opts.databaseUrl `shouldBe` Just "postgresql://x"-            opts.schema `shouldBe` Just "tenant"-            opts.poolSize `shouldBe` Just 2-            opts.port `shouldBe` Just 0-            opts.corsOrigins `shouldBe` ["http://a", "http://b"]-            opts.corsAllowCredentials `shouldBe` Just False-            opts.wsMaxConnections `shouldBe` Just 3-        it "rejects signed, non-ASCII, overflowing and out-of-range CLI numbers and conflicting flags" $ do-            forM_ ["-1", "+1", "", "1", "18446744073709551616", "65536"] $ \value -> isLeft (parsed ["--port", value]) `shouldBe` True-            forM_ ["--pool-size", "--ws-max-connections"] $ \flag -> isLeft (parsed [flag, "0"]) `shouldBe` True-            isLeft (parsed ["--cors-allow-credentials", "--no-cors-allow-credentials"]) `shouldBe` True-        it "resolves environment defaults and reports a missing or empty database/schema" $ do-            opts <- requireParsed []-            isLeft (resolveInspectOptions [] opts) `shouldBe` True-            rt <- resolved [("DATABASE_URL", "postgresql://env")] opts-            rt.databaseUrl `shouldBe` "postgresql://env"-            rt.schema `shouldBe` "kiroku"-            rt.poolSize `shouldBe` (Store.defaultConnectionSettings "").poolSize-            rt.port `shouldBe` 9091-            rt.cors `shouldBe` corsDisabled-            rt.wsMaxConnections `shouldBe` 100-            forM_ [["--database-url", ""], ["--database-url", "x", "--schema", " "]] $ \args -> requireParsed args >>= \o -> isLeft (resolveInspectOptions [] o) `shouldBe` True-        it "valid flags override malformed environment values including explicit credentials False" $ do-            opts <- requireParsed ["--database-url", "flag", "--port", "0", "--pool-size", "1", "--schema", "tenant", "--ws-max-connections", "1", "--cors-origin", "http://a", "--no-cors-allow-credentials"]-            rt <- resolved [("DATABASE_URL", "env"), ("KIROKU_INSPECT_PORT", "bad"), ("KIROKU_INSPECT_POOL_SIZE", "bad"), ("KIROKU_INSPECT_SCHEMA", "bad"), ("KIROKU_INSPECT_WS_MAX_CONNECTIONS", "bad"), ("KIROKU_INSPECT_CORS_ORIGINS", "*"), ("KIROKU_INSPECT_CORS_ALLOW_CREDENTIALS", "bad")] opts-            rt.databaseUrl `shouldBe` "flag"-            rt.port `shouldBe` 0-            rt.schema `shouldBe` "tenant"-            rt.cors.allowCredentials `shouldBe` False-            rt2 <- resolved [("KIROKU_INSPECT_CORS_ALLOW_CREDENTIALS", "true")] opts-            rt2.cors.allowCredentials `shouldBe` False-        it "validates environment numbers and booleans, normalizes origins and refuses wildcard" $ do-            opts <- requireParsed ["--database-url", "x"]-            forM_ ["KIROKU_INSPECT_POOL_SIZE", "KIROKU_INSPECT_WS_MAX_CONNECTIONS", "KIROKU_INSPECT_PORT"] $ \name ->-                forM_ ["abc", "-1", "+1", "9", "18446744073709551616"] $ \value -> isLeft (resolveInspectOptions [(name, value)] opts) `shouldBe` True-            isLeft (resolveInspectOptions [("KIROKU_INSPECT_CORS_ALLOW_CREDENTIALS", "yes")] opts) `shouldBe` True-            rt <- resolved [("KIROKU_INSPECT_CORS_ORIGINS", "https://Ops.example.com/, http://localhost:5173"), ("KIROKU_INSPECT_CORS_ALLOW_CREDENTIALS", "true")] opts-            map renderAllowedOrigin rt.cors.allowedOrigins `shouldBe` ["https://ops.example.com", "http://localhost:5173"]-            rt.cors.allowCredentials `shouldBe` True-            wild <- requireParsed ["--database-url", "x", "--cors-origin", "*"]-            either (T.isInfixOf "wildcard") (const False) (resolveInspectOptions [] wild) `shouldBe` True-            empty <- resolved [("KIROKU_INSPECT_PORT", ""), ("KIROKU_INSPECT_SCHEMA", "")] opts-            empty.port `shouldBe` 9091-            empty.schema `shouldBe` "kiroku"-    describe "Kiroku.Metrics.Standalone (end to end)" $ do-        it "serves durable reads, an empty live registry, CORS, and real event tail from a database URL" $-            withMigratedTestDatabase $ \url -> do-                rt <- runtime url-                ready <- newEmptyMVar-                done <- newEmptyMVar-                let hooks = InspectHooks (\port caps -> putMVar ready (port, caps)) (takeMVar done)-                Async.withAsync (runInspect hooks rt) $ \server -> do-                    (port, caps) <- bounded (takeMVar ready)-                    caps.routes `shouldBe` RouteAvailability True True True True True True True True True-                    caps.corsIsEnabled `shouldBe` True-                    Store.withStore (Store.defaultConnectionSettings url) $ \writer -> do-                        append writer "orders-1" 3-                        response <- get port "/capabilities" []-                        A.eitherDecode (HTTP.responseBody response) `shouldBe` Right caps-                        LBSC.putStrLn ("standalone discovery capture: " <> HTTP.responseBody response)-                        streams <- get port "/streams?category=orders" []-                        HTTP.responseStatus streams `shouldBe` status200-                        (A.decode (HTTP.responseBody streams) >>= key "items") `shouldSatisfy` maybe False (\case A.Array rows -> any (\row -> key "name" row == Just (A.String "orders-1")) rows; _ -> False)-                        live <- get port "/subscriptions" []-                        A.decode (HTTP.responseBody live) `shouldBe` Just (A.toJSON ([] :: [A.Value]))-                        inventory <- get port "/subscription-checkpoints" []-                        case A.eitherDecode (HTTP.responseBody inventory) of-                            Right (CheckpointInventoryResponse pos rows) -> do-                                pos `shouldSatisfy` (>= 3)-                                rows `shouldBe` []-                            Left err -> expectationFailure err-                        forM_ ["/health/ready", "/subscriptions/missing/dead-letters", "/events", "/categories"] $ \path -> HTTP.responseStatus <$> get port path [] `shouldReturn` status200-                        forM_ [("http://localhost:5173", Just "http://localhost:5173"), ("http://evil.example", Nothing)] $ \(origin, expected) -> do-                            response2 <- get port "/metrics" [("Origin", origin)]-                            lookup "Access-Control-Allow-Origin" (HTTP.responseHeaders response2) `shouldBe` expected-                            (A.decode (HTTP.responseBody response2) >>= key "subscriptions") `shouldBe` Just (A.object [])-                        bounded $ WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do-                            WS.sendTextData conn (A.encode (A.object ["type" A..= ("subscribe_events" :: Text)]))-                            void (frame conn "event_stream_started")-                            append writer "orders-2" 1-                            ev <- frame conn "event"-                            (key "event" ev >>= key "original_stream_name") `shouldBe` Just (A.String "orders-2")-                            (key "event" ev >>= key "eventType") `shouldBe` Just (A.String "OrderCreated")-                    putMVar done ()-                    bounded (Async.wait server)-                    assertReleased port-        it "releases its listener on immediate shutdown, hook exception and cancellation" $-            withMigratedTestDatabase $ \url -> do-                rt <- runtime url-                forM_ [False, True] $ \shouldFail -> do-                    portRef <- newIORef 0-                    let hooks = InspectHooks (\port _ -> writeIORef portRef port >> if shouldFail then throwIO (userError "hook failed") else pure ()) (pure ())-                    result <- bounded (try (runInspect hooks rt) :: IO (Either SomeException ()))-                    result `shouldSatisfy` (if shouldFail then isLeft else isRight)-                    readIORef portRef >>= assertReleased-                ready <- newEmptyMVar-                never <- newEmptyMVar-                Async.withAsync (runInspect (InspectHooks (\port _ -> putMVar ready port) (takeMVar never)) rt) $ \server -> do-                    port <- bounded (takeMVar ready)-                    bounded (Async.cancel server)-                    assertReleased port-        it "does not call onListening on an occupied port" $-            withMigratedTestDatabase $ \url -> do-                rt <- runtime url-                withOccupiedPort $ \port -> do-                    called <- newIORef False-                    result <- bounded (try (runInspect (InspectHooks (\_ _ -> writeIORef called True) (pure ())) (InspectRuntime rt.databaseUrl rt.schema rt.poolSize port rt.cors rt.wsMaxConnections)) :: IO (Either SomeException ()))-                    result `shouldSatisfy` isLeft-                    readIORef called `shouldReturn` False-    describe "kiroku-inspect (executable)" $ do-        it "exits 2 for usage/resolution errors and redacts runtime connection failures" $ do-            exe <- executable-            env <- cleanEnvironment-            forM_ [[], ["--unknown"], ["--database-url", "x", "--cors-origin", "*"]] $ \args -> do-                (exit, _, _) <- bounded (Process.readCreateProcessWithExitCode (Process.proc exe args){Process.env = Just env} "")-                exit `shouldBe` ExitFailure 2-            (exit, out, err) <- bounded (Process.readCreateProcessWithExitCode (Process.proc exe ["--database-url", "postgresql://user:secret@127.0.0.1:1/db?connect_timeout=1"]){Process.env = Just env} "")-            exit `shouldBe` ExitFailure 1-            out `shouldBe` ""-            T.pack err `shouldSatisfy` (not . T.isInfixOf "secret")-        it "prints no success banner and exits 1 on bind failure" $-            withMigratedTestDatabase $ \url -> withOccupiedPort $ \port -> do-                exe <- executable-                env <- cleanEnvironment-                (exit, out, err) <- bounded (Process.readCreateProcessWithExitCode (Process.proc exe ["--database-url", T.unpack url, "--port", show port]){Process.env = Just env} "")-                exit `shouldBe` ExitFailure 1-                out `shouldBe` ""-                err `shouldSatisfy` (not . null)-        it "exits 0 after SIGINT or SIGTERM, including repeated signals" $-            withMigratedTestDatabase $ \url -> do-                exe <- executable-                env <- cleanEnvironment-                forM_ [sigINT, sigTERM] $ \signal ->-                    bracket (Process.createProcess (Process.proc exe ["--database-url", T.unpack url, "--port", "0"]){Process.env = Just env, Process.std_out = Process.CreatePipe, Process.std_err = Process.CreatePipe}) Process.cleanupProcess $ \(_, out, _, process) -> do-                        handle <- maybe (fail "No stdout") pure out-                        first <- bounded (hGetLine handle)-                        first `shouldSatisfy` (T.isInfixOf "listening on port" . T.pack)-                        void (bounded (hGetLine handle))-                        void (bounded (hGetLine handle))-                        pid <- Process.getPid process >>= maybe (fail "No PID") pure-                        signalProcess signal pid-                        signalProcess signal pid-                        shutting <- bounded (hGetLine handle)-                        shutting `shouldBe` "kiroku-inspect: shutting down"-                        bounded (Process.waitForProcess process) `shouldReturn` ExitSuccess+  describe "Kiroku.Metrics.Standalone (options)" $ do+    it "parses defaults, repeated origins, and all options" $ do+      isRight (parsed []) `shouldBe` True+      opts <- requireParsed ["--database-url", "postgresql://x", "--schema", "tenant", "--pool-size", "2", "--port", "0", "--cors-origin", "http://a", "--cors-origin", "http://b", "--no-cors-allow-credentials", "--ws-max-connections", "3"]+      opts.databaseUrl `shouldBe` Just "postgresql://x"+      opts.schema `shouldBe` Just "tenant"+      opts.poolSize `shouldBe` Just 2+      opts.port `shouldBe` Just 0+      opts.corsOrigins `shouldBe` ["http://a", "http://b"]+      opts.corsAllowCredentials `shouldBe` Just False+      opts.wsMaxConnections `shouldBe` Just 3+    it "rejects signed, non-ASCII, overflowing and out-of-range CLI numbers and conflicting flags" $ do+      forM_ ["-1", "+1", "", "1", "18446744073709551616", "65536"] $ \value -> isLeft (parsed ["--port", value]) `shouldBe` True+      forM_ ["--pool-size", "--ws-max-connections"] $ \flag -> isLeft (parsed [flag, "0"]) `shouldBe` True+      isLeft (parsed ["--cors-allow-credentials", "--no-cors-allow-credentials"]) `shouldBe` True+    it "resolves environment defaults and reports a missing or empty database/schema" $ do+      opts <- requireParsed []+      isLeft (resolveInspectOptions [] opts) `shouldBe` True+      rt <- resolved [("DATABASE_URL", "postgresql://env")] opts+      rt.databaseUrl `shouldBe` "postgresql://env"+      rt.schema `shouldBe` "kiroku"+      rt.poolSize `shouldBe` (Store.defaultConnectionSettings "").poolSize+      rt.port `shouldBe` 9091+      rt.cors `shouldBe` corsDisabled+      rt.wsMaxConnections `shouldBe` 100+      forM_ [["--database-url", ""], ["--database-url", "x", "--schema", " "]] $ \args -> requireParsed args >>= \o -> isLeft (resolveInspectOptions [] o) `shouldBe` True+    it "valid flags override malformed environment values including explicit credentials False" $ do+      opts <- requireParsed ["--database-url", "flag", "--port", "0", "--pool-size", "1", "--schema", "tenant", "--ws-max-connections", "1", "--cors-origin", "http://a", "--no-cors-allow-credentials"]+      rt <- resolved [("DATABASE_URL", "env"), ("KIROKU_INSPECT_PORT", "bad"), ("KIROKU_INSPECT_POOL_SIZE", "bad"), ("KIROKU_INSPECT_SCHEMA", "bad"), ("KIROKU_INSPECT_WS_MAX_CONNECTIONS", "bad"), ("KIROKU_INSPECT_CORS_ORIGINS", "*"), ("KIROKU_INSPECT_CORS_ALLOW_CREDENTIALS", "bad")] opts+      rt.databaseUrl `shouldBe` "flag"+      rt.port `shouldBe` 0+      rt.schema `shouldBe` "tenant"+      rt.cors.allowCredentials `shouldBe` False+      rt2 <- resolved [("KIROKU_INSPECT_CORS_ALLOW_CREDENTIALS", "true")] opts+      rt2.cors.allowCredentials `shouldBe` False+    it "validates environment numbers and booleans, normalizes origins and refuses wildcard" $ do+      opts <- requireParsed ["--database-url", "x"]+      forM_ ["KIROKU_INSPECT_POOL_SIZE", "KIROKU_INSPECT_WS_MAX_CONNECTIONS", "KIROKU_INSPECT_PORT"] $ \name ->+        forM_ ["abc", "-1", "+1", "9", "18446744073709551616"] $ \value -> isLeft (resolveInspectOptions [(name, value)] opts) `shouldBe` True+      isLeft (resolveInspectOptions [("KIROKU_INSPECT_CORS_ALLOW_CREDENTIALS", "yes")] opts) `shouldBe` True+      rt <- resolved [("KIROKU_INSPECT_CORS_ORIGINS", "https://Ops.example.com/, http://localhost:5173"), ("KIROKU_INSPECT_CORS_ALLOW_CREDENTIALS", "true")] opts+      map renderAllowedOrigin rt.cors.allowedOrigins `shouldBe` ["https://ops.example.com", "http://localhost:5173"]+      rt.cors.allowCredentials `shouldBe` True+      wild <- requireParsed ["--database-url", "x", "--cors-origin", "*"]+      either (T.isInfixOf "wildcard") (const False) (resolveInspectOptions [] wild) `shouldBe` True+      empty <- resolved [("KIROKU_INSPECT_PORT", ""), ("KIROKU_INSPECT_SCHEMA", "")] opts+      empty.port `shouldBe` 9091+      empty.schema `shouldBe` "kiroku"+  describe "Kiroku.Metrics.Standalone (failure classification)" $ do+    it "classifies structured failures without reading diagnostic text" $ do+      let poolError code = Pool.SessionUsageError (Errors.ScriptSessionError "secret SQL" (Errors.ServerError code "secret server message" Nothing Nothing Nothing))+          connection = Errors.NetworkingConnectionError "secret connection string"+          cases =+            [ (toException (InspectBindError (userError "secret address")), BindFailure),+              (toException (NotifierConnectError connection), DatabaseFailure),+              (toException (Pool.ConnectionUsageError connection), DatabaseFailure),+              (toException Pool.AcquisitionTimeoutUsageError, DatabaseFailure),+              (toException (poolError "42P01"), SchemaFailure),+              (toException (poolError "3F000"), SchemaFailure),+              (toException (poolError "42501"), DatabaseFailure),+              (toException (poolError "40001"), DatabaseFailure),+              (toException (NotifierListenError (toException (poolError "42P01"))), SchemaFailure),+              (toException (NotifierListenError (toException (UnexpectedServerError "3F000" "secret"))), SchemaFailure),+              (toException (NotifierListenError (toException (userError "secret"))), DatabaseFailure),+              (toException (ConnectionError "secret"), DatabaseFailure),+              (toException (ConnectionLost "secret"), DatabaseFailure),+              (toException PoolAcquisitionTimeout, DatabaseFailure),+              (toException (TransientTransactionFailure "40P01" "secret"), DatabaseFailure),+              (toException (UnexpectedServerError "42P01" "secret"), SchemaFailure),+              (toException (UnexpectedServerError "42501" "secret"), DatabaseFailure),+              (toException (StreamNotFound (Store.StreamName "secret")), OtherFailure),+              (toException (userError "bind database schema secret"), OtherFailure)+            ]+      forM_ cases $ \(err, expected) -> classifyInspectFailure err `shouldBe` expected+      forM_ [(BindFailure, "bind"), (DatabaseFailure, "database"), (SchemaFailure, "schema"), (OtherFailure, "other")] $ \(failure, label) ->+        renderInspectFailure failure `shouldBe` "kiroku-inspect: " <> label <> " failure (details redacted)"+  describe "Kiroku.Metrics.Standalone (end to end)" $ do+    it "serves durable reads, an empty live registry, CORS, and real event tail from a database URL" $+      withMigratedTestDatabase $ \url -> do+        rt <- runtime url+        ready <- newEmptyMVar+        done <- newEmptyMVar+        let hooks = InspectHooks (\port caps -> putMVar ready (port, caps)) (takeMVar done)+        Async.withAsync (runInspect hooks rt) $ \server -> do+          (port, caps) <- bounded (takeMVar ready)+          caps.routes `shouldBe` RouteAvailability True True True True True True True True True+          caps.corsIsEnabled `shouldBe` True+          Store.withStore (Store.defaultConnectionSettings url) $ \writer -> do+            append writer "orders-1" 3+            response <- get port "/capabilities" []+            A.eitherDecode (HTTP.responseBody response) `shouldBe` Right caps+            LBSC.putStrLn ("standalone discovery capture: " <> HTTP.responseBody response)+            streams <- get port "/streams?category=orders" []+            HTTP.responseStatus streams `shouldBe` status200+            (A.decode (HTTP.responseBody streams) >>= key "items") `shouldSatisfy` maybe False (\case A.Array rows -> any (\row -> key "name" row == Just (A.String "orders-1")) rows; _ -> False)+            live <- get port "/subscriptions" []+            A.decode (HTTP.responseBody live) `shouldBe` Just (A.toJSON ([] :: [A.Value]))+            inventory <- get port "/subscription-checkpoints" []+            case A.eitherDecode (HTTP.responseBody inventory) of+              Right (CheckpointInventoryResponse pos rows) -> do+                pos `shouldSatisfy` (>= 3)+                rows `shouldBe` []+              Left err -> expectationFailure err+            forM_ ["/health/ready", "/subscriptions/missing/dead-letters", "/events", "/categories"] $ \path -> HTTP.responseStatus <$> get port path [] `shouldReturn` status200+            forM_ [("http://localhost:5173", Just "http://localhost:5173"), ("http://evil.example", Nothing)] $ \(origin, expected) -> do+              response2 <- get port "/metrics" [("Origin", origin)]+              lookup "Access-Control-Allow-Origin" (HTTP.responseHeaders response2) `shouldBe` expected+              (A.decode (HTTP.responseBody response2) >>= key "subscriptions") `shouldBe` Just (A.object [])+            bounded $ WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do+              WS.sendTextData conn (A.encode (A.object ["type" A..= ("subscribe_events" :: Text)]))+              void (frame conn "event_stream_started")+              append writer "orders-2" 1+              ev <- frame conn "event"+              (key "event" ev >>= key "original_stream_name") `shouldBe` Just (A.String "orders-2")+              (key "event" ev >>= key "eventType") `shouldBe` Just (A.String "OrderCreated")+          putMVar done ()+          bounded (Async.wait server)+          assertReleased port+    it "releases its listener on immediate shutdown, hook exception and cancellation" $+      withMigratedTestDatabase $ \url -> do+        rt <- runtime url+        forM_ [False, True] $ \shouldFail -> do+          portRef <- newIORef 0+          let hooks = InspectHooks (\port _ -> writeIORef portRef port >> if shouldFail then throwIO (userError "hook failed") else pure ()) (pure ())+          result <- bounded (try (runInspect hooks rt) :: IO (Either SomeException ()))+          result `shouldSatisfy` (if shouldFail then isLeft else isRight)+          case result of+            Left err -> classifyInspectFailure err `shouldBe` OtherFailure+            Right () -> pure ()+          readIORef portRef >>= assertReleased+        ready <- newEmptyMVar+        never <- newEmptyMVar+        Async.withAsync (runInspect (InspectHooks (\port _ -> putMVar ready port) (takeMVar never)) rt) $ \server -> do+          port <- bounded (takeMVar ready)+          bounded (Async.cancel server)+          assertReleased port+    it "does not call onListening on an occupied port" $+      withMigratedTestDatabase $ \url -> do+        rt <- runtime url+        withOccupiedPort $ \port -> do+          called <- newIORef False+          result <- bounded (try (runInspect (InspectHooks (\_ _ -> writeIORef called True) (pure ())) (InspectRuntime rt.databaseUrl rt.schema rt.poolSize port rt.cors rt.wsMaxConnections)) :: IO (Either SomeException ()))+          fmap classifyInspectFailure (either Just (const Nothing) result) `shouldBe` Just BindFailure+          readIORef called `shouldReturn` False+  describe "kiroku-inspect (executable)" $ do+    it "exits 2 for usage/resolution errors and redacts runtime connection failures" $ do+      exe <- executable+      env <- cleanEnvironment+      forM_ [[], ["--unknown"], ["--database-url", "x", "--cors-origin", "*"]] $ \args -> do+        (exit, _, _) <- bounded (Process.readCreateProcessWithExitCode (Process.proc exe args) {Process.env = Just env} "")+        exit `shouldBe` ExitFailure 2+      (exit, out, err) <- bounded (Process.readCreateProcessWithExitCode (Process.proc exe ["--database-url", "postgresql://user:secret@127.0.0.1:1/db?connect_timeout=1"]) {Process.env = Just env} "")+      exit `shouldBe` ExitFailure 1+      out `shouldBe` ""+      err `shouldBe` "kiroku-inspect: database failure (details redacted)\n"+      T.pack err `shouldSatisfy` (not . T.isInfixOf "secret")+    it "prints no success banner and exits 1 on bind failure" $+      withMigratedTestDatabase $ \url -> withOccupiedPort $ \port -> do+        exe <- executable+        env <- cleanEnvironment+        (exit, out, err) <- bounded (Process.readCreateProcessWithExitCode (Process.proc exe ["--database-url", T.unpack url, "--port", show port]) {Process.env = Just env} "")+        exit `shouldBe` ExitFailure 1+        out `shouldBe` ""+        err `shouldBe` "kiroku-inspect: bind failure (details redacted)\n"+        T.pack err `shouldSatisfy` (not . T.isInfixOf url)+    it "reports a missing schema without a banner or database details" $+      withMigratedTestDatabase $ \url -> do+        Store.withStore (Store.defaultConnectionSettings url) $ \store ->+          Pool.use store.pool (Session.script "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'plan101_missing_schema') THEN RAISE EXCEPTION 'fixture schema exists'; END IF; END $$") `shouldReturn` Right ()+        exe <- executable+        env <- cleanEnvironment+        (exit, out, err) <- bounded (Process.readCreateProcessWithExitCode (Process.proc exe ["--database-url", T.unpack url, "--schema", "plan101_missing_schema", "--port", "0"]) {Process.env = Just env} "")+        exit `shouldBe` ExitFailure 1+        out `shouldBe` ""+        err `shouldBe` "kiroku-inspect: schema failure (details redacted)\n"+        forM_ [url, "plan101_missing_schema", "streams", "SELECT"] $ \secret ->+          T.pack err `shouldSatisfy` (not . T.isInfixOf secret)+    forM_ [("SIGINT", sigINT), ("SIGTERM", sigTERM)] $ \(firstName, firstSignal) ->+      forM_ [("SIGINT", sigINT), ("SIGTERM", sigTERM)] $ \(secondName, secondSignal) ->+        it ("forces exit 130 on " <> firstName <> "/" <> secondName <> " during blocked teardown") $ do+          exe <- getExecutablePath+          bracket+            (Process.createProcess (Process.proc exe ["--inspect-signal-fixture"]) {Process.std_out = Process.CreatePipe, Process.std_err = Process.CreatePipe})+            cleanupFixture+            $ \(_, out, _, process) -> do+              handle <- maybe (fail "No stdout") pure out+              bounded (hGetLine handle) `shouldReturn` "READY"+              pid <- Process.getPid process >>= maybe (fail "No PID") pure+              signalProcess firstSignal pid+              bounded (hGetLine handle) `shouldReturn` "SHUTTING_DOWN"+              signalProcess secondSignal pid+              timeout 1_000_000 (Process.waitForProcess process) `shouldReturn` Just (ExitFailure 130)+    it "exits 0 after a single SIGINT or SIGTERM" $+      withMigratedTestDatabase $ \url -> do+        exe <- executable+        env <- cleanEnvironment+        forM_ [sigINT, sigTERM] $ \signal ->+          bracket (Process.createProcess (Process.proc exe ["--database-url", T.unpack url, "--port", "0"]) {Process.env = Just env, Process.std_out = Process.CreatePipe, Process.std_err = Process.CreatePipe}) Process.cleanupProcess $ \(_, out, _, process) -> do+            handle <- maybe (fail "No stdout") pure out+            first <- bounded (hGetLine handle)+            first `shouldSatisfy` (T.isInfixOf "listening on port" . T.pack)+            void (bounded (hGetLine handle))+            void (bounded (hGetLine handle))+            pid <- Process.getPid process >>= maybe (fail "No PID") pure+            signalProcess signal pid+            shutting <- bounded (hGetLine handle)+            shutting `shouldBe` "kiroku-inspect: shutting down"+            bounded (Process.waitForProcess process) `shouldReturn` ExitSuccess  parsed :: [String] -> Either String InspectOptions parsed args = maybe (Left "Parse failed") Right (O.getParseResult (O.execParserPure O.defaultPrefs inspectParserInfo args))@@ -204,47 +269,59 @@  frame :: WS.Connection -> Text -> IO A.Value frame conn wanted = do-    raw <- WS.receiveData conn :: IO LBS.ByteString-    case A.decode raw of-        Just value | key "type" value == Just (A.String wanted) -> pure value-        _ -> frame conn wanted+  raw <- WS.receiveData conn :: IO LBS.ByteString+  case A.decode raw of+    Just value | key "type" value == Just (A.String wanted) -> pure value+    _ -> frame conn wanted  get :: Int -> String -> RequestHeaders -> IO (HTTP.Response LBS.ByteString) get port path headers = do-    manager <- HTTP.newManager HTTP.defaultManagerSettings-    req <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> path)-    HTTP.httpLbs req{HTTP.requestHeaders = headers} manager+  manager <- HTTP.newManager HTTP.defaultManagerSettings+  req <- HTTP.parseRequest ("http://127.0.0.1:" <> show port <> path)+  HTTP.httpLbs req {HTTP.requestHeaders = headers} manager  append :: Store.KirokuStore -> Text -> Int -> IO () append store name count = do-    result <- Store.runStoreIO store (Store.appendToStream (Store.StreamName name) Store.NoStream (replicate count (Store.EventData Nothing (Store.EventType "OrderCreated") A.Null Nothing Nothing Nothing)))-    result `shouldSatisfy` isRight+  result <- Store.runStoreIO store (Store.appendToStream (Store.StreamName name) Store.NoStream (replicate count (Store.EventData Nothing (Store.EventType "OrderCreated") A.Null Nothing Nothing Nothing)))+  result `shouldSatisfy` isRight  bounded :: IO a -> IO a bounded action = timeout 15_000_000 action >>= maybe (fail "Timed out") pure  assertReleased :: Int -> IO () assertReleased port =-    bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \socket -> do-        Socket.setSocketOption socket Socket.ReuseAddr 1-        Socket.bind socket (Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (0, 0, 0, 0)))-        Socket.listen socket 1+  bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \socket -> do+    Socket.setSocketOption socket Socket.ReuseAddr 1+    Socket.bind socket (Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (0, 0, 0, 0)))+    Socket.listen socket 1  withOccupiedPort :: (Int -> IO a) -> IO a withOccupiedPort action = do-    (port, reserved) <- Warp.openFreePort-    Socket.close reserved-    bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \ipv4 ->-        bracket (Socket.socket Socket.AF_INET6 Socket.Stream Socket.defaultProtocol) Socket.close $ \ipv6 -> do-            Socket.bind ipv4 (Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (0, 0, 0, 0)))-            Socket.listen ipv4 1-            Socket.setSocketOption ipv6 Socket.IPv6Only 1-            Socket.bind ipv6 (Socket.SockAddrInet6 (fromIntegral port) 0 (0, 0, 0, 0) 0)-            Socket.listen ipv6 1-            action port+  (port, reserved) <- Warp.openFreePort+  Socket.close reserved+  bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \ipv4 ->+    bracket (Socket.socket Socket.AF_INET6 Socket.Stream Socket.defaultProtocol) Socket.close $ \ipv6 -> do+      Socket.bind ipv4 (Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (0, 0, 0, 0)))+      Socket.listen ipv4 1+      Socket.setSocketOption ipv6 Socket.IPv6Only 1+      Socket.bind ipv6 (Socket.SockAddrInet6 (fromIntegral port) 0 (0, 0, 0, 0) 0)+      Socket.listen ipv6 1+      action port  executable :: IO FilePath executable = findExecutable "kiroku-inspect" >>= maybe (fail "Cabal build-tool-depends did not supply kiroku-inspect") pure  cleanEnvironment :: IO [(String, String)] cleanEnvironment = filter (\(name, _) -> name /= "DATABASE_URL" && not ("KIROKU_INSPECT_" `T.isPrefixOf` T.pack name)) <$> getEnvironment++-- A fixture intentionally blocks finalization, so cleanup must kill a still-live+-- owned child rather than request another graceful shutdown and wait forever.+cleanupFixture :: (Maybe Handle, Maybe Handle, Maybe Handle, Process.ProcessHandle) -> IO ()+cleanupFixture child@(_, _, _, process) =+  ( Process.getProcessExitCode process >>= \case+      Just _ -> pure ()+      Nothing -> do+        pid <- Process.getPid process+        forM_ pid $ \p -> signalProcess sigKILL p `catch` \(_ :: IOException) -> pure ()+  )+    `finally` Process.cleanupProcess child
test/Test/SubscriptionsSpec.hs view
@@ -1,15 +1,14 @@-{- | EP-5 tests for the @GET /subscriptions@ endpoint and the CLI remote client.--  * Cross-package shape: rows produced by the server-side mapping-    ('subscriptionStatusRows') encode and decode back through the shared-    @kiroku-cli@ codec — guarding against either side re-encoding locally.-  * End-to-end: boot a real store with a live subscription, serve-    @\/subscriptions@ from 'storeSubscriptionStatus', and assert the real CLI-    client ('fetchRemoteSubscriptionStatusRows') reports the live subscription-    with a sane phase and the expected cursor. A raw GET checks the by-name route-    (known → one row, unknown → @[]@), and a server with no provider yields the-    configured-404.--}+-- | EP-5 tests for the @GET /subscriptions@ endpoint and the CLI remote client.+--+--   * Cross-package shape: rows produced by the server-side mapping+--     ('subscriptionStatusRows') encode and decode back through the shared+--     @kiroku-cli@ codec — guarding against either side re-encoding locally.+--   * End-to-end: boot a real store with a live subscription, serve+--     @\/subscriptions@ from 'storeSubscriptionStatus', and assert the real CLI+--     client ('fetchRemoteSubscriptionStatusRows') reports the live subscription+--     with a sane phase and the expected cursor. A raw GET checks the by-name route+--     (known → one row, unknown → @[]@), and a server with no provider yields the+--     configured-404. module Test.SubscriptionsSpec (spec) where  import Control.Concurrent (threadDelay)@@ -18,26 +17,26 @@ import Data.Map.Strict qualified as Map import Data.Text (Text) import Data.Text qualified as T-import Network.HTTP.Client (-    defaultManagerSettings,+import Network.HTTP.Client+  ( defaultManagerSettings,     httpLbs,     newManager,     parseRequest,     responseBody,     responseStatus,- )+  ) import Network.HTTP.Types.Status (statusCode) import Network.Wai.Handler.Warp qualified as Warp import Test.Hspec  import Kiroku.Cli.Command (RemoteEndpoint (..))-import Kiroku.Cli.Subscription.Status (-    SubscriptionStatusRow (..),+import Kiroku.Cli.Subscription.Status+  ( SubscriptionStatusRow (..),     fetchRemoteSubscriptionStatusRows,     subscriptionStatusRows,- )-import Kiroku.Metrics (-    MetricsServer (..),+  )+import Kiroku.Metrics+  ( MetricsServer (..),     defaultConfig,     newKirokuMetrics,     startMetricsServer,@@ -45,10 +44,10 @@     storeSubscriptionStatus,     subscriptionsApp,     withMetricsServerSubscriptions,- )+  ) import Kiroku.Metrics.Config (MetricsServerConfig (..))-import Kiroku.Store (-    EventData (..),+import Kiroku.Store+  ( EventData (..),     EventType (..),     ExpectedVersion (..),     KirokuStore,@@ -62,7 +61,7 @@     runStoreIO,     subscribe,     withStore,- )+  ) import Kiroku.Store.Subscription (SubscriptionStateView (..), subscriptionStates) import Kiroku.Store.Subscription.Fsm (SubscriptionState (..)) import Kiroku.Store.Subscription.Types (SubscriptionName (..))@@ -71,111 +70,111 @@  spec :: Spec spec = describe "Kiroku.Metrics.Subscriptions (/subscriptions)" $ do-    it "encodes server-side rows that decode back through the shared CLI codec" $ do-        let views =-                Map.fromList-                    [ ((SubscriptionName "beta", 1), mkView "beta" 1 "live" 42)-                    , ((SubscriptionName "alpha", 0), mkView "alpha" 0 "catching_up" 7)-                    ]-            rows = subscriptionStatusRows views-        Aeson.eitherDecode (Aeson.encode rows) `shouldBe` Right rows+  it "encodes server-side rows that decode back through the shared CLI codec" $ do+    let views =+          Map.fromList+            [ ((SubscriptionName "beta", 1), mkView "beta" 1 "live" 42),+              ((SubscriptionName "alpha", 0), mkView "alpha" 0 "catching_up" 7)+            ]+        rows = subscriptionStatusRows views+    Aeson.eitherDecode (Aeson.encode rows) `shouldBe` Right rows -    it "reports a live subscription to the CLI remote client end to end" $-        withMigratedTestDatabase $ \connStr ->-            withStore (defaultConnectionSettings connStr) $ \store -> do-                m <- newKirokuMetrics store-                let name = SubscriptionName "subs-endpoint-it"-                Right _ <--                    runStoreIO store $-                        appendToStream (StreamName "subs-endpoint-1") NoStream [ev "A", ev "B", ev "C"]-                handle <- subscribe store (defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))-                live <- waitUntilPhase 10_000_000 store (name, 0) "live"-                live `shouldBe` True+  it "reports a live subscription to the CLI remote client end to end" $+    withMigratedTestDatabase $ \connStr ->+      withStore (defaultConnectionSettings connStr) $ \store -> do+        m <- newKirokuMetrics store+        let name = SubscriptionName "subs-endpoint-it"+        Right _ <-+          runStoreIO store $+            appendToStream (StreamName "subs-endpoint-1") NoStream [ev "A", ev "B", ev "C"]+        handle <- subscribe store (defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))+        live <- waitUntilPhase 10_000_000 store (name, 0) "live"+        live `shouldBe` True -                withMetricsServerSubscriptions (defaultConfig{port = 0}) m [] (storeSubscriptionStatus store) $ \srv -> do-                    threadDelay 200_000-                    let base = "http://127.0.0.1:" <> show srv.serverPort+        withMetricsServerSubscriptions (defaultConfig {port = 0}) m [] (storeSubscriptionStatus store) $ \srv -> do+          threadDelay 200_000+          let base = "http://127.0.0.1:" <> show srv.serverPort -                    -- The real CLI client hits GET /subscriptions and decodes the rows.-                    rowsResult <- fetchRemoteSubscriptionStatusRows (RemoteEndpoint (T.pack base))-                    case rowsResult of-                        Left err -> expectationFailure ("expected rows, got error: " <> T.unpack err)-                        Right rows ->-                            case filter (\r -> r.subscription == "subs-endpoint-it") rows of-                                [row] -> do-                                    row.phase `shouldBe` "live"-                                    row.globalPosition `shouldBe` 3-                                other -> expectationFailure ("expected exactly one matching row, got " <> show other)+          -- The real CLI client hits GET /subscriptions and decodes the rows.+          rowsResult <- fetchRemoteSubscriptionStatusRows (RemoteEndpoint (T.pack base))+          case rowsResult of+            Left err -> expectationFailure ("expected rows, got error: " <> T.unpack err)+            Right rows ->+              case filter (\r -> r.subscription == "subs-endpoint-it") rows of+                [row] -> do+                  row.phase `shouldBe` "live"+                  row.globalPosition `shouldBe` 3+                other -> expectationFailure ("expected exactly one matching row, got " <> show other) -                    -- By-name route: known name → one row, unknown name → [].-                    known <- getRows (base <> "/subscriptions/subs-endpoint-it")-                    map (.subscription) known `shouldBe` ["subs-endpoint-it"]-                    unknown <- getRows (base <> "/subscriptions/does-not-exist")-                    unknown `shouldBe` []+          -- By-name route: known name → one row, unknown name → [].+          known <- getRows (base <> "/subscriptions/subs-endpoint-it")+          map (.subscription) known `shouldBe` ["subs-endpoint-it"]+          unknown <- getRows (base <> "/subscriptions/does-not-exist")+          unknown `shouldBe` [] -                cancel handle+        cancel handle -    it "returns a configured-404 when no provider is wired" $-        withMigratedTestDatabase $ \connStr ->-            withStore (defaultConnectionSettings connStr) $ \store -> do-                m <- newKirokuMetrics store-                srv <- startMetricsServer (defaultConfig{port = 0}) m []-                threadDelay 200_000-                result <--                    fetchRemoteSubscriptionStatusRows-                        (RemoteEndpoint (T.pack ("http://127.0.0.1:" <> show srv.serverPort)))-                stopMetricsServer srv-                case result of-                    Left err -> err `shouldSatisfy` T.isInfixOf "404"-                    Right rows -> expectationFailure ("expected a 404 error, got rows: " <> show rows)+  it "returns a configured-404 when no provider is wired" $+    withMigratedTestDatabase $ \connStr ->+      withStore (defaultConnectionSettings connStr) $ \store -> do+        m <- newKirokuMetrics store+        srv <- startMetricsServer (defaultConfig {port = 0}) m []+        threadDelay 200_000+        result <-+          fetchRemoteSubscriptionStatusRows+            (RemoteEndpoint (T.pack ("http://127.0.0.1:" <> show srv.serverPort)))+        stopMetricsServer srv+        case result of+          Left err -> err `shouldSatisfy` T.isInfixOf "404"+          Right rows -> expectationFailure ("expected a 404 error, got rows: " <> show rows) -    it "returns the documented 404 JSON body for unknown paths when mounted standalone" $-        Warp.testWithApplication (pure (subscriptionsApp (pure []))) $ \port -> do-            mgr <- newManager defaultManagerSettings-            req <- parseRequest ("http://127.0.0.1:" <> show port <> "/definitely/not/a/route")-            resp <- httpLbs req mgr-            statusCode (responseStatus resp) `shouldBe` 404-            Aeson.decode (responseBody resp)-                `shouldBe` Just (Aeson.object ["error" Aeson..= ("Not found" :: Text)])+  it "returns the documented 404 JSON body for unknown paths when mounted standalone" $+    Warp.testWithApplication (pure (subscriptionsApp (pure []))) $ \port -> do+      mgr <- newManager defaultManagerSettings+      req <- parseRequest ("http://127.0.0.1:" <> show port <> "/definitely/not/a/route")+      resp <- httpLbs req mgr+      statusCode (responseStatus resp) `shouldBe` 404+      Aeson.decode (responseBody resp)+        `shouldBe` Just (Aeson.object ["error" Aeson..= ("Not found" :: Text)])  -- | GET a URL and decode the body as @[SubscriptionStatusRow]@ (via the shared codec). getRows :: String -> IO [SubscriptionStatusRow] getRows url = do-    mgr <- newManager defaultManagerSettings-    req <- parseRequest url-    resp <- httpLbs req mgr-    case Aeson.eitherDecode (responseBody resp) of-        Right rows -> pure rows-        Left err -> expectationFailure ("could not decode " <> url <> ": " <> err) >> pure []+  mgr <- newManager defaultManagerSettings+  req <- parseRequest url+  resp <- httpLbs req mgr+  case Aeson.eitherDecode (responseBody resp) of+    Right rows -> pure rows+    Left err -> expectationFailure ("could not decode " <> url <> ": " <> err) >> pure []  mkView :: Text -> Int32 -> Text -> Int64 -> SubscriptionStateView mkView name member phase position =-    SubscriptionStateView-        { subscriptionName = SubscriptionName name-        , member = member-        , state = Live (GlobalPosition position)-        , statePhase = phase-        , cursor = GlobalPosition position-        }+  SubscriptionStateView+    { subscriptionName = SubscriptionName name,+      member = member,+      state = Live (GlobalPosition position),+      statePhase = phase,+      cursor = GlobalPosition position+    }  ev :: Text -> EventData ev typ =-    EventData-        { eventId = Nothing-        , eventType = EventType typ-        , payload = Aeson.Null-        , metadata = Nothing-        , causationId = Nothing-        , correlationId = Nothing-        }+  EventData+    { eventId = Nothing,+      eventType = EventType typ,+      payload = Aeson.Null,+      metadata = Nothing,+      causationId = Nothing,+      correlationId = Nothing+    }  waitUntilPhase :: Int -> KirokuStore -> (SubscriptionName, Int32) -> Text -> IO Bool waitUntilPhase budget store key phase-    | budget <= 0 = pure False-    | otherwise = do-        snapshot <- subscriptionStates store-        case Map.lookup key snapshot of-            Just status | statePhase status == phase -> pure True-            _ -> do-                threadDelay 20_000-                waitUntilPhase (budget - 20_000) store key phase+  | budget <= 0 = pure False+  | otherwise = do+      snapshot <- subscriptionStates store+      case Map.lookup key snapshot of+        Just status | statePhase status == phase -> pure True+        _ -> do+          threadDelay 20_000+          waitUntilPhase (budget - 20_000) store key phase
test/Test/WebSocketConvergenceSpec.hs view
@@ -29,15 +29,15 @@ import System.Timeout (timeout) import Test.Hspec -import Kiroku.Metrics (-    MetricsServer (..),+import Kiroku.Metrics+  ( MetricsServer (..),     MetricsServerConfig (..),     defaultConfig,     newKirokuMetricsWith,     snapshotMetrics,     startMetricsServerWithStore,     stopMetricsServer,- )+  ) import Kiroku.Metrics.WebSocket import Kiroku.Store hiding (cancel, id) import Kiroku.Store.Subscription.EventPublisher qualified as Pub@@ -45,235 +45,235 @@  spec :: Spec spec = do-    describe "Kiroku.Metrics.WebSocket (frames)" $ do-        it "preserves every existing server frame" $ do-            toJSON Pong `shouldBe` object ["type" .= ("pong" :: Text)]-            toJSON Goodbye `shouldBe` object ["type" .= ("goodbye" :: Text)]-            toJSON (EventStreamStarted 7) `shouldBe` object ["type" .= ("event_stream_started" :: Text), "from_position" .= (7 :: Int)]-            toJSON (ErrorMsg "x") `shouldBe` object ["type" .= ("error" :: Text), "message" .= ("x" :: Text)]-            toJSON (Event (object ["k" .= (1 :: Int)])) `shouldBe` object ["type" .= ("event" :: Text), "event" .= object ["k" .= (1 :: Int)]]-            km <- newKirokuMetricsWith (pure (GlobalPosition 0)) (pure 0)-            snap <- snapshotMetrics km-            look ["type"] (toJSON (Snapshot snap)) `shouldBe` Just (String "snapshot")-            look ["metrics"] (toJSON (Snapshot snap)) `shouldBe` Just (toJSON snap)-        it "pins coded errors and all four code spellings" $ do-            toJSON (CodedError "replay_failed" "boom") `shouldBe` object ["type" .= ("error" :: Text), "code" .= ("replay_failed" :: Text), "message" .= ("boom" :: Text)]-            [errorCodeReplayFailed, errorCodeCategoryReadFailed, errorCodeEventStreamOverflowed, errorCodeLiveDecodeFailed] `shouldBe` ["replay_failed", "category_read_failed", "event_stream_overflowed", "live_decode_failed"]-        it "accepts every old client frame and unsubscribe_metrics" $ do-            let parse raw = eitherDecode raw :: Either String ClientMessage-            map parse ["{\"type\":\"ping\"}", "{\"type\":\"subscribe_metrics\"}", "{\"type\":\"unsubscribe_events\"}", "{\"type\":\"unsubscribe_metrics\"}", "{\"type\":\"subscribe_events\"}", "{\"type\":\"subscribe_events\",\"from_position\":7,\"category\":\"orders\"}"] `shouldBe` map Right [Ping, SubscribeMetrics, UnsubscribeEvents, UnsubscribeMetrics, SubscribeEvents Nothing Nothing, SubscribeEvents (Just 7) (Just "orders")]-        it "adds exactly original_stream_name and preserves all old event fields" $ do-            let event = fixture 9-                original = objectFields (recordedEventToJSON event)-                resolved = objectFields (recordedEventToJSONResolved (Map.singleton (StreamId 9) (StreamName "orders-7")) event)-            KM.delete "original_stream_name" resolved `shouldBe` original-            length (KM.keys resolved) `shouldBe` 12-            KM.lookup "original_stream_name" resolved `shouldBe` Just (String "orders-7")-            look ["original_stream_name"] (recordedEventToJSONResolved Map.empty event) `shouldBe` Just Null-        it "detects only counter changes, including modular wraparound" $ do-            overflowNotice 0 0 `shouldBe` Nothing-            overflowNotice 3 3 `shouldBe` Nothing-            assertNotice 0 2 "2 undelivered"-            assertNotice 2 5 "3 undelivered"-            assertNotice maxBound 1 "2 undelivered"+  describe "Kiroku.Metrics.WebSocket (frames)" $ do+    it "preserves every existing server frame" $ do+      toJSON Pong `shouldBe` object ["type" .= ("pong" :: Text)]+      toJSON Goodbye `shouldBe` object ["type" .= ("goodbye" :: Text)]+      toJSON (EventStreamStarted 7) `shouldBe` object ["type" .= ("event_stream_started" :: Text), "from_position" .= (7 :: Int)]+      toJSON (ErrorMsg "x") `shouldBe` object ["type" .= ("error" :: Text), "message" .= ("x" :: Text)]+      toJSON (Event (object ["k" .= (1 :: Int)])) `shouldBe` object ["type" .= ("event" :: Text), "event" .= object ["k" .= (1 :: Int)]]+      km <- newKirokuMetricsWith (pure (GlobalPosition 0)) (pure 0)+      snap <- snapshotMetrics km+      look ["type"] (toJSON (Snapshot snap)) `shouldBe` Just (String "snapshot")+      look ["metrics"] (toJSON (Snapshot snap)) `shouldBe` Just (toJSON snap)+    it "pins coded errors and all four code spellings" $ do+      toJSON (CodedError "replay_failed" "boom") `shouldBe` object ["type" .= ("error" :: Text), "code" .= ("replay_failed" :: Text), "message" .= ("boom" :: Text)]+      [errorCodeReplayFailed, errorCodeCategoryReadFailed, errorCodeEventStreamOverflowed, errorCodeLiveDecodeFailed] `shouldBe` ["replay_failed", "category_read_failed", "event_stream_overflowed", "live_decode_failed"]+    it "accepts every old client frame and unsubscribe_metrics" $ do+      let parse raw = eitherDecode raw :: Either String ClientMessage+      map parse ["{\"type\":\"ping\"}", "{\"type\":\"subscribe_metrics\"}", "{\"type\":\"unsubscribe_events\"}", "{\"type\":\"unsubscribe_metrics\"}", "{\"type\":\"subscribe_events\"}", "{\"type\":\"subscribe_events\",\"from_position\":7,\"category\":\"orders\"}"] `shouldBe` map Right [Ping, SubscribeMetrics, UnsubscribeEvents, UnsubscribeMetrics, SubscribeEvents Nothing Nothing, SubscribeEvents (Just 7) (Just "orders")]+    it "adds exactly original_stream_name and preserves all old event fields" $ do+      let event = fixture 9+          original = objectFields (recordedEventToJSON event)+          resolved = objectFields (recordedEventToJSONResolved (Map.singleton (StreamId 9) (StreamName "orders-7")) event)+      KM.delete "original_stream_name" resolved `shouldBe` original+      length (KM.keys resolved) `shouldBe` 12+      KM.lookup "original_stream_name" resolved `shouldBe` Just (String "orders-7")+      look ["original_stream_name"] (recordedEventToJSONResolved Map.empty event) `shouldBe` Just Null+    it "detects only counter changes, including modular wraparound" $ do+      overflowNotice 0 0 `shouldBe` Nothing+      overflowNotice 3 3 `shouldBe` Nothing+      assertNotice 0 2 "2 undelivered"+      assertNotice 2 5 "3 undelivered"+      assertNotice maxBound 1 "2 undelivered" -    describe "Kiroku.Metrics.WebSocket (bounded delivery)" $ do-        it "resolves one cold batch, no warm or empty batches, and bounds FIFO retention" $ do-            cache <- newStreamNameCache-            calls <- newIORef ([] :: [[StreamId]])-            let lookupBatch ids = do-                    atomicModifyIORef' calls (\xs -> (xs <> [ids], ()))-                    pure (Map.fromList [(sid, streamName sid) | sid <- ids])-                events = V.fromList (map fixture [1 .. 5000])-            names <- resolveEventNames cache lookupBatch events-            Map.size names `shouldBe` 5000-            Map.lookup (StreamId 1) names `shouldBe` Just (StreamName "stream-1")-            Map.lookup (StreamId 5000) names `shouldBe` Just (StreamName "stream-5000")-            streamNameCacheSize cache `shouldReturn` (4096, 4096)-            _ <- resolveEventNames cache lookupBatch (V.fromList [fixture 5000, fixture 5000])-            _ <- resolveEventNames cache lookupBatch V.empty-            length <$> readIORef calls `shouldReturn` 1-            _ <- resolveEventNames cache lookupBatch (V.singleton (fixture 1))-            length <$> readIORef calls `shouldReturn` 2-            streamNameCacheSize cache `shouldReturn` (4096, 4096)-        it "does not retain misses and propagates cancellation from a name lookup" $ do-            cache <- newStreamNameCache-            resolveEventNames cache (const (pure Map.empty)) (V.singleton (fixture 1)) `shouldReturn` Map.empty-            streamNameCacheSize cache `shouldReturn` (0, 0)-            entered <- newEmptyTMVarIO-            withAsync (resolveEventNames cache (\_ -> atomically (putTMVar entered ()) >> atomically retry) (V.singleton (fixture 1))) $ \worker -> do-                bounded (atomically (takeTMVar entered))-                cancel worker-            streamNameCacheSize cache `shouldReturn` (0, 0)-        it "delivers unchanged and transformed batches without false overflow notices" $ do-            sub <- fakeSubscription-            cache <- newStreamNameCache-            frames <- newTVarIO []-            atomically $ do-                writeTBQueue sub.subscriptionQueue (UnchangedBatch (V.singleton (fixture 1)))-                writeTBQueue sub.subscriptionQueue (TransformedBatch (V.singleton (Decoded (fixture 2))))-            withAsync (broadcastEventsWith (capture frames) cache (pure . Map.fromList . map (\sid -> (sid, streamName sid))) sub (const True)) $ \_ -> do-                bounded (atomically (readTVar frames >>= \xs -> check (length xs == 2)))-            xs <- readTVarIO frames-            map framePosition xs `shouldBe` [Just 1, Just 2]-        it "never sends partial data and terminates on an applicable typed decode failure" $ do-            sub <- fakeSubscription-            cache <- newStreamNameCache-            frames <- newTVarIO []-            let failed = fixture 2-            atomically (writeTBQueue sub.subscriptionQueue (TransformedBatch (V.fromList [Decoded (fixture 1), Undecodable failed (DecodeFailure failed.eventId "secret payload")])))-            bounded (broadcastEventsWith (capture frames) cache (\_ -> fail "must not look up partial batch") sub (const True))-            readTVarIO frames `shouldReturn` [CodedError errorCodeLiveDecodeFailed "live event decoding failed"]-        it "filters typed failures below the covered replay boundary" $ do-            sub <- fakeSubscription-            cache <- newStreamNameCache-            frames <- newTVarIO []-            let old = fixture 1-            atomically (writeTBQueue sub.subscriptionQueue (TransformedBatch (V.fromList [Undecodable old (DecodeFailure old.eventId "old failure"), Decoded (fixture 2)])))-            withAsync (broadcastEventsWith (capture frames) cache (const (pure Map.empty)) sub (\e -> e.globalPosition > GlobalPosition 1)) $ \_ ->-                bounded (atomically (readTVar frames >>= \xs -> check (length xs == 1)))-            map framePosition <$> readTVarIO frames `shouldReturn` [Just 2]-        it "signals real publisher loss before survivors and recovers from the pre-notice cursor" $ withBareStore $ \store ->-            bracket (atomically (Pub.subscribePublisherWith store.publisher 1 DropOldest)) Pub.unsubscribe $ \sub -> do-                cache <- newStreamNameCache-                frames <- newTVarIO []-                entered <- newEmptyTMVarIO-                release <- newEmptyTMVarIO-                let writer msg = do-                        case msg of-                            Event ev | look ["globalPosition"] ev == Just (toJSON (1 :: Int)) -> atomically (putTMVar entered ()) >> atomically (takeTMVar release)-                            _ -> pure ()-                        capture frames msg-                    lookupBatch ids = either (error . show) id <$> runStoreIO store (lookupStreamNames ids)-                withAsync (broadcastEventsWith writer cache lookupBatch sub (const True)) $ \_ -> do-                    appendEvents store "loss-1" 1-                    bounded (atomically (takeTMVar entered))-                    appendEvents store "loss-2" 1-                    waitPosition store 2-                    appendEvents store "loss-3" 1-                    waitPosition store 3-                    readTVarIO sub.subscriptionDropped `shouldReturn` 1-                    atomically (putTMVar release ())-                    bounded (atomically (readTVar frames >>= \xs -> check (length xs == 3)))-                xs <- readTVarIO frames-                map framePosition xs `shouldBe` [Just 1, Nothing, Just 3]-                case xs of-                    [_, CodedError code _, _] -> code `shouldBe` errorCodeEventStreamOverflowed-                    _ -> expectationFailure (show xs)-                Right recovered <- runStoreIO store (readAllForward (GlobalPosition 1) 10)-                map (.globalPosition) (V.toList recovered) `shouldBe` [GlobalPosition 2, GlobalPosition 3]-                sort (nub ([p | Just p <- map framePosition xs] <> map (\e -> let GlobalPosition p = e.globalPosition in p) (V.toList recovered))) `shouldBe` [1, 2, 3]-        it "coalesces defensive status overflow and a drop notice in the same delivery" $ do-            sub <- fakeSubscription-            cache <- newStreamNameCache-            frames <- newTVarIO []-            atomically $ do-                writeTVar sub.subscriptionStatus Pub.Overflowed-                writeTVar sub.subscriptionDropped 1-                writeTBQueue sub.subscriptionQueue (UnchangedBatch (V.singleton (fixture 1)))-                writeTBQueue sub.subscriptionQueue (UnchangedBatch (V.singleton (fixture 2)))-            withAsync (broadcastEventsWith (capture frames) cache (const (pure Map.empty)) sub (const True)) $ \_ ->-                bounded (atomically (readTVar frames >>= \xs -> check (length xs == 3)))-            map framePosition <$> readTVarIO frames `shouldReturn` [Nothing, Just 1, Just 2]+  describe "Kiroku.Metrics.WebSocket (bounded delivery)" $ do+    it "resolves one cold batch, no warm or empty batches, and bounds FIFO retention" $ do+      cache <- newStreamNameCache+      calls <- newIORef ([] :: [[StreamId]])+      let lookupBatch ids = do+            atomicModifyIORef' calls (\xs -> (xs <> [ids], ()))+            pure (Map.fromList [(sid, streamName sid) | sid <- ids])+          events = V.fromList (map fixture [1 .. 5000])+      names <- resolveEventNames cache lookupBatch events+      Map.size names `shouldBe` 5000+      Map.lookup (StreamId 1) names `shouldBe` Just (StreamName "stream-1")+      Map.lookup (StreamId 5000) names `shouldBe` Just (StreamName "stream-5000")+      streamNameCacheSize cache `shouldReturn` (4096, 4096)+      _ <- resolveEventNames cache lookupBatch (V.fromList [fixture 5000, fixture 5000])+      _ <- resolveEventNames cache lookupBatch V.empty+      length <$> readIORef calls `shouldReturn` 1+      _ <- resolveEventNames cache lookupBatch (V.singleton (fixture 1))+      length <$> readIORef calls `shouldReturn` 2+      streamNameCacheSize cache `shouldReturn` (4096, 4096)+    it "does not retain misses and propagates cancellation from a name lookup" $ do+      cache <- newStreamNameCache+      resolveEventNames cache (const (pure Map.empty)) (V.singleton (fixture 1)) `shouldReturn` Map.empty+      streamNameCacheSize cache `shouldReturn` (0, 0)+      entered <- newEmptyTMVarIO+      withAsync (resolveEventNames cache (\_ -> atomically (putTMVar entered ()) >> atomically retry) (V.singleton (fixture 1))) $ \worker -> do+        bounded (atomically (takeTMVar entered))+        cancel worker+      streamNameCacheSize cache `shouldReturn` (0, 0)+    it "delivers unchanged and transformed batches without false overflow notices" $ do+      sub <- fakeSubscription+      cache <- newStreamNameCache+      frames <- newTVarIO []+      atomically $ do+        writeTBQueue sub.subscriptionQueue (UnchangedBatch (V.singleton (fixture 1)))+        writeTBQueue sub.subscriptionQueue (TransformedBatch (V.singleton (Decoded (fixture 2))))+      withAsync (broadcastEventsWith (capture frames) cache (pure . Map.fromList . map (\sid -> (sid, streamName sid))) sub (const True)) $ \_ -> do+        bounded (atomically (readTVar frames >>= \xs -> check (length xs == 2)))+      xs <- readTVarIO frames+      map framePosition xs `shouldBe` [Just 1, Just 2]+    it "never sends partial data and terminates on an applicable typed decode failure" $ do+      sub <- fakeSubscription+      cache <- newStreamNameCache+      frames <- newTVarIO []+      let failed = fixture 2+      atomically (writeTBQueue sub.subscriptionQueue (TransformedBatch (V.fromList [Decoded (fixture 1), Undecodable failed (DecodeFailure failed.eventId "secret payload")])))+      bounded (broadcastEventsWith (capture frames) cache (\_ -> fail "must not look up partial batch") sub (const True))+      readTVarIO frames `shouldReturn` [CodedError errorCodeLiveDecodeFailed "live event decoding failed"]+    it "filters typed failures below the covered replay boundary" $ do+      sub <- fakeSubscription+      cache <- newStreamNameCache+      frames <- newTVarIO []+      let old = fixture 1+      atomically (writeTBQueue sub.subscriptionQueue (TransformedBatch (V.fromList [Undecodable old (DecodeFailure old.eventId "old failure"), Decoded (fixture 2)])))+      withAsync (broadcastEventsWith (capture frames) cache (const (pure Map.empty)) sub (\e -> e.globalPosition > GlobalPosition 1)) $ \_ ->+        bounded (atomically (readTVar frames >>= \xs -> check (length xs == 1)))+      map framePosition <$> readTVarIO frames `shouldReturn` [Just 2]+    it "signals real publisher loss before survivors and recovers from the pre-notice cursor" $ withBareStore $ \store ->+      bracket (atomically (Pub.subscribePublisherWith store.publisher 1 DropOldest)) Pub.unsubscribe $ \sub -> do+        cache <- newStreamNameCache+        frames <- newTVarIO []+        entered <- newEmptyTMVarIO+        release <- newEmptyTMVarIO+        let writer msg = do+              case msg of+                Event ev | look ["globalPosition"] ev == Just (toJSON (1 :: Int)) -> atomically (putTMVar entered ()) >> atomically (takeTMVar release)+                _ -> pure ()+              capture frames msg+            lookupBatch ids = either (error . show) id <$> runStoreIO store (lookupStreamNames ids)+        withAsync (broadcastEventsWith writer cache lookupBatch sub (const True)) $ \_ -> do+          appendEvents store "loss-1" 1+          bounded (atomically (takeTMVar entered))+          appendEvents store "loss-2" 1+          waitPosition store 2+          appendEvents store "loss-3" 1+          waitPosition store 3+          readTVarIO sub.subscriptionDropped `shouldReturn` 1+          atomically (putTMVar release ())+          bounded (atomically (readTVar frames >>= \xs -> check (length xs == 3)))+        xs <- readTVarIO frames+        map framePosition xs `shouldBe` [Just 1, Nothing, Just 3]+        case xs of+          [_, CodedError code _, _] -> code `shouldBe` errorCodeEventStreamOverflowed+          _ -> expectationFailure (show xs)+        Right recovered <- runStoreIO store (readAllForward (GlobalPosition 1) 10)+        map (.globalPosition) (V.toList recovered) `shouldBe` [GlobalPosition 2, GlobalPosition 3]+        sort (nub ([p | Just p <- map framePosition xs] <> map (\e -> let GlobalPosition p = e.globalPosition in p) (V.toList recovered))) `shouldBe` [1, 2, 3]+    it "coalesces defensive status overflow and a drop notice in the same delivery" $ do+      sub <- fakeSubscription+      cache <- newStreamNameCache+      frames <- newTVarIO []+      atomically $ do+        writeTVar sub.subscriptionStatus Pub.Overflowed+        writeTVar sub.subscriptionDropped 1+        writeTBQueue sub.subscriptionQueue (UnchangedBatch (V.singleton (fixture 1)))+        writeTBQueue sub.subscriptionQueue (UnchangedBatch (V.singleton (fixture 2)))+      withAsync (broadcastEventsWith (capture frames) cache (const (pure Map.empty)) sub (const True)) $ \_ ->+        bounded (atomically (readTVar frames >>= \xs -> check (length xs == 3)))+      map framePosition <$> readTVarIO frames `shouldReturn` [Nothing, Just 1, Just 2] -    describe "Kiroku.Metrics.WebSocket (worker lifecycle)" $ do-        it "keeps one unmasked worker through repeated start/stop and joins on exit" $ do-            active <- newTVarIO (0 :: Int)-            masking <- newTVarIO Nothing-            let worker = bracket_ (atomically (modifyTVar' active (+ 1))) (atomically (modifyTVar' active (subtract 1))) $ do-                    state <- getMaskingState-                    atomically (writeTVar masking (Just state))-                    atomically retry-            withWorkerSlot $ \start stop -> do-                replicateM_ 5 $ do-                    replicateM_ 3 (start worker)-                    bounded (atomically (readTVar active >>= check . (== 1)))-                    bounded (atomically (readTVar masking >>= check . (== Just Unmasked)))-                    stop >> stop-                    readTVarIO active `shouldReturn` 0-                start worker-                bounded (atomically (readTVar active >>= check . (== 1)))-            readTVarIO active `shouldReturn` 0-        it "joins a worker when the connection is cancelled during worker startup" $ do-            active <- newTVarIO (0 :: Int)-            let worker = bracket_ (atomically (modifyTVar' active (+ 1))) (atomically (modifyTVar' active (subtract 1))) (atomically retry)-            withAsync (withWorkerSlot $ \start _ -> start worker >> atomically retry) $ \owner -> do-                bounded (atomically (readTVar active >>= check . (== 1)))-                cancel owner-            readTVarIO active `shouldReturn` 0-        it "propagates unexpected worker failure to the owner" $ do-            result <- try (bounded (withWorkerSlot $ \start _ -> start (throwIO (userError "worker failed")) >> atomically retry)) :: IO (Either SomeException ())-            result `shouldSatisfy` either (T.isInfixOf "worker failed" . T.pack . show) (const False)+  describe "Kiroku.Metrics.WebSocket (worker lifecycle)" $ do+    it "keeps one unmasked worker through repeated start/stop and joins on exit" $ do+      active <- newTVarIO (0 :: Int)+      masking <- newTVarIO Nothing+      let worker = bracket_ (atomically (modifyTVar' active (+ 1))) (atomically (modifyTVar' active (subtract 1))) $ do+            state <- getMaskingState+            atomically (writeTVar masking (Just state))+            atomically retry+      withWorkerSlot $ \start stop -> do+        replicateM_ 5 $ do+          replicateM_ 3 (start worker)+          bounded (atomically (readTVar active >>= check . (== 1)))+          bounded (atomically (readTVar masking >>= check . (== Just Unmasked)))+          stop >> stop+          readTVarIO active `shouldReturn` 0+        start worker+        bounded (atomically (readTVar active >>= check . (== 1)))+      readTVarIO active `shouldReturn` 0+    it "joins a worker when the connection is cancelled during worker startup" $ do+      active <- newTVarIO (0 :: Int)+      let worker = bracket_ (atomically (modifyTVar' active (+ 1))) (atomically (modifyTVar' active (subtract 1))) (atomically retry)+      withAsync (withWorkerSlot $ \start _ -> start worker >> atomically retry) $ \owner -> do+        bounded (atomically (readTVar active >>= check . (== 1)))+        cancel owner+      readTVarIO active `shouldReturn` 0+    it "propagates unexpected worker failure to the owner" $ do+      result <- try (bounded (withWorkerSlot $ \start _ -> start (throwIO (userError "worker failed")) >> atomically retry)) :: IO (Either SomeException ())+      result `shouldSatisfy` either (T.isInfixOf "worker failed" . T.pack . show) (const False) -    describe "Kiroku.Metrics.WebSocket (convergence, real server)" $ do-        it "stops metrics pushes, resumes them and preserves ping" $ withServer id $ \_ srv -> client srv "/ws/metrics" $ \conn -> do-            _ <- waitForType conn "snapshot"-            _ <- waitForType conn "snapshot"-            replicateM_ 3 (command conn "unsubscribe_metrics")-            -- Ping is an ordering barrier: the receive loop has completed cancellation.-            command conn "ping"-            _ <- waitForType conn "pong"-            timeout 700_000 (WS.receiveData conn :: IO LBS.ByteString) `shouldReturn` Nothing-            replicateM_ 3 (command conn "subscribe_metrics")-            replicateM_ 4 (waitForType conn "snapshot")-            command conn "ping"-            _ <- waitForType conn "pong"-            pure ()-        it "labels live events on both new and warm streams" $ withServer id $ \store srv -> do-            names <- client srv "/ws/events" $ \conn -> do-                command conn "subscribe_events"-                _ <- waitForType conn "event_stream_started"-                appendEvents store "conv-live-1" 2-                appendEvents store "conv-live-2" 1-                replicateM 3 (readEventField conn "original_stream_name")-            names `shouldBe` map (Just . String) ["conv-live-1", "conv-live-1", "conv-live-2"]-            waitSubscriberCount store 0-        it "labels replay and category events" $ withServer id $ \store srv -> do-            appendEvents store "convcat-1" 2-            waitPosition store 2-            replayed <- client srv "/ws/events" $ \conn -> do-                sendJSON conn (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])-                _ <- waitForType conn "event_stream_started"-                replicateM 2 (readEventField conn "original_stream_name")-            replayed `shouldBe` replicate 2 (Just (String "convcat-1"))-            names <- client srv "/ws/events" $ \conn -> do-                sendJSON conn (object ["type" .= ("subscribe_events" :: Text), "category" .= ("convcat" :: Text)])-                _ <- waitForType conn "event_stream_started"-                appendEvents store "convcat-2" 1-                readEventField conn "original_stream_name"-            names `shouldBe` Just (String "convcat-2")-            waitSubscriberCount store 0-        it "codes and sanitizes a replay failure and ends its tail" $ withServer id $ \store srv -> do-            appendEvents store "conv-fail-1" 1-            waitPosition store 1-            Pool.use store.pool (Session.script "ALTER TABLE events RENAME TO events_hidden") `shouldReturn` Right ()-            client srv "/ws/events" $ \conn -> do-                sendJSON conn (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])-                err <- waitForType conn "error"-                look ["code"] err `shouldBe` Just (String "replay_failed")-                look ["message"] err `shouldBe` Just (String "replay error: history unavailable")-                timeout 300_000 (WS.receiveData conn :: IO LBS.ByteString) `shouldReturn` Nothing-            waitSubscriberCount store 0-        it "codes a category failure without appending to the renamed table" $ withServer id $ \store srv -> do-            appendEvents store "convcat-1" 1-            waitPosition store 1-            Pool.use store.pool (Session.script "ALTER TABLE events RENAME TO events_hidden") `shouldReturn` Right ()-            client srv "/ws/events" $ \conn -> do-                sendJSON conn (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int), "category" .= ("convcat" :: Text)])-                err <- waitForType conn "error"-                look ["code"] err `shouldBe` Just (String "category_read_failed")-                look ["message"] err `shouldBe` Just (String "category read error: events unavailable")-                timeout 300_000 (WS.receiveData conn :: IO LBS.ByteString) `shouldReturn` Nothing-            waitSubscriberCount store 0-        it "codes typed live decode failures without leaking hook details" $ withServer (\settings -> settings & #storeSettings . #decodeHook .~ Just (\e -> pure (Left (DecodeFailure e.eventId "secret")))) $ \store srv -> do-            client srv "/ws/events" $ \conn -> do-                command conn "subscribe_events"-                _ <- waitForType conn "event_stream_started"-                appendEvents store "conv-decode" 1-                err <- waitForType conn "error"-                look ["code"] err `shouldBe` Just (String "live_decode_failed")-                look ["message"] err `shouldBe` Just (String "live event decoding failed")-            waitSubscriberCount store 0+  describe "Kiroku.Metrics.WebSocket (convergence, real server)" $ do+    it "stops metrics pushes, resumes them and preserves ping" $ withServer id $ \_ srv -> client srv "/ws/metrics" $ \conn -> do+      _ <- waitForType conn "snapshot"+      _ <- waitForType conn "snapshot"+      replicateM_ 3 (command conn "unsubscribe_metrics")+      -- Ping is an ordering barrier: the receive loop has completed cancellation.+      command conn "ping"+      _ <- waitForType conn "pong"+      timeout 700_000 (WS.receiveData conn :: IO LBS.ByteString) `shouldReturn` Nothing+      replicateM_ 3 (command conn "subscribe_metrics")+      replicateM_ 4 (waitForType conn "snapshot")+      command conn "ping"+      _ <- waitForType conn "pong"+      pure ()+    it "labels live events on both new and warm streams" $ withServer id $ \store srv -> do+      names <- client srv "/ws/events" $ \conn -> do+        command conn "subscribe_events"+        _ <- waitForType conn "event_stream_started"+        appendEvents store "conv-live-1" 2+        appendEvents store "conv-live-2" 1+        replicateM 3 (readEventField conn "original_stream_name")+      names `shouldBe` map (Just . String) ["conv-live-1", "conv-live-1", "conv-live-2"]+      waitSubscriberCount store 0+    it "labels replay and category events" $ withServer id $ \store srv -> do+      appendEvents store "convcat-1" 2+      waitPosition store 2+      replayed <- client srv "/ws/events" $ \conn -> do+        sendJSON conn (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])+        _ <- waitForType conn "event_stream_started"+        replicateM 2 (readEventField conn "original_stream_name")+      replayed `shouldBe` replicate 2 (Just (String "convcat-1"))+      names <- client srv "/ws/events" $ \conn -> do+        sendJSON conn (object ["type" .= ("subscribe_events" :: Text), "category" .= ("convcat" :: Text)])+        _ <- waitForType conn "event_stream_started"+        appendEvents store "convcat-2" 1+        readEventField conn "original_stream_name"+      names `shouldBe` Just (String "convcat-2")+      waitSubscriberCount store 0+    it "codes and sanitizes a replay failure and ends its tail" $ withServer id $ \store srv -> do+      appendEvents store "conv-fail-1" 1+      waitPosition store 1+      Pool.use store.pool (Session.script "ALTER TABLE events RENAME TO events_hidden") `shouldReturn` Right ()+      client srv "/ws/events" $ \conn -> do+        sendJSON conn (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])+        err <- waitForType conn "error"+        look ["code"] err `shouldBe` Just (String "replay_failed")+        look ["message"] err `shouldBe` Just (String "replay error: history unavailable")+        timeout 300_000 (WS.receiveData conn :: IO LBS.ByteString) `shouldReturn` Nothing+      waitSubscriberCount store 0+    it "codes a category failure without appending to the renamed table" $ withServer id $ \store srv -> do+      appendEvents store "convcat-1" 1+      waitPosition store 1+      Pool.use store.pool (Session.script "ALTER TABLE events RENAME TO events_hidden") `shouldReturn` Right ()+      client srv "/ws/events" $ \conn -> do+        sendJSON conn (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int), "category" .= ("convcat" :: Text)])+        err <- waitForType conn "error"+        look ["code"] err `shouldBe` Just (String "category_read_failed")+        look ["message"] err `shouldBe` Just (String "category read error: events unavailable")+        timeout 300_000 (WS.receiveData conn :: IO LBS.ByteString) `shouldReturn` Nothing+      waitSubscriberCount store 0+    it "codes typed live decode failures without leaking hook details" $ withServer (\settings -> settings & #storeSettings . #decodeHook .~ Just (\e -> pure (Left (DecodeFailure e.eventId "secret")))) $ \store srv -> do+      client srv "/ws/events" $ \conn -> do+        command conn "subscribe_events"+        _ <- waitForType conn "event_stream_started"+        appendEvents store "conv-decode" 1+        err <- waitForType conn "error"+        look ["code"] err `shouldBe` Just (String "live_decode_failed")+        look ["message"] err `shouldBe` Just (String "live event decoding failed")+      waitSubscriberCount store 0  fixture :: Int64 -> RecordedEvent fixture n = RecordedEvent (EventId UUID.nil) (EventType "E") (StreamVersion 1) (GlobalPosition n) (StreamId n) (StreamVersion 1) (object []) Nothing Nothing Nothing (UTCTime (fromGregorian 2026 10 10) 0)@@ -283,10 +283,10 @@  assertNotice :: Word64 -> Word64 -> Text -> Expectation assertNotice previous current text = case overflowNotice previous current of-    Just (CodedError code msg) -> do-        code `shouldBe` errorCodeEventStreamOverflowed-        msg `shouldSatisfy` T.isInfixOf text-    other -> expectationFailure (show other)+  Just (CodedError code msg) -> do+    code `shouldBe` errorCodeEventStreamOverflowed+    msg `shouldSatisfy` T.isInfixOf text+  other -> expectationFailure (show other)  fakeSubscription :: IO Pub.PublisherSubscription fakeSubscription = Pub.PublisherSubscription <$> newTBQueueIO 16 <*> newTVarIO Pub.Active <*> newTVarIO 0 <*> pure (pure ())@@ -296,8 +296,8 @@  framePosition :: ServerMessage -> Maybe Int64 framePosition (Event ev) = case look ["globalPosition"] ev of-    Just (Number n) -> Just (round n)-    _ -> Nothing+  Just (Number n) -> Just (round n)+  _ -> Nothing framePosition _ = Nothing  bounded :: IO a -> IO a@@ -308,14 +308,14 @@  withServer :: (ConnectionSettings -> ConnectionSettings) -> (KirokuStore -> MetricsServer -> IO a) -> IO a withServer tweak action = withMigratedTestDatabase $ \conn -> do-    var <- newTVarIO Nothing-    km <--        newKirokuMetricsWith-            (readTVar var >>= maybe (pure (GlobalPosition 0)) (Pub.publisherPosition . (.publisher)))-            (readTVar var >>= maybe (pure 0) (fmap IntMap.size . readTVar . Pub.subscribers . (.publisher)))-    withStore (tweak (defaultConnectionSettings conn)) $ \store -> do-        atomically (writeTVar var (Just store))-        bracket (startMetricsServerWithStore (defaultConfig{port = 0, wsPushIntervalUs = 200_000}) km store []) stopMetricsServer (action store)+  var <- newTVarIO Nothing+  km <-+    newKirokuMetricsWith+      (readTVar var >>= maybe (pure (GlobalPosition 0)) (Pub.publisherPosition . (.publisher)))+      (readTVar var >>= maybe (pure 0) (fmap IntMap.size . readTVar . Pub.subscribers . (.publisher)))+  withStore (tweak (defaultConnectionSettings conn)) $ \store -> do+    atomically (writeTVar var (Just store))+    bracket (startMetricsServerWithStore (defaultConfig {port = 0, wsPushIntervalUs = 200_000}) km store []) stopMetricsServer (action store)  client :: MetricsServer -> String -> (WS.Connection -> IO a) -> IO a client srv path = bounded . WS.runClient "127.0.0.1" srv.serverPort path@@ -328,9 +328,9 @@  waitForType :: WS.Connection -> Text -> IO Value waitForType conn name = do-    raw <- WS.receiveData conn :: IO LBS.ByteString-    value <- either fail pure (eitherDecode raw)-    if look ["type"] value == Just (String name) then pure value else waitForType conn name+  raw <- WS.receiveData conn :: IO LBS.ByteString+  value <- either fail pure (eitherDecode raw)+  if look ["type"] value == Just (String name) then pure value else waitForType conn name  readEventField :: WS.Connection -> Text -> IO (Maybe Value) readEventField conn key = look ["event", key] <$> waitForType conn "event"@@ -342,8 +342,8 @@  appendEvents :: KirokuStore -> Text -> Int -> IO () appendEvents store name count = do-    result <- runStoreIO store (appendToStream (StreamName name) NoStream [EventData Nothing (EventType ("E" <> T.pack (show n))) (object []) Nothing Nothing Nothing | n <- [1 .. count]])-    either (fail . show) (const (pure ())) result+  result <- runStoreIO store (appendToStream (StreamName name) NoStream [EventData Nothing (EventType ("E" <> T.pack (show n))) (object []) Nothing Nothing Nothing | n <- [1 .. count]])+  either (fail . show) (const (pure ())) result  waitPosition :: KirokuStore -> Int64 -> IO () waitPosition store target = bounded (atomically (Pub.publisherPosition store.publisher >>= check . (>= GlobalPosition target)))
test/Test/WebSocketSpec.hs view
@@ -1,24 +1,23 @@ {-# LANGUAGE OverloadedLabels #-} {-# LANGUAGE ScopedTypeVariables #-} -{- | End-to-end test of the WebSocket endpoints (EP-3): boot a real store with the-collector wired in, start the store-aware server on an OS-assigned port, then-drive both channels over a real socket with the @websockets@ client.--  * @/ws/events@: subscribe, append three events from the test thread, assert-    three @event@ messages arrive in global-position order with the expected-    @eventType@s. After the client disconnects, the publisher's transient-    broadcast subscriber must be cleaned up (count returns to baseline) — proof-    the tail's @finally@ wiring deregisters and that it leaves no trace.-  * @/ws/metrics@: connect, receive a @snapshot@, assert @store.global_position@-    reflects the appended events.--}+-- | End-to-end test of the WebSocket endpoints (EP-3): boot a real store with the+-- collector wired in, start the store-aware server on an OS-assigned port, then+-- drive both channels over a real socket with the @websockets@ client.+--+--   * @/ws/events@: subscribe, append three events from the test thread, assert+--     three @event@ messages arrive in global-position order with the expected+--     @eventType@s. After the client disconnects, the publisher's transient+--     broadcast subscriber must be cleaned up (count returns to baseline) — proof+--     the tail's @finally@ wiring deregisters and that it leaves no trace.+--   * @/ws/metrics@: connect, receive a @snapshot@, assert @store.global_position@+--     reflects the appended events. module Test.WebSocketSpec (spec) where  import Control.Concurrent (myThreadId, threadDelay) import Control.Concurrent.Async (async, asyncThreadId, wait)-import Control.Concurrent.STM (-    STM,+import Control.Concurrent.STM+  ( STM,     TVar,     atomically,     check,@@ -28,7 +27,7 @@     readTVarIO,     registerDelay,     writeTVar,- )+  ) import Control.Exception (finally) import Control.Lens ((&), (.~)) import Control.Monad (replicateM, unless, when)@@ -47,18 +46,18 @@ import System.Timeout (timeout) import Test.Hspec -import Kiroku.Metrics (-    MetricsServer (..),+import Kiroku.Metrics+  ( MetricsServer (..),     defaultConfig,     metricsEventHandler,     metricsObservationHandler,     newKirokuMetricsWith,     startMetricsServerWithStore,     stopMetricsServer,- )+  ) import Kiroku.Metrics.Config (MetricsServerConfig (..))-import Kiroku.Store (-    EventData (..),+import Kiroku.Store+  ( EventData (..),     EventType (..),     ExpectedVersion (..),     KirokuStore (..),@@ -68,7 +67,7 @@     defaultConnectionSettings,     runStoreIO,     withStore,- )+  ) import Kiroku.Store.Settings (DecodeFailure (..)) import Kiroku.Store.Subscription.EventPublisher (EventPublisher (..), publisherPosition, subscribePublisher) import Kiroku.Store.Subscription.Types (OverflowPolicy (..))@@ -77,198 +76,198 @@  spec :: Spec spec = describe "Kiroku.Metrics.WebSocket (endpoints)" $ do-    it "streams appended events over /ws/events and a snapshot over /ws/metrics" $-        withMigratedTestDatabase $ \connStr -> do-            storeVar <- newTVarIO Nothing-            km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)-            let settings =-                    defaultConnectionSettings connStr-                        & #eventHandler .~ Just (metricsEventHandler km Nothing)-                        & #observationHandler .~ Just (metricsObservationHandler km Nothing)-            withStore settings $ \store -> do-                atomically (writeTVar storeVar (Just store))-                let serverCfg = defaultConfig{port = 0}-                srv <- startMetricsServerWithStore serverCfg km store []-                threadDelay 300_000-                let port = srv.serverPort-                    stream = StreamName "ws-events-stream"+  it "streams appended events over /ws/events and a snapshot over /ws/metrics" $+    withMigratedTestDatabase $ \connStr -> do+      storeVar <- newTVarIO Nothing+      km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)+      let settings =+            defaultConnectionSettings connStr+              & #eventHandler .~ Just (metricsEventHandler km Nothing)+              & #observationHandler .~ Just (metricsObservationHandler km Nothing)+      withStore settings $ \store -> do+        atomically (writeTVar storeVar (Just store))+        let serverCfg = defaultConfig {port = 0}+        srv <- startMetricsServerWithStore serverCfg km store []+        threadDelay 300_000+        let port = srv.serverPort+            stream = StreamName "ws-events-stream" -                -- /ws/events: subscribe, then append three events and read them back.-                received <--                    requireJust "ws/events client timed out" $-                        timeout 15_000_000 $-                            WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do-                                sendJSON conn (object ["type" .= ("subscribe_events" :: Text)])-                                _ <- waitForType conn "event_stream_started"-                                appendThread <- async (appendStoreEvents store stream 3)-                                evs <- replicateM 3 (readEventType conn)-                                wait appendThread-                                pure evs-                received `shouldBe` ["E1", "E2", "E3"]+        -- /ws/events: subscribe, then append three events and read them back.+        received <-+          requireJust "ws/events client timed out" $+            timeout 15_000_000 $+              WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do+                sendJSON conn (object ["type" .= ("subscribe_events" :: Text)])+                _ <- waitForType conn "event_stream_started"+                appendThread <- async (appendStoreEvents store stream 3)+                evs <- replicateM 3 (readEventType conn)+                wait appendThread+                pure evs+        received `shouldBe` ["E1", "E2", "E3"] -                -- The tail uses the public broadcast and unsubscribes on disconnect;-                -- the publisher's subscriber count must return to baseline (0).-                waitForSubscriberCount store 0 5_000_000+        -- The tail uses the public broadcast and unsubscribes on disconnect;+        -- the publisher's subscriber count must return to baseline (0).+        waitForSubscriberCount store 0 5_000_000 -                -- /ws/metrics: the snapshot reflects the three appended events.-                gpos <--                    requireJust "ws/metrics client timed out" $-                        timeout 15_000_000 $-                            WS.runClient "127.0.0.1" port "/ws/metrics" $ \conn -> do-                                snap <- waitForType conn "snapshot"-                                pure (globalPositionOf snap)-                gpos `shouldSatisfy` (>= 3)+        -- /ws/metrics: the snapshot reflects the three appended events.+        gpos <-+          requireJust "ws/metrics client timed out" $+            timeout 15_000_000 $+              WS.runClient "127.0.0.1" port "/ws/metrics" $ \conn -> do+                snap <- waitForType conn "snapshot"+                pure (globalPositionOf snap)+        gpos `shouldSatisfy` (>= 3) -                stopMetricsServer srv+        stopMetricsServer srv -    it "reports a typed live decode failure and releases the publisher queue" $-        withMigratedTestDatabase $ \connStr -> do-            storeVar <- newTVarIO Nothing-            km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)-            let settings =-                    defaultConnectionSettings connStr-                        & #storeSettings . #decodeHook .~ Just (\event -> pure (Left (DecodeFailure event.eventId "cannot decrypt")))-            withStore settings $ \store -> do-                atomically (writeTVar storeVar (Just store))-                srv <- startMetricsServerWithStore (defaultConfig{port = 0}) km store []-                result <- requireJust "ws decode failure timed out" $-                    timeout 15_000_000 $-                        WS.runClient "127.0.0.1" srv.serverPort "/ws/events" $ \conn -> do-                            sendJSON conn (object ["type" .= ("subscribe_events" :: Text)])-                            _ <- waitForType conn "event_stream_started"-                            appendStoreEvents store (StreamName "ws-decode") 1-                            waitForType conn "error"-                case result of-                    Object fields -> KM.lookup "type" fields `shouldBe` Just (String "error")-                    other -> expectationFailure ("expected error frame, got " <> show other)-                waitForSubscriberCount store 0 5_000_000-                waitForPublisherPosition store 1 5_000_000-                stopMetricsServer srv+  it "reports a typed live decode failure and releases the publisher queue" $+    withMigratedTestDatabase $ \connStr -> do+      storeVar <- newTVarIO Nothing+      km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)+      let settings =+            defaultConnectionSettings connStr+              & #storeSettings . #decodeHook .~ Just (\event -> pure (Left (DecodeFailure event.eventId "cannot decrypt")))+      withStore settings $ \store -> do+        atomically (writeTVar storeVar (Just store))+        srv <- startMetricsServerWithStore (defaultConfig {port = 0}) km store []+        result <- requireJust "ws decode failure timed out" $+          timeout 15_000_000 $+            WS.runClient "127.0.0.1" srv.serverPort "/ws/events" $ \conn -> do+              sendJSON conn (object ["type" .= ("subscribe_events" :: Text)])+              _ <- waitForType conn "event_stream_started"+              appendStoreEvents store (StreamName "ws-decode") 1+              waitForType conn "error"+        case result of+          Object fields -> KM.lookup "type" fields `shouldBe` Just (String "error")+          other -> expectationFailure ("expected error frame, got " <> show other)+        waitForSubscriberCount store 0 5_000_000+        waitForPublisherPosition store 1 5_000_000+        stopMetricsServer srv -    it "replays history from a position then continues live without duplicating the boundary" $-        withMigratedTestDatabase $ \connStr -> do-            storeVar <- newTVarIO Nothing-            km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)-            let settings =-                    defaultConnectionSettings connStr-                        & #eventHandler .~ Just (metricsEventHandler km Nothing)-                        & #observationHandler .~ Just (metricsObservationHandler km Nothing)-            withStore settings $ \store -> do-                atomically (writeTVar storeVar (Just store))-                let serverCfg = defaultConfig{port = 0}-                srv <- startMetricsServerWithStore serverCfg km store []-                threadDelay 300_000-                let port = srv.serverPort+  it "replays history from a position then continues live without duplicating the boundary" $+    withMigratedTestDatabase $ \connStr -> do+      storeVar <- newTVarIO Nothing+      km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)+      let settings =+            defaultConnectionSettings connStr+              & #eventHandler .~ Just (metricsEventHandler km Nothing)+              & #observationHandler .~ Just (metricsObservationHandler km Nothing)+      withStore settings $ \store -> do+        atomically (writeTVar storeVar (Just store))+        let serverCfg = defaultConfig {port = 0}+        srv <- startMetricsServerWithStore serverCfg km store []+        threadDelay 300_000+        let port = srv.serverPort -                -- Two events exist before the client connects (stream A); they must-                -- be replayed. The live event lands on a second stream (B) so the-                -- @NoStream@ append helper does not conflict.-                appendStoreEvents store (StreamName "ws-replay-a") 2-                received <--                    requireJust "ws/events replay client timed out" $-                        timeout 15_000_000 $-                            WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do-                                sendJSON-                                    conn-                                    (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])-                                _ <- waitForType conn "event_stream_started"-                                -- Two replayed (A's E1,E2), then one appended live-                                -- (B's E1) — three in global-position order, with no-                                -- duplicate at the replay/live boundary.-                                replayed <- replicateM 2 (readEventType conn)-                                liveThread <- async (appendStoreEvents store (StreamName "ws-replay-b") 1)-                                live <- readEventType conn-                                wait liveThread-                                pure (replayed <> [live])-                received `shouldBe` ["E1", "E2", "E1"]+        -- Two events exist before the client connects (stream A); they must+        -- be replayed. The live event lands on a second stream (B) so the+        -- @NoStream@ append helper does not conflict.+        appendStoreEvents store (StreamName "ws-replay-a") 2+        received <-+          requireJust "ws/events replay client timed out" $+            timeout 15_000_000 $+              WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do+                sendJSON+                  conn+                  (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])+                _ <- waitForType conn "event_stream_started"+                -- Two replayed (A's E1,E2), then one appended live+                -- (B's E1) — three in global-position order, with no+                -- duplicate at the replay/live boundary.+                replayed <- replicateM 2 (readEventType conn)+                liveThread <- async (appendStoreEvents store (StreamName "ws-replay-b") 1)+                live <- readEventType conn+                wait liveThread+                pure (replayed <> [live])+        received `shouldBe` ["E1", "E2", "E1"] -                stopMetricsServer srv+        stopMetricsServer srv -    it "delivers each global position exactly once when the replay overlaps live appends" $-        withMigratedTestDatabase $ \connStr -> do-            gateVar <- newTVarIO True-            storeVar <- newTVarIO Nothing-            let publisherGate e = do-                    mStore <- readTVarIO storeVar-                    for_ mStore $ \s -> do-                        tid <- myThreadId-                        when (tid == asyncThreadId (publisherThread s.publisher)) $-                            atomically (readTVar gateVar >>= check)-                    pure (Right e)-                settings =-                    defaultConnectionSettings connStr-                        & #storeSettings . #decodeHook .~ Just publisherGate-            km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)-            withStore settings $ \store -> do-                atomically (writeTVar storeVar (Just store))-                let serverCfg = defaultConfig{port = 0}-                srv <- startMetricsServerWithStore serverCfg km store []-                threadDelay 300_000-                let port = srv.serverPort+  it "delivers each global position exactly once when the replay overlaps live appends" $+    withMigratedTestDatabase $ \connStr -> do+      gateVar <- newTVarIO True+      storeVar <- newTVarIO Nothing+      let publisherGate e = do+            mStore <- readTVarIO storeVar+            for_ mStore $ \s -> do+              tid <- myThreadId+              when (tid == asyncThreadId (publisherThread s.publisher)) $+                atomically (readTVar gateVar >>= check)+            pure (Right e)+          settings =+            defaultConnectionSettings connStr+              & #storeSettings . #decodeHook .~ Just publisherGate+      km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)+      withStore settings $ \store -> do+        atomically (writeTVar storeVar (Just store))+        let serverCfg = defaultConfig {port = 0}+        srv <- startMetricsServerWithStore serverCfg km store []+        threadDelay 300_000+        let port = srv.serverPort -                appendStoreEvents store (StreamName "ws-race-a") 2-                waitForPublisherPosition store 2 5_000_000+        appendStoreEvents store (StreamName "ws-race-a") 2+        waitForPublisherPosition store 2 5_000_000 -                (_, _, unsubscribeDummy) <- atomically (subscribePublisher store.publisher 16 DropOldest)-                received <--                    flip finally unsubscribeDummy $-                        do-                            atomically (writeTVar gateVar False)-                            appendStoreEvents store (StreamName "ws-race-b") 3+        (_, _, unsubscribeDummy) <- atomically (subscribePublisher store.publisher 16 DropOldest)+        received <-+          flip finally unsubscribeDummy $+            do+              atomically (writeTVar gateVar False)+              appendStoreEvents store (StreamName "ws-race-b") 3 -                            requireJust "ws/events exactly-once client timed out" $-                                timeout 15_000_000 $-                                    WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do-                                        sendJSON-                                            conn-                                            (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])-                                        _ <- waitForType conn "event_stream_started"-                                        replayed <- replicateM 5 (readEventPosition conn)+              requireJust "ws/events exactly-once client timed out" $+                timeout 15_000_000 $+                  WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do+                    sendJSON+                      conn+                      (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])+                    _ <- waitForType conn "event_stream_started"+                    replayed <- replicateM 5 (readEventPosition conn) -                                        atomically (writeTVar gateVar True)-                                        appendStoreEvents store (StreamName "ws-race-c") 1-                                        live <- readEventPosition conn-                                        quiet <- timeout 500_000 (WS.receiveData conn :: IO LBS.ByteString)-                                        quiet `shouldBe` Nothing-                                        pure (replayed <> [live])-                received `shouldBe` [1, 2, 3, 4, 5, 6]+                    atomically (writeTVar gateVar True)+                    appendStoreEvents store (StreamName "ws-race-c") 1+                    live <- readEventPosition conn+                    quiet <- timeout 500_000 (WS.receiveData conn :: IO LBS.ByteString)+                    quiet `shouldBe` Nothing+                    pure (replayed <> [live])+        received `shouldBe` [1, 2, 3, 4, 5, 6] -                stopMetricsServer srv+        stopMetricsServer srv -    it "terminates the tail after a replay error instead of streaming with a gap" $-        withMigratedTestDatabase $ \connStr -> do-            storeVar <- newTVarIO Nothing-            km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)-            let settings =-                    defaultConnectionSettings connStr-                        & #eventHandler .~ Just (metricsEventHandler km Nothing)-                        & #observationHandler .~ Just (metricsObservationHandler km Nothing)-            withStore settings $ \store -> do-                atomically (writeTVar storeVar (Just store))-                let serverCfg = defaultConfig{port = 0}-                srv <- startMetricsServerWithStore serverCfg km store []-                threadDelay 300_000-                let port = srv.serverPort+  it "terminates the tail after a replay error instead of streaming with a gap" $+    withMigratedTestDatabase $ \connStr -> do+      storeVar <- newTVarIO Nothing+      km <- newKirokuMetricsWith (readPosition storeVar) (readSubscribers storeVar)+      let settings =+            defaultConnectionSettings connStr+              & #eventHandler .~ Just (metricsEventHandler km Nothing)+              & #observationHandler .~ Just (metricsObservationHandler km Nothing)+      withStore settings $ \store -> do+        atomically (writeTVar storeVar (Just store))+        let serverCfg = defaultConfig {port = 0}+        srv <- startMetricsServerWithStore serverCfg km store []+        threadDelay 300_000+        let port = srv.serverPort -                appendStoreEvents store (StreamName "ws-err-a") 2-                waitForPublisherPosition store 2 5_000_000-                renamed <- Pool.use store.pool (Session.script "ALTER TABLE events RENAME TO events_hidden")-                renamed `shouldBe` Right ()+        appendStoreEvents store (StreamName "ws-err-a") 2+        waitForPublisherPosition store 2 5_000_000+        renamed <- Pool.use store.pool (Session.script "ALTER TABLE events RENAME TO events_hidden")+        renamed `shouldBe` Right () -                requireJust "ws/events replay-error client timed out" $-                    timeout 15_000_000 $-                        WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do-                            sendJSON-                                conn-                                (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])-                            _ <- waitForType conn "event_stream_started"-                            err <- waitForType conn "error"-                            case look ["message"] err of-                                Just (String msg) -> msg `shouldSatisfy` T.isInfixOf "replay error"-                                other -> expectationFailure ("error without message: " <> show other)-                            waitForSubscriberCount store 0 5_000_000+        requireJust "ws/events replay-error client timed out" $+          timeout 15_000_000 $+            WS.runClient "127.0.0.1" port "/ws/events" $ \conn -> do+              sendJSON+                conn+                (object ["type" .= ("subscribe_events" :: Text), "from_position" .= (0 :: Int)])+              _ <- waitForType conn "event_stream_started"+              err <- waitForType conn "error"+              case look ["message"] err of+                Just (String msg) -> msg `shouldSatisfy` T.isInfixOf "replay error"+                other -> expectationFailure ("error without message: " <> show other)+              waitForSubscriberCount store 0 5_000_000 -                stopMetricsServer srv+        stopMetricsServer srv  -- | Fail the example with a message if the timed action returned 'Nothing'. requireJust :: String -> IO (Maybe a) -> IO a@@ -279,34 +278,34 @@  recvValue :: WS.Connection -> IO Value recvValue conn = do-    raw <- WS.receiveData conn :: IO LBS.ByteString-    case decode raw of-        Just v -> pure v-        Nothing -> expectationFailure ("undecodable frame: " <> show raw) >> error "undecodable"+  raw <- WS.receiveData conn :: IO LBS.ByteString+  case decode raw of+    Just v -> pure v+    Nothing -> expectationFailure ("undecodable frame: " <> show raw) >> error "undecodable"  -- | Read server messages until one with the given @"type"@ arrives; return it. waitForType :: WS.Connection -> Text -> IO Value waitForType conn want = go   where     go = do-        v <- recvValue conn-        if look ["type"] v == Just (String want) then pure v else go+      v <- recvValue conn+      if look ["type"] v == Just (String want) then pure v else go  -- | Read until an @event@ message, returning its @event.eventType@. readEventType :: WS.Connection -> IO Text readEventType conn = do-    v <- waitForType conn "event"-    case look ["event", "eventType"] v of-        Just (String t) -> pure t-        other -> expectationFailure ("event without eventType: " <> show other) >> error "no eventType"+  v <- waitForType conn "event"+  case look ["event", "eventType"] v of+    Just (String t) -> pure t+    other -> expectationFailure ("event without eventType: " <> show other) >> error "no eventType"  -- | Read until an @event@ message, returning its @event.globalPosition@. readEventPosition :: WS.Connection -> IO Int readEventPosition conn = do-    v <- waitForType conn "event"-    case look ["event", "globalPosition"] v of-        Just (Number n) -> pure (truncate (toRealFloat n :: Double))-        other -> expectationFailure ("event without globalPosition: " <> show other) >> error "no position"+  v <- waitForType conn "event"+  case look ["event", "globalPosition"] v of+    Just (Number n) -> pure (truncate (toRealFloat n :: Double))+    other -> expectationFailure ("event without globalPosition: " <> show other) >> error "no position"  -- | Navigate nested object keys. look :: [Text] -> Value -> Maybe Value@@ -317,67 +316,67 @@ -- | Extract @metrics.store.global_position@ from a @snapshot@ message. globalPositionOf :: Value -> Int globalPositionOf v =-    case look ["metrics", "store", "global_position"] v of-        Just (Number n) -> truncate (toRealFloat n :: Double)-        _ -> -1+  case look ["metrics", "store", "global_position"] v of+    Just (Number n) -> truncate (toRealFloat n :: Double)+    _ -> -1  readPosition :: TVar (Maybe KirokuStore) -> STM GlobalPosition readPosition storeVar =-    readTVar storeVar >>= maybe (pure (GlobalPosition 0)) (publisherPosition . (.publisher))+  readTVar storeVar >>= maybe (pure (GlobalPosition 0)) (publisherPosition . (.publisher))  readSubscribers :: TVar (Maybe KirokuStore) -> STM Int readSubscribers storeVar =-    readTVar storeVar-        >>= maybe (pure 0) (\s -> IntMap.size <$> readTVar (subscribers s.publisher))+  readTVar storeVar+    >>= maybe (pure 0) (\s -> IntMap.size <$> readTVar (subscribers s.publisher))  -- | Block until the publisher's subscriber count reaches @target@ or time out. waitForSubscriberCount :: KirokuStore -> Int -> Int -> IO () waitForSubscriberCount store target timeoutMicros = do-    timeoutVar <- registerDelay timeoutMicros-    ok <--        atomically $-            ( do-                c <- IntMap.size <$> readTVar (subscribers store.publisher)-                check (c == target)-                pure True-            )-                `orElse` (readTVar timeoutVar >>= \t -> check t >> pure False)-    unless ok $ do-        actual <- atomically (IntMap.size <$> readTVar (subscribers store.publisher))-        expectationFailure-            ("subscriber count did not reach " <> show target <> "; still " <> show actual)+  timeoutVar <- registerDelay timeoutMicros+  ok <-+    atomically $+      ( do+          c <- IntMap.size <$> readTVar (subscribers store.publisher)+          check (c == target)+          pure True+      )+        `orElse` (readTVar timeoutVar >>= \t -> check t >> pure False)+  unless ok $ do+    actual <- atomically (IntMap.size <$> readTVar (subscribers store.publisher))+    expectationFailure+      ("subscriber count did not reach " <> show target <> "; still " <> show actual)  -- | Block until the publisher cursor reaches @target@ or time out. waitForPublisherPosition :: KirokuStore -> Int -> Int -> IO () waitForPublisherPosition store target timeoutMicros = do-    timeoutVar <- registerDelay timeoutMicros-    ok <--        atomically $-            ( do-                GlobalPosition p <- publisherPosition store.publisher-                check (p >= fromIntegral target)-                pure True-            )-                `orElse` (readTVar timeoutVar >>= \t -> check t >> pure False)-    unless ok $ do-        GlobalPosition actual <- atomically (publisherPosition store.publisher)-        expectationFailure-            ("publisher position did not reach " <> show target <> "; still " <> show actual)+  timeoutVar <- registerDelay timeoutMicros+  ok <-+    atomically $+      ( do+          GlobalPosition p <- publisherPosition store.publisher+          check (p >= fromIntegral target)+          pure True+      )+        `orElse` (readTVar timeoutVar >>= \t -> check t >> pure False)+  unless ok $ do+    GlobalPosition actual <- atomically (publisherPosition store.publisher)+    expectationFailure+      ("publisher position did not reach " <> show target <> "; still " <> show actual)  appendStoreEvents :: KirokuStore -> StreamName -> Int -> IO () appendStoreEvents store stream n = do-    let events =-            [ EventData-                { eventId = Nothing-                , eventType = EventType ("E" <> T.pack (show i))-                , payload = Null-                , metadata = Nothing-                , causationId = Nothing-                , correlationId = Nothing-                }-            | i <- [1 .. n]-            ]-    result <- runStoreIO store (appendToStream stream NoStream events)-    case result of-        Right _ -> pure ()-        Left err -> expectationFailure ("appendStoreEvents failed: " <> show err)+  let events =+        [ EventData+            { eventId = Nothing,+              eventType = EventType ("E" <> T.pack (show i)),+              payload = Null,+              metadata = Nothing,+              causationId = Nothing,+              correlationId = Nothing+            }+        | i <- [1 .. n]+        ]+  result <- runStoreIO store (appendToStream stream NoStream events)+  case result of+    Right _ -> pure ()+    Left err -> expectationFailure ("appendStoreEvents failed: " <> show err)