packages feed

kiroku-store 0.11.0.1 → 0.12.0.0

raw patch · 98 files changed

+20869/−19736 lines, 98 filesdep ~shibuya-corePVP ok

version bump matches the API change (PVP)

Dependency ranges changed: shibuya-core

API changes (from Hackage documentation)

+ Kiroku.Store: KirokuEventSubscriptionTargetMismatch :: !SubscriptionTargetMismatch -> !SubscriptionGroupContext -> KirokuEvent
+ Kiroku.Store.Notification: [namespaceRegistry] :: Notifier -> !NamespaceRegistry
+ Kiroku.Store.Notification: notifyNamespace :: NamespaceRegistry -> Text -> STM ()
+ Kiroku.Store.Notification: registerNamespace :: NamespaceRegistry -> Text -> STM (TVar Word64, STM ())
+ Kiroku.Store.Notification: type NamespaceRegistry = TVar Map Text (Int, TVar Word64)
+ Kiroku.Store.Observability: KirokuEventSubscriptionTargetMismatch :: !SubscriptionTargetMismatch -> !SubscriptionGroupContext -> KirokuEvent
+ Kiroku.Store.SQL: NamespaceWindow :: !Vector RecordedEvent -> !GlobalPosition -> !Int32 -> !Bool -> NamespaceWindow
+ Kiroku.Store.SQL: [exhausted] :: NamespaceWindow -> !Bool
+ Kiroku.Store.SQL: [namespaceEvents] :: NamespaceWindow -> !Vector RecordedEvent
+ Kiroku.Store.SQL: [scannedCount] :: NamespaceWindow -> !Int32
+ Kiroku.Store.SQL: [scannedThrough] :: NamespaceWindow -> !GlobalPosition
+ Kiroku.Store.SQL: data NamespaceWindow
+ Kiroku.Store.SQL: instance GHC.Internal.Show.Show Kiroku.Store.SQL.NamespaceWindow
+ Kiroku.Store.SQL: readNamespaceGroupWindowStmt :: Statement (Int64, Int64, Text, Int32, Int32, Int32) NamespaceWindow
+ Kiroku.Store.SQL: readNamespaceWindowStmt :: Statement (Int64, Int64, Text, Int32) NamespaceWindow
+ Kiroku.Store.SQL: saveNamespaceCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32, Text) ()
+ Kiroku.Store.Subscription.Fsm: DeliverNamespaceWindow :: !DecodedBatch -> !GlobalPosition -> !Bool -> Effect
+ Kiroku.Store.Subscription.Fsm: NamespaceWindowCompleted :: !GlobalPosition -> !Bool -> Input
+ Kiroku.Store.Subscription.Fsm: NamespaceWindowFetched :: !DecodedBatch -> !GlobalPosition -> !Bool -> Input
+ Kiroku.Store.Subscription.Types: CategoryNamespace :: !Text -> SubscriptionTarget
+ Kiroku.Store.Subscription.Worker: LiveFromNamespaceNotify :: !TVar Word64 -> LiveSource
+ Kiroku.Store.Subscription.Worker: withNamespaceSafetyPollForTest :: Int -> IO a -> IO a
+ Kiroku.Store.Subscription.Worker: withNamespaceWindowHookForTest :: NamespaceWindowHook -> IO a -> IO a
- Kiroku.Store.Notification: Notifier :: !TChan () -> !Async () -> !TVar Connection -> !TVar (Map Text Word64) -> Notifier
+ Kiroku.Store.Notification: Notifier :: !TChan () -> !Async () -> !TVar Connection -> !NamespaceRegistry -> !TVar (Map Text Word64) -> Notifier

Files

CHANGELOG.md view
@@ -1,5 +1,53 @@ # Changelog +## 0.12.0.0 — 2026-10-26++### Breaking Changes++- `SubscriptionTarget` gains `CategoryNamespace Text`; update exhaustive matches.+  Apply migration 0016 from kiroku-store-migrations 0.7.2.0 with affected workers+  stopped before using namespace subscriptions. Older binaries must not manage+  namespace bindings.+- `KirokuEvent` gains `KirokuEventSubscriptionTargetMismatch`; update exhaustive+  observers.+- In the exposed low-level modules, `Notifier` gains `namespaceRegistry`;+  complete record constructors must supply it. FSM `Input` gains+  `NamespaceWindowFetched` and `NamespaceWindowCompleted`, and FSM `Effect` gains+  `DeliverNamespaceWindow`; update exhaustive custom drivers.++### New Features++- Add exact-category/colon-boundary namespace fan-in, including future categories+  and consumer groups. Workers scan bounded global windows, decode matching+  payloads only, checkpoint completed scans, and share selective namespace wakes.+- Expose namespace registration and notification helpers and bounded namespace+  window/checkpoint SQL statements for low-level integrations.++### Bug Fixes++- Invalid stall intervals emit one crashed `Stopped` before `wait` returns.+  Target refusals emit their typed detail event followed by one crashed stop.+- Preserve the initial event filter and selector decision across decode retries,+  including recovery to an excluded rewritten type.+- Low-level checkpoint initialization inherits uniform sibling size and binding+  under the topology lock, preserving existing rows and missing-position policies.+- Avoid payload probes for empty namespace windows.++### Other Changes++- Sparse namespace replay traverses global history independently per worker;+  namespace checkpoints can advance beyond the last delivered matching event.+- Retain the namespace cohort's explicit practical performance acceptance:+  20 verified comparison trials, five pairs per case. The statistical+  zero-slowdown verdicts remain inconclusive; historical replay throughput was+  not measured. See `bench/results/ep54-kenshou/README.md` and `acceptance.json`.+- Tighten append workload measurement validation and retain the inspection+  cohort's accepted write-cost evidence.+- Correct omitted source-compatibility changes in the 0.10.0.0 and 0.11.0.0 notes.+- Require [shibuya-core](mori://shinzui/shibuya/packages/shibuya-core)+  `>=0.12 && <0.13` in the Shibuya overhead benchmark and initialize its deliveries+  with `DeliveryCurrent`.+ ## 0.11.0.1 — 2026-10-20  ### Bug Fixes@@ -41,7 +89,19 @@ - Byte-ordered stream browsing with exact category, literal prefix and exclusive   name cursors; paged category enumeration and canonical event lookup by id. +### Erratum — 2026-10-25 +The published 0.11.0.0 notes omitted this source-compatibility break; the Hackage+copy cannot be updated:++- `SubscriptionDeadLetter` and `SubscriptionDeadLetterQuery` add exported record+  selectors `eventId`, `globalPosition`, `after` and `limit` through `Kiroku.Store`+  under `DuplicateRecordFields`. Bare uses of those names on `RecordedEvent` or+  `HistoryRetentionInventoryQuery` can now fail with ambiguous-occurrence errors.+  Disambiguate field access or narrow imports; the repository's `Worker.hs` needed+  `hiding (eventId, globalPosition)` on its conflicting import.++ ## 0.10.0.0 — 2026-10-10  ### Breaking Changes@@ -84,6 +144,37 @@   `DuplicateEvent` with a parseable caller ID. `ux_stream_events_stream_version`   returns `UnexpectedServerError "23505"` with the original message for an   invariant failure. Unknown append constraints retain the expected-version fallback.++### Erratum — 2026-10-25++The published 0.10.0.0 notes omitted or only implied the following breaking+changes; the Hackage copy cannot be updated:++- `SubscriptionConfigM` also gains `targetBindingPolicy` (default `AdoptUnbound`),+  and `batchSize` changes from `Int32` to validated `BatchSize`. Complete record+  constructors must supply the new field; construct batch sizes with `mkBatchSize`.+- `subscriptionStream` and `subscriptionAckStream` take validated+  `StreamBufferSize` in place of their previous numeric buffer-capacity parameter;+  use `mkStreamBufferSize`.+- `StoreError.EventDecodeFailed` and+  `DeadLetterReason.DeadLetterDecodeFailure`, mentioned above as features, also+  require updates to exhaustive matches. Observer matches must additionally+  handle the new `KirokuEvent.KirokuEventSubscriptionTargetBound` constructor.+- `InvalidConsumerGroup` and `InvalidStreamBufferSize` lose their `Exception`+  instances. Handle construction failures as `Left` values from the validated+  constructors; `subscribe`, `subscriptionAckStream` and+  `kirokuConsumerGroupProcessors` no longer throw those errors.+- Exposed `Kiroku.Store.Subscription.Fsm` constructors change payloads:+  `ConnectionLost` carries a `GlobalPosition`, and `BatchFetched` and+  `DeliverBatch` carry `DecodedBatch`.+- In exposed `Kiroku.Store.Subscription.EventPublisher`, `subscribePublisher`+  returns a `TBQueue DecodedBatch` in its result. Exposed+  `Kiroku.Store.Subscription.Worker.LiveFromPublisherQueue` likewise takes a+  `TBQueue DecodedBatch`.+- In exposed `Kiroku.Store.SQL`, `saveCheckpointMemberStmt` takes the six-tuple+  `(Text, Int32, Int64, Int32, Text, Maybe Text)`. `DeadLetterParams` gains+  `dlTargetKind`, `dlTargetCategory` and `dlGroupSize`; update direct callers and+  complete record constructors.  ## 0.9.0.1 — 2026-09-25 
bench/CheckpointTargetCost.hs view
@@ -1,10 +1,9 @@ {-# LANGUAGE MultilineStrings #-} {-# LANGUAGE OverloadedRecordDot #-} -{- | Small supplementary EP-2 comparison of the changed checkpoint-save path.-No acceptance threshold: local ratios retain their uncertainty. The control-statement and row layout are from 23a03a1b8b56773a140d0683c4c8d4d34b9d7e36.--}+-- | Small supplementary EP-2 comparison of the changed checkpoint-save path.+-- No acceptance threshold: local ratios retain their uncertainty. The control+-- statement and row layout are from 23a03a1b8b56773a140d0683c4c8d4d34b9d7e36. module Main where  import Contravariant.Extras (contrazip4)@@ -27,60 +26,60 @@  main :: IO () main = do-    original <- ephemeralConfig-    let keys = ["fsync", "synchronous_commit", "full_page_writes", "shared_buffers", "wal_level"]-        durable = original{Pg.postgresSettings = filter (\(key, _) -> key `notElem` keys) original.postgresSettings <> [("fsync", "on"), ("synchronous_commit", "on"), ("full_page_writes", "on"), ("shared_buffers", "128MB"), ("wal_level", "replica")]}-    result <- Pg.withCachedConfig durable Pg.defaultCacheConfig $ \database -> do-        let connection = Pg.connectionString database-        migrateTestDatabase connection-        withStore (defaultConnectionSettings connection) measure-    either (fail . show) pure result+  original <- ephemeralConfig+  let keys = ["fsync", "synchronous_commit", "full_page_writes", "shared_buffers", "wal_level"]+      durable = original {Pg.postgresSettings = filter (\(key, _) -> key `notElem` keys) original.postgresSettings <> [("fsync", "on"), ("synchronous_commit", "on"), ("full_page_writes", "on"), ("shared_buffers", "128MB"), ("wal_level", "replica")]}+  result <- Pg.withCachedConfig durable Pg.defaultCacheConfig $ \database -> do+    let connection = Pg.connectionString database+    migrateTestDatabase connection+    withStore (defaultConnectionSettings connection) measure+  either (fail . show) pure result  measure :: KirokuStore -> IO () measure store = do-    settings <- use store $ Session.statement () (preparable "SELECT current_setting('server_version_num')::int4 >= 180000 AND current_setting('server_version_num')::int4 < 190000 AND current_setting('fsync') = 'on' AND current_setting('synchronous_commit') = 'on' AND current_setting('full_page_writes') = 'on'" E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))-    if settings then pure () else fail "requires durable PostgreSQL 18"-    use store $-        Session.script-            """-            CREATE SCHEMA ep2_control;-            CREATE TABLE ep2_control.subscriptions (-              subscription_id BIGSERIAL PRIMARY KEY,-              subscription_name TEXT NOT NULL,-              stream_name TEXT NOT NULL DEFAULT '$all',-              last_seen BIGINT NOT NULL DEFAULT 0,-              consumer_group_member INT NOT NULL DEFAULT 0,-              consumer_group_size INT NOT NULL DEFAULT 1,-              created_at TIMESTAMPTZ NOT NULL DEFAULT now(),-              updated_at TIMESTAMPTZ NOT NULL DEFAULT now()-            );-            CREATE UNIQUE INDEX ON ep2_control.subscriptions (subscription_name, consumer_group_member);-            """-    controlCounter <- newIORef 0-    candidateCounter <- newIORef 0-    let save candidate = do-            p <- atomicModifyIORef' (if candidate then candidateCounter else controlCounter) (\n -> (n + 1, n + 1))-            let m = fromIntegral (p `mod` 4)-            if candidate-                then use store $ Session.statement ("target-cost", m, p, 4, "performance") SQL.saveCategoryCheckpointMemberStmt-                else use store $ Session.statement ("target-cost", m, p, 4) controlSave-        trial pair candidate = do-            walBefore <- use store (Session.statement () walPosition)-            start <- getMonotonicTimeNSec-            replicateM_ 2000 (save candidate)-            end <- getMonotonicTimeNSec-            walAfter <- use store (Session.statement () walPosition)-            putStrLn $ show pair <> "," <> (if candidate then "candidate" else "control") <> ",2000," <> show (fromIntegral (end - start) / 2000 :: Double) <> "," <> show (fromIntegral (walAfter - walBefore) / 2000 :: Double)-    replicateM_ 200 (save False >> save True)-    putStrLn "pair,arm,saves,ns_per_save,wal_bytes_per_save"-    forM_ [1 .. 3 :: Int] $ \pair ->-        if odd pair-            then trial pair False >> trial pair True-            else trial pair True >> trial pair False-    -- Every completed save advances one durable member; both arms do equal work.-    expected <- use store $ Session.statement () (preparable "SELECT count(*) = 4 AND sum(last_seen) = 24794 FROM ep2_control.subscriptions" E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))-    actual <- use store $ Session.statement () (preparable "SELECT count(*) = 4 AND sum(last_seen) = 24794 AND bool_and(target_kind = 'category' AND target_category = 'performance') FROM kiroku.subscriptions WHERE subscription_name = 'target-cost'" E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))-    if expected && actual then putStrLn "durable equal-work checks: passed" else fail "durable equal-work checks failed"+  settings <- use store $ Session.statement () (preparable "SELECT current_setting('server_version_num')::int4 >= 180000 AND current_setting('server_version_num')::int4 < 190000 AND current_setting('fsync') = 'on' AND current_setting('synchronous_commit') = 'on' AND current_setting('full_page_writes') = 'on'" E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))+  if settings then pure () else fail "requires durable PostgreSQL 18"+  use store $+    Session.script+      """+      CREATE SCHEMA ep2_control;+      CREATE TABLE ep2_control.subscriptions (+        subscription_id BIGSERIAL PRIMARY KEY,+        subscription_name TEXT NOT NULL,+        stream_name TEXT NOT NULL DEFAULT '$all',+        last_seen BIGINT NOT NULL DEFAULT 0,+        consumer_group_member INT NOT NULL DEFAULT 0,+        consumer_group_size INT NOT NULL DEFAULT 1,+        created_at TIMESTAMPTZ NOT NULL DEFAULT now(),+        updated_at TIMESTAMPTZ NOT NULL DEFAULT now()+      );+      CREATE UNIQUE INDEX ON ep2_control.subscriptions (subscription_name, consumer_group_member);+      """+  controlCounter <- newIORef 0+  candidateCounter <- newIORef 0+  let save candidate = do+        p <- atomicModifyIORef' (if candidate then candidateCounter else controlCounter) (\n -> (n + 1, n + 1))+        let m = fromIntegral (p `mod` 4)+        if candidate+          then use store $ Session.statement ("target-cost", m, p, 4, "performance") SQL.saveCategoryCheckpointMemberStmt+          else use store $ Session.statement ("target-cost", m, p, 4) controlSave+      trial pair candidate = do+        walBefore <- use store (Session.statement () walPosition)+        start <- getMonotonicTimeNSec+        replicateM_ 2000 (save candidate)+        end <- getMonotonicTimeNSec+        walAfter <- use store (Session.statement () walPosition)+        putStrLn $ show pair <> "," <> (if candidate then "candidate" else "control") <> ",2000," <> show (fromIntegral (end - start) / 2000 :: Double) <> "," <> show (fromIntegral (walAfter - walBefore) / 2000 :: Double)+  replicateM_ 200 (save False >> save True)+  putStrLn "pair,arm,saves,ns_per_save,wal_bytes_per_save"+  forM_ [1 .. 3 :: Int] $ \pair ->+    if odd pair+      then trial pair False >> trial pair True+      else trial pair True >> trial pair False+  -- Every completed save advances one durable member; both arms do equal work.+  expected <- use store $ Session.statement () (preparable "SELECT count(*) = 4 AND sum(last_seen) = 24794 FROM ep2_control.subscriptions" E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))+  actual <- use store $ Session.statement () (preparable "SELECT count(*) = 4 AND sum(last_seen) = 24794 AND bool_and(target_kind = 'category' AND target_category = 'performance') FROM kiroku.subscriptions WHERE subscription_name = 'target-cost'" E.noParams (D.singleRow (D.column (D.nonNullable D.bool))))+  if expected && actual then putStrLn "durable equal-work checks: passed" else fail "durable equal-work checks failed"  use :: KirokuStore -> Session.Session a -> IO a use store session = Pool.use (store ^. #pool) session >>= either (fail . show) pure@@ -90,12 +89,12 @@  controlSave :: Statement (Text, Int32, Int64, Int32) () controlSave =-    preparable-        """-        INSERT INTO ep2_control.subscriptions (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size)-        VALUES ($1, $2, $3, now(), $4)-        ON CONFLICT (subscription_name, consumer_group_member)-        DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now(), consumer_group_size = EXCLUDED.consumer_group_size-        """-        (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)))-        D.noResult+  preparable+    """+    INSERT INTO ep2_control.subscriptions (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size)+    VALUES ($1, $2, $3, now(), $4)+    ON CONFLICT (subscription_name, consumer_group_member)+    DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now(), consumer_group_size = EXCLUDED.consumer_group_size+    """+    (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)))+    D.noResult
bench/Explain.hs view
@@ -1,27 +1,26 @@ {-# LANGUAGE MultilineStrings #-} -{- | PostgreSQL-side append profiling harness.--Two modes:--  * default — boots a cached ephemeral PostgreSQL via 'Pg.withCachedConfig', runs-    the production @AnyVersion@ CTE under-    @EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT)@ wrapped in-    @BEGIN ... ROLLBACK@, prints the result to stdout, then runs the same-    query under @FORMAT JSON@ and writes the result to-    @kiroku-store/bench/explain-results/anyversion-singleton.json@.--  * @--auto-explain@ — boots an ephemeral PostgreSQL with the @auto_explain@-    contrib loaded via 'Pg.autoExplainConfig', redirects the server's stderr-    to @kiroku-store/bench/explain-results/auto-explain.log@, opens a-    'KirokuStore' (which migrates the schema), runs a small workload (one-    @AnyVersion@ append, one @ExactVersion@ append against the same stream,-    one @ReadStreamForward@, one @ReadAllForward@), and exits. PostgreSQL-    flushes the @auto_explain@ output to the captured log on shutdown.--Belongs to ExecPlan-@docs/plans/26-postgresql-side-append-profiling-with-explain-analyze-and-auto-explain.md@.--}+-- | PostgreSQL-side append profiling harness.+--+-- Two modes:+--+--   * default — boots a cached ephemeral PostgreSQL via 'Pg.withCachedConfig', runs+--     the production @AnyVersion@ CTE under+--     @EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT)@ wrapped in+--     @BEGIN ... ROLLBACK@, prints the result to stdout, then runs the same+--     query under @FORMAT JSON@ and writes the result to+--     @kiroku-store/bench/explain-results/anyversion-singleton.json@.+--+--   * @--auto-explain@ — boots an ephemeral PostgreSQL with the @auto_explain@+--     contrib loaded via 'Pg.autoExplainConfig', redirects the server's stderr+--     to @kiroku-store/bench/explain-results/auto-explain.log@, opens a+--     'KirokuStore' (which migrates the schema), runs a small workload (one+--     @AnyVersion@ append, one @ExactVersion@ append against the same stream,+--     one @ReadStreamForward@, one @ReadAllForward@), and exits. PostgreSQL+--     flushes the @auto_explain@ output to the captured log on shutdown.+--+-- Belongs to ExecPlan+-- @docs/plans/26-postgresql-side-append-profiling-with-explain-analyze-and-auto-explain.md@. module Main where  import Control.Concurrent (threadDelay)@@ -61,179 +60,173 @@ -- ---------------------------------------------------------------------------  data AppendParams = AppendParams-    { eventIds :: !(V.Vector UUID)-    , eventTypes :: !(V.Vector Text)-    , causationIds :: !(V.Vector (Maybe UUID))-    , correlationIds :: !(V.Vector (Maybe UUID))-    , payloads :: !(V.Vector Aeson.Value)-    , metadatas :: !(V.Vector (Maybe Aeson.Value))-    , createdAts :: !(V.Vector UTCTime)-    , streamName :: !Text-    }-    deriving stock (Generic)+  { eventIds :: !(V.Vector UUID),+    eventTypes :: !(V.Vector Text),+    causationIds :: !(V.Vector (Maybe UUID)),+    correlationIds :: !(V.Vector (Maybe UUID)),+    payloads :: !(V.Vector Aeson.Value),+    metadatas :: !(V.Vector (Maybe Aeson.Value)),+    createdAts :: !(V.Vector UTCTime),+    streamName :: !Text+  }+  deriving stock (Generic)  appendParamsEncoder :: E.Params AppendParams appendParamsEncoder =-    ((^. #eventIds) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))-        <> ((^. #eventTypes) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))-        <> ((^. #causationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))-        <> ((^. #correlationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))-        <> ((^. #payloads) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.jsonb))))-        <> ((^. #metadatas) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.jsonb))))-        <> ((^. #createdAts) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.timestamptz))))-        <> ((^. #streamName) >$< E.param (E.nonNullable E.text))+  ((^. #eventIds) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    <> ((^. #eventTypes) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+    <> ((^. #causationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))+    <> ((^. #correlationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))+    <> ((^. #payloads) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.jsonb))))+    <> ((^. #metadatas) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.jsonb))))+    <> ((^. #createdAts) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.timestamptz))))+    <> ((^. #streamName) >$< E.param (E.nonNullable E.text))  appendAnyVersionSQL :: Text appendAnyVersionSQL =-    """-    WITH-      new_events AS (-        SELECT *-        FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])-        WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)-      ),-      stream_upsert AS (-        INSERT INTO streams (stream_name, stream_version)-        VALUES ($8, (SELECT count(*) FROM new_events))-        ON CONFLICT (stream_name)-        DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)-          WHERE streams.deleted_at IS NULL-        RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version-      ),-      inserted_events AS (-        INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)-        SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at-        FROM new_events-        WHERE EXISTS (SELECT 1 FROM stream_upsert)-        ORDER BY idx-      ),-      source_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-        SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx-        FROM new_events ne-        CROSS JOIN stream_upsert su-      ),-      all_update AS (-        UPDATE streams-        SET stream_version = stream_version + (SELECT count(*) FROM new_events)-        WHERE stream_id = 0-          AND EXISTS (SELECT 1 FROM stream_upsert)-        RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version-      ),-      all_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)-        SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category-        FROM new_events ne-        CROSS JOIN all_update au-        CROSS JOIN stream_upsert su-      )-    SELECT su.stream_id,-           su.initial_version + (SELECT count(*) FROM new_events),-           au.initial_global_version + (SELECT count(*) FROM new_events)-    FROM stream_upsert su-    CROSS JOIN all_update au-    """+  """+  WITH+    new_events AS (+      SELECT *+      FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+      WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+    ),+    stream_upsert AS (+      INSERT INTO streams (stream_name, stream_version)+      VALUES ($8, (SELECT count(*) FROM new_events))+      ON CONFLICT (stream_name)+      DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)+        WHERE streams.deleted_at IS NULL+      RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version+    ),+    inserted_events AS (+      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+      SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+      FROM new_events+      WHERE EXISTS (SELECT 1 FROM stream_upsert)+      ORDER BY idx+    ),+    source_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+      FROM new_events ne+      CROSS JOIN stream_upsert su+    ),+    all_update AS (+      UPDATE streams+      SET stream_version = stream_version + (SELECT count(*) FROM new_events)+      WHERE stream_id = 0+        AND EXISTS (SELECT 1 FROM stream_upsert)+      RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+    ),+    all_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+      SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+      FROM new_events ne+      CROSS JOIN all_update au+      CROSS JOIN stream_upsert su+    )+  SELECT su.stream_id,+         su.initial_version + (SELECT count(*) FROM new_events),+         au.initial_global_version + (SELECT count(*) FROM new_events)+  FROM stream_upsert su+  CROSS JOIN all_update au+  """  -- --------------------------------------------------------------------------- -- EXPLAIN wrappers + sample params -- --------------------------------------------------------------------------- -{- | Wrap @appendAnyVersionSQL@ in @EXPLAIN (...) <CTE>@ with @FORMAT TEXT@.-Each row of the result is one indented plan line. Uses 'unpreparable'-because the EXPLAIN runs once and the statement-cache cost would be wasted.--}+-- | Wrap @appendAnyVersionSQL@ in @EXPLAIN (...) <CTE>@ with @FORMAT TEXT@.+-- Each row of the result is one indented plan line. Uses 'unpreparable'+-- because the EXPLAIN runs once and the statement-cache cost would be wasted. explainStatementText :: Statement AppendParams [Text] explainStatementText =-    unpreparable-        sql-        appendParamsEncoder-        (D.rowList (D.column (D.nonNullable D.text)))+  unpreparable+    sql+    appendParamsEncoder+    (D.rowList (D.column (D.nonNullable D.text)))   where     sql =-        "EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT)\n"-            <> appendAnyVersionSQL+      "EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT)\n"+        <> appendAnyVersionSQL -{- | Wrap @appendAnyVersionSQL@ in @EXPLAIN (...) <CTE>@ with @FORMAT JSON@.-PostgreSQL returns one row whose single column is of type @json@; we use-'D.jsonBytes' with an identity parser to preserve the original-byte-for-byte output.--}+-- | Wrap @appendAnyVersionSQL@ in @EXPLAIN (...) <CTE>@ with @FORMAT JSON@.+-- PostgreSQL returns one row whose single column is of type @json@; we use+-- 'D.jsonBytes' with an identity parser to preserve the original+-- byte-for-byte output. explainStatementJson :: Statement AppendParams BS.ByteString explainStatementJson =-    unpreparable-        sql-        appendParamsEncoder-        (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right))))+  unpreparable+    sql+    appendParamsEncoder+    (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right))))   where     sql =-        "EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT JSON)\n"-            <> appendAnyVersionSQL+      "EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT JSON)\n"+        <> appendAnyVersionSQL -{- | One-event 'AppendParams' for an @AnyVersion@ append against a fresh-stream name.--}+-- | One-event 'AppendParams' for an @AnyVersion@ append against a fresh+-- stream name. mkSampleParams :: IO AppendParams mkSampleParams = do-    eid <- V4.nextRandom-    now <- getCurrentTime-    let nowMicros = show now-    pure-        AppendParams-            { eventIds = V.singleton eid-            , eventTypes = V.singleton "ExplainEvent"-            , causationIds = V.singleton Nothing-            , correlationIds = V.singleton Nothing-            , payloads = V.singleton (Aeson.object ["explain" Aeson..= Aeson.String "anyversion"])-            , metadatas = V.singleton Nothing-            , createdAts = V.singleton now-            , streamName = T.pack ("explain-" <> nowMicros)-            }+  eid <- V4.nextRandom+  now <- getCurrentTime+  let nowMicros = show now+  pure+    AppendParams+      { eventIds = V.singleton eid,+        eventTypes = V.singleton "ExplainEvent",+        causationIds = V.singleton Nothing,+        correlationIds = V.singleton Nothing,+        payloads = V.singleton (Aeson.object ["explain" Aeson..= Aeson.String "anyversion"]),+        metadatas = V.singleton Nothing,+        createdAts = V.singleton now,+        streamName = T.pack ("explain-" <> nowMicros)+      } -{- | Run a single TEXT-format EXPLAIN session inside @BEGIN ... ROLLBACK@-so the inserted rows do not persist.--}+-- | Run a single TEXT-format EXPLAIN session inside @BEGIN ... ROLLBACK@+-- so the inserted rows do not persist. explainSessionText :: AppendParams -> Session.Session [Text] explainSessionText params = do-    Session.script "BEGIN"-    rows <- Session.statement params explainStatementText-    Session.script "ROLLBACK"-    pure rows+  Session.script "BEGIN"+  rows <- Session.statement params explainStatementText+  Session.script "ROLLBACK"+  pure rows -{- | JSON-format counterpart of 'explainSessionText'. The result is the-@json@ column's raw bytes.--}+-- | JSON-format counterpart of 'explainSessionText'. The result is the+-- @json@ column's raw bytes. explainSessionJson :: AppendParams -> Session.Session BS.ByteString explainSessionJson params = do-    Session.script "BEGIN"-    bs <- Session.statement params explainStatementJson-    Session.script "ROLLBACK"-    pure bs+  Session.script "BEGIN"+  bs <- Session.statement params explainStatementJson+  Session.script "ROLLBACK"+  pure bs  -- --------------------------------------------------------------------------- -- Output paths -- --------------------------------------------------------------------------- -{- | Walk up from the current working directory until a @cabal.project@ file-is found, then return that directory. Falls back to the CWD if no marker-is found. This makes the harness work whether invoked from the repo root-('cabal run', direct binary invocation) or from the package directory-('cabal bench', which sets CWD to @kiroku-store\/@).--}+-- | Walk up from the current working directory until a @cabal.project@ file+-- is found, then return that directory. Falls back to the CWD if no marker+-- is found. This makes the harness work whether invoked from the repo root+-- ('cabal run', direct binary invocation) or from the package directory+-- ('cabal bench', which sets CWD to @kiroku-store\/@). locateRepoRoot :: IO FilePath locateRepoRoot = do-    cwd <- getCurrentDirectory-    go cwd+  cwd <- getCurrentDirectory+  go cwd   where     go dir = do-        let marker = dir </> "cabal.project"-        present <- doesFileExist marker-        if present-            then pure dir-            else-                let parent = takeDirectory dir-                 in if parent == dir-                        then getCurrentDirectory-                        else go parent+      let marker = dir </> "cabal.project"+      present <- doesFileExist marker+      if present+        then pure dir+        else+          let parent = takeDirectory dir+           in if parent == dir+                then getCurrentDirectory+                else go parent  explainResultsDir :: FilePath -> FilePath explainResultsDir repoRoot = repoRoot </> "kiroku-store" </> "bench" </> "explain-results"@@ -256,174 +249,172 @@  runExplainAnalyze :: IO () runExplainAnalyze = do-    repoRoot <- locateRepoRoot-    let dir = explainResultsDir repoRoot-        jsonPath = jsonOutputPath repoRoot-        textPath = textOutputPath repoRoot-    createDirectoryIfMissing True dir-    putStrLn ("=== Output directory: " <> dir <> " ===")-    putStrLn "=== Booting cached ephemeral PostgreSQL for EXPLAIN ANALYZE ==="-    config <- ephemeralConfig-    result <- Pg.withCachedConfig config Pg.defaultCacheConfig $ \db -> do-        let settings = defaultConnectionSettings (Pg.connectionString db)-        withStore settings $ \store -> do-            paramsText <- mkSampleParams-            paramsJson <- mkSampleParams+  repoRoot <- locateRepoRoot+  let dir = explainResultsDir repoRoot+      jsonPath = jsonOutputPath repoRoot+      textPath = textOutputPath repoRoot+  createDirectoryIfMissing True dir+  putStrLn ("=== Output directory: " <> dir <> " ===")+  putStrLn "=== Booting cached ephemeral PostgreSQL for EXPLAIN ANALYZE ==="+  config <- ephemeralConfig+  result <- Pg.withCachedConfig config Pg.defaultCacheConfig $ \db -> do+    let settings = defaultConnectionSettings (Pg.connectionString db)+    withStore settings $ \store -> do+      paramsText <- mkSampleParams+      paramsJson <- mkSampleParams -            putStrLn ""-            putStrLn "=== EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT) of appendAnyVersionSQL ==="-            putStrLn ""-            textRows <- runSession store (explainSessionText paramsText)-            let textOut = T.intercalate "\n" textRows-            TIO.putStrLn textOut-            TIO.writeFile textPath textOut+      putStrLn ""+      putStrLn "=== EXPLAIN (ANALYZE, BUFFERS, TIMING, FORMAT TEXT) of appendAnyVersionSQL ==="+      putStrLn ""+      textRows <- runSession store (explainSessionText paramsText)+      let textOut = T.intercalate "\n" textRows+      TIO.putStrLn textOut+      TIO.writeFile textPath textOut -            putStrLn ""-            putStrLn ("=== Writing FORMAT JSON output to " <> jsonPath <> " ===")-            jsonBytes <- runSession store (explainSessionJson paramsJson)-            BS.writeFile jsonPath jsonBytes+      putStrLn ""+      putStrLn ("=== Writing FORMAT JSON output to " <> jsonPath <> " ===")+      jsonBytes <- runSession store (explainSessionJson paramsJson)+      BS.writeFile jsonPath jsonBytes -            putStrLn ""-            putStrLn ("=== TEXT output also archived at " <> textPath <> " ===")-    case result of-        Left err ->-            error ("EphemeralPg failed to start: " <> show err)-        Right () ->-            pure ()+      putStrLn ""+      putStrLn ("=== TEXT output also archived at " <> textPath <> " ===")+  case result of+    Left err ->+      error ("EphemeralPg failed to start: " <> show err)+    Right () ->+      pure ()  -- --------------------------------------------------------------------------- -- Milestone 2: auto_explain capture of a small workload -- --------------------------------------------------------------------------- -{- | A representative slice of the production read+write paths so the-@auto_explain@ log captures one of each kind. Kept tiny so the resulting-log is human-readable.--}+-- | A representative slice of the production read+write paths so the+-- @auto_explain@ log captures one of each kind. Kept tiny so the resulting+-- log is human-readable. runSmallWorkload :: KirokuStore -> IO () runSmallWorkload store = do-    let sn = StreamName "explain-auto"-    -- AnyVersion append: hits the appendAnyVersionSQL CTE.-    r1 <- runStoreIO store $ appendToStream sn AnyVersion [mkEvent "Created"]-    forceOk "AnyVersion" r1-    let res1 = case r1 of-            Right ok -> ok-            Left e -> error ("AnyVersion append failed: " <> show e)-    -- ExactVersion append against the same stream: hits the-    -- appendExpectedVersionSQL CTE with a non-trivial conflict check.-    r2 <--        runStoreIO store $-            appendToStream sn (ExactVersion (res1 ^. #streamVersion)) [mkEvent "Updated"]-    forceOk "ExactVersion" r2-    -- One read forward from the same stream (limit 100 events).-    r3 <- runStoreIO store $ readStreamForward sn (StreamVersion 0) 100-    forceOk "readStreamForward" r3-    -- One read forward from the $all stream (limit 100 events).-    r4 <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-    forceOk "readAllForward" r4+  let sn = StreamName "explain-auto"+  -- AnyVersion append: hits the appendAnyVersionSQL CTE.+  r1 <- runStoreIO store $ appendToStream sn AnyVersion [mkEvent "Created"]+  forceOk "AnyVersion" r1+  let res1 = case r1 of+        Right ok -> ok+        Left e -> error ("AnyVersion append failed: " <> show e)+  -- ExactVersion append against the same stream: hits the+  -- appendExpectedVersionSQL CTE with a non-trivial conflict check.+  r2 <-+    runStoreIO store $+      appendToStream sn (ExactVersion (res1 ^. #streamVersion)) [mkEvent "Updated"]+  forceOk "ExactVersion" r2+  -- One read forward from the same stream (limit 100 events).+  r3 <- runStoreIO store $ readStreamForward sn (StreamVersion 0) 100+  forceOk "readStreamForward" r3+  -- One read forward from the $all stream (limit 100 events).+  r4 <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+  forceOk "readAllForward" r4   where     mkEvent :: Text -> EventData     mkEvent ty =-        EventData-            Nothing-            (EventType ty)-            (Aeson.object ["t" Aeson..= ty])-            Nothing-            Nothing-            Nothing+      EventData+        Nothing+        (EventType ty)+        (Aeson.object ["t" Aeson..= ty])+        Nothing+        Nothing+        Nothing      forceOk :: (Show a) => Text -> Either StoreError a -> IO ()     forceOk label = \case-        Left e -> error (T.unpack label <> " failed: " <> show e)-        Right _ -> pure ()+      Left e -> error (T.unpack label <> " failed: " <> show e)+      Right _ -> pure ()  runAutoExplain :: IO () runAutoExplain = do-    repoRoot <- locateRepoRoot-    let dir = explainResultsDir repoRoot-        logPath = autoExplainLogPath repoRoot-        csvPath = autoExplainCsvPath repoRoot-    createDirectoryIfMissing True dir-    putStrLn ("=== Output directory: " <> dir <> " ===")-    putStrLn "=== Booting ephemeral PostgreSQL with auto_explain enabled ==="-    -- ephemeral-pg unconditionally discards the postgres process's stderr-    -- (`setStderr nullStream` at EphemeralPg/Process/Postgres.hs:78), so-    -- plan 26's documented "override Config.stderr to a file handle"-    -- approach cannot work. Instead, configure PostgreSQL's own logging-    -- collector to write a CSV log directly to disk; auto_explain's output-    -- is captured as the message column of those CSV rows. See plan 26's-    -- Surprises & Discoveries for the full debrief.-    let logDir = dir </> "pglog"-        logFilename = "auto-explain.log"-    createDirectoryIfMissing True logDir-    let collectorSettings =-            [ ("logging_collector", "'on'")-            , ("log_destination", "'csvlog'")-            , ("log_directory", "'" <> T.pack logDir <> "'")-            , ("log_filename", "'" <> T.pack logFilename <> "'")-            , ("log_rotation_age", "'0'")-            , ("log_rotation_size", "'0'")-            , ("log_truncate_on_rotation", "'off'")-            , ("log_line_prefix", "'%t [%p] '")-            , -- Required to actually capture auto_explain output: empirical-              -- testing showed that with default log_min_messages (warning)-              -- the csvlog file stayed at 0 bytes, even though-              -- auto_explain.log_min_duration was 0. Forcing the threshold-              -- down to log_min_messages = 'log' lets the auto_explain-              -- ereport(LOG, ...) calls through. See plan 26's Surprises-              -- & Discoveries for the empirical evidence.-              ("log_min_messages", "'log'")-            ]-    base <- ephemeralConfig-    let cfg =-            base-                <> PgC.autoExplainConfig 0-                <> (mempty :: PgC.Config){PgC.postgresSettings = collectorSettings}-    result <- Pg.withConfig cfg $ \db -> do-        let settings = defaultConnectionSettings (Pg.connectionString db)-        withStore settings $ \store -> do-            putStrLn "=== Running small workload (1 AnyVersion + 1 ExactVersion + 2 reads) ==="-            runSmallWorkload store-            putStrLn "=== Waiting so the logging collector flushes ==="-            threadDelay 3_000_000 -- 3 seconds-    case result of-        Left err ->-            error ("EphemeralPg failed to start: " <> show err)-        Right () -> do-            files <- listDirectory logDir-            let collectorCsv = logDir </> "auto-explain.csv"-            let collectorStderrLog = logDir </> "auto-explain.log"-            hasCsv <- doesFileExist collectorCsv-            hasLog <- doesFileExist collectorStderrLog-            if hasCsv-                then do-                    contents <- BS.readFile collectorCsv-                    BS.writeFile csvPath contents-                    putStrLn ("=== auto_explain csvlog: " <> csvPath <> " (" <> show (BS.length contents) <> " bytes) ===")-                else putStrLn ("=== WARNING: missing " <> collectorCsv <> "; emitted: " <> show files <> " ===")-            if hasLog-                then do-                    contents <- BS.readFile collectorStderrLog-                    BS.writeFile logPath contents-                    putStrLn ("=== auto_explain stderr (small): " <> logPath <> " (" <> show (BS.length contents) <> " bytes) ===")-                else putStrLn ("=== NOTE: collector did not produce a stderr-format log (csvlog only) ===")-            -- Drop the intermediate pglog/ directory; we have copies in the-            -- documented output paths above.-            removePathForcibly logDir+  repoRoot <- locateRepoRoot+  let dir = explainResultsDir repoRoot+      logPath = autoExplainLogPath repoRoot+      csvPath = autoExplainCsvPath repoRoot+  createDirectoryIfMissing True dir+  putStrLn ("=== Output directory: " <> dir <> " ===")+  putStrLn "=== Booting ephemeral PostgreSQL with auto_explain enabled ==="+  -- ephemeral-pg unconditionally discards the postgres process's stderr+  -- (`setStderr nullStream` at EphemeralPg/Process/Postgres.hs:78), so+  -- plan 26's documented "override Config.stderr to a file handle"+  -- approach cannot work. Instead, configure PostgreSQL's own logging+  -- collector to write a CSV log directly to disk; auto_explain's output+  -- is captured as the message column of those CSV rows. See plan 26's+  -- Surprises & Discoveries for the full debrief.+  let logDir = dir </> "pglog"+      logFilename = "auto-explain.log"+  createDirectoryIfMissing True logDir+  let collectorSettings =+        [ ("logging_collector", "'on'"),+          ("log_destination", "'csvlog'"),+          ("log_directory", "'" <> T.pack logDir <> "'"),+          ("log_filename", "'" <> T.pack logFilename <> "'"),+          ("log_rotation_age", "'0'"),+          ("log_rotation_size", "'0'"),+          ("log_truncate_on_rotation", "'off'"),+          ("log_line_prefix", "'%t [%p] '"),+          -- Required to actually capture auto_explain output: empirical+          -- testing showed that with default log_min_messages (warning)+          -- the csvlog file stayed at 0 bytes, even though+          -- auto_explain.log_min_duration was 0. Forcing the threshold+          -- down to log_min_messages = 'log' lets the auto_explain+          -- ereport(LOG, ...) calls through. See plan 26's Surprises+          -- & Discoveries for the empirical evidence.+          ("log_min_messages", "'log'")+        ]+  base <- ephemeralConfig+  let cfg =+        base+          <> PgC.autoExplainConfig 0+          <> (mempty :: PgC.Config) {PgC.postgresSettings = collectorSettings}+  result <- Pg.withConfig cfg $ \db -> do+    let settings = defaultConnectionSettings (Pg.connectionString db)+    withStore settings $ \store -> do+      putStrLn "=== Running small workload (1 AnyVersion + 1 ExactVersion + 2 reads) ==="+      runSmallWorkload store+      putStrLn "=== Waiting so the logging collector flushes ==="+      threadDelay 3_000_000 -- 3 seconds+  case result of+    Left err ->+      error ("EphemeralPg failed to start: " <> show err)+    Right () -> do+      files <- listDirectory logDir+      let collectorCsv = logDir </> "auto-explain.csv"+      let collectorStderrLog = logDir </> "auto-explain.log"+      hasCsv <- doesFileExist collectorCsv+      hasLog <- doesFileExist collectorStderrLog+      if hasCsv+        then do+          contents <- BS.readFile collectorCsv+          BS.writeFile csvPath contents+          putStrLn ("=== auto_explain csvlog: " <> csvPath <> " (" <> show (BS.length contents) <> " bytes) ===")+        else putStrLn ("=== WARNING: missing " <> collectorCsv <> "; emitted: " <> show files <> " ===")+      if hasLog+        then do+          contents <- BS.readFile collectorStderrLog+          BS.writeFile logPath contents+          putStrLn ("=== auto_explain stderr (small): " <> logPath <> " (" <> show (BS.length contents) <> " bytes) ===")+        else putStrLn ("=== NOTE: collector did not produce a stderr-format log (csvlog only) ===")+      -- Drop the intermediate pglog/ directory; we have copies in the+      -- documented output paths above.+      removePathForcibly logDir  -- --------------------------------------------------------------------------- -- Shared helpers -- --------------------------------------------------------------------------- -{- | Run a Hasql 'Session' directly against the store's pool, surfacing-pool errors via 'error'. Used by the EXPLAIN harness which bypasses the-Store effect layer.--}+-- | Run a Hasql 'Session' directly against the store's pool, surfacing+-- pool errors via 'error'. Used by the EXPLAIN harness which bypasses the+-- Store effect layer. runSession :: KirokuStore -> Session.Session a -> IO a runSession store session = do-    r <- Pool.use (store ^. #pool) session-    case r of-        Left e -> error ("Hasql.Pool.use failed: " <> show e)-        Right a -> pure a+  r <- Pool.use (store ^. #pool) session+  case r of+    Left e -> error ("Hasql.Pool.use failed: " <> show e)+    Right a -> pure a  -- --------------------------------------------------------------------------- -- main@@ -431,8 +422,8 @@  main :: IO () main = do-    hSetBuffering stdout LineBuffering-    args <- getArgs-    if "--auto-explain" `elem` args-        then runAutoExplain-        else runExplainAnalyze+  hSetBuffering stdout LineBuffering+  args <- getArgs+  if "--auto-explain" `elem` args+    then runAutoExplain+    else runExplainAnalyze
bench/Main.hs view
@@ -32,992 +32,985 @@ import Test.Tasty.Bench  data RawAppendParams = RawAppendParams-    { eventId :: !UUID-    , eventType :: !Text-    , causationId :: !(Maybe UUID)-    , correlationId :: !(Maybe UUID)-    , payload :: !Aeson.Value-    , metadata :: !(Maybe Aeson.Value)-    , createdAt :: !UTCTime-    , streamName :: !Text-    }-    deriving stock (Generic)--data RawProductionAppendParams = RawProductionAppendParams-    { productionEventIds :: !(V.Vector UUID)-    , productionEventTypes :: !(V.Vector Text)-    , productionCausationIds :: !(V.Vector (Maybe UUID))-    , productionCorrelationIds :: !(V.Vector (Maybe UUID))-    , productionPayloads :: !(V.Vector Aeson.Value)-    , productionMetadatas :: !(V.Vector (Maybe Aeson.Value))-    , productionCreatedAts :: !(V.Vector UTCTime)-    , productionStreamName :: !Text-    }-    deriving stock (Generic)--type RawAppendResult = (Int64, Int64, Int64)--rawAppendParamsEncoder :: E.Params RawAppendParams-rawAppendParamsEncoder =-    ((^. #eventId) >$< E.param (E.nonNullable E.uuid))-        <> ((^. #eventType) >$< E.param (E.nonNullable E.text))-        <> ((^. #causationId) >$< E.param (E.nullable E.uuid))-        <> ((^. #correlationId) >$< E.param (E.nullable E.uuid))-        <> ((^. #payload) >$< E.param (E.nonNullable E.jsonb))-        <> ((^. #metadata) >$< E.param (E.nullable E.jsonb))-        <> ((^. #createdAt) >$< E.param (E.nonNullable E.timestamptz))-        <> ((^. #streamName) >$< E.param (E.nonNullable E.text))--rawProductionAppendParamsEncoder :: E.Params RawProductionAppendParams-rawProductionAppendParamsEncoder =-    ((^. #productionEventIds) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))-        <> ((^. #productionEventTypes) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))-        <> ((^. #productionCausationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))-        <> ((^. #productionCorrelationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))-        <> ((^. #productionPayloads) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.jsonb))))-        <> ((^. #productionMetadatas) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.jsonb))))-        <> ((^. #productionCreatedAts) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.timestamptz))))-        <> ((^. #productionStreamName) >$< E.param (E.nonNullable E.text))--rawAppendResultDecoder :: D.Result (Maybe RawAppendResult)-rawAppendResultDecoder =-    D.rowMaybe $-        (,,)-            <$> D.column (D.nonNullable D.int8)-            <*> D.column (D.nonNullable D.int8)-            <*> D.column (D.nonNullable D.int8)--rawScalarAppendAnyVersion :: Statement RawAppendParams (Maybe RawAppendResult)-rawScalarAppendAnyVersion =-    preparable-        rawScalarAppendAnyVersionSQL-        rawAppendParamsEncoder-        rawAppendResultDecoder--rawProductionAppendAnyVersion :: Statement RawProductionAppendParams (Maybe RawAppendResult)-rawProductionAppendAnyVersion =-    preparable-        rawProductionAppendAnyVersionSQL-        rawProductionAppendParamsEncoder-        rawAppendResultDecoder--rawScalarAppendAnyVersionSQL :: Text-rawScalarAppendAnyVersionSQL =-    """-    WITH-      new_event AS (-        SELECT $1::uuid AS event_id,-               $2::text AS event_type,-               $3::uuid AS causation_id,-               $4::uuid AS correlation_id,-               $5::jsonb AS data,-               $6::jsonb AS metadata,-               $7::timestamptz AS created_at-      ),-      stream_upsert AS (-        INSERT INTO streams (stream_name, stream_version)-        VALUES ($8, 1)-        ON CONFLICT (stream_name)-        DO UPDATE SET stream_version = streams.stream_version + 1-          WHERE streams.deleted_at IS NULL-        RETURNING stream_id, category, stream_version - 1 AS initial_version-      ),-      inserted_events AS (-        INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)-        SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at-        FROM new_event-        WHERE EXISTS (SELECT 1 FROM stream_upsert)-      ),-      source_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-        SELECT ne.event_id, su.stream_id, su.initial_version + 1, su.stream_id, su.initial_version + 1-        FROM new_event ne-        CROSS JOIN stream_upsert su-      ),-      all_update AS (-        UPDATE streams-        SET stream_version = stream_version + 1-        WHERE stream_id = 0-          AND EXISTS (SELECT 1 FROM stream_upsert)-        RETURNING stream_version - 1 AS initial_global_version-      ),-      all_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)-        SELECT ne.event_id, 0, au.initial_global_version + 1, su.stream_id, su.initial_version + 1, su.category-        FROM new_event ne-        CROSS JOIN all_update au-        CROSS JOIN stream_upsert su-      )-    SELECT su.stream_id,-           su.initial_version + 1,-           au.initial_global_version + 1-    FROM stream_upsert su-    CROSS JOIN all_update au-    """--rawProductionAppendAnyVersionSQL :: Text-rawProductionAppendAnyVersionSQL =-    """-    WITH-      new_events AS (-        SELECT *-        FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])-        WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)-      ),-      stream_upsert AS (-        INSERT INTO streams (stream_name, stream_version)-        VALUES ($8, (SELECT count(*) FROM new_events))-        ON CONFLICT (stream_name)-        DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)-          WHERE streams.deleted_at IS NULL-        RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version-      ),-      inserted_events AS (-        INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)-        SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at-        FROM new_events-        WHERE EXISTS (SELECT 1 FROM stream_upsert)-        ORDER BY idx-      ),-      source_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-        SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx-        FROM new_events ne-        CROSS JOIN stream_upsert su-      ),-      all_update AS (-        UPDATE streams-        SET stream_version = stream_version + (SELECT count(*) FROM new_events)-        WHERE stream_id = 0-          AND EXISTS (SELECT 1 FROM stream_upsert)-        RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version-      ),-      all_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)-        SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category-        FROM new_events ne-        CROSS JOIN all_update au-        CROSS JOIN stream_upsert su-      )-    SELECT su.stream_id,-           su.initial_version + (SELECT count(*) FROM new_events),-           au.initial_global_version + (SELECT count(*) FROM new_events)-    FROM stream_upsert su-    CROSS JOIN all_update au-    """---- Two-round-trip variants: the proof-of-concept that the plan-23 restructure--- is structurally faster than the production arrays/unnest CTE. The resolve--- query reads (stream_id, stream_version, deleted_at) by stream_name; one of--- two append CTEs then runs keyed on the resolved stream_id (existing) or on--- the stream_name with no ON CONFLICT (new).--data RawResolution = RawResolution-    { resStreamId :: !Int64-    , resStreamVersion :: !Int64-    , resDeletedAt :: !(Maybe UTCTime)-    }-    deriving stock (Generic)--rawResolveStreamStmt :: Statement Text (Maybe RawResolution)-rawResolveStreamStmt =-    preparable-        rawResolveStreamSQL-        (E.param (E.nonNullable E.text))-        ( D.rowMaybe $-            RawResolution-                <$> D.column (D.nonNullable D.int8)-                <*> D.column (D.nonNullable D.int8)-                <*> D.column (D.nullable D.timestamptz)-        )--rawResolveStreamSQL :: Text-rawResolveStreamSQL =-    """-    SELECT stream_id, stream_version, deleted_at-    FROM streams-    WHERE stream_name = $1-    """---- | Append params for the two-round-trip "existing" path: stream_id is known.-data RawAppendExistingParams = RawAppendExistingParams-    { existingStreamId :: !Int64-    , existingEventId :: !UUID-    , existingEventType :: !Text-    , existingCausationId :: !(Maybe UUID)-    , existingCorrelationId :: !(Maybe UUID)-    , existingPayload :: !Aeson.Value-    , existingMetadata :: !(Maybe Aeson.Value)-    , existingCreatedAt :: !UTCTime-    }-    deriving stock (Generic)--rawAppendExistingEncoder :: E.Params RawAppendExistingParams-rawAppendExistingEncoder =-    ((^. #existingStreamId) >$< E.param (E.nonNullable E.int8))-        <> ((^. #existingEventId) >$< E.param (E.nonNullable E.uuid))-        <> ((^. #existingEventType) >$< E.param (E.nonNullable E.text))-        <> ((^. #existingCausationId) >$< E.param (E.nullable E.uuid))-        <> ((^. #existingCorrelationId) >$< E.param (E.nullable E.uuid))-        <> ((^. #existingPayload) >$< E.param (E.nonNullable E.jsonb))-        <> ((^. #existingMetadata) >$< E.param (E.nullable E.jsonb))-        <> ((^. #existingCreatedAt) >$< E.param (E.nonNullable E.timestamptz))--rawAppendUpdateExisting :: Statement RawAppendExistingParams (Maybe RawAppendResult)-rawAppendUpdateExisting =-    preparable-        rawAppendUpdateExistingSQL-        rawAppendExistingEncoder-        rawAppendResultDecoder--{- | Update an existing stream keyed on integer stream_id. No version check,-no soft-delete check, no EXISTS gating, no count(*) — Haskell-side-validation has already done all of that.--}-rawAppendUpdateExistingSQL :: Text-rawAppendUpdateExistingSQL =-    """-    WITH-      stream_update AS (-        UPDATE streams-        SET stream_version = stream_version + 1-        WHERE stream_id = $1::bigint-        RETURNING stream_id, category, stream_version - 1 AS initial_version-      ),-      inserted_event AS (-        INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)-        VALUES ($2::uuid, $3::text, $4::uuid, $5::uuid, $6::jsonb, $7::jsonb, $8::timestamptz)-      ),-      source_link AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-        SELECT $2::uuid, su.stream_id, su.initial_version + 1, su.stream_id, su.initial_version + 1-        FROM stream_update su-      ),-      all_update AS (-        UPDATE streams-        SET stream_version = stream_version + 1-        WHERE stream_id = 0-        RETURNING stream_version - 1 AS initial_global_version-      ),-      all_link AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)-        SELECT $2::uuid, 0, au.initial_global_version + 1, su.stream_id, su.initial_version + 1, su.category-        FROM all_update au-        CROSS JOIN stream_update su-      )-    SELECT su.stream_id,-           su.initial_version + 1,-           au.initial_global_version + 1-    FROM stream_update su-    CROSS JOIN all_update au-    """---- | Append params for the two-round-trip "new stream" path: stream_name only.-data RawAppendNewParams = RawAppendNewParams-    { newStreamName :: !Text-    , newEventId :: !UUID-    , newEventType :: !Text-    , newCausationId :: !(Maybe UUID)-    , newCorrelationId :: !(Maybe UUID)-    , newPayload :: !Aeson.Value-    , newMetadata :: !(Maybe Aeson.Value)-    , newCreatedAt :: !UTCTime-    }-    deriving stock (Generic)--rawAppendNewEncoder :: E.Params RawAppendNewParams-rawAppendNewEncoder =-    ((^. #newStreamName) >$< E.param (E.nonNullable E.text))-        <> ((^. #newEventId) >$< E.param (E.nonNullable E.uuid))-        <> ((^. #newEventType) >$< E.param (E.nonNullable E.text))-        <> ((^. #newCausationId) >$< E.param (E.nullable E.uuid))-        <> ((^. #newCorrelationId) >$< E.param (E.nullable E.uuid))-        <> ((^. #newPayload) >$< E.param (E.nonNullable E.jsonb))-        <> ((^. #newMetadata) >$< E.param (E.nullable E.jsonb))-        <> ((^. #newCreatedAt) >$< E.param (E.nonNullable E.timestamptz))--rawAppendCreateNew :: Statement RawAppendNewParams (Maybe RawAppendResult)-rawAppendCreateNew =-    preparable-        rawAppendCreateNewSQL-        rawAppendNewEncoder-        rawAppendResultDecoder--{- | Create a new stream and append one event. No ON CONFLICT — Haskell-side-validation already proved the stream is absent; a race-loser hits the-ix_streams_stream_name unique constraint and surfaces as a usage error.--}-rawAppendCreateNewSQL :: Text-rawAppendCreateNewSQL =-    """-    WITH-      stream_insert AS (-        INSERT INTO streams (stream_name, stream_version)-        VALUES ($1::text, 1)-        RETURNING stream_id, category, 0::bigint AS initial_version-      ),-      inserted_event AS (-        INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)-        VALUES ($2::uuid, $3::text, $4::uuid, $5::uuid, $6::jsonb, $7::jsonb, $8::timestamptz)-      ),-      source_link AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-        SELECT $2::uuid, si.stream_id, si.initial_version + 1, si.stream_id, si.initial_version + 1-        FROM stream_insert si-      ),-      all_update AS (-        UPDATE streams-        SET stream_version = stream_version + 1-        WHERE stream_id = 0-        RETURNING stream_version - 1 AS initial_global_version-      ),-      all_link AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)-        SELECT $2::uuid, 0, au.initial_global_version + 1, si.stream_id, si.initial_version + 1, si.category-        FROM all_update au-        CROSS JOIN stream_insert si-      )-    SELECT si.stream_id,-           si.initial_version + 1,-           au.initial_global_version + 1-    FROM stream_insert si-    CROSS JOIN all_update au-    """--mkRawAppendParams :: Text -> IO RawAppendParams-mkRawAppendParams name = do-    eventUuid <- oneUuid-    now <- getCurrentTime-    pure-        RawAppendParams-            { eventId = eventUuid-            , eventType = "RawBenchEvent"-            , causationId = Nothing-            , correlationId = Nothing-            , payload = Aeson.object [("benchmark", Aeson.Bool True)]-            , metadata = Nothing-            , createdAt = now-            , streamName = name-            }--mkRawProductionAppendParams :: RawAppendParams -> RawProductionAppendParams-mkRawProductionAppendParams params =-    RawProductionAppendParams-        { productionEventIds = V.singleton (params ^. #eventId)-        , productionEventTypes = V.singleton (params ^. #eventType)-        , productionCausationIds = V.singleton (params ^. #causationId)-        , productionCorrelationIds = V.singleton (params ^. #correlationId)-        , productionPayloads = V.singleton (params ^. #payload)-        , productionMetadatas = V.singleton (params ^. #metadata)-        , productionCreatedAts = V.singleton (params ^. #createdAt)-        , productionStreamName = params ^. #streamName-        }--oneUuid :: IO UUID-oneUuid = do-    generated <- V7.genUUIDs 1-    case generated of-        uuid : _ -> pure uuid-        [] -> error "oneUuid: UUID generator returned no IDs"--runRawScalarAppendAnyVersionNewStream :: KirokuStore -> IORef Int -> IO ()-runRawScalarAppendAnyVersionNewStream store counter = do-    streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))-    params <- mkRawAppendParams ("raw-scalar-new-" <> T.pack (show streamId))-    result <- Pool.use (store ^. #pool) $ Session.statement params rawScalarAppendAnyVersion-    forceRawAppend result--runRawProductionAppendAnyVersionNewStream :: KirokuStore -> IORef Int -> IO ()-runRawProductionAppendAnyVersionNewStream store counter = do-    streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))-    params <- mkRawProductionAppendParams <$> mkRawAppendParams ("raw-production-new-" <> T.pack (show streamId))-    result <- Pool.use (store ^. #pool) $ Session.statement params rawProductionAppendAnyVersion-    forceRawAppend result--runRawScalarAppendAnyVersionHotStream :: KirokuStore -> IO ()-runRawScalarAppendAnyVersionHotStream store = do-    params <- mkRawAppendParams "raw-scalar-hot"-    result <- Pool.use (store ^. #pool) $ Session.statement params rawScalarAppendAnyVersion-    forceRawAppend result--runRawProductionAppendAnyVersionHotStream :: KirokuStore -> IO ()-runRawProductionAppendAnyVersionHotStream store = do-    params <- mkRawProductionAppendParams <$> mkRawAppendParams "raw-production-hot"-    result <- Pool.use (store ^. #pool) $ Session.statement params rawProductionAppendAnyVersion-    forceRawAppend result--{- | Two-round-trip hot-stream variant: resolve stream by name, then UPDATE-the existing row keyed on integer stream_id. Both statements run on the-same pooled connection in a Session, each in its own implicit transaction-(no explicit BEGIN/COMMIT). This mirrors eventstore's small-batch path-(`EventStore.Streams.Stream.append_to_stream/5` at < 1000 events), which-runs two Postgrex.query calls without a transaction wrapper and relies on-the stream_events (stream_id, stream_version) unique constraint to detect-races. Wrapping in an explicit BEGIN/COMMIT adds two extra round-trips-and pessimises this path; that variant is measured separately below.--}-runRawTwoRoundtripAppendExistingHotStream :: KirokuStore -> IO ()-runRawTwoRoundtripAppendExistingHotStream store = do-    base <- mkRawAppendParams "raw-two-roundtrip-hot"-    result <- Pool.use (store ^. #pool) $ do-        mRes <- Session.statement (base ^. #streamName) rawResolveStreamStmt-        case mRes of-            Nothing -> pure (Left "two-roundtrip hot bench: stream not pre-created")-            Just res -> do-                let params = mkRawAppendExistingParams (res ^. #resStreamId) base-                fmap Right (Session.statement params rawAppendUpdateExisting)-    forceTwoRoundtripResult result--{- | Two-round-trip new-stream variant. Same shape as the existing-stream-variant: two implicit-transaction round-trips on a pooled connection,-no explicit transaction wrapper.--}-runRawTwoRoundtripAppendNewStream :: KirokuStore -> IORef Int -> IO ()-runRawTwoRoundtripAppendNewStream store counter = do-    streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))-    base <- mkRawAppendParams ("raw-two-roundtrip-new-" <> T.pack (show streamId))-    result <- Pool.use (store ^. #pool) $ do-        mRes <- Session.statement (base ^. #streamName) rawResolveStreamStmt-        case mRes of-            Just _ -> pure (Left "two-roundtrip new bench: stream already exists")-            Nothing -> do-                let params = mkRawAppendNewParams base-                fmap Right (Session.statement params rawAppendCreateNew)-    forceTwoRoundtripResult result--{- | Two-round-trip hot-stream variant wrapped in an explicit-read-committed transaction. Provided as a separate benchmark to quantify-the BEGIN/COMMIT round-trip overhead so we can decide whether the live-append path can safely drop the transaction wrapper.--}-runRawTwoRoundtripAppendExistingHotStreamTx :: KirokuStore -> IO ()-runRawTwoRoundtripAppendExistingHotStreamTx store = do-    base <- mkRawAppendParams "raw-two-roundtrip-hot"-    let txn = do-            mRes <- Tx.statement (base ^. #streamName) rawResolveStreamStmt-            case mRes of-                Nothing -> pure (Left "two-roundtrip hot tx bench: stream not pre-created")-                Just res -> do-                    let params = mkRawAppendExistingParams (res ^. #resStreamId) base-                    fmap Right (Tx.statement params rawAppendUpdateExisting)-    result <--        Pool.use (store ^. #pool) $-            TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn-    forceTwoRoundtripResult result---- | Two-round-trip new-stream variant wrapped in an explicit transaction.-runRawTwoRoundtripAppendNewStreamTx :: KirokuStore -> IORef Int -> IO ()-runRawTwoRoundtripAppendNewStreamTx store counter = do-    streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))-    base <- mkRawAppendParams ("raw-two-roundtrip-new-tx-" <> T.pack (show streamId))-    let txn = do-            mRes <- Tx.statement (base ^. #streamName) rawResolveStreamStmt-            case mRes of-                Just _ -> pure (Left "two-roundtrip new tx bench: stream already exists")-                Nothing -> do-                    let params = mkRawAppendNewParams base-                    fmap Right (Tx.statement params rawAppendCreateNew)-    result <--        Pool.use (store ^. #pool) $-            TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn-    forceTwoRoundtripResult result--mkRawAppendExistingParams :: Int64 -> RawAppendParams -> RawAppendExistingParams-mkRawAppendExistingParams sid base =-    RawAppendExistingParams-        { existingStreamId = sid-        , existingEventId = base ^. #eventId-        , existingEventType = base ^. #eventType-        , existingCausationId = base ^. #causationId-        , existingCorrelationId = base ^. #correlationId-        , existingPayload = base ^. #payload-        , existingMetadata = base ^. #metadata-        , existingCreatedAt = base ^. #createdAt-        }--mkRawAppendNewParams :: RawAppendParams -> RawAppendNewParams-mkRawAppendNewParams base =-    RawAppendNewParams-        { newStreamName = base ^. #streamName-        , newEventId = base ^. #eventId-        , newEventType = base ^. #eventType-        , newCausationId = base ^. #causationId-        , newCorrelationId = base ^. #correlationId-        , newPayload = base ^. #payload-        , newMetadata = base ^. #metadata-        , newCreatedAt = base ^. #createdAt-        }--forceTwoRoundtripResult ::-    Either Pool.UsageError (Either String (Maybe RawAppendResult)) -> IO ()-forceTwoRoundtripResult (Left e) =-    error ("Two-round-trip bench pool error: " <> show e)-forceTwoRoundtripResult (Right (Left msg)) =-    error ("Two-round-trip bench precondition failed: " <> msg)-forceTwoRoundtripResult (Right (Right inner)) =-    forceRawAppend (Right inner)--{- | Run @writers@ concurrent appenders, each performing @ops@ appends to-its own unique stream. Used by the structured concurrent-writer-benchmarks (EP-6 F19); replaces the wall-clock @mapConcurrently_@-measurement that previously lived inline in @main@.--The @runCounter@ ref is bumped once per call so stream names are unique-across the many iterations tasty-bench runs.--}-runConcurrentWriters :: KirokuStore -> IORef Int -> Int -> Int -> IO ()-runConcurrentWriters store runCounter writers ops = do-    runId <- atomicModifyIORef' runCounter (\m -> (m + 1, m))-    mapConcurrently_-        (\tid -> mapM_ (appendOne tid runId) [1 .. ops])-        [1 .. writers]-  where-    appendOne :: Int -> Int -> Int -> IO ()-    appendOne tid runId i = do-        let sn = StreamName ("conc-" <> T.pack (show runId) <> "-" <> T.pack (show tid) <> "-" <> T.pack (show i))-        r <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "ConcEvent"]-        forceAppend r---- | Exercise the hot stream named in the focused reliability audit.-runHotInvoicePayment :: KirokuStore -> Int -> IO ()-runHotInvoicePayment store ops =-    mapM_-        ( \i -> do-            r <- runStoreIO store $ appendToStream (StreamName "invoice-payment") AnyVersion [makeEvent ("InvoicePayment" <> T.pack (show i))]-            forceAppend r-        )-        [1 .. ops]---- | Exercise appendMultiStream against existing streams.-runAppendMultiStream :: KirokuStore -> IO ()-runAppendMultiStream store = do-    r <--        runStoreIO store $-            appendMultiStream-                [ (StreamName "bench-multi-a", AnyVersion, [makeEvent "MultiA"])-                , (StreamName "bench-multi-b", AnyVersion, [makeEvent "MultiB"])-                , (StreamName "bench-multi-c", AnyVersion, [makeEvent "MultiC"])-                ]-    forceAppendList r---- | Exercise subscription catch-up over a compact category-local backlog.-runSubscriptionCatchup :: KirokuStore -> IORef Int -> IO ()-runSubscriptionCatchup store runCounter = do-    runId <- atomicModifyIORef' runCounter (\m -> (m + 1, m))-    let cat = "benchsub" <> T.pack (show runId)-        sn = StreamName (cat <> "-stream")-        subName = SubscriptionName ("bench-sub-" <> T.pack (show runId))-        events = map (\i -> makeEvent ("SubCatchup" <> T.pack (show i))) [1 .. 100 :: Int]-    r <- runStoreIO store $ appendToStream sn NoStream events-    forceAppend r-    seenRef <- newIORef (0 :: Int)-    let handler _ = do-            n <- atomicModifyIORef' seenRef (\m -> let m' = m + 1 in (m', m'))-            pure $ if n >= 100 then Stop else Continue-        cfg =-            SubscriptionConfig-                { name = subName-                , target = Category (CategoryName cat)-                , handler = handler-                , batchSize = defaultBatchSize-                , queueCapacity = 16-                , overflowPolicy = DropSubscription-                , consumerGroup = Nothing-                , consumerGroupGuard = False-                , missingCheckpointPolicy = FromBeginning-                , targetBindingPolicy = AdoptUnbound-                , undecodableHandler = Nothing-                , handlerStallWarnAfter = Nothing-                , retryPolicy = defaultRetryPolicy-                , eventTypeFilter = AllEventTypes-                , selector = Nothing-                }-    handle <- subscribe store cfg-    result <- wait handle-    case result of-        Right () -> pure ()-        Left e -> error ("Subscription catch-up benchmark failed: " <> show e)---- | Force evaluation of an append result or fail the benchmark.-forceAppend :: Either StoreError AppendResult -> IO ()-forceAppend (Right r) = (r ^. #streamVersion) `seq` (r ^. #globalPosition) `seq` pure ()-forceAppend (Left e) = error ("Benchmark append failed: " <> show e)---- | Force evaluation of multi-stream append results or fail the benchmark.-forceAppendList :: Either StoreError [AppendResult] -> IO ()-forceAppendList (Right rs) = mapM_ (\r -> (r ^. #streamVersion) `seq` (r ^. #globalPosition) `seq` pure ()) rs-forceAppendList (Left e) = error ("Benchmark appendMultiStream failed: " <> show e)---- | Force evaluation of a raw append shape result or fail the benchmark.-forceRawAppend :: Either Pool.UsageError (Maybe RawAppendResult) -> IO ()-forceRawAppend (Right (Just (!streamId, !streamVersion, !globalPosition))) =-    streamId `seq` streamVersion `seq` globalPosition `seq` pure ()-forceRawAppend (Right Nothing) = error "Raw append benchmark produced no result"-forceRawAppend (Left e) = error ("Raw append benchmark failed: " <> show e)---- | Force evaluation of a read result or fail the benchmark.-forceRead :: Either StoreError (V.Vector RecordedEvent) -> IO ()-forceRead (Right v) = V.length v `seq` pure ()-forceRead (Left e) = error ("Benchmark read failed: " <> show e)--seedCheckpointInventoryStmt :: Statement Int32 ()-seedCheckpointInventoryStmt =-    preparable-        """-        INSERT INTO subscriptions (-            subscription_name,-            consumer_group_member,-            last_seen,-            updated_at-        )-        SELECT 'inventory-' || lpad(n::text, 8, '0'),-               0,-               0,-               now()-        FROM generate_series(1, $1::int4) AS n-        """-        (E.param (E.nonNullable E.int4))-        D.noResult--seedCheckpointInventory :: KirokuStore -> Int32 -> IO ()-seedCheckpointInventory store rowCount = do-    result <- Pool.use (store ^. #pool) $ Session.statement rowCount seedCheckpointInventoryStmt-    case result of-        Left err -> error ("Checkpoint inventory benchmark setup failed: " <> show err)-        Right () -> pure ()--forceCheckpointInventory :: Either StoreError SubscriptionCheckpointInventory -> IO ()-forceCheckpointInventory (Left err) = error ("Checkpoint inventory benchmark failed: " <> show err)-forceCheckpointInventory (Right (SubscriptionCheckpointInventory (GlobalPosition storePos) rows)) = do-    let !checksum = V.foldl' forceCheckpoint (fromIntegral storePos) rows-    checksum `seq` pure ()-  where-    forceCheckpoint !acc (SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) updatedAt) =-        updatedAt `seq`-            acc-                + T.length name-                + fromIntegral member-                + fromIntegral position--runCheckpointInventoryBenchmark :: KirokuStore -> IO ()-runCheckpointInventoryBenchmark store =-    runStoreIO store subscriptionCheckpointInventory >>= forceCheckpointInventory--withInventoryBenchmarkStores :: (KirokuStore -> KirokuStore -> IO a) -> IO a-withInventoryBenchmarkStores action =-    withSharedMigratedPostgres $-        withMigratedTestDatabase $ \inventory100Connection ->-            withMigratedTestDatabase $ \inventory10000Connection ->-                withStore (defaultConnectionSettings inventory100Connection) $ \inventory100Store ->-                    withStore (defaultConnectionSettings inventory10000Connection) $ \inventory10000Store -> do-                        seedCheckpointInventory inventory100Store 100-                        seedCheckpointInventory inventory10000Store 10_000-                        action inventory100Store inventory10000Store--{- | A store seeded with 'categoryScalingFixtureSql': categories @performance@-(200 streams), @idle@ (20,000 single-event streams), and @noise@, head at-'categoryScalingHead'. Must run inside 'withSharedMigratedPostgres'.--}-withCategoryScalingStore :: (KirokuStore -> IO a) -> IO a-withCategoryScalingStore action =-    withMigratedTestDatabase $ \connection ->-        withStore (defaultConnectionSettings connection) $ \store -> do-            seeded <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)-            case seeded of-                Left err -> error ("Category-scaling benchmark setup failed: " <> show err)-                Right () -> action store---- | Run the plain category read 10 times at one cursor and limit.-runCategoryPolls :: KirokuStore -> Text -> Int64 -> Int32 -> IO ()-runCategoryPolls store category cursor limit =-    mapM_-        ( \_ ->-            Pool.use (store ^. #pool) (Session.statement (cursor, category, limit) SQL.readCategoryForwardStmt)-                >>= forceCategoryPoll-        )-        [1 .. 10 :: Int]---- | Run the consumer-group category read 10 times for one member.-runGroupCategoryPolls :: KirokuStore -> Text -> Int64 -> Int32 -> Int32 -> Int32 -> IO ()-runGroupCategoryPolls store category cursor member size limit =-    mapM_-        ( \_ ->-            Pool.use (store ^. #pool) (Session.statement (cursor, category, member, size, limit) SQL.readCategoryForwardConsumerGroupStmt)-                >>= forceCategoryPoll-        )-        [1 .. 10 :: Int]--forceCategoryPoll :: Either Pool.UsageError (V.Vector RecordedEvent) -> IO ()-forceCategoryPoll (Right events) = V.length events `seq` pure ()-forceCategoryPoll (Left err) = error ("Category-scaling benchmark read failed: " <> show err)--main :: IO ()-main = do-    -- Start ephemeral PostgreSQL once for all benchmarks-    config <- ephemeralConfig-    result <- Pg.withCachedConfig config Pg.defaultCacheConfig $ \db -> do-        migrateTestDatabase (Pg.connectionString db)-        let settings = defaultConnectionSettings (Pg.connectionString db)-        withStore settings $ \store -> do-            -- Counter for unique stream names across benchmarks-            counter <- newIORef (0 :: Int)-            let nextStream :: Text -> IO StreamName-                nextStream prefix = do-                    n <- atomicModifyIORef' counter (\n -> (n + 1, n))-                    pure (StreamName (prefix <> "-" <> T.pack (show n)))--            -- Pre-populate streams for category benchmarks (B10)-            -- 100 categories × 10 streams × 100 events = 100K events-            putStrLn "\n--- Pre-populating category data (100 cats × 10 streams × 100 events) ---"-            catT0 <- getCurrentTime-            mapM_-                ( \cat -> do-                    mapM_-                        ( \s -> do-                            let sn = StreamName ("cat" <> T.pack (show cat) <> "-" <> T.pack (show s))-                            let evts = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 100 :: Int]-                            r' <- runStoreIO store $ appendToStream sn NoStream evts-                            forceAppend r'-                        )-                        [1 .. 10 :: Int]-                )-                [1 .. 100 :: Int]-            catT1 <- getCurrentTime-            let catElapsed = realToFrac (diffUTCTime catT1 catT0) :: Double-            putStrLn $ "  Setup time: " <> show catElapsed <> "s (100K events)"--            -- Pre-populate streams for read benchmarks-            -- B4: Single stream with 1000 events-            let readStreamName = StreamName "bench-read-stream"-            let readEvents = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 1000 :: Int]-            r <- runStoreIO store $ appendToStream readStreamName NoStream readEvents-            forceAppend r--            -- B5: 10 streams with 100 events each for $all reads (1000 total)-            mapM_-                ( \s -> do-                    let sn = StreamName ("bench-all-" <> T.pack (show s))-                    let evts = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 100 :: Int]-                    r' <- runStoreIO store $ appendToStream sn NoStream evts-                    forceAppend r'-                )-                [1 .. 10 :: Int]--            -- Pre-create fixed streams for appendMultiStream benchmark iterations.-            mapM_-                ( \sn -> do-                    r' <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Init"]-                    forceAppend r'-                )-                [StreamName "bench-multi-a", StreamName "bench-multi-b", StreamName "bench-multi-c"]--            -- Pre-create the hot stream targeted by the two-round-trip raw-            -- shape variant. rawAppendUpdateExisting requires the row to-            -- exist (no ON CONFLICT), so without this warmup the first-            -- iteration would fail. The scalar-singleton and production-            -- variants already create their hot stream on first iteration-            -- via ON CONFLICT — pre-creating them here would change the-            -- existing benchmark contract, so we only seed the two-round-trip-            -- target.-            do-                r' <- runStoreIO store $ appendToStream (StreamName "raw-two-roundtrip-hot") AnyVersion [makeEvent "Init"]-                forceAppend r'--            -- B9: Pool saturation benchmark (64 concurrent writers, 100 appends each)-            putStrLn "\n--- B9: Pool saturation (64 writers × 100 appends, pool size 10) ---"-            satCounter <- newIORef (0 :: Int)-            let nextSatStream :: Int -> Int -> StreamName-                nextSatStream tid i = StreamName ("sat-" <> T.pack (show tid) <> "-" <> T.pack (show i))-            t0 <- getCurrentTime-            mapConcurrently_-                ( \tid -> do-                    mapM_-                        ( \i -> do-                            let sn = nextSatStream tid i-                            r' <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "SatEvent"]-                            forceAppend r'-                            atomicModifyIORef' satCounter (\n -> (n + 1, ()))-                        )-                        [1 .. 100 :: Int]-                )-                [1 .. 64 :: Int]-            t1 <- getCurrentTime-            totalOps <- readIORef satCounter-            let elapsed = realToFrac (diffUTCTime t1 t0) :: Double-            let throughput = fromIntegral totalOps / elapsed-            let avgLatency = elapsed / fromIntegral totalOps * 1000 -- ms-            putStrLn $ "  Total appends: " <> show totalOps-            putStrLn $ "  Elapsed: " <> show elapsed <> "s"-            putStrLn $ "  Throughput: " <> show (round throughput :: Int) <> " ops/s"-            putStrLn $ "  Avg latency: " <> show avgLatency <> " ms"-            putStrLn "---"--            -- Counter shared by the concurrent-writer benchmarks so each-            -- iteration uses a fresh stream-name run-id.-            concCounter <- newIORef (0 :: Int)-            rawCounter <- newIORef (0 :: Int)-            subCounter <- newIORef (0 :: Int)--            withInventoryBenchmarkStores $ \inventory100Store inventory10000Store ->-                withCategoryScalingStore $ \scalingStore ->-                    defaultMain-                        [ bgroup-                            "append"-                            [ bgroup-                                "single-event"-                                [ bench "NoStream (new stream)" $ whnfIO $ do-                                    sn <- nextStream "bench-single"-                                    r' <- runStoreIO store $ appendToStream sn NoStream [makeEvent "BenchEvent"]-                                    forceAppend r'-                                , bench "AnyVersion (new stream)" $ whnfIO $ do-                                    sn <- nextStream "bench-any"-                                    r' <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "BenchEvent"]-                                    forceAppend r'-                                ]-                            , bgroup-                                "batch-10"-                                [ bench "NoStream" $ whnfIO $ do-                                    sn <- nextStream "bench-b10"-                                    let events = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 10 :: Int]-                                    r' <- runStoreIO store $ appendToStream sn NoStream events-                                    forceAppend r'-                                ]-                            , bgroup-                                "batch-100"-                                [ bench "NoStream" $ whnfIO $ do-                                    sn <- nextStream "bench-b100"-                                    let events = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 100 :: Int]-                                    r' <- runStoreIO store $ appendToStream sn NoStream events-                                    forceAppend r'-                                ]-                            , bgroup-                                "sequential"-                                [ bench "10 appends to same stream" $ whnfIO $ do-                                    sn <- nextStream "bench-seq"-                                    r0 <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Init"]-                                    forceAppend r0-                                    let res0 = case r0 of-                                            Right ok -> ok-                                            Left e -> error ("Sequential append failed: " <> show e)-                                    let go _ 0 = pure ()-                                        go v n = do-                                            r' <- runStoreIO store $ appendToStream sn (ExactVersion v) [makeEvent "Seq"]-                                            case r' of-                                                Right res -> go (res ^. #streamVersion) (n - 1 :: Int)-                                                Left e -> error ("Sequential append failed: " <> show e)-                                    go (res0 ^. #streamVersion) 9-                                ]-                            ]-                        , bgroup-                            "raw-append-shape"-                            [ bgroup-                                "AnyVersion"-                                [ bench "scalar singleton (new stream)" $-                                    whnfIO $-                                        runRawScalarAppendAnyVersionNewStream store rawCounter-                                , bench "production arrays/unnest (new stream)" $-                                    whnfIO $-                                        runRawProductionAppendAnyVersionNewStream store rawCounter-                                , bench "two-roundtrip (new stream)" $-                                    whnfIO $-                                        runRawTwoRoundtripAppendNewStream store rawCounter-                                , bench "two-roundtrip + BEGIN/COMMIT (new stream)" $-                                    whnfIO $-                                        runRawTwoRoundtripAppendNewStreamTx store rawCounter-                                , bench "scalar singleton (hot stream)" $-                                    whnfIO $-                                        runRawScalarAppendAnyVersionHotStream store-                                , bench "production arrays/unnest (hot stream)" $-                                    whnfIO $-                                        runRawProductionAppendAnyVersionHotStream store-                                , bench "two-roundtrip (hot stream)" $-                                    whnfIO $-                                        runRawTwoRoundtripAppendExistingHotStream store-                                , bench "two-roundtrip + BEGIN/COMMIT (hot stream)" $-                                    whnfIO $-                                        runRawTwoRoundtripAppendExistingHotStreamTx store-                                ]-                            ]-                        , bgroup-                            "read"-                            [ bench "stream forward (100-event page)" $ whnfIO $ do-                                r' <- runStoreIO store $ readStreamForward readStreamName (StreamVersion 0) 100-                                forceRead r'-                            , bench "$all forward (100-event page)" $ whnfIO $ do-                                r' <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-                                forceRead r'-                            ]-                        , bgroup-                            "category"-                            [ bench "category forward (100-event page)" $ whnfIO $ do-                                -- Read from cat1 category (has 10 streams × 100 events = 1000 events)-                                r' <- runStoreIO store $ readCategory (CategoryName "cat1") (GlobalPosition 0) 100-                                forceRead r'-                            , bench "exhausted-category" $ whnfIO $ do-                                -- cat1 events are inserted early in setup; a high cursor proves-                                -- category reads do not scan the rest of $all looking for matches.-                                r' <- runStoreIO store $ readCategory (CategoryName "cat1") (GlobalPosition 90_000) 100-                                forceRead r'-                            , bench "$all forward (100-event page baseline)" $ whnfIO $ do-                                r' <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-                                forceRead r'-                            ]-                        , -- F19 — Concurrent-writer stress as structured benchmarks.-                          -- The legacy ad-hoc B9 measurement (still present above-                          -- for historical comparability) prints throughput and-                          -- latency once; these bgroup entries surface the same-                          -- workload through tasty-bench so it participates in the-                          -- baseline-regression workflow (Justfile bench-regression).-                          bgroup-                            "concurrent"-                            [ bench "8 writers x 10 appends" $ whnfIO $ runConcurrentWriters store concCounter 8 10-                            , bench "32 writers x 10 appends" $ whnfIO $ runConcurrentWriters store concCounter 32 10-                            ]-                        , bgroup-                            "reliability-audit"-                            [ bench "hot invoice-payment 10 AnyVersion appends" $ whnfIO $ runHotInvoicePayment store 10-                            , bench "appendMultiStream 3 existing streams" $ whnfIO $ runAppendMultiStream store-                            , bench "subscription category catch-up 100 events" $ whnfIO $ runSubscriptionCatchup store subCounter-                            ]-                        , -- BUG-2: a caught-up category poll must not cost work-                          -- proportional to the number of streams in the category.-                          -- Each cell runs the statement 10 times.-                          bgroup-                            "category-scaling"-                            [ bench "plain caught-up poll (200 streams)" $-                                whnfIO $-                                    runCategoryPolls scalingStore "performance" categoryScalingHead 100-                            , bench "plain caught-up poll (20000 streams)" $-                                whnfIO $-                                    runCategoryPolls scalingStore "idle" categoryScalingHead 100-                            , bench "group caught-up poll (20000 streams)" $-                                whnfIO $-                                    runGroupCategoryPolls scalingStore "idle" categoryScalingHead 1 2 100-                            , bench "plain page from 0 (20000 streams)" $-                                whnfIO $-                                    runCategoryPolls scalingStore "idle" 0 100-                            , bench "exhausted category (200 streams)" $-                                -- performance ends at 20,000; 60,000 other rows follow.-                                whnfIO $-                                    runCategoryPolls scalingStore "performance" 20_000 100-                            ]-                        , bgroup-                            "subscription-checkpoint-inventory"-                            [ bench "100 rows" $ whnfIO $ runCheckpointInventoryBenchmark inventory100Store-                            , bench "10000 rows" $ whnfIO $ runCheckpointInventoryBenchmark inventory10000Store-                            ]-                        ]-    case result of-        Left err -> error ("Failed to start ephemeral PostgreSQL: " <> show err)-        Right () -> pure ()--makeEvent :: Text -> EventData-makeEvent typ =-    EventData-        { eventId = Nothing-        , eventType = EventType typ-        , payload = Aeson.object [("benchmark", Aeson.Bool True)]-        , metadata = Nothing-        , causationId = Nothing-        , correlationId = Nothing-        }+  { eventId :: !UUID,+    eventType :: !Text,+    causationId :: !(Maybe UUID),+    correlationId :: !(Maybe UUID),+    payload :: !Aeson.Value,+    metadata :: !(Maybe Aeson.Value),+    createdAt :: !UTCTime,+    streamName :: !Text+  }+  deriving stock (Generic)++data RawProductionAppendParams = RawProductionAppendParams+  { productionEventIds :: !(V.Vector UUID),+    productionEventTypes :: !(V.Vector Text),+    productionCausationIds :: !(V.Vector (Maybe UUID)),+    productionCorrelationIds :: !(V.Vector (Maybe UUID)),+    productionPayloads :: !(V.Vector Aeson.Value),+    productionMetadatas :: !(V.Vector (Maybe Aeson.Value)),+    productionCreatedAts :: !(V.Vector UTCTime),+    productionStreamName :: !Text+  }+  deriving stock (Generic)++type RawAppendResult = (Int64, Int64, Int64)++rawAppendParamsEncoder :: E.Params RawAppendParams+rawAppendParamsEncoder =+  ((^. #eventId) >$< E.param (E.nonNullable E.uuid))+    <> ((^. #eventType) >$< E.param (E.nonNullable E.text))+    <> ((^. #causationId) >$< E.param (E.nullable E.uuid))+    <> ((^. #correlationId) >$< E.param (E.nullable E.uuid))+    <> ((^. #payload) >$< E.param (E.nonNullable E.jsonb))+    <> ((^. #metadata) >$< E.param (E.nullable E.jsonb))+    <> ((^. #createdAt) >$< E.param (E.nonNullable E.timestamptz))+    <> ((^. #streamName) >$< E.param (E.nonNullable E.text))++rawProductionAppendParamsEncoder :: E.Params RawProductionAppendParams+rawProductionAppendParamsEncoder =+  ((^. #productionEventIds) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    <> ((^. #productionEventTypes) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+    <> ((^. #productionCausationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))+    <> ((^. #productionCorrelationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))+    <> ((^. #productionPayloads) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.jsonb))))+    <> ((^. #productionMetadatas) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.jsonb))))+    <> ((^. #productionCreatedAts) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.timestamptz))))+    <> ((^. #productionStreamName) >$< E.param (E.nonNullable E.text))++rawAppendResultDecoder :: D.Result (Maybe RawAppendResult)+rawAppendResultDecoder =+  D.rowMaybe $+    (,,)+      <$> D.column (D.nonNullable D.int8)+      <*> D.column (D.nonNullable D.int8)+      <*> D.column (D.nonNullable D.int8)++rawScalarAppendAnyVersion :: Statement RawAppendParams (Maybe RawAppendResult)+rawScalarAppendAnyVersion =+  preparable+    rawScalarAppendAnyVersionSQL+    rawAppendParamsEncoder+    rawAppendResultDecoder++rawProductionAppendAnyVersion :: Statement RawProductionAppendParams (Maybe RawAppendResult)+rawProductionAppendAnyVersion =+  preparable+    rawProductionAppendAnyVersionSQL+    rawProductionAppendParamsEncoder+    rawAppendResultDecoder++rawScalarAppendAnyVersionSQL :: Text+rawScalarAppendAnyVersionSQL =+  """+  WITH+    new_event AS (+      SELECT $1::uuid AS event_id,+             $2::text AS event_type,+             $3::uuid AS causation_id,+             $4::uuid AS correlation_id,+             $5::jsonb AS data,+             $6::jsonb AS metadata,+             $7::timestamptz AS created_at+    ),+    stream_upsert AS (+      INSERT INTO streams (stream_name, stream_version)+      VALUES ($8, 1)+      ON CONFLICT (stream_name)+      DO UPDATE SET stream_version = streams.stream_version + 1+        WHERE streams.deleted_at IS NULL+      RETURNING stream_id, category, stream_version - 1 AS initial_version+    ),+    inserted_events AS (+      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+      SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+      FROM new_event+      WHERE EXISTS (SELECT 1 FROM stream_upsert)+    ),+    source_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT ne.event_id, su.stream_id, su.initial_version + 1, su.stream_id, su.initial_version + 1+      FROM new_event ne+      CROSS JOIN stream_upsert su+    ),+    all_update AS (+      UPDATE streams+      SET stream_version = stream_version + 1+      WHERE stream_id = 0+        AND EXISTS (SELECT 1 FROM stream_upsert)+      RETURNING stream_version - 1 AS initial_global_version+    ),+    all_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+      SELECT ne.event_id, 0, au.initial_global_version + 1, su.stream_id, su.initial_version + 1, su.category+      FROM new_event ne+      CROSS JOIN all_update au+      CROSS JOIN stream_upsert su+    )+  SELECT su.stream_id,+         su.initial_version + 1,+         au.initial_global_version + 1+  FROM stream_upsert su+  CROSS JOIN all_update au+  """++rawProductionAppendAnyVersionSQL :: Text+rawProductionAppendAnyVersionSQL =+  """+  WITH+    new_events AS (+      SELECT *+      FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+      WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+    ),+    stream_upsert AS (+      INSERT INTO streams (stream_name, stream_version)+      VALUES ($8, (SELECT count(*) FROM new_events))+      ON CONFLICT (stream_name)+      DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)+        WHERE streams.deleted_at IS NULL+      RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version+    ),+    inserted_events AS (+      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+      SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+      FROM new_events+      WHERE EXISTS (SELECT 1 FROM stream_upsert)+      ORDER BY idx+    ),+    source_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+      FROM new_events ne+      CROSS JOIN stream_upsert su+    ),+    all_update AS (+      UPDATE streams+      SET stream_version = stream_version + (SELECT count(*) FROM new_events)+      WHERE stream_id = 0+        AND EXISTS (SELECT 1 FROM stream_upsert)+      RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+    ),+    all_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+      SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+      FROM new_events ne+      CROSS JOIN all_update au+      CROSS JOIN stream_upsert su+    )+  SELECT su.stream_id,+         su.initial_version + (SELECT count(*) FROM new_events),+         au.initial_global_version + (SELECT count(*) FROM new_events)+  FROM stream_upsert su+  CROSS JOIN all_update au+  """++-- Two-round-trip variants: the proof-of-concept that the plan-23 restructure+-- is structurally faster than the production arrays/unnest CTE. The resolve+-- query reads (stream_id, stream_version, deleted_at) by stream_name; one of+-- two append CTEs then runs keyed on the resolved stream_id (existing) or on+-- the stream_name with no ON CONFLICT (new).++data RawResolution = RawResolution+  { resStreamId :: !Int64,+    resStreamVersion :: !Int64,+    resDeletedAt :: !(Maybe UTCTime)+  }+  deriving stock (Generic)++rawResolveStreamStmt :: Statement Text (Maybe RawResolution)+rawResolveStreamStmt =+  preparable+    rawResolveStreamSQL+    (E.param (E.nonNullable E.text))+    ( D.rowMaybe $+        RawResolution+          <$> D.column (D.nonNullable D.int8)+          <*> D.column (D.nonNullable D.int8)+          <*> D.column (D.nullable D.timestamptz)+    )++rawResolveStreamSQL :: Text+rawResolveStreamSQL =+  """+  SELECT stream_id, stream_version, deleted_at+  FROM streams+  WHERE stream_name = $1+  """++-- | Append params for the two-round-trip "existing" path: stream_id is known.+data RawAppendExistingParams = RawAppendExistingParams+  { existingStreamId :: !Int64,+    existingEventId :: !UUID,+    existingEventType :: !Text,+    existingCausationId :: !(Maybe UUID),+    existingCorrelationId :: !(Maybe UUID),+    existingPayload :: !Aeson.Value,+    existingMetadata :: !(Maybe Aeson.Value),+    existingCreatedAt :: !UTCTime+  }+  deriving stock (Generic)++rawAppendExistingEncoder :: E.Params RawAppendExistingParams+rawAppendExistingEncoder =+  ((^. #existingStreamId) >$< E.param (E.nonNullable E.int8))+    <> ((^. #existingEventId) >$< E.param (E.nonNullable E.uuid))+    <> ((^. #existingEventType) >$< E.param (E.nonNullable E.text))+    <> ((^. #existingCausationId) >$< E.param (E.nullable E.uuid))+    <> ((^. #existingCorrelationId) >$< E.param (E.nullable E.uuid))+    <> ((^. #existingPayload) >$< E.param (E.nonNullable E.jsonb))+    <> ((^. #existingMetadata) >$< E.param (E.nullable E.jsonb))+    <> ((^. #existingCreatedAt) >$< E.param (E.nonNullable E.timestamptz))++rawAppendUpdateExisting :: Statement RawAppendExistingParams (Maybe RawAppendResult)+rawAppendUpdateExisting =+  preparable+    rawAppendUpdateExistingSQL+    rawAppendExistingEncoder+    rawAppendResultDecoder++-- | Update an existing stream keyed on integer stream_id. No version check,+-- no soft-delete check, no EXISTS gating, no count(*) — Haskell-side+-- validation has already done all of that.+rawAppendUpdateExistingSQL :: Text+rawAppendUpdateExistingSQL =+  """+  WITH+    stream_update AS (+      UPDATE streams+      SET stream_version = stream_version + 1+      WHERE stream_id = $1::bigint+      RETURNING stream_id, category, stream_version - 1 AS initial_version+    ),+    inserted_event AS (+      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+      VALUES ($2::uuid, $3::text, $4::uuid, $5::uuid, $6::jsonb, $7::jsonb, $8::timestamptz)+    ),+    source_link AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT $2::uuid, su.stream_id, su.initial_version + 1, su.stream_id, su.initial_version + 1+      FROM stream_update su+    ),+    all_update AS (+      UPDATE streams+      SET stream_version = stream_version + 1+      WHERE stream_id = 0+      RETURNING stream_version - 1 AS initial_global_version+    ),+    all_link AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+      SELECT $2::uuid, 0, au.initial_global_version + 1, su.stream_id, su.initial_version + 1, su.category+      FROM all_update au+      CROSS JOIN stream_update su+    )+  SELECT su.stream_id,+         su.initial_version + 1,+         au.initial_global_version + 1+  FROM stream_update su+  CROSS JOIN all_update au+  """++-- | Append params for the two-round-trip "new stream" path: stream_name only.+data RawAppendNewParams = RawAppendNewParams+  { newStreamName :: !Text,+    newEventId :: !UUID,+    newEventType :: !Text,+    newCausationId :: !(Maybe UUID),+    newCorrelationId :: !(Maybe UUID),+    newPayload :: !Aeson.Value,+    newMetadata :: !(Maybe Aeson.Value),+    newCreatedAt :: !UTCTime+  }+  deriving stock (Generic)++rawAppendNewEncoder :: E.Params RawAppendNewParams+rawAppendNewEncoder =+  ((^. #newStreamName) >$< E.param (E.nonNullable E.text))+    <> ((^. #newEventId) >$< E.param (E.nonNullable E.uuid))+    <> ((^. #newEventType) >$< E.param (E.nonNullable E.text))+    <> ((^. #newCausationId) >$< E.param (E.nullable E.uuid))+    <> ((^. #newCorrelationId) >$< E.param (E.nullable E.uuid))+    <> ((^. #newPayload) >$< E.param (E.nonNullable E.jsonb))+    <> ((^. #newMetadata) >$< E.param (E.nullable E.jsonb))+    <> ((^. #newCreatedAt) >$< E.param (E.nonNullable E.timestamptz))++rawAppendCreateNew :: Statement RawAppendNewParams (Maybe RawAppendResult)+rawAppendCreateNew =+  preparable+    rawAppendCreateNewSQL+    rawAppendNewEncoder+    rawAppendResultDecoder++-- | Create a new stream and append one event. No ON CONFLICT — Haskell-side+-- validation already proved the stream is absent; a race-loser hits the+-- ix_streams_stream_name unique constraint and surfaces as a usage error.+rawAppendCreateNewSQL :: Text+rawAppendCreateNewSQL =+  """+  WITH+    stream_insert AS (+      INSERT INTO streams (stream_name, stream_version)+      VALUES ($1::text, 1)+      RETURNING stream_id, category, 0::bigint AS initial_version+    ),+    inserted_event AS (+      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+      VALUES ($2::uuid, $3::text, $4::uuid, $5::uuid, $6::jsonb, $7::jsonb, $8::timestamptz)+    ),+    source_link AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT $2::uuid, si.stream_id, si.initial_version + 1, si.stream_id, si.initial_version + 1+      FROM stream_insert si+    ),+    all_update AS (+      UPDATE streams+      SET stream_version = stream_version + 1+      WHERE stream_id = 0+      RETURNING stream_version - 1 AS initial_global_version+    ),+    all_link AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+      SELECT $2::uuid, 0, au.initial_global_version + 1, si.stream_id, si.initial_version + 1, si.category+      FROM all_update au+      CROSS JOIN stream_insert si+    )+  SELECT si.stream_id,+         si.initial_version + 1,+         au.initial_global_version + 1+  FROM stream_insert si+  CROSS JOIN all_update au+  """++mkRawAppendParams :: Text -> IO RawAppendParams+mkRawAppendParams name = do+  eventUuid <- oneUuid+  now <- getCurrentTime+  pure+    RawAppendParams+      { eventId = eventUuid,+        eventType = "RawBenchEvent",+        causationId = Nothing,+        correlationId = Nothing,+        payload = Aeson.object [("benchmark", Aeson.Bool True)],+        metadata = Nothing,+        createdAt = now,+        streamName = name+      }++mkRawProductionAppendParams :: RawAppendParams -> RawProductionAppendParams+mkRawProductionAppendParams params =+  RawProductionAppendParams+    { productionEventIds = V.singleton (params ^. #eventId),+      productionEventTypes = V.singleton (params ^. #eventType),+      productionCausationIds = V.singleton (params ^. #causationId),+      productionCorrelationIds = V.singleton (params ^. #correlationId),+      productionPayloads = V.singleton (params ^. #payload),+      productionMetadatas = V.singleton (params ^. #metadata),+      productionCreatedAts = V.singleton (params ^. #createdAt),+      productionStreamName = params ^. #streamName+    }++oneUuid :: IO UUID+oneUuid = do+  generated <- V7.genUUIDs 1+  case generated of+    uuid : _ -> pure uuid+    [] -> error "oneUuid: UUID generator returned no IDs"++runRawScalarAppendAnyVersionNewStream :: KirokuStore -> IORef Int -> IO ()+runRawScalarAppendAnyVersionNewStream store counter = do+  streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))+  params <- mkRawAppendParams ("raw-scalar-new-" <> T.pack (show streamId))+  result <- Pool.use (store ^. #pool) $ Session.statement params rawScalarAppendAnyVersion+  forceRawAppend result++runRawProductionAppendAnyVersionNewStream :: KirokuStore -> IORef Int -> IO ()+runRawProductionAppendAnyVersionNewStream store counter = do+  streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))+  params <- mkRawProductionAppendParams <$> mkRawAppendParams ("raw-production-new-" <> T.pack (show streamId))+  result <- Pool.use (store ^. #pool) $ Session.statement params rawProductionAppendAnyVersion+  forceRawAppend result++runRawScalarAppendAnyVersionHotStream :: KirokuStore -> IO ()+runRawScalarAppendAnyVersionHotStream store = do+  params <- mkRawAppendParams "raw-scalar-hot"+  result <- Pool.use (store ^. #pool) $ Session.statement params rawScalarAppendAnyVersion+  forceRawAppend result++runRawProductionAppendAnyVersionHotStream :: KirokuStore -> IO ()+runRawProductionAppendAnyVersionHotStream store = do+  params <- mkRawProductionAppendParams <$> mkRawAppendParams "raw-production-hot"+  result <- Pool.use (store ^. #pool) $ Session.statement params rawProductionAppendAnyVersion+  forceRawAppend result++-- | Two-round-trip hot-stream variant: resolve stream by name, then UPDATE+-- the existing row keyed on integer stream_id. Both statements run on the+-- same pooled connection in a Session, each in its own implicit transaction+-- (no explicit BEGIN/COMMIT). This mirrors eventstore's small-batch path+-- (`EventStore.Streams.Stream.append_to_stream/5` at < 1000 events), which+-- runs two Postgrex.query calls without a transaction wrapper and relies on+-- the stream_events (stream_id, stream_version) unique constraint to detect+-- races. Wrapping in an explicit BEGIN/COMMIT adds two extra round-trips+-- and pessimises this path; that variant is measured separately below.+runRawTwoRoundtripAppendExistingHotStream :: KirokuStore -> IO ()+runRawTwoRoundtripAppendExistingHotStream store = do+  base <- mkRawAppendParams "raw-two-roundtrip-hot"+  result <- Pool.use (store ^. #pool) $ do+    mRes <- Session.statement (base ^. #streamName) rawResolveStreamStmt+    case mRes of+      Nothing -> pure (Left "two-roundtrip hot bench: stream not pre-created")+      Just res -> do+        let params = mkRawAppendExistingParams (res ^. #resStreamId) base+        fmap Right (Session.statement params rawAppendUpdateExisting)+  forceTwoRoundtripResult result++-- | Two-round-trip new-stream variant. Same shape as the existing-stream+-- variant: two implicit-transaction round-trips on a pooled connection,+-- no explicit transaction wrapper.+runRawTwoRoundtripAppendNewStream :: KirokuStore -> IORef Int -> IO ()+runRawTwoRoundtripAppendNewStream store counter = do+  streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))+  base <- mkRawAppendParams ("raw-two-roundtrip-new-" <> T.pack (show streamId))+  result <- Pool.use (store ^. #pool) $ do+    mRes <- Session.statement (base ^. #streamName) rawResolveStreamStmt+    case mRes of+      Just _ -> pure (Left "two-roundtrip new bench: stream already exists")+      Nothing -> do+        let params = mkRawAppendNewParams base+        fmap Right (Session.statement params rawAppendCreateNew)+  forceTwoRoundtripResult result++-- | Two-round-trip hot-stream variant wrapped in an explicit+-- read-committed transaction. Provided as a separate benchmark to quantify+-- the BEGIN/COMMIT round-trip overhead so we can decide whether the live+-- append path can safely drop the transaction wrapper.+runRawTwoRoundtripAppendExistingHotStreamTx :: KirokuStore -> IO ()+runRawTwoRoundtripAppendExistingHotStreamTx store = do+  base <- mkRawAppendParams "raw-two-roundtrip-hot"+  let txn = do+        mRes <- Tx.statement (base ^. #streamName) rawResolveStreamStmt+        case mRes of+          Nothing -> pure (Left "two-roundtrip hot tx bench: stream not pre-created")+          Just res -> do+            let params = mkRawAppendExistingParams (res ^. #resStreamId) base+            fmap Right (Tx.statement params rawAppendUpdateExisting)+  result <-+    Pool.use (store ^. #pool) $+      TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn+  forceTwoRoundtripResult result++-- | Two-round-trip new-stream variant wrapped in an explicit transaction.+runRawTwoRoundtripAppendNewStreamTx :: KirokuStore -> IORef Int -> IO ()+runRawTwoRoundtripAppendNewStreamTx store counter = do+  streamId <- atomicModifyIORef' counter (\n -> (n + 1, n))+  base <- mkRawAppendParams ("raw-two-roundtrip-new-tx-" <> T.pack (show streamId))+  let txn = do+        mRes <- Tx.statement (base ^. #streamName) rawResolveStreamStmt+        case mRes of+          Just _ -> pure (Left "two-roundtrip new tx bench: stream already exists")+          Nothing -> do+            let params = mkRawAppendNewParams base+            fmap Right (Tx.statement params rawAppendCreateNew)+  result <-+    Pool.use (store ^. #pool) $+      TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn+  forceTwoRoundtripResult result++mkRawAppendExistingParams :: Int64 -> RawAppendParams -> RawAppendExistingParams+mkRawAppendExistingParams sid base =+  RawAppendExistingParams+    { existingStreamId = sid,+      existingEventId = base ^. #eventId,+      existingEventType = base ^. #eventType,+      existingCausationId = base ^. #causationId,+      existingCorrelationId = base ^. #correlationId,+      existingPayload = base ^. #payload,+      existingMetadata = base ^. #metadata,+      existingCreatedAt = base ^. #createdAt+    }++mkRawAppendNewParams :: RawAppendParams -> RawAppendNewParams+mkRawAppendNewParams base =+  RawAppendNewParams+    { newStreamName = base ^. #streamName,+      newEventId = base ^. #eventId,+      newEventType = base ^. #eventType,+      newCausationId = base ^. #causationId,+      newCorrelationId = base ^. #correlationId,+      newPayload = base ^. #payload,+      newMetadata = base ^. #metadata,+      newCreatedAt = base ^. #createdAt+    }++forceTwoRoundtripResult ::+  Either Pool.UsageError (Either String (Maybe RawAppendResult)) -> IO ()+forceTwoRoundtripResult (Left e) =+  error ("Two-round-trip bench pool error: " <> show e)+forceTwoRoundtripResult (Right (Left msg)) =+  error ("Two-round-trip bench precondition failed: " <> msg)+forceTwoRoundtripResult (Right (Right inner)) =+  forceRawAppend (Right inner)++-- | Run @writers@ concurrent appenders, each performing @ops@ appends to+-- its own unique stream. Used by the structured concurrent-writer+-- benchmarks (EP-6 F19); replaces the wall-clock @mapConcurrently_@+-- measurement that previously lived inline in @main@.+--+-- The @runCounter@ ref is bumped once per call so stream names are unique+-- across the many iterations tasty-bench runs.+runConcurrentWriters :: KirokuStore -> IORef Int -> Int -> Int -> IO ()+runConcurrentWriters store runCounter writers ops = do+  runId <- atomicModifyIORef' runCounter (\m -> (m + 1, m))+  mapConcurrently_+    (\tid -> mapM_ (appendOne tid runId) [1 .. ops])+    [1 .. writers]+  where+    appendOne :: Int -> Int -> Int -> IO ()+    appendOne tid runId i = do+      let sn = StreamName ("conc-" <> T.pack (show runId) <> "-" <> T.pack (show tid) <> "-" <> T.pack (show i))+      r <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "ConcEvent"]+      forceAppend r++-- | Exercise the hot stream named in the focused reliability audit.+runHotInvoicePayment :: KirokuStore -> Int -> IO ()+runHotInvoicePayment store ops =+  mapM_+    ( \i -> do+        r <- runStoreIO store $ appendToStream (StreamName "invoice-payment") AnyVersion [makeEvent ("InvoicePayment" <> T.pack (show i))]+        forceAppend r+    )+    [1 .. ops]++-- | Exercise appendMultiStream against existing streams.+runAppendMultiStream :: KirokuStore -> IO ()+runAppendMultiStream store = do+  r <-+    runStoreIO store $+      appendMultiStream+        [ (StreamName "bench-multi-a", AnyVersion, [makeEvent "MultiA"]),+          (StreamName "bench-multi-b", AnyVersion, [makeEvent "MultiB"]),+          (StreamName "bench-multi-c", AnyVersion, [makeEvent "MultiC"])+        ]+  forceAppendList r++-- | Exercise subscription catch-up over a compact category-local backlog.+runSubscriptionCatchup :: KirokuStore -> IORef Int -> IO ()+runSubscriptionCatchup store runCounter = do+  runId <- atomicModifyIORef' runCounter (\m -> (m + 1, m))+  let cat = "benchsub" <> T.pack (show runId)+      sn = StreamName (cat <> "-stream")+      subName = SubscriptionName ("bench-sub-" <> T.pack (show runId))+      events = map (\i -> makeEvent ("SubCatchup" <> T.pack (show i))) [1 .. 100 :: Int]+  r <- runStoreIO store $ appendToStream sn NoStream events+  forceAppend r+  seenRef <- newIORef (0 :: Int)+  let handler _ = do+        n <- atomicModifyIORef' seenRef (\m -> let m' = m + 1 in (m', m'))+        pure $ if n >= 100 then Stop else Continue+      cfg =+        SubscriptionConfig+          { name = subName,+            target = Category (CategoryName cat),+            handler = handler,+            batchSize = defaultBatchSize,+            queueCapacity = 16,+            overflowPolicy = DropSubscription,+            consumerGroup = Nothing,+            consumerGroupGuard = False,+            missingCheckpointPolicy = FromBeginning,+            targetBindingPolicy = AdoptUnbound,+            undecodableHandler = Nothing,+            handlerStallWarnAfter = Nothing,+            retryPolicy = defaultRetryPolicy,+            eventTypeFilter = AllEventTypes,+            selector = Nothing+          }+  handle <- subscribe store cfg+  result <- wait handle+  case result of+    Right () -> pure ()+    Left e -> error ("Subscription catch-up benchmark failed: " <> show e)++-- | Force evaluation of an append result or fail the benchmark.+forceAppend :: Either StoreError AppendResult -> IO ()+forceAppend (Right r) = (r ^. #streamVersion) `seq` (r ^. #globalPosition) `seq` pure ()+forceAppend (Left e) = error ("Benchmark append failed: " <> show e)++-- | Force evaluation of multi-stream append results or fail the benchmark.+forceAppendList :: Either StoreError [AppendResult] -> IO ()+forceAppendList (Right rs) = mapM_ (\r -> (r ^. #streamVersion) `seq` (r ^. #globalPosition) `seq` pure ()) rs+forceAppendList (Left e) = error ("Benchmark appendMultiStream failed: " <> show e)++-- | Force evaluation of a raw append shape result or fail the benchmark.+forceRawAppend :: Either Pool.UsageError (Maybe RawAppendResult) -> IO ()+forceRawAppend (Right (Just (!streamId, !streamVersion, !globalPosition))) =+  streamId `seq` streamVersion `seq` globalPosition `seq` pure ()+forceRawAppend (Right Nothing) = error "Raw append benchmark produced no result"+forceRawAppend (Left e) = error ("Raw append benchmark failed: " <> show e)++-- | Force evaluation of a read result or fail the benchmark.+forceRead :: Either StoreError (V.Vector RecordedEvent) -> IO ()+forceRead (Right v) = V.length v `seq` pure ()+forceRead (Left e) = error ("Benchmark read failed: " <> show e)++seedCheckpointInventoryStmt :: Statement Int32 ()+seedCheckpointInventoryStmt =+  preparable+    """+    INSERT INTO subscriptions (+        subscription_name,+        consumer_group_member,+        last_seen,+        updated_at+    )+    SELECT 'inventory-' || lpad(n::text, 8, '0'),+           0,+           0,+           now()+    FROM generate_series(1, $1::int4) AS n+    """+    (E.param (E.nonNullable E.int4))+    D.noResult++seedCheckpointInventory :: KirokuStore -> Int32 -> IO ()+seedCheckpointInventory store rowCount = do+  result <- Pool.use (store ^. #pool) $ Session.statement rowCount seedCheckpointInventoryStmt+  case result of+    Left err -> error ("Checkpoint inventory benchmark setup failed: " <> show err)+    Right () -> pure ()++forceCheckpointInventory :: Either StoreError SubscriptionCheckpointInventory -> IO ()+forceCheckpointInventory (Left err) = error ("Checkpoint inventory benchmark failed: " <> show err)+forceCheckpointInventory (Right (SubscriptionCheckpointInventory (GlobalPosition storePos) rows)) = do+  let !checksum = V.foldl' forceCheckpoint (fromIntegral storePos) rows+  checksum `seq` pure ()+  where+    forceCheckpoint !acc (SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) updatedAt) =+      updatedAt `seq`+        acc+          + T.length name+          + fromIntegral member+          + fromIntegral position++runCheckpointInventoryBenchmark :: KirokuStore -> IO ()+runCheckpointInventoryBenchmark store =+  runStoreIO store subscriptionCheckpointInventory >>= forceCheckpointInventory++withInventoryBenchmarkStores :: (KirokuStore -> KirokuStore -> IO a) -> IO a+withInventoryBenchmarkStores action =+  withSharedMigratedPostgres $+    withMigratedTestDatabase $ \inventory100Connection ->+      withMigratedTestDatabase $ \inventory10000Connection ->+        withStore (defaultConnectionSettings inventory100Connection) $ \inventory100Store ->+          withStore (defaultConnectionSettings inventory10000Connection) $ \inventory10000Store -> do+            seedCheckpointInventory inventory100Store 100+            seedCheckpointInventory inventory10000Store 10_000+            action inventory100Store inventory10000Store++-- | A store seeded with 'categoryScalingFixtureSql': categories @performance@+-- (200 streams), @idle@ (20,000 single-event streams), and @noise@, head at+-- 'categoryScalingHead'. Must run inside 'withSharedMigratedPostgres'.+withCategoryScalingStore :: (KirokuStore -> IO a) -> IO a+withCategoryScalingStore action =+  withMigratedTestDatabase $ \connection ->+    withStore (defaultConnectionSettings connection) $ \store -> do+      seeded <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)+      case seeded of+        Left err -> error ("Category-scaling benchmark setup failed: " <> show err)+        Right () -> action store++-- | Run the plain category read 10 times at one cursor and limit.+runCategoryPolls :: KirokuStore -> Text -> Int64 -> Int32 -> IO ()+runCategoryPolls store category cursor limit =+  mapM_+    ( \_ ->+        Pool.use (store ^. #pool) (Session.statement (cursor, category, limit) SQL.readCategoryForwardStmt)+          >>= forceCategoryPoll+    )+    [1 .. 10 :: Int]++-- | Run the consumer-group category read 10 times for one member.+runGroupCategoryPolls :: KirokuStore -> Text -> Int64 -> Int32 -> Int32 -> Int32 -> IO ()+runGroupCategoryPolls store category cursor member size limit =+  mapM_+    ( \_ ->+        Pool.use (store ^. #pool) (Session.statement (cursor, category, member, size, limit) SQL.readCategoryForwardConsumerGroupStmt)+          >>= forceCategoryPoll+    )+    [1 .. 10 :: Int]++forceCategoryPoll :: Either Pool.UsageError (V.Vector RecordedEvent) -> IO ()+forceCategoryPoll (Right events) = V.length events `seq` pure ()+forceCategoryPoll (Left err) = error ("Category-scaling benchmark read failed: " <> show err)++main :: IO ()+main = do+  -- Start ephemeral PostgreSQL once for all benchmarks+  config <- ephemeralConfig+  result <- Pg.withCachedConfig config Pg.defaultCacheConfig $ \db -> do+    migrateTestDatabase (Pg.connectionString db)+    let settings = defaultConnectionSettings (Pg.connectionString db)+    withStore settings $ \store -> do+      -- Counter for unique stream names across benchmarks+      counter <- newIORef (0 :: Int)+      let nextStream :: Text -> IO StreamName+          nextStream prefix = do+            n <- atomicModifyIORef' counter (\n -> (n + 1, n))+            pure (StreamName (prefix <> "-" <> T.pack (show n)))++      -- Pre-populate streams for category benchmarks (B10)+      -- 100 categories × 10 streams × 100 events = 100K events+      putStrLn "\n--- Pre-populating category data (100 cats × 10 streams × 100 events) ---"+      catT0 <- getCurrentTime+      mapM_+        ( \cat -> do+            mapM_+              ( \s -> do+                  let sn = StreamName ("cat" <> T.pack (show cat) <> "-" <> T.pack (show s))+                  let evts = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 100 :: Int]+                  r' <- runStoreIO store $ appendToStream sn NoStream evts+                  forceAppend r'+              )+              [1 .. 10 :: Int]+        )+        [1 .. 100 :: Int]+      catT1 <- getCurrentTime+      let catElapsed = realToFrac (diffUTCTime catT1 catT0) :: Double+      putStrLn $ "  Setup time: " <> show catElapsed <> "s (100K events)"++      -- Pre-populate streams for read benchmarks+      -- B4: Single stream with 1000 events+      let readStreamName = StreamName "bench-read-stream"+      let readEvents = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 1000 :: Int]+      r <- runStoreIO store $ appendToStream readStreamName NoStream readEvents+      forceAppend r++      -- B5: 10 streams with 100 events each for $all reads (1000 total)+      mapM_+        ( \s -> do+            let sn = StreamName ("bench-all-" <> T.pack (show s))+            let evts = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 100 :: Int]+            r' <- runStoreIO store $ appendToStream sn NoStream evts+            forceAppend r'+        )+        [1 .. 10 :: Int]++      -- Pre-create fixed streams for appendMultiStream benchmark iterations.+      mapM_+        ( \sn -> do+            r' <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Init"]+            forceAppend r'+        )+        [StreamName "bench-multi-a", StreamName "bench-multi-b", StreamName "bench-multi-c"]++      -- Pre-create the hot stream targeted by the two-round-trip raw+      -- shape variant. rawAppendUpdateExisting requires the row to+      -- exist (no ON CONFLICT), so without this warmup the first+      -- iteration would fail. The scalar-singleton and production+      -- variants already create their hot stream on first iteration+      -- via ON CONFLICT — pre-creating them here would change the+      -- existing benchmark contract, so we only seed the two-round-trip+      -- target.+      do+        r' <- runStoreIO store $ appendToStream (StreamName "raw-two-roundtrip-hot") AnyVersion [makeEvent "Init"]+        forceAppend r'++      -- B9: Pool saturation benchmark (64 concurrent writers, 100 appends each)+      putStrLn "\n--- B9: Pool saturation (64 writers × 100 appends, pool size 10) ---"+      satCounter <- newIORef (0 :: Int)+      let nextSatStream :: Int -> Int -> StreamName+          nextSatStream tid i = StreamName ("sat-" <> T.pack (show tid) <> "-" <> T.pack (show i))+      t0 <- getCurrentTime+      mapConcurrently_+        ( \tid -> do+            mapM_+              ( \i -> do+                  let sn = nextSatStream tid i+                  r' <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "SatEvent"]+                  forceAppend r'+                  atomicModifyIORef' satCounter (\n -> (n + 1, ()))+              )+              [1 .. 100 :: Int]+        )+        [1 .. 64 :: Int]+      t1 <- getCurrentTime+      totalOps <- readIORef satCounter+      let elapsed = realToFrac (diffUTCTime t1 t0) :: Double+      let throughput = fromIntegral totalOps / elapsed+      let avgLatency = elapsed / fromIntegral totalOps * 1000 -- ms+      putStrLn $ "  Total appends: " <> show totalOps+      putStrLn $ "  Elapsed: " <> show elapsed <> "s"+      putStrLn $ "  Throughput: " <> show (round throughput :: Int) <> " ops/s"+      putStrLn $ "  Avg latency: " <> show avgLatency <> " ms"+      putStrLn "---"++      -- Counter shared by the concurrent-writer benchmarks so each+      -- iteration uses a fresh stream-name run-id.+      concCounter <- newIORef (0 :: Int)+      rawCounter <- newIORef (0 :: Int)+      subCounter <- newIORef (0 :: Int)++      withInventoryBenchmarkStores $ \inventory100Store inventory10000Store ->+        withCategoryScalingStore $ \scalingStore ->+          defaultMain+            [ bgroup+                "append"+                [ bgroup+                    "single-event"+                    [ bench "NoStream (new stream)" $ whnfIO $ do+                        sn <- nextStream "bench-single"+                        r' <- runStoreIO store $ appendToStream sn NoStream [makeEvent "BenchEvent"]+                        forceAppend r',+                      bench "AnyVersion (new stream)" $ whnfIO $ do+                        sn <- nextStream "bench-any"+                        r' <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "BenchEvent"]+                        forceAppend r'+                    ],+                  bgroup+                    "batch-10"+                    [ bench "NoStream" $ whnfIO $ do+                        sn <- nextStream "bench-b10"+                        let events = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 10 :: Int]+                        r' <- runStoreIO store $ appendToStream sn NoStream events+                        forceAppend r'+                    ],+                  bgroup+                    "batch-100"+                    [ bench "NoStream" $ whnfIO $ do+                        sn <- nextStream "bench-b100"+                        let events = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. 100 :: Int]+                        r' <- runStoreIO store $ appendToStream sn NoStream events+                        forceAppend r'+                    ],+                  bgroup+                    "sequential"+                    [ bench "10 appends to same stream" $ whnfIO $ do+                        sn <- nextStream "bench-seq"+                        r0 <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Init"]+                        forceAppend r0+                        let res0 = case r0 of+                              Right ok -> ok+                              Left e -> error ("Sequential append failed: " <> show e)+                        let go _ 0 = pure ()+                            go v n = do+                              r' <- runStoreIO store $ appendToStream sn (ExactVersion v) [makeEvent "Seq"]+                              case r' of+                                Right res -> go (res ^. #streamVersion) (n - 1 :: Int)+                                Left e -> error ("Sequential append failed: " <> show e)+                        go (res0 ^. #streamVersion) 9+                    ]+                ],+              bgroup+                "raw-append-shape"+                [ bgroup+                    "AnyVersion"+                    [ bench "scalar singleton (new stream)" $+                        whnfIO $+                          runRawScalarAppendAnyVersionNewStream store rawCounter,+                      bench "production arrays/unnest (new stream)" $+                        whnfIO $+                          runRawProductionAppendAnyVersionNewStream store rawCounter,+                      bench "two-roundtrip (new stream)" $+                        whnfIO $+                          runRawTwoRoundtripAppendNewStream store rawCounter,+                      bench "two-roundtrip + BEGIN/COMMIT (new stream)" $+                        whnfIO $+                          runRawTwoRoundtripAppendNewStreamTx store rawCounter,+                      bench "scalar singleton (hot stream)" $+                        whnfIO $+                          runRawScalarAppendAnyVersionHotStream store,+                      bench "production arrays/unnest (hot stream)" $+                        whnfIO $+                          runRawProductionAppendAnyVersionHotStream store,+                      bench "two-roundtrip (hot stream)" $+                        whnfIO $+                          runRawTwoRoundtripAppendExistingHotStream store,+                      bench "two-roundtrip + BEGIN/COMMIT (hot stream)" $+                        whnfIO $+                          runRawTwoRoundtripAppendExistingHotStreamTx store+                    ]+                ],+              bgroup+                "read"+                [ bench "stream forward (100-event page)" $ whnfIO $ do+                    r' <- runStoreIO store $ readStreamForward readStreamName (StreamVersion 0) 100+                    forceRead r',+                  bench "$all forward (100-event page)" $ whnfIO $ do+                    r' <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+                    forceRead r'+                ],+              bgroup+                "category"+                [ bench "category forward (100-event page)" $ whnfIO $ do+                    -- Read from cat1 category (has 10 streams × 100 events = 1000 events)+                    r' <- runStoreIO store $ readCategory (CategoryName "cat1") (GlobalPosition 0) 100+                    forceRead r',+                  bench "exhausted-category" $ whnfIO $ do+                    -- cat1 events are inserted early in setup; a high cursor proves+                    -- category reads do not scan the rest of $all looking for matches.+                    r' <- runStoreIO store $ readCategory (CategoryName "cat1") (GlobalPosition 90_000) 100+                    forceRead r',+                  bench "$all forward (100-event page baseline)" $ whnfIO $ do+                    r' <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+                    forceRead r'+                ],+              -- F19 — Concurrent-writer stress as structured benchmarks.+              -- The legacy ad-hoc B9 measurement (still present above+              -- for historical comparability) prints throughput and+              -- latency once; these bgroup entries surface the same+              -- workload through tasty-bench so it participates in the+              -- baseline-regression workflow (Justfile bench-regression).+              bgroup+                "concurrent"+                [ bench "8 writers x 10 appends" $ whnfIO $ runConcurrentWriters store concCounter 8 10,+                  bench "32 writers x 10 appends" $ whnfIO $ runConcurrentWriters store concCounter 32 10+                ],+              bgroup+                "reliability-audit"+                [ bench "hot invoice-payment 10 AnyVersion appends" $ whnfIO $ runHotInvoicePayment store 10,+                  bench "appendMultiStream 3 existing streams" $ whnfIO $ runAppendMultiStream store,+                  bench "subscription category catch-up 100 events" $ whnfIO $ runSubscriptionCatchup store subCounter+                ],+              -- BUG-2: a caught-up category poll must not cost work+              -- proportional to the number of streams in the category.+              -- Each cell runs the statement 10 times.+              bgroup+                "category-scaling"+                [ bench "plain caught-up poll (200 streams)" $+                    whnfIO $+                      runCategoryPolls scalingStore "performance" categoryScalingHead 100,+                  bench "plain caught-up poll (20000 streams)" $+                    whnfIO $+                      runCategoryPolls scalingStore "idle" categoryScalingHead 100,+                  bench "group caught-up poll (20000 streams)" $+                    whnfIO $+                      runGroupCategoryPolls scalingStore "idle" categoryScalingHead 1 2 100,+                  bench "plain page from 0 (20000 streams)" $+                    whnfIO $+                      runCategoryPolls scalingStore "idle" 0 100,+                  bench "exhausted category (200 streams)" $+                    -- performance ends at 20,000; 60,000 other rows follow.+                    whnfIO $+                      runCategoryPolls scalingStore "performance" 20_000 100+                ],+              bgroup+                "subscription-checkpoint-inventory"+                [ bench "100 rows" $ whnfIO $ runCheckpointInventoryBenchmark inventory100Store,+                  bench "10000 rows" $ whnfIO $ runCheckpointInventoryBenchmark inventory10000Store+                ]+            ]+  case result of+    Left err -> error ("Failed to start ephemeral PostgreSQL: " <> show err)+    Right () -> pure ()++makeEvent :: Text -> EventData+makeEvent typ =+  EventData+    { eventId = Nothing,+      eventType = EventType typ,+      payload = Aeson.object [("benchmark", Aeson.Bool True)],+      metadata = Nothing,+      causationId = Nothing,+      correlationId = Nothing+    }
bench/RegressionGate.hs view
@@ -2,17 +2,21 @@  module Main where +import Control.Exception (finally) import Control.Lens ((^.))-import Control.Monad (forM, unless)+import Control.Monad (forM, replicateM_, unless) import Data.Aeson qualified as Aeson import Data.Generics.Labels ()-import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef) import Data.Int (Int32, Int64)+import Data.List (intercalate) import Data.Maybe (isNothing) import Data.Text (Text) import Data.Text qualified as T+import Data.Text.Encoding (decodeUtf8Lenient) import Data.Time.Clock (getCurrentTime) import Data.Vector qualified as V+import GHC.Clock (getMonotonicTime) import Hasql.Decoders qualified as D import Hasql.Pool qualified as Pool import Hasql.Session qualified as Session@@ -22,135 +26,391 @@ import Hasql.Transaction.Sessions qualified as TxSessions import Kiroku.Store import Kiroku.Store.SQL qualified as SQL+import Kiroku.Store.Subscription.EventPublisher qualified as Publisher import Kiroku.Test.Fixtures.CategoryScaling (categoryScalingFixtureSql, categoryScalingHead) import Kiroku.Test.Postgres (withMigratedTestDatabase, withSharedMigratedPostgres)-import Test.Tasty (localOption)+import System.Environment (lookupEnv)+import System.Mem (performGC)+import Test.Tasty (localOption, withResource) import Test.Tasty.Bench  main :: IO ()-main =-    withSharedMigratedPostgres $-        withMigratedTestDatabase $ \controlConnectionString ->-            withMigratedTestDatabase $ \candidateConnectionString ->-                withStore (defaultConnectionSettings controlConnectionString) $ \controlStore ->-                    withStore (defaultConnectionSettings candidateConnectionString) $ \candidateStore ->-                        withAppendCategoryStores $ \appendControlStore appendCandidateStore ->-                            withCategoryScalingStore $ \scalingStore -> do-                                let fourStreams = namedStreams "workload-gate-4" 4-                                    eightStreams = namedStreams "workload-gate-8" 8-                                seedStreams controlStore (fourStreams <> eightStreams)-                                seedStreams candidateStore (fourStreams <> eightStreams)+main = do+  mode <- lookupEnv "KIROKU_GATE_DIAGNOSE"+  withSharedMigratedPostgres $+    withMigratedTestDatabase $ \controlConnectionString ->+      withMigratedTestDatabase $ \candidateConnectionString ->+        withStore (defaultConnectionSettings controlConnectionString) $ \controlStore ->+          withStore (defaultConnectionSettings candidateConnectionString) $ \candidateStore ->+            withAppendCategoryStores $ \appendControlStore appendCandidateStore appendWarmStore ->+              withCategoryScalingStore $ \scalingStore -> do+                let fourStreams = namedStreams "workload-gate-4" 4+                    eightStreams = namedStreams "workload-gate-8" 8+                seedStreams controlStore (fourStreams <> eightStreams)+                seedStreams candidateStore (fourStreams <> eightStreams) -                                runSequentialMultiAppend controlStore fourStreams-                                runProductionMultiAppend candidateStore fourStreams-                                runSequentialMultiAppend controlStore eightStreams-                                runProductionMultiAppend candidateStore eightStreams+                runSequentialMultiAppend controlStore fourStreams+                runProductionMultiAppend candidateStore fourStreams+                runSequentialMultiAppend controlStore eightStreams+                runProductionMultiAppend candidateStore eightStreams -                                appendControlCounter <- newIORef 0-                                appendCandidateCounter <- newIORef 0-                                runAppendWorkload preCategoryAppendAnyVersion appendControlStore appendControlCounter-                                runAppendWorkload SQL.appendAnyVersion appendCandidateStore appendCandidateCounter+                (controlStatement, candidateStatement) <- diagnosticStatements mode appendControlStore appendCandidateStore+                appendWarmCounter <- newIORef 0+                appendControlCounter <- newIORef 0+                appendCandidateCounter <- newIORef 0+                -- Prime each growing table before ANALYZE so prepared plans+                -- see a populated store rather than the empty migration template.+                mapM_+                  ( \_ -> do+                      runAppendWorkload controlStatement appendControlStore appendControlCounter+                      runAppendWorkload candidateStatement appendCandidateStore appendCandidateCounter+                      runAppendWorkload SQL.appendAnyVersion appendWarmStore appendWarmCounter+                  )+                  [1 .. 20 :: Int] -                                defaultMain-                                    [ localOption WallTime $-                                        bgroup-                                            "append-multi-stream"-                                            [ bench "sequential-control-4" $-                                                whnfIO (runSequentialMultiAppend controlStore fourStreams)-                                            , bcompareWithin 0 0.90 "sequential-control-4" $-                                                bench "production-pipeline-4" $-                                                    whnfIO (runProductionMultiAppend candidateStore fourStreams)-                                            , bench "sequential-control-8" $-                                                whnfIO (runSequentialMultiAppend controlStore eightStreams)-                                            , bcompareWithin 0 0.90 "sequential-control-8" $-                                                bench "production-pipeline-8" $-                                                    whnfIO (runProductionMultiAppend candidateStore eightStreams)-                                            ]-                                    , -- BUG-2 / plan 91 G4: carrying the category onto each $all-                                      -- row (migration 0012) adds a column and one partial-index-                                      -- insert per event. The control runs the pre-0012 append on-                                      -- a database without the index or CHECK.-                                      localOption WallTime $-                                        bgroup-                                            "append-category-column"-                                            [ bench "control-append-40" $-                                                whnfIO (runAppendWorkload preCategoryAppendAnyVersion appendControlStore appendControlCounter)-                                            , bcompareWithin 0 1.05 "control-append-40" $-                                                bench "candidate-append-40" $-                                                    whnfIO (runAppendWorkload SQL.appendAnyVersion appendCandidateStore appendCandidateCounter)-                                            ]-                                    , -- BUG-2 / plan 91 G3: the index-range category reads against-                                      -- the LATERAL statements they replaced, on one database seeded-                                      -- with the category-scaling fixture. The unpartitioned read-                                      -- must not be slower where LATERAL was already cheap, and both-                                      -- caught-up polls on 20,000 streams must be 5x faster.-                                      localOption WallTime $-                                        bgroup-                                            "category-read"-                                            [ bench "control-exhausted-category" $-                                                whnfIO (runPlainReads lateralCategoryRead scalingStore "performance" categoryScalingHead)-                                            , bcompareWithin 0 1.05 "control-exhausted-category" $-                                                bench "candidate-exhausted-category" $-                                                    whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "performance" categoryScalingHead)-                                            , bench "control-page-200-streams-from-0" $-                                                whnfIO (runPlainReads lateralCategoryRead scalingStore "performance" 0)-                                            , bcompareWithin 0 1.05 "control-page-200-streams-from-0" $-                                                bench "candidate-page-200-streams-from-0" $-                                                    whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "performance" 0)-                                            , bench "control-page-20000-streams-from-0" $-                                                whnfIO (runPlainReads lateralCategoryRead scalingStore "idle" 0)-                                            , bcompareWithin 0 1.05 "control-page-20000-streams-from-0" $-                                                bench "candidate-page-20000-streams-from-0" $-                                                    whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "idle" 0)-                                            , bench "control-plain-caught-up-20000-streams" $-                                                whnfIO (runPlainReads lateralCategoryRead scalingStore "idle" categoryScalingHead)-                                            , bcompareWithin 0 0.20 "control-plain-caught-up-20000-streams" $-                                                bench "candidate-plain-caught-up-20000-streams" $-                                                    whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "idle" categoryScalingHead)-                                            , bench "control-group-caught-up-20000-streams" $-                                                whnfIO (runGroupReads lateralCategoryGroupRead scalingStore "idle" categoryScalingHead)-                                            , bcompareWithin 0 0.20 "control-group-caught-up-20000-streams" $-                                                bench "candidate-group-caught-up-20000-streams" $-                                                    whnfIO (runGroupReads SQL.readCategoryForwardConsumerGroupStmt scalingStore "idle" categoryScalingHead)-                                            ]-                                    ]+                -- Settle fixture writes and refresh plans before measurement.+                runSetupScript scalingStore "CHECKPOINT"+                mapM_+                  (\store -> runSetupScript store "VACUUM (ANALYZE)")+                  [controlStore, candidateStore, appendControlStore, appendCandidateStore, appendWarmStore, scalingStore] +                case mode of+                  Nothing ->+                    defaultMain+                      [ -- ADR-10: the user accepted migration 0012's write cost.+                        -- Protect the accepted implementation against later SQL+                        -- regressions, keeping the historical comparison diagnostic.+                        localOption (RelStDev 0.01) $+                          localOption WallTime $+                            bgroup+                              "append-accepted-category"+                              [ -- Keep this group first: the disposable cell absorbs+                                -- post-setup effects without growing either append arm.+                                localOption (RelStDev 0.05) $+                                  bench "warm-append-40" $+                                    whnfIO (runAppendWorkload SQL.appendAnyVersion appendWarmStore appendWarmCounter),+                                bench "accepted-control-append-40x32" $+                                  whnfIO (replicateM_ 32 (runAppendWorkload controlStatement appendControlStore appendControlCounter)),+                                bcompareWithin 0.5 1.05 "accepted-control-append-40x32" $+                                  bench "candidate-append-40x32" $+                                    whnfIO (replicateM_ 32 (runAppendWorkload candidateStatement appendCandidateStore appendCandidateCounter))+                              ],+                        withResource+                          (warmPipelineStores controlStore candidateStore fourStreams eightStreams)+                          (const (pure ()))+                          $ \_ ->+                            localOption WallTime $+                              bgroup+                                "append-multi-stream"+                                [ localOption (RelStDev 0.01) $+                                    bench "sequential-control-4" $+                                      whnfIO (runSequentialMultiAppend controlStore fourStreams),+                                  bcompareWithin 0.5 0.90 "sequential-control-4" $+                                    localOption (RelStDev 0.01) $+                                      bench "production-pipeline-4" $+                                        whnfIO (runProductionMultiAppend candidateStore fourStreams),+                                  bench "sequential-control-8" $+                                    whnfIO (runSequentialMultiAppend controlStore eightStreams),+                                  bcompareWithin 0.5 0.90 "sequential-control-8" $+                                    bench "production-pipeline-8" $+                                      whnfIO (runProductionMultiAppend candidateStore eightStreams)+                                ],+                        -- BUG-2 / plan 91 G3: the index-range category reads against+                        -- the LATERAL statements they replaced, on one database seeded+                        -- with the category-scaling fixture. The unpartitioned read+                        -- must not be slower where LATERAL was already cheap, and both+                        -- caught-up polls on 20,000 streams must be 5x faster.+                        localOption WallTime $+                          bgroup+                            "category-read"+                            [ bench "control-exhausted-category" $+                                whnfIO (runPlainReads lateralCategoryRead scalingStore "performance" categoryScalingHead),+                              bcompareWithin 0 1.05 "control-exhausted-category" $+                                bench "candidate-exhausted-category" $+                                  whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "performance" categoryScalingHead),+                              bench "control-page-200-streams-from-0" $+                                whnfIO (runPlainReads lateralCategoryRead scalingStore "performance" 0),+                              bcompareWithin 0 1.05 "control-page-200-streams-from-0" $+                                bench "candidate-page-200-streams-from-0" $+                                  whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "performance" 0),+                              bench "control-page-20000-streams-from-0" $+                                whnfIO (runPlainReads lateralCategoryRead scalingStore "idle" 0),+                              bcompareWithin 0 1.05 "control-page-20000-streams-from-0" $+                                bench "candidate-page-20000-streams-from-0" $+                                  whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "idle" 0),+                              bench "control-plain-caught-up-20000-streams" $+                                whnfIO (runPlainReads lateralCategoryRead scalingStore "idle" categoryScalingHead),+                              bcompareWithin 0 0.20 "control-plain-caught-up-20000-streams" $+                                bench "candidate-plain-caught-up-20000-streams" $+                                  whnfIO (runPlainReads SQL.readCategoryForwardStmt scalingStore "idle" categoryScalingHead),+                              bench "control-group-caught-up-20000-streams" $+                                whnfIO (runGroupReads lateralCategoryGroupRead scalingStore "idle" categoryScalingHead),+                              bcompareWithin 0 0.20 "control-group-caught-up-20000-streams" $+                                bench "candidate-group-caught-up-20000-streams" $+                                  whnfIO (runGroupReads SQL.readCategoryForwardConsumerGroupStmt scalingStore "idle" categoryScalingHead)+                            ]+                      ]+                      `finally` do+                        counts <- mapM readIORef [appendControlCounter, appendCandidateCounter]+                        putStrLn ("append workload counts including setup: " <> show counts)+                  Just "explain-combined-links" -> runAppendExplainDiagnostic True controlStatement candidateStatement appendControlStore appendCandidateStore+                  Just "explain" -> runAppendExplainDiagnostic False controlStatement candidateStatement appendControlStore appendCandidateStore+                  Just label -> runPairedAppendDiagnostic label controlStatement candidateStatement appendControlStore appendCandidateStore appendControlCounter appendCandidateCounter++runSetupScript :: KirokuStore -> Text -> IO ()+runSetupScript store script = do+  result <- Pool.use (store ^. #pool) (Session.script script)+  case result of+    Left err -> error ("workload gate quiescence failed: " <> show err)+    Right () -> pure ()++-- | Refresh a group whose pools/plans have been idle during the append cells.+-- No resource thunk reaches STM, and all setup stays outside measured actions.+warmPipelineStores :: KirokuStore -> KirokuStore -> [(StreamName, Text)] -> [(StreamName, Text)] -> IO ()+warmPipelineStores control candidate fourStreams eightStreams = do+  replicateM_ 20 $ do+    runSequentialMultiAppend control fourStreams+    runProductionMultiAppend candidate fourStreams+    runSequentialMultiAppend control eightStreams+    runProductionMultiAppend candidate eightStreams+  mapM_ (\store -> runSetupScript store "VACUUM (ANALYZE)") [control, candidate]+  runSequentialMultiAppend control fourStreams+  runProductionMultiAppend candidate fourStreams+  runSequentialMultiAppend control eightStreams+  runProductionMultiAppend candidate eightStreams++-- | Diagnostic comparisons retain the default gate's SQL and bounds. Each+-- invocation gets fresh stores. The A/A mode tests the harness with identical+-- SQL and schema; the other modes separate column/statement from index/CHECK cost.+diagnosticStatements ::+  Maybe String ->+  KirokuStore ->+  KirokuStore ->+  IO (Statement SQL.AppendParams (Maybe AppendResult), Statement SQL.AppendParams (Maybe AppendResult))+diagnosticStatements mode control candidate = case mode of+  Nothing -> pure (acceptedCategoryAppendAnyVersion, SQL.appendAnyVersion)+  Just "paired-accepted" -> pure (acceptedCategoryAppendAnyVersion, SQL.appendAnyVersion)+  Just "paired" -> historical+  Just "explain" -> historical+  Just "paired-aa" -> do+    runSetupScript control dropCategoryIndexAndCheck+    runSetupScript candidate dropCategoryIndexAndCheck+    pure (preCategoryAppendAnyVersion, preCategoryAppendAnyVersion)+  Just "paired-column" -> do+    runSetupScript control dropCategoryIndexAndCheck+    runSetupScript candidate dropCategoryIndexAndCheck+    pure (preCategoryAppendAnyVersion, SQL.appendAnyVersion)+  Just "paired-index-check" -> do+    runSetupScript control dropCategoryIndexAndCheck+    pure (SQL.appendAnyVersion, SQL.appendAnyVersion)+  Just "paired-no-publisher" -> do+    -- Diagnostic upper bound only: there are no subscribers in this fixture.+    -- Stopping this worker is not a production optimization or valid subscriber mode.+    Publisher.stopPublisher (candidate ^. #publisher)+    pure (SQL.appendAnyVersion, SQL.appendAnyVersion)+  Just "paired-combined-links" -> pure (SQL.appendAnyVersion, combinedLinksAppend)+  Just "explain-combined-links" -> pure (SQL.appendAnyVersion, combinedLinksAppend)+  Just invalid -> error ("unknown KIROKU_GATE_DIAGNOSE mode: " <> invalid)+  where+    historical = do+      runSetupScript control dropCategoryIndexAndCheck+      pure (preCategoryAppendAnyVersion, SQL.appendAnyVersion)+    dropCategoryIndexAndCheck =+      "DROP INDEX kiroku.ix_stream_events_all_by_category; ALTER TABLE kiroku.stream_events DROP CONSTRAINT ck_stream_events_all_category;"++-- | Benchmark-only hypothesis: one junction INSERT instead of two. This+-- preserves the production bind parameters, row values and result decoder.+combinedLinksAppend :: Statement SQL.AppendParams (Maybe AppendResult)+combinedLinksAppend = Statement.preparable sql SQL.appendParamsEncoder SQL.appendResultDecoder+  where+    production = Statement.toSql SQL.appendAnyVersion+    -- The exact production SQL is extracted below using stable CTE boundaries;+    -- fail rather than silently producing a different candidate after an edit.+    sql = combineJunctionInserts production++combineJunctionInserts :: Text -> Text+combineJunctionInserts production =+  let (beforeSource, sourceAndRest) = T.breakOn "  source_links AS (" production+      (_, allUpdateAndRest) = T.breakOn "  all_update AS (" sourceAndRest+      (allUpdate, allLinksAndResult) = T.breakOn "  all_links AS (" allUpdateAndRest+      (_, result) = T.breakOn "SELECT su.stream_id," allLinksAndResult+      combined =+        """+        event_links AS (+          INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+          SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx, NULL::text+          FROM new_events ne+          CROSS JOIN stream_upsert su+          UNION ALL+          SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+          FROM new_events ne+          CROSS JOIN all_update au+          CROSS JOIN stream_upsert su+        )+        """+   in if T.null beforeSource || T.null allUpdate || T.null result+        then error "combined-links prototype could not identify production CTE boundaries"+        else beforeSource <> allUpdate <> combined <> "\n" <> result++-- | Twelve alternating pairs of equal-sized, growing workloads. Every round+-- starts at the same logical stream/event counts in both databases. These are+-- diagnostic samples, never a substitute passing result for the tasty gate.+runPairedAppendDiagnostic ::+  String ->+  Statement SQL.AppendParams (Maybe AppendResult) ->+  Statement SQL.AppendParams (Maybe AppendResult) ->+  KirokuStore ->+  KirokuStore ->+  IORef Int ->+  IORef Int ->+  IO ()+runPairedAppendDiagnostic label controlStatement candidateStatement control candidate controlCounter candidateCounter = do+  putStrLn "PAIR,variant,pair,first,control_ns,candidate_ns,control_before,candidate_before,control_after,candidate_after,pre_gc"+  mapM_ runPair [1 .. 12 :: Int]+  verifyCounts "control" control controlCounter+  verifyCounts "candidate" candidate candidateCounter+  where+    iterations = 32+    timed :: IO () -> IO Integer+    timed action = do+      -- Match tasty-bench: collect before the timer, rather than letting setup+      -- garbage fall unpredictably into one arm of this diagnostic comparison.+      performGC+      start <- getMonotonicTime+      action+      end <- getMonotonicTime+      pure (round ((end - start) * 1e9) :: Integer)+    controlAction = replicateM_ iterations (runAppendWorkload controlStatement control controlCounter)+    candidateAction = replicateM_ iterations (runAppendWorkload candidateStatement candidate candidateCounter)+    runPair pair = do+      beforeControl <- readIORef controlCounter+      beforeCandidate <- readIORef candidateCounter+      unless (beforeControl == beforeCandidate) $ error "diagnostic arms start at unequal workload counts"+      (controlNs, candidateNs) <-+        if odd pair+          then do+            c <- timed controlAction+            n <- timed candidateAction+            pure (c, n)+          else do+            n <- timed candidateAction+            c <- timed controlAction+            pure (c, n)+      afterControl <- readIORef controlCounter+      afterCandidate <- readIORef candidateCounter+      unless (afterControl == beforeControl + iterations && afterCandidate == beforeCandidate + iterations) $+        error "diagnostic arms did not complete equal work"+      putStrLn $+        intercalate+          ","+          [ "PAIR",+            label,+            show pair,+            if odd pair then "control" else "candidate",+            show controlNs,+            show candidateNs,+            show beforeControl,+            show beforeCandidate,+            show afterControl,+            show afterCandidate,+            "1"+          ]+    verifyCounts arm store counter = do+      completed <- readIORef counter+      let expectedEvents = fromIntegral (40 * completed) :: Int64+          expectedStreams = fromIntegral (20 * completed + 2) :: Int64+      result <- Pool.use (store ^. #pool) (Session.statement () diagnosticCounts)+      case result of+        Right counts@(streams, events, links, hotVersion, globalVersion) -> do+          unless (counts == (expectedStreams, expectedEvents, 2 * expectedEvents, expectedEvents `div` 2, expectedEvents)) $+            error ("diagnostic database counts differ for " <> arm <> ": " <> show counts)+          putStrLn $ intercalate "," ["COUNTS", label, arm, show streams, show events, show links, show hotVersion, show globalVersion]+        Left err -> error ("diagnostic count verification failed: " <> show err)++-- | Use the exact statements under comparison, rather than a copied EXPLAIN+-- query. These four plans show structure/buffers/WAL, not a latency estimate.+runAppendExplainDiagnostic ::+  Bool ->+  Statement SQL.AppendParams (Maybe AppendResult) ->+  Statement SQL.AppendParams (Maybe AppendResult) ->+  KirokuStore ->+  KirokuStore ->+  IO ()+runAppendExplainDiagnostic timings controlStatement candidateStatement control candidate = do+  mapM_+    ( \(arm, statement, store) -> do+        mapM_ (explainOne arm statement store) [("fresh", "append-gate-explain-fresh"), ("hot", "append-gate-hot")]+        rows <- Pool.use (store ^. #pool) (Session.statement () physicalRows)+        case rows of+          Right value -> putStrLn ("PHYSICAL," <> arm <> "," <> T.unpack value)+          Left err -> error ("diagnostic physical row query failed: " <> show err)+    )+    [("control", controlStatement, control), ("candidate", candidateStatement, candidate)]+  where+    jsonText = D.singleRow (D.column (D.nonNullable (D.jsonBytes (Right . decodeUtf8Lenient))))+    physicalRows =+      Statement.unpreparable+        "SELECT json_agg(row_to_json(x)) FROM (SELECT stream_id = 0 AS all_row, category IS NULL AS category_is_null, pg_column_size(se) AS row_bytes, count(*) AS samples FROM stream_events se GROUP BY 1,2,3 ORDER BY 1,2,3) x"+        mempty+        jsonText+    explainOne arm statement store (kind, name) = do+      enriched <- enrichEvents (store ^. #storeSettings) [makeEvent "AppendCategoryGate"]+      prepared <- prepareEvents enriched+      now <- getCurrentTime+      let explain =+            Statement.unpreparable+              ("EXPLAIN (ANALYZE, BUFFERS, WAL, TIMING " <> (if timings then "ON" else "OFF") <> ", FORMAT JSON) " <> Statement.toSql statement)+              SQL.appendParamsEncoder+              jsonText+      result <- Pool.use (store ^. #pool) (Session.statement (buildAppendParams name now prepared) explain)+      case result of+        Right value -> putStrLn ("PLAN," <> arm <> "," <> kind <> "," <> T.unpack value)+        Left err -> error ("diagnostic EXPLAIN failed: " <> show err)++diagnosticCounts :: Statement () (Int64, Int64, Int64, Int64, Int64)+diagnosticCounts =+  Statement.unpreparable+    "SELECT (SELECT count(*) FROM streams), (SELECT count(*) FROM events), (SELECT count(*) FROM stream_events), (SELECT stream_version FROM streams WHERE stream_name = 'append-gate-hot'), (SELECT stream_version FROM streams WHERE stream_id = 0)"+    mempty+    (D.singleRow ((,,,,) <$> int8 <*> int8 <*> int8 <*> int8 <*> int8))+  where+    int8 = D.column (D.nonNullable D.int8)+ -- | A migrated store seeded with 'categoryScalingFixtureSql'. withCategoryScalingStore :: (KirokuStore -> IO a) -> IO a withCategoryScalingStore action =-    withMigratedTestDatabase $ \connectionString ->-        withStore (defaultConnectionSettings connectionString) $ \store -> do-            seeded <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)-            case seeded of-                Left err -> error ("category-read gate setup failed: " <> show err)-                Right () -> action store+  withMigratedTestDatabase $ \connectionString ->+    withStore (defaultConnectionSettings connectionString) $ \store -> do+      seeded <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)+      case seeded of+        Left err -> error ("category-read gate setup failed: " <> show err)+        Right () -> action store -{- | Ten executions of an unpartitioned category read (limit 100). Ten, not a-hundred: a LATERAL control poll on 20,000 streams costs about 15 ms, and a-hundred per iteration exceeds tasty-bench's timeout.--}+-- | Ten executions of an unpartitioned category read (limit 100). Ten, not a+-- hundred: a LATERAL control poll on 20,000 streams costs about 15 ms, and a+-- hundred per iteration exceeds tasty-bench's timeout. runPlainReads ::-    Statement (Int64, Text, Int32) (V.Vector RecordedEvent) ->-    KirokuStore ->-    Text ->-    Int64 ->-    IO ()+  Statement (Int64, Text, Int32) (V.Vector RecordedEvent) ->+  KirokuStore ->+  Text ->+  Int64 ->+  IO () runPlainReads statement store category cursor =-    mapM_-        (\_ -> Pool.use (store ^. #pool) (Session.statement (cursor, category, 100) statement) >>= forceReads)-        [1 .. 10 :: Int]+  mapM_+    (\_ -> Pool.use (store ^. #pool) (Session.statement (cursor, category, 100) statement) >>= forceReads)+    [1 .. 10 :: Int]  -- | Ten executions of a consumer-group category read, member 1 of 2 (limit 100). runGroupReads ::-    Statement (Int64, Text, Int32, Int32, Int32) (V.Vector RecordedEvent) ->-    KirokuStore ->-    Text ->-    Int64 ->-    IO ()+  Statement (Int64, Text, Int32, Int32, Int32) (V.Vector RecordedEvent) ->+  KirokuStore ->+  Text ->+  Int64 ->+  IO () runGroupReads statement store category cursor =-    mapM_-        (\_ -> Pool.use (store ^. #pool) (Session.statement (cursor, category, 1, 2, 100) statement) >>= forceReads)-        [1 .. 10 :: Int]+  mapM_+    (\_ -> Pool.use (store ^. #pool) (Session.statement (cursor, category, 1, 2, 100) statement) >>= forceReads)+    [1 .. 10 :: Int]  forceReads :: Either Pool.UsageError (V.Vector RecordedEvent) -> IO () forceReads (Right events) = V.length events `seq` pure ()@@ -159,246 +419,292 @@ -- | 'SQL.readCategoryForwardStmt' as it was before plan 91 (git 12d50d5). lateralCategoryRead :: Statement (Int64, Text, Int32) (V.Vector RecordedEvent) lateralCategoryRead =-    Statement.preparable-        lateralCategoryReadSQL-        SQL.readCategoryEncoder-        (D.rowVector SQL.recordedEventRow)+  Statement.preparable+    lateralCategoryReadSQL+    SQL.readCategoryEncoder+    (D.rowVector SQL.recordedEventRow)  -- | 'SQL.readCategoryForwardConsumerGroupStmt' as it was before plan 91 (git 12d50d5). lateralCategoryGroupRead :: Statement (Int64, Text, Int32, Int32, Int32) (V.Vector RecordedEvent) lateralCategoryGroupRead =-    Statement.preparable-        lateralCategoryGroupReadSQL-        SQL.readCategoryConsumerGroupEncoder-        (D.rowVector SQL.recordedEventRow)+  Statement.preparable+    lateralCategoryGroupReadSQL+    SQL.readCategoryConsumerGroupEncoder+    (D.rowVector SQL.recordedEventRow)  lateralCategoryReadSQL :: Text lateralCategoryReadSQL =-    """-    SELECT e.event_id, e.event_type,-           se.stream_version, se.stream_version AS global_position,-           se.original_stream_id, se.original_stream_version,-           e.data, e.metadata, e.causation_id, e.correlation_id,-           e.created_at-    FROM streams s-    JOIN LATERAL (-      SELECT se.*-      FROM stream_events se-      WHERE se.stream_id = 0-        AND se.original_stream_id = s.stream_id-        AND se.stream_version > $1-      ORDER BY se.stream_version ASC-      LIMIT $3-    ) se ON true-    JOIN events e ON e.event_id = se.event_id-    WHERE s.category = $2+  """+  SELECT e.event_id, e.event_type,+         se.stream_version, se.stream_version AS global_position,+         se.original_stream_id, se.original_stream_version,+         e.data, e.metadata, e.causation_id, e.correlation_id,+         e.created_at+  FROM streams s+  JOIN LATERAL (+    SELECT se.*+    FROM stream_events se+    WHERE se.stream_id = 0+      AND se.original_stream_id = s.stream_id+      AND se.stream_version > $1     ORDER BY se.stream_version ASC     LIMIT $3-    """+  ) se ON true+  JOIN events e ON e.event_id = se.event_id+  WHERE s.category = $2+  ORDER BY se.stream_version ASC+  LIMIT $3+  """  lateralCategoryGroupReadSQL :: Text lateralCategoryGroupReadSQL =-    """-    SELECT e.event_id, e.event_type,-           se.stream_version, se.stream_version AS global_position,-           se.original_stream_id, se.original_stream_version,-           e.data, e.metadata, e.causation_id, e.correlation_id,-           e.created_at-    FROM streams s-    JOIN LATERAL (-      SELECT se.*-      FROM stream_events se-      WHERE se.stream_id = 0-        AND se.original_stream_id = s.stream_id-        AND se.stream_version > $1-      ORDER BY se.stream_version ASC-      LIMIT $5-    ) se ON true-    JOIN events e ON e.event_id = se.event_id-    WHERE s.category = $2-      AND (((hashtextextended(s.stream_id::text, 0) % $4) + $4) % $4) = $3+  """+  SELECT e.event_id, e.event_type,+         se.stream_version, se.stream_version AS global_position,+         se.original_stream_id, se.original_stream_version,+         e.data, e.metadata, e.causation_id, e.correlation_id,+         e.created_at+  FROM streams s+  JOIN LATERAL (+    SELECT se.*+    FROM stream_events se+    WHERE se.stream_id = 0+      AND se.original_stream_id = s.stream_id+      AND se.stream_version > $1     ORDER BY se.stream_version ASC     LIMIT $5-    """+  ) se ON true+  JOIN events e ON e.event_id = se.event_id+  WHERE s.category = $2+    AND (((hashtextextended(s.stream_id::text, 0) % $4) + $4) % $4) = $3+  ORDER BY se.stream_version ASC+  LIMIT $5+  """ -{- | Two freshly migrated stores for the append-category-column gate. The-control database has the category index and CHECK from migration 0012-dropped, so it pays exactly the pre-0012 write cost when driven by-'preCategoryAppendAnyVersion'; the candidate is left as migrated.--}-withAppendCategoryStores :: (KirokuStore -> KirokuStore -> IO a) -> IO a+-- | Three fully migrated stores: the frozen accepted control, current+-- production candidate, and disposable warm-up. Historical diagnostic modes+-- explicitly remove the category index/CHECK from the appropriate arm.+withAppendCategoryStores :: (KirokuStore -> KirokuStore -> KirokuStore -> IO a) -> IO a withAppendCategoryStores action =-    withMigratedTestDatabase $ \controlConnectionString ->-        withMigratedTestDatabase $ \candidateConnectionString ->-            withStore (defaultConnectionSettings controlConnectionString) $ \controlStore ->-                withStore (defaultConnectionSettings candidateConnectionString) $ \candidateStore -> do-                    dropped <--                        Pool.use-                            (controlStore ^. #pool)-                            ( Session.script-                                """-                                DROP INDEX kiroku.ix_stream_events_all_by_category;-                                ALTER TABLE kiroku.stream_events DROP CONSTRAINT ck_stream_events_all_category;-                                """-                            )-                    case dropped of-                        Left err -> error ("append-category control setup failed: " <> show err)-                        Right () -> action controlStore candidateStore+  withMigratedTestDatabase $ \controlConnectionString ->+    withMigratedTestDatabase $ \candidateConnectionString ->+      withMigratedTestDatabase $ \warmConnectionString ->+        withStore (defaultConnectionSettings warmConnectionString) $ \warmStore ->+          withStore (defaultConnectionSettings controlConnectionString) $ \controlStore ->+            withStore (defaultConnectionSettings candidateConnectionString) $ \candidateStore ->+              action controlStore candidateStore warmStore -{- | One gate iteration: 20 single-event appends to fresh streams, then 20 to-one hot stream. Each store gets its own counter, so control and candidate-create the same stream names and do the same work.--}+-- | One gate iteration: 20 single-event appends to fresh streams, then 20 to+-- one hot stream. Each store gets its own counter, so control and candidate+-- create the same stream names and do the same work. runAppendWorkload ::-    Statement SQL.AppendParams (Maybe AppendResult) ->-    KirokuStore ->-    IORef Int ->-    IO ()+  Statement SQL.AppendParams (Maybe AppendResult) ->+  KirokuStore ->+  IORef Int ->+  IO () runAppendWorkload statement store counter = do-    iteration <- atomicModifyIORef' counter (\n -> (n + 1, n))-    now <- getCurrentTime-    let freshNames =-            [ "append-gate-" <> T.pack (show iteration) <> "-" <> T.pack (show index)-            | index <- [1 .. 20 :: Int]-            ]-        names = freshNames <> replicate 20 "append-gate-hot"-    mapM_-        ( \name -> do-            enriched <- enrichEvents (store ^. #storeSettings) [makeEvent "AppendCategoryGate"]-            prepared <- prepareEvents enriched-            result <--                Pool.use (store ^. #pool) $-                    Session.statement (buildAppendParams name now prepared) statement-            case result of-                Right (Just appendResult) -> forceAppendResults [appendResult]-                Right Nothing -> error "append-category gate append returned no row"-                Left err -> error ("append-category gate append failed: " <> show err)-        )-        names+  iteration <- atomicModifyIORef' counter (\n -> (n + 1, n))+  now <- getCurrentTime+  let freshNames =+        [ "append-gate-" <> T.pack (show iteration) <> "-" <> T.pack (show index)+        | index <- [1 .. 20 :: Int]+        ]+      names = freshNames <> replicate 20 "append-gate-hot"+  mapM_+    ( \name -> do+        enriched <- enrichEvents (store ^. #storeSettings) [makeEvent "AppendCategoryGate"]+        prepared <- prepareEvents enriched+        result <-+          Pool.use (store ^. #pool) $+            Session.statement (buildAppendParams name now prepared) statement+        case result of+          Right (Just appendResult) -> forceAppendResults [appendResult]+          Right Nothing -> error "append-category gate append returned no row"+          Left err -> error ("append-category gate append failed: " <> show err)+    )+    names +-- | Frozen post-0012 append SQL from the user-accepted production source at+-- git 22ac4de. Keep this literal independent of SQL.appendAnyVersion so later+-- production changes are compared against the accepted implementation.+acceptedCategoryAppendAnyVersion :: Statement SQL.AppendParams (Maybe AppendResult)+acceptedCategoryAppendAnyVersion =+  Statement.preparable acceptedCategoryAppendAnyVersionSQL SQL.appendParamsEncoder SQL.appendResultDecoder++acceptedCategoryAppendAnyVersionSQL :: Text+acceptedCategoryAppendAnyVersionSQL =+  """+  WITH+    new_events AS (+      SELECT *+      FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+      WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+    ),+    stream_upsert AS (+      INSERT INTO streams (stream_name, stream_version)+      VALUES ($8, (SELECT count(*) FROM new_events))+      ON CONFLICT (stream_name)+      DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)+        WHERE streams.deleted_at IS NULL+      RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version+    ),+    inserted_events AS (+      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+      SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+      FROM new_events+      WHERE EXISTS (SELECT 1 FROM stream_upsert)+      ORDER BY idx+    ),+    source_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+      FROM new_events ne+      CROSS JOIN stream_upsert su+    ),+    all_update AS (+      UPDATE streams+      SET stream_version = stream_version + (SELECT count(*) FROM new_events)+      WHERE stream_id = 0+        AND EXISTS (SELECT 1 FROM stream_upsert)+      RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+    ),+    all_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+      SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+      FROM new_events ne+      CROSS JOIN all_update au+      CROSS JOIN stream_upsert su+    )+  SELECT su.stream_id,+         su.initial_version + (SELECT count(*) FROM new_events),+         au.initial_global_version + (SELECT count(*) FROM new_events)+  FROM stream_upsert su+  CROSS JOIN all_update au+  """+ -- | 'SQL.appendAnyVersion' as it was before migration 0012 (git 12d50d5). preCategoryAppendAnyVersion :: Statement SQL.AppendParams (Maybe AppendResult) preCategoryAppendAnyVersion =-    Statement.preparable-        preCategoryAppendAnyVersionSQL-        SQL.appendParamsEncoder-        SQL.appendResultDecoder+  Statement.preparable+    preCategoryAppendAnyVersionSQL+    SQL.appendParamsEncoder+    SQL.appendResultDecoder  preCategoryAppendAnyVersionSQL :: Text preCategoryAppendAnyVersionSQL =-    """-    WITH-      new_events AS (-        SELECT *-        FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])-        WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)-      ),-      stream_upsert AS (-        INSERT INTO streams (stream_name, stream_version)-        VALUES ($8, (SELECT count(*) FROM new_events))-        ON CONFLICT (stream_name)-        DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)-          WHERE streams.deleted_at IS NULL-        RETURNING stream_id, stream_version - (SELECT count(*) FROM new_events) AS initial_version-      ),-      inserted_events AS (-        INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)-        SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at-        FROM new_events-        WHERE EXISTS (SELECT 1 FROM stream_upsert)-        ORDER BY idx-      ),-      source_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-        SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx-        FROM new_events ne-        CROSS JOIN stream_upsert su-      ),-      all_update AS (-        UPDATE streams-        SET stream_version = stream_version + (SELECT count(*) FROM new_events)-        WHERE stream_id = 0-          AND EXISTS (SELECT 1 FROM stream_upsert)-        RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version-      ),-      all_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-        SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx-        FROM new_events ne-        CROSS JOIN all_update au-        CROSS JOIN stream_upsert su-      )-    SELECT su.stream_id,-           su.initial_version + (SELECT count(*) FROM new_events),-           au.initial_global_version + (SELECT count(*) FROM new_events)-    FROM stream_upsert su-    CROSS JOIN all_update au-    """+  """+  WITH+    new_events AS (+      SELECT *+      FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+      WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+    ),+    stream_upsert AS (+      INSERT INTO streams (stream_name, stream_version)+      VALUES ($8, (SELECT count(*) FROM new_events))+      ON CONFLICT (stream_name)+      DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)+        WHERE streams.deleted_at IS NULL+      RETURNING stream_id, stream_version - (SELECT count(*) FROM new_events) AS initial_version+    ),+    inserted_events AS (+      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+      SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+      FROM new_events+      WHERE EXISTS (SELECT 1 FROM stream_upsert)+      ORDER BY idx+    ),+    source_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+      FROM new_events ne+      CROSS JOIN stream_upsert su+    ),+    all_update AS (+      UPDATE streams+      SET stream_version = stream_version + (SELECT count(*) FROM new_events)+      WHERE stream_id = 0+        AND EXISTS (SELECT 1 FROM stream_upsert)+      RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+    ),+    all_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx+      FROM new_events ne+      CROSS JOIN all_update au+      CROSS JOIN stream_upsert su+    )+  SELECT su.stream_id,+         su.initial_version + (SELECT count(*) FROM new_events),+         au.initial_global_version + (SELECT count(*) FROM new_events)+  FROM stream_upsert su+  CROSS JOIN all_update au+  """  namedStreams :: Text -> Int -> [(StreamName, Text)] namedStreams prefix count =-    [ ( StreamName (prefix <> "-" <> T.pack (show index))-      , "WorkloadGate" <> T.pack (show index)-      )-    | index <- [1 .. count]-    ]+  [ ( StreamName (prefix <> "-" <> T.pack (show index)),+      "WorkloadGate" <> T.pack (show index)+    )+  | index <- [1 .. count]+  ]  seedStreams :: KirokuStore -> [(StreamName, Text)] -> IO () seedStreams store streams =-    mapM_-        ( \(streamName, eventType) -> do-            result <- runStoreIO store $ appendToStream streamName NoStream [makeEvent (eventType <> "Seed")]-            forceStoreResults "seed append" (fmap pure result)-        )-        streams+  mapM_+    ( \(streamName, eventType) -> do+        result <- runStoreIO store $ appendToStream streamName NoStream [makeEvent (eventType <> "Seed")]+        forceStoreResults "seed append" (fmap pure result)+    )+    streams  runProductionMultiAppend :: KirokuStore -> [(StreamName, Text)] -> IO () runProductionMultiAppend store streams = do-    result <--        runStoreIO store $-            appendMultiStream-                [ (streamName, AnyVersion, [makeEvent eventType])-                | (streamName, eventType) <- streams-                ]-    forceStoreResults "production appendMultiStream" result+  result <-+    runStoreIO store $+      appendMultiStream+        [ (streamName, AnyVersion, [makeEvent eventType])+        | (streamName, eventType) <- streams+        ]+  forceStoreResults "production appendMultiStream" result  runSequentialMultiAppend :: KirokuStore -> [(StreamName, Text)] -> IO () runSequentialMultiAppend store streams = do-    now <- getCurrentTime-    preparedOps <--        forM streams $ \(streamName@(StreamName name), eventType) -> do-            enriched <- enrichEvents (store ^. #storeSettings) [makeEvent eventType]-            prepared <- prepareEvents enriched-            pure (streamName, name, buildAppendParams name now prepared)-    let names = V.fromList [name | (_, name, _) <- preparedOps]-        transaction = do-            Tx.statement names SQL.lockStreamsForMultiStmt-            results <--                forM preparedOps $ \(_, _, params) ->-                    appendDispatchTx AnyVersion params-            if any isNothing results-                then Tx.condemn >> pure results-                else pure results-    result <--        Pool.use (store ^. #pool) $-            TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write transaction-    case result of-        Left err -> error ("sequential appendMultiStream control failed: " <> show err)-        Right maybeResults -> do-            unless (all isJustAppend maybeResults) $-                error "sequential appendMultiStream control returned an empty append result"-            forceAppendResults [appendResult | Just appendResult <- maybeResults]+  now <- getCurrentTime+  preparedOps <-+    forM streams $ \(streamName@(StreamName name), eventType) -> do+      enriched <- enrichEvents (store ^. #storeSettings) [makeEvent eventType]+      prepared <- prepareEvents enriched+      pure (streamName, name, buildAppendParams name now prepared)+  let names = V.fromList [name | (_, name, _) <- preparedOps]+      transaction = do+        Tx.statement names SQL.lockStreamsForMultiStmt+        results <-+          forM preparedOps $ \(_, _, params) ->+            appendDispatchTx AnyVersion params+        if any isNothing results+          then Tx.condemn >> pure results+          else pure results+  result <-+    Pool.use (store ^. #pool) $+      TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write transaction+  case result of+    Left err -> error ("sequential appendMultiStream control failed: " <> show err)+    Right maybeResults -> do+      unless (all isJustAppend maybeResults) $+        error "sequential appendMultiStream control returned an empty append result"+      forceAppendResults [appendResult | Just appendResult <- maybeResults]  makeEvent :: Text -> EventData makeEvent eventType =-    EventData-        { eventId = Nothing-        , eventType = EventType eventType-        , payload = Aeson.object [("workloadGate", Aeson.Bool True)]-        , metadata = Nothing-        , causationId = Nothing-        , correlationId = Nothing-        }+  EventData+    { eventId = Nothing,+      eventType = EventType eventType,+      payload = Aeson.object [("workloadGate", Aeson.Bool True)],+      metadata = Nothing,+      causationId = Nothing,+      correlationId = Nothing+    }  isJustAppend :: Maybe AppendResult -> Bool isJustAppend (Just _) = True@@ -410,8 +716,8 @@  forceAppendResults :: [AppendResult] -> IO () forceAppendResults =-    mapM_ $ \result ->-        (result ^. #streamId) `seq`-            (result ^. #streamVersion) `seq`-                (result ^. #globalPosition) `seq`-                    pure ()+  mapM_ $ \result ->+    (result ^. #streamId) `seq`+      (result ^. #streamVersion) `seq`+        (result ^. #globalPosition) `seq`+          pure ()
bench/ShibuyaOverhead.hs view
@@ -1,16 +1,15 @@-{- | Benchmark measuring the overhead of the Shibuya adapter layer-compared to bare kiroku subscriptions. Three layers are measured:--1. Bare subscribe: handler receives events directly from the subscription worker-2. subscriptionStream: events pass through a TBQueue bridge to a Streamly stream-3. Shibuya adapter: events flow through subscriptionStream → morphInner → Shibuya pipeline--The bare subscribe handler processes events synchronously (one at a time),-while the stream-based approaches decouple the producer from the consumer-via a TBQueue, allowing the subscription worker to batch-fetch ahead.-This means subscriptionStream and Shibuya can be *faster* for catch-up —-the relevant overhead comparison is shibuya vs subscriptionStream.--}+-- | Benchmark measuring the overhead of the Shibuya adapter layer+-- compared to bare kiroku subscriptions. Three layers are measured:+--+-- 1. Bare subscribe: handler receives events directly from the subscription worker+-- 2. subscriptionStream: events pass through a TBQueue bridge to a Streamly stream+-- 3. Shibuya adapter: events flow through subscriptionStream → morphInner → Shibuya pipeline+--+-- The bare subscribe handler processes events synchronously (one at a time),+-- while the stream-based approaches decouple the producer from the consumer+-- via a TBQueue, allowing the subscription worker to batch-fetch ahead.+-- This means subscriptionStream and Shibuya can be *faster* for catch-up —+-- the relevant overhead comparison is shibuya vs subscriptionStream. module Main where  import Control.Concurrent (threadDelay)@@ -35,7 +34,7 @@ import Shibuya.App (ProcessorId (..), defaultAppConfig, mkProcessor, runApp, stopApp) import Shibuya.Core.Ack (AckDecision (..)) import Shibuya.Core.AckHandle (AckHandle (..))-import Shibuya.Core.Ingested (Ingested (..), Message)+import Shibuya.Core.Ingested (DeliveryStatus (..), Ingested (..), Message) import Shibuya.Core.Types (Cursor (..), Envelope (..), MessageId (..)) import Shibuya.Telemetry.Effect (runTracingNoop) import Streamly.Data.Fold qualified as Fold@@ -48,269 +47,270 @@  main :: IO () main = do-    config <- ephemeralConfig-    result <- Pg.withCachedConfig config Pg.defaultCacheConfig $ \db -> do-        migrateTestDatabase (Pg.connectionString db)-        let settings = defaultConnectionSettings (Pg.connectionString db)-        withStore settings $ \store -> do-            putStrLn "=== Kiroku Shibuya Adapter Overhead Benchmark ==="-            printf "    %d iterations per measurement\n\n" iterations+  config <- ephemeralConfig+  result <- Pg.withCachedConfig config Pg.defaultCacheConfig $ \db -> do+    migrateTestDatabase (Pg.connectionString db)+    let settings = defaultConnectionSettings (Pg.connectionString db)+    withStore settings $ \store -> do+      putStrLn "=== Kiroku Shibuya Adapter Overhead Benchmark ==="+      printf "    %d iterations per measurement\n\n" iterations -            mapM_ (runBenchmarkSuite store) [100, 1000, 5000]+      mapM_ (runBenchmarkSuite store) [100, 1000, 5000] -    case result of-        Left err -> error ("Failed to start ephemeral PostgreSQL: " <> show err)-        Right () -> pure ()+  case result of+    Left err -> error ("Failed to start ephemeral PostgreSQL: " <> show err)+    Right () -> pure ()  runBenchmarkSuite :: KirokuStore -> Int -> IO () runBenchmarkSuite store n = do-    printf "--- %d events ---\n\n" n+  printf "--- %d events ---\n\n" n -    -- Pre-populate events-    counter <- newIORef (0 :: Int)-    let nextId = atomicModifyIORef' counter (\i -> (i + 1, i))+  -- Pre-populate events+  counter <- newIORef (0 :: Int)+  let nextId = atomicModifyIORef' counter (\i -> (i + 1, i)) -    let streamPrefix = "bench-" <> T.pack (show n) <> "-"-    let events = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. n]-    let chunked = chunksOf 10 events-    mapM_-        ( \(idx, chunk) -> do-            let sn = StreamName (streamPrefix <> T.pack (show idx))-            Right _ <- runStoreIO store $ appendToStream sn AnyVersion chunk-            pure ()-        )-        (zip [(0 :: Int) ..] chunked)-    threadDelay 500_000+  let streamPrefix = "bench-" <> T.pack (show n) <> "-"+  let events = map (\i -> makeEvent ("E" <> T.pack (show i))) [1 .. n]+  let chunked = chunksOf 10 events+  mapM_+    ( \(idx, chunk) -> do+        let sn = StreamName (streamPrefix <> T.pack (show idx))+        Right _ <- runStoreIO store $ appendToStream sn AnyVersion chunk+        pure ()+    )+    (zip [(0 :: Int) ..] chunked)+  threadDelay 500_000 -    -- Run each benchmark multiple times-    bareTimes <- sequence [benchBareSubscribe store n nextId | _ <- [1 .. iterations]]-    streamTimes <- sequence [benchSubscriptionStream store n nextId | _ <- [1 .. iterations]]-    shibuyaTimes <- sequence [benchShibuyaAdapter store n nextId | _ <- [1 .. iterations]]+  -- Run each benchmark multiple times+  bareTimes <- sequence [benchBareSubscribe store n nextId | _ <- [1 .. iterations]]+  streamTimes <- sequence [benchSubscriptionStream store n nextId | _ <- [1 .. iterations]]+  shibuyaTimes <- sequence [benchShibuyaAdapter store n nextId | _ <- [1 .. iterations]] -    let bareMedian = median bareTimes-        streamMedian = median streamTimes-        shibuyaMedian = median shibuyaTimes+  let bareMedian = median bareTimes+      streamMedian = median streamTimes+      shibuyaMedian = median shibuyaTimes -    putRow "  bare subscribe" n bareTimes-    putRow "  subscriptionStream" n streamTimes-    putRow "  shibuya adapter" n shibuyaTimes+  putRow "  bare subscribe" n bareTimes+  putRow "  subscriptionStream" n streamTimes+  putRow "  shibuya adapter" n shibuyaTimes -    putStrLn ""-    let streamVsBare = (streamMedian - bareMedian) / bareMedian * 100-        shibuyaVsBare = (shibuyaMedian - bareMedian) / bareMedian * 100-        shibuyaVsStream = (shibuyaMedian - streamMedian) / streamMedian * 100-    printf "  stream vs bare:    %s\n" (showPct streamVsBare)-    printf "  shibuya vs bare:   %s\n" (showPct shibuyaVsBare)-    printf "  shibuya vs stream: %s  (adapter + framework overhead)\n" (showPct shibuyaVsStream)-    putStrLn ""+  putStrLn ""+  let streamVsBare = (streamMedian - bareMedian) / bareMedian * 100+      shibuyaVsBare = (shibuyaMedian - bareMedian) / bareMedian * 100+      shibuyaVsStream = (shibuyaMedian - streamMedian) / streamMedian * 100+  printf "  stream vs bare:    %s\n" (showPct streamVsBare)+  printf "  shibuya vs bare:   %s\n" (showPct shibuyaVsBare)+  printf "  shibuya vs stream: %s  (adapter + framework overhead)\n" (showPct shibuyaVsStream)+  putStrLn ""  -- | Benchmark: bare subscribe with IO handler benchBareSubscribe :: KirokuStore -> Int -> IO Int -> IO Double benchBareSubscribe store n nextId = do-    subId <- nextId-    let subName = SubscriptionName ("bare-bench-" <> T.pack (show subId))-    countVar <- newTVarIO (0 :: Int)+  subId <- nextId+  let subName = SubscriptionName ("bare-bench-" <> T.pack (show subId))+  countVar <- newTVarIO (0 :: Int) -    t0 <- getCurrentTime-    let handler _evt = do-            atomically $ do-                c <- readTVar countVar-                writeTVar countVar (c + 1)-            pure Continue-    let cfg =-            SubscriptionConfig-                { name = subName-                , target = AllStreams-                , handler = handler-                , batchSize = either (error . show) Prelude.id (mkBatchSize 500)-                , queueCapacity = 16-                , overflowPolicy = DropSubscription-                , consumerGroup = Nothing-                , consumerGroupGuard = False-                , missingCheckpointPolicy = FromBeginning-                , targetBindingPolicy = AdoptUnbound-                , undecodableHandler = Nothing-                , handlerStallWarnAfter = Nothing-                , retryPolicy = defaultRetryPolicy-                , eventTypeFilter = AllEventTypes-                , selector = Nothing-                }-    handle <- subscribe store cfg-    waitForCount countVar n 30_000_000-    cancel handle-    t1 <- getCurrentTime-    pure (realToFrac (diffUTCTime t1 t0))+  t0 <- getCurrentTime+  let handler _evt = do+        atomically $ do+          c <- readTVar countVar+          writeTVar countVar (c + 1)+        pure Continue+  let cfg =+        SubscriptionConfig+          { name = subName,+            target = AllStreams,+            handler = handler,+            batchSize = either (error . show) Prelude.id (mkBatchSize 500),+            queueCapacity = 16,+            overflowPolicy = DropSubscription,+            consumerGroup = Nothing,+            consumerGroupGuard = False,+            missingCheckpointPolicy = FromBeginning,+            targetBindingPolicy = AdoptUnbound,+            undecodableHandler = Nothing,+            handlerStallWarnAfter = Nothing,+            retryPolicy = defaultRetryPolicy,+            eventTypeFilter = AllEventTypes,+            selector = Nothing+          }+  handle <- subscribe store cfg+  waitForCount countVar n 30_000_000+  cancel handle+  t1 <- getCurrentTime+  pure (realToFrac (diffUTCTime t1 t0))  -- | Benchmark: subscriptionStream consumed via Streamly fold benchSubscriptionStream :: KirokuStore -> Int -> IO Int -> IO Double benchSubscriptionStream store n nextId = do-    subId <- nextId-    let subName = SubscriptionName ("stream-bench-" <> T.pack (show subId))+  subId <- nextId+  let subName = SubscriptionName ("stream-bench-" <> T.pack (show subId)) -    let cfg =-            SubscriptionConfig-                { name = subName-                , target = AllStreams-                , handler = \_ -> pure Continue-                , batchSize = either (error . show) Prelude.id (mkBatchSize 500)-                , queueCapacity = 16-                , overflowPolicy = DropSubscription-                , consumerGroup = Nothing-                , consumerGroupGuard = False-                , missingCheckpointPolicy = FromBeginning-                , targetBindingPolicy = AdoptUnbound-                , undecodableHandler = Nothing-                , handlerStallWarnAfter = Nothing-                , retryPolicy = defaultRetryPolicy-                , eventTypeFilter = AllEventTypes-                , selector = Nothing-                }+  let cfg =+        SubscriptionConfig+          { name = subName,+            target = AllStreams,+            handler = \_ -> pure Continue,+            batchSize = either (error . show) Prelude.id (mkBatchSize 500),+            queueCapacity = 16,+            overflowPolicy = DropSubscription,+            consumerGroup = Nothing,+            consumerGroupGuard = False,+            missingCheckpointPolicy = FromBeginning,+            targetBindingPolicy = AdoptUnbound,+            undecodableHandler = Nothing,+            handlerStallWarnAfter = Nothing,+            retryPolicy = defaultRetryPolicy,+            eventTypeFilter = AllEventTypes,+            selector = Nothing+          } -    t0 <- getCurrentTime-    (stream, cancelStream) <- subscriptionStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 256))-    Stream.fold Fold.drain (Stream.take n stream)-    cancelStream-    t1 <- getCurrentTime-    pure (realToFrac (diffUTCTime t1 t0))+  t0 <- getCurrentTime+  (stream, cancelStream) <- subscriptionStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 256))+  Stream.fold Fold.drain (Stream.take n stream)+  cancelStream+  t1 <- getCurrentTime+  pure (realToFrac (diffUTCTime t1 t0))  -- | Benchmark: Shibuya adapter with runApp benchShibuyaAdapter :: KirokuStore -> Int -> IO Int -> IO Double benchShibuyaAdapter store n nextId = do-    subId <- nextId-    let subName = SubscriptionName ("shibuya-bench-" <> T.pack (show subId))-    countVar <- newTVarIO (0 :: Int)+  subId <- nextId+  let subName = SubscriptionName ("shibuya-bench-" <> T.pack (show subId))+  countVar <- newTVarIO (0 :: Int) -    t0 <- getCurrentTime-    runEff $ runTracingNoop $ do-        let cfg =-                SubscriptionConfig-                    { name = subName-                    , target = AllStreams-                    , handler = \_ -> pure Continue-                    , batchSize = either (error . show) Prelude.id (mkBatchSize 500)-                    , queueCapacity = 16-                    , overflowPolicy = DropSubscription-                    , consumerGroup = Nothing-                    , consumerGroupGuard = False-                    , missingCheckpointPolicy = FromBeginning-                    , targetBindingPolicy = AdoptUnbound-                    , undecodableHandler = Nothing-                    , handlerStallWarnAfter = Nothing-                    , retryPolicy = defaultRetryPolicy-                    , eventTypeFilter = AllEventTypes-                    , selector = Nothing-                    }+  t0 <- getCurrentTime+  runEff $ runTracingNoop $ do+    let cfg =+          SubscriptionConfig+            { name = subName,+              target = AllStreams,+              handler = \_ -> pure Continue,+              batchSize = either (error . show) Prelude.id (mkBatchSize 500),+              queueCapacity = 16,+              overflowPolicy = DropSubscription,+              consumerGroup = Nothing,+              consumerGroupGuard = False,+              missingCheckpointPolicy = FromBeginning,+              targetBindingPolicy = AdoptUnbound,+              undecodableHandler = Nothing,+              handlerStallWarnAfter = Nothing,+              retryPolicy = defaultRetryPolicy,+              eventTypeFilter = AllEventTypes,+              selector = Nothing+            } -        (ioStream, cancelAction) <- liftIO $ subscriptionStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 256))+    (ioStream, cancelAction) <- liftIO $ subscriptionStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 256)) -        let effStream = Stream.morphInner liftIO ioStream-            ingestedStream = fmap (mkIngested cancelAction) effStream+    let effStream = Stream.morphInner liftIO ioStream+        ingestedStream = fmap (mkIngested cancelAction) effStream -        let adapter =-                Adapter-                    { adapterName = "kiroku-bench"-                    , source = ingestedStream-                    , shutdown = liftIO cancelAction-                    }+    let adapter =+          Adapter+            { adapterName = "kiroku-bench",+              source = ingestedStream,+              shutdown = liftIO cancelAction+            } -        let handler :: (IOE :> es) => Message es RecordedEvent -> Eff es AckDecision-            handler _ingested = do-                liftIO $ atomically $ do-                    c <- readTVar countVar-                    writeTVar countVar (c + 1)-                pure AckOk+    let handler :: (IOE :> es) => Message es RecordedEvent -> Eff es AckDecision+        handler _ingested = do+          liftIO $ atomically $ do+            c <- readTVar countVar+            writeTVar countVar (c + 1)+          pure AckOk -        res <- runApp defaultAppConfig [(ProcessorId "bench", mkProcessor adapter handler)]-        case res of-            Left err -> liftIO $ error ("runApp failed: " <> show err)-            Right appHandle -> do-                liftIO $ waitForCount countVar n 30_000_000-                stopApp appHandle+    res <- runApp defaultAppConfig [(ProcessorId "bench", mkProcessor adapter handler)]+    case res of+      Left err -> liftIO $ error ("runApp failed: " <> show err)+      Right appHandle -> do+        liftIO $ waitForCount countVar n 30_000_000+        stopApp appHandle -    t1 <- getCurrentTime-    pure (realToFrac (diffUTCTime t1 t0))+  t1 <- getCurrentTime+  pure (realToFrac (diffUTCTime t1 t0))  -- Helpers  mkIngested :: (IOE :> es) => IO () -> RecordedEvent -> Ingested es RecordedEvent mkIngested cancelAction event =-    Ingested-        { envelope =-            Envelope-                { messageId = MessageId (T.pack (show (event ^. #globalPosition)))-                , cursor = let GlobalPosition pos = event ^. #globalPosition in Just (CursorInt (fromIntegral pos))-                , partition = Nothing-                , enqueuedAt = Just (event ^. #createdAt)-                , traceContext = Nothing-                , headers = Nothing-                , attempt = Nothing-                , attributes = HashMap.empty-                , payload = event-                }-        , ack =-            AckHandle-                { finalize = \case-                    AckHalt _ -> liftIO cancelAction-                    _ -> pure ()-                }-        , lease = Nothing-        }+  Ingested+    { envelope =+        Envelope+          { messageId = MessageId (T.pack (show (event ^. #globalPosition))),+            cursor = let GlobalPosition pos = event ^. #globalPosition in Just (CursorInt (fromIntegral pos)),+            partition = Nothing,+            enqueuedAt = Just (event ^. #createdAt),+            traceContext = Nothing,+            headers = Nothing,+            attempt = Nothing,+            attributes = HashMap.empty,+            payload = event+          },+      ack =+        AckHandle+          { finalize = \case+              AckHalt _ -> liftIO cancelAction+              _ -> pure ()+          },+      lease = Nothing,+      deliveryStatus = pure DeliveryCurrent+    }  waitForCount :: STM.TVar Int -> Int -> Int -> IO () waitForCount countVar target timeoutMicros = do-    timeoutVar <- registerDelay timeoutMicros-    ok <--        atomically $-            (readTVar countVar >>= \c -> STM.check (c >= target) >> pure True)-                `STM.orElse` (readTVar timeoutVar >>= STM.check >> pure False)-    actual <- readTVarIO countVar-    if ok-        then pure ()-        else error ("Timed out: expected " <> show target <> " events, got " <> show actual)+  timeoutVar <- registerDelay timeoutMicros+  ok <-+    atomically $+      (readTVar countVar >>= \c -> STM.check (c >= target) >> pure True)+        `STM.orElse` (readTVar timeoutVar >>= STM.check >> pure False)+  actual <- readTVarIO countVar+  if ok+    then pure ()+    else error ("Timed out: expected " <> show target <> " events, got " <> show actual)  median :: [Double] -> Double median xs =-    let sorted = sort xs-        len = length sorted-     in if even len-            then (sorted !! (len `div` 2 - 1) + sorted !! (len `div` 2)) / 2-            else sorted !! (len `div` 2)+  let sorted = sort xs+      len = length sorted+   in if even len+        then (sorted !! (len `div` 2 - 1) + sorted !! (len `div` 2)) / 2+        else sorted !! (len `div` 2)  putRow :: String -> Int -> [Double] -> IO () putRow label n times = do-    let med = median times-        lo = minimum times-        hi = maximum times-        throughput = fromIntegral n / med :: Double-        perEvent = med / fromIntegral n * 1_000_000 :: Double-    printf-        "%s:  median %s  [%s .. %s]  (%d events/s, %s/event)\n"-        label-        (showMs med)-        (showMs lo)-        (showMs hi)-        (round throughput :: Int)-        (showUs perEvent)-    hFlush stdout+  let med = median times+      lo = minimum times+      hi = maximum times+      throughput = fromIntegral n / med :: Double+      perEvent = med / fromIntegral n * 1_000_000 :: Double+  printf+    "%s:  median %s  [%s .. %s]  (%d events/s, %s/event)\n"+    label+    (showMs med)+    (showMs lo)+    (showMs hi)+    (round throughput :: Int)+    (showUs perEvent)+  hFlush stdout  showMs :: Double -> String showMs s-    | ms < 1 = printf "%.1f ms" ms-    | ms < 100 = printf "%.0f ms" ms-    | otherwise = printf "%.0f ms" ms+  | ms < 1 = printf "%.1f ms" ms+  | ms < 100 = printf "%.0f ms" ms+  | otherwise = printf "%.0f ms" ms   where     ms = s * 1000  showUs :: Double -> String showUs us-    | us >= 1000 = printf "%.1f ms" (us / 1000)-    | us >= 1 = printf "%.0f μs" us-    | otherwise = printf "%.1f μs" us+  | us >= 1000 = printf "%.1f ms" (us / 1000)+  | us >= 1 = printf "%.0f μs" us+  | otherwise = printf "%.1f μs" us  showPct :: Double -> String showPct p-    | p >= 0 = printf "+%.0f%%" p-    | otherwise = printf "%.0f%%" p+  | p >= 0 = printf "+%.0f%%" p+  | otherwise = printf "%.0f%%" p  chunksOf :: Int -> [a] -> [[a]] chunksOf _ [] = []@@ -318,11 +318,11 @@  makeEvent :: Text -> EventData makeEvent typ =-    EventData-        { eventId = Nothing-        , eventType = EventType typ-        , payload = Aeson.object [("benchmark", Aeson.Bool True)]-        , metadata = Nothing-        , causationId = Nothing-        , correlationId = Nothing-        }+  EventData+    { eventId = Nothing,+      eventType = EventType typ,+      payload = Aeson.object [("benchmark", Aeson.Bool True)],+      metadata = Nothing,+      causationId = Nothing,+      correlationId = Nothing+    }
bench/StreamHeadCost.hs view
@@ -23,59 +23,59 @@  main :: IO () main = do-    start <- getCurrentTime-    withSharedMigratedPostgres $ withMigratedTestDatabase $ \connection ->-        withStore (defaultConnectionSettings connection) $ \store -> do-            sql store streamHeadFixtureSql-            sql store "VACUUM (ANALYZE) stream_events"-            details <--                Pool.use (store ^. #pool) $-                    Session.statement () $-                        preparable-                            "SELECT json_build_object('server', version(), 'shared_buffers', current_setting('shared_buffers'), 'work_mem', current_setting('work_mem'), 'jit', current_setting('jit'), 'plan_cache_mode', current_setting('plan_cache_mode'), 'streams', (SELECT count(*) FROM streams), 'events', (SELECT count(*) FROM events), 'junctions', (SELECT count(*) FROM stream_events))::text"-                            E.noParams-                            (D.singleRow (D.column (D.nonNullable D.text)))-            either (error . show) (putStrLn . T.unpack) details-            setup <- getCurrentTime-            putStrLn ("setup_seconds=" <> show (diffUTCTime setup start))-            groups <- mapM (sizeGroup store) [("100", StreamName "bench-1"), ("100000", StreamName "long-1")]-            warmed <- getCurrentTime-            putStrLn ("warmup_seconds=" <> show (diffUTCTime warmed setup))-            defaultMain groups `finally` do-                finished <- getCurrentTime-                putStrLn ("measurement_seconds=" <> show (diffUTCTime finished warmed))-                putStrLn ("total_seconds=" <> show (diffUTCTime finished start))+  start <- getCurrentTime+  withSharedMigratedPostgres $ withMigratedTestDatabase $ \connection ->+    withStore (defaultConnectionSettings connection) $ \store -> do+      sql store streamHeadFixtureSql+      sql store "VACUUM (ANALYZE) stream_events"+      details <-+        Pool.use (store ^. #pool) $+          Session.statement () $+            preparable+              "SELECT json_build_object('server', version(), 'shared_buffers', current_setting('shared_buffers'), 'work_mem', current_setting('work_mem'), 'jit', current_setting('jit'), 'plan_cache_mode', current_setting('plan_cache_mode'), 'streams', (SELECT count(*) FROM streams), 'events', (SELECT count(*) FROM events), 'junctions', (SELECT count(*) FROM stream_events))::text"+              E.noParams+              (D.singleRow (D.column (D.nonNullable D.text)))+      either (error . show) (putStrLn . T.unpack) details+      setup <- getCurrentTime+      putStrLn ("setup_seconds=" <> show (diffUTCTime setup start))+      groups <- mapM (sizeGroup store) [("100", StreamName "bench-1"), ("100000", StreamName "long-1")]+      warmed <- getCurrentTime+      putStrLn ("warmup_seconds=" <> show (diffUTCTime warmed setup))+      defaultMain groups `finally` do+        finished <- getCurrentTime+        putStrLn ("measurement_seconds=" <> show (diffUTCTime finished warmed))+        putStrLn ("total_seconds=" <> show (diffUTCTime finished start))  sizeGroup :: KirokuStore -> (String, StreamName) -> IO Benchmark sizeGroup store (label, name) = do-    Right (Just expected) <- runFrozen store (getStream name)-    unless (expected ^. #version == StreamVersion (read label)) (error "invalid fixture version")-    let validate result = case result of-            Right (Just actual) -> unless (actual == expected) (error "unexpected metadata")-            other -> error ("metadata read failed: " <> show other)-        control = replicateM_ 100 (runFrozen store (getStream name) >>= validate)-        production = replicateM_ 100 (runStoreIO store (getStream name) >>= validate)-    -- Equality checks every StreamInfo field in successful calls in both arms.-    control-    production-    let withHead = replicateM_ 100 $ do-            result <- runStoreIO store (getStreamWithHead name)-            case result of-                Right (Just (actual, Just headPosition)) -> do-                    validate (Right (Just actual))-                    unless-                        (headPosition == GlobalPosition (if label == "100" then 99001 else 200000))-                        (error "unexpected originated head")-                other -> error ("head read failed: " <> show other)-    withHead-    pure $-        bgroup-            label-            [ bench "control-metadata" (whnfIO control)-            , bcompareWithin 0 1.10 ("$(NF-1) == \"" <> label <> "\" && $NF == \"control-metadata\"") $-                bench "production-metadata" (whnfIO production)-            , bench "production-with-head" (whnfIO withHead)-            ]+  Right (Just expected) <- runFrozen store (getStream name)+  unless (expected ^. #version == StreamVersion (read label)) (error "invalid fixture version")+  let validate result = case result of+        Right (Just actual) -> unless (actual == expected) (error "unexpected metadata")+        other -> error ("metadata read failed: " <> show other)+      control = replicateM_ 100 (runFrozen store (getStream name) >>= validate)+      production = replicateM_ 100 (runStoreIO store (getStream name) >>= validate)+  -- Equality checks every StreamInfo field in successful calls in both arms.+  control+  production+  let withHead = replicateM_ 100 $ do+        result <- runStoreIO store (getStreamWithHead name)+        case result of+          Right (Just (actual, Just headPosition)) -> do+            validate (Right (Just actual))+            unless+              (headPosition == GlobalPosition (if label == "100" then 99001 else 200000))+              (error "unexpected originated head")+          other -> error ("head read failed: " <> show other)+  withHead+  pure $+    bgroup+      label+      [ bench "control-metadata" (whnfIO control),+        bcompareWithin 0 1.10 ("$(NF-1) == \"" <> label <> "\" && $NF == \"control-metadata\"") $+          bench "production-metadata" (whnfIO production),+        bench "production-with-head" (whnfIO withHead)+      ]  sql :: KirokuStore -> T.Text -> IO () sql store command = Pool.use (store ^. #pool) (Session.script command) >>= either (error . show) pure@@ -87,33 +87,33 @@  frozenInterpreter :: (IOE :> es, Error StoreError :> es) => KirokuStore -> Eff (Store : es) a -> Eff es a frozenInterpreter store = interpret_ $ \case-    GetStream (StreamName name) -> frozenPool (store ^. #pool) (Session.statement name frozenStatement)-    _ -> error "unexpected operation in frozen metadata control"+  GetStream (StreamName name) -> frozenPool (store ^. #pool) (Session.statement name frozenStatement)+  _ -> error "unexpected operation in frozen metadata control"  frozenPool :: (IOE :> es, Error StoreError :> es) => Pool.Pool -> Session.Session a -> Eff es a frozenPool pool session = do-    result <- liftIO (Pool.use pool session)-    case result of-        Left usageErr -> throwError (ConnectionError (T.pack (show usageErr)))-        Right a -> pure a+  result <- liftIO (Pool.use pool session)+  case result of+    Left usageErr -> throwError (ConnectionError (T.pack (show usageErr)))+    Right a -> pure a  frozenStatement :: Statement T.Text (Maybe StreamInfo) frozenStatement =-    preparable-        """-        SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before-        FROM streams-        WHERE stream_name = $1-        """-        (E.param (E.nonNullable E.text))-        (D.rowMaybe frozenRow)+  preparable+    """+    SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before+    FROM streams+    WHERE stream_name = $1+    """+    (E.param (E.nonNullable E.text))+    (D.rowMaybe frozenRow)  frozenRow :: D.Row StreamInfo frozenRow =-    StreamInfo-        <$> (StreamId <$> D.column (D.nonNullable D.int8))-        <*> (StreamName <$> D.column (D.nonNullable D.text))-        <*> (StreamVersion <$> D.column (D.nonNullable D.int8))-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nullable D.timestamptz)-        <*> (StreamVersion <$> D.column (D.nonNullable D.int8))+  StreamInfo+    <$> (StreamId <$> D.column (D.nonNullable D.int8))+    <*> (StreamName <$> D.column (D.nonNullable D.text))+    <*> (StreamVersion <$> D.column (D.nonNullable D.int8))+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)+    <*> (StreamVersion <$> D.column (D.nonNullable D.int8))
bench/StreamHeadPaired.hs view
@@ -26,82 +26,82 @@ -- ABBA/BAAB schedule. Each CSV row is one batch; no adaptive early stopping. main :: IO () main = do-    [schedulePath, outputPath, callsText] <- getArgs-    let calls = read callsText :: Int-    unless (calls > 0 && calls <= 10000 && calls `mod` 5 == 0) (error "invalid batch size")-    schedule <- map (T.splitOn ",") . T.lines . T.pack <$> readFile schedulePath-    start <- getCurrentTime-    withSharedMigratedPostgres $ withMigratedTestDatabase $ \connection ->-        withStore (defaultConnectionSettings connection) $ \store -> do-            sql store streamHeadFixtureSql-            sql store "VACUUM (ANALYZE) stream_events"-            details <--                Pool.use (store ^. #pool) $-                    Session.statement () $-                        preparable-                            "SELECT json_build_object('server', version(), 'shared_buffers', current_setting('shared_buffers'), 'work_mem', current_setting('work_mem'), 'jit', current_setting('jit'), 'plan_cache_mode', current_setting('plan_cache_mode'), 'streams', (SELECT count(*) FROM streams), 'events', (SELECT count(*) FROM events), 'junctions', (SELECT count(*) FROM stream_events))::text"-                            E.noParams-                            (D.singleRow (D.column (D.nonNullable D.text)))-            either (error . show) (putStrLn . T.unpack) details-            setup <- getCurrentTime-            putStrLn ("setup_seconds=" <> show (diffUTCTime setup start))-            small <- actions store (StreamName "bench-1") (StreamVersion 100)-            large <- actions store (StreamName "long-1") (StreamVersion 100000)-            let (frozenSmall, productionSmall) = small-                (frozenLarge, productionLarge) = large-                groups =-                    [ ("legacy-100", (frozenSmall, productionSmall, calls))-                    , ("legacy-100000", (frozenLarge, productionLarge, calls))-                    , ("aa-100", (productionSmall, productionSmall, calls))-                    , ("aa-100000", (productionLarge, productionLarge, calls))-                    , ("positive-20pct", (productionLarge, productionLarge, calls * 6 `div` 5))+  [schedulePath, outputPath, callsText] <- getArgs+  let calls = read callsText :: Int+  unless (calls > 0 && calls <= 10000 && calls `mod` 5 == 0) (error "invalid batch size")+  schedule <- map (T.splitOn ",") . T.lines . T.pack <$> readFile schedulePath+  start <- getCurrentTime+  withSharedMigratedPostgres $ withMigratedTestDatabase $ \connection ->+    withStore (defaultConnectionSettings connection) $ \store -> do+      sql store streamHeadFixtureSql+      sql store "VACUUM (ANALYZE) stream_events"+      details <-+        Pool.use (store ^. #pool) $+          Session.statement () $+            preparable+              "SELECT json_build_object('server', version(), 'shared_buffers', current_setting('shared_buffers'), 'work_mem', current_setting('work_mem'), 'jit', current_setting('jit'), 'plan_cache_mode', current_setting('plan_cache_mode'), 'streams', (SELECT count(*) FROM streams), 'events', (SELECT count(*) FROM events), 'junctions', (SELECT count(*) FROM stream_events))::text"+              E.noParams+              (D.singleRow (D.column (D.nonNullable D.text)))+      either (error . show) (putStrLn . T.unpack) details+      setup <- getCurrentTime+      putStrLn ("setup_seconds=" <> show (diffUTCTime setup start))+      small <- actions store (StreamName "bench-1") (StreamVersion 100)+      large <- actions store (StreamName "long-1") (StreamVersion 100000)+      let (frozenSmall, productionSmall) = small+          (frozenLarge, productionLarge) = large+          groups =+            [ ("legacy-100", (frozenSmall, productionSmall, calls)),+              ("legacy-100000", (frozenLarge, productionLarge, calls)),+              ("aa-100", (productionSmall, productionSmall, calls)),+              ("aa-100000", (productionLarge, productionLarge, calls)),+              ("positive-20pct", (productionLarge, productionLarge, calls * 6 `div` 5))+            ]+      -- Warm both actual implementations and connections before measurement.+      forM_ [frozenSmall, productionSmall, frozenLarge, productionLarge] (replicateM_ 1000)+      warmed <- getCurrentTime+      putStrLn ("warmup_seconds=" <> show (diffUTCTime warmed setup))+      withFile outputPath WriteMode $ \out -> do+        hSetBuffering out LineBuffering+        hPutStrLn out "round,case,order,slot,arm,calls,start_ns,end_ns,duration_ns"+        forM_ schedule $ \row -> case row of+          [roundText, caseName, order] -> do+            unless (order == "ABBA" || order == "BAAB") (error "invalid order")+            (armA, armB, callsB) <- maybe (error "unknown case") pure (lookup caseName groups)+            forM_ (zip [0 :: Int ..] (T.unpack order)) $ \(slot, arm) -> do+              let count = if arm == 'A' then calls else callsB+                  action = if arm == 'A' then armA else armB+              before <- getMonotonicTimeNSec+              replicateM_ count action+              after <- getMonotonicTimeNSec+              hPutStrLn out $+                T.unpack $+                  T.intercalate+                    ","+                    [ roundText,+                      caseName,+                      order,+                      T.pack (show slot),+                      T.singleton arm,+                      T.pack (show count),+                      T.pack (show before),+                      T.pack (show after),+                      T.pack (show (after - before))                     ]-            -- Warm both actual implementations and connections before measurement.-            forM_ [frozenSmall, productionSmall, frozenLarge, productionLarge] (replicateM_ 1000)-            warmed <- getCurrentTime-            putStrLn ("warmup_seconds=" <> show (diffUTCTime warmed setup))-            withFile outputPath WriteMode $ \out -> do-                hSetBuffering out LineBuffering-                hPutStrLn out "round,case,order,slot,arm,calls,start_ns,end_ns,duration_ns"-                forM_ schedule $ \row -> case row of-                    [roundText, caseName, order] -> do-                        unless (order == "ABBA" || order == "BAAB") (error "invalid order")-                        (armA, armB, callsB) <- maybe (error "unknown case") pure (lookup caseName groups)-                        forM_ (zip [0 :: Int ..] (T.unpack order)) $ \(slot, arm) -> do-                            let count = if arm == 'A' then calls else callsB-                                action = if arm == 'A' then armA else armB-                            before <- getMonotonicTimeNSec-                            replicateM_ count action-                            after <- getMonotonicTimeNSec-                            hPutStrLn out $-                                T.unpack $-                                    T.intercalate-                                        ","-                                        [ roundText-                                        , caseName-                                        , order-                                        , T.pack (show slot)-                                        , T.singleton arm-                                        , T.pack (show count)-                                        , T.pack (show before)-                                        , T.pack (show after)-                                        , T.pack (show (after - before))-                                        ]-                    _ -> error "invalid schedule row"-            finished <- getCurrentTime-            putStrLn ("measurement_seconds=" <> show (diffUTCTime finished warmed))-            putStrLn ("total_seconds=" <> show (diffUTCTime finished start))+          _ -> error "invalid schedule row"+      finished <- getCurrentTime+      putStrLn ("measurement_seconds=" <> show (diffUTCTime finished warmed))+      putStrLn ("total_seconds=" <> show (diffUTCTime finished start))  -- The A/A arms receive the same IO action, including validation. The positive -- control repeats that same action 20% more times, without normalizing it away. actions :: KirokuStore -> StreamName -> StreamVersion -> IO (IO (), IO ()) actions store name version = do-    Right (Just expected) <- runFrozen store (getStream name)-    unless (expected ^. #version == version) (error "invalid fixture version")-    let validate result = case result of-            Right (Just actual) -> unless (actual == expected) (error "unexpected metadata")-            other -> error ("metadata read failed: " <> show other)-    pure (runFrozen store (getStream name) >>= validate, runStoreIO store (getStream name) >>= validate)+  Right (Just expected) <- runFrozen store (getStream name)+  unless (expected ^. #version == version) (error "invalid fixture version")+  let validate result = case result of+        Right (Just actual) -> unless (actual == expected) (error "unexpected metadata")+        other -> error ("metadata read failed: " <> show other)+  pure (runFrozen store (getStream name) >>= validate, runStoreIO store (getStream name) >>= validate)  sql :: KirokuStore -> T.Text -> IO () sql store command = Pool.use (store ^. #pool) (Session.script command) >>= either (error . show) pure@@ -113,33 +113,33 @@  frozenInterpreter :: (IOE :> es, Error StoreError :> es) => KirokuStore -> Eff (Store : es) a -> Eff es a frozenInterpreter store = interpret_ $ \case-    GetStream (StreamName name) -> frozenPool (store ^. #pool) (Session.statement name frozenStatement)-    _ -> error "unexpected operation in frozen metadata control"+  GetStream (StreamName name) -> frozenPool (store ^. #pool) (Session.statement name frozenStatement)+  _ -> error "unexpected operation in frozen metadata control"  frozenPool :: (IOE :> es, Error StoreError :> es) => Pool.Pool -> Session.Session a -> Eff es a frozenPool pool session = do-    result <- liftIO (Pool.use pool session)-    case result of-        Left usageErr -> throwError (ConnectionError (T.pack (show usageErr)))-        Right a -> pure a+  result <- liftIO (Pool.use pool session)+  case result of+    Left usageErr -> throwError (ConnectionError (T.pack (show usageErr)))+    Right a -> pure a  frozenStatement :: Statement T.Text (Maybe StreamInfo) frozenStatement =-    preparable-        """-        SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before-        FROM streams-        WHERE stream_name = $1-        """-        (E.param (E.nonNullable E.text))-        (D.rowMaybe frozenRow)+  preparable+    """+    SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before+    FROM streams+    WHERE stream_name = $1+    """+    (E.param (E.nonNullable E.text))+    (D.rowMaybe frozenRow)  frozenRow :: D.Row StreamInfo frozenRow =-    StreamInfo-        <$> (StreamId <$> D.column (D.nonNullable D.int8))-        <*> (StreamName <$> D.column (D.nonNullable D.text))-        <*> (StreamVersion <$> D.column (D.nonNullable D.int8))-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nullable D.timestamptz)-        <*> (StreamVersion <$> D.column (D.nonNullable D.int8))+  StreamInfo+    <$> (StreamId <$> D.column (D.nonNullable D.int8))+    <*> (StreamName <$> D.column (D.nonNullable D.text))+    <*> (StreamVersion <$> D.column (D.nonNullable D.int8))+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)+    <*> (StreamVersion <$> D.column (D.nonNullable D.int8))
kiroku-store.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            kiroku-store-version:         0.11.0.1+version:         0.12.0.0 synopsis:        High-performance PostgreSQL event store description:   Kiroku is a PostgreSQL-backed event store for Haskell applications. It@@ -122,6 +122,7 @@     Test.HistoryRetention     Test.HistoryRetentionMock     Test.InterpreterHooks+    Test.NamespaceQueryPlans     Test.NotifyGuard     Test.PerformanceStructure     Test.Properties@@ -146,6 +147,7 @@     Test.SubscriptionCheckpointWorker     Test.SubscriptionDeadLetters     Test.SubscriptionDeadLettersMock+    Test.SubscriptionNamespace     Test.SubscriptionPauseResume     Test.SubscriptionReconnect     Test.SubscriptionRegistry@@ -258,7 +260,7 @@     , kiroku-store     , kiroku-test-support     , lens                  >=5.2   && <5.4-    , shibuya-core          >=0.10  && <0.11+    , shibuya-core          >=0.12  && <0.13     , stm                   >=2.5   && <2.6     , streamly              >=0.11     , streamly-core         >=0.3   && <0.4
src/Kiroku/Store.hs view
@@ -1,14 +1,13 @@-{- | Kiroku Store — high-performance PostgreSQL event store.--Public API re-exports.--Note: The effectful @subscribe@ wrapper from "Kiroku.Store.Subscription.Effect"-is /not/ re-exported here to avoid a name clash with-"Kiroku.Store.Subscription.subscribe". Import @Kiroku.Store.Subscription.Effect@-explicitly to use the effectful version.--}-module Kiroku.Store (-    module Kiroku.Store.Types,+-- | Kiroku Store — high-performance PostgreSQL event store.+--+-- Public API re-exports.+--+-- Note: The effectful @subscribe@ wrapper from "Kiroku.Store.Subscription.Effect"+-- is /not/ re-exported here to avoid a name clash with+-- "Kiroku.Store.Subscription.subscribe". Import @Kiroku.Store.Subscription.Effect@+-- explicitly to use the effectful version.+module Kiroku.Store+  ( module Kiroku.Store.Types,     module Kiroku.Store.Connection,     module Kiroku.Store.Effect,     module Kiroku.Store.Effect.Resource,@@ -47,7 +46,8 @@     ConnectionStatus (..),     ConnectionReadyForUseReason (..),     ConnectionTerminationReason (..),-) where+  )+where  import Hasql.Pool.Observation (ConnectionReadyForUseReason (..), ConnectionStatus (..), ConnectionTerminationReason (..), Observation (..)) import Kiroku.Store.Append
src/Kiroku/Store/Append.hs view
@@ -1,7 +1,8 @@-module Kiroku.Store.Append (-    appendToStream,+module Kiroku.Store.Append+  ( appendToStream,     appendMultiStream,-) where+  )+where  import Effectful (Eff, (:>)) import Effectful.Dispatch.Dynamic (send)@@ -9,103 +10,101 @@ import Kiroku.Store.Effect (Store (..)) import Kiroku.Store.Types -{- | Append a batch of events to a single stream under the given-'ExpectedVersion' precondition.--When to use this vs.-'Kiroku.Store.Transaction.runTransactionAppending':--* Reach for 'appendToStream' by default. It is the cheapest path —-  one CTE on a pool connection, no Haskell-layer @BEGIN@/@COMMIT@-  envelope, no caller-driven retry semantics to reason about.-* Reach for 'Kiroku.Store.Transaction.runTransactionAppending' /only/-  when you need to atomically combine the append with additional SQL-  writes that the store doesn't perform (most prominently a-  projection-row insert from a downstream projection table). The-  wrapper opens an explicit 'BEGIN'/'COMMIT', enables PostgreSQL-  serialization-conflict retry of the entire body by default, and-  threads the caller's continuation into the same transaction.--Semantics:--* If the precondition fails, the entire batch is rejected — appends are-  all-or-nothing per call.-* On success, events are visible to subsequent reads on /this/ store-  handle (read-your-own-writes is guaranteed). Other store handles see-  the events after their pool connection's transaction-visibility-  horizon advances.-* The returned 'AppendResult' carries the position of the /last/ event-  in the batch.--Idempotent retries: supply 'Kiroku.Store.Types.EventData.eventId' yourself-and retry on transient failures. A retry whose previous attempt actually-committed surfaces as 'Kiroku.Store.Error.DuplicateEvent' (when the-events_pkey detail is parseable). A retry that observed-'Kiroku.Store.Error.WrongExpectedVersion' on an 'ExactVersion' append-should be treated as ambiguous: either a concurrent writer raced you or-your previous attempt succeeded; the recovery in both cases is to-re-read the stream and decide.--Errors (all variants of 'Kiroku.Store.Error.StoreError'):--* 'Kiroku.Store.Error.WrongExpectedVersion' — 'ExactVersion' mismatch.-* 'Kiroku.Store.Error.StreamNotFound' — 'StreamExists' against a missing-  or soft-deleted stream.-* 'Kiroku.Store.Error.ReservedStreamName' — the target is @$all@, which-  is the global read stream and cannot be appended as an application-  stream.-* 'Kiroku.Store.Error.StreamAlreadyExists' — 'NoStream' against an-  existing stream (including soft-deleted).-* 'Kiroku.Store.Error.DuplicateEvent' — caller-supplied 'eventId'-  collides with an existing event.-* 'Kiroku.Store.Error.EmptyAppendBatch' — the supplied event list is-  empty.--Empty event lists: passing @[]@ is a programming mistake. The-interpreter rejects the call with 'Kiroku.Store.Error.EmptyAppendBatch'-before any database work. This avoids taking the global @$all@ row lock,-firing notifications, or creating an empty stream under 'NoStream'.--}+-- | Append a batch of events to a single stream under the given+-- 'ExpectedVersion' precondition.+--+-- When to use this vs.+-- 'Kiroku.Store.Transaction.runTransactionAppending':+--+-- * Reach for 'appendToStream' by default. It is the cheapest path —+--   one CTE on a pool connection, no Haskell-layer @BEGIN@/@COMMIT@+--   envelope, no caller-driven retry semantics to reason about.+-- * Reach for 'Kiroku.Store.Transaction.runTransactionAppending' /only/+--   when you need to atomically combine the append with additional SQL+--   writes that the store doesn't perform (most prominently a+--   projection-row insert from a downstream projection table). The+--   wrapper opens an explicit 'BEGIN'/'COMMIT', enables PostgreSQL+--   serialization-conflict retry of the entire body by default, and+--   threads the caller's continuation into the same transaction.+--+-- Semantics:+--+-- * If the precondition fails, the entire batch is rejected — appends are+--   all-or-nothing per call.+-- * On success, events are visible to subsequent reads on /this/ store+--   handle (read-your-own-writes is guaranteed). Other store handles see+--   the events after their pool connection's transaction-visibility+--   horizon advances.+-- * The returned 'AppendResult' carries the position of the /last/ event+--   in the batch.+--+-- Idempotent retries: supply 'Kiroku.Store.Types.EventData.eventId' yourself+-- and retry on transient failures. A retry whose previous attempt actually+-- committed surfaces as 'Kiroku.Store.Error.DuplicateEvent' (when the+-- events_pkey detail is parseable). A retry that observed+-- 'Kiroku.Store.Error.WrongExpectedVersion' on an 'ExactVersion' append+-- should be treated as ambiguous: either a concurrent writer raced you or+-- your previous attempt succeeded; the recovery in both cases is to+-- re-read the stream and decide.+--+-- Errors (all variants of 'Kiroku.Store.Error.StoreError'):+--+-- * 'Kiroku.Store.Error.WrongExpectedVersion' — 'ExactVersion' mismatch.+-- * 'Kiroku.Store.Error.StreamNotFound' — 'StreamExists' against a missing+--   or soft-deleted stream.+-- * 'Kiroku.Store.Error.ReservedStreamName' — the target is @$all@, which+--   is the global read stream and cannot be appended as an application+--   stream.+-- * 'Kiroku.Store.Error.StreamAlreadyExists' — 'NoStream' against an+--   existing stream (including soft-deleted).+-- * 'Kiroku.Store.Error.DuplicateEvent' — caller-supplied 'eventId'+--   collides with an existing event.+-- * 'Kiroku.Store.Error.EmptyAppendBatch' — the supplied event list is+--   empty.+--+-- Empty event lists: passing @[]@ is a programming mistake. The+-- interpreter rejects the call with 'Kiroku.Store.Error.EmptyAppendBatch'+-- before any database work. This avoids taking the global @$all@ row lock,+-- firing notifications, or creating an empty stream under 'NoStream'. appendToStream ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    ExpectedVersion ->-    [EventData] ->-    Eff es AppendResult+  (HasCallStack, Store :> es) =>+  StreamName ->+  ExpectedVersion ->+  [EventData] ->+  Eff es AppendResult appendToStream name expected events = send (AppendToStream name expected events) -{- | Atomically append events to multiple streams in a single-transaction.--Either every per-stream append succeeds or all of them roll back —-there is no partial-commit state. The interpreter pre-locks the named-streams in deterministic @stream_id@ order to avoid deadlocks when two-concurrent multi-stream calls touch overlapping streams in different-user orders.--@$all@ is reserved for the global read stream. If any operation targets-@$all@, the whole call is rejected before opening the transaction with-'Kiroku.Store.Error.ReservedStreamName'.--Per-stream errors are attributed to the stream that caused them: the-@Right results@ branch of the interpreter iterates the input list and-maps each empty CTE result back to the corresponding @(StreamName,-ExpectedVersion)@. Errors raised as PostgreSQL exceptions are-attributed via 'Kiroku.Store.Error.attributeMultiStreamError', which-parses the server-error detail for the offending stream name when-possible; the latent misattribution path (a future schema change that-introduces a constraint violation visible to the client) is covered-defensively.--The returned list mirrors the input order. Each 'AppendResult' carries-the corresponding stream's final state.--Empty operation input @[]@ returns @[]@ without a database round trip.-Any per-stream empty event list is rejected with-'Kiroku.Store.Error.EmptyAppendBatch' before the transaction opens.--}+-- | Atomically append events to multiple streams in a single+-- transaction.+--+-- Either every per-stream append succeeds or all of them roll back —+-- there is no partial-commit state. The interpreter pre-locks the named+-- streams in deterministic @stream_id@ order to avoid deadlocks when two+-- concurrent multi-stream calls touch overlapping streams in different+-- user orders.+--+-- @$all@ is reserved for the global read stream. If any operation targets+-- @$all@, the whole call is rejected before opening the transaction with+-- 'Kiroku.Store.Error.ReservedStreamName'.+--+-- Per-stream errors are attributed to the stream that caused them: the+-- @Right results@ branch of the interpreter iterates the input list and+-- maps each empty CTE result back to the corresponding @(StreamName,+-- ExpectedVersion)@. Errors raised as PostgreSQL exceptions are+-- attributed via 'Kiroku.Store.Error.attributeMultiStreamError', which+-- parses the server-error detail for the offending stream name when+-- possible; the latent misattribution path (a future schema change that+-- introduces a constraint violation visible to the client) is covered+-- defensively.+--+-- The returned list mirrors the input order. Each 'AppendResult' carries+-- the corresponding stream's final state.+--+-- Empty operation input @[]@ returns @[]@ without a database round trip.+-- Any per-stream empty event list is rejected with+-- 'Kiroku.Store.Error.EmptyAppendBatch' before the transaction opens. appendMultiStream ::-    (HasCallStack, Store :> es) =>-    [(StreamName, ExpectedVersion, [EventData])] ->-    Eff es [AppendResult]+  (HasCallStack, Store :> es) =>+  [(StreamName, ExpectedVersion, [EventData])] ->+  Eff es [AppendResult] appendMultiStream ops = send (AppendMultiStream ops)
src/Kiroku/Store/Causation.hs view
@@ -1,25 +1,25 @@-{- | Smart constructors for the causation- and correlation-walking reads.--These wrap the 'Kiroku.Store.Effect.FindEvents' constructor with a filter-suited to a specific question:--* 'findCausationDescendants' — walk the causation graph forward from a-  trigger event and return every event that descended from it.-* 'findCausationAncestors' — walk the causation graph backward from a-  leaf event and return every event reachable by following-  @causation_id@ upward.-* 'findByCorrelation' — return every event whose @correlation_id@ equals-  the input.--All three reuse the existing partial indexes @ix_events_causation_id@ and-@ix_events_correlation_id@ on the @events@ table, so they do not require-any schema change.--}-module Kiroku.Store.Causation (-    findCausationDescendants,+-- | Smart constructors for the causation- and correlation-walking reads.+--+-- These wrap the 'Kiroku.Store.Effect.FindEvents' constructor with a filter+-- suited to a specific question:+--+-- * 'findCausationDescendants' — walk the causation graph forward from a+--   trigger event and return every event that descended from it.+-- * 'findCausationAncestors' — walk the causation graph backward from a+--   leaf event and return every event reachable by following+--   @causation_id@ upward.+-- * 'findByCorrelation' — return every event whose @correlation_id@ equals+--   the input.+--+-- All three reuse the existing partial indexes @ix_events_causation_id@ and+-- @ix_events_correlation_id@ on the @events@ table, so they do not require+-- any schema change.+module Kiroku.Store.Causation+  ( findCausationDescendants,     findCausationAncestors,     findByCorrelation,-) where+  )+where  import Data.UUID (UUID) import Data.Vector (Vector)@@ -29,38 +29,35 @@ import Kiroku.Store.Effect (Store (..)) import Kiroku.Store.Types -{- | Return the seed event and every event whose @causation_id@ chain leads-back to it, in ascending @global_position@ order. The seed event is-included as the depth-0 row when it exists; otherwise the result is-empty.--Uses the @ix_events_causation_id@ partial index. Cost is-@O(depth * log n)@ where @depth@ is the length of the longest chain-rooted at the seed and @n@ is the total event count.--}+-- | Return the seed event and every event whose @causation_id@ chain leads+-- back to it, in ascending @global_position@ order. The seed event is+-- included as the depth-0 row when it exists; otherwise the result is+-- empty.+--+-- Uses the @ix_events_causation_id@ partial index. Cost is+-- @O(depth * log n)@ where @depth@ is the length of the longest chain+-- rooted at the seed and @n@ is the total event count. findCausationDescendants ::-    (HasCallStack, Store :> es) =>-    EventId ->-    Eff es (Vector RecordedEvent)+  (HasCallStack, Store :> es) =>+  EventId ->+  Eff es (Vector RecordedEvent) findCausationDescendants eid = send (FindEvents (FilterCausationDescendants eid)) -{- | Return the seed event and every ancestor reachable via @causation_id@,-in depth-ascending order (the seed is first, its immediate cause is-second, etc.). The seed event is included as the depth-0 row when it-exists; otherwise the result is empty.--}+-- | Return the seed event and every ancestor reachable via @causation_id@,+-- in depth-ascending order (the seed is first, its immediate cause is+-- second, etc.). The seed event is included as the depth-0 row when it+-- exists; otherwise the result is empty. findCausationAncestors ::-    (HasCallStack, Store :> es) =>-    EventId ->-    Eff es (Vector RecordedEvent)+  (HasCallStack, Store :> es) =>+  EventId ->+  Eff es (Vector RecordedEvent) findCausationAncestors eid = send (FindEvents (FilterCausationAncestors eid)) -{- | Return every event whose @correlation_id@ equals the input, in-ascending @global_position@ order. Uses the @ix_events_correlation_id@-partial index.--}+-- | Return every event whose @correlation_id@ equals the input, in+-- ascending @global_position@ order. Uses the @ix_events_correlation_id@+-- partial index. findByCorrelation ::-    (HasCallStack, Store :> es) =>-    UUID ->-    Eff es (Vector RecordedEvent)+  (HasCallStack, Store :> es) =>+  UUID ->+  Eff es (Vector RecordedEvent) findByCorrelation cid = send (FindEvents (FilterCorrelation cid))
src/Kiroku/Store/Connection.hs view
@@ -1,10 +1,11 @@-module Kiroku.Store.Connection (-    KirokuStore (..),+module Kiroku.Store.Connection+  ( KirokuStore (..),     ConnectionSettingsM (..),     ConnectionSettings,     defaultConnectionSettings,     withStore,-) where+  )+where  import Control.Concurrent.STM (TVar, newTVarIO) import Control.Exception (bracket, onException)@@ -35,90 +36,84 @@  -- | Connection settings for the store, parameterized by monad. data ConnectionSettingsM m = ConnectionSettings-    { connString :: !Text-    {- ^ PostgreSQL connection string (libpq URI or key=value format).-    Reaches libpq verbatim; no application-level parsing or-    substitution. May contain a password.-    -}-    , poolSize :: !Int-    -- ^ Connection pool size (default: 10)-    , schema :: !Text-    {- ^ PostgreSQL schema that owns every Kiroku object-    (default: @"kiroku"@).--    This field is authoritative for two things, kept in lockstep:--    * __Table resolution.__ Every pooled connection runs-      @SET search_path TO \<schema\>, pg_catalog@ before any statement,-      so the unqualified names in "Kiroku.Store.SQL" resolve to this-      schema.-    * __Notification channel.__ The-      'Kiroku.Store.Notification.Notifier' issues-      @LISTEN \<schema\>.events@ on its dedicated connection, matching the-      channel published by the migration-created @notify_events()@-      trigger.--    To run more than one isolated 'KirokuStore' against the same database-    (for example schema-per-tenant), migrate each schema and give each-    store a distinct 'schema'; each gets its own set of Kiroku tables and-    its own notification channel.-    -}-    , extraSearchPath :: ![Text]-    {- ^ Additional schemas appended to every pooled connection's-    @search_path@, after 'schema' and before @pg_catalog@ (default: @[]@).--    The store's own unqualified names always resolve to 'schema' first.-    This is for consumers whose /application/ objects — typically inline-    projections' read-model tables, queried on the same pool as the event-    store — live in another schema (commonly @public@). Without it those-    unqualified application tables are unreachable on the store pool, since-    'schema' replaces the default @search_path@. Entries are quoted as-    identifiers; like 'schema' they are not validated at connection time-    (PostgreSQL does not validate @search_path@ entries).-    -}-    , idleInTransactionTimeout :: !Int-    -- ^ idle_in_transaction_session_timeout in seconds (default: 30)-    , statementTimeout :: !(Maybe Int)-    {- ^ When @Just s@, set @statement_timeout = 's'@ (in seconds) on-    every pooled connection via @initSession@. Bounds the wall-clock-    runtime of any single statement; protects against pathological-    queries holding pool slots indefinitely. Default 'Nothing'-    (PostgreSQL's session default applies — typically @0@,-    meaning no timeout).--    A reasonable starting value for typical workloads is @Just 30@-    (30 seconds) — long enough to absorb GC pauses and transient-    slow disks, short enough to free the pool slot under genuine-    pathology. See @docs\/PRODUCTION-TUNING.md@ for sizing-    guidance.-    -}-    , observationHandler :: !(Maybe (Observation -> m ()))-    -- ^ Optional callback for pool connection lifecycle events-    , eventHandler :: !(Maybe (KirokuEvent -> m ()))-    {- ^ Optional callback for store-emitted operational events. See-    "Kiroku.Store.Observability" for the event taxonomy. Covers-    notifier reconnection, publisher pool errors, subscription-    lifecycle and per-phase database errors, and hard-delete-    issuance — events that 'observationHandler' (which surfaces-    @hasql-pool@'s connection-lifecycle observations) does not-    cover.--    Invoked synchronously from the originating thread (notifier-    loop, publisher loop, worker loop, store interpreter); slow-    callbacks stall those loops. For callbacks that may block, fan-    out asynchronously (e.g., write to a 'TBQueue' and drain in a-    separate thread).-    -}-    , storeSettings :: !StoreSettings-    {- ^ Interpreter-level hooks applied to 'EventData' on the append-    path and to 'RecordedEvent' on the read and subscription paths.-    Defaults to 'defaultStoreSettings' (no-op).--    See "Kiroku.Store.Settings" for the hook semantics and the-    OpenTelemetry trace-context use case that motivates this seam.-    -}-    }-    deriving stock (Generic)+  { -- | PostgreSQL connection string (libpq URI or key=value format).+    --     Reaches libpq verbatim; no application-level parsing or+    --     substitution. May contain a password.+    connString :: !Text,+    -- | Connection pool size (default: 10)+    poolSize :: !Int,+    -- | PostgreSQL schema that owns every Kiroku object+    --     (default: @"kiroku"@).+    --+    --     This field is authoritative for two things, kept in lockstep:+    --+    --     * __Table resolution.__ Every pooled connection runs+    --       @SET search_path TO \<schema\>, pg_catalog@ before any statement,+    --       so the unqualified names in "Kiroku.Store.SQL" resolve to this+    --       schema.+    --     * __Notification channel.__ The+    --       'Kiroku.Store.Notification.Notifier' issues+    --       @LISTEN \<schema\>.events@ on its dedicated connection, matching the+    --       channel published by the migration-created @notify_events()@+    --       trigger.+    --+    --     To run more than one isolated 'KirokuStore' against the same database+    --     (for example schema-per-tenant), migrate each schema and give each+    --     store a distinct 'schema'; each gets its own set of Kiroku tables and+    --     its own notification channel.+    schema :: !Text,+    -- | Additional schemas appended to every pooled connection's+    --     @search_path@, after 'schema' and before @pg_catalog@ (default: @[]@).+    --+    --     The store's own unqualified names always resolve to 'schema' first.+    --     This is for consumers whose /application/ objects — typically inline+    --     projections' read-model tables, queried on the same pool as the event+    --     store — live in another schema (commonly @public@). Without it those+    --     unqualified application tables are unreachable on the store pool, since+    --     'schema' replaces the default @search_path@. Entries are quoted as+    --     identifiers; like 'schema' they are not validated at connection time+    --     (PostgreSQL does not validate @search_path@ entries).+    extraSearchPath :: ![Text],+    -- | idle_in_transaction_session_timeout in seconds (default: 30)+    idleInTransactionTimeout :: !Int,+    -- | When @Just s@, set @statement_timeout = 's'@ (in seconds) on+    --     every pooled connection via @initSession@. Bounds the wall-clock+    --     runtime of any single statement; protects against pathological+    --     queries holding pool slots indefinitely. Default 'Nothing'+    --     (PostgreSQL's session default applies — typically @0@,+    --     meaning no timeout).+    --+    --     A reasonable starting value for typical workloads is @Just 30@+    --     (30 seconds) — long enough to absorb GC pauses and transient+    --     slow disks, short enough to free the pool slot under genuine+    --     pathology. See @docs\/PRODUCTION-TUNING.md@ for sizing+    --     guidance.+    statementTimeout :: !(Maybe Int),+    -- | Optional callback for pool connection lifecycle events+    observationHandler :: !(Maybe (Observation -> m ())),+    -- | Optional callback for store-emitted operational events. See+    --     "Kiroku.Store.Observability" for the event taxonomy. Covers+    --     notifier reconnection, publisher pool errors, subscription+    --     lifecycle and per-phase database errors, and hard-delete+    --     issuance — events that 'observationHandler' (which surfaces+    --     @hasql-pool@'s connection-lifecycle observations) does not+    --     cover.+    --+    --     Invoked synchronously from the originating thread (notifier+    --     loop, publisher loop, worker loop, store interpreter); slow+    --     callbacks stall those loops. For callbacks that may block, fan+    --     out asynchronously (e.g., write to a 'TBQueue' and drain in a+    --     separate thread).+    eventHandler :: !(Maybe (KirokuEvent -> m ())),+    -- | Interpreter-level hooks applied to 'EventData' on the append+    --     path and to 'RecordedEvent' on the read and subscription paths.+    --     Defaults to 'defaultStoreSettings' (no-op).+    --+    --     See "Kiroku.Store.Settings" for the hook semantics and the+    --     OpenTelemetry trace-context use case that motivates this seam.+    storeSettings :: !StoreSettings+  }+  deriving stock (Generic)  -- | Connection settings defaulting to 'IO'. type ConnectionSettings = ConnectionSettingsM IO@@ -126,154 +121,149 @@ -- | Default connection settings. defaultConnectionSettings :: Text -> ConnectionSettings defaultConnectionSettings cs =-    ConnectionSettings-        { connString = cs-        , poolSize = 10-        , schema = "kiroku"-        , extraSearchPath = []-        , idleInTransactionTimeout = 30-        , statementTimeout = Nothing-        , observationHandler = Nothing-        , eventHandler = Nothing-        , storeSettings = defaultStoreSettings-        }+  ConnectionSettings+    { connString = cs,+      poolSize = 10,+      schema = "kiroku",+      extraSearchPath = [],+      idleInTransactionTimeout = 30,+      statementTimeout = Nothing,+      observationHandler = Nothing,+      eventHandler = Nothing,+      storeSettings = defaultStoreSettings+    }  -- | The store handle. Holds a connection pool, schema name, and subscription infrastructure. data KirokuStore = KirokuStore-    { pool :: !Pool-    , schema :: !Text-    , notifier :: !Notifier-    , publisher :: !EventPublisher-    , eventHandler :: !(Maybe (KirokuEvent -> IO ()))-    {- ^ Effective event handler captured from-    'ConnectionSettingsM.eventHandler' when 'withStore' acquires-    the store. Surfaces hard-delete events emitted by-    'Kiroku.Store.Effect.runStorePool' and is the channel-    'Kiroku.Store.Subscription.subscribe' threads through to-    'Kiroku.Store.Subscription.Worker.runWorker'.-    -}-    , storeSettings :: !StoreSettings-    {- ^ Interpreter-level hooks captured from-    'ConnectionSettingsM.storeSettings' when 'withStore' acquires-    the store. Reached by 'Kiroku.Store.Effect.runStorePool' and the-    subscription publisher\/worker for every event flowing through.-    -}-    , subscriptionRegistry :: !(TVar (Map (SubscriptionName, Int32) (Unique, TVar SubscriptionState)))-    {- ^ Central registry of every live subscription worker's FSM-state cell,-    keyed by (subscription name, consumer-group member; 0 for non-group).-    Each entry also carries the worker's registry token, so cleanup from an-    older worker cannot delete a newer worker's replacement entry for the same-    key.-    Each 'Kiroku.Store.Subscription.subscribe' call registers its worker's-    'stateVar' here on start and removes it on any exit (stop, cancel, crash)-    via the worker's @finally@ cleanup. The cell is the same 'TVar' the worker-    writes on every transition and 'currentState' reads — it is registered, not-    copied — so the registry observes the live value with no extra write path.-    A subscription is represented in the snapshot by /presence/ while live and-    by /absence/ once it has stopped, been cancelled, or crashed; the FSM never-    writes 'Kiroku.Store.Subscription.Fsm.Stopped' into the cell, so a not-live-    subscription is never represented by a @"stopped"@ phase.-    Read a consistent snapshot with-    'Kiroku.Store.Subscription.subscriptionStates'.-    -}-    }-    deriving stock (Generic)--{- | Bracket-style store lifecycle.--Acquire phase, in order:--1. Acquire the connection pool from @hasql-pool@ with the configured-   size and the @idle_in_transaction_session_timeout@ init session.-2. Start the 'Kiroku.Store.Notification.Notifier' on a dedicated-   connection: @LISTEN \<schema\>.events@.-3. Start the 'Kiroku.Store.Subscription.EventPublisher' which consumes-   notifier ticks and broadcasts new events to subscribers.--Release phase, in reverse order:--1. Cancel the 'Kiroku.Store.Subscription.EventPublisher' worker.-2. Stop the 'Kiroku.Store.Notification.Notifier' (cancel listener,-   release connection).-3. Release the pool.+  { pool :: !Pool,+    schema :: !Text,+    notifier :: !Notifier,+    publisher :: !EventPublisher,+    -- | Effective event handler captured from+    --     'ConnectionSettingsM.eventHandler' when 'withStore' acquires+    --     the store. Surfaces hard-delete events emitted by+    --     'Kiroku.Store.Effect.runStorePool' and is the channel+    --     'Kiroku.Store.Subscription.subscribe' threads through to+    --     'Kiroku.Store.Subscription.Worker.runWorker'.+    eventHandler :: !(Maybe (KirokuEvent -> IO ())),+    -- | Interpreter-level hooks captured from+    --     'ConnectionSettingsM.storeSettings' when 'withStore' acquires+    --     the store. Reached by 'Kiroku.Store.Effect.runStorePool' and the+    --     subscription publisher\/worker for every event flowing through.+    storeSettings :: !StoreSettings,+    -- | Central registry of every live subscription worker's FSM-state cell,+    --     keyed by (subscription name, consumer-group member; 0 for non-group).+    --     Each entry also carries the worker's registry token, so cleanup from an+    --     older worker cannot delete a newer worker's replacement entry for the same+    --     key.+    --     Each 'Kiroku.Store.Subscription.subscribe' call registers its worker's+    --     'stateVar' here on start and removes it on any exit (stop, cancel, crash)+    --     via the worker's @finally@ cleanup. The cell is the same 'TVar' the worker+    --     writes on every transition and 'currentState' reads — it is registered, not+    --     copied — so the registry observes the live value with no extra write path.+    --     A subscription is represented in the snapshot by /presence/ while live and+    --     by /absence/ once it has stopped, been cancelled, or crashed; the FSM never+    --     writes 'Kiroku.Store.Subscription.Fsm.Stopped' into the cell, so a not-live+    --     subscription is never represented by a @"stopped"@ phase.+    --     Read a consistent snapshot with+    --     'Kiroku.Store.Subscription.subscriptionStates'.+    subscriptionRegistry :: !(TVar (Map (SubscriptionName, Int32) (Unique, TVar SubscriptionState)))+  }+  deriving stock (Generic) -The @bracket@ semantics guarantee release runs on either normal exit-or an exception in the body. The 'MonadUnliftIO' constraint matches-'Control.Exception.bracket'; consumers in pure 'IO' get an exact match,-consumers in effectful monads with an unlift in scope (e.g., a-'ReaderT'-like stack) get the same guarantee transparently.--}+-- | Bracket-style store lifecycle.+--+-- Acquire phase, in order:+--+-- 1. Acquire the connection pool from @hasql-pool@ with the configured+--    size and the @idle_in_transaction_session_timeout@ init session.+-- 2. Start the 'Kiroku.Store.Notification.Notifier' on a dedicated+--    connection: @LISTEN \<schema\>.events@.+-- 3. Start the 'Kiroku.Store.Subscription.EventPublisher' which consumes+--    notifier ticks and broadcasts new events to subscribers.+--+-- Release phase, in reverse order:+--+-- 1. Cancel the 'Kiroku.Store.Subscription.EventPublisher' worker.+-- 2. Stop the 'Kiroku.Store.Notification.Notifier' (cancel listener,+--    release connection).+-- 3. Release the pool.+--+-- The @bracket@ semantics guarantee release runs on either normal exit+-- or an exception in the body. The 'MonadUnliftIO' constraint matches+-- 'Control.Exception.bracket'; consumers in pure 'IO' get an exact match,+-- consumers in effectful monads with an unlift in scope (e.g., a+-- 'ReaderT'-like stack) get the same guarantee transparently. withStore :: (MonadUnliftIO m) => ConnectionSettings -> (KirokuStore -> m a) -> m a withStore settings action = withRunInIO $ \runInIO ->-    bracket acquire release (runInIO . action)+  bracket acquire release (runInIO . action)   where     initScript :: Text     initScript =-        T.intercalate "; " $-            -- Resolve unqualified Kiroku table names (see "Kiroku.Store.SQL")-            -- to the configured schema on every pooled connection, followed by-            -- any 'extraSearchPath' schemas (for application objects such as-            -- inline-projection read-model tables queried on the same pool).-            -- Setting a not-yet-created schema is harmless: PostgreSQL does not-            -- validate search_path entries. Runtime queries still require-            -- migrations to have created the schemas before the store is opened.-            ( "SET search_path TO "-                <> T.intercalate-                    ", "-                    (quoteIdentifier (settings ^. #schema) : map quoteIdentifier (settings ^. #extraSearchPath))-                <> ", pg_catalog"-            )-                : ("SET idle_in_transaction_session_timeout = '" <> T.pack (show (settings ^. #idleInTransactionTimeout)) <> "s'")-                : maybe-                    []-                    (\t -> ["SET statement_timeout = '" <> T.pack (show t) <> "s'"])-                    (settings ^. #statementTimeout)+      T.intercalate "; " $+        -- Resolve unqualified Kiroku table names (see "Kiroku.Store.SQL")+        -- to the configured schema on every pooled connection, followed by+        -- any 'extraSearchPath' schemas (for application objects such as+        -- inline-projection read-model tables queried on the same pool).+        -- Setting a not-yet-created schema is harmless: PostgreSQL does not+        -- validate search_path entries. Runtime queries still require+        -- migrations to have created the schemas before the store is opened.+        ( "SET search_path TO "+            <> T.intercalate+              ", "+              (quoteIdentifier (settings ^. #schema) : map quoteIdentifier (settings ^. #extraSearchPath))+            <> ", pg_catalog"+        )+          : ("SET idle_in_transaction_session_timeout = '" <> T.pack (show (settings ^. #idleInTransactionTimeout)) <> "s'")+          : maybe+            []+            (\t -> ["SET statement_timeout = '" <> T.pack (show t) <> "s'"])+            (settings ^. #statementTimeout)      poolConfig :: Pool.Config.Config     poolConfig =-        Pool.Config.settings $-            [ Pool.Config.staticConnectionSettings (Conn.connectionString (settings ^. #connString))-            , Pool.Config.size (settings ^. #poolSize)-            , Pool.Config.initSession (Session.script initScript)-            ]-                ++ maybe [] (\h -> [Pool.Config.observationHandler h]) (settings ^. #observationHandler)+      Pool.Config.settings $+        [ Pool.Config.staticConnectionSettings (Conn.connectionString (settings ^. #connString)),+          Pool.Config.size (settings ^. #poolSize),+          Pool.Config.initSession (Session.script initScript)+        ]+          ++ maybe [] (\h -> [Pool.Config.observationHandler h]) (settings ^. #observationHandler)      acquire = do-        p <- Pool.acquire poolConfig-        flip onException (Pool.release p) $ do-            let s = settings ^. #schema-                cs = settings ^. #connString-                evtHandler = settings ^. #eventHandler-                stSettings = settings ^. #storeSettings-            -- Start Notifier (dedicated LISTEN connection)-            n <- Notifier.startNotifier cs s evtHandler-            flip onException (Notifier.stopNotifier n) $ do-                -- Start EventPublisher (depends on Notifier's TChan)-                pub <- Publisher.startPublisher p (Notifier.tickChan n) evtHandler stSettings-                -- Central, in-memory subscription-state registry: starts empty-                -- and is populated/cleaned by each `subscribe` call's worker-                -- lifecycle. Purely in-memory; discarded when the store closes.-                reg <- newTVarIO Map.empty-                pure-                    KirokuStore-                        { pool = p-                        , schema = s-                        , notifier = n-                        , publisher = pub-                        , eventHandler = evtHandler-                        , storeSettings = stSettings-                        , subscriptionRegistry = reg-                        }+      p <- Pool.acquire poolConfig+      flip onException (Pool.release p) $ do+        let s = settings ^. #schema+            cs = settings ^. #connString+            evtHandler = settings ^. #eventHandler+            stSettings = settings ^. #storeSettings+        -- Start Notifier (dedicated LISTEN connection)+        n <- Notifier.startNotifier cs s evtHandler+        flip onException (Notifier.stopNotifier n) $ do+          -- Start EventPublisher (depends on Notifier's TChan)+          pub <- Publisher.startPublisher p (Notifier.tickChan n) evtHandler stSettings+          -- Central, in-memory subscription-state registry: starts empty+          -- and is populated/cleaned by each `subscribe` call's worker+          -- lifecycle. Purely in-memory; discarded when the store closes.+          reg <- newTVarIO Map.empty+          pure+            KirokuStore+              { pool = p,+                schema = s,+                notifier = n,+                publisher = pub,+                eventHandler = evtHandler,+                storeSettings = stSettings,+                subscriptionRegistry = reg+              }      release store = do-        -- Stop in reverse order: Publisher first, then Notifier, then pool-        Publisher.stopPublisher (store ^. #publisher)-        Notifier.stopNotifier (store ^. #notifier)-        Pool.release (store ^. #pool)+      -- Stop in reverse order: Publisher first, then Notifier, then pool+      Publisher.stopPublisher (store ^. #publisher)+      Notifier.stopNotifier (store ^. #notifier)+      Pool.release (store ^. #pool) -{- | Quote a 'Text' as a PostgreSQL identifier: wrap it in double quotes and-double any embedded double quote. Used to set the configured schema in-@search_path@ without risking SQL injection from a hostile schema setting.--}+-- | Quote a 'Text' as a PostgreSQL identifier: wrap it in double quotes and+-- double any embedded double quote. Used to set the configured schema in+-- @search_path@ without risking SQL injection from a hostile schema setting. quoteIdentifier :: Text -> Text quoteIdentifier ident = "\"" <> T.replace "\"" "\"\"" ident <> "\""
src/Kiroku/Store/Effect.hs view
@@ -1,709 +1,696 @@ {-# LANGUAGE TypeFamilies #-} -module Kiroku.Store.Effect (-    -- * The Store effect-    Store (..),--    -- * Interpreters-    runStorePool,-    runStoreResource,-    runStoreIO,--    -- * Internal building blocks--    ---    -- $internal-    PreparedEvent,-    prepareEvents,-    buildAppendParams,-    appendDispatchTx,-) where--import Control.Lens ((^.))-import Control.Monad.Except qualified as Except-import Control.Monad.IO.Class (MonadIO, liftIO)-import Data.Aeson (Value)-import Data.Generics.Labels ()-import Data.Int (Int32)-import Data.List (find)-import Data.Map.Strict (Map)-import Data.Map.Strict qualified as Map-import Data.Maybe (isNothing)-import Data.Text (Text)-import Data.Time.Clock (UTCTime, getCurrentTime)-import Data.UUID (UUID)-import Data.UUID.V7 qualified as V7-import Data.Vector (Vector)-import Data.Vector qualified as V-import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, runEff, (:>))-import Effectful.Dispatch.Dynamic (interpret_)-import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)-import GHC.Generics (Generic)-import Hasql.Decoders qualified as D-import Hasql.Encoders qualified as E-import Hasql.Pipeline qualified as Pipeline-import Hasql.Pool (Pool)-import Hasql.Pool qualified as Pool-import Hasql.Session qualified as Session-import Hasql.Statement (Statement, unpreparable)-import Hasql.Transaction qualified as Tx-import Hasql.Transaction.Sessions qualified as TxSessions-import Kiroku.Store.Connection (KirokuStore (..))-import Kiroku.Store.Effect.Resource (KirokuStoreResource, getKirokuStore)-import Kiroku.Store.Error (StoreError (..), attributeMultiStreamError, emptyResultError, isTransientSerializationError, mapGenericUsageError, mapLinkUsageError, mapTransactionUsageError, mapUsageError, validateStreamName)-import Kiroku.Store.HistoryRetention.Internal qualified as HistoryRetention-import Kiroku.Store.HistoryRetention.Types-import Kiroku.Store.Observability (KirokuEvent (..), emitOrDrop)-import Kiroku.Store.SQL qualified as SQL-import Kiroku.Store.Settings (StoreSettings (..), enrichEvents)-import Kiroku.Store.Subscription.Checkpoint.SQL qualified as CheckpointSQL-import Kiroku.Store.Subscription.CheckpointInventory.SQL qualified as CheckpointInventorySQL-import Kiroku.Store.Subscription.DeadLetter.SQL qualified as DeadLetterSQL-import Kiroku.Store.Subscription.Types (-    CheckpointInitialization,-    MissingCheckpointPolicy,-    SubscriptionCheckpointInventory,-    SubscriptionCheckpointMissing,-    SubscriptionDeadLetterPage,-    SubscriptionDeadLetterQuery,-    SubscriptionName,- )-import Kiroku.Store.Types---- ------------------------------------------------------------------------------ Store effect--- ------------------------------------------------------------------------------- | The Store effect — dynamically dispatched, mockable.-data Store :: Effect where-    AppendToStream :: StreamName -> ExpectedVersion -> [EventData] -> Store m AppendResult-    ReadStreamForward :: StreamName -> StreamVersion -> Int32 -> Store m (Vector RecordedEvent)-    ReadStreamBackward :: StreamName -> StreamVersion -> Int32 -> Store m (Vector RecordedEvent)-    ReadAllForward :: GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)-    ReadAllBackward :: GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)-    {- | Read the greatest position whose @$all@ junction still survives, or-    zero when no event remains visible. Unlike-    'Kiroku.Store.Subscription.subscriptionCheckpointInventory', this is a-    visibility cursor rather than the authoritative append frontier.--    Surfaced as 'Kiroku.Store.Read.visibleGlobalHeadPosition'.-    -}-    GetVisibleGlobalHeadPosition :: Store m GlobalPosition-    GetStream :: StreamName -> Store m (Maybe StreamInfo)-    {- | One-statement metadata and newest surviving originated head observation.-    Outer 'Nothing' means absent; inner 'Nothing' means no originated events,-    including link-only streams and @$all@. Logical lifecycle markers do not-    hide the head. Custom exhaustive interpreters must handle this constructor.-    -}-    GetStreamWithHead :: StreamName -> Store m (Maybe (StreamInfo, Maybe GlobalPosition))-    {- | Resolve a 'StreamName' to its surrogate 'StreamId' without-    materializing the full 'StreamInfo' row. Mirrors 'GetStream'\'s-    soft-delete semantics: returns 'Just' for both live and soft-deleted-    streams, 'Nothing' for hard-deleted or never-created streams.--    Cheaper than 'GetStream' — decodes one @int8@ column instead of five.-    Surfaced as 'Kiroku.Store.Read.lookupStreamId'.-    -}-    LookupStreamId :: StreamName -> Store m (Maybe StreamId)-    {- | Check whether an event id exists in a live stream without materializing-    stream events. Soft-deleted streams behave as nonexistent, mirroring-    'Kiroku.Store.Read.readStreamForward'.--    Surfaced as 'Kiroku.Store.Read.eventExistsInStream'.-    -}-    EventExistsInStream :: StreamName -> EventId -> Store m Bool-    {- | Resolve a batch of surrogate 'StreamId's to their 'StreamName's in a-    single round trip. The result 'Map' contains an entry only for ids that-    name an existing stream (live or soft-deleted); hard-deleted or unknown ids-    are absent.--    The primary use is recovering source stream names from the-    'RecordedEvent.originalStreamId' of events obtained via fan-in reads-    (@$all@, categories, causation/correlation queries, subscriptions): collect-    the distinct ids from a batch and resolve them once, rather than per event.-    Surfaced as 'Kiroku.Store.Read.lookupStreamNames' (and the singular-    'Kiroku.Store.Read.lookupStreamName').-    -}-    LookupStreamNames :: [StreamId] -> Store m (Map StreamId StreamName)-    {- | Byte-ordered names; optional exact category, literal prefix and exclusive cursor.-    Includes soft-deleted streams, excludes only the reserved stream row.-    -}-    ListStreams :: Maybe CategoryName -> Maybe Text -> Maybe StreamName -> BrowsePageSize -> Store m (Vector StreamInfo)-    -- | Deployment-collation category order with an exclusive cursor.-    ListCategories :: Maybe CategoryName -> BrowsePageSize -> Store m (Vector CategoryName)-    -- | The canonical global-log event; absent after hard deletion.-    GetEvent :: EventId -> Store m (Maybe RecordedEvent)-    LinkToStream :: StreamName -> [EventId] -> Store m LinkResult-    ReadCategoryForward :: CategoryName -> GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)-    AppendMultiStream :: [(StreamName, ExpectedVersion, [EventData])] -> Store m [AppendResult]-    {- | Fetch a set of 'RecordedEvent' rows that match an 'EventFilter'.-    Surfaced to consumers as the smart constructors in-    "Kiroku.Store.Causation": 'Kiroku.Store.Causation.findByCorrelation',-    'Kiroku.Store.Causation.findCausationDescendants', and-    'Kiroku.Store.Causation.findCausationAncestors'.--    The filter is a closed sum ('EventFilter'); mock interpreters can-    pattern-match exhaustively.-    -}-    FindEvents :: EventFilter -> Store m (Vector RecordedEvent)-    SoftDeleteStream :: StreamName -> Store m (Maybe StreamId)-    HardDeleteStream :: StreamName -> Store m (Maybe StreamId)-    UndeleteStream :: StreamName -> Store m (Maybe StreamId)-    {- | Set a stream's logical truncate-before marker. Per-stream ordered-    reads then return only events whose version is >= the marker; the @$all@-    global log, category reads, and subscriptions are unaffected. Reversible.--    Surfaced as 'Kiroku.Store.Lifecycle.setStreamTruncateBefore' /-    'Kiroku.Store.Lifecycle.clearStreamTruncateBefore'.-    -}-    SetStreamTruncateBefore :: StreamName -> StreamVersion -> Store m (Maybe StreamId)-    {- | Read the global store position and all durable subscription checkpoint-    rows from one PostgreSQL statement snapshot.--    Surfaced as-    'Kiroku.Store.Subscription.subscriptionCheckpointInventory'.-    -}-    GetSubscriptionCheckpointInventory :: Store m SubscriptionCheckpointInventory-    -- | Read-only keyset page; no event decode hook or checkpoint write.-    ListSubscriptionDeadLetters :: SubscriptionDeadLetterQuery -> Store m SubscriptionDeadLetterPage-    {- | Resolve one exact subscription checkpoint key according to its-    missing-row policy. Existing rows always take precedence.--    Surfaced as-    'Kiroku.Store.Subscription.initializeSubscriptionCheckpoint'.-    -}-    InitializeSubscriptionCheckpoint ::-        SubscriptionName ->-        Int32 ->-        MissingCheckpointPolicy ->-        Store m (Either SubscriptionCheckpointMissing CheckpointInitialization)-    AcquireHistoryRetentionLease :: HistoryRetentionLeaseRequest -> Store m HistoryRetentionLease-    RenewHistoryRetentionLease ::-        HistoryRetentionLeaseHandle ->-        HistoryRetentionLeaseDuration ->-        Store m (Either HistoryRetentionRenewalError HistoryRetentionLease)-    ReleaseHistoryRetentionLease ::-        HistoryRetentionLeaseHandle ->-        Store m HistoryRetentionReleaseResult-    GetHistoryRetentionLeaseInventory ::-        HistoryRetentionInventoryQuery ->-        Store m (Vector HistoryRetentionLease)-    PruneHistoryRetentionLeases :: UTCTime -> Store m HistoryRetentionPruneResult-    {- | Run an arbitrary @hasql-transaction@ value in a 'BEGIN'/'COMMIT'-    block on a single pool connection. Escape hatch from the abstract-    'Store' effect into the underlying SQL world; mock interpreters are-    expected to reject this constructor.-    -}-    RunTransaction :: Tx.Transaction a -> Store m a-    {- | Like 'RunTransaction' but uses-    'Hasql.Transaction.Sessions.transactionNoRetry' under the hood —-    the body is run exactly once even on PostgreSQL serialization-    conflicts.-    -}-    RunTransactionNoRetry :: Tx.Transaction a -> Store m a--type instance DispatchOf Store = Dynamic---- ------------------------------------------------------------------------------ PostgreSQL interpreter--- ------------------------------------------------------------------------------- | Interpret Store operations against PostgreSQL via hasql-pool.-runStorePool ::-    (IOE :> es, Error StoreError :> es) =>-    KirokuStore ->-    Eff (Store : es) a ->-    Eff es a-runStorePool store = interpret_ $ \case-    AppendToStream (StreamName name) expected events -> do-        rejectInvalidApplicationStream name-        case events of-            [] -> throwError (EmptyAppendBatch (StreamName name))-            _ -> pure ()-        events' <- liftIO $ enrichEvents (store ^. #storeSettings) events-        now <- liftIO getCurrentTime-        prepared <- prepareEvents events'-        let params = buildAppendParams name now prepared-        let runOnce =-                Pool.use (store ^. #pool) $ case expected of-                    ExactVersion (StreamVersion v) ->-                        Session.statement (params, v) SQL.appendExpectedVersion-                    StreamExists ->-                        Session.statement params SQL.appendStreamExists-                    NoStream ->-                        Session.statement params SQL.appendNoStream-                    AnyVersion ->-                        Session.statement params SQL.appendAnyVersion-        firstAttempt <- liftIO runOnce-        result <- case firstAttempt of-            -- Match hasql-transaction's retryable SQLSTATE set, but only once:-            -- PostgreSQL rolls back the victim transaction and event ids were-            -- prepared before the first attempt, so this retry is idempotent.-            Left usageErr-                | isTransientSerializationError usageErr ->-                    liftIO runOnce-            _ ->-                pure firstAttempt-        case result of-            Left usageErr ->-                throwError (mapUsageError name expected usageErr)-            Right Nothing ->-                throwError (emptyResultError name expected)-            Right (Just r) ->-                pure r-    ReadStreamForward (StreamName name) (StreamVersion startVer) limit -> do-        evs <--            usePool (store ^. #pool) $-                Session.statement (name, startVer, limit) SQL.readStreamForwardStmt-        decodeReadEvents (store ^. #storeSettings) evs-    ReadStreamBackward (StreamName name) (StreamVersion startVer) limit -> do-        let cursor = if startVer == 0 then maxBound else startVer-        evs <--            usePool (store ^. #pool) $-                Session.statement (name, cursor, limit) SQL.readStreamBackwardStmt-        decodeReadEvents (store ^. #storeSettings) evs-    ReadAllForward (GlobalPosition startPos) limit -> do-        evs <--            usePool (store ^. #pool) $-                Session.statement (startPos, limit) SQL.readAllForwardStmt-        decodeReadEvents (store ^. #storeSettings) evs-    ReadAllBackward (GlobalPosition startPos) limit -> do-        let cursor = if startPos == 0 then maxBound else startPos-        evs <--            usePool (store ^. #pool) $-                Session.statement (cursor, limit) SQL.readAllBackwardStmt-        decodeReadEvents (store ^. #storeSettings) evs-    GetVisibleGlobalHeadPosition ->-        usePool (store ^. #pool) $-            Session.statement () SQL.visibleGlobalHeadPositionStmt-    GetStreamWithHead (StreamName name) ->-        usePool (store ^. #pool) $-            Session.statement name SQL.getStreamWithHeadStmt-    GetStream (StreamName name) ->-        usePool (store ^. #pool) $-            Session.statement name SQL.getStreamStmt-    LookupStreamId (StreamName name) ->-        fmap (fmap StreamId) $-            usePool (store ^. #pool) $-                Session.statement name SQL.findStreamIdStmt-    EventExistsInStream (StreamName name) (EventId eid) ->-        usePool (store ^. #pool) $-            Session.statement (name, eid) SQL.eventExistsInStreamStmt-    LookupStreamNames [] ->-        pure Map.empty-    LookupStreamNames sids ->-        fmap-            (Map.fromList . map (\(s, nm) -> (StreamId s, StreamName nm)) . V.toList)-            ( usePool (store ^. #pool) $-                Session.statement [s | StreamId s <- sids] SQL.lookupStreamNamesStmt-            )-    ListStreams category prefix after limit ->-        usePool (store ^. #pool) (SQL.listStreamsSession category prefix after limit)-    ListCategories after limit ->-        fmap (V.map CategoryName) $-            usePool (store ^. #pool) $-                Session.statement (fmap (\(CategoryName value) -> value) after, browsePageSizeValue limit) (SQL.listCategoriesStmt (not (isNothing after)))-    GetEvent (EventId eid) -> do-        found <- usePool (store ^. #pool) $ Session.statement eid SQL.getEventStmt-        decoded <- decodeReadEvents (store ^. #storeSettings) (maybe V.empty V.singleton found)-        pure (decoded V.!? 0)-    LinkToStream (StreamName name) eventIds -> do-        rejectInvalidApplicationStream name-        let uuids = V.fromList [uid | EventId uid <- eventIds]-        result <--            liftIO $-                Pool.use (store ^. #pool) $-                    Session.statement (uuids, name) SQL.linkToStreamStmt-        case result of-            Left usageErr -> throwError (mapLinkUsageError (StreamName name) usageErr)-            Right Nothing -> throwError (StreamNotFound (StreamName name))-            Right (Just r) -> pure r-    ReadCategoryForward (CategoryName cat) (GlobalPosition startPos) limit -> do-        evs <--            usePool (store ^. #pool) $-                Session.statement (startPos, cat, limit) SQL.readCategoryForwardStmt-        decodeReadEvents (store ^. #storeSettings) evs-    AppendMultiStream [] ->-        pure []-    AppendMultiStream ops -> do-        case firstStreamNameError ops of-            Just err -> throwError err-            Nothing -> pure ()-        case find (\(_, _, evts) -> null evts) ops of-            Just (sn, _, _) -> throwError (EmptyAppendBatch sn)-            Nothing -> pure ()-        now <- liftIO getCurrentTime-        -- Prepare all events for all streams-        preparedOps <--            mapM-                ( \(sn, ev, evts) -> do-                    evts' <- liftIO $ enrichEvents (store ^. #storeSettings) evts-                    prepared <- prepareEvents evts'-                    pure (sn, ev, prepared)-                )-                ops-        let names = V.fromList [n | (StreamName n, _, _) <- ops]-        let runOnce =-                Pool.use (store ^. #pool) $-                    runAppendMultiStreamPipeline names now preparedOps-        firstAttempt <- liftIO runOnce-        result <- case firstAttempt of-            -- Match the single-stream append retry and hasql-transaction's-            -- retryable SQLSTATE set. The failed transaction has rolled back,-            -- and event ids were prepared before the first attempt, so retrying-            -- once is idempotent.-            Left usageErr-                | isTransientSerializationError usageErr ->-                    liftIO runOnce-            _ ->-                pure firstAttempt-        case result of-            Left usageErr ->-                throwError (attributeMultiStreamError [(sn, ev) | (sn, ev, _) <- ops] usageErr)-            Right results -> do-                -- Check for any Nothing results (version conflicts)-                let indexed = zip ops results-                mapM-                    ( \((StreamName sn, ev, _), mResult) ->-                        case mResult of-                            Nothing -> throwError (emptyResultError sn ev)-                            Just r -> pure r-                    )-                    indexed-    FindEvents filt -> do-        evs <- case filt of-            FilterCorrelation cid ->-                usePool (store ^. #pool) $-                    Session.statement cid SQL.findByCorrelationStmt-            FilterCausationDescendants (EventId eid) ->-                usePool (store ^. #pool) $-                    Session.statement eid SQL.findCausationDescendantsStmt-            FilterCausationAncestors (EventId eid) ->-                usePool (store ^. #pool) $-                    Session.statement eid SQL.findCausationAncestorsStmt-        decodeReadEvents (store ^. #storeSettings) evs-    SoftDeleteStream (StreamName name) -> do-        rejectInvalidApplicationStream name-        usePool (store ^. #pool) $-            Session.statement name SQL.softDeleteStreamStmt-    HardDeleteStream (StreamName name) -> do-        rejectInvalidApplicationStream name-        let txn = do-                Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"-                HistoryRetention.lockHistoryRetentionCoordinatorTx-                mSid <- Tx.statement name SQL.findStreamIdStmt-                case mSid of-                    Nothing -> pure (Right Nothing)-                    Just sid -> do-                        conflict <- HistoryRetention.activeHistoryRetentionConflictTx-                        case conflict of-                            Just active -> pure (Left active)-                            Nothing -> do-                                HistoryRetention.lockAffectedStreamsForHardDeleteTx sid-                                originated <- Tx.statement sid SQL.deleteAllRowsForOriginStmt-                                Tx.statement originated SQL.deleteJunctionsByEventIdsStmt-                                linkedIn <- Tx.statement sid SQL.deleteStreamOwnJunctionsStmt-                                let affected = originated <> linkedIn-                                Tx.statement affected SQL.deleteDeadLettersForOrphanedEventsStmt-                                Tx.statement affected SQL.deleteOrphanedEventsStmt-                                Tx.statement sid SQL.deleteStreamRowStmt-                                pure (Right (Just (StreamId sid)))-        result <--            usePool (store ^. #pool) $-                TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn-        -- Emit a fail-safe audit signal when the delete actually removed-        -- rows. Compliance-grade audit should still record an-        -- application-level event before calling hardDeleteStream — see-        -- docs/PRODUCTION-DEPLOYMENT.md.-        case result of-            Right (Just sid) ->-                liftIO $ emitOrDrop (store ^. #eventHandler) (KirokuEventHardDeleteIssued (StreamName name) sid)-            Right Nothing -> pure ()-            Left conflict -> do-                liftIO $-                    emitOrDrop-                        (store ^. #eventHandler)-                        (KirokuEventHardDeleteHistoryRetentionConflict (StreamName name) conflict)-                throwError (HistoryRetentionActive (StreamName name) conflict)-        pure (either (const Nothing) (\value -> value) result)-    UndeleteStream (StreamName name) -> do-        rejectInvalidApplicationStream name-        usePool (store ^. #pool) $-            Session.statement name SQL.undeleteStreamStmt-    SetStreamTruncateBefore (StreamName name) (StreamVersion v) -> do-        rejectInvalidApplicationStream name-        usePool (store ^. #pool) $-            Session.statement (name, v) SQL.setStreamTruncateBeforeStmt-    ListSubscriptionDeadLetters query ->-        usePool (store ^. #pool) (DeadLetterSQL.listSubscriptionDeadLettersSession query)-    GetSubscriptionCheckpointInventory ->-        usePool (store ^. #pool) $-            Session.statement () CheckpointInventorySQL.getSubscriptionCheckpointInventoryStmt-    InitializeSubscriptionCheckpoint subscriptionName member policy ->-        usePool (store ^. #pool) $-            CheckpointSQL.initializeSubscriptionCheckpointSession subscriptionName member policy-    AcquireHistoryRetentionLease request -> do-        lease <- runTxOnPool (store ^. #pool) TxSessions.transaction (HistoryRetention.acquireHistoryRetentionLeaseTx request)-        let HistoryRetentionLease{leaseId, owner, protectedThrough, expiresAt} = lease-        liftIO $-            emitOrDrop-                (store ^. #eventHandler)-                (KirokuEventHistoryRetentionLeaseAcquired leaseId owner protectedThrough expiresAt)-        pure lease-    RenewHistoryRetentionLease handle duration -> do-        result <--            runTxOnPool-                (store ^. #pool)-                TxSessions.transaction-                (HistoryRetention.renewHistoryRetentionLeaseTx handle duration)-        case result of-            Right lease ->-                let HistoryRetentionLease{leaseId, owner, expiresAt} = lease-                 in liftIO $-                        emitOrDrop-                            (store ^. #eventHandler)-                            (KirokuEventHistoryRetentionLeaseRenewed leaseId owner expiresAt)-            Left _ -> pure ()-        pure result-    ReleaseHistoryRetentionLease handle -> do-        result <--            runTxOnPool-                (store ^. #pool)-                TxSessions.transaction-                (HistoryRetention.releaseHistoryRetentionLeaseTx handle)-        case result of-            HistoryRetentionReleased HistoryRetentionLease{leaseId, owner, releasedAt = Just releasedAt} ->-                liftIO $-                    emitOrDrop-                        (store ^. #eventHandler)-                        (KirokuEventHistoryRetentionLeaseReleased leaseId owner releasedAt)-            _ -> pure ()-        pure result-    GetHistoryRetentionLeaseInventory query ->-        runTxOnPool-            (store ^. #pool)-            TxSessions.transaction-            (HistoryRetention.historyRetentionLeaseInventoryTx query)-    PruneHistoryRetentionLeases cutoff -> do-        result <--            runTxOnPool-                (store ^. #pool)-                TxSessions.transaction-                (HistoryRetention.pruneHistoryRetentionLeasesTx cutoff)-        liftIO $ emitOrDrop (store ^. #eventHandler) (KirokuEventHistoryRetentionLeasesPruned result)-        pure result-    RunTransaction tx ->-        runTxOnPool (store ^. #pool) TxSessions.transaction tx-    RunTransactionNoRetry tx ->-        runTxOnPool (store ^. #pool) TxSessions.transactionNoRetry tx---- | Convenience: run a Store computation to IO.-runStoreIO ::-    KirokuStore ->-    Eff '[Store, Error StoreError, IOE] a ->-    IO (Either StoreError a)-runStoreIO store = runEff . runErrorNoCallStack . runStorePool store---- | Interpret Store by reading the store handle from 'KirokuStoreResource'.-runStoreResource ::-    (IOE :> es, Error StoreError :> es, KirokuStoreResource :> es) =>-    Eff (Store : es) a ->-    Eff es a-runStoreResource action = do-    store <- getKirokuStore-    runStorePool store action---- ------------------------------------------------------------------------------ Internal pool helper--- ------------------------------------------------------------------------------- | Run a hasql session against the pool, mapping pool errors to 'StoreError'.-usePool ::-    (IOE :> es, Error StoreError :> es) =>-    Pool ->-    Session.Session a ->-    Eff es a-usePool pool session = do-    result <- liftIO (Pool.use pool session)-    case result of-        Left usageErr -> throwError (mapGenericUsageError usageErr)-        Right a -> pure a--{- | Run a 'Tx.Transaction' against the pool using the supplied entry-point ('TxSessions.transaction' or 'TxSessions.transactionNoRetry'),-mapping pool errors to 'StoreError'. The isolation level and access-mode mirror 'appendMultiStream' / 'HardDeleteStream'.--}-runTxOnPool ::-    (IOE :> es, Error StoreError :> es) =>-    Pool ->-    (TxSessions.IsolationLevel -> TxSessions.Mode -> Tx.Transaction a -> Session.Session a) ->-    Tx.Transaction a ->-    Eff es a-runTxOnPool pool entry tx = do-    result <--        liftIO $-            Pool.use pool $-                entry TxSessions.ReadCommitted TxSessions.Write tx-    case result of-        Left usageErr -> throwError (mapTransactionUsageError usageErr)-        Right a -> pure a--rejectInvalidApplicationStream ::-    (Error StoreError :> es) =>-    Text ->-    Eff es ()-rejectInvalidApplicationStream name =-    either throwError pure (validateStreamName (StreamName name))--firstStreamNameError :: [(StreamName, ExpectedVersion, [EventData])] -> Maybe StoreError-firstStreamNameError ops =-    case find (isLeft . validateStreamName . streamNameOf) ops of-        Just (sn, _, _) -> either Just (const Nothing) (validateStreamName sn)-        Nothing -> Nothing-  where-    streamNameOf (sn, _, _) = sn-    isLeft = either (const True) (const False)---- ------------------------------------------------------------------------------ Internal helpers (moved from Append)--- ------------------------------------------------------------------------------- | An event with a guaranteed event ID (pre-generated if needed).-data PreparedEvent = PreparedEvent-    { peEventId :: !UUID-    , peEventType :: !EventType-    , pePayload :: !Value-    , peMetadata :: !(Maybe Value)-    , peCausationId :: !(Maybe UUID)-    , peCorrelationId :: !(Maybe UUID)-    }-    deriving stock (Generic)--{- | Prepare events by generating UUIDv7s for any event that doesn't-have a caller-supplied event ID.--}-prepareEvents :: (MonadIO m) => [EventData] -> m [PreparedEvent]-prepareEvents evts = liftIO $ do-    let needCount = length (filter (\(EventData eid _ _ _ _ _) -> isNothing eid) evts)-    newIds <--        if needCount > 0-            then V7.genUUIDs (fromIntegral needCount)-            else pure []-    pure (assign evts newIds)-  where-    assign :: [EventData] -> [UUID] -> [PreparedEvent]-    assign [] _ = []-    assign (EventData mEid eType ePayload eMeta eCaus eCorr : es) ids =-        case mEid of-            Just (EventId uid) ->-                PreparedEvent uid eType ePayload eMeta eCaus eCorr-                    : assign es ids-            Nothing -> case ids of-                (uid : rest) ->-                    PreparedEvent uid eType ePayload eMeta eCaus eCorr-                        : assign es rest-                [] -> error "prepareEvents: ran out of pre-generated UUIDs (bug)"---- | Build SQL parameters from prepared events.-buildAppendParams :: Text -> UTCTime -> [PreparedEvent] -> SQL.AppendParams-buildAppendParams name now prepared =-    SQL.AppendParams-        { eventIds = V.fromList (map (^. #peEventId) prepared)-        , eventTypes = V.fromList (map (\e -> let EventType t = e ^. #peEventType in t) prepared)-        , causationIds = V.fromList (map (^. #peCausationId) prepared)-        , correlationIds = V.fromList (map (^. #peCorrelationId) prepared)-        , payloads = V.fromList (map (^. #pePayload) prepared)-        , metadatas = V.fromList (map (^. #peMetadata) prepared)-        , createdAts = V.fromList (replicate (length prepared) now)-        , streamName = name-        }--multiAppendBeginStmt :: Statement () ()-multiAppendBeginStmt = unpreparable "BEGIN" E.noParams D.noResult--multiAppendCommitStmt :: Statement () ()-multiAppendCommitStmt = unpreparable "COMMIT" E.noParams D.noResult--multiAppendRollbackStmt :: Statement () ()-multiAppendRollbackStmt = unpreparable "ROLLBACK" E.noParams D.noResult--runAppendMultiStreamPipeline ::-    Vector Text ->-    UTCTime ->-    [(StreamName, ExpectedVersion, [PreparedEvent])] ->-    Session.Session [Maybe AppendResult]-runAppendMultiStreamPipeline names now preparedOps =-    Except.catchError body $ \err -> do-        Session.statement () multiAppendRollbackStmt-        Except.throwError err-  where-    body = do-        results <--            Session.pipeline $-                Pipeline.statement () multiAppendBeginStmt-                    -- Pre-lock the user-named streams in deterministic (stream_id)-                    -- order to avoid row-lock deadlocks between concurrent-                    -- multi-stream txns touching overlapping streams in different-                    -- user orders. See EP-1 F4.-                    *> Pipeline.statement names SQL.lockStreamsForMultiStmt-                    *> traverse appendPrepared preparedOps-        Session.statement () $-            if any isNothing results-                then multiAppendRollbackStmt-                else multiAppendCommitStmt-        pure results--    appendPrepared (StreamName name, expected, prepared) =-        appendDispatchPipeline expected (buildAppendParams name now prepared)--appendDispatchPipeline :: ExpectedVersion -> SQL.AppendParams -> Pipeline.Pipeline (Maybe AppendResult)-appendDispatchPipeline expected params = case expected of-    ExactVersion (StreamVersion v) ->-        Pipeline.statement (params, v) SQL.appendExpectedVersion-    StreamExists ->-        Pipeline.statement params SQL.appendStreamExists-    NoStream ->-        Pipeline.statement params SQL.appendNoStream-    AnyVersion ->-        Pipeline.statement params SQL.appendAnyVersion--{- | Dispatch the four 'SQL.append*' statements through 'Tx.statement',-selecting the right one based on the supplied 'ExpectedVersion'.--This is the shared building block used by 'AppendMultiStream'\'s-interpreter branch and by 'Kiroku.Store.Transaction.appendToStreamTx'.-'AppendToStream' keeps its 'Session.statement'-flavored dispatch — see-the M2 entry in the Decision Log on plan 11 for why a 'Tx.Transaction'-wrapping a single statement was rejected as a refactoring target.--@'Nothing'@ comes back when the underlying CTE returns 0 rows — i.e.-the precondition failed silently. Callers map that to either-'Kiroku.Store.Error.AppendConflict' (the Tx surface) or-'Kiroku.Store.Error.StoreError' (the @Eff@ surface).--}-appendDispatchTx :: ExpectedVersion -> SQL.AppendParams -> Tx.Transaction (Maybe AppendResult)-appendDispatchTx expected params = case expected of-    ExactVersion (StreamVersion v) ->-        Tx.statement (params, v) SQL.appendExpectedVersion-    StreamExists ->-        Tx.statement params SQL.appendStreamExists-    NoStream ->-        Tx.statement params SQL.appendNoStream-    AnyVersion ->-        Tx.statement params SQL.appendAnyVersion--{- $internal-These bindings are intentionally exposed so that-"Kiroku.Store.Transaction" can compose appends with arbitrary-'Tx.Transaction' work without re-implementing UUID prep, parameter-packing, or per-version dispatch. They are not part of the supported-public surface and may change without notice.--}---- No hook preserves the direct read passthrough. Successful hooks map directly--- into a result vector, avoiding the subscription-specific wrapper/unwrapper.-decodeReadEvents :: (IOE :> es, Error StoreError :> es) => StoreSettings -> Vector RecordedEvent -> Eff es (Vector RecordedEvent)-decodeReadEvents settings events = case decodeHook settings of-    Nothing -> pure events-    Just hook -> V.mapM (\event -> liftIO (hook event) >>= either (throwError . EventDecodeFailed) pure) events+module Kiroku.Store.Effect+  ( -- * The Store effect+    Store (..),++    -- * Interpreters+    runStorePool,+    runStoreResource,+    runStoreIO,++    -- * Internal building blocks++    --+    -- $internal+    PreparedEvent,+    prepareEvents,+    buildAppendParams,+    appendDispatchTx,+  )+where++import Control.Lens ((^.))+import Control.Monad.Except qualified as Except+import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.Aeson (Value)+import Data.Generics.Labels ()+import Data.Int (Int32)+import Data.List (find)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Maybe (isNothing)+import Data.Text (Text)+import Data.Time.Clock (UTCTime, getCurrentTime)+import Data.UUID (UUID)+import Data.UUID.V7 qualified as V7+import Data.Vector (Vector)+import Data.Vector qualified as V+import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, runEff, (:>))+import Effectful.Dispatch.Dynamic (interpret_)+import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)+import GHC.Generics (Generic)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pipeline qualified as Pipeline+import Hasql.Pool (Pool)+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Hasql.Transaction qualified as Tx+import Hasql.Transaction.Sessions qualified as TxSessions+import Kiroku.Store.Connection (KirokuStore (..))+import Kiroku.Store.Effect.Resource (KirokuStoreResource, getKirokuStore)+import Kiroku.Store.Error (StoreError (..), attributeMultiStreamError, emptyResultError, isTransientSerializationError, mapGenericUsageError, mapLinkUsageError, mapTransactionUsageError, mapUsageError, validateStreamName)+import Kiroku.Store.HistoryRetention.Internal qualified as HistoryRetention+import Kiroku.Store.HistoryRetention.Types+import Kiroku.Store.Observability (KirokuEvent (..), emitOrDrop)+import Kiroku.Store.SQL qualified as SQL+import Kiroku.Store.Settings (StoreSettings (..), enrichEvents)+import Kiroku.Store.Subscription.Checkpoint.SQL qualified as CheckpointSQL+import Kiroku.Store.Subscription.CheckpointInventory.SQL qualified as CheckpointInventorySQL+import Kiroku.Store.Subscription.DeadLetter.SQL qualified as DeadLetterSQL+import Kiroku.Store.Subscription.Types+  ( CheckpointInitialization,+    MissingCheckpointPolicy,+    SubscriptionCheckpointInventory,+    SubscriptionCheckpointMissing,+    SubscriptionDeadLetterPage,+    SubscriptionDeadLetterQuery,+    SubscriptionName,+  )+import Kiroku.Store.Types++-- ---------------------------------------------------------------------------+-- Store effect+-- ---------------------------------------------------------------------------++-- | The Store effect — dynamically dispatched, mockable.+data Store :: Effect where+  AppendToStream :: StreamName -> ExpectedVersion -> [EventData] -> Store m AppendResult+  ReadStreamForward :: StreamName -> StreamVersion -> Int32 -> Store m (Vector RecordedEvent)+  ReadStreamBackward :: StreamName -> StreamVersion -> Int32 -> Store m (Vector RecordedEvent)+  ReadAllForward :: GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)+  ReadAllBackward :: GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)+  -- | Read the greatest position whose @$all@ junction still survives, or+  --     zero when no event remains visible. Unlike+  --     'Kiroku.Store.Subscription.subscriptionCheckpointInventory', this is a+  --     visibility cursor rather than the authoritative append frontier.+  --+  --     Surfaced as 'Kiroku.Store.Read.visibleGlobalHeadPosition'.+  GetVisibleGlobalHeadPosition :: Store m GlobalPosition+  GetStream :: StreamName -> Store m (Maybe StreamInfo)+  -- | One-statement metadata and newest surviving originated head observation.+  --     Outer 'Nothing' means absent; inner 'Nothing' means no originated events,+  --     including link-only streams and @$all@. Logical lifecycle markers do not+  --     hide the head. Custom exhaustive interpreters must handle this constructor.+  GetStreamWithHead :: StreamName -> Store m (Maybe (StreamInfo, Maybe GlobalPosition))+  -- | Resolve a 'StreamName' to its surrogate 'StreamId' without+  --     materializing the full 'StreamInfo' row. Mirrors 'GetStream'\'s+  --     soft-delete semantics: returns 'Just' for both live and soft-deleted+  --     streams, 'Nothing' for hard-deleted or never-created streams.+  --+  --     Cheaper than 'GetStream' — decodes one @int8@ column instead of five.+  --     Surfaced as 'Kiroku.Store.Read.lookupStreamId'.+  LookupStreamId :: StreamName -> Store m (Maybe StreamId)+  -- | Check whether an event id exists in a live stream without materializing+  --     stream events. Soft-deleted streams behave as nonexistent, mirroring+  --     'Kiroku.Store.Read.readStreamForward'.+  --+  --     Surfaced as 'Kiroku.Store.Read.eventExistsInStream'.+  EventExistsInStream :: StreamName -> EventId -> Store m Bool+  -- | Resolve a batch of surrogate 'StreamId's to their 'StreamName's in a+  --     single round trip. The result 'Map' contains an entry only for ids that+  --     name an existing stream (live or soft-deleted); hard-deleted or unknown ids+  --     are absent.+  --+  --     The primary use is recovering source stream names from the+  --     'RecordedEvent.originalStreamId' of events obtained via fan-in reads+  --     (@$all@, categories, causation/correlation queries, subscriptions): collect+  --     the distinct ids from a batch and resolve them once, rather than per event.+  --     Surfaced as 'Kiroku.Store.Read.lookupStreamNames' (and the singular+  --     'Kiroku.Store.Read.lookupStreamName').+  LookupStreamNames :: [StreamId] -> Store m (Map StreamId StreamName)+  -- | Byte-ordered names; optional exact category, literal prefix and exclusive cursor.+  --     Includes soft-deleted streams, excludes only the reserved stream row.+  ListStreams :: Maybe CategoryName -> Maybe Text -> Maybe StreamName -> BrowsePageSize -> Store m (Vector StreamInfo)+  -- | Deployment-collation category order with an exclusive cursor.+  ListCategories :: Maybe CategoryName -> BrowsePageSize -> Store m (Vector CategoryName)+  -- | The canonical global-log event; absent after hard deletion.+  GetEvent :: EventId -> Store m (Maybe RecordedEvent)+  LinkToStream :: StreamName -> [EventId] -> Store m LinkResult+  ReadCategoryForward :: CategoryName -> GlobalPosition -> Int32 -> Store m (Vector RecordedEvent)+  AppendMultiStream :: [(StreamName, ExpectedVersion, [EventData])] -> Store m [AppendResult]+  -- | Fetch a set of 'RecordedEvent' rows that match an 'EventFilter'.+  --     Surfaced to consumers as the smart constructors in+  --     "Kiroku.Store.Causation": 'Kiroku.Store.Causation.findByCorrelation',+  --     'Kiroku.Store.Causation.findCausationDescendants', and+  --     'Kiroku.Store.Causation.findCausationAncestors'.+  --+  --     The filter is a closed sum ('EventFilter'); mock interpreters can+  --     pattern-match exhaustively.+  FindEvents :: EventFilter -> Store m (Vector RecordedEvent)+  SoftDeleteStream :: StreamName -> Store m (Maybe StreamId)+  HardDeleteStream :: StreamName -> Store m (Maybe StreamId)+  UndeleteStream :: StreamName -> Store m (Maybe StreamId)+  -- | Set a stream's logical truncate-before marker. Per-stream ordered+  --     reads then return only events whose version is >= the marker; the @$all@+  --     global log, category reads, and subscriptions are unaffected. Reversible.+  --+  --     Surfaced as 'Kiroku.Store.Lifecycle.setStreamTruncateBefore' /+  --     'Kiroku.Store.Lifecycle.clearStreamTruncateBefore'.+  SetStreamTruncateBefore :: StreamName -> StreamVersion -> Store m (Maybe StreamId)+  -- | Read the global store position and all durable subscription checkpoint+  --     rows from one PostgreSQL statement snapshot.+  --+  --     Surfaced as+  --     'Kiroku.Store.Subscription.subscriptionCheckpointInventory'.+  GetSubscriptionCheckpointInventory :: Store m SubscriptionCheckpointInventory+  -- | Read-only keyset page; no event decode hook or checkpoint write.+  ListSubscriptionDeadLetters :: SubscriptionDeadLetterQuery -> Store m SubscriptionDeadLetterPage+  -- | Resolve one exact subscription checkpoint key according to its+  --     missing-row policy. Existing rows always take precedence.+  --+  --     Surfaced as+  --     'Kiroku.Store.Subscription.initializeSubscriptionCheckpoint'.+  InitializeSubscriptionCheckpoint ::+    SubscriptionName ->+    Int32 ->+    MissingCheckpointPolicy ->+    Store m (Either SubscriptionCheckpointMissing CheckpointInitialization)+  AcquireHistoryRetentionLease :: HistoryRetentionLeaseRequest -> Store m HistoryRetentionLease+  RenewHistoryRetentionLease ::+    HistoryRetentionLeaseHandle ->+    HistoryRetentionLeaseDuration ->+    Store m (Either HistoryRetentionRenewalError HistoryRetentionLease)+  ReleaseHistoryRetentionLease ::+    HistoryRetentionLeaseHandle ->+    Store m HistoryRetentionReleaseResult+  GetHistoryRetentionLeaseInventory ::+    HistoryRetentionInventoryQuery ->+    Store m (Vector HistoryRetentionLease)+  PruneHistoryRetentionLeases :: UTCTime -> Store m HistoryRetentionPruneResult+  -- | Run an arbitrary @hasql-transaction@ value in a 'BEGIN'/'COMMIT'+  --     block on a single pool connection. Escape hatch from the abstract+  --     'Store' effect into the underlying SQL world; mock interpreters are+  --     expected to reject this constructor.+  RunTransaction :: Tx.Transaction a -> Store m a+  -- | Like 'RunTransaction' but uses+  --     'Hasql.Transaction.Sessions.transactionNoRetry' under the hood —+  --     the body is run exactly once even on PostgreSQL serialization+  --     conflicts.+  RunTransactionNoRetry :: Tx.Transaction a -> Store m a++type instance DispatchOf Store = Dynamic++-- ---------------------------------------------------------------------------+-- PostgreSQL interpreter+-- ---------------------------------------------------------------------------++-- | Interpret Store operations against PostgreSQL via hasql-pool.+runStorePool ::+  (IOE :> es, Error StoreError :> es) =>+  KirokuStore ->+  Eff (Store : es) a ->+  Eff es a+runStorePool store = interpret_ $ \case+  AppendToStream (StreamName name) expected events -> do+    rejectInvalidApplicationStream name+    case events of+      [] -> throwError (EmptyAppendBatch (StreamName name))+      _ -> pure ()+    events' <- liftIO $ enrichEvents (store ^. #storeSettings) events+    now <- liftIO getCurrentTime+    prepared <- prepareEvents events'+    let params = buildAppendParams name now prepared+    let runOnce =+          Pool.use (store ^. #pool) $ case expected of+            ExactVersion (StreamVersion v) ->+              Session.statement (params, v) SQL.appendExpectedVersion+            StreamExists ->+              Session.statement params SQL.appendStreamExists+            NoStream ->+              Session.statement params SQL.appendNoStream+            AnyVersion ->+              Session.statement params SQL.appendAnyVersion+    firstAttempt <- liftIO runOnce+    result <- case firstAttempt of+      -- Match hasql-transaction's retryable SQLSTATE set, but only once:+      -- PostgreSQL rolls back the victim transaction and event ids were+      -- prepared before the first attempt, so this retry is idempotent.+      Left usageErr+        | isTransientSerializationError usageErr ->+            liftIO runOnce+      _ ->+        pure firstAttempt+    case result of+      Left usageErr ->+        throwError (mapUsageError name expected usageErr)+      Right Nothing ->+        throwError (emptyResultError name expected)+      Right (Just r) ->+        pure r+  ReadStreamForward (StreamName name) (StreamVersion startVer) limit -> do+    evs <-+      usePool (store ^. #pool) $+        Session.statement (name, startVer, limit) SQL.readStreamForwardStmt+    decodeReadEvents (store ^. #storeSettings) evs+  ReadStreamBackward (StreamName name) (StreamVersion startVer) limit -> do+    let cursor = if startVer == 0 then maxBound else startVer+    evs <-+      usePool (store ^. #pool) $+        Session.statement (name, cursor, limit) SQL.readStreamBackwardStmt+    decodeReadEvents (store ^. #storeSettings) evs+  ReadAllForward (GlobalPosition startPos) limit -> do+    evs <-+      usePool (store ^. #pool) $+        Session.statement (startPos, limit) SQL.readAllForwardStmt+    decodeReadEvents (store ^. #storeSettings) evs+  ReadAllBackward (GlobalPosition startPos) limit -> do+    let cursor = if startPos == 0 then maxBound else startPos+    evs <-+      usePool (store ^. #pool) $+        Session.statement (cursor, limit) SQL.readAllBackwardStmt+    decodeReadEvents (store ^. #storeSettings) evs+  GetVisibleGlobalHeadPosition ->+    usePool (store ^. #pool) $+      Session.statement () SQL.visibleGlobalHeadPositionStmt+  GetStreamWithHead (StreamName name) ->+    usePool (store ^. #pool) $+      Session.statement name SQL.getStreamWithHeadStmt+  GetStream (StreamName name) ->+    usePool (store ^. #pool) $+      Session.statement name SQL.getStreamStmt+  LookupStreamId (StreamName name) ->+    fmap (fmap StreamId) $+      usePool (store ^. #pool) $+        Session.statement name SQL.findStreamIdStmt+  EventExistsInStream (StreamName name) (EventId eid) ->+    usePool (store ^. #pool) $+      Session.statement (name, eid) SQL.eventExistsInStreamStmt+  LookupStreamNames [] ->+    pure Map.empty+  LookupStreamNames sids ->+    fmap+      (Map.fromList . map (\(s, nm) -> (StreamId s, StreamName nm)) . V.toList)+      ( usePool (store ^. #pool) $+          Session.statement [s | StreamId s <- sids] SQL.lookupStreamNamesStmt+      )+  ListStreams category prefix after limit ->+    usePool (store ^. #pool) (SQL.listStreamsSession category prefix after limit)+  ListCategories after limit ->+    fmap (V.map CategoryName) $+      usePool (store ^. #pool) $+        Session.statement (fmap (\(CategoryName value) -> value) after, browsePageSizeValue limit) (SQL.listCategoriesStmt (not (isNothing after)))+  GetEvent (EventId eid) -> do+    found <- usePool (store ^. #pool) $ Session.statement eid SQL.getEventStmt+    decoded <- decodeReadEvents (store ^. #storeSettings) (maybe V.empty V.singleton found)+    pure (decoded V.!? 0)+  LinkToStream (StreamName name) eventIds -> do+    rejectInvalidApplicationStream name+    let uuids = V.fromList [uid | EventId uid <- eventIds]+    result <-+      liftIO $+        Pool.use (store ^. #pool) $+          Session.statement (uuids, name) SQL.linkToStreamStmt+    case result of+      Left usageErr -> throwError (mapLinkUsageError (StreamName name) usageErr)+      Right Nothing -> throwError (StreamNotFound (StreamName name))+      Right (Just r) -> pure r+  ReadCategoryForward (CategoryName cat) (GlobalPosition startPos) limit -> do+    evs <-+      usePool (store ^. #pool) $+        Session.statement (startPos, cat, limit) SQL.readCategoryForwardStmt+    decodeReadEvents (store ^. #storeSettings) evs+  AppendMultiStream [] ->+    pure []+  AppendMultiStream ops -> do+    case firstStreamNameError ops of+      Just err -> throwError err+      Nothing -> pure ()+    case find (\(_, _, evts) -> null evts) ops of+      Just (sn, _, _) -> throwError (EmptyAppendBatch sn)+      Nothing -> pure ()+    now <- liftIO getCurrentTime+    -- Prepare all events for all streams+    preparedOps <-+      mapM+        ( \(sn, ev, evts) -> do+            evts' <- liftIO $ enrichEvents (store ^. #storeSettings) evts+            prepared <- prepareEvents evts'+            pure (sn, ev, prepared)+        )+        ops+    let names = V.fromList [n | (StreamName n, _, _) <- ops]+    let runOnce =+          Pool.use (store ^. #pool) $+            runAppendMultiStreamPipeline names now preparedOps+    firstAttempt <- liftIO runOnce+    result <- case firstAttempt of+      -- Match the single-stream append retry and hasql-transaction's+      -- retryable SQLSTATE set. The failed transaction has rolled back,+      -- and event ids were prepared before the first attempt, so retrying+      -- once is idempotent.+      Left usageErr+        | isTransientSerializationError usageErr ->+            liftIO runOnce+      _ ->+        pure firstAttempt+    case result of+      Left usageErr ->+        throwError (attributeMultiStreamError [(sn, ev) | (sn, ev, _) <- ops] usageErr)+      Right results -> do+        -- Check for any Nothing results (version conflicts)+        let indexed = zip ops results+        mapM+          ( \((StreamName sn, ev, _), mResult) ->+              case mResult of+                Nothing -> throwError (emptyResultError sn ev)+                Just r -> pure r+          )+          indexed+  FindEvents filt -> do+    evs <- case filt of+      FilterCorrelation cid ->+        usePool (store ^. #pool) $+          Session.statement cid SQL.findByCorrelationStmt+      FilterCausationDescendants (EventId eid) ->+        usePool (store ^. #pool) $+          Session.statement eid SQL.findCausationDescendantsStmt+      FilterCausationAncestors (EventId eid) ->+        usePool (store ^. #pool) $+          Session.statement eid SQL.findCausationAncestorsStmt+    decodeReadEvents (store ^. #storeSettings) evs+  SoftDeleteStream (StreamName name) -> do+    rejectInvalidApplicationStream name+    usePool (store ^. #pool) $+      Session.statement name SQL.softDeleteStreamStmt+  HardDeleteStream (StreamName name) -> do+    rejectInvalidApplicationStream name+    let txn = do+          Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"+          HistoryRetention.lockHistoryRetentionCoordinatorTx+          mSid <- Tx.statement name SQL.findStreamIdStmt+          case mSid of+            Nothing -> pure (Right Nothing)+            Just sid -> do+              conflict <- HistoryRetention.activeHistoryRetentionConflictTx+              case conflict of+                Just active -> pure (Left active)+                Nothing -> do+                  HistoryRetention.lockAffectedStreamsForHardDeleteTx sid+                  originated <- Tx.statement sid SQL.deleteAllRowsForOriginStmt+                  Tx.statement originated SQL.deleteJunctionsByEventIdsStmt+                  linkedIn <- Tx.statement sid SQL.deleteStreamOwnJunctionsStmt+                  let affected = originated <> linkedIn+                  Tx.statement affected SQL.deleteDeadLettersForOrphanedEventsStmt+                  Tx.statement affected SQL.deleteOrphanedEventsStmt+                  Tx.statement sid SQL.deleteStreamRowStmt+                  pure (Right (Just (StreamId sid)))+    result <-+      usePool (store ^. #pool) $+        TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write txn+    -- Emit a fail-safe audit signal when the delete actually removed+    -- rows. Compliance-grade audit should still record an+    -- application-level event before calling hardDeleteStream — see+    -- docs/PRODUCTION-DEPLOYMENT.md.+    case result of+      Right (Just sid) ->+        liftIO $ emitOrDrop (store ^. #eventHandler) (KirokuEventHardDeleteIssued (StreamName name) sid)+      Right Nothing -> pure ()+      Left conflict -> do+        liftIO $+          emitOrDrop+            (store ^. #eventHandler)+            (KirokuEventHardDeleteHistoryRetentionConflict (StreamName name) conflict)+        throwError (HistoryRetentionActive (StreamName name) conflict)+    pure (either (const Nothing) (\value -> value) result)+  UndeleteStream (StreamName name) -> do+    rejectInvalidApplicationStream name+    usePool (store ^. #pool) $+      Session.statement name SQL.undeleteStreamStmt+  SetStreamTruncateBefore (StreamName name) (StreamVersion v) -> do+    rejectInvalidApplicationStream name+    usePool (store ^. #pool) $+      Session.statement (name, v) SQL.setStreamTruncateBeforeStmt+  ListSubscriptionDeadLetters query ->+    usePool (store ^. #pool) (DeadLetterSQL.listSubscriptionDeadLettersSession query)+  GetSubscriptionCheckpointInventory ->+    usePool (store ^. #pool) $+      Session.statement () CheckpointInventorySQL.getSubscriptionCheckpointInventoryStmt+  InitializeSubscriptionCheckpoint subscriptionName member policy ->+    usePool (store ^. #pool) $+      CheckpointSQL.initializeSubscriptionCheckpointSession subscriptionName member policy+  AcquireHistoryRetentionLease request -> do+    lease <- runTxOnPool (store ^. #pool) TxSessions.transaction (HistoryRetention.acquireHistoryRetentionLeaseTx request)+    let HistoryRetentionLease {leaseId, owner, protectedThrough, expiresAt} = lease+    liftIO $+      emitOrDrop+        (store ^. #eventHandler)+        (KirokuEventHistoryRetentionLeaseAcquired leaseId owner protectedThrough expiresAt)+    pure lease+  RenewHistoryRetentionLease handle duration -> do+    result <-+      runTxOnPool+        (store ^. #pool)+        TxSessions.transaction+        (HistoryRetention.renewHistoryRetentionLeaseTx handle duration)+    case result of+      Right lease ->+        let HistoryRetentionLease {leaseId, owner, expiresAt} = lease+         in liftIO $+              emitOrDrop+                (store ^. #eventHandler)+                (KirokuEventHistoryRetentionLeaseRenewed leaseId owner expiresAt)+      Left _ -> pure ()+    pure result+  ReleaseHistoryRetentionLease handle -> do+    result <-+      runTxOnPool+        (store ^. #pool)+        TxSessions.transaction+        (HistoryRetention.releaseHistoryRetentionLeaseTx handle)+    case result of+      HistoryRetentionReleased HistoryRetentionLease {leaseId, owner, releasedAt = Just releasedAt} ->+        liftIO $+          emitOrDrop+            (store ^. #eventHandler)+            (KirokuEventHistoryRetentionLeaseReleased leaseId owner releasedAt)+      _ -> pure ()+    pure result+  GetHistoryRetentionLeaseInventory query ->+    runTxOnPool+      (store ^. #pool)+      TxSessions.transaction+      (HistoryRetention.historyRetentionLeaseInventoryTx query)+  PruneHistoryRetentionLeases cutoff -> do+    result <-+      runTxOnPool+        (store ^. #pool)+        TxSessions.transaction+        (HistoryRetention.pruneHistoryRetentionLeasesTx cutoff)+    liftIO $ emitOrDrop (store ^. #eventHandler) (KirokuEventHistoryRetentionLeasesPruned result)+    pure result+  RunTransaction tx ->+    runTxOnPool (store ^. #pool) TxSessions.transaction tx+  RunTransactionNoRetry tx ->+    runTxOnPool (store ^. #pool) TxSessions.transactionNoRetry tx++-- | Convenience: run a Store computation to IO.+runStoreIO ::+  KirokuStore ->+  Eff '[Store, Error StoreError, IOE] a ->+  IO (Either StoreError a)+runStoreIO store = runEff . runErrorNoCallStack . runStorePool store++-- | Interpret Store by reading the store handle from 'KirokuStoreResource'.+runStoreResource ::+  (IOE :> es, Error StoreError :> es, KirokuStoreResource :> es) =>+  Eff (Store : es) a ->+  Eff es a+runStoreResource action = do+  store <- getKirokuStore+  runStorePool store action++-- ---------------------------------------------------------------------------+-- Internal pool helper+-- ---------------------------------------------------------------------------++-- | Run a hasql session against the pool, mapping pool errors to 'StoreError'.+usePool ::+  (IOE :> es, Error StoreError :> es) =>+  Pool ->+  Session.Session a ->+  Eff es a+usePool pool session = do+  result <- liftIO (Pool.use pool session)+  case result of+    Left usageErr -> throwError (mapGenericUsageError usageErr)+    Right a -> pure a++-- | Run a 'Tx.Transaction' against the pool using the supplied entry+-- point ('TxSessions.transaction' or 'TxSessions.transactionNoRetry'),+-- mapping pool errors to 'StoreError'. The isolation level and access+-- mode mirror 'appendMultiStream' / 'HardDeleteStream'.+runTxOnPool ::+  (IOE :> es, Error StoreError :> es) =>+  Pool ->+  (TxSessions.IsolationLevel -> TxSessions.Mode -> Tx.Transaction a -> Session.Session a) ->+  Tx.Transaction a ->+  Eff es a+runTxOnPool pool entry tx = do+  result <-+    liftIO $+      Pool.use pool $+        entry TxSessions.ReadCommitted TxSessions.Write tx+  case result of+    Left usageErr -> throwError (mapTransactionUsageError usageErr)+    Right a -> pure a++rejectInvalidApplicationStream ::+  (Error StoreError :> es) =>+  Text ->+  Eff es ()+rejectInvalidApplicationStream name =+  either throwError pure (validateStreamName (StreamName name))++firstStreamNameError :: [(StreamName, ExpectedVersion, [EventData])] -> Maybe StoreError+firstStreamNameError ops =+  case find (isLeft . validateStreamName . streamNameOf) ops of+    Just (sn, _, _) -> either Just (const Nothing) (validateStreamName sn)+    Nothing -> Nothing+  where+    streamNameOf (sn, _, _) = sn+    isLeft = either (const True) (const False)++-- ---------------------------------------------------------------------------+-- Internal helpers (moved from Append)+-- ---------------------------------------------------------------------------++-- | An event with a guaranteed event ID (pre-generated if needed).+data PreparedEvent = PreparedEvent+  { peEventId :: !UUID,+    peEventType :: !EventType,+    pePayload :: !Value,+    peMetadata :: !(Maybe Value),+    peCausationId :: !(Maybe UUID),+    peCorrelationId :: !(Maybe UUID)+  }+  deriving stock (Generic)++-- | Prepare events by generating UUIDv7s for any event that doesn't+-- have a caller-supplied event ID.+prepareEvents :: (MonadIO m) => [EventData] -> m [PreparedEvent]+prepareEvents evts = liftIO $ do+  let needCount = length (filter (\(EventData eid _ _ _ _ _) -> isNothing eid) evts)+  newIds <-+    if needCount > 0+      then V7.genUUIDs (fromIntegral needCount)+      else pure []+  pure (assign evts newIds)+  where+    assign :: [EventData] -> [UUID] -> [PreparedEvent]+    assign [] _ = []+    assign (EventData mEid eType ePayload eMeta eCaus eCorr : es) ids =+      case mEid of+        Just (EventId uid) ->+          PreparedEvent uid eType ePayload eMeta eCaus eCorr+            : assign es ids+        Nothing -> case ids of+          (uid : rest) ->+            PreparedEvent uid eType ePayload eMeta eCaus eCorr+              : assign es rest+          [] -> error "prepareEvents: ran out of pre-generated UUIDs (bug)"++-- | Build SQL parameters from prepared events.+buildAppendParams :: Text -> UTCTime -> [PreparedEvent] -> SQL.AppendParams+buildAppendParams name now prepared =+  SQL.AppendParams+    { eventIds = V.fromList (map (^. #peEventId) prepared),+      eventTypes = V.fromList (map (\e -> let EventType t = e ^. #peEventType in t) prepared),+      causationIds = V.fromList (map (^. #peCausationId) prepared),+      correlationIds = V.fromList (map (^. #peCorrelationId) prepared),+      payloads = V.fromList (map (^. #pePayload) prepared),+      metadatas = V.fromList (map (^. #peMetadata) prepared),+      createdAts = V.fromList (replicate (length prepared) now),+      streamName = name+    }++-- Cache transaction-control statements just like hasql-transaction; reparsing+-- them on every multi-stream call erodes the pipeline's round-trip savings.+multiAppendBeginStmt :: Statement () ()+multiAppendBeginStmt = preparable "BEGIN" E.noParams D.noResult++multiAppendCommitStmt :: Statement () ()+multiAppendCommitStmt = preparable "COMMIT" E.noParams D.noResult++multiAppendRollbackStmt :: Statement () ()+multiAppendRollbackStmt = preparable "ROLLBACK" E.noParams D.noResult++runAppendMultiStreamPipeline ::+  Vector Text ->+  UTCTime ->+  [(StreamName, ExpectedVersion, [PreparedEvent])] ->+  Session.Session [Maybe AppendResult]+runAppendMultiStreamPipeline names now preparedOps =+  Except.catchError body $ \err -> do+    Session.statement () multiAppendRollbackStmt+    Except.throwError err+  where+    body = do+      results <-+        Session.pipeline $+          Pipeline.statement () multiAppendBeginStmt+            -- Pre-lock the user-named streams in deterministic (stream_id)+            -- order to avoid row-lock deadlocks between concurrent+            -- multi-stream txns touching overlapping streams in different+            -- user orders. See EP-1 F4.+            *> Pipeline.statement names SQL.lockStreamsForMultiStmt+            *> traverse appendPrepared preparedOps+      Session.statement () $+        if any isNothing results+          then multiAppendRollbackStmt+          else multiAppendCommitStmt+      pure results++    appendPrepared (StreamName name, expected, prepared) =+      appendDispatchPipeline expected (buildAppendParams name now prepared)++appendDispatchPipeline :: ExpectedVersion -> SQL.AppendParams -> Pipeline.Pipeline (Maybe AppendResult)+appendDispatchPipeline expected params = case expected of+  ExactVersion (StreamVersion v) ->+    Pipeline.statement (params, v) SQL.appendExpectedVersion+  StreamExists ->+    Pipeline.statement params SQL.appendStreamExists+  NoStream ->+    Pipeline.statement params SQL.appendNoStream+  AnyVersion ->+    Pipeline.statement params SQL.appendAnyVersion++-- | Dispatch the four 'SQL.append*' statements through 'Tx.statement',+-- selecting the right one based on the supplied 'ExpectedVersion'.+--+-- This is the shared building block used by 'AppendMultiStream'\'s+-- interpreter branch and by 'Kiroku.Store.Transaction.appendToStreamTx'.+-- 'AppendToStream' keeps its 'Session.statement'-flavored dispatch — see+-- the M2 entry in the Decision Log on plan 11 for why a 'Tx.Transaction'+-- wrapping a single statement was rejected as a refactoring target.+--+-- @'Nothing'@ comes back when the underlying CTE returns 0 rows — i.e.+-- the precondition failed silently. Callers map that to either+-- 'Kiroku.Store.Error.AppendConflict' (the Tx surface) or+-- 'Kiroku.Store.Error.StoreError' (the @Eff@ surface).+appendDispatchTx :: ExpectedVersion -> SQL.AppendParams -> Tx.Transaction (Maybe AppendResult)+appendDispatchTx expected params = case expected of+  ExactVersion (StreamVersion v) ->+    Tx.statement (params, v) SQL.appendExpectedVersion+  StreamExists ->+    Tx.statement params SQL.appendStreamExists+  NoStream ->+    Tx.statement params SQL.appendNoStream+  AnyVersion ->+    Tx.statement params SQL.appendAnyVersion++-- $internal+-- These bindings are intentionally exposed so that+-- "Kiroku.Store.Transaction" can compose appends with arbitrary+-- 'Tx.Transaction' work without re-implementing UUID prep, parameter+-- packing, or per-version dispatch. They are not part of the supported+-- public surface and may change without notice.++-- No hook preserves the direct read passthrough. Successful hooks map directly+-- into a result vector, avoiding the subscription-specific wrapper/unwrapper.+decodeReadEvents :: (IOE :> es, Error StoreError :> es) => StoreSettings -> Vector RecordedEvent -> Eff es (Vector RecordedEvent)+decodeReadEvents settings events = case decodeHook settings of+  Nothing -> pure events+  Just hook -> V.mapM (\event -> liftIO (hook event) >>= either (throwError . EventDecodeFailed) pure) events
src/Kiroku/Store/Effect/Resource.hs view
@@ -1,7 +1,7 @@ {-# LANGUAGE TypeFamilies #-} -module Kiroku.Store.Effect.Resource (-    -- * The KirokuStoreResource effect+module Kiroku.Store.Effect.Resource+  ( -- * The KirokuStoreResource effect     KirokuStoreResource,      -- * Operations@@ -12,7 +12,8 @@      -- * Runner over an existing handle     runKirokuStoreWith,-) where+  )+where  import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, UnliftStrategy (..), withEffToIO, (:>)) import Effectful.Dispatch.Static (SideEffects (..), StaticRep, evalStaticRep, getStaticRep)@@ -22,18 +23,17 @@ -- KirokuStoreResource static effect -- --------------------------------------------------------------------------- -{- | Static effect carrying a 'KirokuStore' handle.--Static rather than dynamic because the store handle is acquired exactly-once per program and is not meant to be mocked — the dynamic mocking-surface lives on the 'Kiroku.Store.Effect.Store' effect that operates-against the handle. Splitting the resource (static) from the operations-(dynamic) keeps mocking ergonomic without requiring callers to swap the-handle itself.--@Static WithSideEffects@ rather than @Static NoSideEffects@ because the-underlying connection pool performs IO during operation lookup.--}+-- | Static effect carrying a 'KirokuStore' handle.+--+-- Static rather than dynamic because the store handle is acquired exactly+-- once per program and is not meant to be mocked — the dynamic mocking+-- surface lives on the 'Kiroku.Store.Effect.Store' effect that operates+-- against the handle. Splitting the resource (static) from the operations+-- (dynamic) keeps mocking ergonomic without requiring callers to swap the+-- handle itself.+--+-- @Static WithSideEffects@ rather than @Static NoSideEffects@ because the+-- underlying connection pool performs IO during operation lookup. data KirokuStoreResource :: Effect  type instance DispatchOf KirokuStoreResource = Static WithSideEffects@@ -43,34 +43,33 @@ -- | Retrieve the 'KirokuStore' handle from the effect stack. getKirokuStore :: (KirokuStoreResource :> es) => Eff es KirokuStore getKirokuStore = do-    KirokuStoreResource store <- getStaticRep-    pure store+  KirokuStoreResource store <- getStaticRep+  pure store  -- | Bracket-style runner: acquire the store, install the effect, run the action, release. withKirokuStore ::-    (IOE :> es) =>-    ConnectionSettings ->-    Eff (KirokuStoreResource : es) a ->-    Eff es a+  (IOE :> es) =>+  ConnectionSettings ->+  Eff (KirokuStoreResource : es) a ->+  Eff es a withKirokuStore settings action = withEffToIO SeqUnlift $ \unlift ->-    withStore settings $ \store ->-        unlift (evalStaticRep (KirokuStoreResource store) action)--{- | Install an /already acquired/ 'KirokuStore' into the effect stack.--Unlike 'withKirokuStore', this neither acquires nor releases: the caller owns-the handle's lifetime. Use it when a process has opened exactly one store (the-documented arrangement) and needs to run several independent effectful actions-against it. Acquiring a store per action instead costs a connection pool, a-dedicated @LISTEN@ connection, and a publisher thread each time.+  withStore settings $ \store ->+    unlift (evalStaticRep (KirokuStoreResource store) action) -There is no bracket and no unlifting here, but @IOE@ is still required:-'KirokuStoreResource' is @Static WithSideEffects@, and 'evalStaticRep' demands-@IOE@ for any effect declared that way.--}+-- | Install an /already acquired/ 'KirokuStore' into the effect stack.+--+-- Unlike 'withKirokuStore', this neither acquires nor releases: the caller owns+-- the handle's lifetime. Use it when a process has opened exactly one store (the+-- documented arrangement) and needs to run several independent effectful actions+-- against it. Acquiring a store per action instead costs a connection pool, a+-- dedicated @LISTEN@ connection, and a publisher thread each time.+--+-- There is no bracket and no unlifting here, but @IOE@ is still required:+-- 'KirokuStoreResource' is @Static WithSideEffects@, and 'evalStaticRep' demands+-- @IOE@ for any effect declared that way. runKirokuStoreWith ::-    (IOE :> es) =>-    KirokuStore ->-    Eff (KirokuStoreResource : es) a ->-    Eff es a+  (IOE :> es) =>+  KirokuStore ->+  Eff (KirokuStoreResource : es) a ->+  Eff es a runKirokuStoreWith = evalStaticRep . KirokuStoreResource
src/Kiroku/Store/Error.hs view
@@ -1,5 +1,5 @@-module Kiroku.Store.Error (-    StoreError (..),+module Kiroku.Store.Error+  ( StoreError (..),     maxStreamNameBytes,     validateStreamName, @@ -17,7 +17,8 @@     isTransientSerializationError,     -- Internal pure helper exposed for unit testing     extractStreamNameFromDetail,-) where+  )+where  import Control.Applicative ((<|>)) import Control.Exception (Exception)@@ -36,142 +37,127 @@ import Kiroku.Store.Settings (DecodeFailure) import Kiroku.Store.Types -{- | Errors that can occur during store operations.--The constructor set is designed for additive evolution: new failure modes-are added rather than changing existing constructors. Pattern matches that-do not handle a new constructor will surface as @-Wincomplete-patterns@-warnings, never as silent misclassification.--The 'ConnectionError' catch-all is retained for backward compatibility:-anything not matched by a more specific constructor falls through to it.-Consumers should match on the specific constructors first when they want-to make retry-vs-escalate decisions.--'StoreError' derives 'Exception' so it can be thrown with 'throwIO' from-any 'IO' or 'MonadIO' context. The standard pattern for store callers-remains @runStoreIO :: IO (Either StoreError a)@; the 'Exception' instance-is for callers who prefer the exception-based idiom (e.g., when bridging-to libraries that expect 'SomeException').--}+-- | Errors that can occur during store operations.+--+-- The constructor set is designed for additive evolution: new failure modes+-- are added rather than changing existing constructors. Pattern matches that+-- do not handle a new constructor will surface as @-Wincomplete-patterns@+-- warnings, never as silent misclassification.+--+-- The 'ConnectionError' catch-all is retained for backward compatibility:+-- anything not matched by a more specific constructor falls through to it.+-- Consumers should match on the specific constructors first when they want+-- to make retry-vs-escalate decisions.+--+-- 'StoreError' derives 'Exception' so it can be thrown with 'throwIO' from+-- any 'IO' or 'MonadIO' context. The standard pattern for store callers+-- remains @runStoreIO :: IO (Either StoreError a)@; the 'Exception' instance+-- is for callers who prefer the exception-based idiom (e.g., when bridging+-- to libraries that expect 'SomeException'). data StoreError-    = {- | The actual stream version did not match the caller's-      'ExactVersion' expectation. The third field is the actual-      version placeholder. The append statement returns zero rows on a-      version mismatch and the store does not issue an extra read to-      recover the live version, so this field is 'StreamVersion' 0 on-      every empty-CTE rejection. Callers needing the live version must-      read the stream, for example with 'Kiroku.Store.Read.getStream'.-      -}-      WrongExpectedVersion !StreamName !ExpectedVersion !StreamVersion-    | {- | The caller supplied an empty event batch to an append surface.--      Appending zero events is always a programming mistake: before this-      guard existed, an empty batch silently took the global @$all@ row-      lock, fired NOTIFY triggers, and under 'NoStream' even created an-      empty stream. The interpreter rejects it before any pool work.-      -}-      EmptyAppendBatch !StreamName-    | -- | The named stream does not exist (or has been soft-deleted).-      StreamNotFound !StreamName-    | {- | A supported hard delete found one or more active replay-history-      retention leases and committed no destructive work.-      -}-      HistoryRetentionActive !StreamName !HistoryRetentionConflict-    | {- | The named stream is reserved for store internals and cannot be-      used as an application stream target. For now this applies only-      to @$all@, which is the global read stream backed by the seeded-      @streams.stream_id = 0@ row.-      -}-      ReservedStreamName !StreamName-    | {- | The stream name exceeds 'maxStreamNameBytes' bytes of UTF-8.--      The append notification payload embeds the stream name, and-      PostgreSQL rejects @pg_notify@ payloads of 8,000 bytes or more.-      Enforcing a much smaller byte bound before any database work-      prevents an oversized stream name from failing late as an opaque-      trigger/server error. The second field is the offending UTF-8-      byte length.-      -}-      StreamNameTooLong !StreamName !Int-    | {- | The named stream already exists. Returned for 'NoStream'-      expectations against an existing stream and for 'linkToStream'-      targets that already exist with conflicting state.-      -}-      StreamAlreadyExists !StreamName-    | {- | A caller-supplied @event_id@ collides with an existing event.--      Append maps both @events_pkey@ and the composite-      @stream_events_pkey@ to this constructor.--      The constructor carries 'Just' the id when the PostgreSQL detail-      string could be parsed, 'Nothing' otherwise. A 'Nothing' payload-      is rare in practice; it occurs when the server's locale changes-      the detail-string format. Consumers that want to surface the-      offending id to the user should match on 'Just'.-      -}-      DuplicateEvent !(Maybe EventId)-    | {- | A 'Kiroku.Store.Link.linkToStream' call tried to link an-      event into a target stream that already contains it.--      Maps the @23505@ unique violation on @stream_events_pkey@, whose-      key is @(event_id, stream_id)@. Carries 'Just' the offending event-      id when PostgreSQL's detail string is parseable.-      -}-      EventAlreadyLinked !StreamName !(Maybe EventId)-    | {- | A 'Kiroku.Store.Link.linkToStream' call referenced an event id-      that does not exist. The link CTE surfaces this as a @23502@-      not-null violation on @stream_events.original_stream_id@ and the-      whole batch rolls back.-      -}-      LinkSourceEventMissing !StreamName-    | {- | The connection pool timed out acquiring a connection. Almost-      always retryable after a small backoff; sustained timeouts-      indicate that the pool is undersized for the offered load or-      that the database is unreachable.-      -}-      PoolAcquisitionTimeout-    | {- | A network or session-level error tore down the connection-      mid-operation. The 'Text' carries the underlying hasql error-      description for diagnostics. Retryable in most cases.-      -}-      ConnectionLost !Text-    | {- | PostgreSQL aborted the transaction with a class-40-      (transaction rollback) @SQLSTATE@: @40001@ serialization_failure-      or @40P01@ deadlock_detected. The first 'Text' is the @SQLSTATE@-      code, the second is the human-readable message.--      __Retryable.__ PostgreSQL rolled the transaction back completely-      and expects the client to run it again; nothing was committed.-      The store already retries an append once on these codes before-      surfacing them, so reaching a caller means the conflict repeated-      — retry with backoff rather than escalating.--      Appends are safe to retry when the caller supplies-      'Kiroku.Store.Types.EventData.eventId' values, which makes the-      retry idempotent; see the "Idempotent retries" note on-      'Kiroku.Store.Append.appendToStream'.-      -}-      TransientTransactionFailure !Text !Text-    | {- | PostgreSQL raised a server error whose @SQLSTATE@ code is-      outside the set this store recognises, or an append violated-      @ux_stream_events_stream_version@ (@23505@), indicating an-      internal stream-version invariant failure. The-      first 'Text' is the @SQLSTATE@ code, the second is the-      human-readable message. This is *not* generally retryable —-      investigate.-      -}-      UnexpectedServerError !Text !Text-    | {- | Catch-all for everything not matched by a more specific-      constructor above. Retained for backward compatibility with-      consumers that already pattern-match on it; new code should-      prefer the specific constructors.-      -}-      ConnectionError !Text-    | -- | A decode hook refused an event; the read returns no partial result.-      EventDecodeFailed !DecodeFailure-    deriving stock (Eq, Show, Generic)-    deriving anyclass (Exception)+  = -- | The actual stream version did not match the caller's+    --       'ExactVersion' expectation. The third field is the actual+    --       version placeholder. The append statement returns zero rows on a+    --       version mismatch and the store does not issue an extra read to+    --       recover the live version, so this field is 'StreamVersion' 0 on+    --       every empty-CTE rejection. Callers needing the live version must+    --       read the stream, for example with 'Kiroku.Store.Read.getStream'.+    WrongExpectedVersion !StreamName !ExpectedVersion !StreamVersion+  | -- | The caller supplied an empty event batch to an append surface.+    --+    --       Appending zero events is always a programming mistake: before this+    --       guard existed, an empty batch silently took the global @$all@ row+    --       lock, fired NOTIFY triggers, and under 'NoStream' even created an+    --       empty stream. The interpreter rejects it before any pool work.+    EmptyAppendBatch !StreamName+  | -- | The named stream does not exist (or has been soft-deleted).+    StreamNotFound !StreamName+  | -- | A supported hard delete found one or more active replay-history+    --       retention leases and committed no destructive work.+    HistoryRetentionActive !StreamName !HistoryRetentionConflict+  | -- | The named stream is reserved for store internals and cannot be+    --       used as an application stream target. For now this applies only+    --       to @$all@, which is the global read stream backed by the seeded+    --       @streams.stream_id = 0@ row.+    ReservedStreamName !StreamName+  | -- | The stream name exceeds 'maxStreamNameBytes' bytes of UTF-8.+    --+    --       The append notification payload embeds the stream name, and+    --       PostgreSQL rejects @pg_notify@ payloads of 8,000 bytes or more.+    --       Enforcing a much smaller byte bound before any database work+    --       prevents an oversized stream name from failing late as an opaque+    --       trigger/server error. The second field is the offending UTF-8+    --       byte length.+    StreamNameTooLong !StreamName !Int+  | -- | The named stream already exists. Returned for 'NoStream'+    --       expectations against an existing stream and for 'linkToStream'+    --       targets that already exist with conflicting state.+    StreamAlreadyExists !StreamName+  | -- | A caller-supplied @event_id@ collides with an existing event.+    --+    --       Append maps both @events_pkey@ and the composite+    --       @stream_events_pkey@ to this constructor.+    --+    --       The constructor carries 'Just' the id when the PostgreSQL detail+    --       string could be parsed, 'Nothing' otherwise. A 'Nothing' payload+    --       is rare in practice; it occurs when the server's locale changes+    --       the detail-string format. Consumers that want to surface the+    --       offending id to the user should match on 'Just'.+    DuplicateEvent !(Maybe EventId)+  | -- | A 'Kiroku.Store.Link.linkToStream' call tried to link an+    --       event into a target stream that already contains it.+    --+    --       Maps the @23505@ unique violation on @stream_events_pkey@, whose+    --       key is @(event_id, stream_id)@. Carries 'Just' the offending event+    --       id when PostgreSQL's detail string is parseable.+    EventAlreadyLinked !StreamName !(Maybe EventId)+  | -- | A 'Kiroku.Store.Link.linkToStream' call referenced an event id+    --       that does not exist. The link CTE surfaces this as a @23502@+    --       not-null violation on @stream_events.original_stream_id@ and the+    --       whole batch rolls back.+    LinkSourceEventMissing !StreamName+  | -- | The connection pool timed out acquiring a connection. Almost+    --       always retryable after a small backoff; sustained timeouts+    --       indicate that the pool is undersized for the offered load or+    --       that the database is unreachable.+    PoolAcquisitionTimeout+  | -- | A network or session-level error tore down the connection+    --       mid-operation. The 'Text' carries the underlying hasql error+    --       description for diagnostics. Retryable in most cases.+    ConnectionLost !Text+  | -- | PostgreSQL aborted the transaction with a class-40+    --       (transaction rollback) @SQLSTATE@: @40001@ serialization_failure+    --       or @40P01@ deadlock_detected. The first 'Text' is the @SQLSTATE@+    --       code, the second is the human-readable message.+    --+    --       __Retryable.__ PostgreSQL rolled the transaction back completely+    --       and expects the client to run it again; nothing was committed.+    --       The store already retries an append once on these codes before+    --       surfacing them, so reaching a caller means the conflict repeated+    --       — retry with backoff rather than escalating.+    --+    --       Appends are safe to retry when the caller supplies+    --       'Kiroku.Store.Types.EventData.eventId' values, which makes the+    --       retry idempotent; see the "Idempotent retries" note on+    --       'Kiroku.Store.Append.appendToStream'.+    TransientTransactionFailure !Text !Text+  | -- | PostgreSQL raised a server error whose @SQLSTATE@ code is+    --       outside the set this store recognises, or an append violated+    --       @ux_stream_events_stream_version@ (@23505@), indicating an+    --       internal stream-version invariant failure. The+    --       first 'Text' is the @SQLSTATE@ code, the second is the+    --       human-readable message. This is *not* generally retryable —+    --       investigate.+    UnexpectedServerError !Text !Text+  | -- | Catch-all for everything not matched by a more specific+    --       constructor above. Retained for backward compatibility with+    --       consumers that already pattern-match on it; new code should+    --       prefer the specific constructors.+    ConnectionError !Text+  | -- | A decode hook refused an event; the read returns no partial result.+    EventDecodeFailed !DecodeFailure+  deriving stock (Eq, Show, Generic)+  deriving anyclass (Exception)  -- | Maximum stream-name length in UTF-8 bytes. maxStreamNameBytes :: Int@@ -180,379 +166,364 @@ -- | Validate application stream names before any database work. validateStreamName :: StreamName -> Either StoreError () validateStreamName sn@(StreamName name)-    | name == "$all" = Left (ReservedStreamName sn)-    | byteLength > maxStreamNameBytes = Left (StreamNameTooLong sn byteLength)-    | otherwise = Right ()+  | name == "$all" = Left (ReservedStreamName sn)+  | byteLength > maxStreamNameBytes = Left (StreamNameTooLong sn byteLength)+  | otherwise = Right ()   where     byteLength = BS.length (TE.encodeUtf8 name) -{- | Map a hasql 'UsageError' to a 'StoreError'.--Pattern matches on the error hierarchy:-  UsageError -> SessionUsageError -> StatementSessionError -> ServerStatementError -> ServerError-  UsageError -> SessionUsageError -> ScriptSessionError -> ServerError--Scripts and statements share server classification. Connection establishment-and session connection loss use 'ConnectionLost'; acquisition timeout uses-'PoolAcquisitionTimeout'. Unsupported non-server errors retain 'ConnectionError'.--PostgreSQL error code mapping:-  23505 (unique_violation) + events_pkey            -> 'DuplicateEvent'-  23505 (unique_violation) + stream_events_pkey     -> 'DuplicateEvent'-  23505 + ux_stream_events_stream_version          -> 'UnexpectedServerError'-  23505 (unique_violation) + ix_streams_stream_name -> 'StreamAlreadyExists'-  23505 (unique_violation) + other                  -> 'WrongExpectedVersion'-  23503 (foreign_key_violation)                     -> 'StreamNotFound'-  40001 / 40P01                                    -> 'TransientTransactionFailure'-  any other server code                             -> 'UnexpectedServerError'--Constraint names are compared exactly, with the quoted message taking-precedence over a delimiter-aware detail fallback. Matching depends on the-four literal names above. If a future schema migration renames a-constraint, the @23505@ branch falls through to the generic-'WrongExpectedVersion' mapping; keep the names stable in-@kiroku-store-migrations/migrations@.--}+-- | Map a hasql 'UsageError' to a 'StoreError'.+--+-- Pattern matches on the error hierarchy:+--   UsageError -> SessionUsageError -> StatementSessionError -> ServerStatementError -> ServerError+--   UsageError -> SessionUsageError -> ScriptSessionError -> ServerError+--+-- Scripts and statements share server classification. Connection establishment+-- and session connection loss use 'ConnectionLost'; acquisition timeout uses+-- 'PoolAcquisitionTimeout'. Unsupported non-server errors retain 'ConnectionError'.+--+-- PostgreSQL error code mapping:+--   23505 (unique_violation) + events_pkey            -> 'DuplicateEvent'+--   23505 (unique_violation) + stream_events_pkey     -> 'DuplicateEvent'+--   23505 + ux_stream_events_stream_version          -> 'UnexpectedServerError'+--   23505 (unique_violation) + ix_streams_stream_name -> 'StreamAlreadyExists'+--   23505 (unique_violation) + other                  -> 'WrongExpectedVersion'+--   23503 (foreign_key_violation)                     -> 'StreamNotFound'+--   40001 / 40P01                                    -> 'TransientTransactionFailure'+--   any other server code                             -> 'UnexpectedServerError'+--+-- Constraint names are compared exactly, with the quoted message taking+-- precedence over a delimiter-aware detail fallback. Matching depends on the+-- four literal names above. If a future schema migration renames a+-- constraint, the @23505@ branch falls through to the generic+-- 'WrongExpectedVersion' mapping; keep the names stable in+-- @kiroku-store-migrations/migrations@. mapUsageError :: Text -> ExpectedVersion -> UsageError -> StoreError mapUsageError streamName expected = \case-    SessionUsageError sessionErr ->-        mapSessionError streamName expected sessionErr-    ConnectionUsageError connErr ->-        ConnectionLost (T.pack (show connErr))-    AcquisitionTimeoutUsageError ->-        PoolAcquisitionTimeout--{- | Map a hasql 'UsageError' raised inside an opaque transaction body.+  SessionUsageError sessionErr ->+    mapSessionError streamName expected sessionErr+  ConnectionUsageError connErr ->+    ConnectionLost (T.pack (show connErr))+  AcquisitionTimeoutUsageError ->+    PoolAcquisitionTimeout -The body has no stream context, so append duplicate violations are mapped-directly to 'DuplicateEvent'. Every other failure uses the generic mapper so-its SQLSTATE and server message survive without inventing stream context.--}+-- | Map a hasql 'UsageError' raised inside an opaque transaction body.+--+-- The body has no stream context, so append duplicate violations are mapped+-- directly to 'DuplicateEvent'. Every other failure uses the generic mapper so+-- its SQLSTATE and server message survive without inventing stream context. mapTransactionUsageError :: UsageError -> StoreError mapTransactionUsageError usageErr =-    case extractServerError usageErr of-        Just (Errors.ServerError "23505" message detail _ _)-            | uniqueConstraintName message detail == Just "events_pkey" ->-                DuplicateEvent (EventId <$> (detail >>= extractUuidFromDetail))-            | uniqueConstraintName message detail == Just "stream_events_pkey" ->-                DuplicateEvent (EventId <$> (detail >>= extractFirstUuidFromCompositeDetail))-        _ ->-            mapGenericUsageError usageErr+  case extractServerError usageErr of+    Just (Errors.ServerError "23505" message detail _ _)+      | uniqueConstraintName message detail == Just "events_pkey" ->+          DuplicateEvent (EventId <$> (detail >>= extractUuidFromDetail))+      | uniqueConstraintName message detail == Just "stream_events_pkey" ->+          DuplicateEvent (EventId <$> (detail >>= extractFirstUuidFromCompositeDetail))+    _ ->+      mapGenericUsageError usageErr  -- | Generic, non-append-shaped mapping for hasql pool usage errors. mapGenericUsageError :: UsageError -> StoreError mapGenericUsageError = \case-    ConnectionUsageError connErr ->-        ConnectionLost (T.pack (show connErr))-    AcquisitionTimeoutUsageError ->-        PoolAcquisitionTimeout-    SessionUsageError (Errors.ConnectionSessionError detail) ->-        ConnectionLost detail-    SessionUsageError sessionErr ->-        case extractServerError (SessionUsageError sessionErr) of-            Just (Errors.ServerError code message _ _ _)-                | isTransientTransactionCode code ->-                    TransientTransactionFailure code message-                | otherwise ->-                    UnexpectedServerError code message-            Nothing ->-                ConnectionError ("Session error: " <> T.pack (show sessionErr))+  ConnectionUsageError connErr ->+    ConnectionLost (T.pack (show connErr))+  AcquisitionTimeoutUsageError ->+    PoolAcquisitionTimeout+  SessionUsageError (Errors.ConnectionSessionError detail) ->+    ConnectionLost detail+  SessionUsageError sessionErr ->+    case extractServerError (SessionUsageError sessionErr) of+      Just (Errors.ServerError code message _ _ _)+        | isTransientTransactionCode code ->+            TransientTransactionFailure code message+        | otherwise ->+            UnexpectedServerError code message+      Nothing ->+        ConnectionError ("Session error: " <> T.pack (show sessionErr))  -- | Map link-specific hasql failures to typed link errors. mapLinkUsageError :: StreamName -> UsageError -> StoreError mapLinkUsageError target usageErr =-    case extractServerError usageErr of-        Just (Errors.ServerError "23505" message detail _ _)-            | uniqueConstraintName message detail == Just "stream_events_pkey" ->-                EventAlreadyLinked target (extractCompositeEventId detail)-        Just (Errors.ServerError "23502" _ _ _ _) ->-            LinkSourceEventMissing target-        _ ->-            mapGenericUsageError usageErr+  case extractServerError usageErr of+    Just (Errors.ServerError "23505" message detail _ _)+      | uniqueConstraintName message detail == Just "stream_events_pkey" ->+          EventAlreadyLinked target (extractCompositeEventId detail)+    Just (Errors.ServerError "23502" _ _ _ _) ->+      LinkSourceEventMissing target+    _ ->+      mapGenericUsageError usageErr   where     extractCompositeEventId (Just d) = EventId <$> extractFirstUuidFromCompositeDetail d     extractCompositeEventId Nothing = Nothing  mapSessionError :: Text -> ExpectedVersion -> Errors.SessionError -> StoreError mapSessionError streamName expected = \case-    Errors.StatementSessionError _ _ _ _ _ stmtErr ->-        mapStatementError streamName expected stmtErr-    Errors.ScriptSessionError _ serverErr ->-        mapServerError streamName expected serverErr-    Errors.ConnectionSessionError detail ->-        ConnectionLost detail-    other ->-        ConnectionError ("Session error: " <> T.pack (show other))+  Errors.StatementSessionError _ _ _ _ _ stmtErr ->+    mapStatementError streamName expected stmtErr+  Errors.ScriptSessionError _ serverErr ->+    mapServerError streamName expected serverErr+  Errors.ConnectionSessionError detail ->+    ConnectionLost detail+  other ->+    ConnectionError ("Session error: " <> T.pack (show other))  mapStatementError :: Text -> ExpectedVersion -> Errors.StatementError -> StoreError mapStatementError streamName expected = \case-    Errors.ServerStatementError serverErr ->-        mapServerError streamName expected serverErr-    other ->-        ConnectionError ("Statement error: " <> T.pack (show other))+  Errors.ServerStatementError serverErr ->+    mapServerError streamName expected serverErr+  other ->+    ConnectionError ("Statement error: " <> T.pack (show other))  mapServerError :: Text -> ExpectedVersion -> Errors.ServerError -> StoreError mapServerError streamName expected (Errors.ServerError code message detail _hint _position)-    | code == "23505" = mapUniqueViolation streamName expected message detail-    | code == "23503" = StreamNotFound (StreamName streamName)-    | isTransientTransactionCode code = TransientTransactionFailure code message-    | otherwise = UnexpectedServerError code message--{- | Map a unique_violation (23505) to an StoreError.--PostgreSQL reports constraint violations with:-  - message: "duplicate key value violates unique constraint \"events_pkey\""-  - detail: "Key (event_id)=(uuid-value) already exists."--We extract one constraint name, preferring the quoted message over detail,-then compare it exactly. Composite link keys use their first UUID. A-stream-version index violation is an invariant failure, not a precondition-conflict, and preserves the SQLSTATE and original message.+  | code == "23505" = mapUniqueViolation streamName expected message detail+  | code == "23503" = StreamNotFound (StreamName streamName)+  | isTransientTransactionCode code = TransientTransactionFailure code message+  | otherwise = UnexpectedServerError code message -When the events_pkey case fires but the detail string cannot be parsed-(e.g., the server's locale produced an unexpected format), the-'DuplicateEvent' constructor carries 'Nothing' rather than a fabricated-all-zeroes UUID.--}+-- | Map a unique_violation (23505) to an StoreError.+--+-- PostgreSQL reports constraint violations with:+--   - message: "duplicate key value violates unique constraint \"events_pkey\""+--   - detail: "Key (event_id)=(uuid-value) already exists."+--+-- We extract one constraint name, preferring the quoted message over detail,+-- then compare it exactly. Composite link keys use their first UUID. A+-- stream-version index violation is an invariant failure, not a precondition+-- conflict, and preserves the SQLSTATE and original message.+--+-- When the events_pkey case fires but the detail string cannot be parsed+-- (e.g., the server's locale produced an unexpected format), the+-- 'DuplicateEvent' constructor carries 'Nothing' rather than a fabricated+-- all-zeroes UUID. mapUniqueViolation :: Text -> ExpectedVersion -> Text -> Maybe Text -> StoreError mapUniqueViolation streamName expected message detail =-    case uniqueConstraintName message detail of-        Just "events_pkey" -> DuplicateEvent (EventId <$> (detail >>= extractUuidFromDetail))-        Just "stream_events_pkey" -> DuplicateEvent (EventId <$> (detail >>= extractFirstUuidFromCompositeDetail))-        Just "ix_streams_stream_name" -> StreamAlreadyExists (StreamName streamName)-        Just "ux_stream_events_stream_version" -> UnexpectedServerError "23505" message-        _ ->-            -- Generic unique violation — treat as version conflict-            WrongExpectedVersion (StreamName streamName) expected (StreamVersion 0)+  case uniqueConstraintName message detail of+    Just "events_pkey" -> DuplicateEvent (EventId <$> (detail >>= extractUuidFromDetail))+    Just "stream_events_pkey" -> DuplicateEvent (EventId <$> (detail >>= extractFirstUuidFromCompositeDetail))+    Just "ix_streams_stream_name" -> StreamAlreadyExists (StreamName streamName)+    Just "ux_stream_events_stream_version" -> UnexpectedServerError "23505" message+    _ ->+      -- Generic unique violation — treat as version conflict+      WrongExpectedVersion (StreamName streamName) expected (StreamVersion 0) -{- | PostgreSQL's quoted constraint wins even when it is unknown. Legacy-detail-only errors may name an owned constraint as a complete identifier;-underscores and dollar signs are identifier characters, not delimiters.-This helper is used only after a failed statement has returned SQLSTATE 23505.--}+-- | PostgreSQL's quoted constraint wins even when it is unknown. Legacy+-- detail-only errors may name an owned constraint as a complete identifier;+-- underscores and dollar signs are identifier characters, not delimiters.+-- This helper is used only after a failed statement has returned SQLSTATE 23505. uniqueConstraintName :: Text -> Maybe Text -> Maybe Text uniqueConstraintName message detail =-    quotedConstraint message <|> (detail >>= fromDetail)+  quotedConstraint message <|> (detail >>= fromDetail)   where     quotedConstraint input =-        case T.breakOn "unique constraint \"" input of-            (_, rest)-                | not (T.null rest) ->-                    let (name, closing) = T.breakOn "\"" (T.drop (T.length "unique constraint \"") rest)-                     in if T.null name || T.null closing then Nothing else Just name-            _ -> Nothing+      case T.breakOn "unique constraint \"" input of+        (_, rest)+          | not (T.null rest) ->+              let (name, closing) = T.breakOn "\"" (T.drop (T.length "unique constraint \"") rest)+               in if T.null name || T.null closing then Nothing else Just name+        _ -> Nothing     fromDetail input =-        quotedConstraint input <|> find (`elem` ownedConstraints) (T.split (not . identifierChar) input)+      quotedConstraint input <|> find (`elem` ownedConstraints) (T.split (not . identifierChar) input)     identifierChar c = isAlphaNum c || c == '_' || c == '$'     ownedConstraints = ["events_pkey", "stream_events_pkey", "ix_streams_stream_name", "ux_stream_events_stream_version"] -{- | Append-precondition failures observable inside a-'Hasql.Transaction.Transaction' body.--'Kiroku.Store.Transaction.appendToStreamTx' returns-@'Either' 'AppendConflict' 'AppendResult'@ rather than throwing, because-'Hasql.Transaction.Transaction' has no exception channel — the caller-decides whether to call 'Hasql.Transaction.condemn', branch around the-conflict, or recover. Reserved-stream rejection is /not/ part of this-sum because callers of 'Kiroku.Store.Transaction.appendToStreamTx' are-expected to validate the stream name themselves before entering the-transaction body (the high-level wrapper-'Kiroku.Store.Transaction.runTransactionAppending' does so prior to-opening the transaction and surfaces 'ReservedStreamName' as a-'StoreError' instead).--The constructors are 1:1 with the corresponding 'StoreError' variants —-see 'appendConflictToStoreError'.--}+-- | Append-precondition failures observable inside a+-- 'Hasql.Transaction.Transaction' body.+--+-- 'Kiroku.Store.Transaction.appendToStreamTx' returns+-- @'Either' 'AppendConflict' 'AppendResult'@ rather than throwing, because+-- 'Hasql.Transaction.Transaction' has no exception channel — the caller+-- decides whether to call 'Hasql.Transaction.condemn', branch around the+-- conflict, or recover. Reserved-stream rejection is /not/ part of this+-- sum because callers of 'Kiroku.Store.Transaction.appendToStreamTx' are+-- expected to validate the stream name themselves before entering the+-- transaction body (the high-level wrapper+-- 'Kiroku.Store.Transaction.runTransactionAppending' does so prior to+-- opening the transaction and surfaces 'ReservedStreamName' as a+-- 'StoreError' instead).+--+-- The constructors are 1:1 with the corresponding 'StoreError' variants —+-- see 'appendConflictToStoreError'. data AppendConflict-    = {- | Mirror of 'WrongExpectedVersion'. The third field is the-      stream-version placeholder. The append statement returns zero-      rows on a version mismatch and the store does not issue an extra-      read to recover the live version, so this field is-      @'StreamVersion' 0@ on every empty-CTE rejection.-      -}-      WrongExpectedVersionConflict !StreamName !ExpectedVersion !StreamVersion-    | -- | Mirror of 'EmptyAppendBatch'.-      EmptyAppendBatchConflict !StreamName-    | -- | Mirror of 'StreamNotFound'.-      StreamNotFoundConflict !StreamName-    | -- | Mirror of 'StreamAlreadyExists'.-      StreamAlreadyExistsConflict !StreamName-    deriving stock (Eq, Show, Generic)+  = -- | Mirror of 'WrongExpectedVersion'. The third field is the+    --       stream-version placeholder. The append statement returns zero+    --       rows on a version mismatch and the store does not issue an extra+    --       read to recover the live version, so this field is+    --       @'StreamVersion' 0@ on every empty-CTE rejection.+    WrongExpectedVersionConflict !StreamName !ExpectedVersion !StreamVersion+  | -- | Mirror of 'EmptyAppendBatch'.+    EmptyAppendBatchConflict !StreamName+  | -- | Mirror of 'StreamNotFound'.+    StreamNotFoundConflict !StreamName+  | -- | Mirror of 'StreamAlreadyExists'.+    StreamAlreadyExistsConflict !StreamName+  deriving stock (Eq, Show, Generic) -{- | Project an 'AppendConflict' onto the corresponding 'StoreError'-constructor. Used by 'Kiroku.Store.Transaction.runTransactionAppending'-when surfacing conflicts at the @Eff@ boundary.--}+-- | Project an 'AppendConflict' onto the corresponding 'StoreError'+-- constructor. Used by 'Kiroku.Store.Transaction.runTransactionAppending'+-- when surfacing conflicts at the @Eff@ boundary. appendConflictToStoreError :: AppendConflict -> StoreError appendConflictToStoreError = \case-    WrongExpectedVersionConflict sn ev sv -> WrongExpectedVersion sn ev sv-    EmptyAppendBatchConflict sn -> EmptyAppendBatch sn-    StreamNotFoundConflict sn -> StreamNotFound sn-    StreamAlreadyExistsConflict sn -> StreamAlreadyExists sn--{- | Infer the appropriate 'AppendConflict' from an empty CTE result.--Mirror of 'emptyResultError' for the 'AppendConflict' surface. When the-CTE returns 0 rows, the version check or existence check failed-silently — the constructor depends on the supplied 'ExpectedVersion':+  WrongExpectedVersionConflict sn ev sv -> WrongExpectedVersion sn ev sv+  EmptyAppendBatchConflict sn -> EmptyAppendBatch sn+  StreamNotFoundConflict sn -> StreamNotFound sn+  StreamAlreadyExistsConflict sn -> StreamAlreadyExists sn -  ExactVersion v -> WrongExpectedVersionConflict (version mismatch or-                    soft-deleted)-  StreamExists   -> StreamNotFoundConflict (missing or soft-deleted)-  NoStream       -> StreamAlreadyExistsConflict-  AnyVersion     -> StreamNotFoundConflict (only happens when the-                    existing row is soft-deleted and the upsert's DO-                    UPDATE WHERE filter rejects it)--}+-- | Infer the appropriate 'AppendConflict' from an empty CTE result.+--+-- Mirror of 'emptyResultError' for the 'AppendConflict' surface. When the+-- CTE returns 0 rows, the version check or existence check failed+-- silently — the constructor depends on the supplied 'ExpectedVersion':+--+--   ExactVersion v -> WrongExpectedVersionConflict (version mismatch or+--                     soft-deleted)+--   StreamExists   -> StreamNotFoundConflict (missing or soft-deleted)+--   NoStream       -> StreamAlreadyExistsConflict+--   AnyVersion     -> StreamNotFoundConflict (only happens when the+--                     existing row is soft-deleted and the upsert's DO+--                     UPDATE WHERE filter rejects it) emptyResultConflict :: StreamName -> ExpectedVersion -> AppendConflict emptyResultConflict sn = \case-    ExactVersion v ->-        WrongExpectedVersionConflict sn (ExactVersion v) (StreamVersion 0)-    StreamExists ->-        StreamNotFoundConflict sn-    NoStream ->-        StreamAlreadyExistsConflict sn-    AnyVersion ->-        StreamNotFoundConflict sn--{- | Infer the appropriate error from an empty CTE result.+  ExactVersion v ->+    WrongExpectedVersionConflict sn (ExactVersion v) (StreamVersion 0)+  StreamExists ->+    StreamNotFoundConflict sn+  NoStream ->+    StreamAlreadyExistsConflict sn+  AnyVersion ->+    StreamNotFoundConflict sn -When the CTE returns 0 rows (no ServerError raised), the version check-or existence check failed silently. Map based on the ExpectedVersion:-  ExactVersion v -> WrongExpectedVersion (version mismatch, or soft-deleted)-  StreamExists   -> StreamNotFound (stream doesn't exist, or soft-deleted)-  NoStream       -> StreamAlreadyExists (stream already exists)-  AnyVersion     -> StreamNotFound (only happens when the existing row is-                    soft-deleted and the upsert's DO UPDATE WHERE filter-                    rejects it; the soft-delete CTE filter was added in-                    EP-1 F2, so this branch is the soft-deleted-stream case)--}+-- | Infer the appropriate error from an empty CTE result.+--+-- When the CTE returns 0 rows (no ServerError raised), the version check+-- or existence check failed silently. Map based on the ExpectedVersion:+--   ExactVersion v -> WrongExpectedVersion (version mismatch, or soft-deleted)+--   StreamExists   -> StreamNotFound (stream doesn't exist, or soft-deleted)+--   NoStream       -> StreamAlreadyExists (stream already exists)+--   AnyVersion     -> StreamNotFound (only happens when the existing row is+--                     soft-deleted and the upsert's DO UPDATE WHERE filter+--                     rejects it; the soft-delete CTE filter was added in+--                     EP-1 F2, so this branch is the soft-deleted-stream case) emptyResultError :: Text -> ExpectedVersion -> StoreError emptyResultError streamName expected =-    appendConflictToStoreError-        (emptyResultConflict (StreamName streamName) expected)+  appendConflictToStoreError+    (emptyResultConflict (StreamName streamName) expected) -{- | Extract a UUID from a PostgreSQL detail string like:-"Key (event_id)=(01234567-89ab-7def-8012-34567890abcd) already exists."--}+-- | Extract a UUID from a PostgreSQL detail string like:+-- "Key (event_id)=(01234567-89ab-7def-8012-34567890abcd) already exists." extractUuidFromDetail :: Text -> Maybe UUID extractUuidFromDetail detail =-    case T.breakOn "=(" detail of-        (_, rest)-            | not (T.null rest) ->-                let afterParen = T.drop 2 rest -- skip "=("-                    uuidText = T.takeWhile (/= ')') afterParen-                 in UUID.fromText uuidText-        _ -> Nothing+  case T.breakOn "=(" detail of+    (_, rest)+      | not (T.null rest) ->+          let afterParen = T.drop 2 rest -- skip "=("+              uuidText = T.takeWhile (/= ')') afterParen+           in UUID.fromText uuidText+    _ -> Nothing -{- | Extract the first UUID from a PostgreSQL composite-key detail string like:-"Key (event_id, stream_id)=(01234567-89ab-7def-8012-34567890abcd, 42) already exists."--}+-- | Extract the first UUID from a PostgreSQL composite-key detail string like:+-- "Key (event_id, stream_id)=(01234567-89ab-7def-8012-34567890abcd, 42) already exists." extractFirstUuidFromCompositeDetail :: Text -> Maybe UUID extractFirstUuidFromCompositeDetail detail =-    case T.breakOn "=(" detail of-        (_, rest)-            | not (T.null rest) ->-                let afterParen = T.drop 2 rest -- skip "=("-                    uuidText = T.strip (T.takeWhile (\c -> c /= ',' && c /= ')') afterParen)-                 in UUID.fromText uuidText-        _ -> Nothing--{- | Extract a stream name from a PostgreSQL unique-violation detail string-like @"Key (stream_name)=(orders-1) already exists."@.+  case T.breakOn "=(" detail of+    (_, rest)+      | not (T.null rest) ->+          let afterParen = T.drop 2 rest -- skip "=("+              uuidText = T.strip (T.takeWhile (\c -> c /= ',' && c /= ')') afterParen)+           in UUID.fromText uuidText+    _ -> Nothing -Returns @Nothing@ when the format is unrecognized — most commonly because-the server emitted a non-English locale variant or because a future schema-change altered the constraint's column. Callers should use a sensible-fallback (e.g., the first stream in a multi-stream operation) rather than-treat 'Nothing' as a fatal condition.--}+-- | Extract a stream name from a PostgreSQL unique-violation detail string+-- like @"Key (stream_name)=(orders-1) already exists."@.+--+-- Returns @Nothing@ when the format is unrecognized — most commonly because+-- the server emitted a non-English locale variant or because a future schema+-- change altered the constraint's column. Callers should use a sensible+-- fallback (e.g., the first stream in a multi-stream operation) rather than+-- treat 'Nothing' as a fatal condition. extractStreamNameFromDetail :: Text -> Maybe Text extractStreamNameFromDetail detail =-    case T.breakOn "=(" detail of-        (_, rest)-            | not (T.null rest) ->-                let afterParen = T.drop 2 rest -- skip "=("-                    inner = T.takeWhile (/= ')') afterParen-                 in if T.null inner then Nothing else Just inner-        _ -> Nothing--{- | For 'appendMultiStream' errors, recover the offending stream from the-PostgreSQL detail string when possible.--The multi-stream interpreter's transaction returns a single-@Either UsageError result@; per-statement attribution is not visible at the-@hasql@ layer. When the failure is a @23505@ unique violation on-@ix_streams_stream_name@, the PostgreSQL detail string carries the-offending stream name (e.g., @"Key (stream_name)=(multi-c) already exists."@)-and we look up the matching op to recover its 'ExpectedVersion'.--When the detail cannot be parsed (any other failure mode, including-@events_pkey@ violations whose 'DuplicateEvent' constructor carries no-stream attribution, generic server errors, and connection errors), we fall-back to attributing against the first stream in the input list and let-'mapUsageError' map the rest.+  case T.breakOn "=(" detail of+    (_, rest)+      | not (T.null rest) ->+          let afterParen = T.drop 2 rest -- skip "=("+              inner = T.takeWhile (/= ')') afterParen+           in if T.null inner then Nothing else Just inner+    _ -> Nothing -This is defensive — the current SQL paths in @kiroku-store/src/Kiroku/Store/SQL.hs@-do not raise @ix_streams_stream_name@ violations because every append CTE-uses @ON CONFLICT DO NOTHING@/@DO UPDATE@ — but a future schema change could-introduce a path that does, and the attribution should be correct from day-one.--}+-- | For 'appendMultiStream' errors, recover the offending stream from the+-- PostgreSQL detail string when possible.+--+-- The multi-stream interpreter's transaction returns a single+-- @Either UsageError result@; per-statement attribution is not visible at the+-- @hasql@ layer. When the failure is a @23505@ unique violation on+-- @ix_streams_stream_name@, the PostgreSQL detail string carries the+-- offending stream name (e.g., @"Key (stream_name)=(multi-c) already exists."@)+-- and we look up the matching op to recover its 'ExpectedVersion'.+--+-- When the detail cannot be parsed (any other failure mode, including+-- @events_pkey@ violations whose 'DuplicateEvent' constructor carries no+-- stream attribution, generic server errors, and connection errors), we fall+-- back to attributing against the first stream in the input list and let+-- 'mapUsageError' map the rest.+--+-- This is defensive — the current SQL paths in @kiroku-store/src/Kiroku/Store/SQL.hs@+-- do not raise @ix_streams_stream_name@ violations because every append CTE+-- uses @ON CONFLICT DO NOTHING@/@DO UPDATE@ — but a future schema change could+-- introduce a path that does, and the attribution should be correct from day+-- one. attributeMultiStreamError ::-    {- | The (name, expected) pairs from the multi-stream ops, in the order-    the caller supplied them.-    -}-    [(StreamName, ExpectedVersion)] ->-    UsageError ->-    StoreError+  -- | The (name, expected) pairs from the multi-stream ops, in the order+  --     the caller supplied them.+  [(StreamName, ExpectedVersion)] ->+  UsageError ->+  StoreError attributeMultiStreamError [] usageErr =-    -- Defensive: an empty multi-stream call should not reach the-    -- transaction layer, but if it somehow does, surface the raw error.-    ConnectionError ("Empty multi-stream usage error: " <> T.pack (show usageErr))+  -- Defensive: an empty multi-stream call should not reach the+  -- transaction layer, but if it somehow does, surface the raw error.+  ConnectionError ("Empty multi-stream usage error: " <> T.pack (show usageErr)) attributeMultiStreamError ops@((StreamName firstName, firstExpected) : _) usageErr =-    case extractServerError usageErr of-        Just (Errors.ServerError "23505" message (Just detail) _ _)-            | uniqueConstraintName message (Just detail) == Just "ix_streams_stream_name"-            , Just sn <- extractStreamNameFromDetail detail-            , Just (StreamName name, expected) <- lookupStream sn ops ->-                mapUsageError name expected usageErr-        _ ->-            mapUsageError firstName firstExpected usageErr+  case extractServerError usageErr of+    Just (Errors.ServerError "23505" message (Just detail) _ _)+      | uniqueConstraintName message (Just detail) == Just "ix_streams_stream_name",+        Just sn <- extractStreamNameFromDetail detail,+        Just (StreamName name, expected) <- lookupStream sn ops ->+          mapUsageError name expected usageErr+    _ ->+      mapUsageError firstName firstExpected usageErr   where     lookupStream tgt = lookup' tgt     lookup' _ [] = Nothing     lookup' tgt (op@(StreamName n, _) : rest)-        | n == tgt = Just op-        | otherwise = lookup' tgt rest+      | n == tgt = Just op+      | otherwise = lookup' tgt rest  -- | Walk a 'UsageError' down to the underlying 'Errors.ServerError', if any. extractServerError :: UsageError -> Maybe Errors.ServerError extractServerError = \case-    SessionUsageError (Errors.ScriptSessionError _ serverErr) ->-        Just serverErr-    SessionUsageError (Errors.StatementSessionError _ _ _ _ _ stmtErr) ->-        case stmtErr of-            Errors.ServerStatementError serverErr -> Just serverErr-            _ -> Nothing-    _ -> Nothing--{- | True for PostgreSQL class-40 (transaction rollback) @SQLSTATE@ codes:-@40001@ serialization_failure and @40P01@ deadlock_detected.+  SessionUsageError (Errors.ScriptSessionError _ serverErr) ->+    Just serverErr+  SessionUsageError (Errors.StatementSessionError _ _ _ _ _ stmtErr) ->+    case stmtErr of+      Errors.ServerStatementError serverErr -> Just serverErr+      _ -> Nothing+  _ -> Nothing -These are the codes hasql-transaction retries, and the ones the store maps to-'TransientTransactionFailure'. Kept as one predicate so the retry decision and-the error classification can never drift apart.--}+-- | True for PostgreSQL class-40 (transaction rollback) @SQLSTATE@ codes:+-- @40001@ serialization_failure and @40P01@ deadlock_detected.+--+-- These are the codes hasql-transaction retries, and the ones the store maps to+-- 'TransientTransactionFailure'. Kept as one predicate so the retry decision and+-- the error classification can never drift apart. isTransientTransactionCode :: Text -> Bool isTransientTransactionCode code = code == "40001" || code == "40P01"  -- | True for PostgreSQL transient transaction aborts retried by hasql-transaction. isTransientSerializationError :: UsageError -> Bool isTransientSerializationError usageErr =-    case extractServerError usageErr of-        Just (Errors.ServerError code _ _ _ _) ->-            isTransientTransactionCode code-        Nothing ->-            False+  case extractServerError usageErr of+    Just (Errors.ServerError code _ _ _ _) ->+      isTransientTransactionCode code+    Nothing ->+      False
src/Kiroku/Store/HistoryRetention.hs view
@@ -1,22 +1,21 @@-{- | Durable replay-history retention and transaction-scoped stream guards.--A lease protects the retained global event set from Kiroku hard delete and-authorized direct SQL deletion until its database-derived expiry. Transaction-combinators persist durable evidence but cannot emit process-local observability-events from inside an opaque caller-owned transaction.--Lease duration is one second through one hour. Expiry is passive: a crashed-owner blocks destructive work only until PostgreSQL time reaches @expiresAt@.-The owner is an accidental-mutation guard, not an authorization credential;-database roles and grants remain the security boundary. Direct destructive SQL-receives SQLSTATE @KR001@ while any lease is active.--When one transaction needs both a lease operation and a stream-history guard,-perform the lease operation first. This preserves Kiroku's coordinator-before--stream-row lock order.--}-module Kiroku.Store.HistoryRetention (-    module Kiroku.Store.HistoryRetention.Types,+-- | Durable replay-history retention and transaction-scoped stream guards.+--+-- A lease protects the retained global event set from Kiroku hard delete and+-- authorized direct SQL deletion until its database-derived expiry. Transaction+-- combinators persist durable evidence but cannot emit process-local observability+-- events from inside an opaque caller-owned transaction.+--+-- Lease duration is one second through one hour. Expiry is passive: a crashed+-- owner blocks destructive work only until PostgreSQL time reaches @expiresAt@.+-- The owner is an accidental-mutation guard, not an authorization credential;+-- database roles and grants remain the security boundary. Direct destructive SQL+-- receives SQLSTATE @KR001@ while any lease is active.+--+-- When one transaction needs both a lease operation and a stream-history guard,+-- perform the lease operation first. This preserves Kiroku's coordinator-before-+-- stream-row lock order.+module Kiroku.Store.HistoryRetention+  ( module Kiroku.Store.HistoryRetention.Types,     acquireHistoryRetentionLeaseTx,     renewHistoryRetentionLeaseTx,     releaseHistoryRetentionLeaseTx,@@ -29,7 +28,8 @@     pruneHistoryRetentionLeases,     lockStreamHistoryForReplayTx,     readStreamForwardTx,-) where+  )+where  import Data.Int (Int32) import Data.Time.Clock (UTCTime)@@ -44,104 +44,98 @@ import Kiroku.Store.SQL qualified as SQL import Kiroku.Store.Types (RecordedEvent, StreamInfo, StreamName (..), StreamVersion (..)) -{- | Acquire a new lease and atomically capture the authoritative @$all@-frontier as the inclusive @protectedThrough@ rebuild ceiling. PostgreSQL-supplies every timestamp. Rollback leaves no lease row.--}+-- | Acquire a new lease and atomically capture the authoritative @$all@+-- frontier as the inclusive @protectedThrough@ rebuild ceiling. PostgreSQL+-- supplies every timestamp. Rollback leaves no lease row. acquireHistoryRetentionLeaseTx :: HistoryRetentionLeaseRequest -> Tx.Transaction HistoryRetentionLease acquireHistoryRetentionLeaseTx = Internal.acquireHistoryRetentionLeaseTx -{- | Renew a still-active lease without shortening its current expiry.-Unknown, wrong-owner, expired, and released leases return distinct typed-errors and are never resurrected.--}+-- | Renew a still-active lease without shortening its current expiry.+-- Unknown, wrong-owner, expired, and released leases return distinct typed+-- errors and are never resurrected. renewHistoryRetentionLeaseTx ::-    HistoryRetentionLeaseHandle ->-    HistoryRetentionLeaseDuration ->-    Tx.Transaction (Either HistoryRetentionRenewalError HistoryRetentionLease)+  HistoryRetentionLeaseHandle ->+  HistoryRetentionLeaseDuration ->+  Tx.Transaction (Either HistoryRetentionRenewalError HistoryRetentionLease) renewHistoryRetentionLeaseTx = Internal.renewHistoryRetentionLeaseTx  -- | Release a live lease. Repetition is typed and does not rewrite the row. releaseHistoryRetentionLeaseTx ::-    HistoryRetentionLeaseHandle ->-    Tx.Transaction HistoryRetentionReleaseResult+  HistoryRetentionLeaseHandle ->+  Tx.Transaction HistoryRetentionReleaseResult releaseHistoryRetentionLeaseTx = Internal.releaseHistoryRetentionLeaseTx -{- | Read a deterministic, database-time-derived, bounded inventory. Active,-expired, and released state is derived at statement time; no expiry worker is-required.--}+-- | Read a deterministic, database-time-derived, bounded inventory. Active,+-- expired, and released state is derived at statement time; no expiry worker is+-- required. historyRetentionLeaseInventoryTx ::-    HistoryRetentionInventoryQuery ->-    Tx.Transaction (Vector HistoryRetentionLease)+  HistoryRetentionInventoryQuery ->+  Tx.Transaction (Vector HistoryRetentionLease) historyRetentionLeaseInventoryTx = Internal.historyRetentionLeaseInventoryTx  -- | Remove only expired or released rows strictly older than the supplied cutoff. pruneHistoryRetentionLeasesTx :: UTCTime -> Tx.Transaction HistoryRetentionPruneResult pruneHistoryRetentionLeasesTx = Internal.pruneHistoryRetentionLeasesTx -{- | Acquire a lease through the mockable 'Store' effect. The production-interpreter emits the committed acquisition event after the transaction ends.--}+-- | Acquire a lease through the mockable 'Store' effect. The production+-- interpreter emits the committed acquisition event after the transaction ends. acquireHistoryRetentionLease :: (Store :> es) => HistoryRetentionLeaseRequest -> Eff es HistoryRetentionLease acquireHistoryRetentionLease request = send (AcquireHistoryRetentionLease request)  -- | Renew a lease through the mockable 'Store' effect and emit only a committed success. renewHistoryRetentionLease ::-    (Store :> es) =>-    HistoryRetentionLeaseHandle ->-    HistoryRetentionLeaseDuration ->-    Eff es (Either HistoryRetentionRenewalError HistoryRetentionLease)+  (Store :> es) =>+  HistoryRetentionLeaseHandle ->+  HistoryRetentionLeaseDuration ->+  Eff es (Either HistoryRetentionRenewalError HistoryRetentionLease) renewHistoryRetentionLease handle duration = send (RenewHistoryRetentionLease handle duration)  -- | Release a lease through the mockable 'Store' effect. Repeated release emits no false transition. releaseHistoryRetentionLease ::-    (Store :> es) =>-    HistoryRetentionLeaseHandle ->-    Eff es HistoryRetentionReleaseResult+  (Store :> es) =>+  HistoryRetentionLeaseHandle ->+  Eff es HistoryRetentionReleaseResult releaseHistoryRetentionLease handle = send (ReleaseHistoryRetentionLease handle)  -- | Read the bounded durable inventory through the mockable 'Store' effect. historyRetentionLeaseInventory ::-    (Store :> es) =>-    HistoryRetentionInventoryQuery ->-    Eff es (Vector HistoryRetentionLease)+  (Store :> es) =>+  HistoryRetentionInventoryQuery ->+  Eff es (Vector HistoryRetentionLease) historyRetentionLeaseInventory query = send (GetHistoryRetentionLeaseInventory query)  -- | Prune terminal rows through the mockable 'Store' effect. pruneHistoryRetentionLeases ::-    (Store :> es) =>-    UTCTime ->-    Eff es HistoryRetentionPruneResult+  (Store :> es) =>+  UTCTime ->+  Eff es HistoryRetentionPruneResult pruneHistoryRetentionLeases cutoff = send (PruneHistoryRetentionLeases cutoff) -{- | Lock one application's stream row in share mode until the surrounding-transaction ends. The returned metadata includes soft-delete and logical-truncate state so a repair can reject incomplete history before mutating its-target. @$all@ is reserved.--When composing this guard with a lease operation, acquire or renew the lease-first and take this guard second.--}+-- | Lock one application's stream row in share mode until the surrounding+-- transaction ends. The returned metadata includes soft-delete and logical+-- truncate state so a repair can reject incomplete history before mutating its+-- target. @$all@ is reserved.+--+-- When composing this guard with a lease operation, acquire or renew the lease+-- first and take this guard second. lockStreamHistoryForReplayTx ::-    StreamName ->-    Tx.Transaction (Either StreamHistoryUnavailable StreamInfo)+  StreamName ->+  Tx.Transaction (Either StreamHistoryUnavailable StreamInfo) lockStreamHistoryForReplayTx stream@(StreamName name)-    | name == "$all" = pure (Left (StreamHistoryReserved stream))-    | otherwise = do-        locked <- Tx.statement name HistoryRetentionSQL.lockStreamHistoryStmt-        pure $ maybe (Left (StreamHistoryNotFound stream)) Right locked+  | name == "$all" = pure (Left (StreamHistoryReserved stream))+  | otherwise = do+      locked <- Tx.statement name HistoryRetentionSQL.lockStreamHistoryStmt+      pure $ maybe (Left (StreamHistoryNotFound stream)) Right locked -{- | Read a page inside the caller's transaction using the production-exclusive-lower cursor and ascending-order statement. This function deliberately-does not run 'Kiroku.Store.Settings.decodeHook': 'Tx.Transaction' has no 'IO'.-The caller must take 'lockStreamHistoryForReplayTx' earlier in the same-transaction when stable one-stream history is required.--}+-- | Read a page inside the caller's transaction using the production+-- exclusive-lower cursor and ascending-order statement. This function deliberately+-- does not run 'Kiroku.Store.Settings.decodeHook': 'Tx.Transaction' has no 'IO'.+-- The caller must take 'lockStreamHistoryForReplayTx' earlier in the same+-- transaction when stable one-stream history is required. readStreamForwardTx ::-    StreamName ->-    StreamVersion ->-    Int32 ->-    Tx.Transaction (Vector RecordedEvent)+  StreamName ->+  StreamVersion ->+  Int32 ->+  Tx.Transaction (Vector RecordedEvent) readStreamForwardTx (StreamName name) (StreamVersion cursor) limit =-    Tx.statement (name, cursor, limit) SQL.readStreamForwardStmt+  Tx.statement (name, cursor, limit) SQL.readStreamForwardStmt
src/Kiroku/Store/HistoryRetention/Internal.hs view
@@ -1,5 +1,5 @@-module Kiroku.Store.HistoryRetention.Internal (-    acquireHistoryRetentionLeaseTx,+module Kiroku.Store.HistoryRetention.Internal+  ( acquireHistoryRetentionLeaseTx,     renewHistoryRetentionLeaseTx,     releaseHistoryRetentionLeaseTx,     historyRetentionLeaseInventoryTx,@@ -7,7 +7,8 @@     lockHistoryRetentionCoordinatorTx,     activeHistoryRetentionConflictTx,     lockAffectedStreamsForHardDeleteTx,-) where+  )+where  import Data.Int (Int64) import Data.Time.Clock (UTCTime)@@ -18,74 +19,74 @@ import Kiroku.Store.HistoryRetention.Types  acquireHistoryRetentionLeaseTx :: HistoryRetentionLeaseRequest -> Tx.Transaction HistoryRetentionLease-acquireHistoryRetentionLeaseTx HistoryRetentionLeaseRequest{owner, reason, duration} =-    Tx.statement-        ( historyRetentionLeaseOwnerText owner-        , historyRetentionLeaseReasonText reason-        , historyRetentionLeaseDurationValue duration-        )-        SQL.acquireLeaseStmt+acquireHistoryRetentionLeaseTx HistoryRetentionLeaseRequest {owner, reason, duration} =+  Tx.statement+    ( historyRetentionLeaseOwnerText owner,+      historyRetentionLeaseReasonText reason,+      historyRetentionLeaseDurationValue duration+    )+    SQL.acquireLeaseStmt  renewHistoryRetentionLeaseTx ::-    HistoryRetentionLeaseHandle ->-    HistoryRetentionLeaseDuration ->-    Tx.Transaction (Either HistoryRetentionRenewalError HistoryRetentionLease)-renewHistoryRetentionLeaseTx HistoryRetentionLeaseHandle{leaseId, owner = requestedOwner} duration = do-    lockHistoryRetentionCoordinatorTx-    current <- Tx.statement (leaseUuid leaseId) SQL.readLeaseForUpdateStmt-    case current of-        Nothing -> pure (Left HistoryRetentionRenewalUnknown)-        Just HistoryRetentionLease{owner = actualOwner, state}-            | actualOwner /= requestedOwner -> pure (Left HistoryRetentionRenewalOwnerMismatch)-            | state == HistoryRetentionLeaseReleased -> pure (Left HistoryRetentionRenewalReleased)-            | state == HistoryRetentionLeaseExpired -> pure (Left HistoryRetentionRenewalExpired)-            | otherwise ->-                Right-                    <$> Tx.statement-                        (leaseUuid leaseId, historyRetentionLeaseDurationValue duration)-                        SQL.renewLeaseStmt+  HistoryRetentionLeaseHandle ->+  HistoryRetentionLeaseDuration ->+  Tx.Transaction (Either HistoryRetentionRenewalError HistoryRetentionLease)+renewHistoryRetentionLeaseTx HistoryRetentionLeaseHandle {leaseId, owner = requestedOwner} duration = do+  lockHistoryRetentionCoordinatorTx+  current <- Tx.statement (leaseUuid leaseId) SQL.readLeaseForUpdateStmt+  case current of+    Nothing -> pure (Left HistoryRetentionRenewalUnknown)+    Just HistoryRetentionLease {owner = actualOwner, state}+      | actualOwner /= requestedOwner -> pure (Left HistoryRetentionRenewalOwnerMismatch)+      | state == HistoryRetentionLeaseReleased -> pure (Left HistoryRetentionRenewalReleased)+      | state == HistoryRetentionLeaseExpired -> pure (Left HistoryRetentionRenewalExpired)+      | otherwise ->+          Right+            <$> Tx.statement+              (leaseUuid leaseId, historyRetentionLeaseDurationValue duration)+              SQL.renewLeaseStmt  releaseHistoryRetentionLeaseTx ::-    HistoryRetentionLeaseHandle ->-    Tx.Transaction HistoryRetentionReleaseResult-releaseHistoryRetentionLeaseTx HistoryRetentionLeaseHandle{leaseId, owner = requestedOwner} = do-    lockHistoryRetentionCoordinatorTx-    current <- Tx.statement (leaseUuid leaseId) SQL.readLeaseForUpdateStmt-    case current of-        Nothing -> pure HistoryRetentionReleaseUnknown-        Just lease@HistoryRetentionLease{owner = actualOwner, state}-            | actualOwner /= requestedOwner -> pure HistoryRetentionReleaseOwnerMismatch-            | state == HistoryRetentionLeaseReleased -> pure (HistoryRetentionAlreadyReleased lease)-            | state == HistoryRetentionLeaseExpired -> pure (HistoryRetentionReleaseExpired lease)-            | otherwise ->-                HistoryRetentionReleased-                    <$> Tx.statement (leaseUuid leaseId) SQL.releaseLeaseStmt+  HistoryRetentionLeaseHandle ->+  Tx.Transaction HistoryRetentionReleaseResult+releaseHistoryRetentionLeaseTx HistoryRetentionLeaseHandle {leaseId, owner = requestedOwner} = do+  lockHistoryRetentionCoordinatorTx+  current <- Tx.statement (leaseUuid leaseId) SQL.readLeaseForUpdateStmt+  case current of+    Nothing -> pure HistoryRetentionReleaseUnknown+    Just lease@HistoryRetentionLease {owner = actualOwner, state}+      | actualOwner /= requestedOwner -> pure HistoryRetentionReleaseOwnerMismatch+      | state == HistoryRetentionLeaseReleased -> pure (HistoryRetentionAlreadyReleased lease)+      | state == HistoryRetentionLeaseExpired -> pure (HistoryRetentionReleaseExpired lease)+      | otherwise ->+          HistoryRetentionReleased+            <$> Tx.statement (leaseUuid leaseId) SQL.releaseLeaseStmt  historyRetentionLeaseInventoryTx ::-    HistoryRetentionInventoryQuery ->-    Tx.Transaction (Vector HistoryRetentionLease)-historyRetentionLeaseInventoryTx HistoryRetentionInventoryQuery{limit} =-    Tx.statement (historyRetentionInventoryLimitValue limit) SQL.leaseInventoryStmt+  HistoryRetentionInventoryQuery ->+  Tx.Transaction (Vector HistoryRetentionLease)+historyRetentionLeaseInventoryTx HistoryRetentionInventoryQuery {limit} =+  Tx.statement (historyRetentionInventoryLimitValue limit) SQL.leaseInventoryStmt  pruneHistoryRetentionLeasesTx ::-    UTCTime ->-    Tx.Transaction HistoryRetentionPruneResult+  UTCTime ->+  Tx.Transaction HistoryRetentionPruneResult pruneHistoryRetentionLeasesTx cutoff = do-    lockHistoryRetentionCoordinatorTx-    Tx.statement cutoff SQL.pruneLeasesStmt+  lockHistoryRetentionCoordinatorTx+  Tx.statement cutoff SQL.pruneLeasesStmt  lockHistoryRetentionCoordinatorTx :: Tx.Transaction () lockHistoryRetentionCoordinatorTx = do-    _ <- Tx.statement () SQL.lockCoordinatorStmt-    pure ()+  _ <- Tx.statement () SQL.lockCoordinatorStmt+  pure ()  activeHistoryRetentionConflictTx :: Tx.Transaction (Maybe HistoryRetentionConflict) activeHistoryRetentionConflictTx = Tx.statement () SQL.activeConflictStmt  lockAffectedStreamsForHardDeleteTx :: Int64 -> Tx.Transaction () lockAffectedStreamsForHardDeleteTx streamId = do-    _ <- Tx.statement streamId SQL.lockAffectedStreamsForHardDeleteStmt-    pure ()+  _ <- Tx.statement streamId SQL.lockAffectedStreamsForHardDeleteStmt+  pure ()  leaseUuid :: HistoryRetentionLeaseId -> UUID leaseUuid (HistoryRetentionLeaseId value) = value
src/Kiroku/Store/HistoryRetention/SQL.hs view
@@ -1,7 +1,7 @@ {-# LANGUAGE MultilineStrings #-} -module Kiroku.Store.HistoryRetention.SQL (-    acquireLeaseStmt,+module Kiroku.Store.HistoryRetention.SQL+  ( acquireLeaseStmt,     lockCoordinatorStmt,     readLeaseForUpdateStmt,     renewLeaseStmt,@@ -11,7 +11,8 @@     activeConflictStmt,     lockStreamHistoryStmt,     lockAffectedStreamsForHardDeleteStmt,-) where+  )+where  import Contravariant.Extras (contrazip2, contrazip3) import Data.Int (Int32, Int64)@@ -27,250 +28,250 @@  acquireLeaseStmt :: Statement (Text, Text, DiffTime) HistoryRetentionLease acquireLeaseStmt =-    preparable-        """-        WITH coordinator AS MATERIALIZED (-          SELECT singleton-          FROM history_retention_coordinator-          WHERE singleton-          FOR UPDATE-        ), snapshot AS MATERIALIZED (-          SELECT streams.stream_version AS protected_through,-                 clock_timestamp() AS database_now-          FROM streams-          CROSS JOIN coordinator-          WHERE streams.stream_id = 0-        ), inserted AS (-          INSERT INTO history_retention_leases-            (owner, reason, protected_through, created_at, renewed_at, expires_at)-          SELECT $1, $2, protected_through, database_now, database_now, database_now + $3-          FROM snapshot-          RETURNING lease_id, owner, reason, protected_through,-                    created_at, renewed_at, expires_at, released_at-        )-        SELECT lease_id, owner, reason, protected_through,-               created_at, renewed_at, expires_at, released_at,-               'active'::text-        FROM inserted-        """-        (contrazip3 textParam textParam intervalParam)-        (D.singleRow leaseRow)+  preparable+    """+    WITH coordinator AS MATERIALIZED (+      SELECT singleton+      FROM history_retention_coordinator+      WHERE singleton+      FOR UPDATE+    ), snapshot AS MATERIALIZED (+      SELECT streams.stream_version AS protected_through,+             clock_timestamp() AS database_now+      FROM streams+      CROSS JOIN coordinator+      WHERE streams.stream_id = 0+    ), inserted AS (+      INSERT INTO history_retention_leases+        (owner, reason, protected_through, created_at, renewed_at, expires_at)+      SELECT $1, $2, protected_through, database_now, database_now, database_now + $3+      FROM snapshot+      RETURNING lease_id, owner, reason, protected_through,+                created_at, renewed_at, expires_at, released_at+    )+    SELECT lease_id, owner, reason, protected_through,+           created_at, renewed_at, expires_at, released_at,+           'active'::text+    FROM inserted+    """+    (contrazip3 textParam textParam intervalParam)+    (D.singleRow leaseRow)  lockCoordinatorStmt :: Statement () Bool lockCoordinatorStmt =-    preparable-        """-        SELECT singleton-        FROM history_retention_coordinator-        WHERE singleton-        FOR UPDATE-        """-        E.noParams-        (D.singleRow (column D.bool))+  preparable+    """+    SELECT singleton+    FROM history_retention_coordinator+    WHERE singleton+    FOR UPDATE+    """+    E.noParams+    (D.singleRow (column D.bool))  readLeaseForUpdateStmt :: Statement UUID (Maybe HistoryRetentionLease) readLeaseForUpdateStmt =-    preparable-        """-        SELECT lease_id, owner, reason, protected_through,-               created_at, renewed_at, expires_at, released_at,-               CASE-                 WHEN released_at IS NOT NULL THEN 'released'-                 WHEN expires_at <= clock_timestamp() THEN 'expired'-                 ELSE 'active'-               END::text-        FROM history_retention_leases-        WHERE lease_id = $1-        FOR UPDATE-        """-        uuidParam-        (D.rowMaybe leaseRow)+  preparable+    """+    SELECT lease_id, owner, reason, protected_through,+           created_at, renewed_at, expires_at, released_at,+           CASE+             WHEN released_at IS NOT NULL THEN 'released'+             WHEN expires_at <= clock_timestamp() THEN 'expired'+             ELSE 'active'+           END::text+    FROM history_retention_leases+    WHERE lease_id = $1+    FOR UPDATE+    """+    uuidParam+    (D.rowMaybe leaseRow)  renewLeaseStmt :: Statement (UUID, DiffTime) HistoryRetentionLease renewLeaseStmt =-    preparable-        """-        WITH renewal_time AS MATERIALIZED (-          SELECT clock_timestamp() AS database_now-        ), renewed AS (-          UPDATE history_retention_leases-          SET renewed_at = renewal_time.database_now,-              expires_at = GREATEST(-                history_retention_leases.expires_at,-                renewal_time.database_now + $2-              )-          FROM renewal_time-          WHERE lease_id = $1-          RETURNING lease_id, owner, reason, protected_through,-                    created_at, renewed_at, expires_at, released_at-        )-        SELECT lease_id, owner, reason, protected_through,-               created_at, renewed_at, expires_at, released_at,-               'active'::text-        FROM renewed-        """-        (contrazip2 uuidParam intervalParam)-        (D.singleRow leaseRow)+  preparable+    """+    WITH renewal_time AS MATERIALIZED (+      SELECT clock_timestamp() AS database_now+    ), renewed AS (+      UPDATE history_retention_leases+      SET renewed_at = renewal_time.database_now,+          expires_at = GREATEST(+            history_retention_leases.expires_at,+            renewal_time.database_now + $2+          )+      FROM renewal_time+      WHERE lease_id = $1+      RETURNING lease_id, owner, reason, protected_through,+                created_at, renewed_at, expires_at, released_at+    )+    SELECT lease_id, owner, reason, protected_through,+           created_at, renewed_at, expires_at, released_at,+           'active'::text+    FROM renewed+    """+    (contrazip2 uuidParam intervalParam)+    (D.singleRow leaseRow)  releaseLeaseStmt :: Statement UUID HistoryRetentionLease releaseLeaseStmt =-    preparable-        """-        WITH released AS (-          UPDATE history_retention_leases-          SET released_at = clock_timestamp()-          WHERE lease_id = $1-          RETURNING lease_id, owner, reason, protected_through,-                    created_at, renewed_at, expires_at, released_at-        )-        SELECT lease_id, owner, reason, protected_through,-               created_at, renewed_at, expires_at, released_at,-               'released'::text-        FROM released-        """-        uuidParam-        (D.singleRow leaseRow)+  preparable+    """+    WITH released AS (+      UPDATE history_retention_leases+      SET released_at = clock_timestamp()+      WHERE lease_id = $1+      RETURNING lease_id, owner, reason, protected_through,+                created_at, renewed_at, expires_at, released_at+    )+    SELECT lease_id, owner, reason, protected_through,+           created_at, renewed_at, expires_at, released_at,+           'released'::text+    FROM released+    """+    uuidParam+    (D.singleRow leaseRow)  leaseInventoryStmt :: Statement Int32 (Vector HistoryRetentionLease) leaseInventoryStmt =-    preparable-        """-        WITH inventory_time AS MATERIALIZED (-          SELECT clock_timestamp() AS database_now-        )-        SELECT lease_id, owner, reason, protected_through,-               created_at, renewed_at, expires_at, released_at,-               CASE-                 WHEN released_at IS NOT NULL THEN 'released'-                 WHEN expires_at <= inventory_time.database_now THEN 'expired'-                 ELSE 'active'-               END::text-        FROM history_retention_leases-        CROSS JOIN inventory_time-        ORDER BY created_at, lease_id-        LIMIT $1-        """-        int4Param-        (D.rowVector leaseRow)+  preparable+    """+    WITH inventory_time AS MATERIALIZED (+      SELECT clock_timestamp() AS database_now+    )+    SELECT lease_id, owner, reason, protected_through,+           created_at, renewed_at, expires_at, released_at,+           CASE+             WHEN released_at IS NOT NULL THEN 'released'+             WHEN expires_at <= inventory_time.database_now THEN 'expired'+             ELSE 'active'+           END::text+    FROM history_retention_leases+    CROSS JOIN inventory_time+    ORDER BY created_at, lease_id+    LIMIT $1+    """+    int4Param+    (D.rowVector leaseRow)  pruneLeasesStmt :: Statement UTCTime HistoryRetentionPruneResult pruneLeasesStmt =-    preparable-        """-        WITH released AS (-          DELETE FROM history_retention_leases-          WHERE released_at IS NOT NULL-            AND released_at < $1-          RETURNING 1-        ), expired AS (-          DELETE FROM history_retention_leases-          WHERE released_at IS NULL-            AND expires_at < $1-          RETURNING 1-        )-        SELECT (SELECT count(*) FROM expired),-               (SELECT count(*) FROM released)-        """-        timestamptzParam-        ( D.singleRow-            ( HistoryRetentionPruneResult-                <$> column D.int8-                <*> column D.int8-            )+  preparable+    """+    WITH released AS (+      DELETE FROM history_retention_leases+      WHERE released_at IS NOT NULL+        AND released_at < $1+      RETURNING 1+    ), expired AS (+      DELETE FROM history_retention_leases+      WHERE released_at IS NULL+        AND expires_at < $1+      RETURNING 1+    )+    SELECT (SELECT count(*) FROM expired),+           (SELECT count(*) FROM released)+    """+    timestamptzParam+    ( D.singleRow+        ( HistoryRetentionPruneResult+            <$> column D.int8+            <*> column D.int8         )+    )  activeConflictStmt :: Statement () (Maybe HistoryRetentionConflict) activeConflictStmt =-    preparable-        """-        SELECT count(*), min(expires_at)-        FROM history_retention_leases-        WHERE released_at IS NULL-          AND expires_at > clock_timestamp()-        HAVING count(*) > 0-        """-        E.noParams-        ( D.rowMaybe-            ( HistoryRetentionConflict-                <$> column D.int8-                <*> column D.timestamptz-            )+  preparable+    """+    SELECT count(*), min(expires_at)+    FROM history_retention_leases+    WHERE released_at IS NULL+      AND expires_at > clock_timestamp()+    HAVING count(*) > 0+    """+    E.noParams+    ( D.rowMaybe+        ( HistoryRetentionConflict+            <$> column D.int8+            <*> column D.timestamptz         )+    )  lockStreamHistoryStmt :: Statement Text (Maybe StreamInfo) lockStreamHistoryStmt =-    preparable-        """-        SELECT stream_id, stream_name, stream_version,-               created_at, deleted_at, truncate_before-        FROM streams-        WHERE stream_name = $1-        FOR SHARE-        """-        textParam-        (D.rowMaybe streamInfoRow)+  preparable+    """+    SELECT stream_id, stream_name, stream_version,+           created_at, deleted_at, truncate_before+    FROM streams+    WHERE stream_name = $1+    FOR SHARE+    """+    textParam+    (D.rowMaybe streamInfoRow)  lockAffectedStreamsForHardDeleteStmt :: Statement Int64 (Vector Int64) lockAffectedStreamsForHardDeleteStmt =-    preparable-        """-        WITH affected_streams AS (-          SELECT $1::bigint AS stream_id-          UNION-          SELECT stream_events.stream_id-          FROM stream_events-          WHERE stream_events.original_stream_id = $1-            AND stream_events.stream_id <> 0-        )-        SELECT streams.stream_id-        FROM streams-        JOIN affected_streams USING (stream_id)-        ORDER BY streams.stream_id-        FOR UPDATE OF streams-        """-        int8Param-        (D.rowVector (column D.int8))+  preparable+    """+    WITH affected_streams AS (+      SELECT $1::bigint AS stream_id+      UNION+      SELECT stream_events.stream_id+      FROM stream_events+      WHERE stream_events.original_stream_id = $1+        AND stream_events.stream_id <> 0+    )+    SELECT streams.stream_id+    FROM streams+    JOIN affected_streams USING (stream_id)+    ORDER BY streams.stream_id+    FOR UPDATE OF streams+    """+    int8Param+    (D.rowVector (column D.int8))  leaseRow :: D.Row HistoryRetentionLease leaseRow =-    makeLease-        <$> column D.uuid-        <*> column D.text-        <*> column D.text-        <*> column D.int8-        <*> column D.timestamptz-        <*> column D.timestamptz-        <*> column D.timestamptz-        <*> D.column (D.nullable D.timestamptz)-        <*> column D.text+  makeLease+    <$> column D.uuid+    <*> column D.text+    <*> column D.text+    <*> column D.int8+    <*> column D.timestamptz+    <*> column D.timestamptz+    <*> column D.timestamptz+    <*> D.column (D.nullable D.timestamptz)+    <*> column D.text  makeLease :: UUID -> Text -> Text -> Int64 -> UTCTime -> UTCTime -> UTCTime -> Maybe UTCTime -> Text -> HistoryRetentionLease makeLease leaseUuid ownerText reasonText frontier created renewed expires released stateText =-    HistoryRetentionLease-        { leaseId = HistoryRetentionLeaseId leaseUuid-        , owner = requireValidated (mkHistoryRetentionLeaseOwner ownerText)-        , reason = requireValidated (mkHistoryRetentionLeaseReason reasonText)-        , protectedThrough = GlobalPosition frontier-        , createdAt = created-        , renewedAt = renewed-        , expiresAt = expires-        , releasedAt = released-        , state = case stateText of-            "active" -> HistoryRetentionLeaseActive-            "expired" -> HistoryRetentionLeaseExpired-            "released" -> HistoryRetentionLeaseReleased-            unexpected -> error ("unknown history retention lease state from database: " <> show unexpected)-        }+  HistoryRetentionLease+    { leaseId = HistoryRetentionLeaseId leaseUuid,+      owner = requireValidated (mkHistoryRetentionLeaseOwner ownerText),+      reason = requireValidated (mkHistoryRetentionLeaseReason reasonText),+      protectedThrough = GlobalPosition frontier,+      createdAt = created,+      renewedAt = renewed,+      expiresAt = expires,+      releasedAt = released,+      state = case stateText of+        "active" -> HistoryRetentionLeaseActive+        "expired" -> HistoryRetentionLeaseExpired+        "released" -> HistoryRetentionLeaseReleased+        unexpected -> error ("unknown history retention lease state from database: " <> show unexpected)+    }  streamInfoRow :: D.Row StreamInfo streamInfoRow =-    StreamInfo-        <$> (StreamId <$> column D.int8)-        <*> (StreamName <$> column D.text)-        <*> (StreamVersion <$> column D.int8)-        <*> column D.timestamptz-        <*> D.column (D.nullable D.timestamptz)-        <*> (StreamVersion <$> column D.int8)+  StreamInfo+    <$> (StreamId <$> column D.int8)+    <*> (StreamName <$> column D.text)+    <*> (StreamVersion <$> column D.int8)+    <*> column D.timestamptz+    <*> D.column (D.nullable D.timestamptz)+    <*> (StreamVersion <$> column D.int8)  requireValidated :: Either error value -> value requireValidated = either (const (error "database returned an invalid history retention lease")) (\value -> value)
src/Kiroku/Store/HistoryRetention/Types.hs view
@@ -1,5 +1,5 @@-module Kiroku.Store.HistoryRetention.Types (-    HistoryRetentionLeaseId (..),+module Kiroku.Store.HistoryRetention.Types+  ( HistoryRetentionLeaseId (..),     HistoryRetentionLeaseOwner,     HistoryRetentionLeaseReason,     HistoryRetentionLeaseDuration,@@ -25,7 +25,8 @@     historyRetentionLeaseDurationValue,     historyRetentionInventoryLimitValue,     maxHistoryRetentionLeaseDuration,-) where+  )+where  import Data.ByteString qualified as ByteString import Data.Int (Int32, Int64)@@ -38,131 +39,131 @@  -- | Database-generated identity of one durable retention lease. newtype HistoryRetentionLeaseId = HistoryRetentionLeaseId UUID-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic)  -- | Validated operational owner label (1 through 512 UTF-8 bytes). newtype HistoryRetentionLeaseOwner = HistoryRetentionLeaseOwner Text-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic)  -- | Validated human-readable reason (1 through 2,048 UTF-8 bytes). newtype HistoryRetentionLeaseReason = HistoryRetentionLeaseReason Text-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic)  -- | Requested remaining lifetime, from one second through one hour. newtype HistoryRetentionLeaseDuration = HistoryRetentionLeaseDuration DiffTime-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic)  data HistoryRetentionLeaseRequest = HistoryRetentionLeaseRequest-    { owner :: !HistoryRetentionLeaseOwner-    , reason :: !HistoryRetentionLeaseReason-    , duration :: !HistoryRetentionLeaseDuration-    }-    deriving stock (Eq, Show, Generic)+  { owner :: !HistoryRetentionLeaseOwner,+    reason :: !HistoryRetentionLeaseReason,+    duration :: !HistoryRetentionLeaseDuration+  }+  deriving stock (Eq, Show, Generic)  data HistoryRetentionLeaseHandle = HistoryRetentionLeaseHandle-    { leaseId :: !HistoryRetentionLeaseId-    , owner :: !HistoryRetentionLeaseOwner-    }-    deriving stock (Eq, Show, Generic)+  { leaseId :: !HistoryRetentionLeaseId,+    owner :: !HistoryRetentionLeaseOwner+  }+  deriving stock (Eq, Show, Generic)  data HistoryRetentionLeaseState-    = HistoryRetentionLeaseActive-    | HistoryRetentionLeaseExpired-    | HistoryRetentionLeaseReleased-    deriving stock (Eq, Ord, Show, Generic)+  = HistoryRetentionLeaseActive+  | HistoryRetentionLeaseExpired+  | HistoryRetentionLeaseReleased+  deriving stock (Eq, Ord, Show, Generic)  data HistoryRetentionLease = HistoryRetentionLease-    { leaseId :: !HistoryRetentionLeaseId-    , owner :: !HistoryRetentionLeaseOwner-    , reason :: !HistoryRetentionLeaseReason-    , protectedThrough :: !GlobalPosition-    , createdAt :: !UTCTime-    , renewedAt :: !UTCTime-    , expiresAt :: !UTCTime-    , releasedAt :: !(Maybe UTCTime)-    , state :: !HistoryRetentionLeaseState-    }-    deriving stock (Eq, Show, Generic)+  { leaseId :: !HistoryRetentionLeaseId,+    owner :: !HistoryRetentionLeaseOwner,+    reason :: !HistoryRetentionLeaseReason,+    protectedThrough :: !GlobalPosition,+    createdAt :: !UTCTime,+    renewedAt :: !UTCTime,+    expiresAt :: !UTCTime,+    releasedAt :: !(Maybe UTCTime),+    state :: !HistoryRetentionLeaseState+  }+  deriving stock (Eq, Show, Generic)  data HistoryRetentionRenewalError-    = HistoryRetentionRenewalUnknown-    | HistoryRetentionRenewalOwnerMismatch-    | HistoryRetentionRenewalExpired-    | HistoryRetentionRenewalReleased-    deriving stock (Eq, Show, Generic)+  = HistoryRetentionRenewalUnknown+  | HistoryRetentionRenewalOwnerMismatch+  | HistoryRetentionRenewalExpired+  | HistoryRetentionRenewalReleased+  deriving stock (Eq, Show, Generic)  data HistoryRetentionReleaseResult-    = HistoryRetentionReleased !HistoryRetentionLease-    | HistoryRetentionAlreadyReleased !HistoryRetentionLease-    | HistoryRetentionReleaseExpired !HistoryRetentionLease-    | HistoryRetentionReleaseUnknown-    | HistoryRetentionReleaseOwnerMismatch-    deriving stock (Eq, Show, Generic)+  = HistoryRetentionReleased !HistoryRetentionLease+  | HistoryRetentionAlreadyReleased !HistoryRetentionLease+  | HistoryRetentionReleaseExpired !HistoryRetentionLease+  | HistoryRetentionReleaseUnknown+  | HistoryRetentionReleaseOwnerMismatch+  deriving stock (Eq, Show, Generic)  data HistoryRetentionConflict = HistoryRetentionConflict-    { activeLeaseCount :: !Int64-    , earliestExpiry :: !UTCTime-    }-    deriving stock (Eq, Show, Generic)+  { activeLeaseCount :: !Int64,+    earliestExpiry :: !UTCTime+  }+  deriving stock (Eq, Show, Generic)  -- | Validated maximum number of inventory rows (1 through 1,000). newtype HistoryRetentionInventoryLimit = HistoryRetentionInventoryLimit Int32-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic)  data HistoryRetentionInventoryQuery = HistoryRetentionInventoryQuery-    { limit :: !HistoryRetentionInventoryLimit-    }-    deriving stock (Eq, Show, Generic)+  { limit :: !HistoryRetentionInventoryLimit+  }+  deriving stock (Eq, Show, Generic)  data HistoryRetentionPruneResult = HistoryRetentionPruneResult-    { expiredPruned :: !Int64-    , releasedPruned :: !Int64-    }-    deriving stock (Eq, Show, Generic)+  { expiredPruned :: !Int64,+    releasedPruned :: !Int64+  }+  deriving stock (Eq, Show, Generic)  data HistoryRetentionRequestError-    = HistoryRetentionLeaseOwnerEmpty-    | HistoryRetentionLeaseOwnerTooLong !Int-    | HistoryRetentionLeaseReasonEmpty-    | HistoryRetentionLeaseReasonTooLong !Int-    | HistoryRetentionLeaseDurationOutOfRange !DiffTime-    deriving stock (Eq, Show, Generic)+  = HistoryRetentionLeaseOwnerEmpty+  | HistoryRetentionLeaseOwnerTooLong !Int+  | HistoryRetentionLeaseReasonEmpty+  | HistoryRetentionLeaseReasonTooLong !Int+  | HistoryRetentionLeaseDurationOutOfRange !DiffTime+  deriving stock (Eq, Show, Generic)  data HistoryRetentionInventoryError-    = HistoryRetentionInventoryLimitOutOfRange !Int32-    deriving stock (Eq, Show, Generic)+  = HistoryRetentionInventoryLimitOutOfRange !Int32+  deriving stock (Eq, Show, Generic)  data StreamHistoryUnavailable-    = StreamHistoryNotFound !StreamName-    | StreamHistoryReserved !StreamName-    deriving stock (Eq, Show, Generic)+  = StreamHistoryNotFound !StreamName+  | StreamHistoryReserved !StreamName+  deriving stock (Eq, Show, Generic)  mkHistoryRetentionLeaseOwner :: Text -> Either HistoryRetentionRequestError HistoryRetentionLeaseOwner mkHistoryRetentionLeaseOwner value-    | bytes == 0 = Left HistoryRetentionLeaseOwnerEmpty-    | bytes > 512 = Left (HistoryRetentionLeaseOwnerTooLong bytes)-    | otherwise = Right (HistoryRetentionLeaseOwner value)+  | bytes == 0 = Left HistoryRetentionLeaseOwnerEmpty+  | bytes > 512 = Left (HistoryRetentionLeaseOwnerTooLong bytes)+  | otherwise = Right (HistoryRetentionLeaseOwner value)   where     bytes = ByteString.length (Text.encodeUtf8 value)  mkHistoryRetentionLeaseReason :: Text -> Either HistoryRetentionRequestError HistoryRetentionLeaseReason mkHistoryRetentionLeaseReason value-    | bytes == 0 = Left HistoryRetentionLeaseReasonEmpty-    | bytes > 2048 = Left (HistoryRetentionLeaseReasonTooLong bytes)-    | otherwise = Right (HistoryRetentionLeaseReason value)+  | bytes == 0 = Left HistoryRetentionLeaseReasonEmpty+  | bytes > 2048 = Left (HistoryRetentionLeaseReasonTooLong bytes)+  | otherwise = Right (HistoryRetentionLeaseReason value)   where     bytes = ByteString.length (Text.encodeUtf8 value)  mkHistoryRetentionLeaseDuration :: DiffTime -> Either HistoryRetentionRequestError HistoryRetentionLeaseDuration mkHistoryRetentionLeaseDuration value-    | value < secondsToDiffTime 1 = Left (HistoryRetentionLeaseDurationOutOfRange value)-    | value > maxHistoryRetentionLeaseDuration = Left (HistoryRetentionLeaseDurationOutOfRange value)-    | otherwise = Right (HistoryRetentionLeaseDuration value)+  | value < secondsToDiffTime 1 = Left (HistoryRetentionLeaseDurationOutOfRange value)+  | value > maxHistoryRetentionLeaseDuration = Left (HistoryRetentionLeaseDurationOutOfRange value)+  | otherwise = Right (HistoryRetentionLeaseDuration value)  mkHistoryRetentionInventoryLimit :: Int32 -> Either HistoryRetentionInventoryError HistoryRetentionInventoryLimit mkHistoryRetentionInventoryLimit value-    | value < 1 || value > 1000 = Left (HistoryRetentionInventoryLimitOutOfRange value)-    | otherwise = Right (HistoryRetentionInventoryLimit value)+  | value < 1 || value > 1000 = Left (HistoryRetentionInventoryLimitOutOfRange value)+  | otherwise = Right (HistoryRetentionInventoryLimit value)  historyRetentionLeaseOwnerText :: HistoryRetentionLeaseOwner -> Text historyRetentionLeaseOwnerText (HistoryRetentionLeaseOwner value) = value
src/Kiroku/Store/Lifecycle.hs view
@@ -1,10 +1,11 @@-module Kiroku.Store.Lifecycle (-    softDeleteStream,+module Kiroku.Store.Lifecycle+  ( softDeleteStream,     hardDeleteStream,     undeleteStream,     setStreamTruncateBefore,     clearStreamTruncateBefore,-) where+  )+where  import Effectful (Eff, (:>)) import Effectful.Dispatch.Dynamic (send)@@ -12,153 +13,148 @@ import Kiroku.Store.Effect (Store (..)) import Kiroku.Store.Types -{- | Mark a stream as deleted without removing its rows.--Soft-deleted streams are invisible to 'Kiroku.Store.Read.readStreamForward'-(returns empty), to 'Kiroku.Store.Read.readStreamBackward', and to-'Kiroku.Store.Append.appendToStream' (returns-'Kiroku.Store.Error.StreamNotFound' or-'Kiroku.Store.Error.StreamAlreadyExists' depending on-'Kiroku.Store.Types.ExpectedVersion'). They remain visible in the-@$all@ stream — the global event log is append-only — and to-'Kiroku.Store.Read.readCategory'. Use 'undeleteStream' to restore.--The exact stream name @$all@ is reserved for the global read stream.-Lifecycle operations reject it with-'Kiroku.Store.Error.ReservedStreamName' so callers cannot hide, remove,-or restore the internal global stream row.--Returns @Just streamId@ on success, or @Nothing@ if the stream did not-exist or was already soft-deleted. Reverse with 'undeleteStream' or-'hardDeleteStream'.--}+-- | Mark a stream as deleted without removing its rows.+--+-- Soft-deleted streams are invisible to 'Kiroku.Store.Read.readStreamForward'+-- (returns empty), to 'Kiroku.Store.Read.readStreamBackward', and to+-- 'Kiroku.Store.Append.appendToStream' (returns+-- 'Kiroku.Store.Error.StreamNotFound' or+-- 'Kiroku.Store.Error.StreamAlreadyExists' depending on+-- 'Kiroku.Store.Types.ExpectedVersion'). They remain visible in the+-- @$all@ stream — the global event log is append-only — and to+-- 'Kiroku.Store.Read.readCategory'. Use 'undeleteStream' to restore.+--+-- The exact stream name @$all@ is reserved for the global read stream.+-- Lifecycle operations reject it with+-- 'Kiroku.Store.Error.ReservedStreamName' so callers cannot hide, remove,+-- or restore the internal global stream row.+--+-- Returns @Just streamId@ on success, or @Nothing@ if the stream did not+-- exist or was already soft-deleted. Reverse with 'undeleteStream' or+-- 'hardDeleteStream'. softDeleteStream ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    Eff es (Maybe StreamId)+  (HasCallStack, Store :> es) =>+  StreamName ->+  Eff es (Maybe StreamId) softDeleteStream name = send (SoftDeleteStream name) -{- | Permanently remove a stream, its events (where they are not-referenced by other streams), and its links.--== Authorization model--Hard delete is gated by a session-local PostgreSQL GUC-(@kiroku.enable_hard_deletes@) that the interpreter sets inside its-transaction. Direct @DELETE@ or @TRUNCATE@ against the underlying-tables without setting that GUC raises an exception via the-@protect_deletion@ and @protect_truncation@ triggers in-@kiroku-store-migrations/migrations@.--/The GUC is an advisory protection, not a security boundary./ Any-PostgreSQL session with @DELETE@ privilege on @events@,-@stream_events@, and @streams@ can issue @SET LOCAL-kiroku.enable_hard_deletes = \'on\'@ before its own @DELETE@ —-PostgreSQL grants @SET LOCAL@ to every session. The trigger exists-to make accidental issuance of @DELETE@ (a typo, an ad-hoc operator-query, an ORM that does not know the table is meant to be-append-only) fail loudly rather than to enforce role-based access-control.--In practice the model is "applications running with full @DELETE@-privilege on the data tables are trusted to call hard-delete-correctly". Production deployments that need stricter control should:--* Run the application as a low-privileged role with only @INSERT,-  UPDATE, SELECT@ on the data tables (not @DELETE@); soft-delete-  via 'softDeleteStream' is unaffected. Issue hard-deletes from a-  separate, more privileged role gated by your own access controls.--* /Or/ wrap calls to 'hardDeleteStream' in your application's-  authorization layer before they reach this function. Reading the-  @protect_deletion@ trigger as a security boundary is incorrect.--Replay-history retention is an independent safety layer. While any-'Kiroku.Store.HistoryRetention.Types.HistoryRetentionLease' is active,-supported hard delete returns-'Kiroku.Store.Error.HistoryRetentionActive' before changing rows. A-GUC-enabled direct @DELETE@ or @TRUNCATE@ receives SQLSTATE @KR001@. There is-no ordinary bypass: release the lease or wait for its database-derived expiry.--== Event preservation semantics--The interpreter cleans up junction rows ('stream_events') first,-then deletes orphaned 'events' rows — events still linked to other-streams from this one's hard-deleted source junctions are removed;-events linked to streams /not/ owned by this deletion are preserved.--Before deletion, Kiroku locks the target and every stream containing a link to-one of the target's originated events in ascending internal ID order. This-serializes hard delete with-'Kiroku.Store.HistoryRetention.lockStreamHistoryForReplayTx' guards, including-a guard held on a linked stream.--== Result--Returns @Just streamId@ on success, @Nothing@ if the stream did not-exist. The exact stream name @$all@ is rejected with-'Kiroku.Store.Error.ReservedStreamName'. There is no \"undo\" — for reversible deletes use-'softDeleteStream' instead. The deletion emits no in-band audit row;-operators relying on an audit log must capture hard-deletes through-the store event handler (see-'Kiroku.Store.Connection.ConnectionSettingsM.eventHandler') or-record an application-level event /before/ calling this function.--}+-- | Permanently remove a stream, its events (where they are not+-- referenced by other streams), and its links.+--+-- == Authorization model+--+-- Hard delete is gated by a session-local PostgreSQL GUC+-- (@kiroku.enable_hard_deletes@) that the interpreter sets inside its+-- transaction. Direct @DELETE@ or @TRUNCATE@ against the underlying+-- tables without setting that GUC raises an exception via the+-- @protect_deletion@ and @protect_truncation@ triggers in+-- @kiroku-store-migrations/migrations@.+--+-- /The GUC is an advisory protection, not a security boundary./ Any+-- PostgreSQL session with @DELETE@ privilege on @events@,+-- @stream_events@, and @streams@ can issue @SET LOCAL+-- kiroku.enable_hard_deletes = \'on\'@ before its own @DELETE@ —+-- PostgreSQL grants @SET LOCAL@ to every session. The trigger exists+-- to make accidental issuance of @DELETE@ (a typo, an ad-hoc operator+-- query, an ORM that does not know the table is meant to be+-- append-only) fail loudly rather than to enforce role-based access+-- control.+--+-- In practice the model is "applications running with full @DELETE@+-- privilege on the data tables are trusted to call hard-delete+-- correctly". Production deployments that need stricter control should:+--+-- * Run the application as a low-privileged role with only @INSERT,+--   UPDATE, SELECT@ on the data tables (not @DELETE@); soft-delete+--   via 'softDeleteStream' is unaffected. Issue hard-deletes from a+--   separate, more privileged role gated by your own access controls.+--+-- * /Or/ wrap calls to 'hardDeleteStream' in your application's+--   authorization layer before they reach this function. Reading the+--   @protect_deletion@ trigger as a security boundary is incorrect.+--+-- Replay-history retention is an independent safety layer. While any+-- 'Kiroku.Store.HistoryRetention.Types.HistoryRetentionLease' is active,+-- supported hard delete returns+-- 'Kiroku.Store.Error.HistoryRetentionActive' before changing rows. A+-- GUC-enabled direct @DELETE@ or @TRUNCATE@ receives SQLSTATE @KR001@. There is+-- no ordinary bypass: release the lease or wait for its database-derived expiry.+--+-- == Event preservation semantics+--+-- The interpreter cleans up junction rows ('stream_events') first,+-- then deletes orphaned 'events' rows — events still linked to other+-- streams from this one's hard-deleted source junctions are removed;+-- events linked to streams /not/ owned by this deletion are preserved.+--+-- Before deletion, Kiroku locks the target and every stream containing a link to+-- one of the target's originated events in ascending internal ID order. This+-- serializes hard delete with+-- 'Kiroku.Store.HistoryRetention.lockStreamHistoryForReplayTx' guards, including+-- a guard held on a linked stream.+--+-- == Result+--+-- Returns @Just streamId@ on success, @Nothing@ if the stream did not+-- exist. The exact stream name @$all@ is rejected with+-- 'Kiroku.Store.Error.ReservedStreamName'. There is no \"undo\" — for reversible deletes use+-- 'softDeleteStream' instead. The deletion emits no in-band audit row;+-- operators relying on an audit log must capture hard-deletes through+-- the store event handler (see+-- 'Kiroku.Store.Connection.ConnectionSettingsM.eventHandler') or+-- record an application-level event /before/ calling this function. hardDeleteStream ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    Eff es (Maybe StreamId)+  (HasCallStack, Store :> es) =>+  StreamName ->+  Eff es (Maybe StreamId) hardDeleteStream name = send (HardDeleteStream name) -{- | Restore a soft-deleted stream by clearing its @deleted_at@ row.--Reads of the restored stream return its full event history. Subsequent-appends behave as if the soft-delete never happened. Returns-@Just streamId@ on success, @Nothing@ if the stream is missing or was-not soft-deleted (already live). The exact stream name @$all@ is-rejected with 'Kiroku.Store.Error.ReservedStreamName'.--}+-- | Restore a soft-deleted stream by clearing its @deleted_at@ row.+--+-- Reads of the restored stream return its full event history. Subsequent+-- appends behave as if the soft-delete never happened. Returns+-- @Just streamId@ on success, @Nothing@ if the stream is missing or was+-- not soft-deleted (already live). The exact stream name @$all@ is+-- rejected with 'Kiroku.Store.Error.ReservedStreamName'. undeleteStream ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    Eff es (Maybe StreamId)+  (HasCallStack, Store :> es) =>+  StreamName ->+  Eff es (Maybe StreamId) undeleteStream name = send (UndeleteStream name) -{- | Set the logical truncate-before marker for a stream. After this call,-'Kiroku.Store.Read.readStreamForward' and 'readStreamBackward' return only the-events whose per-stream version is >= @before@; earlier events are hidden from-ordered stream reads but are NOT deleted.--This is the close-the-book / snapshot-and-compact primitive: append a snapshot-event (it lands at version @V@), then call @setStreamTruncateBefore name V@ so-rehydration starts from the snapshot.--The marker does NOT affect the @$all@ global log, 'Kiroku.Store.Read.readCategory',-subscriptions, or existence probes — the global history stays complete, so-projections (including ones not yet written) can still be built from it. The-operation is fully reversible: lower the marker or call-'clearStreamTruncateBefore' to re-expose hidden events.--Returns @Just streamId@ on success, @Nothing@ if the stream does not exist or is-soft-deleted. The reserved stream @$all@ is rejected with-'Kiroku.Store.Error.ReservedStreamName'. Per-stream versions are 1-based, so a-@before@ of 0 or 1 keeps the whole stream. Idempotent: setting the same value-again returns the same result and changes nothing.--}+-- | Set the logical truncate-before marker for a stream. After this call,+-- 'Kiroku.Store.Read.readStreamForward' and 'readStreamBackward' return only the+-- events whose per-stream version is >= @before@; earlier events are hidden from+-- ordered stream reads but are NOT deleted.+--+-- This is the close-the-book / snapshot-and-compact primitive: append a snapshot+-- event (it lands at version @V@), then call @setStreamTruncateBefore name V@ so+-- rehydration starts from the snapshot.+--+-- The marker does NOT affect the @$all@ global log, 'Kiroku.Store.Read.readCategory',+-- subscriptions, or existence probes — the global history stays complete, so+-- projections (including ones not yet written) can still be built from it. The+-- operation is fully reversible: lower the marker or call+-- 'clearStreamTruncateBefore' to re-expose hidden events.+--+-- Returns @Just streamId@ on success, @Nothing@ if the stream does not exist or is+-- soft-deleted. The reserved stream @$all@ is rejected with+-- 'Kiroku.Store.Error.ReservedStreamName'. Per-stream versions are 1-based, so a+-- @before@ of 0 or 1 keeps the whole stream. Idempotent: setting the same value+-- again returns the same result and changes nothing. setStreamTruncateBefore ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    StreamVersion ->-    Eff es (Maybe StreamId)+  (HasCallStack, Store :> es) =>+  StreamName ->+  StreamVersion ->+  Eff es (Maybe StreamId) setStreamTruncateBefore name before = send (SetStreamTruncateBefore name before) -{- | Clear a stream's truncate-before marker, re-exposing the full history to-ordered stream reads. Equivalent to @setStreamTruncateBefore name 0@. Returns-@Just streamId@ on success, @Nothing@ for a missing or soft-deleted stream.-Rejects @$all@ with 'Kiroku.Store.Error.ReservedStreamName'.--}+-- | Clear a stream's truncate-before marker, re-exposing the full history to+-- ordered stream reads. Equivalent to @setStreamTruncateBefore name 0@. Returns+-- @Just streamId@ on success, @Nothing@ for a missing or soft-deleted stream.+-- Rejects @$all@ with 'Kiroku.Store.Error.ReservedStreamName'. clearStreamTruncateBefore ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    Eff es (Maybe StreamId)+  (HasCallStack, Store :> es) =>+  StreamName ->+  Eff es (Maybe StreamId) clearStreamTruncateBefore name = setStreamTruncateBefore name (StreamVersion 0)
src/Kiroku/Store/Link.hs view
@@ -1,6 +1,7 @@-module Kiroku.Store.Link (-    linkToStream,-) where+module Kiroku.Store.Link+  ( linkToStream,+  )+where  import Effectful (Eff, (:>)) import Effectful.Dispatch.Dynamic (send)@@ -8,49 +9,48 @@ import Kiroku.Store.Effect (Store (..)) import Kiroku.Store.Types -{- | Link existing events into a target stream.--__Provisional API.__ No known consumer uses this function (audited-2026-06-11: zero usage in keiro, the only downstream framework). It is the-only public feature that requires the @stream_events@ junction-table layout,-which a future global-position migration may replace with a single-table event-layout (global position as a column on @events@); in that case this function-will be removed or redesigned (e.g. rehomed to a dedicated @stream_links@ side-table). If you have a real use case, surface it before depending on this. See-docs/architecture/global-position-migration-path.md.--Creates the target stream if it does not exist; otherwise appends-links to the existing target. Linking does not duplicate the event-payload — the @events@ row is shared, and only a new junction row in-@stream_events@ is created. Linked events keep their original-'Kiroku.Store.Types.GlobalPosition' and 'Kiroku.Store.Types.RecordedEvent.originalStreamId' /-'originalVersion' fields; the target's 'Kiroku.Store.Types.RecordedEvent.streamVersion'-reflects the link's position in the target.--Preconditions:--* Every supplied 'Kiroku.Store.Types.EventId' must reference an event-  that currently exists. Linking an unknown id (a typo, or an id that-  was hard-deleted) rejects the entire batch atomically — the target-  stream is left in its pre-call state — with-  'Kiroku.Store.Error.LinkSourceEventMissing'. (See EP-1 F3.)-* The target must not be soft-deleted. Linking to a soft-deleted target-  fails with 'Kiroku.Store.Error.StreamNotFound'. (See EP-1 F5.)-* The target must not be @$all@. @$all@ is the global read stream, and-  linking into it would bypass the append-only global ordering contract;-  the interpreter rejects this with-  'Kiroku.Store.Error.ReservedStreamName'.-* Linking the same event into the same target stream twice fails with-  'Kiroku.Store.Error.EventAlreadyLinked'.--Returns the target's 'Kiroku.Store.Types.LinkResult' — its id and the-position of the /last/ linked event in the target.--Empty input @[]@ is a no-op programming mistake; do not call.--}+-- | Link existing events into a target stream.+--+-- __Provisional API.__ No known consumer uses this function (audited+-- 2026-06-11: zero usage in keiro, the only downstream framework). It is the+-- only public feature that requires the @stream_events@ junction-table layout,+-- which a future global-position migration may replace with a single-table event+-- layout (global position as a column on @events@); in that case this function+-- will be removed or redesigned (e.g. rehomed to a dedicated @stream_links@ side+-- table). If you have a real use case, surface it before depending on this. See+-- docs/architecture/global-position-migration-path.md.+--+-- Creates the target stream if it does not exist; otherwise appends+-- links to the existing target. Linking does not duplicate the event+-- payload — the @events@ row is shared, and only a new junction row in+-- @stream_events@ is created. Linked events keep their original+-- 'Kiroku.Store.Types.GlobalPosition' and 'Kiroku.Store.Types.RecordedEvent.originalStreamId' /+-- 'originalVersion' fields; the target's 'Kiroku.Store.Types.RecordedEvent.streamVersion'+-- reflects the link's position in the target.+--+-- Preconditions:+--+-- * Every supplied 'Kiroku.Store.Types.EventId' must reference an event+--   that currently exists. Linking an unknown id (a typo, or an id that+--   was hard-deleted) rejects the entire batch atomically — the target+--   stream is left in its pre-call state — with+--   'Kiroku.Store.Error.LinkSourceEventMissing'. (See EP-1 F3.)+-- * The target must not be soft-deleted. Linking to a soft-deleted target+--   fails with 'Kiroku.Store.Error.StreamNotFound'. (See EP-1 F5.)+-- * The target must not be @$all@. @$all@ is the global read stream, and+--   linking into it would bypass the append-only global ordering contract;+--   the interpreter rejects this with+--   'Kiroku.Store.Error.ReservedStreamName'.+-- * Linking the same event into the same target stream twice fails with+--   'Kiroku.Store.Error.EventAlreadyLinked'.+--+-- Returns the target's 'Kiroku.Store.Types.LinkResult' — its id and the+-- position of the /last/ linked event in the target.+--+-- Empty input @[]@ is a no-op programming mistake; do not call. linkToStream ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    [EventId] ->-    Eff es LinkResult+  (HasCallStack, Store :> es) =>+  StreamName ->+  [EventId] ->+  Eff es LinkResult linkToStream targetStream eventIds = send (LinkToStream targetStream eventIds)
src/Kiroku/Store/Notification.hs view
@@ -1,22 +1,27 @@-module Kiroku.Store.Notification (-    Notifier (..),+module Kiroku.Store.Notification+  ( Notifier (..),     NotifierStartError (..),     startNotifier,     stopNotifier,-) where+    NamespaceRegistry,+    registerNamespace,+    notifyNamespace,+  )+where  import Control.Concurrent (threadDelay) import Control.Concurrent.Async (Async) import Control.Concurrent.Async qualified as Async-import Control.Concurrent.STM (TChan, TVar, atomically, modifyTVar', newBroadcastTChanIO, newTVarIO, readTVarIO, writeTChan, writeTVar)+import Control.Concurrent.STM (STM, TChan, TVar, atomically, modifyTVar', newBroadcastTChanIO, newTVar, newTVarIO, readTVar, readTVarIO, writeTChan, writeTVar) import Control.Exception (Exception, SomeAsyncException, SomeException, asyncExceptionFromException, bracketOnError, catch, throwIO, toException)-import Control.Monad (void)+import Control.Monad (forM_, unless, void) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.ByteString (ByteString) import Data.ByteString.Char8 qualified as BC import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Text (Text)+import Data.Text qualified as T import Data.Text.Encoding (decodeUtf8Lenient) import Data.Word (Word64) import Hasql.Connection (Connection)@@ -32,46 +37,81 @@ import Kiroku.Store.Observability (KirokuEvent (..), emitOrDrop) import Kiroku.Store.Types (CategoryName (..), StreamName (..), categoryName) -{- | A Notifier manages a dedicated PostgreSQL connection for LISTEN/NOTIFY.-It writes a @()@ tick to a broadcast 'TChan' on every notification,-allowing subscribers to wake without polling.--The current connection is held in a 'TVar' so that-'Kiroku.Store.Notification.stopNotifier' releases whichever connection-the listener loop most recently acquired (rather than the original one,-which may have been replaced by reconnection).--}+-- | A Notifier manages a dedicated PostgreSQL connection for LISTEN/NOTIFY.+-- It writes a @()@ tick to a broadcast 'TChan' on every notification,+-- allowing subscribers to wake without polling.+--+-- The current connection is held in a 'TVar' so that+-- 'Kiroku.Store.Notification.stopNotifier' releases whichever connection+-- the listener loop most recently acquired (rather than the original one,+-- which may have been replaced by reconnection). data Notifier = Notifier-    { tickChan :: !(TChan ())-    -- ^ Broadcast channel; consumers must use 'dupTChan' to get a personal copy-    , listenerThread :: !(Async ())-    -- ^ The async thread running the LISTEN loop-    , listenerConnRef :: !(TVar Connection)-    {- ^ The /current/ dedicated listener connection. Updated by the loop on-    each successful reconnection so 'stopNotifier' always releases the-    live socket.-    -}-    , categoryGenerations :: !(TVar (Map Text Word64))-    {- ^ Per-category wake counter. The listener callback increments a-    category's entry on every NOTIFY for a stream in that category. A-    'Kiroku.Store.Subscription.Types.Category' subscription worker blocks until-    its category's counter advances, so an idle category does zero database work-    while other categories receive traffic. Notifications lost across a listener-    reconnect are reconciled by the worker's safety-poll timeout.-    -}-    }+  { -- | Broadcast channel; consumers must use 'dupTChan' to get a personal copy+    tickChan :: !(TChan ()),+    -- | The async thread running the LISTEN loop+    listenerThread :: !(Async ()),+    -- | The /current/ dedicated listener connection. Updated by the loop on+    --     each successful reconnection so 'stopNotifier' always releases the+    --     live socket.+    listenerConnRef :: !(TVar Connection),+    namespaceRegistry :: !NamespaceRegistry,+    -- | Per-category wake counter. The listener callback increments a+    --     category's entry on every NOTIFY for a stream in that category. A+    --     'Kiroku.Store.Subscription.Types.Category' subscription worker blocks until+    --     its category's counter advances, so an idle category does zero database work+    --     while other categories receive traffic. Notifications lost across a listener+    --     reconnect are reconciled by the worker's safety-poll timeout.+    categoryGenerations :: !(TVar (Map Text Word64))+  } -{- | Raised by 'startNotifier' when the initial dedicated @LISTEN@ setup fails.+-- | Only active namespace keys are retained. Duplicate workers share one cell.+type NamespaceRegistry = TVar (Map Text (Int, TVar Word64)) -Connection-acquire failures carry @hasql@'s underlying-'Connection.ConnectionError'. LISTEN failures carry the exception thrown after-the connection was acquired; that connection has already been released.--}+-- | Register before catch-up. The release is idempotent and cell-conditional,+-- so an old release cannot remove a replacement registration.+registerNamespace :: NamespaceRegistry -> Text -> STM (TVar Word64, STM ())+registerNamespace registry namespace = do+  entries <- readTVar registry+  (count, cell) <- case Map.lookup namespace entries of+    Just entry -> pure entry+    Nothing -> (0,) <$> newTVar 0+  writeTVar registry (Map.insert namespace (count + 1, cell) entries)+  released <- newTVar False+  let release = do+        done <- readTVar released+        unless done $ do+          writeTVar released True+          modifyTVar' registry $+            Map.update+              ( \(refs, current) ->+                  if current /= cell+                    then Just (refs, current)+                    else if refs == 1 then Nothing else Just (refs - 1, current)+              )+              namespace+  pure (cell, release)++-- | Bump exact category and colon ancestors in the callback transaction.+-- An empty registry does no ancestor construction or map mutation.+notifyNamespace :: NamespaceRegistry -> Text -> STM ()+notifyNamespace registry category = do+  entries <- readTVar registry+  unless (Map.null entries) $+    forM_ (scanl1 (\prefix segment -> prefix <> ":" <> segment) (T.splitOn ":" category)) $ \candidate ->+      case Map.lookup candidate entries of+        Nothing -> pure ()+        Just (_, cell) -> modifyTVar' cell (+ 1)++-- | Raised by 'startNotifier' when the initial dedicated @LISTEN@ setup fails.+--+-- Connection-acquire failures carry @hasql@'s underlying+-- 'Connection.ConnectionError'. LISTEN failures carry the exception thrown after+-- the connection was acquired; that connection has already been released. data NotifierStartError-    = NotifierConnectError !ConnectionError-    | NotifierListenError !SomeException-    deriving stock (Show)-    deriving anyclass (Exception)+  = NotifierConnectError !ConnectionError+  | NotifierListenError !SomeException+  deriving stock (Show)+  deriving anyclass (Exception)  -- Cap reconnect backoff at 30 seconds; the EventPublisher's safety poll -- runs at the same cadence, so the worst-case latency between a@@ -85,69 +125,69 @@ -- failure, @n >= 1@. Schedule: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... reconnectDelayMicros :: Int -> Int reconnectDelayMicros n-    | n <= 0 = 1_000_000-    | otherwise = min maxReconnectDelayMicros (1_000_000 * 2 ^ (min (n - 1) 30))--{- | Start a Notifier. Acquires a dedicated connection, sets-@application_name@ to @kiroku-listener@ for operator visibility, issues-LISTEN on the @\<schema\>.events@ channel, and spawns a thread that-writes @()@ to the broadcast TChan on every notification.--On connection failure during the loop, releases the dead connection,-waits a backoff interval (capped exponential: 1s, 2s, 4s, 8s, 16s, 30s-thereafter), acquires a replacement, re-LISTENs, and resumes. The-replacement is published into 'listenerConnRef' so 'stopNotifier'-releases it on shutdown.--If an 'eventHandler' callback is supplied, the notifier emits-'Kiroku.Store.Observability.KirokuEventNotifierReconnecting' on each-attempted reconnect (with the consecutive failure count and the-underlying exception) and 'KirokuEventNotifierReconnected' on each-successful reconnect. The failure counter resets after a successful-reconnect.--On 'Async.AsyncCancelled' (from cancellation), exits cleanly.+  | n <= 0 = 1_000_000+  | otherwise = min maxReconnectDelayMicros (1_000_000 * 2 ^ (min (n - 1) 30)) -Initial acquire or LISTEN failure raises 'NotifierStartError'.--}+-- | Start a Notifier. Acquires a dedicated connection, sets+-- @application_name@ to @kiroku-listener@ for operator visibility, issues+-- LISTEN on the @\<schema\>.events@ channel, and spawns a thread that+-- writes @()@ to the broadcast TChan on every notification.+--+-- On connection failure during the loop, releases the dead connection,+-- waits a backoff interval (capped exponential: 1s, 2s, 4s, 8s, 16s, 30s+-- thereafter), acquires a replacement, re-LISTENs, and resumes. The+-- replacement is published into 'listenerConnRef' so 'stopNotifier'+-- releases it on shutdown.+--+-- If an 'eventHandler' callback is supplied, the notifier emits+-- 'Kiroku.Store.Observability.KirokuEventNotifierReconnecting' on each+-- attempted reconnect (with the consecutive failure count and the+-- underlying exception) and 'KirokuEventNotifierReconnected' on each+-- successful reconnect. The failure counter resets after a successful+-- reconnect.+--+-- On 'Async.AsyncCancelled' (from cancellation), exits cleanly.+--+-- Initial acquire or LISTEN failure raises 'NotifierStartError'. startNotifier ::-    (MonadIO m) =>-    -- | libpq connection string-    Text ->-    -- | schema name (used to construct the LISTEN channel)-    Text ->-    -- | optional event handler for reconnect observability-    Maybe (KirokuEvent -> IO ()) ->-    m Notifier+  (MonadIO m) =>+  -- | libpq connection string+  Text ->+  -- | schema name (used to construct the LISTEN channel)+  Text ->+  -- | optional event handler for reconnect observability+  Maybe (KirokuEvent -> IO ()) ->+  m Notifier startNotifier connString schema mHandler = liftIO $ do-    chan <- newBroadcastTChanIO-    catGenVar <- newTVarIO Map.empty-    let channel = toPgIdentifier (schema <> ".events")-    bracketOnError (acquireOrThrow connString) Connection.release $ \conn -> do-        Notifications.listen conn channel-            `catch` \(e :: SomeException) ->-                case asyncExceptionFromException e of-                    Just (ae :: SomeAsyncException) -> throwIO ae-                    Nothing -> throwIO (NotifierListenError e)-        connRef <- newTVarIO conn-        thread <- Async.async (listenerLoop chan catGenVar connRef channel connString mHandler)-        pure-            Notifier-                { tickChan = chan-                , listenerThread = thread-                , listenerConnRef = connRef-                , categoryGenerations = catGenVar-                }+  chan <- newBroadcastTChanIO+  catGenVar <- newTVarIO Map.empty+  namespaces <- newTVarIO Map.empty+  let channel = toPgIdentifier (schema <> ".events")+  bracketOnError (acquireOrThrow connString) Connection.release $ \conn -> do+    Notifications.listen conn channel+      `catch` \(e :: SomeException) ->+        case asyncExceptionFromException e of+          Just (ae :: SomeAsyncException) -> throwIO ae+          Nothing -> throwIO (NotifierListenError e)+    connRef <- newTVarIO conn+    thread <- Async.async (listenerLoop chan catGenVar namespaces connRef channel connString mHandler)+    pure+      Notifier+        { tickChan = chan,+          listenerThread = thread,+          listenerConnRef = connRef,+          categoryGenerations = catGenVar,+          namespaceRegistry = namespaces+        } -{- | Stop the Notifier. Cancels the listener thread, waits for it to-finish, and releases whichever connection the loop is currently holding.--}+-- | Stop the Notifier. Cancels the listener thread, waits for it to+-- finish, and releases whichever connection the loop is currently holding. stopNotifier :: (MonadIO m) => Notifier -> m () stopNotifier notifier = liftIO $ do-    Async.cancel (listenerThread notifier)-    void $ Async.waitCatch (listenerThread notifier)-    conn <- readTVarIO (listenerConnRef notifier)-    Connection.release conn+  Async.cancel (listenerThread notifier)+  void $ Async.waitCatch (listenerThread notifier)+  conn <- readTVarIO (listenerConnRef notifier)+  Connection.release conn  -- Internal: the listener loop. Calls 'waitForNotifications' on the current -- connection; that call blocks indefinitely under normal operation. If it@@ -161,58 +201,59 @@ -- on the 'KirokuEventNotifierReconnecting' event; the wait-returned-without- -- exception path uses a synthetic message. listenerLoop ::-    TChan () ->-    TVar (Map Text Word64) ->-    TVar Connection ->-    PgIdentifier ->-    Text ->-    Maybe (KirokuEvent -> IO ()) ->-    IO ()-listenerLoop chan catGenVar connRef channel connStr mHandler = go+  TChan () ->+  TVar (Map Text Word64) ->+  NamespaceRegistry ->+  TVar Connection ->+  PgIdentifier ->+  Text ->+  Maybe (KirokuEvent -> IO ()) ->+  IO ()+listenerLoop chan catGenVar namespaces connRef channel connStr mHandler = go   where     go =-        ( do-            currentConn <- readTVarIO connRef-            waitForNotifications (handleNotification chan catGenVar) currentConn-            -- waitForNotifications returns only if the underlying connection-            -- went bad and Hasql converted the error into a Left result; in-            -- that case we must reconnect rather than spin re-invoking the-            -- same dead conn.-            reconnect 1 (toException ListenerWaitReturned)-        )-            `catch` \(e :: SomeException) ->-                case asyncExceptionFromException e of-                    Just (_ :: Async.AsyncCancelled) -> pure ()-                    Nothing -> reconnect 1 e+      ( do+          currentConn <- readTVarIO connRef+          waitForNotifications (handleNotification chan catGenVar namespaces) currentConn+          -- waitForNotifications returns only if the underlying connection+          -- went bad and Hasql converted the error into a Left result; in+          -- that case we must reconnect rather than spin re-invoking the+          -- same dead conn.+          reconnect 1 (toException ListenerWaitReturned)+      )+        `catch` \(e :: SomeException) ->+          case asyncExceptionFromException e of+            Just (_ :: Async.AsyncCancelled) -> pure ()+            Nothing -> reconnect 1 e      reconnect attempt cause = do-        emit (KirokuEventNotifierReconnecting attempt cause)-        oldConn <- readTVarIO connRef-        Connection.release oldConn-        threadDelay (reconnectDelayMicros attempt)-        -- bracketOnError releases the freshly acquired connection if an-        -- async exception lands between acquire and the TVar write — without-        -- it the new connection would be unreachable from stopNotifier and-        -- would leak.-        result <--            ( Right-                <$> bracketOnError-                    (acquireOrThrow connStr)-                    Connection.release-                    ( \newConn -> do-                        Notifications.listen newConn channel-                        atomically (writeTVar connRef newConn)-                    )-            )-                `catch` (\(e :: SomeException) -> pure (Left e))-        case result of-            Left e ->-                case asyncExceptionFromException e of-                    Just (_ :: Async.AsyncCancelled) -> throwIO e-                    Nothing -> reconnect (attempt + 1) e-            Right () -> do-                emit KirokuEventNotifierReconnected-                go+      emit (KirokuEventNotifierReconnecting attempt cause)+      oldConn <- readTVarIO connRef+      Connection.release oldConn+      threadDelay (reconnectDelayMicros attempt)+      -- bracketOnError releases the freshly acquired connection if an+      -- async exception lands between acquire and the TVar write — without+      -- it the new connection would be unreachable from stopNotifier and+      -- would leak.+      result <-+        ( Right+            <$> bracketOnError+              (acquireOrThrow connStr)+              Connection.release+              ( \newConn -> do+                  Notifications.listen newConn channel+                  atomically (writeTVar connRef newConn)+              )+        )+          `catch` (\(e :: SomeException) -> pure (Left e))+      case result of+        Left e ->+          case asyncExceptionFromException e of+            Just (_ :: Async.AsyncCancelled) -> throwIO e+            Nothing -> reconnect (attempt + 1) e+        Right () -> do+          emit KirokuEventNotifierReconnected+          go      emit = emitOrDrop mHandler @@ -222,11 +263,13 @@ -- worker blocked on that category unblocks. Both updates happen in one STM -- transaction. The two callback arguments are the channel and the payload; we -- ignore the channel (we only LISTEN on one) and parse the payload.-handleNotification :: TChan () -> TVar (Map Text Word64) -> ByteString -> ByteString -> IO ()-handleNotification chan catGenVar _channel payload =-    atomically $ do-        writeTChan chan ()-        modifyTVar' catGenVar (Map.insertWith (+) (categoryFromPayload payload) 1)+handleNotification :: TChan () -> TVar (Map Text Word64) -> NamespaceRegistry -> ByteString -> ByteString -> IO ()+handleNotification chan catGenVar namespaces _channel payload =+  atomically $ do+    writeTChan chan ()+    let category = categoryFromPayload payload+    modifyTVar' catGenVar (Map.insertWith (+) category 1)+    notifyNamespace namespaces category  -- Recover the originating stream's category from a @notify_events@ payload. The -- payload is @stream_name,stream_id,stream_version@; @stream_id@ and@@ -237,34 +280,34 @@ -- the @streams.category GENERATED ALWAYS AS split_part(stream_name,'-',1)@ column. categoryFromPayload :: ByteString -> Text categoryFromPayload payload =-    let fields = BC.split ',' payload-        streamNameBytes = BC.intercalate "," (take (max 0 (length fields - 2)) fields)-        CategoryName category = categoryName (StreamName (decodeUtf8Lenient streamNameBytes))-     in category+  let fields = BC.split ',' payload+      streamNameBytes = BC.intercalate "," (take (max 0 (length fields - 2)) fields)+      CategoryName category = categoryName (StreamName (decodeUtf8Lenient streamNameBytes))+   in category  -- A synthetic exception used when 'waitForNotifications' returns without -- raising (hasql-notifications turned a connection error into a Left -- result and dropped the diagnostic). The reconnect-event payload still -- needs an exception value — this is it. data ListenerWaitReturned = ListenerWaitReturned-    deriving stock (Show)-    deriving anyclass (Exception)+  deriving stock (Show)+  deriving anyclass (Exception)  -- Internal: acquire a connection, set its application_name, or throw on failure. -- Throws 'NotifierStartError' (which derives 'Exception') for connection -- acquisition failures, so callers can match on a typed startup exception. acquireOrThrow :: Text -> IO Connection acquireOrThrow connStr = do-    result <- Connection.acquire (Conn.connectionString connStr)-    case result of-        Left err -> throwIO (NotifierConnectError err)-        Right conn -> do-            -- Tag the connection so operators can identify the listener in-            -- pg_stat_activity. Failures here are non-fatal — fall back to the-            -- default application_name silently rather than aborting startup.-            _ <- Connection.use conn (Session.statement () setAppNameStmt)-            pure conn+  result <- Connection.acquire (Conn.connectionString connStr)+  case result of+    Left err -> throwIO (NotifierConnectError err)+    Right conn -> do+      -- Tag the connection so operators can identify the listener in+      -- pg_stat_activity. Failures here are non-fatal — fall back to the+      -- default application_name silently rather than aborting startup.+      _ <- Connection.use conn (Session.statement () setAppNameStmt)+      pure conn  setAppNameStmt :: Statement () () setAppNameStmt =-    unpreparable "SET application_name = 'kiroku-listener'" E.noParams D.noResult+  unpreparable "SET application_name = 'kiroku-listener'" E.noParams D.noResult
src/Kiroku/Store/Observability.hs view
@@ -1,55 +1,55 @@-{- | Structured operational events emitted by 'Kiroku.Store'.--This module provides the 'KirokuEvent' sum type and its supporting-enumerations. It complements-'Kiroku.Store.Connection.ConnectionSettings.observationHandler' (which-covers @hasql-pool@'s connection-lifecycle events) with events the-package emits itself:--* Notifier reconnection (the dedicated @LISTEN@ connection went bad and-  is being re-established).-* EventPublisher pool errors (the publisher's read query failed and-  will retry on the next tick or 30-second safety poll).-* Per-subscription database errors (checkpoint load, batch fetch,-  checkpoint save).-* Subscription lifecycle (started, caught-up, paused, resumed, reconnecting,-  stopped).-* Subscription batch delivery (one per non-empty batch handed to the-  handler, on every target and in both catch-up and live phases).-* Subscription live fetches (one per DB-driven live-mode fetch).-* Hard-delete issuance (a fail-safe audit signal — see-  @docs\/PRODUCTION-DEPLOYMENT.md@ for the recommended in-band audit-  pattern).-* Committed replay-history lease acquisition, renewal, actual release, pruning,-  and supported hard-delete refusal.--Lease reasons remain in the durable inventory and are deliberately absent from-these process-local events. Do not turn free-form reasons into metrics labels.-Direct @Tx.Transaction@ combinators cannot emit process-local events; the-mockable effect wrappers emit only after their database transaction finishes.--Wire 'Kiroku.Store.Connection.ConnectionSettingsM.eventHandler' to a-callback that forwards to your structured logger or metrics pipeline.-The callback runs synchronously on the emit-site thread (notifier loop,-publisher loop, worker loop, store interpreter); slow callbacks therefore-stall those loops. For callbacks that may block, fan out asynchronously-(write to a 'Control.Concurrent.STM.TBQueue' and drain in a separate-thread).--The constructor set is /additive/: new events are added rather than-existing constructors changed. Pattern matches that do not handle a-new constructor will surface as @-Wincomplete-patterns@ warnings, never-as silent regressions.--}-module Kiroku.Store.Observability (-    KirokuEvent (..),+-- | Structured operational events emitted by 'Kiroku.Store'.+--+-- This module provides the 'KirokuEvent' sum type and its supporting+-- enumerations. It complements+-- 'Kiroku.Store.Connection.ConnectionSettings.observationHandler' (which+-- covers @hasql-pool@'s connection-lifecycle events) with events the+-- package emits itself:+--+-- * Notifier reconnection (the dedicated @LISTEN@ connection went bad and+--   is being re-established).+-- * EventPublisher pool errors (the publisher's read query failed and+--   will retry on the next tick or 30-second safety poll).+-- * Per-subscription database errors (checkpoint load, batch fetch,+--   checkpoint save).+-- * Subscription lifecycle (started, caught-up, paused, resumed, reconnecting,+--   stopped).+-- * Subscription batch delivery (one per non-empty batch handed to the+--   handler, on every target and in both catch-up and live phases).+-- * Subscription live fetches (one per DB-driven live-mode fetch).+-- * Hard-delete issuance (a fail-safe audit signal — see+--   @docs\/PRODUCTION-DEPLOYMENT.md@ for the recommended in-band audit+--   pattern).+-- * Committed replay-history lease acquisition, renewal, actual release, pruning,+--   and supported hard-delete refusal.+--+-- Lease reasons remain in the durable inventory and are deliberately absent from+-- these process-local events. Do not turn free-form reasons into metrics labels.+-- Direct @Tx.Transaction@ combinators cannot emit process-local events; the+-- mockable effect wrappers emit only after their database transaction finishes.+--+-- Wire 'Kiroku.Store.Connection.ConnectionSettingsM.eventHandler' to a+-- callback that forwards to your structured logger or metrics pipeline.+-- The callback runs synchronously on the emit-site thread (notifier loop,+-- publisher loop, worker loop, store interpreter); slow callbacks therefore+-- stall those loops. For callbacks that may block, fan out asynchronously+-- (write to a 'Control.Concurrent.STM.TBQueue' and drain in a separate+-- thread).+--+-- The constructor set is /additive/: new events are added rather than+-- existing constructors changed. Pattern matches that do not handle a+-- new constructor will surface as @-Wincomplete-patterns@ warnings, never+-- as silent regressions.+module Kiroku.Store.Observability+  ( KirokuEvent (..),     SubscriptionDbPhase (..),     SubscriptionDeliveryPhase (..),     SubscriptionStopReason (..),     SubscriptionGroupContext (..),     DeadLetterReason (..),     emitOrDrop,-) where+  )+where  import Control.Exception (SomeAsyncException, SomeException, asyncExceptionFromException, catch, throwIO) import Data.Foldable (for_)@@ -60,246 +60,222 @@ import Kiroku.Store.HistoryRetention.Types (HistoryRetentionConflict, HistoryRetentionLeaseId, HistoryRetentionLeaseOwner, HistoryRetentionPruneResult) import Kiroku.Store.Settings (DecodeFailure) import Kiroku.Store.Subscription.Fsm (DeadLetterReason (..), SubscriptionStopReason (..))-import Kiroku.Store.Subscription.Types (-    CheckpointInitialization,+import Kiroku.Store.Subscription.Types+  ( CheckpointInitialization,     ConsumerGroupSizeMismatch,     SubscriptionCheckpointMissing,     SubscriptionName,     SubscriptionTarget,- )+    SubscriptionTargetMismatch,+  ) import Kiroku.Store.Types (EventId, GlobalPosition, StreamId, StreamName) -{- | A structured operational event emitted by 'Kiroku.Store' itself.--Events are emitted synchronously from the originating thread; consumer-callbacks should be fast or fan out to an asynchronous worker. See the-module Haddock for context.--}+-- | A structured operational event emitted by 'Kiroku.Store' itself.+--+-- Events are emitted synchronously from the originating thread; consumer+-- callbacks should be fast or fan out to an asynchronous worker. See the+-- module Haddock for context. data KirokuEvent-    = KirokuEventSubscriptionTargetBound !SubscriptionName !SubscriptionTarget !SubscriptionGroupContext-    | KirokuEventSubscriptionGroupSizeMismatch !ConsumerGroupSizeMismatch !SubscriptionGroupContext-    | {- | The dedicated @LISTEN@ connection encountered a non-async-      exception and the listener loop is about to attempt reconnection.-      The 'Int' is the consecutive failure count starting at @1@; it-      drives the exponential-backoff delay (capped at 30 seconds) and-      is useful as a metric label for sustained-outage alerting.-      -}-      KirokuEventNotifierReconnecting !Int !SomeException-    | {- | The listener loop successfully re-established the @LISTEN@-      connection. Pairs with the most recent-      'KirokuEventNotifierReconnecting'; the failure counter resets to-      @0@ on observing this event.-      -}-      KirokuEventNotifierReconnected-    | {- | The 'Kiroku.Store.Subscription.EventPublisher.EventPublisher'-      read query returned a 'UsageError'. The publisher will retry on-      the next notification tick or the 30-second safety poll. Sustained-      emissions indicate either pool exhaustion (the publisher shares-      the application pool) or a persistent server error.-      -}-      KirokuEventPublisherPoolError !UsageError-    | {- | The publisher loop's broadcast iteration threw a synchronous-      exception that was not a 'UsageError' — typically a user-supplied-      'Kiroku.Store.Settings.decodeHook' or observability handler throwing.-      The publisher skipped this tick and will retry on the next notification-      or the 30-second safety poll. Sustained emissions mean a deterministically-      failing callback is stalling live broadcast until it is fixed.-      -}-      KirokuEventPublisherLoopError !SomeException-    | -- | Typed hook failure at broadcast time; publisher still advances.-      KirokuEventPublisherDecodeFailed !GlobalPosition !EventId !DecodeFailure-    | -- | One handler invocation remains pending after the configured interval.-      KirokuEventSubscriptionHandlerStalled !SubscriptionName !GlobalPosition !EventId !NominalDiffTime !SubscriptionGroupContext-    | {- | A subscription's worker thread encountered a 'UsageError' in-      the database phase identified by 'SubscriptionDbPhase'. Checkpoint-load-      errors fail startup loudly, fetch-batch errors are retried at the same-      cursor, and checkpoint-save errors are reported while the worker continues.-      The event is the operator's structured signal that this happened. The-      trailing 'SubscriptionGroupContext' identifies which consumer-group member-      (if any) emitted it.-      -}-      KirokuEventSubscriptionDbError !SubscriptionName !SubscriptionDbPhase !UsageError !SubscriptionGroupContext-    | {- | Startup resolved an exact checkpoint key before the worker emitted-      'KirokuEventSubscriptionStarted'. 'ExistingCheckpoint' means the worker-      resumed durable progress; 'InitializedCheckpoint' identifies whether it-      seeded zero or the current store head. The event contains no payload data.-      -}-      KirokuEventSubscriptionCheckpointResolved !CheckpointInitialization !SubscriptionGroupContext-    | {- | Startup applied 'Kiroku.Store.Subscription.Types.FailIfMissing' to-      an absent exact checkpoint key. The worker emits this event and throws the-      carried typed exception before emitting 'KirokuEventSubscriptionStarted'-      or invoking the handler.-      -}-      KirokuEventSubscriptionCheckpointMissing !SubscriptionCheckpointMissing !SubscriptionGroupContext-    | {- | A subscription's worker thread has just started; the worker-      will begin from the durable 'GlobalPosition' reported by the immediately-      preceding checkpoint-resolution event. The trailing-      'SubscriptionGroupContext' identifies which consumer-group member (if any)-      started.-      -}-      KirokuEventSubscriptionStarted !SubscriptionName !GlobalPosition !SubscriptionGroupContext-    | {- | The subscription has reached the EventPublisher's-      @lastPublished@ position and is switching from catch-up to-      live mode at the indicated 'GlobalPosition'. Fires at most-      once per worker run. The trailing 'SubscriptionGroupContext'-      identifies which consumer-group member (if any) caught up.-      -}-      KirokuEventSubscriptionCaughtUp !SubscriptionName !GlobalPosition !SubscriptionGroupContext-    | {- | The subscription's worker has stopped at the indicated-      'GlobalPosition'. The 'SubscriptionStopReason' discriminates-      normal completion (handler returned 'Stop') from cancellation,-      overflow, and worker-thread crashes. The trailing-      'SubscriptionGroupContext' identifies which consumer-group member-      (if any) stopped.-      -}-      KirokuEventSubscriptionStopped !SubscriptionName !GlobalPosition !SubscriptionStopReason !SubscriptionGroupContext-    | {- | A subscription's worker hit recoverable backpressure: its bounded-      queue filled under the 'Kiroku.Store.Subscription.Types.PauseAndResume'-      overflow policy, so the worker paused delivery at the indicated-      'GlobalPosition'. Pairs with a subsequent-      'KirokuEventSubscriptionResumed'. The trailing 'SubscriptionGroupContext'-      identifies which consumer-group member (if any) paused.-      -}-      KirokuEventSubscriptionPaused !SubscriptionName !GlobalPosition !SubscriptionGroupContext-    | {- | A subscription's worker resumed after a pause: it drained the stale-      queue and re-catches-up from its checkpoint (the indicated-      'GlobalPosition') so every event skipped while paused is re-read from the-      database. Pairs with a preceding 'KirokuEventSubscriptionPaused'.-      -}-      KirokuEventSubscriptionResumed !SubscriptionName !GlobalPosition !SubscriptionGroupContext-    | {- | A subscription's worker lost its database connection while live (a-      'UsageError' on a live-mode fetch) and is reconnecting: it backs off and-      re-catches-up from its checkpoint rather than dying. The 'Int' is the-      consecutive reconnect-attempt count starting at @1@; it drives the-      capped backoff and is a useful sustained-outage metric label. Emitted by-      Category and consumer-group subscriptions (AllStreams live delivery is fed-      by the publisher, which owns its own reconnect). The trailing-      'SubscriptionGroupContext' identifies the consumer-group member (if any).-      -}-      KirokuEventSubscriptionReconnecting !SubscriptionName !Int !SubscriptionGroupContext-    | {- | A live DB-driven subscription loop-      ('Kiroku.Store.Subscription.Worker') issued one category/partition fetch-      in live mode, returning the given row count ('Int'). Emitted by the-      @Category@ NOTIFY-driven loop and the consumer-group loop on every live-      fetch (not on the catch-up path). Lets operators observe the-      per-subscription live-fetch rate and lets tests assert that an idle-      category does no work. The trailing 'SubscriptionGroupContext' identifies-      the consumer-group member (if any).-      -}-      KirokuEventSubscriptionFetched !SubscriptionName !Int !SubscriptionGroupContext-    | {- | A subscription's worker delivered one non-empty batch of events to-      the handler through the single delivery primitive-      'Kiroku.Store.Subscription.Worker.processEvents'. Emitted once per batch on-      __every__ delivery path — catch-up for every target, @AllStreams@ live, and-      the @Category@\/consumer-group DB-driven live loops — so it is a uniform-      per-batch delivery signal. The 'Int' is the batch row count (always @>= 1@).-      The 'SubscriptionDeliveryPhase' says whether the worker was catching up or-      live when it delivered. The trailing 'SubscriptionGroupContext' identifies-      the consumer-group member (if any).--      This is distinct from 'KirokuEventSubscriptionFetched', which only the-      DB-driven live loops emit (per /fetch/, including empty fetches) and which-      this constructor does /not/ replace. A DB-driven live batch therefore emits-      both 'KirokuEventSubscriptionFetched' (the fetch) and-      'KirokuEventSubscriptionDelivered' (the delivery).-      -}-      KirokuEventSubscriptionDelivered !SubscriptionName !Int !SubscriptionDeliveryPhase !SubscriptionGroupContext-    | {- | A subscription handler returned-      'Kiroku.Store.Subscription.Types.Retry' for the event at the indicated-      'GlobalPosition' and the worker is about to redeliver it. The 'Int' is the-      redelivery attempt count starting at @1@ (so @1@ is the first redelivery-      after the initial delivery). Bounded by the subscription's-      'Kiroku.Store.Subscription.Types.RetryPolicy'; exhaustion produces a-      'KirokuEventSubscriptionDeadLettered' with-      'Kiroku.Store.Subscription.Types.DeadLetterMaxAttempts'. The trailing-      'SubscriptionGroupContext' identifies the consumer-group member (if any).-      -}-      KirokuEventSubscriptionRetrying !SubscriptionName !GlobalPosition !Int !SubscriptionGroupContext-    | {- | The worker recorded the event at the indicated 'GlobalPosition' in-      @kiroku.dead_letters@ with the given 'DeadLetterReason' and atomically-      advanced the checkpoint past it. Emitted when a handler returns-      'Kiroku.Store.Subscription.Types.DeadLetter' or when retry attempts are-      exhausted. The trailing 'SubscriptionGroupContext' identifies the-      consumer-group member (if any).-      -}-      KirokuEventSubscriptionDeadLettered !SubscriptionName !GlobalPosition !DeadLetterReason !SubscriptionGroupContext-    | {- | A hard-delete transaction completed successfully. Operators-      relying on a fail-safe audit log can capture this event;-      compliance-grade audit should still record an application-level-      event /before/ calling-      'Kiroku.Store.Lifecycle.hardDeleteStream' (see-      @docs\/PRODUCTION-DEPLOYMENT.md@). Not emitted when the named-      stream did not exist.-      -}-      KirokuEventHardDeleteIssued !StreamName !StreamId-    | {- | A lease acquisition committed. The lease row is the durable audit-      evidence; this process-local event contains no event payload.-      -}-      KirokuEventHistoryRetentionLeaseAcquired !HistoryRetentionLeaseId !HistoryRetentionLeaseOwner !GlobalPosition !UTCTime-    | -- | A live lease renewal committed; reason text is not copied into the event.-      KirokuEventHistoryRetentionLeaseRenewed !HistoryRetentionLeaseId !HistoryRetentionLeaseOwner !UTCTime-    | -- | A previously active lease was actually released and committed.-      KirokuEventHistoryRetentionLeaseReleased !HistoryRetentionLeaseId !HistoryRetentionLeaseOwner !UTCTime-    | -- | A prune operation committed with the reported terminal-row counts.-      KirokuEventHistoryRetentionLeasesPruned !HistoryRetentionPruneResult-    | -- | Supported hard delete was refused before changing history.-      KirokuEventHardDeleteHistoryRetentionConflict !StreamName !HistoryRetentionConflict-    deriving stock (Show)--{- | Invoke the optional observability handler, dropping any synchronous exception-it throws. Asynchronous exceptions, including thread cancellation, are rethrown.+  = KirokuEventSubscriptionTargetBound !SubscriptionName !SubscriptionTarget !SubscriptionGroupContext+  | KirokuEventSubscriptionGroupSizeMismatch !ConsumerGroupSizeMismatch !SubscriptionGroupContext+  | KirokuEventSubscriptionTargetMismatch !SubscriptionTargetMismatch !SubscriptionGroupContext+  | -- | The dedicated @LISTEN@ connection encountered a non-async+    --       exception and the listener loop is about to attempt reconnection.+    --       The 'Int' is the consecutive failure count starting at @1@; it+    --       drives the exponential-backoff delay (capped at 30 seconds) and+    --       is useful as a metric label for sustained-outage alerting.+    KirokuEventNotifierReconnecting !Int !SomeException+  | -- | The listener loop successfully re-established the @LISTEN@+    --       connection. Pairs with the most recent+    --       'KirokuEventNotifierReconnecting'; the failure counter resets to+    --       @0@ on observing this event.+    KirokuEventNotifierReconnected+  | -- | The 'Kiroku.Store.Subscription.EventPublisher.EventPublisher'+    --       read query returned a 'UsageError'. The publisher will retry on+    --       the next notification tick or the 30-second safety poll. Sustained+    --       emissions indicate either pool exhaustion (the publisher shares+    --       the application pool) or a persistent server error.+    KirokuEventPublisherPoolError !UsageError+  | -- | The publisher loop's broadcast iteration threw a synchronous+    --       exception that was not a 'UsageError' — typically a user-supplied+    --       'Kiroku.Store.Settings.decodeHook' or observability handler throwing.+    --       The publisher skipped this tick and will retry on the next notification+    --       or the 30-second safety poll. Sustained emissions mean a deterministically+    --       failing callback is stalling live broadcast until it is fixed.+    KirokuEventPublisherLoopError !SomeException+  | -- | Typed hook failure at broadcast time; publisher still advances.+    KirokuEventPublisherDecodeFailed !GlobalPosition !EventId !DecodeFailure+  | -- | One handler invocation remains pending after the configured interval.+    KirokuEventSubscriptionHandlerStalled !SubscriptionName !GlobalPosition !EventId !NominalDiffTime !SubscriptionGroupContext+  | -- | A subscription's worker thread encountered a 'UsageError' in+    --       the database phase identified by 'SubscriptionDbPhase'. Checkpoint-load+    --       errors fail startup loudly, fetch-batch errors are retried at the same+    --       cursor, and checkpoint-save errors are reported while the worker continues.+    --       The event is the operator's structured signal that this happened. The+    --       trailing 'SubscriptionGroupContext' identifies which consumer-group member+    --       (if any) emitted it.+    KirokuEventSubscriptionDbError !SubscriptionName !SubscriptionDbPhase !UsageError !SubscriptionGroupContext+  | -- | Startup resolved an exact checkpoint key before the worker emitted+    --       'KirokuEventSubscriptionStarted'. 'ExistingCheckpoint' means the worker+    --       resumed durable progress; 'InitializedCheckpoint' identifies whether it+    --       seeded zero or the current store head. The event contains no payload data.+    KirokuEventSubscriptionCheckpointResolved !CheckpointInitialization !SubscriptionGroupContext+  | -- | Startup applied 'Kiroku.Store.Subscription.Types.FailIfMissing' to+    --       an absent exact checkpoint key. The worker emits this event and throws the+    --       carried typed exception before emitting 'KirokuEventSubscriptionStarted'+    --       or invoking the handler.+    KirokuEventSubscriptionCheckpointMissing !SubscriptionCheckpointMissing !SubscriptionGroupContext+  | -- | A subscription's worker thread has just started; the worker+    --       will begin from the durable 'GlobalPosition' reported by the immediately+    --       preceding checkpoint-resolution event. The trailing+    --       'SubscriptionGroupContext' identifies which consumer-group member (if any)+    --       started.+    KirokuEventSubscriptionStarted !SubscriptionName !GlobalPosition !SubscriptionGroupContext+  | -- | The subscription has reached the EventPublisher's+    --       @lastPublished@ position and is switching from catch-up to+    --       live mode at the indicated 'GlobalPosition'. Fires at most+    --       once per worker run. The trailing 'SubscriptionGroupContext'+    --       identifies which consumer-group member (if any) caught up.+    KirokuEventSubscriptionCaughtUp !SubscriptionName !GlobalPosition !SubscriptionGroupContext+  | -- | The subscription's worker has stopped at the indicated+    --       'GlobalPosition'. The 'SubscriptionStopReason' discriminates+    --       normal completion (handler returned 'Stop') from cancellation,+    --       overflow, and worker-thread crashes. The trailing+    --       'SubscriptionGroupContext' identifies which consumer-group member+    --       (if any) stopped.+    KirokuEventSubscriptionStopped !SubscriptionName !GlobalPosition !SubscriptionStopReason !SubscriptionGroupContext+  | -- | A subscription's worker hit recoverable backpressure: its bounded+    --       queue filled under the 'Kiroku.Store.Subscription.Types.PauseAndResume'+    --       overflow policy, so the worker paused delivery at the indicated+    --       'GlobalPosition'. Pairs with a subsequent+    --       'KirokuEventSubscriptionResumed'. The trailing 'SubscriptionGroupContext'+    --       identifies which consumer-group member (if any) paused.+    KirokuEventSubscriptionPaused !SubscriptionName !GlobalPosition !SubscriptionGroupContext+  | -- | A subscription's worker resumed after a pause: it drained the stale+    --       queue and re-catches-up from its checkpoint (the indicated+    --       'GlobalPosition') so every event skipped while paused is re-read from the+    --       database. Pairs with a preceding 'KirokuEventSubscriptionPaused'.+    KirokuEventSubscriptionResumed !SubscriptionName !GlobalPosition !SubscriptionGroupContext+  | -- | A subscription's worker lost its database connection while live (a+    --       'UsageError' on a live-mode fetch) and is reconnecting: it backs off and+    --       re-catches-up from its checkpoint rather than dying. The 'Int' is the+    --       consecutive reconnect-attempt count starting at @1@; it drives the+    --       capped backoff and is a useful sustained-outage metric label. Emitted by+    --       Category and consumer-group subscriptions (AllStreams live delivery is fed+    --       by the publisher, which owns its own reconnect). The trailing+    --       'SubscriptionGroupContext' identifies the consumer-group member (if any).+    KirokuEventSubscriptionReconnecting !SubscriptionName !Int !SubscriptionGroupContext+  | -- | A live DB-driven subscription loop+    --       ('Kiroku.Store.Subscription.Worker') issued one category/partition fetch+    --       in live mode, returning the given row count ('Int'). Emitted by the+    --       @Category@ NOTIFY-driven loop and the consumer-group loop on every live+    --       fetch (not on the catch-up path). Lets operators observe the+    --       per-subscription live-fetch rate and lets tests assert that an idle+    --       category does no work. The trailing 'SubscriptionGroupContext' identifies+    --       the consumer-group member (if any).+    KirokuEventSubscriptionFetched !SubscriptionName !Int !SubscriptionGroupContext+  | -- | A subscription's worker delivered one non-empty batch of events to+    --       the handler through the single delivery primitive+    --       'Kiroku.Store.Subscription.Worker.processEvents'. Emitted once per batch on+    --       __every__ delivery path — catch-up for every target, @AllStreams@ live, and+    --       the @Category@\/consumer-group DB-driven live loops — so it is a uniform+    --       per-batch delivery signal. The 'Int' is the batch row count (always @>= 1@).+    --       The 'SubscriptionDeliveryPhase' says whether the worker was catching up or+    --       live when it delivered. The trailing 'SubscriptionGroupContext' identifies+    --       the consumer-group member (if any).+    --+    --       This is distinct from 'KirokuEventSubscriptionFetched', which only the+    --       DB-driven live loops emit (per /fetch/, including empty fetches) and which+    --       this constructor does /not/ replace. A DB-driven live batch therefore emits+    --       both 'KirokuEventSubscriptionFetched' (the fetch) and+    --       'KirokuEventSubscriptionDelivered' (the delivery).+    KirokuEventSubscriptionDelivered !SubscriptionName !Int !SubscriptionDeliveryPhase !SubscriptionGroupContext+  | -- | A subscription handler returned+    --       'Kiroku.Store.Subscription.Types.Retry' for the event at the indicated+    --       'GlobalPosition' and the worker is about to redeliver it. The 'Int' is the+    --       redelivery attempt count starting at @1@ (so @1@ is the first redelivery+    --       after the initial delivery). Bounded by the subscription's+    --       'Kiroku.Store.Subscription.Types.RetryPolicy'; exhaustion produces a+    --       'KirokuEventSubscriptionDeadLettered' with+    --       'Kiroku.Store.Subscription.Types.DeadLetterMaxAttempts'. The trailing+    --       'SubscriptionGroupContext' identifies the consumer-group member (if any).+    KirokuEventSubscriptionRetrying !SubscriptionName !GlobalPosition !Int !SubscriptionGroupContext+  | -- | The worker recorded the event at the indicated 'GlobalPosition' in+    --       @kiroku.dead_letters@ with the given 'DeadLetterReason' and atomically+    --       advanced the checkpoint past it. Emitted when a handler returns+    --       'Kiroku.Store.Subscription.Types.DeadLetter' or when retry attempts are+    --       exhausted. The trailing 'SubscriptionGroupContext' identifies the+    --       consumer-group member (if any).+    KirokuEventSubscriptionDeadLettered !SubscriptionName !GlobalPosition !DeadLetterReason !SubscriptionGroupContext+  | -- | A hard-delete transaction completed successfully. Operators+    --       relying on a fail-safe audit log can capture this event;+    --       compliance-grade audit should still record an application-level+    --       event /before/ calling+    --       'Kiroku.Store.Lifecycle.hardDeleteStream' (see+    --       @docs\/PRODUCTION-DEPLOYMENT.md@). Not emitted when the named+    --       stream did not exist.+    KirokuEventHardDeleteIssued !StreamName !StreamId+  | -- | A lease acquisition committed. The lease row is the durable audit+    --       evidence; this process-local event contains no event payload.+    KirokuEventHistoryRetentionLeaseAcquired !HistoryRetentionLeaseId !HistoryRetentionLeaseOwner !GlobalPosition !UTCTime+  | -- | A live lease renewal committed; reason text is not copied into the event.+    KirokuEventHistoryRetentionLeaseRenewed !HistoryRetentionLeaseId !HistoryRetentionLeaseOwner !UTCTime+  | -- | A previously active lease was actually released and committed.+    KirokuEventHistoryRetentionLeaseReleased !HistoryRetentionLeaseId !HistoryRetentionLeaseOwner !UTCTime+  | -- | A prune operation committed with the reported terminal-row counts.+    KirokuEventHistoryRetentionLeasesPruned !HistoryRetentionPruneResult+  | -- | Supported hard delete was refused before changing history.+    KirokuEventHardDeleteHistoryRetentionConflict !StreamName !HistoryRetentionConflict+  deriving stock (Show) -Store-internal threads must not die because a metrics or logging callback threw;-the callback contract is that it should be fast and non-throwing.--}+-- | Invoke the optional observability handler, dropping any synchronous exception+-- it throws. Asynchronous exceptions, including thread cancellation, are rethrown.+--+-- Store-internal threads must not die because a metrics or logging callback threw;+-- the callback contract is that it should be fast and non-throwing. emitOrDrop :: Maybe (KirokuEvent -> IO ()) -> KirokuEvent -> IO () emitOrDrop mHandler evt =-    for_ mHandler $ \handler ->-        handler evt `catch` \(e :: SomeException) ->-            case asyncExceptionFromException e of-                Just (ae :: SomeAsyncException) -> throwIO ae-                Nothing -> pure ()+  for_ mHandler $ \handler ->+    handler evt `catch` \(e :: SomeException) ->+      case asyncExceptionFromException e of+        Just (ae :: SomeAsyncException) -> throwIO ae+        Nothing -> pure ()  -- | Which database phase a 'KirokuEventSubscriptionDbError' fired in. data SubscriptionDbPhase-    = {- | 'Kiroku.Store.Subscription.Worker' failed to read the saved-      checkpoint at subscription startup. The worker fails startup loudly-      instead of silently re-processing from 'Kiroku.Store.Types.GlobalPosition'-      @0@.-      -}-      LoadCheckpoint-    | {- | The worker's catch-up or category-live database fetch-      returned an error. The worker retries the same cursor with capped-      backoff instead of treating the error as an empty result.-      -}-      FetchBatch-    | {- | The worker's @saveCheckpoint@ statement failed. The-      subscription continues running but the next restart with the-      same name re-processes events the handler has already seen.-      -}-      SaveCheckpoint-    deriving stock (Eq, Show)+  = -- | 'Kiroku.Store.Subscription.Worker' failed to read the saved+    --       checkpoint at subscription startup. The worker fails startup loudly+    --       instead of silently re-processing from 'Kiroku.Store.Types.GlobalPosition'+    --       @0@.+    LoadCheckpoint+  | -- | The worker's catch-up or category-live database fetch+    --       returned an error. The worker retries the same cursor with capped+    --       backoff instead of treating the error as an empty result.+    FetchBatch+  | -- | The worker's @saveCheckpoint@ statement failed. The+    --       subscription continues running but the next restart with the+    --       same name re-processes events the handler has already seen.+    SaveCheckpoint+  deriving stock (Eq, Show) -{- | Which FSM phase a 'KirokuEventSubscriptionDelivered' batch was delivered in:-the worker was either still catching up from history ('DeliveredCatchUp') or-already live ('DeliveredLive'). Derived from the driving 'SubscriptionState' the-worker wrote before the batch (see 'Kiroku.Store.Subscription.Worker.processEvents').--}+-- | Which FSM phase a 'KirokuEventSubscriptionDelivered' batch was delivered in:+-- the worker was either still catching up from history ('DeliveredCatchUp') or+-- already live ('DeliveredLive'). Derived from the driving 'SubscriptionState' the+-- worker wrote before the batch (see 'Kiroku.Store.Subscription.Worker.processEvents'). data SubscriptionDeliveryPhase-    = -- | The batch was delivered while the worker was in 'CatchingUp'.-      DeliveredCatchUp-    | -- | The batch was delivered while the worker was 'Live'.-      DeliveredLive-    deriving stock (Eq, Show)+  = -- | The batch was delivered while the worker was in 'CatchingUp'.+    DeliveredCatchUp+  | -- | The batch was delivered while the worker was 'Live'.+    DeliveredLive+  deriving stock (Eq, Show) -{- | Consumer-group context attached to subscription lifecycle events. A plain-(non-grouped) subscription reports 'NonGroup'; a member of a group reports-@GroupMember member size@ so operators can attribute a lifecycle event to a-specific @(member, size)@.--}+-- | Consumer-group context attached to subscription lifecycle events. A plain+-- (non-grouped) subscription reports 'NonGroup'; a member of a group reports+-- @GroupMember member size@ so operators can attribute a lifecycle event to a+-- specific @(member, size)@. data SubscriptionGroupContext-    = -- | Ordinary, non-grouped subscription.-      NonGroup-    | -- | Member of a group: @GroupMember member size@.-      GroupMember !Int32 !Int32-    deriving stock (Eq, Show)+  = -- | Ordinary, non-grouped subscription.+    NonGroup+  | -- | Member of a group: @GroupMember member size@.+    GroupMember !Int32 !Int32+  deriving stock (Eq, Show)
src/Kiroku/Store/Read.hs view
@@ -1,5 +1,5 @@-module Kiroku.Store.Read (-    readStreamForward,+module Kiroku.Store.Read+  ( readStreamForward,     readStreamForwardStream,     readStreamBackward,     readAllForward,@@ -15,7 +15,8 @@     eventExistsInStream,     lookupStreamName,     lookupStreamNames,-) where+  )+where  import Control.Lens ((^.)) import Data.Generics.Labels ()@@ -33,265 +34,251 @@ import Streamly.Data.Stream (Stream) import Streamly.Data.Stream qualified as Stream -{- | Read events from a named stream in forward (ascending version)-order.--The cursor is exclusive: events with @streamVersion > startVer@ are-returned. To read the entire stream from the beginning, pass-@'StreamVersion' 0@. Returns an empty vector for nonexistent or-soft-deleted streams. The @limit@ caps the batch size; pass a large-value for \"read everything\".--}+-- | Read events from a named stream in forward (ascending version)+-- order.+--+-- The cursor is exclusive: events with @streamVersion > startVer@ are+-- returned. To read the entire stream from the beginning, pass+-- @'StreamVersion' 0@. Returns an empty vector for nonexistent or+-- soft-deleted streams. The @limit@ caps the batch size; pass a large+-- value for \"read everything\". readStreamForward ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    StreamVersion ->-    Int32 ->-    Eff es (Vector RecordedEvent)+  (HasCallStack, Store :> es) =>+  StreamName ->+  StreamVersion ->+  Int32 ->+  Eff es (Vector RecordedEvent) readStreamForward name startVer limit = send (ReadStreamForward name startVer limit) -{- | Forward read a single stream as a constant-memory Streamly 'Stream'.--The streaming sibling of 'readStreamForward'. Identical SQL path and identical-error semantics: this function dispatches 'readStreamForward' repeatedly with-the supplied @pageSize@ as the per-call limit, advancing the exclusive-'StreamVersion' cursor across pages until a page comes back shorter than-@pageSize@. If the stream length is an exact multiple of @pageSize@, one-final empty-page probe is still required because the wrapper does not know-the total stream length up front.--The exclusive-cursor convention is preserved end-to-end: passing-@'StreamVersion' 0@ reads from the first event in the stream. Empty and-nonexistent streams terminate the stream immediately with zero elements.--The recommended @pageSize@ is @256@. Callers reading very wide events (large-payloads / metadata) should pass a smaller value to keep per-page memory-bounded; callers reading very long streams of small events may pass a larger-value to reduce round-trip count.--}+-- | Forward read a single stream as a constant-memory Streamly 'Stream'.+--+-- The streaming sibling of 'readStreamForward'. Identical SQL path and identical+-- error semantics: this function dispatches 'readStreamForward' repeatedly with+-- the supplied @pageSize@ as the per-call limit, advancing the exclusive+-- 'StreamVersion' cursor across pages until a page comes back shorter than+-- @pageSize@. If the stream length is an exact multiple of @pageSize@, one+-- final empty-page probe is still required because the wrapper does not know+-- the total stream length up front.+--+-- The exclusive-cursor convention is preserved end-to-end: passing+-- @'StreamVersion' 0@ reads from the first event in the stream. Empty and+-- nonexistent streams terminate the stream immediately with zero elements.+--+-- The recommended @pageSize@ is @256@. Callers reading very wide events (large+-- payloads / metadata) should pass a smaller value to keep per-page memory+-- bounded; callers reading very long streams of small events may pass a larger+-- value to reduce round-trip count. readStreamForwardStream ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    StreamVersion ->-    Int32 ->-    Stream (Eff es) RecordedEvent+  (HasCallStack, Store :> es) =>+  StreamName ->+  StreamVersion ->+  Int32 ->+  Stream (Eff es) RecordedEvent readStreamForwardStream name startVer pageSize =-    Stream.concatMap fromVector pages+  Stream.concatMap fromVector pages   where     pages = Stream.unfoldrM nextPage (Just startVer)     nextPage Nothing = pure Nothing     nextPage (Just cursor) = do-        events <- readStreamForward name cursor pageSize-        if V.null events-            then pure Nothing-            else do-                let nextState-                        | V.length events < fromIntegral pageSize = Nothing-                        | otherwise = Just (V.last events ^. #streamVersion)-                pure (Just (events, nextState))+      events <- readStreamForward name cursor pageSize+      if V.null events+        then pure Nothing+        else do+          let nextState+                | V.length events < fromIntegral pageSize = Nothing+                | otherwise = Just (V.last events ^. #streamVersion)+          pure (Just (events, nextState))      fromVector :: Vector a -> Stream (Eff es) a     fromVector v = Stream.unfoldr (\i -> fmap (\x -> (x, i + 1)) (v V.!? i)) 0 -{- | Read events from a named stream in backward (descending version)-order.--The cursor is exclusive: events with @streamVersion < startVer@ are-returned (events older than the cursor). To read the entire stream from-the latest event backward, pass @'StreamVersion' 0@. The interpreter maps-0 to the maximum cursor value, so it never collides with a real stream-version; versions start at 1. Returns an empty vector for nonexistent or-soft-deleted streams.--}+-- | Read events from a named stream in backward (descending version)+-- order.+--+-- The cursor is exclusive: events with @streamVersion < startVer@ are+-- returned (events older than the cursor). To read the entire stream from+-- the latest event backward, pass @'StreamVersion' 0@. The interpreter maps+-- 0 to the maximum cursor value, so it never collides with a real stream+-- version; versions start at 1. Returns an empty vector for nonexistent or+-- soft-deleted streams. readStreamBackward ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    StreamVersion ->-    Int32 ->-    Eff es (Vector RecordedEvent)+  (HasCallStack, Store :> es) =>+  StreamName ->+  StreamVersion ->+  Int32 ->+  Eff es (Vector RecordedEvent) readStreamBackward name startVer limit = send (ReadStreamBackward name startVer limit) -{- | Read events from the global @$all@ stream in forward-('GlobalPosition'-ascending) order.--Cursor exclusive: events with @globalPosition > startPos@ are returned.-@$all@ contains every event ever appended (including events from-soft-deleted streams; they survive in @$all@ even after their owning-stream is hidden). Hard-deleted streams' events do /not/ appear in-@$all@. The seed row at @globalPosition = 0@ is internal and is never-returned.--}+-- | Read events from the global @$all@ stream in forward+-- ('GlobalPosition'-ascending) order.+--+-- Cursor exclusive: events with @globalPosition > startPos@ are returned.+-- @$all@ contains every event ever appended (including events from+-- soft-deleted streams; they survive in @$all@ even after their owning+-- stream is hidden). Hard-deleted streams' events do /not/ appear in+-- @$all@. The seed row at @globalPosition = 0@ is internal and is never+-- returned. readAllForward ::-    (HasCallStack, Store :> es) =>-    GlobalPosition ->-    Int32 ->-    Eff es (Vector RecordedEvent)+  (HasCallStack, Store :> es) =>+  GlobalPosition ->+  Int32 ->+  Eff es (Vector RecordedEvent) readAllForward startPos limit = send (ReadAllForward startPos limit) -{- | Read events from the global @$all@ stream in backward-('GlobalPosition'-descending) order.--Cursor exclusive. To start from the most recent event, pass-@'GlobalPosition' 0@. The interpreter maps 0 to the maximum cursor value,-so it never collides with a real global position; positions start at 1.--}+-- | Read events from the global @$all@ stream in backward+-- ('GlobalPosition'-descending) order.+--+-- Cursor exclusive. To start from the most recent event, pass+-- @'GlobalPosition' 0@. The interpreter maps 0 to the maximum cursor value,+-- so it never collides with a real global position; positions start at 1. readAllBackward ::-    (HasCallStack, Store :> es) =>-    GlobalPosition ->-    Int32 ->-    Eff es (Vector RecordedEvent)+  (HasCallStack, Store :> es) =>+  GlobalPosition ->+  Int32 ->+  Eff es (Vector RecordedEvent) readAllBackward startPos limit = send (ReadAllBackward startPos limit) -{- | Return the greatest 'GlobalPosition' still visible in the global @$all@-stream, or @'GlobalPosition' 0@ when no event remains.--Logical truncation and soft deletion leave @$all@ junctions intact, so neither-changes this value. Hard deletion removes junctions and can make the visible-head move backward or return to zero. This differs from-'Kiroku.Store.Subscription.Types.storePosition' in-'Kiroku.Store.Subscription.Types.SubscriptionCheckpointInventory', which is-the authoritative append frontier and remains monotonic after hard deletion.--The value is observed by one database statement. A concurrent append or hard-deletion may change the visible head immediately after this function returns;-the result does not retain a database snapshot.--}+-- | Return the greatest 'GlobalPosition' still visible in the global @$all@+-- stream, or @'GlobalPosition' 0@ when no event remains.+--+-- Logical truncation and soft deletion leave @$all@ junctions intact, so neither+-- changes this value. Hard deletion removes junctions and can make the visible+-- head move backward or return to zero. This differs from+-- 'Kiroku.Store.Subscription.Types.storePosition' in+-- 'Kiroku.Store.Subscription.Types.SubscriptionCheckpointInventory', which is+-- the authoritative append frontier and remains monotonic after hard deletion.+--+-- The value is observed by one database statement. A concurrent append or hard+-- deletion may change the visible head immediately after this function returns;+-- the result does not retain a database snapshot. visibleGlobalHeadPosition ::-    (HasCallStack, Store :> es) =>-    Eff es GlobalPosition+  (HasCallStack, Store :> es) =>+  Eff es GlobalPosition visibleGlobalHeadPosition = send GetVisibleGlobalHeadPosition -{- | Read events whose source stream's category prefix matches the given-'CategoryName', in 'GlobalPosition' order.--The category is the substring of a 'StreamName' before the first @-@:-@StreamName "orders-1"@ has @CategoryName "orders"@. Linked events-appear at their /source/ position; the category is the source's-category, not the link target's.--}+-- | Read events whose source stream's category prefix matches the given+-- 'CategoryName', in 'GlobalPosition' order.+--+-- The category is the substring of a 'StreamName' before the first @-@:+-- @StreamName "orders-1"@ has @CategoryName "orders"@. Linked events+-- appear at their /source/ position; the category is the source's+-- category, not the link target's. readCategory ::-    (HasCallStack, Store :> es) =>-    CategoryName ->-    GlobalPosition ->-    Int32 ->-    Eff es (Vector RecordedEvent)+  (HasCallStack, Store :> es) =>+  CategoryName ->+  GlobalPosition ->+  Int32 ->+  Eff es (Vector RecordedEvent) readCategory cat startPos limit = send (ReadCategoryForward cat startPos limit) -{- | Query stream metadata.--Returns 'Just' for both live and soft-deleted streams (with @deletedAt@-populated). Returns 'Nothing' for hard-deleted streams and streams that-have never been created.--}+-- | Query stream metadata.+--+-- Returns 'Just' for both live and soft-deleted streams (with @deletedAt@+-- populated). Returns 'Nothing' for hard-deleted streams and streams that+-- have never been created. getStream ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    Eff es (Maybe StreamInfo)+  (HasCallStack, Store :> es) =>+  StreamName ->+  Eff es (Maybe StreamInfo) getStream name = send (GetStream name) -{- | Read metadata and the newest surviving /originated/ event's global-position in one SQL statement, from one database snapshot.--Outer 'Nothing' means absent or hard-deleted. Inner 'Nothing' means the-stream exists without surviving originated events, including empty and-link-only streams. @$all@ always has an inner 'Nothing': it aggregates events-but originates none. Links advance the stream version without advancing this-head. Soft deletion and logical truncation preserve the originated head;-physical retention can remove it. This is neither the store-wide visible-head nor the monotonically allocated append frontier.--For an origin-only stream with retained required history, capture this pair,-require @version >= N@ and @Just head@, then wait for the projection cursor to-reach @head@. Waiting and timeouts belong to the consumer. The observation does-not lock history or freeze subsequent appends; later physical removal can-require a timeout. A positive linked version gives no such guarantee.--Only this opt-in operation pays for the indexed head probe. 'getStream' and-'StreamInfo' retain their existing representation and cost.--}+-- | Read metadata and the newest surviving /originated/ event's global+-- position in one SQL statement, from one database snapshot.+--+-- Outer 'Nothing' means absent or hard-deleted. Inner 'Nothing' means the+-- stream exists without surviving originated events, including empty and+-- link-only streams. @$all@ always has an inner 'Nothing': it aggregates events+-- but originates none. Links advance the stream version without advancing this+-- head. Soft deletion and logical truncation preserve the originated head;+-- physical retention can remove it. This is neither the store-wide visible+-- head nor the monotonically allocated append frontier.+--+-- For an origin-only stream with retained required history, capture this pair,+-- require @version >= N@ and @Just head@, then wait for the projection cursor to+-- reach @head@. Waiting and timeouts belong to the consumer. The observation does+-- not lock history or freeze subsequent appends; later physical removal can+-- require a timeout. A positive linked version gives no such guarantee.+--+-- Only this opt-in operation pays for the indexed head probe. 'getStream' and+-- 'StreamInfo' retain their existing representation and cost. getStreamWithHead ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    Eff es (Maybe (StreamInfo, Maybe GlobalPosition))+  (HasCallStack, Store :> es) =>+  StreamName ->+  Eff es (Maybe (StreamInfo, Maybe GlobalPosition)) getStreamWithHead name = send (GetStreamWithHead name) -{- | Look up a stream's surrogate id by name.--Returns 'Just' the 'StreamId' for both live and soft-deleted streams (mirroring-'getStream'\'s soft-delete behavior). Returns 'Nothing' for streams that have-never been created and for streams that have been hard-deleted.--This is a lighter-weight alternative to 'getStream' when the caller only needs-the surrogate id: it decodes one @int8@ column instead of the five columns-that 'StreamInfo' carries. Equivalent to projecting @info ^. #id@ from a-successful 'getStream' result, but cheaper.--}+-- | Look up a stream's surrogate id by name.+--+-- Returns 'Just' the 'StreamId' for both live and soft-deleted streams (mirroring+-- 'getStream'\'s soft-delete behavior). Returns 'Nothing' for streams that have+-- never been created and for streams that have been hard-deleted.+--+-- This is a lighter-weight alternative to 'getStream' when the caller only needs+-- the surrogate id: it decodes one @int8@ column instead of the five columns+-- that 'StreamInfo' carries. Equivalent to projecting @info ^. #id@ from a+-- successful 'getStream' result, but cheaper. lookupStreamId ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    Eff es (Maybe StreamId)+  (HasCallStack, Store :> es) =>+  StreamName ->+  Eff es (Maybe StreamId) lookupStreamId name = send (LookupStreamId name) -{- | Return 'True' when the supplied event id is linked into the supplied live-stream, and 'False' when the event id is absent, belongs only to another stream,-or the stream is nonexistent or soft-deleted.--This is a point lookup for idempotency checks. Prefer it over scanning a stream-when the caller already has the event id it is looking for.--}+-- | Return 'True' when the supplied event id is linked into the supplied live+-- stream, and 'False' when the event id is absent, belongs only to another stream,+-- or the stream is nonexistent or soft-deleted.+--+-- This is a point lookup for idempotency checks. Prefer it over scanning a stream+-- when the caller already has the event id it is looking for. eventExistsInStream ::-    (HasCallStack, Store :> es) =>-    StreamName ->-    EventId ->-    Eff es Bool+  (HasCallStack, Store :> es) =>+  StreamName ->+  EventId ->+  Eff es Bool eventExistsInStream name eid = send (EventExistsInStream name eid) -{- | Resolve a batch of surrogate 'StreamId's to their 'StreamName's in a single-round trip, returning a 'Map' that omits any id which does not name an existing-stream (hard-deleted or never created). Live and soft-deleted streams are-included, mirroring 'lookupStreamId'.--This is the inverse of 'lookupStreamId' and the supported way to recover the-human-readable source stream for events obtained from /fan-in/ reads — the-global @$all@ stream, 'readCategory', the "Kiroku.Store.Causation" queries, and-subscriptions — where each 'RecordedEvent' carries only its surrogate-@originalStreamId@. Collect the distinct ids from a batch and resolve them once,-rather than paying a round trip per event:--@-let ids = 'Data.List.nub' (map (^. #originalStreamId) events)-names <- 'lookupStreamNames' ids--- names '!?' (event '^.' #originalStreamId) :: Maybe StreamName-@--Passing @[]@ returns an empty map without any database round trip because-the interpreter short-circuits.--}+-- | Resolve a batch of surrogate 'StreamId's to their 'StreamName's in a single+-- round trip, returning a 'Map' that omits any id which does not name an existing+-- stream (hard-deleted or never created). Live and soft-deleted streams are+-- included, mirroring 'lookupStreamId'.+--+-- This is the inverse of 'lookupStreamId' and the supported way to recover the+-- human-readable source stream for events obtained from /fan-in/ reads — the+-- global @$all@ stream, 'readCategory', the "Kiroku.Store.Causation" queries, and+-- subscriptions — where each 'RecordedEvent' carries only its surrogate+-- @originalStreamId@. Collect the distinct ids from a batch and resolve them once,+-- rather than paying a round trip per event:+--+-- @+-- let ids = 'Data.List.nub' (map (^. #originalStreamId) events)+-- names <- 'lookupStreamNames' ids+-- -- names '!?' (event '^.' #originalStreamId) :: Maybe StreamName+-- @+--+-- Passing @[]@ returns an empty map without any database round trip because+-- the interpreter short-circuits. lookupStreamNames ::-    (HasCallStack, Store :> es) =>-    [StreamId] ->-    Eff es (Map StreamId StreamName)+  (HasCallStack, Store :> es) =>+  [StreamId] ->+  Eff es (Map StreamId StreamName) lookupStreamNames sids = send (LookupStreamNames sids) -{- | Resolve a single surrogate 'StreamId' to its 'StreamName', or 'Nothing' if-no such stream exists. A convenience wrapper over 'lookupStreamNames'; prefer-the batch form when resolving the ids of a whole read batch, to avoid one round-trip per id.--}+-- | Resolve a single surrogate 'StreamId' to its 'StreamName', or 'Nothing' if+-- no such stream exists. A convenience wrapper over 'lookupStreamNames'; prefer+-- the batch form when resolving the ids of a whole read batch, to avoid one round+-- trip per id. lookupStreamName ::-    (HasCallStack, Store :> es) =>-    StreamId ->-    Eff es (Maybe StreamName)+  (HasCallStack, Store :> es) =>+  StreamId ->+  Eff es (Maybe StreamName) lookupStreamName sid = Map.lookup sid <$> lookupStreamNames [sid] -{- | Page streams in stable UTF-8 byte order, independent of database locale.-Category equality includes its bare name; prefix matching is literal, including-@%@ and @_@. Use the last returned name as the exclusive cursor. Soft-deleted-streams remain visible, hard-deleted streams do not. Only the reserved row is-excluded, so @$all-x@ and empty categories are valid. A short page proves current-exhaustion. Concurrent lifecycle changes are not a snapshot-isolation guarantee.-TypeID timestamps give generation order, not event/commit order.--}+-- | Page streams in stable UTF-8 byte order, independent of database locale.+-- Category equality includes its bare name; prefix matching is literal, including+-- @%@ and @_@. Use the last returned name as the exclusive cursor. Soft-deleted+-- streams remain visible, hard-deleted streams do not. Only the reserved row is+-- excluded, so @$all-x@ and empty categories are valid. A short page proves current+-- exhaustion. Concurrent lifecycle changes are not a snapshot-isolation guarantee.+-- TypeID timestamps give generation order, not event/commit order. listStreams :: (HasCallStack, Store :> es) => Maybe CategoryName -> Maybe Text -> Maybe StreamName -> BrowsePageSize -> Eff es (Vector StreamInfo) listStreams category prefix after limit = send (ListStreams category prefix after limit) 
src/Kiroku/Store/SQL.hs view
@@ -1,1574 +1,1655 @@ {-# LANGUAGE MultilineStrings #-} -module Kiroku.Store.SQL (-    -- * Append statements-    AppendParams (..),-    appendParamsEncoder,-    appendResultDecoder,-    appendExpectedVersion,-    appendStreamExists,-    appendNoStream,-    appendAnyVersion,--    -- * Link statements-    linkToStreamStmt,--    -- * Read statements-    readStreamForwardStmt,-    readStreamBackwardStmt,-    readAllForwardStmt,-    readAllBackwardStmt,-    readCategoryForwardStmt,-    readCategoryEncoder,-    getStreamStmt,-    getStreamWithHeadStmt,-    eventExistsInStreamStmt,-    lookupStreamNamesStmt,-    listStreamsSession,-    StreamRangeBound (..),-    listStreamsRangeStmt,-    listStreamsPairStmt,-    listCategoriesStmt,-    getEventStmt,-    currentGlobalPositionStmt,-    visibleGlobalHeadPositionStmt,--    -- * Consumer-group read statements-    readCategoryForwardConsumerGroupStmt,-    readCategoryConsumerGroupEncoder,-    readAllForwardConsumerGroupStmt,--    -- * Row decoders-    recordedEventRow,--    -- * Causation / correlation statements-    findByCorrelationStmt,-    findCausationDescendantsStmt,-    findCausationAncestorsStmt,--    -- * Lifecycle statements-    softDeleteStreamStmt,-    undeleteStreamStmt,-    setStreamTruncateBeforeStmt,--    -- * Hard-delete statements (used in sequence inside one transaction)-    findStreamIdStmt,-    deleteAllRowsForOriginStmt,-    deleteJunctionsByEventIdsStmt,-    deleteStreamOwnJunctionsStmt,-    deleteDeadLettersForOrphanedEventsStmt,-    deleteOrphanedEventsStmt,-    deleteStreamRowStmt,--    -- * Multi-stream pre-lock (avoids row-lock deadlocks)-    lockStreamsForMultiStmt,--    -- * Checkpoint statements-    getCheckpointStmt,-    saveCheckpointStmt,-    getCheckpointMemberStmt,-    saveCheckpointMemberStmt,-    saveAllCheckpointMemberStmt,-    saveCategoryCheckpointMemberStmt,--    -- * Dead-letter statements-    DeadLetterParams (..),-    DeadLetterRecord (..),-    insertDeadLetterAndCheckpointStmt,-    readDeadLettersStmt,-    listSubscriptionDeadLettersStmt,-    listSubscriptionDeadLettersFromStartStmt,-    listSubscriptionMemberDeadLettersStmt,-    listSubscriptionMemberDeadLettersFromStartStmt,-) where--import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5, contrazip6)-import Control.Lens ((^.))-import Data.Aeson (Value)-import Data.Char (chr, ord)-import Data.Functor.Contravariant ((>$<))-import Data.Generics.Labels ()-import Data.Int (Int32, Int64)-import Data.Text (Text)-import Data.Text qualified as T-import Data.Time (UTCTime)-import Data.UUID (UUID)-import Data.Vector (Vector)-import GHC.Generics (Generic)-import Hasql.Decoders qualified as D-import Hasql.Encoders qualified as E-import Hasql.Session qualified as Session-import Hasql.Statement (Statement, preparable)-import Kiroku.Store.Subscription.DeadLetter.SQL (listSubscriptionDeadLettersFromStartStmt, listSubscriptionDeadLettersStmt, listSubscriptionMemberDeadLettersFromStartStmt, listSubscriptionMemberDeadLettersStmt)-import Kiroku.Store.Types---- | Parameters for append CTE variants (the 7 parallel arrays + stream name).-data AppendParams = AppendParams-    { eventIds :: !(Vector UUID)-    , eventTypes :: !(Vector Text)-    , causationIds :: !(Vector (Maybe UUID))-    , correlationIds :: !(Vector (Maybe UUID))-    , payloads :: !(Vector Value)-    , metadatas :: !(Vector (Maybe Value))-    , createdAts :: !(Vector UTCTime)-    , streamName :: !Text-    }-    deriving stock (Show, Generic)---- | Encoder for the common 8 parameters shared by all append variants.-appendParamsEncoder :: E.Params AppendParams-appendParamsEncoder =-    ((^. #eventIds) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))-        <> ((^. #eventTypes) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))-        <> ((^. #causationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))-        <> ((^. #correlationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))-        <> ((^. #payloads) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.jsonb))))-        <> ((^. #metadatas) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.jsonb))))-        <> ((^. #createdAts) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.timestamptz))))-        <> ((^. #streamName) >$< E.param (E.nonNullable E.text))---- | Encoder for append_expected_version: base params + expected version (Int64).-appendExpectedEncoder :: E.Params (AppendParams, Int64)-appendExpectedEncoder =-    contrazip2 appendParamsEncoder (E.param (E.nonNullable E.int8))--{- | Decoder for append results: stream_id, stream_version, global_position.-Returns Nothing if the CTE produced 0 rows (version conflict / stream not found).--}-appendResultDecoder :: D.Result (Maybe AppendResult)-appendResultDecoder =-    D.rowMaybe $-        AppendResult-            <$> (StreamId <$> D.column (D.nonNullable D.int8))-            <*> (StreamVersion <$> D.column (D.nonNullable D.int8))-            <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))--{- | Append with exact expected version (optimistic concurrency).-Returns Nothing if the stream doesn't exist or version doesn't match.--}-appendExpectedVersion :: Statement (AppendParams, Int64) (Maybe AppendResult)-appendExpectedVersion =-    preparable-        appendExpectedVersionSQL-        appendExpectedEncoder-        appendResultDecoder--{- | Append to an existing stream at any version.-Returns Nothing if the stream doesn't exist.--}-appendStreamExists :: Statement AppendParams (Maybe AppendResult)-appendStreamExists =-    preparable-        appendStreamExistsSQL-        appendParamsEncoder-        appendResultDecoder--{- | Append to a new stream (must not already exist).-Returns Nothing if the stream already exists.--}-appendNoStream :: Statement AppendParams (Maybe AppendResult)-appendNoStream =-    preparable-        appendNoStreamSQL-        appendParamsEncoder-        appendResultDecoder--{- | Append to a stream, creating it if it doesn't exist.-Should always return Just (unless duplicate event ID constraint violation).--}-appendAnyVersion :: Statement AppendParams (Maybe AppendResult)-appendAnyVersion =-    preparable-        appendAnyVersionSQL-        appendParamsEncoder-        appendResultDecoder---- ------------------------------------------------------------------------------ SQL Templates--- ------------------------------------------------------------------------------- | CTE with exact version check: UPDATE streams WHERE stream_version = $9.-appendExpectedVersionSQL :: Text-appendExpectedVersionSQL =-    """-    WITH-      new_events AS (-        SELECT *-        FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])-        WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)-      ),-      stream_update AS (-        UPDATE streams-        SET stream_version = stream_version + (SELECT count(*) FROM new_events)-        WHERE stream_name = $8-          AND stream_version = $9-          AND deleted_at IS NULL-        RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version-      ),-      inserted_events AS (-        INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)-        SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at-        FROM new_events-        WHERE EXISTS (SELECT 1 FROM stream_update)-        ORDER BY idx-      ),-      source_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-        SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx-        FROM new_events ne-        CROSS JOIN stream_update su-      ),-      all_update AS (-        UPDATE streams-        SET stream_version = stream_version + (SELECT count(*) FROM new_events)-        WHERE stream_id = 0-          AND EXISTS (SELECT 1 FROM stream_update)-        RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version-      ),-      all_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)-        SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category-        FROM new_events ne-        CROSS JOIN all_update au-        CROSS JOIN stream_update su-      )-    SELECT su.stream_id,-           su.initial_version + (SELECT count(*) FROM new_events),-           au.initial_global_version + (SELECT count(*) FROM new_events)-    FROM stream_update su-    CROSS JOIN all_update au-    """---- | CTE without version check: UPDATE streams WHERE stream_name = $8 (no $9).-appendStreamExistsSQL :: Text-appendStreamExistsSQL =-    """-    WITH-      new_events AS (-        SELECT *-        FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])-        WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)-      ),-      stream_update AS (-        UPDATE streams-        SET stream_version = stream_version + (SELECT count(*) FROM new_events)-        WHERE stream_name = $8-          AND deleted_at IS NULL-        RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version-      ),-      inserted_events AS (-        INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)-        SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at-        FROM new_events-        WHERE EXISTS (SELECT 1 FROM stream_update)-        ORDER BY idx-      ),-      source_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-        SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx-        FROM new_events ne-        CROSS JOIN stream_update su-      ),-      all_update AS (-        UPDATE streams-        SET stream_version = stream_version + (SELECT count(*) FROM new_events)-        WHERE stream_id = 0-          AND EXISTS (SELECT 1 FROM stream_update)-        RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version-      ),-      all_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)-        SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category-        FROM new_events ne-        CROSS JOIN all_update au-        CROSS JOIN stream_update su-      )-    SELECT su.stream_id,-           su.initial_version + (SELECT count(*) FROM new_events),-           au.initial_global_version + (SELECT count(*) FROM new_events)-    FROM stream_update su-    CROSS JOIN all_update au-    """---- | CTE for new stream creation: INSERT INTO streams ... ON CONFLICT DO NOTHING.-appendNoStreamSQL :: Text-appendNoStreamSQL =-    """-    WITH-      new_events AS (-        SELECT *-        FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])-        WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)-      ),-      stream_insert AS (-        INSERT INTO streams (stream_name, stream_version)-        VALUES ($8, (SELECT count(*) FROM new_events))-        ON CONFLICT (stream_name) DO NOTHING-        RETURNING stream_id, category, 0::bigint AS initial_version-      ),-      inserted_events AS (-        INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)-        SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at-        FROM new_events-        WHERE EXISTS (SELECT 1 FROM stream_insert)-        ORDER BY idx-      ),-      source_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-        SELECT ne.event_id, si.stream_id, si.initial_version + ne.idx, si.stream_id, si.initial_version + ne.idx-        FROM new_events ne-        CROSS JOIN stream_insert si-      ),-      all_update AS (-        UPDATE streams-        SET stream_version = stream_version + (SELECT count(*) FROM new_events)-        WHERE stream_id = 0-          AND EXISTS (SELECT 1 FROM stream_insert)-        RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version-      ),-      all_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)-        SELECT ne.event_id, 0, au.initial_global_version + ne.idx, si.stream_id, si.initial_version + ne.idx, si.category-        FROM new_events ne-        CROSS JOIN all_update au-        CROSS JOIN stream_insert si-      )-    SELECT si.stream_id,-           si.initial_version + (SELECT count(*) FROM new_events),-           au.initial_global_version + (SELECT count(*) FROM new_events)-    FROM stream_insert si-    CROSS JOIN all_update au-    """--{- | CTE for create-or-append using INSERT ... ON CONFLICT DO UPDATE (upsert).-A plain INSERT + separate UPDATE in the same CTE won't work because-data-modifying CTEs cannot see each other's changes. Instead, we use-a single upsert that both creates the stream and bumps its version atomically.--}-appendAnyVersionSQL :: Text-appendAnyVersionSQL =-    """-    WITH-      new_events AS (-        SELECT *-        FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])-        WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)-      ),-      stream_upsert AS (-        INSERT INTO streams (stream_name, stream_version)-        VALUES ($8, (SELECT count(*) FROM new_events))-        ON CONFLICT (stream_name)-        DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)-          WHERE streams.deleted_at IS NULL-        RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version-      ),-      inserted_events AS (-        INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)-        SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at-        FROM new_events-        WHERE EXISTS (SELECT 1 FROM stream_upsert)-        ORDER BY idx-      ),-      source_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-        SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx-        FROM new_events ne-        CROSS JOIN stream_upsert su-      ),-      all_update AS (-        UPDATE streams-        SET stream_version = stream_version + (SELECT count(*) FROM new_events)-        WHERE stream_id = 0-          AND EXISTS (SELECT 1 FROM stream_upsert)-        RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version-      ),-      all_links AS (-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)-        SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category-        FROM new_events ne-        CROSS JOIN all_update au-        CROSS JOIN stream_upsert su-      )-    SELECT su.stream_id,-           su.initial_version + (SELECT count(*) FROM new_events),-           au.initial_global_version + (SELECT count(*) FROM new_events)-    FROM stream_upsert su-    CROSS JOIN all_update au-    """---- ------------------------------------------------------------------------------ Read Statements--- ------------------------------------------------------------------------------- | Shared decoder for a RecordedEvent row (11 columns).-recordedEventRow :: D.Row RecordedEvent-recordedEventRow =-    RecordedEvent-        <$> (EventId <$> D.column (D.nonNullable D.uuid))-        <*> (EventType <$> D.column (D.nonNullable D.text))-        <*> (StreamVersion <$> D.column (D.nonNullable D.int8))-        <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))-        <*> (StreamId <$> D.column (D.nonNullable D.int8))-        <*> (StreamVersion <$> D.column (D.nonNullable D.int8))-        <*> D.column (D.nonNullable D.jsonb)-        <*> D.column (D.nullable D.jsonb)-        <*> D.column (D.nullable D.uuid)-        <*> D.column (D.nullable D.uuid)-        <*> D.column (D.nonNullable D.timestamptz)---- | Shared decoder for a StreamInfo row (6 columns).-streamInfoRow :: D.Row StreamInfo-streamInfoRow =-    StreamInfo-        <$> (StreamId <$> D.column (D.nonNullable D.int8))-        <*> (StreamName <$> D.column (D.nonNullable D.text))-        <*> (StreamVersion <$> D.column (D.nonNullable D.int8))-        <*> D.column (D.nonNullable D.timestamptz)-        <*> D.column (D.nullable D.timestamptz)-        <*> (StreamVersion <$> D.column (D.nonNullable D.int8)) -- truncate_before---- | Encoder for stream read params: (stream_name, start_version, limit).-readStreamEncoder :: E.Params (Text, Int64, Int32)-readStreamEncoder =-    contrazip3-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.int8))-        (E.param (E.nonNullable E.int4))---- | Encoder for $all read params: (start_position, limit).-readAllEncoder :: E.Params (Int64, Int32)-readAllEncoder =-    contrazip2-        (E.param (E.nonNullable E.int8))-        (E.param (E.nonNullable E.int4))---- | Read events from a named stream in forward order.-readStreamForwardStmt :: Statement (Text, Int64, Int32) (Vector RecordedEvent)-readStreamForwardStmt =-    preparable-        readStreamForwardSQL-        readStreamEncoder-        (D.rowVector recordedEventRow)---- | Read events from a named stream in backward order.-readStreamBackwardStmt :: Statement (Text, Int64, Int32) (Vector RecordedEvent)-readStreamBackwardStmt =-    preparable-        readStreamBackwardSQL-        readStreamEncoder-        (D.rowVector recordedEventRow)---- | Read events from the global $all stream in forward order.-readAllForwardStmt :: Statement (Int64, Int32) (Vector RecordedEvent)-readAllForwardStmt =-    preparable-        readAllForwardSQL-        readAllEncoder-        (D.rowVector recordedEventRow)---- | Read events from the global $all stream in backward order.-readAllBackwardStmt :: Statement (Int64, Int32) (Vector RecordedEvent)-readAllBackwardStmt =-    preparable-        readAllBackwardSQL-        readAllEncoder-        (D.rowVector recordedEventRow)---- | Read the current tail of the global $all stream.-currentGlobalPositionStmt :: Statement () Int64-currentGlobalPositionStmt =-    preparable-        "SELECT stream_version FROM streams WHERE stream_id = 0"-        E.noParams-        (D.singleRow (D.column (D.nonNullable D.int8)))---- | Read the greatest position still visible in the global $all stream.-visibleGlobalHeadPositionStmt :: Statement () GlobalPosition-visibleGlobalHeadPositionStmt =-    preparable-        """-        SELECT COALESCE((-          SELECT stream_version-          FROM stream_events-          WHERE stream_id = 0-          ORDER BY stream_version DESC-          LIMIT 1-        ), 0)-        """-        E.noParams-        (D.singleRow (GlobalPosition <$> D.column (D.nonNullable D.int8)))---- | Metadata and the newest surviving originated global position in one snapshot.-getStreamWithHeadStmt :: Statement Text (Maybe (StreamInfo, Maybe GlobalPosition))-getStreamWithHeadStmt =-    preparable-        getStreamWithHeadSQL-        (E.param (E.nonNullable E.text))-        (D.rowMaybe ((,) <$> streamInfoRow <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))))--getStreamWithHeadSQL :: Text-getStreamWithHeadSQL =-    """-    SELECT s.stream_id, s.stream_name, s.stream_version,-           s.created_at, s.deleted_at, s.truncate_before,-           (SELECT se.stream_version-            FROM stream_events AS se-            WHERE se.stream_id = 0-              AND se.original_stream_id = s.stream_id-            ORDER BY se.stream_version DESC-            LIMIT 1) AS head_global_position-    FROM streams AS s-    WHERE s.stream_name = $1-    """---- | Get stream metadata by name.-getStreamStmt :: Statement Text (Maybe StreamInfo)-getStreamStmt =-    preparable-        getStreamSQL-        (E.param (E.nonNullable E.text))-        (D.rowMaybe streamInfoRow)---- | Point probe for whether an event exists in a live stream.-eventExistsInStreamStmt :: Statement (Text, UUID) Bool-eventExistsInStreamStmt =-    preparable-        """-        SELECT EXISTS (-          SELECT 1-          FROM stream_events se-          WHERE se.event_id = $2-            AND se.stream_id = (-              SELECT stream_id-              FROM streams-              WHERE stream_name = $1-                AND deleted_at IS NULL-            )-        )-        """-        (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.uuid)))-        (D.singleRow (D.column (D.nonNullable D.bool)))---- ------------------------------------------------------------------------------ Read SQL Templates--- -----------------------------------------------------------------------------{- | Read from a named stream in forward order.-Joins stream_events with events and streams, filters on stream_version >-start_version, hides the logically-truncated prefix (stream_version >=-streams.truncate_before), orders ascending, limits.-For stream reads, global_position is set to 0 (not available without $all join).--}-readStreamForwardSQL :: Text-readStreamForwardSQL =-    """-    SELECT e.event_id, e.event_type,-           se.stream_version, 0::bigint AS global_position,-           se.original_stream_id, se.original_stream_version,-           e.data, e.metadata, e.causation_id, e.correlation_id,-           e.created_at-    FROM stream_events se-    JOIN events e  ON e.event_id  = se.event_id-    JOIN streams s ON s.stream_id = se.stream_id-    WHERE s.stream_name = $1-      AND s.deleted_at IS NULL-      AND se.stream_version > $2-      AND se.stream_version >= s.truncate_before-    ORDER BY se.stream_version ASC-    LIMIT $3-    """--{- | Read from a named stream in backward order.-The start version is an exclusive upper bound. The interpreter maps-caller cursor 0 to 'maxBound' so "from latest" stays a Haskell API-sentinel and the SQL keeps a plain range predicate.--}-readStreamBackwardSQL :: Text-readStreamBackwardSQL =-    """-    SELECT e.event_id, e.event_type,-           se.stream_version, 0::bigint AS global_position,-           se.original_stream_id, se.original_stream_version,-           e.data, e.metadata, e.causation_id, e.correlation_id,-           e.created_at-    FROM stream_events se-    JOIN events e  ON e.event_id  = se.event_id-    JOIN streams s ON s.stream_id = se.stream_id-    WHERE s.stream_name = $1-      AND s.deleted_at IS NULL-      AND se.stream_version < $2-      AND se.stream_version >= s.truncate_before-    ORDER BY se.stream_version DESC-    LIMIT $3-    """--{- | Read from the global $all stream in forward order.-stream_id = 0 is the $all stream. stream_version on $all is the global position.-The global log deliberately ignores the per-stream @truncate_before@ marker:-logical truncation only hides events from ordered per-stream reads, never from-the @$all@ backbone that subscriptions and projections consume.--}-readAllForwardSQL :: Text-readAllForwardSQL =-    """-    SELECT e.event_id, e.event_type,-           se.stream_version, se.stream_version AS global_position,-           se.original_stream_id, se.original_stream_version,-           e.data, e.metadata, e.causation_id, e.correlation_id,-           e.created_at-    FROM stream_events se-    JOIN events e ON e.event_id = se.event_id-    WHERE se.stream_id = 0-      AND se.stream_version > $1-    ORDER BY se.stream_version ASC-    LIMIT $2-    """--{- | Read from the global $all stream in backward order.-The start position is an exclusive upper bound. The interpreter maps-caller cursor 0 to 'maxBound' so "from latest" stays a Haskell API-sentinel and the SQL keeps a plain range predicate.--}-readAllBackwardSQL :: Text-readAllBackwardSQL =-    """-    SELECT e.event_id, e.event_type,-           se.stream_version, se.stream_version AS global_position,-           se.original_stream_id, se.original_stream_version,-           e.data, e.metadata, e.causation_id, e.correlation_id,-           e.created_at-    FROM stream_events se-    JOIN events e ON e.event_id = se.event_id-    WHERE se.stream_id = 0-      AND se.stream_version < $1-    ORDER BY se.stream_version DESC-    LIMIT $2-    """---- | Get stream metadata by name.-getStreamSQL :: Text-getStreamSQL =-    """-    SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before-    FROM streams-    WHERE stream_name = $1-    """---- ------------------------------------------------------------------------------ Causation / Correlation Statements--- -----------------------------------------------------------------------------{- | Return every event whose @correlation_id@ equals the input, in ascending-@global_position@ order. Uses the @ix_events_correlation_id@ partial index.--The @stream_events se ON se.stream_id = 0@ join resolves each event to its-single row in the global @$all@ stream, which is also where the global-position is materialized as @stream_version@.--}-findByCorrelationStmt :: Statement UUID (Vector RecordedEvent)-findByCorrelationStmt =-    preparable-        findByCorrelationSQL-        (E.param (E.nonNullable E.uuid))-        (D.rowVector recordedEventRow)--findByCorrelationSQL :: Text-findByCorrelationSQL =-    """-    SELECT e.event_id, e.event_type,-           se.stream_version, se.stream_version AS global_position,-           se.original_stream_id, se.original_stream_version,-           e.data, e.metadata, e.causation_id, e.correlation_id,-           e.created_at-    FROM events e-    JOIN stream_events se-      ON se.event_id = e.event_id AND se.stream_id = 0-    WHERE e.correlation_id = $1-    ORDER BY se.stream_version ASC-    """--{- | Walk the causation graph forward from a seed event, returning the seed-itself and every event whose @causation_id@ chain leads back to it. The-result is ordered by ascending @global_position@.--The recursive CTE follows @causation_id@ links downstream: each child's-@causation_id@ equals a parent's @event_id@. Cost is @O(depth * log n)@-backed by the @ix_events_causation_id@ partial index.--}-findCausationDescendantsStmt :: Statement UUID (Vector RecordedEvent)-findCausationDescendantsStmt =-    preparable-        findCausationDescendantsSQL-        (E.param (E.nonNullable E.uuid))-        (D.rowVector recordedEventRow)--findCausationDescendantsSQL :: Text-findCausationDescendantsSQL =-    """-    WITH RECURSIVE chain (event_id, depth) AS (-        SELECT event_id, 0-        FROM events-        WHERE event_id = $1-      UNION ALL-        SELECT e.event_id, c.depth + 1-        FROM events e-        JOIN chain c ON e.causation_id = c.event_id-    )-    SELECT e.event_id, e.event_type,-           se.stream_version, se.stream_version AS global_position,-           se.original_stream_id, se.original_stream_version,-           e.data, e.metadata, e.causation_id, e.correlation_id,-           e.created_at-    FROM chain c-    JOIN events e ON e.event_id = c.event_id-    JOIN stream_events se-      ON se.event_id = e.event_id AND se.stream_id = 0-    ORDER BY se.stream_version ASC-    """--{- | Walk the causation graph backward from a seed event, returning the seed-itself and every ancestor reachable via @causation_id@. Result is ordered-by ascending @depth@ (the seed is depth 0, its immediate cause is depth 1,-etc.).--The recursive CTE follows @causation_id@ links upstream: for each row-@current@ already in the working set, its parent is the row of @events@-whose @event_id@ equals @current.causation_id@.--}-findCausationAncestorsStmt :: Statement UUID (Vector RecordedEvent)-findCausationAncestorsStmt =-    preparable-        findCausationAncestorsSQL-        (E.param (E.nonNullable E.uuid))-        (D.rowVector recordedEventRow)--findCausationAncestorsSQL :: Text-findCausationAncestorsSQL =-    """-    WITH RECURSIVE chain (event_id, depth) AS (-        SELECT event_id, 0-        FROM events-        WHERE event_id = $1-      UNION ALL-        SELECT parent.event_id, c.depth + 1-        FROM events parent-        JOIN events current ON parent.event_id = current.causation_id-        JOIN chain c ON c.event_id = current.event_id-    )-    SELECT e.event_id, e.event_type,-           se.stream_version, se.stream_version AS global_position,-           se.original_stream_id, se.original_stream_version,-           e.data, e.metadata, e.causation_id, e.correlation_id,-           e.created_at-    FROM chain c-    JOIN events e ON e.event_id = c.event_id-    JOIN stream_events se-      ON se.event_id = e.event_id AND se.stream_id = 0-    ORDER BY c.depth ASC-    """---- ------------------------------------------------------------------------------ Link Statements--- -----------------------------------------------------------------------------{- | Link existing events into a target stream (upsert semantics).-Returns @Nothing@ when the target stream exists and is soft-deleted (the-@DO UPDATE WHERE streams.deleted_at IS NULL@ filter rejects the upsert,-so the stream_upsert CTE produces no rows). The interpreter maps @Nothing@-to @StreamNotFound@ for symmetry with @appendAnyVersion@'s soft-deleted-behavior added in EP-1 F2.--}-linkToStreamStmt :: Statement (Vector UUID, Text) (Maybe LinkResult)-linkToStreamStmt =-    preparable-        linkToStreamSQL-        linkEncoder-        linkResultDecoder--linkEncoder :: E.Params (Vector UUID, Text)-linkEncoder =-    contrazip2-        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))-        (E.param (E.nonNullable E.text))--linkResultDecoder :: D.Result (Maybe LinkResult)-linkResultDecoder =-    D.rowMaybe $-        LinkResult-            <$> (StreamId <$> D.column (D.nonNullable D.int8))-            <*> (StreamVersion <$> D.column (D.nonNullable D.int8))--linkToStreamSQL :: Text-linkToStreamSQL =-    """-    WITH-      event_list AS (-        SELECT event_id, idx-        FROM unnest($1::uuid[]) WITH ORDINALITY AS t(event_id, idx)-      ),-      stream_upsert AS (-        INSERT INTO streams (stream_name, stream_version)-        VALUES ($2, (SELECT count(*) FROM event_list))-        ON CONFLICT (stream_name)-        DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM event_list)-          WHERE streams.deleted_at IS NULL-        RETURNING stream_id, stream_version - (SELECT count(*) FROM event_list) AS initial_version-      ),-      link_inserts AS (-        -- LEFT JOIN LATERAL surfaces missing-event rows as NULLs for original_*; the-        -- NOT NULL constraint on stream_events.original_stream_id then aborts the-        -- entire CTE, rolling back the stream_upsert's version bump. Before the F3-        -- fix this was a plain JOIN LATERAL, which silently dropped missing-event-        -- rows while still bumping stream_version → silent gap in the link target.-        INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-        SELECT el.event_id, su.stream_id, su.initial_version + el.idx,-               orig.original_stream_id, orig.original_stream_version-        FROM event_list el-        CROSS JOIN stream_upsert su-        LEFT JOIN LATERAL (-          SELECT se.original_stream_id, se.original_stream_version-          FROM stream_events se-          WHERE se.event_id = el.event_id AND se.stream_id <> 0-          LIMIT 1-        ) orig ON true-      )-    SELECT su.stream_id, su.initial_version + (SELECT count(*) FROM event_list)-    FROM stream_upsert su-    """---- ------------------------------------------------------------------------------ Category Read Statements--- -----------------------------------------------------------------------------{- | Read events from streams matching a category, in global position order.--Scans @ix_stream_events_all_by_category@ from @(category, startPosition)@ and-stops at the limit, so a poll's cost follows the rows it returns, not the-number of streams in the category (BUG-2). The @category@ column on @$all@-junction rows is written by the append statements (migration @0012@). Params:-@(startPosition, category, limit)@.--}-readCategoryForwardStmt :: Statement (Int64, Text, Int32) (Vector RecordedEvent)-readCategoryForwardStmt =-    preparable-        readCategoryForwardSQL-        readCategoryEncoder-        (D.rowVector recordedEventRow)--readCategoryEncoder :: E.Params (Int64, Text, Int32)-readCategoryEncoder =-    contrazip3-        (E.param (E.nonNullable E.int8))-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.int4))--readCategoryForwardSQL :: Text-readCategoryForwardSQL =-    """-    SELECT e.event_id, e.event_type,-           se.stream_version, se.stream_version AS global_position,-           se.original_stream_id, se.original_stream_version,-           e.data, e.metadata, e.causation_id, e.correlation_id,-           e.created_at-    FROM stream_events se-    JOIN events e ON e.event_id = se.event_id-    WHERE se.stream_id = 0-      AND se.category = $2-      AND se.stream_version > $1-    ORDER BY se.stream_version ASC-    LIMIT $3-    """---- ------------------------------------------------------------------------------ Consumer-Group Read Statements--- -----------------------------------------------------------------------------{- | Partition-filtered category read for one consumer-group member.--Mirrors 'readCategoryForwardStmt' but returns only events whose originating-stream is assigned to member @$3@ of a group of size @$4@. The assignment rule-(MasterPlan IP-1) hashes the originating stream's surrogate id and folds the-signed result into @[0, size)@:--@member_of(stream_id) = (((hashtextextended(stream_id::text, 0) % size) + size) % size)@--The read scans @ix_stream_events_all_by_category@ from-@(category, startPosition)@ and applies the predicate to-@se.original_stream_id@, which the index carries as an @INCLUDE@ column, so-other members' rows are skipped on index tuples. A poll touches about-@limit * size@ index entries at most, never one probe per stream in the-category (BUG-2). Params: @(startPosition, category, member, size, limit)@.--}-readCategoryForwardConsumerGroupStmt ::-    Statement (Int64, Text, Int32, Int32, Int32) (Vector RecordedEvent)-readCategoryForwardConsumerGroupStmt =-    preparable-        readCategoryForwardConsumerGroupSQL-        readCategoryConsumerGroupEncoder-        (D.rowVector recordedEventRow)--readCategoryConsumerGroupEncoder :: E.Params (Int64, Text, Int32, Int32, Int32)-readCategoryConsumerGroupEncoder =-    contrazip5-        (E.param (E.nonNullable E.int8))-        (E.param (E.nonNullable E.text))-        (E.param (E.nonNullable E.int4))-        (E.param (E.nonNullable E.int4))-        (E.param (E.nonNullable E.int4))--readCategoryForwardConsumerGroupSQL :: Text-readCategoryForwardConsumerGroupSQL =-    """-    SELECT e.event_id, e.event_type,-           se.stream_version, se.stream_version AS global_position,-           se.original_stream_id, se.original_stream_version,-           e.data, e.metadata, e.causation_id, e.correlation_id,-           e.created_at-    FROM stream_events se-    JOIN events e ON e.event_id = se.event_id-    WHERE se.stream_id = 0-      AND se.category = $2-      AND se.stream_version > $1-      AND (((hashtextextended(se.original_stream_id::text, 0) % $4) + $4) % $4) = $3-    ORDER BY se.stream_version ASC-    LIMIT $5-    """--{- | Partition-filtered @$all@ read for one consumer-group member.--Mirrors 'readAllForwardStmt' but returns only events whose originating stream is-assigned to member @$2@ of a group of size @$3@, using the same MasterPlan IP-1-rule as 'readCategoryForwardConsumerGroupStmt'. The predicate is applied to-@se.original_stream_id@ — the real originating stream of each @$all@ junction row-(never the reserved id 0 for normal appends). Params:-@(startPosition, member, size, limit)@.--}-readAllForwardConsumerGroupStmt ::-    Statement (Int64, Int32, Int32, Int32) (Vector RecordedEvent)-readAllForwardConsumerGroupStmt =-    preparable-        readAllForwardConsumerGroupSQL-        readAllConsumerGroupEncoder-        (D.rowVector recordedEventRow)--readAllConsumerGroupEncoder :: E.Params (Int64, Int32, Int32, Int32)-readAllConsumerGroupEncoder =-    contrazip4-        (E.param (E.nonNullable E.int8))-        (E.param (E.nonNullable E.int4))-        (E.param (E.nonNullable E.int4))-        (E.param (E.nonNullable E.int4))--readAllForwardConsumerGroupSQL :: Text-readAllForwardConsumerGroupSQL =-    """-    SELECT e.event_id, e.event_type,-           se.stream_version, se.stream_version AS global_position,-           se.original_stream_id, se.original_stream_version,-           e.data, e.metadata, e.causation_id, e.correlation_id,-           e.created_at-    FROM stream_events se-    JOIN events e ON e.event_id = se.event_id-    WHERE se.stream_id = 0-      AND se.stream_version > $1-      AND (((hashtextextended(se.original_stream_id::text, 0) % $3) + $3) % $3) = $2-    ORDER BY se.stream_version ASC-    LIMIT $4-    """---- ------------------------------------------------------------------------------ Lifecycle Statements--- ------------------------------------------------------------------------------- | Soft-delete a stream by setting deleted_at. Returns Nothing if stream doesn't exist or is already deleted.-softDeleteStreamStmt :: Statement Text (Maybe StreamId)-softDeleteStreamStmt =-    preparable-        softDeleteStreamSQL-        (E.param (E.nonNullable E.text))-        (D.rowMaybe (StreamId <$> D.column (D.nonNullable D.int8)))---- | Undelete a soft-deleted stream by clearing deleted_at. Returns Nothing if stream doesn't exist or is not deleted.-undeleteStreamStmt :: Statement Text (Maybe StreamId)-undeleteStreamStmt =-    preparable-        undeleteStreamSQL-        (E.param (E.nonNullable E.text))-        (D.rowMaybe (StreamId <$> D.column (D.nonNullable D.int8)))--{- | Set a stream's logical truncate-before marker. Returns Nothing if the-stream does not exist or is soft-deleted.--}-setStreamTruncateBeforeStmt :: Statement (Text, Int64) (Maybe StreamId)-setStreamTruncateBeforeStmt =-    preparable-        setStreamTruncateBeforeSQL-        (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int8)))-        (D.rowMaybe (StreamId <$> D.column (D.nonNullable D.int8)))--{- | Look up a stream's id by name. Returns Nothing if the stream does not exist.-Used as the first step in hard-delete; subsequent steps key off the id rather-than the name so a single resolution is reused.--}-findStreamIdStmt :: Statement Text (Maybe Int64)-findStreamIdStmt =-    preparable-        "SELECT stream_id FROM streams WHERE stream_name = $1"-        (E.param (E.nonNullable E.text))-        (D.rowMaybe (D.column (D.nonNullable D.int8)))--{- | Resolve a batch of surrogate stream ids to their (id, name) pairs in one-round trip. Ids that do not name an existing stream simply produce no row, so-the caller's 'Data.Map.Strict.Map' omits them. Surfaced as-'Kiroku.Store.Read.lookupStreamNames'.--}-lookupStreamNamesStmt :: Statement [Int64] (Vector (Int64, Text))-lookupStreamNamesStmt =-    preparable-        "SELECT stream_id, stream_name FROM streams WHERE stream_id = ANY($1::bigint[])"-        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.int8))))-        ( D.rowVector-            ( (,)-                <$> D.column (D.nonNullable D.int8)-                <*> D.column (D.nonNullable D.text)-            )-        )--{- | Delete the @$all@ junction rows for events that originated in the target-stream, returning exactly those originated event ids.--Served by @ix_stream_events_all_by_origin@, whose partial predicate is-@stream_id = 0@. Every event that originated in a stream has one @$all@ row, so-this indexed delete yields the complete originated-event set without scanning all-of @stream_events@.--The caller then passes the returned ids to 'deleteJunctionsByEventIdsStmt' to-remove the remaining home/link junction rows for those originated events.--}-deleteAllRowsForOriginStmt :: Statement Int64 (Vector UUID)-deleteAllRowsForOriginStmt =-    preparable-        """-        DELETE FROM stream_events-        WHERE original_stream_id = $1-          AND stream_id = 0-        RETURNING event_id-        """-        (E.param (E.nonNullable E.int8))-        (D.rowVector (D.column (D.nonNullable D.uuid)))--{- | Delete all remaining junction rows for the given event ids.--Served by the @stream_events@ primary key @(event_id, stream_id)@. This removes-the originated events' home rows and any link rows in other streams after-'deleteAllRowsForOriginStmt' has removed their @$all@ rows.--}-deleteJunctionsByEventIdsStmt :: Statement (Vector UUID) ()-deleteJunctionsByEventIdsStmt =-    preparable-        """-        DELETE FROM stream_events-        WHERE event_id = ANY($1::uuid[])-        """-        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))-        D.noResult--{- | Delete junction rows whose visible stream is the target stream, returning-the affected event ids.--Served by @ix_stream_events_stream_version@ (or the unique replacement-@ux_stream_events_stream_version@ after EP-5 M3) on @(stream_id,-stream_version)@. After 'deleteJunctionsByEventIdsStmt' this matches only-events that originated elsewhere and were linked into the deleted stream. Those-events normally survive; returning them lets the same orphan check make the-decision instead of relying on that assumption.--}-deleteStreamOwnJunctionsStmt :: Statement Int64 (Vector UUID)-deleteStreamOwnJunctionsStmt =-    preparable-        """-        DELETE FROM stream_events-        WHERE stream_id = $1-        RETURNING event_id-        """-        (E.param (E.nonNullable E.int8))-        (D.rowVector (D.column (D.nonNullable D.uuid)))--{- | Delete dead-letter rows for candidate events that have become orphaned.--Served by @ix_dead_letters_event_id@. The @NOT EXISTS@ predicate mirrors-'deleteOrphanedEventsStmt', so dead letters are purged exactly for events whose-payload rows are about to be removed while dead letters for surviving linked-in-events remain.--Must be called after the junction deletes and before 'deleteOrphanedEventsStmt'-within the same transaction so the @NOT EXISTS@ subquery sees the post-delete-state of @stream_events@ and the @dead_letters.event_id@ foreign key remains-satisfied when orphan event payloads are deleted.--}-deleteDeadLettersForOrphanedEventsStmt :: Statement (Vector UUID) ()-deleteDeadLettersForOrphanedEventsStmt =-    preparable-        """-        DELETE FROM dead_letters dl-        WHERE dl.event_id = ANY($1::uuid[])-          AND NOT EXISTS (-            SELECT 1 FROM stream_events se-            WHERE se.event_id = dl.event_id-          )-        """-        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))-        D.noResult--{- | Delete event payloads from the @events@ table for the given event ids,-but only those whose junction rows have all been removed. Events that still-have any surviving @stream_events@ row are preserved (they remain visible from-their other homes).--The first implementation tried to inline orphan-event deletion with a-data-modifying CTE, but PostgreSQL §7.8.2 specifies that data-modifying CTEs run-against the same snapshot, so the @NOT EXISTS@ subquery on stream_events saw the-pre-delete state and never deleted any events. Splitting into multiple-statements within the same hasql-transaction lets each statement see previous-statements' effects.--Must be called after 'deleteDeadLettersForOrphanedEventsStmt' within the same-transaction so the @NOT EXISTS@ subquery sees the post-delete state of-@stream_events@ and the @dead_letters.event_id@ foreign key is already clear.--}-deleteOrphanedEventsStmt :: Statement (Vector UUID) ()-deleteOrphanedEventsStmt =-    preparable-        """-        DELETE FROM events-        WHERE event_id = ANY($1::uuid[])-          AND NOT EXISTS (-            SELECT 1 FROM stream_events-            WHERE stream_events.event_id = events.event_id-          )-        """-        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))-        D.noResult--{- | Delete the @streams@ row for the given stream id. Used as the final step-of hard-delete after junction rows and orphan events are cleared.--}-deleteStreamRowStmt :: Statement Int64 ()-deleteStreamRowStmt =-    preparable-        "DELETE FROM streams WHERE stream_id = $1"-        (E.param (E.nonNullable E.int8))-        D.noResult--{- | Pre-acquire row locks on the named streams in deterministic (stream_id)-order. Used by AppendMultiStream to avoid row-lock deadlocks between-concurrent multi-stream transactions that touch overlapping streams in-different orders.--\$all is intentionally NOT included in the pre-lock — its row lock is-acquired by each per-stream CTE inside the transaction, after the source-stream's row lock. See EP-1 F4.--Streams that don't yet exist (NoStream variant on a fresh stream) are not-matched by the WHERE clause, so they aren't pre-locked here. Concurrent-INSERTs of a fresh stream do serialize on the unique index on-@stream_name@, but that serialization does /not/ preserve the-source-before-$all acquisition order this pre-lock exists to establish,-and a multi-stream append can still deadlock against a concurrent-single-stream append:--  * multi locks stream A, then @$all@ (both inside A's CTE), then stream B;-  * a single-stream append to the still-fresh B locks B, then wants @$all@.--Each then waits on the other. PostgreSQL detects it and aborts one side-with @40P01@; the interpreter retries the append once, and a repeated-conflict reaches the caller as-'Kiroku.Store.Error.TransientTransactionFailure', which is documented-retryable. Eliminating the cycle would mean establishing every source-lock before any @$all@ lock, including for streams that do not exist yet-— tracked as IR-7, deliberately not done here because it is a throughput-question that needs benchmarking, not a correctness gap.--}-lockStreamsForMultiStmt :: Statement (Vector Text) ()-lockStreamsForMultiStmt =-    preparable-        """-        SELECT 1 FROM streams-        WHERE stream_name = ANY($1::text[])-        ORDER BY stream_id-        FOR UPDATE-        """-        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))-        D.noResult---- ------------------------------------------------------------------------------ Lifecycle SQL Templates--- -----------------------------------------------------------------------------softDeleteStreamSQL :: Text-softDeleteStreamSQL =-    """-    UPDATE streams-    SET deleted_at = now()-    WHERE stream_name = $1-      AND deleted_at IS NULL-    RETURNING stream_id-    """--undeleteStreamSQL :: Text-undeleteStreamSQL =-    """-    UPDATE streams-    SET deleted_at = NULL-    WHERE stream_name = $1-      AND deleted_at IS NOT NULL-    RETURNING stream_id-    """--{- | Set the logical truncate-before marker on a live stream. Soft-deleted-streams (deleted_at IS NOT NULL) match no row and return Nothing, matching-the other lifecycle statements.--}-setStreamTruncateBeforeSQL :: Text-setStreamTruncateBeforeSQL =-    """-    UPDATE streams-    SET truncate_before = $2-    WHERE stream_name = $1-      AND deleted_at IS NULL-    RETURNING stream_id-    """---- ------------------------------------------------------------------------------ Checkpoint Statements--- ------------------------------------------------------------------------------- | Read the last-seen global position for a subscription.-getCheckpointStmt :: Statement Text (Maybe Int64)-getCheckpointStmt =-    preparable-        getCheckpointSQL-        (E.param (E.nonNullable E.text))-        (D.rowMaybe (D.column (D.nonNullable D.int8)))---- | Upsert a checkpoint: insert or update the last-seen position.-saveCheckpointStmt :: Statement (Text, Int64) ()-saveCheckpointStmt =-    preparable-        saveCheckpointSQL-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int8))-        )-        D.noResult--getCheckpointSQL :: Text-getCheckpointSQL =-    """-    SELECT last_seen-    FROM subscriptions-    WHERE subscription_name = $1-    """--saveCheckpointSQL :: Text-saveCheckpointSQL =-    """-    INSERT INTO subscriptions (subscription_name, last_seen, updated_at)-    VALUES ($1, $2, now())-    ON CONFLICT (subscription_name, consumer_group_member)-    DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now()-    """---- | Read the last-seen position for one consumer-group member of a subscription.-getCheckpointMemberStmt :: Statement (Text, Int32) (Maybe Int64)-getCheckpointMemberStmt =-    preparable-        getCheckpointMemberSQL-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-        )-        (D.rowMaybe (D.column (D.nonNullable D.int8)))--{- | Upsert the last-seen position for one consumer-group member, keyed on the-composite @(subscription_name, consumer_group_member)@ unique index. Uses the-same @GREATEST(...)@ monotonicity as 'saveCheckpointStmt' so a save never moves-a member's checkpoint backward.--}-saveCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32, Text, Maybe Text) ()-saveCheckpointMemberStmt =-    preparable-        saveCheckpointMemberSQL-        ( contrazip6-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.text))-            (E.param (E.nullable E.text))-        )-        D.noResult---- | Bound targets use a fixed kind rather than encoding a constant per save.-saveAllCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32) ()-saveAllCheckpointMemberStmt =-    preparable-        (saveCheckpointMemberSQLWith "'all', NULL")-        (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)))-        D.noResult--saveCategoryCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32, Text) ()-saveCategoryCheckpointMemberStmt =-    preparable-        (saveCheckpointMemberSQLWith "'category', $5")-        (contrazip5 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.text)))-        D.noResult--getCheckpointMemberSQL :: Text-getCheckpointMemberSQL =-    """-    SELECT last_seen-    FROM subscriptions-    WHERE subscription_name = $1-      AND consumer_group_member = $2-    """--saveCheckpointMemberSQL :: Text-saveCheckpointMemberSQL = saveCheckpointMemberSQLWith "$5, $6"--saveCheckpointMemberSQLWith :: Text -> Text-saveCheckpointMemberSQLWith binding =-    "INSERT INTO subscriptions (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category) VALUES ($1, $2, $3, now(), $4, "-        <> binding-        <> ") "-        <> """-           ON CONFLICT (subscription_name, consumer_group_member)-           DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now(), consumer_group_size = EXCLUDED.consumer_group_size, target_kind = EXCLUDED.target_kind, target_category = EXCLUDED.target_category-           """---- ------------------------------------------------------------------------------ Dead-letter Statements--- ------------------------------------------------------------------------------- | Parameters for 'insertDeadLetterAndCheckpointStmt'.-data DeadLetterParams = DeadLetterParams-    { dlSubscriptionName :: !Text-    , dlMember :: !Int32-    , dlTargetKind :: !Text-    , dlTargetCategory :: !(Maybe Text)-    , dlGroupSize :: !Int32-    , dlGlobalPosition :: !Int64-    , dlEventId :: !UUID-    , dlReason :: !Value-    -- ^ structured JSONB reason (@reason@ column)-    , dlReasonSummary :: !Text-    -- ^ operator-facing summary (@reason_summary@ column)-    , dlAttemptCount :: !Int32-    }-    deriving stock (Show, Generic)---- | A row of @kiroku.dead_letters@, returned by 'readDeadLettersStmt'.-data DeadLetterRecord = DeadLetterRecord-    { deadLetterGlobalPosition :: !Int64-    , deadLetterEventId :: !UUID-    , deadLetterReason :: !Value-    , deadLetterReasonSummary :: !Text-    , deadLetterAttemptCount :: !Int32-    , deadLetterCreatedAt :: !UTCTime-    }-    deriving stock (Show, Generic)--deadLetterParamsEncoder :: E.Params DeadLetterParams-deadLetterParamsEncoder =-    ((^. #dlSubscriptionName) >$< E.param (E.nonNullable E.text))-        <> ((^. #dlMember) >$< E.param (E.nonNullable E.int4))-        <> ((^. #dlGlobalPosition) >$< E.param (E.nonNullable E.int8))-        <> ((^. #dlEventId) >$< E.param (E.nonNullable E.uuid))-        <> ((^. #dlReason) >$< E.param (E.nonNullable E.jsonb))-        <> ((^. #dlReasonSummary) >$< E.param (E.nonNullable E.text))-        <> ((^. #dlAttemptCount) >$< E.param (E.nonNullable E.int4))-        <> ((^. #dlGroupSize) >$< E.param (E.nonNullable E.int4))-        <> ((^. #dlTargetKind) >$< E.param (E.nonNullable E.text))-        <> ((^. #dlTargetCategory) >$< E.param (E.nullable E.text))--{- | Atomically record an event in @kiroku.dead_letters@ and advance the-subscription's checkpoint past it, in a single statement.--The dead-letter insert and the checkpoint upsert run in one round trip: a-data-modifying CTE performs the insert (idempotent via @ON CONFLICT DO NOTHING@-on the natural key), and the final statement upserts the checkpoint with the-same @GREATEST(...)@ monotonicity as 'saveCheckpointMemberStmt'. If the insert-fails the whole statement aborts and the checkpoint does not advance, satisfying-the dead-letter atomicity criterion. The checkpoint reuses @$1@/@$2@/@$3@-(name / member / global_position) from the dead-letter params.--}-insertDeadLetterAndCheckpointStmt :: Statement DeadLetterParams ()-insertDeadLetterAndCheckpointStmt =-    preparable-        insertDeadLetterAndCheckpointSQL-        deadLetterParamsEncoder-        D.noResult--insertDeadLetterAndCheckpointSQL :: Text-insertDeadLetterAndCheckpointSQL =-    """-    WITH dl AS (-      INSERT INTO dead_letters-        (subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count)-      VALUES ($1, $2, $3, $4, $5, $6, $7)-      ON CONFLICT (subscription_name, consumer_group_member, global_position, event_id) DO NOTHING-    )-    INSERT INTO subscriptions (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category)-    VALUES ($1, $2, $3, now(), $8, $9, $10)-    ON CONFLICT (subscription_name, consumer_group_member)-    DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now(), consumer_group_size = EXCLUDED.consumer_group_size, target_kind = EXCLUDED.target_kind, target_category = EXCLUDED.target_category-    """---- | Read the dead letters recorded for one subscription member, newest first.-readDeadLettersStmt :: Statement (Text, Int32) (Vector DeadLetterRecord)-readDeadLettersStmt =-    preparable-        readDeadLettersSQL-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-        )-        (D.rowVector deadLetterRecordRow)--deadLetterRecordRow :: D.Row DeadLetterRecord-deadLetterRecordRow =-    DeadLetterRecord-        <$> D.column (D.nonNullable D.int8)-        <*> D.column (D.nonNullable D.uuid)-        <*> D.column (D.nonNullable D.jsonb)-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.int4)-        <*> D.column (D.nonNullable D.timestamptz)--readDeadLettersSQL :: Text-readDeadLettersSQL =-    """-    SELECT global_position, event_id, reason, reason_summary, attempt_count, created_at-    FROM dead_letters-    WHERE subscription_name = $1-      AND consumer_group_member = $2-    ORDER BY global_position DESC, dead_letter_id DESC-    """---- Catalog statements have a finite family of prepared shapes. Upper bounds--- are deliberately checked after the ordered LIMIT, keeping generic plans--- from bitmap-scanning and sorting an entire matching prefix.-data StreamRangeBound = InclusiveLower | ExclusiveLower | ExactName-    deriving stock (Eq, Show)--streamColumns :: Text-streamColumns = "stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before"--streamRangeSQL :: StreamRangeBound -> Text -> Text -> Text -> Text-streamRangeSQL bound lower upper limit =-    "SELECT * FROM (SELECT "-        <> streamColumns-        <> " FROM streams WHERE stream_id <> 0 AND stream_name COLLATE \"C\" "-        <> operator-        <> " "-        <> lower-        <> " ORDER BY stream_name COLLATE \"C\" LIMIT "-        <> limit-        <> ") bounded WHERE ("-        <> upper-        <> "::text IS NULL OR stream_name COLLATE \"C\" < "-        <> upper-        <> ")"-  where-    operator = case bound of InclusiveLower -> ">="; ExclusiveLower -> ">"; ExactName -> "="--listStreamsRangeStmt :: StreamRangeBound -> Statement (Text, Maybe Text, Int32) (Vector StreamInfo)-listStreamsRangeStmt bound =-    preparable-        (streamRangeSQL bound "$1" "$2" "$3" <> " ORDER BY stream_name COLLATE \"C\"")-        (contrazip3 (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int4)))-        (D.rowVector streamInfoRow)--listStreamsPairStmt :: StreamRangeBound -> StreamRangeBound -> Statement (Text, Maybe Text, Text, Maybe Text, Int32) (Vector StreamInfo)-listStreamsPairStmt first second =-    preparable-        ( "SELECT * FROM (("-            <> streamRangeSQL first "$1" "$2" "$5"-            <> ") UNION ALL ("-            <> streamRangeSQL second "$3" "$4" "$5"-            <> ")) matching ORDER BY stream_name COLLATE \"C\" LIMIT $5"-        )-        ( contrazip5-            (E.param (E.nonNullable E.text))-            (E.param (E.nullable E.text))-            (E.param (E.nonNullable E.text))-            (E.param (E.nullable E.text))-            (E.param (E.nonNullable E.int4))-        )-        (D.rowVector streamInfoRow)--{- | One statement and one checkout, including the bare-category/descendant union.-All text is encoded as parameters; only closed operator choices build SQL.--}-listStreamsSession :: Maybe CategoryName -> Maybe Text -> Maybe StreamName -> BrowsePageSize -> Session.Session (Vector StreamInfo)-listStreamsSession category prefix after page = case ranges of-    [] -> pure mempty-    [(kind, lower, upper)] -> Session.statement (lower, upper, limit) (listStreamsRangeStmt kind)-    [(kind, lower, upper), (kind2, lower2, upper2)] ->-        Session.statement (lower, upper, lower2, upper2, limit) (listStreamsPairStmt kind kind2)-    _ -> error "catalog range invariant: at most two disjoint ranges"-  where-    limit = browsePageSizeValue page-    cursor = fmap (\(StreamName value) -> value) after-    ranges = case category of-        Nothing -> maybe [] pure (interval "" Nothing)-        Just (CategoryName value)-            | T.any (== '-') value -> []-            | otherwise -> bare value <> maybe [] pure (interval (value <> "-") (prefixEnd (value <> "-")))-    bare value-        | maybe True (`T.isPrefixOf` value) prefix && maybe True (< value) cursor = [(ExactName, value, Nothing)]-        | otherwise = []-    interval start end =-        let lower = max start (maybe "" (\value -> value) prefix)-            upper = minimumEnd end (prefix >>= prefixEnd)-            (kind, seek) = case cursor of-                Just value | value >= lower -> (ExclusiveLower, value)-                _ -> (InclusiveLower, lower)-         in if maybe False (<= seek) upper then Nothing else Just (kind, seek, upper)-    minimumEnd Nothing b = b-    minimumEnd a Nothing = a-    minimumEnd (Just a) (Just b) = Just (min a b)---- Valid UTF-8 byte order follows Unicode scalar order. The all-maximum and--- empty prefixes have no finite upper bound; PostgreSQL text has no NUL.-prefixEnd :: Text -> Maybe Text-prefixEnd value = case T.unsnoc value of-    Nothing -> Nothing-    Just (initial, lastChar)-        | ord lastChar == 0x10ffff -> prefixEnd initial-        | otherwise -> Just (T.snoc initial (chr (if ord lastChar + 1 == 0xd800 then 0xe000 else ord lastChar + 1)))---- First and later category pages have separate cursor shapes. Category order--- retains the deployment collation and its existing category index.-listCategoriesStmt :: Bool -> Statement (Maybe Text, Int32) (Vector Text)-listCategoriesStmt after =-    preparable-        query-        (contrazip2 (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int4)))-        (D.rowVector (D.column (D.nonNullable D.text)))-  where-    first = if after then "AND s.category > $1" else "AND $1::text IS NULL"-    query =-        "WITH RECURSIVE next_category AS (SELECT (SELECT s.category FROM streams s WHERE s.stream_id <> 0 "-            <> first-            <> " ORDER BY s.category LIMIT 1) AS category UNION ALL SELECT (SELECT s.category FROM streams s "-            <> "WHERE s.stream_id <> 0 AND s.category > n.category ORDER BY s.category LIMIT 1) "-            <> "FROM next_category n WHERE n.category IS NOT NULL) SELECT category FROM next_category "-            <> "WHERE category IS NOT NULL LIMIT $2"--getEventStmt :: Statement UUID (Maybe RecordedEvent)-getEventStmt =-    preparable-        "SELECT e.event_id,e.event_type,se.stream_version,se.stream_version AS global_position, se.original_stream_id,se.original_stream_version,e.data,e.metadata,e.causation_id,e.correlation_id,e.created_at FROM events e JOIN stream_events se ON se.event_id=e.event_id AND se.stream_id=0 WHERE e.event_id=$1"-        (E.param (E.nonNullable E.uuid))-        (D.rowMaybe recordedEventRow)+module Kiroku.Store.SQL+  ( -- * Append statements+    AppendParams (..),+    appendParamsEncoder,+    appendResultDecoder,+    appendExpectedVersion,+    appendStreamExists,+    appendNoStream,+    appendAnyVersion,++    -- * Link statements+    linkToStreamStmt,++    -- * Read statements+    readStreamForwardStmt,+    readStreamBackwardStmt,+    readAllForwardStmt,+    readAllBackwardStmt,+    readCategoryForwardStmt,+    readCategoryEncoder,+    NamespaceWindow (..),+    readNamespaceWindowStmt,+    readNamespaceGroupWindowStmt,+    getStreamStmt,+    getStreamWithHeadStmt,+    eventExistsInStreamStmt,+    lookupStreamNamesStmt,+    listStreamsSession,+    StreamRangeBound (..),+    listStreamsRangeStmt,+    listStreamsPairStmt,+    listCategoriesStmt,+    getEventStmt,+    currentGlobalPositionStmt,+    visibleGlobalHeadPositionStmt,++    -- * Consumer-group read statements+    readCategoryForwardConsumerGroupStmt,+    readCategoryConsumerGroupEncoder,+    readAllForwardConsumerGroupStmt,++    -- * Row decoders+    recordedEventRow,++    -- * Causation / correlation statements+    findByCorrelationStmt,+    findCausationDescendantsStmt,+    findCausationAncestorsStmt,++    -- * Lifecycle statements+    softDeleteStreamStmt,+    undeleteStreamStmt,+    setStreamTruncateBeforeStmt,++    -- * Hard-delete statements (used in sequence inside one transaction)+    findStreamIdStmt,+    deleteAllRowsForOriginStmt,+    deleteJunctionsByEventIdsStmt,+    deleteStreamOwnJunctionsStmt,+    deleteDeadLettersForOrphanedEventsStmt,+    deleteOrphanedEventsStmt,+    deleteStreamRowStmt,++    -- * Multi-stream pre-lock (avoids row-lock deadlocks)+    lockStreamsForMultiStmt,++    -- * Checkpoint statements+    getCheckpointStmt,+    saveCheckpointStmt,+    getCheckpointMemberStmt,+    saveCheckpointMemberStmt,+    saveAllCheckpointMemberStmt,+    saveCategoryCheckpointMemberStmt,+    saveNamespaceCheckpointMemberStmt,++    -- * Dead-letter statements+    DeadLetterParams (..),+    DeadLetterRecord (..),+    insertDeadLetterAndCheckpointStmt,+    readDeadLettersStmt,+    listSubscriptionDeadLettersStmt,+    listSubscriptionDeadLettersFromStartStmt,+    listSubscriptionMemberDeadLettersStmt,+    listSubscriptionMemberDeadLettersFromStartStmt,+  )+where++import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5, contrazip6)+import Control.Lens ((^.))+import Data.Aeson (Value)+import Data.Char (chr, ord)+import Data.Functor.Contravariant ((>$<))+import Data.Generics.Labels ()+import Data.Int (Int32, Int64)+import Data.Maybe (mapMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Time (UTCTime)+import Data.UUID (UUID)+import Data.Vector (Vector)+import Data.Vector qualified as V+import GHC.Generics (Generic)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Kiroku.Store.Subscription.DeadLetter.SQL (listSubscriptionDeadLettersFromStartStmt, listSubscriptionDeadLettersStmt, listSubscriptionMemberDeadLettersFromStartStmt, listSubscriptionMemberDeadLettersStmt)+import Kiroku.Store.Types++-- | Parameters for append CTE variants (the 7 parallel arrays + stream name).+data AppendParams = AppendParams+  { eventIds :: !(Vector UUID),+    eventTypes :: !(Vector Text),+    causationIds :: !(Vector (Maybe UUID)),+    correlationIds :: !(Vector (Maybe UUID)),+    payloads :: !(Vector Value),+    metadatas :: !(Vector (Maybe Value)),+    createdAts :: !(Vector UTCTime),+    streamName :: !Text+  }+  deriving stock (Show, Generic)++-- | Encoder for the common 8 parameters shared by all append variants.+appendParamsEncoder :: E.Params AppendParams+appendParamsEncoder =+  ((^. #eventIds) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    <> ((^. #eventTypes) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+    <> ((^. #causationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))+    <> ((^. #correlationIds) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.uuid))))+    <> ((^. #payloads) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.jsonb))))+    <> ((^. #metadatas) >$< E.param (E.nonNullable (E.foldableArray (E.nullable E.jsonb))))+    <> ((^. #createdAts) >$< E.param (E.nonNullable (E.foldableArray (E.nonNullable E.timestamptz))))+    <> ((^. #streamName) >$< E.param (E.nonNullable E.text))++-- | Encoder for append_expected_version: base params + expected version (Int64).+appendExpectedEncoder :: E.Params (AppendParams, Int64)+appendExpectedEncoder =+  contrazip2 appendParamsEncoder (E.param (E.nonNullable E.int8))++-- | Decoder for append results: stream_id, stream_version, global_position.+-- Returns Nothing if the CTE produced 0 rows (version conflict / stream not found).+appendResultDecoder :: D.Result (Maybe AppendResult)+appendResultDecoder =+  D.rowMaybe $+    AppendResult+      <$> (StreamId <$> D.column (D.nonNullable D.int8))+      <*> (StreamVersion <$> D.column (D.nonNullable D.int8))+      <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))++-- | Append with exact expected version (optimistic concurrency).+-- Returns Nothing if the stream doesn't exist or version doesn't match.+appendExpectedVersion :: Statement (AppendParams, Int64) (Maybe AppendResult)+appendExpectedVersion =+  preparable+    appendExpectedVersionSQL+    appendExpectedEncoder+    appendResultDecoder++-- | Append to an existing stream at any version.+-- Returns Nothing if the stream doesn't exist.+appendStreamExists :: Statement AppendParams (Maybe AppendResult)+appendStreamExists =+  preparable+    appendStreamExistsSQL+    appendParamsEncoder+    appendResultDecoder++-- | Append to a new stream (must not already exist).+-- Returns Nothing if the stream already exists.+appendNoStream :: Statement AppendParams (Maybe AppendResult)+appendNoStream =+  preparable+    appendNoStreamSQL+    appendParamsEncoder+    appendResultDecoder++-- | Append to a stream, creating it if it doesn't exist.+-- Should always return Just (unless duplicate event ID constraint violation).+appendAnyVersion :: Statement AppendParams (Maybe AppendResult)+appendAnyVersion =+  preparable+    appendAnyVersionSQL+    appendParamsEncoder+    appendResultDecoder++-- ---------------------------------------------------------------------------+-- SQL Templates+-- ---------------------------------------------------------------------------++-- | CTE with exact version check: UPDATE streams WHERE stream_version = $9.+appendExpectedVersionSQL :: Text+appendExpectedVersionSQL =+  """+  WITH+    new_events AS (+      SELECT *+      FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+      WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+    ),+    stream_update AS (+      UPDATE streams+      SET stream_version = stream_version + (SELECT count(*) FROM new_events)+      WHERE stream_name = $8+        AND stream_version = $9+        AND deleted_at IS NULL+      RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version+    ),+    inserted_events AS (+      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+      SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+      FROM new_events+      WHERE EXISTS (SELECT 1 FROM stream_update)+      ORDER BY idx+    ),+    source_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+      FROM new_events ne+      CROSS JOIN stream_update su+    ),+    all_update AS (+      UPDATE streams+      SET stream_version = stream_version + (SELECT count(*) FROM new_events)+      WHERE stream_id = 0+        AND EXISTS (SELECT 1 FROM stream_update)+      RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+    ),+    all_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+      SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+      FROM new_events ne+      CROSS JOIN all_update au+      CROSS JOIN stream_update su+    )+  SELECT su.stream_id,+         su.initial_version + (SELECT count(*) FROM new_events),+         au.initial_global_version + (SELECT count(*) FROM new_events)+  FROM stream_update su+  CROSS JOIN all_update au+  """++-- | CTE without version check: UPDATE streams WHERE stream_name = $8 (no $9).+appendStreamExistsSQL :: Text+appendStreamExistsSQL =+  """+  WITH+    new_events AS (+      SELECT *+      FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+      WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+    ),+    stream_update AS (+      UPDATE streams+      SET stream_version = stream_version + (SELECT count(*) FROM new_events)+      WHERE stream_name = $8+        AND deleted_at IS NULL+      RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version+    ),+    inserted_events AS (+      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+      SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+      FROM new_events+      WHERE EXISTS (SELECT 1 FROM stream_update)+      ORDER BY idx+    ),+    source_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+      FROM new_events ne+      CROSS JOIN stream_update su+    ),+    all_update AS (+      UPDATE streams+      SET stream_version = stream_version + (SELECT count(*) FROM new_events)+      WHERE stream_id = 0+        AND EXISTS (SELECT 1 FROM stream_update)+      RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+    ),+    all_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+      SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+      FROM new_events ne+      CROSS JOIN all_update au+      CROSS JOIN stream_update su+    )+  SELECT su.stream_id,+         su.initial_version + (SELECT count(*) FROM new_events),+         au.initial_global_version + (SELECT count(*) FROM new_events)+  FROM stream_update su+  CROSS JOIN all_update au+  """++-- | CTE for new stream creation: INSERT INTO streams ... ON CONFLICT DO NOTHING.+appendNoStreamSQL :: Text+appendNoStreamSQL =+  """+  WITH+    new_events AS (+      SELECT *+      FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+      WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+    ),+    stream_insert AS (+      INSERT INTO streams (stream_name, stream_version)+      VALUES ($8, (SELECT count(*) FROM new_events))+      ON CONFLICT (stream_name) DO NOTHING+      RETURNING stream_id, category, 0::bigint AS initial_version+    ),+    inserted_events AS (+      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+      SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+      FROM new_events+      WHERE EXISTS (SELECT 1 FROM stream_insert)+      ORDER BY idx+    ),+    source_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT ne.event_id, si.stream_id, si.initial_version + ne.idx, si.stream_id, si.initial_version + ne.idx+      FROM new_events ne+      CROSS JOIN stream_insert si+    ),+    all_update AS (+      UPDATE streams+      SET stream_version = stream_version + (SELECT count(*) FROM new_events)+      WHERE stream_id = 0+        AND EXISTS (SELECT 1 FROM stream_insert)+      RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+    ),+    all_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+      SELECT ne.event_id, 0, au.initial_global_version + ne.idx, si.stream_id, si.initial_version + ne.idx, si.category+      FROM new_events ne+      CROSS JOIN all_update au+      CROSS JOIN stream_insert si+    )+  SELECT si.stream_id,+         si.initial_version + (SELECT count(*) FROM new_events),+         au.initial_global_version + (SELECT count(*) FROM new_events)+  FROM stream_insert si+  CROSS JOIN all_update au+  """++-- | CTE for create-or-append using INSERT ... ON CONFLICT DO UPDATE (upsert).+-- A plain INSERT + separate UPDATE in the same CTE won't work because+-- data-modifying CTEs cannot see each other's changes. Instead, we use+-- a single upsert that both creates the stream and bumps its version atomically.+appendAnyVersionSQL :: Text+appendAnyVersionSQL =+  """+  WITH+    new_events AS (+      SELECT *+      FROM unnest($1::uuid[], $2::text[], $3::uuid[], $4::uuid[], $5::jsonb[], $6::jsonb[], $7::timestamptz[])+      WITH ORDINALITY AS t(event_id, event_type, causation_id, correlation_id, data, metadata, created_at, idx)+    ),+    stream_upsert AS (+      INSERT INTO streams (stream_name, stream_version)+      VALUES ($8, (SELECT count(*) FROM new_events))+      ON CONFLICT (stream_name)+      DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM new_events)+        WHERE streams.deleted_at IS NULL+      RETURNING stream_id, category, stream_version - (SELECT count(*) FROM new_events) AS initial_version+    ),+    inserted_events AS (+      INSERT INTO events (event_id, event_type, causation_id, correlation_id, data, metadata, created_at)+      SELECT event_id, event_type, causation_id, correlation_id, data, metadata, created_at+      FROM new_events+      WHERE EXISTS (SELECT 1 FROM stream_upsert)+      ORDER BY idx+    ),+    source_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT ne.event_id, su.stream_id, su.initial_version + ne.idx, su.stream_id, su.initial_version + ne.idx+      FROM new_events ne+      CROSS JOIN stream_upsert su+    ),+    all_update AS (+      UPDATE streams+      SET stream_version = stream_version + (SELECT count(*) FROM new_events)+      WHERE stream_id = 0+        AND EXISTS (SELECT 1 FROM stream_upsert)+      RETURNING stream_version - (SELECT count(*) FROM new_events) AS initial_global_version+    ),+    all_links AS (+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+      SELECT ne.event_id, 0, au.initial_global_version + ne.idx, su.stream_id, su.initial_version + ne.idx, su.category+      FROM new_events ne+      CROSS JOIN all_update au+      CROSS JOIN stream_upsert su+    )+  SELECT su.stream_id,+         su.initial_version + (SELECT count(*) FROM new_events),+         au.initial_global_version + (SELECT count(*) FROM new_events)+  FROM stream_upsert su+  CROSS JOIN all_update au+  """++-- ---------------------------------------------------------------------------+-- Read Statements+-- ---------------------------------------------------------------------------++-- | Shared decoder for a RecordedEvent row (11 columns).+recordedEventRow :: D.Row RecordedEvent+recordedEventRow =+  RecordedEvent+    <$> (EventId <$> D.column (D.nonNullable D.uuid))+    <*> (EventType <$> D.column (D.nonNullable D.text))+    <*> (StreamVersion <$> D.column (D.nonNullable D.int8))+    <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))+    <*> (StreamId <$> D.column (D.nonNullable D.int8))+    <*> (StreamVersion <$> D.column (D.nonNullable D.int8))+    <*> D.column (D.nonNullable D.jsonb)+    <*> D.column (D.nullable D.jsonb)+    <*> D.column (D.nullable D.uuid)+    <*> D.column (D.nullable D.uuid)+    <*> D.column (D.nonNullable D.timestamptz)++-- | Shared decoder for a StreamInfo row (6 columns).+streamInfoRow :: D.Row StreamInfo+streamInfoRow =+  StreamInfo+    <$> (StreamId <$> D.column (D.nonNullable D.int8))+    <*> (StreamName <$> D.column (D.nonNullable D.text))+    <*> (StreamVersion <$> D.column (D.nonNullable D.int8))+    <*> D.column (D.nonNullable D.timestamptz)+    <*> D.column (D.nullable D.timestamptz)+    <*> (StreamVersion <$> D.column (D.nonNullable D.int8)) -- truncate_before++-- | Encoder for stream read params: (stream_name, start_version, limit).+readStreamEncoder :: E.Params (Text, Int64, Int32)+readStreamEncoder =+  contrazip3+    (E.param (E.nonNullable E.text))+    (E.param (E.nonNullable E.int8))+    (E.param (E.nonNullable E.int4))++-- | Encoder for $all read params: (start_position, limit).+readAllEncoder :: E.Params (Int64, Int32)+readAllEncoder =+  contrazip2+    (E.param (E.nonNullable E.int8))+    (E.param (E.nonNullable E.int4))++-- | Read events from a named stream in forward order.+readStreamForwardStmt :: Statement (Text, Int64, Int32) (Vector RecordedEvent)+readStreamForwardStmt =+  preparable+    readStreamForwardSQL+    readStreamEncoder+    (D.rowVector recordedEventRow)++-- | Read events from a named stream in backward order.+readStreamBackwardStmt :: Statement (Text, Int64, Int32) (Vector RecordedEvent)+readStreamBackwardStmt =+  preparable+    readStreamBackwardSQL+    readStreamEncoder+    (D.rowVector recordedEventRow)++-- | Read events from the global $all stream in forward order.+readAllForwardStmt :: Statement (Int64, Int32) (Vector RecordedEvent)+readAllForwardStmt =+  preparable+    readAllForwardSQL+    readAllEncoder+    (D.rowVector recordedEventRow)++-- | Read events from the global $all stream in backward order.+readAllBackwardStmt :: Statement (Int64, Int32) (Vector RecordedEvent)+readAllBackwardStmt =+  preparable+    readAllBackwardSQL+    readAllEncoder+    (D.rowVector recordedEventRow)++-- | Read the current tail of the global $all stream.+currentGlobalPositionStmt :: Statement () Int64+currentGlobalPositionStmt =+  preparable+    "SELECT stream_version FROM streams WHERE stream_id = 0"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))++-- | Read the greatest position still visible in the global $all stream.+visibleGlobalHeadPositionStmt :: Statement () GlobalPosition+visibleGlobalHeadPositionStmt =+  preparable+    """+    SELECT COALESCE((+      SELECT stream_version+      FROM stream_events+      WHERE stream_id = 0+      ORDER BY stream_version DESC+      LIMIT 1+    ), 0)+    """+    E.noParams+    (D.singleRow (GlobalPosition <$> D.column (D.nonNullable D.int8)))++-- | Metadata and the newest surviving originated global position in one snapshot.+getStreamWithHeadStmt :: Statement Text (Maybe (StreamInfo, Maybe GlobalPosition))+getStreamWithHeadStmt =+  preparable+    getStreamWithHeadSQL+    (E.param (E.nonNullable E.text))+    (D.rowMaybe ((,) <$> streamInfoRow <*> (fmap GlobalPosition <$> D.column (D.nullable D.int8))))++getStreamWithHeadSQL :: Text+getStreamWithHeadSQL =+  """+  SELECT s.stream_id, s.stream_name, s.stream_version,+         s.created_at, s.deleted_at, s.truncate_before,+         (SELECT se.stream_version+          FROM stream_events AS se+          WHERE se.stream_id = 0+            AND se.original_stream_id = s.stream_id+          ORDER BY se.stream_version DESC+          LIMIT 1) AS head_global_position+  FROM streams AS s+  WHERE s.stream_name = $1+  """++-- | Get stream metadata by name.+getStreamStmt :: Statement Text (Maybe StreamInfo)+getStreamStmt =+  preparable+    getStreamSQL+    (E.param (E.nonNullable E.text))+    (D.rowMaybe streamInfoRow)++-- | Point probe for whether an event exists in a live stream.+eventExistsInStreamStmt :: Statement (Text, UUID) Bool+eventExistsInStreamStmt =+  preparable+    """+    SELECT EXISTS (+      SELECT 1+      FROM stream_events se+      WHERE se.event_id = $2+        AND se.stream_id = (+          SELECT stream_id+          FROM streams+          WHERE stream_name = $1+            AND deleted_at IS NULL+        )+    )+    """+    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.uuid)))+    (D.singleRow (D.column (D.nonNullable D.bool)))++-- ---------------------------------------------------------------------------+-- Read SQL Templates+-- ---------------------------------------------------------------------------++-- | Read from a named stream in forward order.+-- Joins stream_events with events and streams, filters on stream_version >+-- start_version, hides the logically-truncated prefix (stream_version >=+-- streams.truncate_before), orders ascending, limits.+-- For stream reads, global_position is set to 0 (not available without $all join).+readStreamForwardSQL :: Text+readStreamForwardSQL =+  """+  SELECT e.event_id, e.event_type,+         se.stream_version, 0::bigint AS global_position,+         se.original_stream_id, se.original_stream_version,+         e.data, e.metadata, e.causation_id, e.correlation_id,+         e.created_at+  FROM stream_events se+  JOIN events e  ON e.event_id  = se.event_id+  JOIN streams s ON s.stream_id = se.stream_id+  WHERE s.stream_name = $1+    AND s.deleted_at IS NULL+    AND se.stream_version > $2+    AND se.stream_version >= s.truncate_before+  ORDER BY se.stream_version ASC+  LIMIT $3+  """++-- | Read from a named stream in backward order.+-- The start version is an exclusive upper bound. The interpreter maps+-- caller cursor 0 to 'maxBound' so "from latest" stays a Haskell API+-- sentinel and the SQL keeps a plain range predicate.+readStreamBackwardSQL :: Text+readStreamBackwardSQL =+  """+  SELECT e.event_id, e.event_type,+         se.stream_version, 0::bigint AS global_position,+         se.original_stream_id, se.original_stream_version,+         e.data, e.metadata, e.causation_id, e.correlation_id,+         e.created_at+  FROM stream_events se+  JOIN events e  ON e.event_id  = se.event_id+  JOIN streams s ON s.stream_id = se.stream_id+  WHERE s.stream_name = $1+    AND s.deleted_at IS NULL+    AND se.stream_version < $2+    AND se.stream_version >= s.truncate_before+  ORDER BY se.stream_version DESC+  LIMIT $3+  """++-- | Read from the global $all stream in forward order.+-- stream_id = 0 is the $all stream. stream_version on $all is the global position.+-- The global log deliberately ignores the per-stream @truncate_before@ marker:+-- logical truncation only hides events from ordered per-stream reads, never from+-- the @$all@ backbone that subscriptions and projections consume.+readAllForwardSQL :: Text+readAllForwardSQL =+  """+  SELECT e.event_id, e.event_type,+         se.stream_version, se.stream_version AS global_position,+         se.original_stream_id, se.original_stream_version,+         e.data, e.metadata, e.causation_id, e.correlation_id,+         e.created_at+  FROM stream_events se+  JOIN events e ON e.event_id = se.event_id+  WHERE se.stream_id = 0+    AND se.stream_version > $1+  ORDER BY se.stream_version ASC+  LIMIT $2+  """++-- | Read from the global $all stream in backward order.+-- The start position is an exclusive upper bound. The interpreter maps+-- caller cursor 0 to 'maxBound' so "from latest" stays a Haskell API+-- sentinel and the SQL keeps a plain range predicate.+readAllBackwardSQL :: Text+readAllBackwardSQL =+  """+  SELECT e.event_id, e.event_type,+         se.stream_version, se.stream_version AS global_position,+         se.original_stream_id, se.original_stream_version,+         e.data, e.metadata, e.causation_id, e.correlation_id,+         e.created_at+  FROM stream_events se+  JOIN events e ON e.event_id = se.event_id+  WHERE se.stream_id = 0+    AND se.stream_version < $1+  ORDER BY se.stream_version DESC+  LIMIT $2+  """++-- | Get stream metadata by name.+getStreamSQL :: Text+getStreamSQL =+  """+  SELECT stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before+  FROM streams+  WHERE stream_name = $1+  """++-- ---------------------------------------------------------------------------+-- Causation / Correlation Statements+-- ---------------------------------------------------------------------------++-- | Return every event whose @correlation_id@ equals the input, in ascending+-- @global_position@ order. Uses the @ix_events_correlation_id@ partial index.+--+-- The @stream_events se ON se.stream_id = 0@ join resolves each event to its+-- single row in the global @$all@ stream, which is also where the global+-- position is materialized as @stream_version@.+findByCorrelationStmt :: Statement UUID (Vector RecordedEvent)+findByCorrelationStmt =+  preparable+    findByCorrelationSQL+    (E.param (E.nonNullable E.uuid))+    (D.rowVector recordedEventRow)++findByCorrelationSQL :: Text+findByCorrelationSQL =+  """+  SELECT e.event_id, e.event_type,+         se.stream_version, se.stream_version AS global_position,+         se.original_stream_id, se.original_stream_version,+         e.data, e.metadata, e.causation_id, e.correlation_id,+         e.created_at+  FROM events e+  JOIN stream_events se+    ON se.event_id = e.event_id AND se.stream_id = 0+  WHERE e.correlation_id = $1+  ORDER BY se.stream_version ASC+  """++-- | Walk the causation graph forward from a seed event, returning the seed+-- itself and every event whose @causation_id@ chain leads back to it. The+-- result is ordered by ascending @global_position@.+--+-- The recursive CTE follows @causation_id@ links downstream: each child's+-- @causation_id@ equals a parent's @event_id@. Cost is @O(depth * log n)@+-- backed by the @ix_events_causation_id@ partial index.+findCausationDescendantsStmt :: Statement UUID (Vector RecordedEvent)+findCausationDescendantsStmt =+  preparable+    findCausationDescendantsSQL+    (E.param (E.nonNullable E.uuid))+    (D.rowVector recordedEventRow)++findCausationDescendantsSQL :: Text+findCausationDescendantsSQL =+  """+  WITH RECURSIVE chain (event_id, depth) AS (+      SELECT event_id, 0+      FROM events+      WHERE event_id = $1+    UNION ALL+      SELECT e.event_id, c.depth + 1+      FROM events e+      JOIN chain c ON e.causation_id = c.event_id+  )+  SELECT e.event_id, e.event_type,+         se.stream_version, se.stream_version AS global_position,+         se.original_stream_id, se.original_stream_version,+         e.data, e.metadata, e.causation_id, e.correlation_id,+         e.created_at+  FROM chain c+  JOIN events e ON e.event_id = c.event_id+  JOIN stream_events se+    ON se.event_id = e.event_id AND se.stream_id = 0+  ORDER BY se.stream_version ASC+  """++-- | Walk the causation graph backward from a seed event, returning the seed+-- itself and every ancestor reachable via @causation_id@. Result is ordered+-- by ascending @depth@ (the seed is depth 0, its immediate cause is depth 1,+-- etc.).+--+-- The recursive CTE follows @causation_id@ links upstream: for each row+-- @current@ already in the working set, its parent is the row of @events@+-- whose @event_id@ equals @current.causation_id@.+findCausationAncestorsStmt :: Statement UUID (Vector RecordedEvent)+findCausationAncestorsStmt =+  preparable+    findCausationAncestorsSQL+    (E.param (E.nonNullable E.uuid))+    (D.rowVector recordedEventRow)++findCausationAncestorsSQL :: Text+findCausationAncestorsSQL =+  """+  WITH RECURSIVE chain (event_id, depth) AS (+      SELECT event_id, 0+      FROM events+      WHERE event_id = $1+    UNION ALL+      SELECT parent.event_id, c.depth + 1+      FROM events parent+      JOIN events current ON parent.event_id = current.causation_id+      JOIN chain c ON c.event_id = current.event_id+  )+  SELECT e.event_id, e.event_type,+         se.stream_version, se.stream_version AS global_position,+         se.original_stream_id, se.original_stream_version,+         e.data, e.metadata, e.causation_id, e.correlation_id,+         e.created_at+  FROM chain c+  JOIN events e ON e.event_id = c.event_id+  JOIN stream_events se+    ON se.event_id = e.event_id AND se.stream_id = 0+  ORDER BY c.depth ASC+  """++-- ---------------------------------------------------------------------------+-- Link Statements+-- ---------------------------------------------------------------------------++-- | Link existing events into a target stream (upsert semantics).+-- Returns @Nothing@ when the target stream exists and is soft-deleted (the+-- @DO UPDATE WHERE streams.deleted_at IS NULL@ filter rejects the upsert,+-- so the stream_upsert CTE produces no rows). The interpreter maps @Nothing@+-- to @StreamNotFound@ for symmetry with @appendAnyVersion@'s soft-deleted+-- behavior added in EP-1 F2.+linkToStreamStmt :: Statement (Vector UUID, Text) (Maybe LinkResult)+linkToStreamStmt =+  preparable+    linkToStreamSQL+    linkEncoder+    linkResultDecoder++linkEncoder :: E.Params (Vector UUID, Text)+linkEncoder =+  contrazip2+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    (E.param (E.nonNullable E.text))++linkResultDecoder :: D.Result (Maybe LinkResult)+linkResultDecoder =+  D.rowMaybe $+    LinkResult+      <$> (StreamId <$> D.column (D.nonNullable D.int8))+      <*> (StreamVersion <$> D.column (D.nonNullable D.int8))++linkToStreamSQL :: Text+linkToStreamSQL =+  """+  WITH+    event_list AS (+      SELECT event_id, idx+      FROM unnest($1::uuid[]) WITH ORDINALITY AS t(event_id, idx)+    ),+    stream_upsert AS (+      INSERT INTO streams (stream_name, stream_version)+      VALUES ($2, (SELECT count(*) FROM event_list))+      ON CONFLICT (stream_name)+      DO UPDATE SET stream_version = streams.stream_version + (SELECT count(*) FROM event_list)+        WHERE streams.deleted_at IS NULL+      RETURNING stream_id, stream_version - (SELECT count(*) FROM event_list) AS initial_version+    ),+    link_inserts AS (+      -- LEFT JOIN LATERAL surfaces missing-event rows as NULLs for original_*; the+      -- NOT NULL constraint on stream_events.original_stream_id then aborts the+      -- entire CTE, rolling back the stream_upsert's version bump. Before the F3+      -- fix this was a plain JOIN LATERAL, which silently dropped missing-event+      -- rows while still bumping stream_version → silent gap in the link target.+      INSERT INTO stream_events (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+      SELECT el.event_id, su.stream_id, su.initial_version + el.idx,+             orig.original_stream_id, orig.original_stream_version+      FROM event_list el+      CROSS JOIN stream_upsert su+      LEFT JOIN LATERAL (+        SELECT se.original_stream_id, se.original_stream_version+        FROM stream_events se+        WHERE se.event_id = el.event_id AND se.stream_id <> 0+        LIMIT 1+      ) orig ON true+    )+  SELECT su.stream_id, su.initial_version + (SELECT count(*) FROM event_list)+  FROM stream_upsert su+  """++-- ---------------------------------------------------------------------------+-- Category Read Statements+-- ---------------------------------------------------------------------------++-- | Read events from streams matching a category, in global position order.+--+-- Scans @ix_stream_events_all_by_category@ from @(category, startPosition)@ and+-- stops at the limit, so a poll's cost follows the rows it returns, not the+-- number of streams in the category (BUG-2). The @category@ column on @$all@+-- junction rows is written by the append statements (migration @0012@). Params:+-- @(startPosition, category, limit)@.+readCategoryForwardStmt :: Statement (Int64, Text, Int32) (Vector RecordedEvent)+readCategoryForwardStmt =+  preparable+    readCategoryForwardSQL+    readCategoryEncoder+    (D.rowVector recordedEventRow)++readCategoryEncoder :: E.Params (Int64, Text, Int32)+readCategoryEncoder =+  contrazip3+    (E.param (E.nonNullable E.int8))+    (E.param (E.nonNullable E.text))+    (E.param (E.nonNullable E.int4))++readCategoryForwardSQL :: Text+readCategoryForwardSQL =+  """+  SELECT e.event_id, e.event_type,+         se.stream_version, se.stream_version AS global_position,+         se.original_stream_id, se.original_stream_version,+         e.data, e.metadata, e.causation_id, e.correlation_id,+         e.created_at+  FROM stream_events se+  JOIN events e ON e.event_id = se.event_id+  WHERE se.stream_id = 0+    AND se.category = $2+    AND se.stream_version > $1+  ORDER BY se.stream_version ASC+  LIMIT $3+  """++-- ---------------------------------------------------------------------------+-- Consumer-Group Read Statements+-- ---------------------------------------------------------------------------++-- | Partition-filtered category read for one consumer-group member.+--+-- Mirrors 'readCategoryForwardStmt' but returns only events whose originating+-- stream is assigned to member @$3@ of a group of size @$4@. The assignment rule+-- (MasterPlan IP-1) hashes the originating stream's surrogate id and folds the+-- signed result into @[0, size)@:+--+-- @member_of(stream_id) = (((hashtextextended(stream_id::text, 0) % size) + size) % size)@+--+-- The read scans @ix_stream_events_all_by_category@ from+-- @(category, startPosition)@ and applies the predicate to+-- @se.original_stream_id@, which the index carries as an @INCLUDE@ column, so+-- other members' rows are skipped on index tuples. A poll touches about+-- @limit * size@ index entries at most, never one probe per stream in the+-- category (BUG-2). Params: @(startPosition, category, member, size, limit)@.+readCategoryForwardConsumerGroupStmt ::+  Statement (Int64, Text, Int32, Int32, Int32) (Vector RecordedEvent)+readCategoryForwardConsumerGroupStmt =+  preparable+    readCategoryForwardConsumerGroupSQL+    readCategoryConsumerGroupEncoder+    (D.rowVector recordedEventRow)++readCategoryConsumerGroupEncoder :: E.Params (Int64, Text, Int32, Int32, Int32)+readCategoryConsumerGroupEncoder =+  contrazip5+    (E.param (E.nonNullable E.int8))+    (E.param (E.nonNullable E.text))+    (E.param (E.nonNullable E.int4))+    (E.param (E.nonNullable E.int4))+    (E.param (E.nonNullable E.int4))++readCategoryForwardConsumerGroupSQL :: Text+readCategoryForwardConsumerGroupSQL =+  """+  SELECT e.event_id, e.event_type,+         se.stream_version, se.stream_version AS global_position,+         se.original_stream_id, se.original_stream_version,+         e.data, e.metadata, e.causation_id, e.correlation_id,+         e.created_at+  FROM stream_events se+  JOIN events e ON e.event_id = se.event_id+  WHERE se.stream_id = 0+    AND se.category = $2+    AND se.stream_version > $1+    AND (((hashtextextended(se.original_stream_id::text, 0) % $4) + $4) % $4) = $3+  ORDER BY se.stream_version ASC+  LIMIT $5+  """++-- | Partition-filtered @$all@ read for one consumer-group member.+--+-- Mirrors 'readAllForwardStmt' but returns only events whose originating stream is+-- assigned to member @$2@ of a group of size @$3@, using the same MasterPlan IP-1+-- rule as 'readCategoryForwardConsumerGroupStmt'. The predicate is applied to+-- @se.original_stream_id@ — the real originating stream of each @$all@ junction row+-- (never the reserved id 0 for normal appends). Params:+-- @(startPosition, member, size, limit)@.+readAllForwardConsumerGroupStmt ::+  Statement (Int64, Int32, Int32, Int32) (Vector RecordedEvent)+readAllForwardConsumerGroupStmt =+  preparable+    readAllForwardConsumerGroupSQL+    readAllConsumerGroupEncoder+    (D.rowVector recordedEventRow)++readAllConsumerGroupEncoder :: E.Params (Int64, Int32, Int32, Int32)+readAllConsumerGroupEncoder =+  contrazip4+    (E.param (E.nonNullable E.int8))+    (E.param (E.nonNullable E.int4))+    (E.param (E.nonNullable E.int4))+    (E.param (E.nonNullable E.int4))++readAllForwardConsumerGroupSQL :: Text+readAllForwardConsumerGroupSQL =+  """+  SELECT e.event_id, e.event_type,+         se.stream_version, se.stream_version AS global_position,+         se.original_stream_id, se.original_stream_version,+         e.data, e.metadata, e.causation_id, e.correlation_id,+         e.created_at+  FROM stream_events se+  JOIN events e ON e.event_id = se.event_id+  WHERE se.stream_id = 0+    AND se.stream_version > $1+    AND (((hashtextextended(se.original_stream_id::text, 0) % $3) + $3) % $3) = $2+  ORDER BY se.stream_version ASC+  LIMIT $4+  """++-- | One bounded global scan, including progress when there are no matches.+-- The payloads and frontier come from one statement snapshot.+data NamespaceWindow = NamespaceWindow+  { namespaceEvents :: !(Vector RecordedEvent),+    scannedThrough :: !GlobalPosition,+    scannedCount :: !Int32,+    exhausted :: !Bool+  }+  deriving stock (Show)++-- | @(exclusive cursor, captured head, literal namespace, window size)@.+readNamespaceWindowStmt :: Statement (Int64, Int64, Text, Int32) NamespaceWindow+readNamespaceWindowStmt =+  preparable+    (namespaceWindowSQL False)+    (contrazip4 (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)))+    namespaceWindowDecoder++-- | Group filtering happens after bounding the scan, using the originating id.+readNamespaceGroupWindowStmt :: Statement (Int64, Int64, Text, Int32, Int32, Int32) NamespaceWindow+readNamespaceGroupWindowStmt =+  preparable+    (namespaceWindowSQL True)+    (contrazip6 (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int4)))+    namespaceWindowDecoder++namespaceWindowSQL :: Bool -> Text+namespaceWindowSQL grouped =+  """+  WITH scanned AS MATERIALIZED (+    SELECT event_id, stream_version, original_stream_id, original_stream_version, category+    FROM stream_events+    -- stream_id is integral: this range selects exactly $all. Keeping both+    -- index keys in the ordering prevents custom tail plans from choosing+    -- the partial category index and sorting an unrestricted metadata scan.+    WHERE stream_id >= 0 AND stream_id < 1 AND stream_version > $1+    ORDER BY stream_id, stream_version LIMIT $4+  ), eligible AS MATERIALIZED (+    SELECT * FROM scanned WHERE stream_version <= $2+  ), progress AS (+    SELECT count(*)::int4 AS scanned_count,+           count(*) < $4 OR coalesce(max(stream_version) = $2, false) AS exhausted,+           CASE WHEN count(*) < $4 OR coalesce(max(stream_version) = $2, false)+                THEN $2 ELSE max(stream_version) END AS frontier+    FROM eligible+  ), matching AS MATERIALIZED (+    SELECT * FROM eligible+    WHERE (category COLLATE "C" = $3 OR starts_with(category COLLATE "C", $3 || ':'))+  """+    <> (if grouped then " AND (((hashtextextended(original_stream_id::text, 0) % $6) + $6) % $6) = $5" else "")+    <> """+       )+       , payloads AS MATERIALIZED (+         SELECT m.stream_version,+                ROW(e.event_id, e.event_type, m.stream_version, m.stream_version,+                    m.original_stream_id, m.original_stream_version,+                    e.data, e.metadata, e.causation_id, e.correlation_id, e.created_at) AS event+         FROM matching m+         -- OFFSET 0 keeps event-id probes behind matching in generic plans.+         LEFT JOIN LATERAL (SELECT * FROM events WHERE event_id = m.event_id OFFSET 0) e ON true+       )+       SELECT p.frontier, p.scanned_count, p.exhausted, payloads.event+       FROM progress p+       -- Generate the sentinel after payload probing, so an empty matching set+       -- never invokes even a zero-I/O NULL event-id lookup.+       LEFT JOIN payloads ON true+       ORDER BY payloads.stream_version+       """++-- The sentinel's NULL record is explicit. A selected junction without a payload+-- produces a non-null record with null required fields and fails decoding.+namespaceWindowDecoder :: D.Result NamespaceWindow+namespaceWindowDecoder = assemble <$> D.rowVector row+  where+    row =+      (,,,)+        <$> (GlobalPosition <$> D.column (D.nonNullable D.int8))+        <*> D.column (D.nonNullable D.int4)+        <*> D.column (D.nonNullable D.bool)+        <*> D.column (D.nullable (D.record eventRecord))+    assemble rows =+      let (frontier, count, done, _) = V.head rows+       in NamespaceWindow (V.fromList (mapMaybe (\(_, _, _, event) -> event) (V.toList rows))) frontier count done+    eventRecord =+      RecordedEvent+        <$> (EventId <$> D.field (D.nonNullable D.uuid))+        <*> (EventType <$> D.field (D.nonNullable D.text))+        <*> (StreamVersion <$> D.field (D.nonNullable D.int8))+        <*> (GlobalPosition <$> D.field (D.nonNullable D.int8))+        <*> (StreamId <$> D.field (D.nonNullable D.int8))+        <*> (StreamVersion <$> D.field (D.nonNullable D.int8))+        <*> D.field (D.nonNullable D.jsonb)+        <*> D.field (D.nullable D.jsonb)+        <*> D.field (D.nullable D.uuid)+        <*> D.field (D.nullable D.uuid)+        <*> D.field (D.nonNullable D.timestamptz)++-- ---------------------------------------------------------------------------+-- Lifecycle Statements+-- ---------------------------------------------------------------------------++-- | Soft-delete a stream by setting deleted_at. Returns Nothing if stream doesn't exist or is already deleted.+softDeleteStreamStmt :: Statement Text (Maybe StreamId)+softDeleteStreamStmt =+  preparable+    softDeleteStreamSQL+    (E.param (E.nonNullable E.text))+    (D.rowMaybe (StreamId <$> D.column (D.nonNullable D.int8)))++-- | Undelete a soft-deleted stream by clearing deleted_at. Returns Nothing if stream doesn't exist or is not deleted.+undeleteStreamStmt :: Statement Text (Maybe StreamId)+undeleteStreamStmt =+  preparable+    undeleteStreamSQL+    (E.param (E.nonNullable E.text))+    (D.rowMaybe (StreamId <$> D.column (D.nonNullable D.int8)))++-- | Set a stream's logical truncate-before marker. Returns Nothing if the+-- stream does not exist or is soft-deleted.+setStreamTruncateBeforeStmt :: Statement (Text, Int64) (Maybe StreamId)+setStreamTruncateBeforeStmt =+  preparable+    setStreamTruncateBeforeSQL+    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int8)))+    (D.rowMaybe (StreamId <$> D.column (D.nonNullable D.int8)))++-- | Look up a stream's id by name. Returns Nothing if the stream does not exist.+-- Used as the first step in hard-delete; subsequent steps key off the id rather+-- than the name so a single resolution is reused.+findStreamIdStmt :: Statement Text (Maybe Int64)+findStreamIdStmt =+  preparable+    "SELECT stream_id FROM streams WHERE stream_name = $1"+    (E.param (E.nonNullable E.text))+    (D.rowMaybe (D.column (D.nonNullable D.int8)))++-- | Resolve a batch of surrogate stream ids to their (id, name) pairs in one+-- round trip. Ids that do not name an existing stream simply produce no row, so+-- the caller's 'Data.Map.Strict.Map' omits them. Surfaced as+-- 'Kiroku.Store.Read.lookupStreamNames'.+lookupStreamNamesStmt :: Statement [Int64] (Vector (Int64, Text))+lookupStreamNamesStmt =+  preparable+    "SELECT stream_id, stream_name FROM streams WHERE stream_id = ANY($1::bigint[])"+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.int8))))+    ( D.rowVector+        ( (,)+            <$> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.text)+        )+    )++-- | Delete the @$all@ junction rows for events that originated in the target+-- stream, returning exactly those originated event ids.+--+-- Served by @ix_stream_events_all_by_origin@, whose partial predicate is+-- @stream_id = 0@. Every event that originated in a stream has one @$all@ row, so+-- this indexed delete yields the complete originated-event set without scanning all+-- of @stream_events@.+--+-- The caller then passes the returned ids to 'deleteJunctionsByEventIdsStmt' to+-- remove the remaining home/link junction rows for those originated events.+deleteAllRowsForOriginStmt :: Statement Int64 (Vector UUID)+deleteAllRowsForOriginStmt =+  preparable+    """+    DELETE FROM stream_events+    WHERE original_stream_id = $1+      AND stream_id = 0+    RETURNING event_id+    """+    (E.param (E.nonNullable E.int8))+    (D.rowVector (D.column (D.nonNullable D.uuid)))++-- | Delete all remaining junction rows for the given event ids.+--+-- Served by the @stream_events@ primary key @(event_id, stream_id)@. This removes+-- the originated events' home rows and any link rows in other streams after+-- 'deleteAllRowsForOriginStmt' has removed their @$all@ rows.+deleteJunctionsByEventIdsStmt :: Statement (Vector UUID) ()+deleteJunctionsByEventIdsStmt =+  preparable+    """+    DELETE FROM stream_events+    WHERE event_id = ANY($1::uuid[])+    """+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    D.noResult++-- | Delete junction rows whose visible stream is the target stream, returning+-- the affected event ids.+--+-- Served by @ix_stream_events_stream_version@ (or the unique replacement+-- @ux_stream_events_stream_version@ after EP-5 M3) on @(stream_id,+-- stream_version)@. After 'deleteJunctionsByEventIdsStmt' this matches only+-- events that originated elsewhere and were linked into the deleted stream. Those+-- events normally survive; returning them lets the same orphan check make the+-- decision instead of relying on that assumption.+deleteStreamOwnJunctionsStmt :: Statement Int64 (Vector UUID)+deleteStreamOwnJunctionsStmt =+  preparable+    """+    DELETE FROM stream_events+    WHERE stream_id = $1+    RETURNING event_id+    """+    (E.param (E.nonNullable E.int8))+    (D.rowVector (D.column (D.nonNullable D.uuid)))++-- | Delete dead-letter rows for candidate events that have become orphaned.+--+-- Served by @ix_dead_letters_event_id@. The @NOT EXISTS@ predicate mirrors+-- 'deleteOrphanedEventsStmt', so dead letters are purged exactly for events whose+-- payload rows are about to be removed while dead letters for surviving linked-in+-- events remain.+--+-- Must be called after the junction deletes and before 'deleteOrphanedEventsStmt'+-- within the same transaction so the @NOT EXISTS@ subquery sees the post-delete+-- state of @stream_events@ and the @dead_letters.event_id@ foreign key remains+-- satisfied when orphan event payloads are deleted.+deleteDeadLettersForOrphanedEventsStmt :: Statement (Vector UUID) ()+deleteDeadLettersForOrphanedEventsStmt =+  preparable+    """+    DELETE FROM dead_letters dl+    WHERE dl.event_id = ANY($1::uuid[])+      AND NOT EXISTS (+        SELECT 1 FROM stream_events se+        WHERE se.event_id = dl.event_id+      )+    """+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    D.noResult++-- | Delete event payloads from the @events@ table for the given event ids,+-- but only those whose junction rows have all been removed. Events that still+-- have any surviving @stream_events@ row are preserved (they remain visible from+-- their other homes).+--+-- The first implementation tried to inline orphan-event deletion with a+-- data-modifying CTE, but PostgreSQL §7.8.2 specifies that data-modifying CTEs run+-- against the same snapshot, so the @NOT EXISTS@ subquery on stream_events saw the+-- pre-delete state and never deleted any events. Splitting into multiple+-- statements within the same hasql-transaction lets each statement see previous+-- statements' effects.+--+-- Must be called after 'deleteDeadLettersForOrphanedEventsStmt' within the same+-- transaction so the @NOT EXISTS@ subquery sees the post-delete state of+-- @stream_events@ and the @dead_letters.event_id@ foreign key is already clear.+deleteOrphanedEventsStmt :: Statement (Vector UUID) ()+deleteOrphanedEventsStmt =+  preparable+    """+    DELETE FROM events+    WHERE event_id = ANY($1::uuid[])+      AND NOT EXISTS (+        SELECT 1 FROM stream_events+        WHERE stream_events.event_id = events.event_id+      )+    """+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    D.noResult++-- | Delete the @streams@ row for the given stream id. Used as the final step+-- of hard-delete after junction rows and orphan events are cleared.+deleteStreamRowStmt :: Statement Int64 ()+deleteStreamRowStmt =+  preparable+    "DELETE FROM streams WHERE stream_id = $1"+    (E.param (E.nonNullable E.int8))+    D.noResult++-- | Pre-acquire row locks on the named streams in deterministic (stream_id)+-- order. Used by AppendMultiStream to avoid row-lock deadlocks between+-- concurrent multi-stream transactions that touch overlapping streams in+-- different orders.+--+-- \$all is intentionally NOT included in the pre-lock — its row lock is+-- acquired by each per-stream CTE inside the transaction, after the source+-- stream's row lock. See EP-1 F4.+--+-- Streams that don't yet exist (NoStream variant on a fresh stream) are not+-- matched by the WHERE clause, so they aren't pre-locked here. Concurrent+-- INSERTs of a fresh stream do serialize on the unique index on+-- @stream_name@, but that serialization does /not/ preserve the+-- source-before-$all acquisition order this pre-lock exists to establish,+-- and a multi-stream append can still deadlock against a concurrent+-- single-stream append:+--+--   * multi locks stream A, then @$all@ (both inside A's CTE), then stream B;+--   * a single-stream append to the still-fresh B locks B, then wants @$all@.+--+-- Each then waits on the other. PostgreSQL detects it and aborts one side+-- with @40P01@; the interpreter retries the append once, and a repeated+-- conflict reaches the caller as+-- 'Kiroku.Store.Error.TransientTransactionFailure', which is documented+-- retryable. Eliminating the cycle would mean establishing every source+-- lock before any @$all@ lock, including for streams that do not exist yet+-- — tracked as IR-7, deliberately not done here because it is a throughput+-- question that needs benchmarking, not a correctness gap.+lockStreamsForMultiStmt :: Statement (Vector Text) ()+lockStreamsForMultiStmt =+  preparable+    """+    SELECT 1 FROM streams+    WHERE stream_name = ANY($1::text[])+    ORDER BY stream_id+    FOR UPDATE+    """+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+    D.noResult++-- ---------------------------------------------------------------------------+-- Lifecycle SQL Templates+-- ---------------------------------------------------------------------------++softDeleteStreamSQL :: Text+softDeleteStreamSQL =+  """+  UPDATE streams+  SET deleted_at = now()+  WHERE stream_name = $1+    AND deleted_at IS NULL+  RETURNING stream_id+  """++undeleteStreamSQL :: Text+undeleteStreamSQL =+  """+  UPDATE streams+  SET deleted_at = NULL+  WHERE stream_name = $1+    AND deleted_at IS NOT NULL+  RETURNING stream_id+  """++-- | Set the logical truncate-before marker on a live stream. Soft-deleted+-- streams (deleted_at IS NOT NULL) match no row and return Nothing, matching+-- the other lifecycle statements.+setStreamTruncateBeforeSQL :: Text+setStreamTruncateBeforeSQL =+  """+  UPDATE streams+  SET truncate_before = $2+  WHERE stream_name = $1+    AND deleted_at IS NULL+  RETURNING stream_id+  """++-- ---------------------------------------------------------------------------+-- Checkpoint Statements+-- ---------------------------------------------------------------------------++-- | Read the last-seen global position for a subscription.+getCheckpointStmt :: Statement Text (Maybe Int64)+getCheckpointStmt =+  preparable+    getCheckpointSQL+    (E.param (E.nonNullable E.text))+    (D.rowMaybe (D.column (D.nonNullable D.int8)))++-- | Upsert a checkpoint: insert or update the last-seen position.+saveCheckpointStmt :: Statement (Text, Int64) ()+saveCheckpointStmt =+  preparable+    saveCheckpointSQL+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int8))+    )+    D.noResult++getCheckpointSQL :: Text+getCheckpointSQL =+  """+  SELECT last_seen+  FROM subscriptions+  WHERE subscription_name = $1+  """++saveCheckpointSQL :: Text+saveCheckpointSQL =+  """+  INSERT INTO subscriptions (subscription_name, last_seen, updated_at)+  VALUES ($1, $2, now())+  ON CONFLICT (subscription_name, consumer_group_member)+  DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now()+  """++-- | Read the last-seen position for one consumer-group member of a subscription.+getCheckpointMemberStmt :: Statement (Text, Int32) (Maybe Int64)+getCheckpointMemberStmt =+  preparable+    getCheckpointMemberSQL+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    (D.rowMaybe (D.column (D.nonNullable D.int8)))++-- | Upsert the last-seen position for one consumer-group member, keyed on the+-- composite @(subscription_name, consumer_group_member)@ unique index. Uses the+-- same @GREATEST(...)@ monotonicity as 'saveCheckpointStmt' so a save never moves+-- a member's checkpoint backward.+saveCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32, Text, Maybe Text) ()+saveCheckpointMemberStmt =+  preparable+    saveCheckpointMemberSQL+    ( contrazip6+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.text))+        (E.param (E.nullable E.text))+    )+    D.noResult++-- | Bound targets use a fixed kind rather than encoding a constant per save.+saveAllCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32) ()+saveAllCheckpointMemberStmt =+  preparable+    (saveCheckpointMemberSQLWith "'all', NULL")+    (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)))+    D.noResult++saveCategoryCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32, Text) ()+saveCategoryCheckpointMemberStmt =+  preparable+    (saveCheckpointMemberSQLWith "'category', $5")+    (contrazip5 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.text)))+    D.noResult++-- | Namespace saves keep the existing all/category hot statements unchanged.+saveNamespaceCheckpointMemberStmt :: Statement (Text, Int32, Int64, Int32, Text) ()+saveNamespaceCheckpointMemberStmt =+  preparable+    (saveCheckpointMemberSQLWith "'category_namespace', $5")+    (contrazip5 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.int8)) (E.param (E.nonNullable E.int4)) (E.param (E.nonNullable E.text)))+    D.noResult++getCheckpointMemberSQL :: Text+getCheckpointMemberSQL =+  """+  SELECT last_seen+  FROM subscriptions+  WHERE subscription_name = $1+    AND consumer_group_member = $2+  """++saveCheckpointMemberSQL :: Text+saveCheckpointMemberSQL = saveCheckpointMemberSQLWith "$5, $6"++saveCheckpointMemberSQLWith :: Text -> Text+saveCheckpointMemberSQLWith binding =+  "INSERT INTO subscriptions (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category) VALUES ($1, $2, $3, now(), $4, "+    <> binding+    <> ") "+    <> """+       ON CONFLICT (subscription_name, consumer_group_member)+       DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now(), consumer_group_size = EXCLUDED.consumer_group_size, target_kind = EXCLUDED.target_kind, target_category = EXCLUDED.target_category+       """++-- ---------------------------------------------------------------------------+-- Dead-letter Statements+-- ---------------------------------------------------------------------------++-- | Parameters for 'insertDeadLetterAndCheckpointStmt'.+data DeadLetterParams = DeadLetterParams+  { dlSubscriptionName :: !Text,+    dlMember :: !Int32,+    dlTargetKind :: !Text,+    dlTargetCategory :: !(Maybe Text),+    dlGroupSize :: !Int32,+    dlGlobalPosition :: !Int64,+    dlEventId :: !UUID,+    -- | structured JSONB reason (@reason@ column)+    dlReason :: !Value,+    -- | operator-facing summary (@reason_summary@ column)+    dlReasonSummary :: !Text,+    dlAttemptCount :: !Int32+  }+  deriving stock (Show, Generic)++-- | A row of @kiroku.dead_letters@, returned by 'readDeadLettersStmt'.+data DeadLetterRecord = DeadLetterRecord+  { deadLetterGlobalPosition :: !Int64,+    deadLetterEventId :: !UUID,+    deadLetterReason :: !Value,+    deadLetterReasonSummary :: !Text,+    deadLetterAttemptCount :: !Int32,+    deadLetterCreatedAt :: !UTCTime+  }+  deriving stock (Show, Generic)++deadLetterParamsEncoder :: E.Params DeadLetterParams+deadLetterParamsEncoder =+  ((^. #dlSubscriptionName) >$< E.param (E.nonNullable E.text))+    <> ((^. #dlMember) >$< E.param (E.nonNullable E.int4))+    <> ((^. #dlGlobalPosition) >$< E.param (E.nonNullable E.int8))+    <> ((^. #dlEventId) >$< E.param (E.nonNullable E.uuid))+    <> ((^. #dlReason) >$< E.param (E.nonNullable E.jsonb))+    <> ((^. #dlReasonSummary) >$< E.param (E.nonNullable E.text))+    <> ((^. #dlAttemptCount) >$< E.param (E.nonNullable E.int4))+    <> ((^. #dlGroupSize) >$< E.param (E.nonNullable E.int4))+    <> ((^. #dlTargetKind) >$< E.param (E.nonNullable E.text))+    <> ((^. #dlTargetCategory) >$< E.param (E.nullable E.text))++-- | Atomically record an event in @kiroku.dead_letters@ and advance the+-- subscription's checkpoint past it, in a single statement.+--+-- The dead-letter insert and the checkpoint upsert run in one round trip: a+-- data-modifying CTE performs the insert (idempotent via @ON CONFLICT DO NOTHING@+-- on the natural key), and the final statement upserts the checkpoint with the+-- same @GREATEST(...)@ monotonicity as 'saveCheckpointMemberStmt'. If the insert+-- fails the whole statement aborts and the checkpoint does not advance, satisfying+-- the dead-letter atomicity criterion. The checkpoint reuses @$1@/@$2@/@$3@+-- (name / member / global_position) from the dead-letter params.+insertDeadLetterAndCheckpointStmt :: Statement DeadLetterParams ()+insertDeadLetterAndCheckpointStmt =+  preparable+    insertDeadLetterAndCheckpointSQL+    deadLetterParamsEncoder+    D.noResult++insertDeadLetterAndCheckpointSQL :: Text+insertDeadLetterAndCheckpointSQL =+  """+  WITH dl AS (+    INSERT INTO dead_letters+      (subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count)+    VALUES ($1, $2, $3, $4, $5, $6, $7)+    ON CONFLICT (subscription_name, consumer_group_member, global_position, event_id) DO NOTHING+  )+  INSERT INTO subscriptions (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category)+  VALUES ($1, $2, $3, now(), $8, $9, $10)+  ON CONFLICT (subscription_name, consumer_group_member)+  DO UPDATE SET last_seen = GREATEST(subscriptions.last_seen, EXCLUDED.last_seen), updated_at = now(), consumer_group_size = EXCLUDED.consumer_group_size, target_kind = EXCLUDED.target_kind, target_category = EXCLUDED.target_category+  """++-- | Read the dead letters recorded for one subscription member, newest first.+readDeadLettersStmt :: Statement (Text, Int32) (Vector DeadLetterRecord)+readDeadLettersStmt =+  preparable+    readDeadLettersSQL+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    (D.rowVector deadLetterRecordRow)++deadLetterRecordRow :: D.Row DeadLetterRecord+deadLetterRecordRow =+  DeadLetterRecord+    <$> D.column (D.nonNullable D.int8)+    <*> D.column (D.nonNullable D.uuid)+    <*> D.column (D.nonNullable D.jsonb)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.int4)+    <*> D.column (D.nonNullable D.timestamptz)++readDeadLettersSQL :: Text+readDeadLettersSQL =+  """+  SELECT global_position, event_id, reason, reason_summary, attempt_count, created_at+  FROM dead_letters+  WHERE subscription_name = $1+    AND consumer_group_member = $2+  ORDER BY global_position DESC, dead_letter_id DESC+  """++-- Catalog statements have a finite family of prepared shapes. Upper bounds+-- are deliberately checked after the ordered LIMIT, keeping generic plans+-- from bitmap-scanning and sorting an entire matching prefix.+data StreamRangeBound = InclusiveLower | ExclusiveLower | ExactName+  deriving stock (Eq, Show)++streamColumns :: Text+streamColumns = "stream_id, stream_name, stream_version, created_at, deleted_at, truncate_before"++streamRangeSQL :: StreamRangeBound -> Text -> Text -> Text -> Text+streamRangeSQL bound lower upper limit =+  "SELECT * FROM (SELECT "+    <> streamColumns+    <> " FROM streams WHERE stream_id <> 0 AND stream_name COLLATE \"C\" "+    <> operator+    <> " "+    <> lower+    <> " ORDER BY stream_name COLLATE \"C\" LIMIT "+    <> limit+    <> ") bounded WHERE ("+    <> upper+    <> "::text IS NULL OR stream_name COLLATE \"C\" < "+    <> upper+    <> ")"+  where+    operator = case bound of InclusiveLower -> ">="; ExclusiveLower -> ">"; ExactName -> "="++listStreamsRangeStmt :: StreamRangeBound -> Statement (Text, Maybe Text, Int32) (Vector StreamInfo)+listStreamsRangeStmt bound =+  preparable+    (streamRangeSQL bound "$1" "$2" "$3" <> " ORDER BY stream_name COLLATE \"C\"")+    (contrazip3 (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int4)))+    (D.rowVector streamInfoRow)++listStreamsPairStmt :: StreamRangeBound -> StreamRangeBound -> Statement (Text, Maybe Text, Text, Maybe Text, Int32) (Vector StreamInfo)+listStreamsPairStmt first second =+  preparable+    ( "SELECT * FROM (("+        <> streamRangeSQL first "$1" "$2" "$5"+        <> ") UNION ALL ("+        <> streamRangeSQL second "$3" "$4" "$5"+        <> ")) matching ORDER BY stream_name COLLATE \"C\" LIMIT $5"+    )+    ( contrazip5+        (E.param (E.nonNullable E.text))+        (E.param (E.nullable E.text))+        (E.param (E.nonNullable E.text))+        (E.param (E.nullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    (D.rowVector streamInfoRow)++-- | One statement and one checkout, including the bare-category/descendant union.+-- All text is encoded as parameters; only closed operator choices build SQL.+listStreamsSession :: Maybe CategoryName -> Maybe Text -> Maybe StreamName -> BrowsePageSize -> Session.Session (Vector StreamInfo)+listStreamsSession category prefix after page = case ranges of+  [] -> pure mempty+  [(kind, lower, upper)] -> Session.statement (lower, upper, limit) (listStreamsRangeStmt kind)+  [(kind, lower, upper), (kind2, lower2, upper2)] ->+    Session.statement (lower, upper, lower2, upper2, limit) (listStreamsPairStmt kind kind2)+  _ -> error "catalog range invariant: at most two disjoint ranges"+  where+    limit = browsePageSizeValue page+    cursor = fmap (\(StreamName value) -> value) after+    ranges = case category of+      Nothing -> maybe [] pure (interval "" Nothing)+      Just (CategoryName value)+        | T.any (== '-') value -> []+        | otherwise -> bare value <> maybe [] pure (interval (value <> "-") (prefixEnd (value <> "-")))+    bare value+      | maybe True (`T.isPrefixOf` value) prefix && maybe True (< value) cursor = [(ExactName, value, Nothing)]+      | otherwise = []+    interval start end =+      let lower = max start (maybe "" (\value -> value) prefix)+          upper = minimumEnd end (prefix >>= prefixEnd)+          (kind, seek) = case cursor of+            Just value | value >= lower -> (ExclusiveLower, value)+            _ -> (InclusiveLower, lower)+       in if maybe False (<= seek) upper then Nothing else Just (kind, seek, upper)+    minimumEnd Nothing b = b+    minimumEnd a Nothing = a+    minimumEnd (Just a) (Just b) = Just (min a b)++-- Valid UTF-8 byte order follows Unicode scalar order. The all-maximum and+-- empty prefixes have no finite upper bound; PostgreSQL text has no NUL.+prefixEnd :: Text -> Maybe Text+prefixEnd value = case T.unsnoc value of+  Nothing -> Nothing+  Just (initial, lastChar)+    | ord lastChar == 0x10ffff -> prefixEnd initial+    | otherwise -> Just (T.snoc initial (chr (if ord lastChar + 1 == 0xd800 then 0xe000 else ord lastChar + 1)))++-- First and later category pages have separate cursor shapes. Category order+-- retains the deployment collation and its existing category index.+listCategoriesStmt :: Bool -> Statement (Maybe Text, Int32) (Vector Text)+listCategoriesStmt after =+  preparable+    query+    (contrazip2 (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int4)))+    (D.rowVector (D.column (D.nonNullable D.text)))+  where+    first = if after then "AND s.category > $1" else "AND $1::text IS NULL"+    query =+      "WITH RECURSIVE next_category AS (SELECT (SELECT s.category FROM streams s WHERE s.stream_id <> 0 "+        <> first+        <> " ORDER BY s.category LIMIT 1) AS category UNION ALL SELECT (SELECT s.category FROM streams s "+        <> "WHERE s.stream_id <> 0 AND s.category > n.category ORDER BY s.category LIMIT 1) "+        <> "FROM next_category n WHERE n.category IS NOT NULL) SELECT category FROM next_category "+        <> "WHERE category IS NOT NULL LIMIT $2"++getEventStmt :: Statement UUID (Maybe RecordedEvent)+getEventStmt =+  preparable+    "SELECT e.event_id,e.event_type,se.stream_version,se.stream_version AS global_position, se.original_stream_id,se.original_stream_version,e.data,e.metadata,e.causation_id,e.correlation_id,e.created_at FROM events e JOIN stream_events se ON se.event_id=e.event_id AND se.stream_id=0 WHERE e.event_id=$1"+    (E.param (E.nonNullable E.uuid))+    (D.rowMaybe recordedEventRow)
src/Kiroku/Store/Settings.hs view
@@ -1,44 +1,43 @@-{- | Interpreter-level hooks applied to 'EventData' before encoding on-the append path and to 'RecordedEvent' after decoding on the read and-subscription paths.--The hook seam lives inside 'Kiroku.Store.Effect.runStorePool' (and the-subscription publisher\/worker) rather than at the SQL encoder\/decoder-layer so the hook sees the typed value — payload and metadata as-'Data.Aeson.Value', the event type, ids — and can branch on event type-or mutate structured JSON. Plumbing it at the encoder layer would force-hooks to operate on opaque bytes.--Both fields default to 'Nothing'. With the defaults, the helpers below-retain the original event list/vector without traversal. Subscription-decoding adds one batch constructor, with no per-event wrappers.--A typical use case is enriching every appended event with an-OpenTelemetry trace context drawn from the calling thread:--@-storeSettings = 'defaultStoreSettings'-  { 'enrichEvent' = Just $ \\ed -> do-      ctx <- captureCurrentSpan        -- OpenTelemetry, OTLP, whatever-      pure (ed & #metadata %~ injectTraceContext ctx)-  , 'decodeHook' = Just $ \\re ->-      pure (Right (re & #metadata %~ Just . redactPII))-  }-@--Wire the resulting 'StoreSettings' into-'Kiroku.Store.Connection.ConnectionSettings' via its @storeSettings@-field; 'Kiroku.Store.Connection.withStore' copies it onto the-'Kiroku.Store.Connection.KirokuStore' handle for the interpreter to-reach.--Direct callers of 'Kiroku.Store.Transaction.appendToStreamTx' bypass-'runStorePool' and therefore the 'enrichEvent' hook. Use-'Kiroku.Store.Transaction.enrichEventsIO' to opt in to enrichment-manually before constructing the prepared event list.--}-module Kiroku.Store.Settings (-    StoreSettings (..),+-- | Interpreter-level hooks applied to 'EventData' before encoding on+-- the append path and to 'RecordedEvent' after decoding on the read and+-- subscription paths.+--+-- The hook seam lives inside 'Kiroku.Store.Effect.runStorePool' (and the+-- subscription publisher\/worker) rather than at the SQL encoder\/decoder+-- layer so the hook sees the typed value — payload and metadata as+-- 'Data.Aeson.Value', the event type, ids — and can branch on event type+-- or mutate structured JSON. Plumbing it at the encoder layer would force+-- hooks to operate on opaque bytes.+--+-- Both fields default to 'Nothing'. With the defaults, the helpers below+-- retain the original event list/vector without traversal. Subscription+-- decoding adds one batch constructor, with no per-event wrappers.+--+-- A typical use case is enriching every appended event with an+-- OpenTelemetry trace context drawn from the calling thread:+--+-- @+-- storeSettings = 'defaultStoreSettings'+--   { 'enrichEvent' = Just $ \\ed -> do+--       ctx <- captureCurrentSpan        -- OpenTelemetry, OTLP, whatever+--       pure (ed & #metadata %~ injectTraceContext ctx)+--   , 'decodeHook' = Just $ \\re ->+--       pure (Right (re & #metadata %~ Just . redactPII))+--   }+-- @+--+-- Wire the resulting 'StoreSettings' into+-- 'Kiroku.Store.Connection.ConnectionSettings' via its @storeSettings@+-- field; 'Kiroku.Store.Connection.withStore' copies it onto the+-- 'Kiroku.Store.Connection.KirokuStore' handle for the interpreter to+-- reach.+--+-- Direct callers of 'Kiroku.Store.Transaction.appendToStreamTx' bypass+-- 'runStorePool' and therefore the 'enrichEvent' hook. Use+-- 'Kiroku.Store.Transaction.enrichEventsIO' to opt in to enrichment+-- manually before constructing the prepared event list.+module Kiroku.Store.Settings+  ( StoreSettings (..),     defaultStoreSettings,     enrichEvents,     DecodeFailure (..),@@ -50,7 +49,8 @@     decodedBatchLength,     decodedBatchLastEvent,     filterDecodedBatch,-) where+  )+where  import Data.Text (Text) import Data.Vector (Vector)@@ -58,108 +58,102 @@ import GHC.Generics (Generic) import Kiroku.Store.Types (EventData, EventId, RecordedEvent) -{- | A hook could not decode this event. Return it through 'Left'; throwing-from the hook remains a programming error. Default retry exhaustion is surfaced-by SubscriptionUndecodable, carrying this failure.--}+-- | A hook could not decode this event. Return it through 'Left'; throwing+-- from the hook remains a programming error. Default retry exhaustion is surfaced+-- by SubscriptionUndecodable, carrying this failure. data DecodeFailure = DecodeFailure-    { decodeFailureEventId :: !EventId-    , decodeFailureReason :: !Text-    }-    deriving stock (Eq, Show, Generic)+  { decodeFailureEventId :: !EventId,+    decodeFailureReason :: !Text+  }+  deriving stock (Eq, Show, Generic)  -- | A transformed event, or its raw value retained for retry and disposition. data DecodedEvent-    = Decoded !RecordedEvent-    | Undecodable !RecordedEvent !DecodeFailure-    deriving stock (Eq, Show)+  = Decoded !RecordedEvent+  | Undecodable !RecordedEvent !DecodeFailure+  deriving stock (Eq, Show) -{- | No hook means no traversal or per-event wrappers. Hook results are shared-by all live publisher subscribers, whose retry/disposition is independent.--}+-- | No hook means no traversal or per-event wrappers. Hook results are shared+-- by all live publisher subscribers, whose retry/disposition is independent. data DecodedBatch-    = UnchangedBatch !(Vector RecordedEvent)-    | TransformedBatch !(Vector DecodedEvent)-    deriving stock (Eq, Show)+  = UnchangedBatch !(Vector RecordedEvent)+  | TransformedBatch !(Vector DecodedEvent)+  deriving stock (Eq, Show)  decodedEventRecorded :: DecodedEvent -> RecordedEvent decodedEventRecorded = \case-    Decoded event -> event-    Undecodable event _ -> event+  Decoded event -> event+  Undecodable event _ -> event  decodedBatchLength :: DecodedBatch -> Int decodedBatchLength = \case-    UnchangedBatch events -> V.length events-    TransformedBatch events -> V.length events+  UnchangedBatch events -> V.length events+  TransformedBatch events -> V.length events  decodedBatchLastEvent :: DecodedBatch -> RecordedEvent decodedBatchLastEvent = \case-    UnchangedBatch events -> V.last events-    TransformedBatch events -> decodedEventRecorded (V.last events)+  UnchangedBatch events -> V.last events+  TransformedBatch events -> decodedEventRecorded (V.last events)  filterDecodedBatch :: (RecordedEvent -> Bool) -> DecodedBatch -> DecodedBatch filterDecodedBatch predicate = \case-    UnchangedBatch events -> UnchangedBatch (V.filter predicate events)-    TransformedBatch events -> TransformedBatch (V.filter (predicate . decodedEventRecorded) events)--{- | Interpreter-level hooks for cross-cutting concerns at the-event-data boundary. All fields default to 'Nothing' (no-op).--* 'enrichEvent' fires on the append path before the SQL encoder runs,-  on the typed 'EventData' the caller supplied. Used to inject trace-  contexts, attach tenant ids, or encrypt payloads.--* 'decodeHook' fires on the read and subscription paths after the SQL-  decoder runs, on the typed 'RecordedEvent' about to be surfaced to-  the caller. Used to decrypt payloads, redact PII, or attach derived-  metadata.+  UnchangedBatch events -> UnchangedBatch (V.filter predicate events)+  TransformedBatch events -> TransformedBatch (V.filter (predicate . decodedEventRecorded) events) -When a field is 'Nothing', reads and enrichment return their input directly.-Subscription decoding retains the vector in one batch constructor without-traversal or per-event wrappers.--}+-- | Interpreter-level hooks for cross-cutting concerns at the+-- event-data boundary. All fields default to 'Nothing' (no-op).+--+-- * 'enrichEvent' fires on the append path before the SQL encoder runs,+--   on the typed 'EventData' the caller supplied. Used to inject trace+--   contexts, attach tenant ids, or encrypt payloads.+--+-- * 'decodeHook' fires on the read and subscription paths after the SQL+--   decoder runs, on the typed 'RecordedEvent' about to be surfaced to+--   the caller. Used to decrypt payloads, redact PII, or attach derived+--   metadata.+--+-- When a field is 'Nothing', reads and enrichment return their input directly.+-- Subscription decoding retains the vector in one batch constructor without+-- traversal or per-event wrappers. data StoreSettings = StoreSettings-    { enrichEvent :: !(Maybe (EventData -> IO EventData))-    -- ^ Append-path hook. Runs once per appended event before encoding.-    , decodeHook :: !(Maybe (RecordedEvent -> IO (Either DecodeFailure RecordedEvent)))-    {- ^ Read- and subscription-path hook. Return 'Left' for an undecodable-    event: reads fail with a typed store error, subscriptions use their-    optional undecodable handler or retry and stop by default. Exceptions-    remain programming failures. Runs once per surfaced event; an undecodable-    event's retry re-applies the hook to its original value.-    -}-    }-    deriving stock (Generic)+  { -- | Append-path hook. Runs once per appended event before encoding.+    enrichEvent :: !(Maybe (EventData -> IO EventData)),+    -- | Read- and subscription-path hook. Return 'Left' for an undecodable+    --     event: reads fail with a typed store error, subscriptions use their+    --     optional undecodable handler or retry and stop by default. Exceptions+    --     remain programming failures. Runs once per surfaced event; an undecodable+    --     event's retry re-applies the hook to its original value.+    decodeHook :: !(Maybe (RecordedEvent -> IO (Either DecodeFailure RecordedEvent)))+  }+  deriving stock (Generic)  -- | Defaults to both hooks being 'Nothing' — semantically a no-op. defaultStoreSettings :: StoreSettings defaultStoreSettings =-    StoreSettings-        { enrichEvent = Nothing-        , decodeHook = Nothing-        }+  StoreSettings+    { enrichEvent = Nothing,+      decodeHook = Nothing+    } -{- | Apply 'enrichEvent' to a list of events. When the hook is-'Nothing', returns the list unchanged with no traversal.--}+-- | Apply 'enrichEvent' to a list of events. When the hook is+-- 'Nothing', returns the list unchanged with no traversal. enrichEvents :: StoreSettings -> [EventData] -> IO [EventData] enrichEvents ss xs = case enrichEvent ss of-    Nothing -> pure xs-    Just f -> traverse f xs+  Nothing -> pure xs+  Just f -> traverse f xs -{- | Apply 'decodeHook' to a vector of events. When the hook is-'Nothing', retains the vector unchanged without traversal or per-event wrappers.--}+-- | Apply 'decodeHook' to a vector of events. When the hook is+-- 'Nothing', retains the vector unchanged without traversal or per-event wrappers. decodeEvents :: StoreSettings -> Vector RecordedEvent -> IO DecodedBatch decodeEvents ss xs = case decodeHook ss of-    Nothing -> pure (UnchangedBatch xs)-    Just f -> TransformedBatch <$> V.mapM (applyDecode f) xs+  Nothing -> pure (UnchangedBatch xs)+  Just f -> TransformedBatch <$> V.mapM (applyDecode f) xs  -- | Re-apply the hook to one raw undecodable event on a subscriber retry. decodeEvent :: StoreSettings -> RecordedEvent -> IO DecodedEvent decodeEvent ss event = case decodeHook ss of-    Nothing -> pure (Decoded event)-    Just f -> applyDecode f event+  Nothing -> pure (Decoded event)+  Just f -> applyDecode f event  applyDecode :: (RecordedEvent -> IO (Either DecodeFailure RecordedEvent)) -> RecordedEvent -> IO DecodedEvent applyDecode f event = either (Undecodable event) Decoded <$> f event
src/Kiroku/Store/Subscription.hs view
@@ -1,5 +1,5 @@-module Kiroku.Store.Subscription (-    -- * Subscribe+module Kiroku.Store.Subscription+  ( -- * Subscribe     subscribe,     withSubscription, @@ -12,7 +12,8 @@      -- * Types     module Kiroku.Store.Subscription.Types,-) where+  )+where  import Control.Concurrent.Async qualified as Async import Control.Concurrent.STM (atomically, modifyTVar', newTVarIO, readTVarIO)@@ -39,313 +40,314 @@ import Kiroku.Store.Subscription.Worker (LiveSource (..), configMember, runWorker) import Kiroku.Store.Types (CategoryName (..), GlobalPosition (..)) -{- | Start a subscription. Returns a handle for cancellation and waiting.--The subscription spawns a worker thread that:--1. Resolves the exact @(subscription name, consumer-group member)@ checkpoint.-   An existing row always wins. If absent, 'missingCheckpointPolicy' durably-   seeds zero, atomically seeds the current @$all@ head, or fails before the-   handler runs. A database error fails the worker loudly through 'wait'; it-   does not fall back to 0.-2. Catches up by querying the database directly until it reaches the-   'Kiroku.Store.Subscription.EventPublisher.lastPublished' cursor.-3. Switches to live mode. For 'Kiroku.Store.Subscription.Types.AllStreams'-   subscriptions outside a consumer group, the worker reads pre-broadcast-   events from the publisher's bounded per-subscriber queue. For-   'Kiroku.Store.Subscription.Types.Category' subscriptions and all-   consumer-group subscriptions, no publisher queue is created; the worker-   re-queries the database in live mode.--The returned 'SubscriptionHandle' carries an implicit lifecycle contract:-the worker thread runs until 'cancel' is called or the handler returns 'Stop'.-Forgetting to cancel leaks the thread. Prefer 'withSubscription' for any-non-trivial code path; use the bare 'subscribe' only when the caller already-has a structured lifecycle (e.g., the Streamly 'subscriptionStream' bridge).--=== Delivery semantics--Events are delivered __at least once__. The store's checkpoint is advanced-__per batch__, not per event: when the handler returns 'Continue' for every-event in a batch the checkpoint is saved at the batch tail; when the handler-returns 'Stop' for some event the checkpoint is saved at that event. The-following events on the boundary therefore __replay__ on the next-subscription with the same 'Kiroku.Store.Subscription.Types.SubscriptionName':--* The handler returned 'Continue' but the worker was cancelled or the-  process crashed before 'saveCheckpoint' completed. The events processed-  by the handler are re-delivered.-* The handler was interrupted between events (cancellation, crash) inside-  one batch. The events already processed are re-delivered along with the-  not-yet-processed ones.-* The publisher could not reach the database for one cycle (transient-  pool error). The next cycle re-fetches and re-broadcasts; subscribers-  that had already exited catch-up may see those events delivered late-  with no checkpoint advance in between, so a subsequent restart replays-  them.--Handlers must therefore be idempotent — process a duplicate event without-producing a wrong-on-replay result — or be tolerant of duplicates by some-domain-specific check (e.g., a unique key on the projection table).--=== Failure modes--The 'Kiroku.Store.Subscription.Types.SubscriptionHandleM.wait' on the-returned handle resolves with one of:--* @Right ()@ — the handler returned 'Stop' for some event and the worker-  exited cleanly. The checkpoint is saved at that event.-* @Left e@ where @e@ is 'Control.Concurrent.Async.AsyncCancelled' — the-  caller invoked 'cancel'. No checkpoint advance is guaranteed; events-  in flight at cancellation time will replay.-* @Left e@ where @e@ is-  'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' — the publisher-  marked this non-group 'Kiroku.Store.Subscription.Types.AllStreams'-  subscription overflowed under-  'Kiroku.Store.Subscription.Types.DropSubscription'. Category and-  consumer-group subscriptions do not own publisher queues, so-  'queueCapacity' and 'overflowPolicy' have no effect for them.-  Investigate the slow handler and either fix the slowness, raise-  'queueCapacity', or switch to 'Kiroku.Store.Subscription.Types.DropOldest'-  if the consumer can tolerate event loss.-* @Left e@ where @e@ is-  'Kiroku.Store.Subscription.Types.SubscriptionUndecodable' — the default-  decode retry policy exhausted. The exception carries 'DecodeFailure';-  the checkpoint remains before the failed event. Fix the hook and restart-  the same subscription to replay it.-* @Left e@ where @e@ is a 'Hasql.Pool.UsageError' from checkpoint load —-  startup could not read the saved checkpoint. The worker stops rather than-  silently replaying from global position 0.-* @Left e@ where @e@ is-  'Kiroku.Store.Subscription.Types.SubscriptionCheckpointMissing' —-  'Kiroku.Store.Subscription.Types.FailIfMissing' refused an absent exact key.-  No checkpoint row was inserted and the handler did not run.-* @Left e@ for any exception thrown by the handler — handler exceptions-  are not caught; the worker thread dies and the original exception-  propagates to the consumer. This is intentional: a handler that-  throws is signalling that the subscription cannot proceed safely.--}+-- | Start a subscription. Returns a handle for cancellation and waiting.+--+-- The subscription spawns a worker thread that:+--+-- 1. Resolves the exact @(subscription name, consumer-group member)@ checkpoint.+--    An existing row always wins. If absent, 'missingCheckpointPolicy' durably+--    seeds zero, atomically seeds the current @$all@ head, or fails before the+--    handler runs. A database error fails the worker loudly through 'wait'; it+--    does not fall back to 0.+-- 2. Catches up by querying the database directly until it reaches the+--    'Kiroku.Store.Subscription.EventPublisher.lastPublished' cursor. Namespace+--    targets instead capture the committed database head and drain bounded+--    global windows through it, including windows without matching events.+-- 3. Switches to live mode. For 'Kiroku.Store.Subscription.Types.AllStreams'+--    subscriptions outside a consumer group, the worker reads pre-broadcast+--    events from the publisher's bounded per-subscriber queue. For+--    'Kiroku.Store.Subscription.Types.Category' subscriptions and all+--    consumer-group subscriptions, no publisher queue is created; the worker+--    re-queries the database in live mode.+--+-- The returned 'SubscriptionHandle' carries an implicit lifecycle contract:+-- the worker thread runs until 'cancel' is called or the handler returns 'Stop'.+-- Forgetting to cancel leaks the thread. Prefer 'withSubscription' for any+-- non-trivial code path; use the bare 'subscribe' only when the caller already+-- has a structured lifecycle (e.g., the Streamly 'subscriptionStream' bridge).+--+-- === Delivery semantics+--+-- Events are delivered __at least once__. The store's checkpoint is advanced+-- __per batch__, not per event: when the handler returns 'Continue' for every+-- event in a batch the checkpoint is saved at the batch tail (or the completed+-- scan frontier for a namespace window); when the handler+-- returns 'Stop' for some event the checkpoint is saved at that event. The+-- following events on the boundary therefore __replay__ on the next+-- subscription with the same 'Kiroku.Store.Subscription.Types.SubscriptionName':+--+-- * The handler returned 'Continue' but the worker was cancelled or the+--   process crashed before 'saveCheckpoint' completed. The events processed+--   by the handler are re-delivered.+-- * The handler was interrupted between events (cancellation, crash) inside+--   one batch. The events already processed are re-delivered along with the+--   not-yet-processed ones.+-- * The publisher could not reach the database for one cycle (transient+--   pool error). The next cycle re-fetches and re-broadcasts; subscribers+--   that had already exited catch-up may see those events delivered late+--   with no checkpoint advance in between, so a subsequent restart replays+--   them.+--+-- Handlers must therefore be idempotent — process a duplicate event without+-- producing a wrong-on-replay result — or be tolerant of duplicates by some+-- domain-specific check (e.g., a unique key on the projection table).+--+-- === Failure modes+--+-- The 'Kiroku.Store.Subscription.Types.SubscriptionHandleM.wait' on the+-- returned handle resolves with one of:+--+-- * @Right ()@ — the handler returned 'Stop' for some event and the worker+--   exited cleanly. The checkpoint is saved at that event.+-- * @Left e@ where @e@ is 'Control.Concurrent.Async.AsyncCancelled' — the+--   caller invoked 'cancel'. No checkpoint advance is guaranteed; events+--   in flight at cancellation time will replay.+-- * @Left e@ where @e@ is+--   'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' — the publisher+--   marked this non-group 'Kiroku.Store.Subscription.Types.AllStreams'+--   subscription overflowed under+--   'Kiroku.Store.Subscription.Types.DropSubscription'. Category and+--   consumer-group subscriptions do not own publisher queues, so+--   'queueCapacity' and 'overflowPolicy' have no effect for them.+--   Investigate the slow handler and either fix the slowness, raise+--   'queueCapacity', or switch to 'Kiroku.Store.Subscription.Types.DropOldest'+--   if the consumer can tolerate event loss.+-- * @Left e@ where @e@ is+--   'Kiroku.Store.Subscription.Types.SubscriptionUndecodable' — the default+--   decode retry policy exhausted. The exception carries 'DecodeFailure';+--   the checkpoint remains before the failed event. Fix the hook and restart+--   the same subscription to replay it.+-- * @Left e@ where @e@ is a 'Hasql.Pool.UsageError' from checkpoint load —+--   startup could not read the saved checkpoint. The worker stops rather than+--   silently replaying from global position 0.+-- * @Left e@ where @e@ is+--   'Kiroku.Store.Subscription.Types.SubscriptionCheckpointMissing' —+--   'Kiroku.Store.Subscription.Types.FailIfMissing' refused an absent exact key.+--   No checkpoint row was inserted and the handler did not run.+-- * @Left e@ for any exception thrown by the handler — handler exceptions+--   are not caught; the worker thread dies and the original exception+--   propagates to the consumer. This is intentional: a handler that+--   throws is signalling that the subscription cannot proceed safely. subscribe :: (MonadIO m) => KirokuStore -> SubscriptionConfig -> m SubscriptionHandle subscribe store config = liftIO $ do-    mask $ \_restore ->+  mask $ \_restore ->+    bracketOnError+      ( case (consumerGroup config, target config) of+          (Nothing, AllStreams) -> do+            (queue, statusVar, unsubscribe) <-+              atomically $+                Pub.subscribePublisher+                  (store ^. #publisher)+                  (queueCapacity config)+                  (overflowPolicy config)+            pure (LiveFromPublisherQueue queue statusVar, unsubscribe)+          -- Plain categories and consumer-group category members both wake+          -- on the category's NOTIFY generation; a member's fetch applies+          -- its partition predicate in SQL.+          (_, Category (CategoryName cat)) ->+            pure (LiveFromCategoryNotify cat, pure ())+          (_, CategoryNamespace namespace) -> do+            (generation, release) <-+              atomically $+                Notifier.registerNamespace (Notifier.namespaceRegistry (store ^. #notifier)) namespace+            pure (LiveFromNamespaceNotify generation, atomically release)+          (Just _, AllStreams) ->+            pure (LiveFromGroupPolling, pure ())+      )+      (\(_, unsubscribe) -> unsubscribe)+      $ \(liveSource, unsubscribe) -> do+        -- The worker writes its current FSM state here on every transition; the+        -- handle's 'currentState' reads it. Seeded with the catch-up entry state so+        -- a read before the worker's first transition is sensible.+        stateVar <- newTVarIO (CatchingUp (GlobalPosition 0) 0)+        let pubPosVar = Pub.lastPublished (store ^. #publisher)+            catGenVar = Notifier.categoryGenerations (store ^. #notifier)+        -- Register this worker's state cell into the store's central registry under+        -- (name, member). A fresh token identifies *this* worker so that an older+        -- worker's cleanup cannot delete a newer worker's replacement entry under+        -- the same key, and a held handle reads only the cell it registered.+        token <- newUnique+        let reg = store ^. #subscriptionRegistry+            key = (name config, configMember config)+            deregister =+              atomically+                ( modifyTVar' reg $+                    Map.update+                      ( \(tok', cell) ->+                          if tok' == token+                            then Nothing+                            else Just (tok', cell)+                      )+                      key+                )+            -- `finally cleanup` removes this subscription from the publisher's+            -- registry whenever the worker exits — gracefully on Stop, by+            -- cancellation, or on any exception (including SubscriptionOverflowed).+            -- Forgetting to unsubscribe would leave a registry entry with no+            -- reader, and the publisher would needlessly trigger this subscriber's+            -- overflow policy on the next batch. We extend the same `finally` to+            -- also deregister from the subscription-state registry on ANY exit, so+            -- the registry never leaks a stale entry. The delete is+            -- token-conditional so stale cleanup from an older duplicate-key worker+            -- cannot remove a newer worker's live entry.+            cleanup = unsubscribe >> deregister+        -- Insert before forking so a caller that reads a snapshot immediately after+        -- `subscribe` returns already sees this subscription.         bracketOnError-            ( case (consumerGroup config, target config) of-                (Nothing, AllStreams) -> do-                    (queue, statusVar, unsubscribe) <--                        atomically $-                            Pub.subscribePublisher-                                (store ^. #publisher)-                                (queueCapacity config)-                                (overflowPolicy config)-                    pure (LiveFromPublisherQueue queue statusVar, unsubscribe)-                -- Plain categories and consumer-group category members both wake-                -- on the category's NOTIFY generation; a member's fetch applies-                -- its partition predicate in SQL.-                (_, Category (CategoryName cat)) ->-                    pure (LiveFromCategoryNotify cat, pure ())-                (Just _, AllStreams) ->-                    pure (LiveFromGroupPolling, pure ())-            )-            (\(_, unsubscribe) -> unsubscribe)-            $ \(liveSource, unsubscribe) -> do-                -- The worker writes its current FSM state here on every transition; the-                -- handle's 'currentState' reads it. Seeded with the catch-up entry state so-                -- a read before the worker's first transition is sensible.-                stateVar <- newTVarIO (CatchingUp (GlobalPosition 0) 0)-                let pubPosVar = Pub.lastPublished (store ^. #publisher)-                    catGenVar = Notifier.categoryGenerations (store ^. #notifier)-                -- Register this worker's state cell into the store's central registry under-                -- (name, member). A fresh token identifies *this* worker so that an older-                -- worker's cleanup cannot delete a newer worker's replacement entry under-                -- the same key, and a held handle reads only the cell it registered.-                token <- newUnique-                let reg = store ^. #subscriptionRegistry-                    key = (name config, configMember config)-                    deregister =-                        atomically-                            ( modifyTVar' reg $-                                Map.update-                                    ( \(tok', cell) ->-                                        if tok' == token-                                            then Nothing-                                            else Just (tok', cell)-                                    )-                                    key-                            )-                    -- `finally cleanup` removes this subscription from the publisher's-                    -- registry whenever the worker exits — gracefully on Stop, by-                    -- cancellation, or on any exception (including SubscriptionOverflowed).-                    -- Forgetting to unsubscribe would leave a registry entry with no-                    -- reader, and the publisher would needlessly trigger this subscriber's-                    -- overflow policy on the next batch. We extend the same `finally` to-                    -- also deregister from the subscription-state registry on ANY exit, so-                    -- the registry never leaks a stale entry. The delete is-                    -- token-conditional so stale cleanup from an older duplicate-key worker-                    -- cannot remove a newer worker's live entry.-                    cleanup = unsubscribe >> deregister-                -- Insert before forking so a caller that reads a snapshot immediately after-                -- `subscribe` returns already sees this subscription.-                bracketOnError-                    (atomically $ modifyTVar' reg (Map.insert key (token, stateVar)))-                    (const deregister)-                    $ \() -> do-                        -- Every acquisition above is paired with a release in the-                        -- pre-fork window; once asyncWithUnmask returns, ownership of-                        -- both releases has transferred to the worker thread's cleanup.-                        thread <--                            Async.asyncWithUnmask $ \unmask ->-                                unmask-                                    (runWorker (store ^. #pool) liveSource stateVar pubPosVar catGenVar config (store ^. #eventHandler) (store ^. #storeSettings))-                                    `finally` cleanup-                        pure-                            SubscriptionHandle-                                { cancel = Async.cancel thread-                                , wait = Async.waitCatch thread-                                , currentState = do-                                    m <- readTVarIO reg-                                    case Map.lookup key m of-                                        Just (tok, cell) | tok == token -> Just <$> readTVarIO cell-                                        _ -> pure Nothing-                                }--{- | Bracket-style subscription lifecycle.--Starts a subscription, runs the body, and ensures the underlying worker-thread is cancelled on either normal exit or an exception thrown inside-the body. This is the recommended way to use subscriptions: forgetting-to call 'cancel' on a 'SubscriptionHandle' leaks the worker thread for-the lifetime of the process.--Equivalent to:--    bracket (subscribe store config) cancel action+          (atomically $ modifyTVar' reg (Map.insert key (token, stateVar)))+          (const deregister)+          $ \() -> do+            -- Every acquisition above is paired with a release in the+            -- pre-fork window; once asyncWithUnmask returns, ownership of+            -- both releases has transferred to the worker thread's cleanup.+            thread <-+              Async.asyncWithUnmask $ \unmask ->+                unmask+                  (runWorker (store ^. #pool) liveSource stateVar pubPosVar catGenVar config (store ^. #eventHandler) (store ^. #storeSettings))+                  `finally` cleanup+            pure+              SubscriptionHandle+                { cancel = Async.cancel thread,+                  wait = Async.waitCatch thread,+                  currentState = do+                    m <- readTVarIO reg+                    case Map.lookup key m of+                      Just (tok, cell) | tok == token -> Just <$> readTVarIO cell+                      _ -> pure Nothing+                } -but lifted to any 'MonadUnliftIO' so it composes with effectful stacks-that already have an unlift in scope.--}+-- | Bracket-style subscription lifecycle.+--+-- Starts a subscription, runs the body, and ensures the underlying worker+-- thread is cancelled on either normal exit or an exception thrown inside+-- the body. This is the recommended way to use subscriptions: forgetting+-- to call 'cancel' on a 'SubscriptionHandle' leaks the worker thread for+-- the lifetime of the process.+--+-- Equivalent to:+--+--     bracket (subscribe store config) cancel action+--+-- but lifted to any 'MonadUnliftIO' so it composes with effectful stacks+-- that already have an unlift in scope. withSubscription ::-    (MonadUnliftIO m) =>-    KirokuStore ->-    SubscriptionConfig ->-    (SubscriptionHandle -> m a) ->-    m a+  (MonadUnliftIO m) =>+  KirokuStore ->+  SubscriptionConfig ->+  (SubscriptionHandle -> m a) ->+  m a withSubscription store config action = withRunInIO $ \runInIO ->-    bracket (subscribe store config) cancel (runInIO . action)--{- | Read the durable checkpoint inventory and global store position captured by-one PostgreSQL statement snapshot.--An empty 'checkpoints' vector means that no subscription checkpoint has yet-been written. Unlike 'subscriptionStates', stopped subscriptions remain in this-inventory because their committed rows are durable. Rows are sorted by-subscription name and consumer-group member, and a new call is required to-observe commits made after this snapshot.+  bracket (subscribe store config) cancel (runInIO . action) -A live worker cursor may be ahead of its durable checkpoint while work is in-flight. Member zero alone does not reveal whether a subscription is ungrouped-or is member zero of a group. 'checkpointUpdatedAt' records the last successful-checkpoint write, not proof that its position advanced. Finally, subtracting a-'checkpointPosition' from 'storePosition' yields a global position distance,-not an exact count of relevant events for filtered, category, or sharded-consumers.--}+-- | Read the durable checkpoint inventory and global store position captured by+-- one PostgreSQL statement snapshot.+--+-- An empty 'checkpoints' vector means that no subscription checkpoint has yet+-- been written. Unlike 'subscriptionStates', stopped subscriptions remain in this+-- inventory because their committed rows are durable. Rows are sorted by+-- subscription name and consumer-group member, and a new call is required to+-- observe commits made after this snapshot.+--+-- A live worker cursor may be ahead of its durable checkpoint while work is in+-- flight. Member zero alone does not reveal whether a subscription is ungrouped+-- or is member zero of a group. 'checkpointUpdatedAt' records the last successful+-- checkpoint write, not proof that its position advanced. Finally, subtracting a+-- 'checkpointPosition' from 'storePosition' yields a global position distance,+-- not an exact count of relevant events for filtered, category, or sharded+-- consumers. subscriptionCheckpointInventory ::-    (HasCallStack, Store :> es) =>-    Eff es SubscriptionCheckpointInventory+  (HasCallStack, Store :> es) =>+  Eff es SubscriptionCheckpointInventory subscriptionCheckpointInventory = send GetSubscriptionCheckpointInventory -{- | Resolve the durable checkpoint for one exact @(subscription name,-consumer-group member)@ key.--An existing row is returned unchanged for every policy. When no row exists,-'FromBeginning' atomically inserts position zero, 'FromCurrentHead' atomically-inserts the current @$all@ store position, and 'FailIfMissing' returns-'SubscriptionCheckpointMissing' without inserting. Concurrent initializers-converge on the first committed row.--}+-- | Resolve the durable checkpoint for one exact @(subscription name,+-- consumer-group member)@ key.+--+-- An existing row is returned unchanged for every policy. When no row exists,+-- 'FromBeginning' atomically inserts position zero, 'FromCurrentHead' atomically+-- inserts the current @$all@ store position, and 'FailIfMissing' returns+-- 'SubscriptionCheckpointMissing' without inserting. Concurrent initializers+-- converge on the first committed row. initializeSubscriptionCheckpoint ::-    (HasCallStack, Store :> es) =>-    SubscriptionName ->-    Int32 ->-    MissingCheckpointPolicy ->-    Eff es (Either SubscriptionCheckpointMissing CheckpointInitialization)+  (HasCallStack, Store :> es) =>+  SubscriptionName ->+  Int32 ->+  MissingCheckpointPolicy ->+  Eff es (Either SubscriptionCheckpointMissing CheckpointInitialization) initializeSubscriptionCheckpoint subscriptionName member policy =-    send (InitializeSubscriptionCheckpoint subscriptionName member policy)--{- | A public, point-in-time view of one live subscription's state, as returned-by 'subscriptionStates'. This is the committed observability surface external-consumers (a future Prometheus exporter, an admin tool) read directly; it is-intentionally a flat view, not the internal 'SubscriptionState' cell type.--* 'subscriptionName' / 'member' identify the subscription (member 0 for a-  non-group subscription), matching the registry/checkpoint key.-* 'state' is the live 'SubscriptionState' read from the worker's registered cell.-* 'statePhase' is a stable, low-cardinality label (via-  'Kiroku.Store.Subscription.Fsm.stateName') suitable as a metric label value or-  admin column; it does not drift if a constructor's fields change.-* 'cursor' is the worker FSM cursor (via 'Kiroku.Store.Subscription.Fsm.stateCursor').-  It is the cheap live progress signal for observability, not a guaranteed-  durable checkpoint row.+  send (InitializeSubscriptionCheckpoint subscriptionName member policy) -Read fields via the codebase's @generic-lens@ convention, e.g. @view ^. #state@.--}+-- | A public, point-in-time view of one live subscription's state, as returned+-- by 'subscriptionStates'. This is the committed observability surface external+-- consumers (a future Prometheus exporter, an admin tool) read directly; it is+-- intentionally a flat view, not the internal 'SubscriptionState' cell type.+--+-- * 'subscriptionName' / 'member' identify the subscription (member 0 for a+--   non-group subscription), matching the registry/checkpoint key.+-- * 'state' is the live 'SubscriptionState' read from the worker's registered cell.+-- * 'statePhase' is a stable, low-cardinality label (via+--   'Kiroku.Store.Subscription.Fsm.stateName') suitable as a metric label value or+--   admin column; it does not drift if a constructor's fields change.+-- * 'cursor' is the worker FSM cursor (via 'Kiroku.Store.Subscription.Fsm.stateCursor').+--   It is the cheap live progress signal for observability, not a guaranteed+--   durable checkpoint row.+--+-- Read fields via the codebase's @generic-lens@ convention, e.g. @view ^. #state@. data SubscriptionStateView = SubscriptionStateView-    { subscriptionName :: !SubscriptionName-    , member :: !Int32-    , state :: !SubscriptionState-    , statePhase :: !Text-    , cursor :: !GlobalPosition-    }-    deriving stock (Show, Generic)--{- | A near-instant snapshot of every live subscription as a public-'SubscriptionStateView', keyed by (subscription name, consumer-group member;-0 for non-group).--Snapshots the registry's outer map with 'readTVarIO', then reads each registered-state cell with 'readTVarIO' __outside__ STM. This is deliberately not one STM-transaction over all cells: a single transaction would put every subscription's-state cell in the reader's STM read set, and since each worker writes its cell-~once per batch, any such write would force the whole scan to re-run — a-reader-side cost that scales with subscription count. The named consumers (a-Prometheus scrape, an admin listing) read independent values and do not need a-globally atomic snapshot, so each entry is read as its own freshest value.+  { subscriptionName :: !SubscriptionName,+    member :: !Int32,+    state :: !SubscriptionState,+    statePhase :: !Text,+    cursor :: !GlobalPosition+  }+  deriving stock (Show, Generic) -A subscription appears here from the moment 'subscribe' returns its handle until-its worker exits (stop, cancel, or crash), at which point its key is removed; a-stopped/cancelled/crashed subscription is represented by __absence__, never by a-@"stopped"@ phase (the FSM never writes 'Stopped' into the cell). This map of-view records is the committed surface the future Prometheus exporter and admin-tool consume.--}+-- | A near-instant snapshot of every live subscription as a public+-- 'SubscriptionStateView', keyed by (subscription name, consumer-group member;+-- 0 for non-group).+--+-- Snapshots the registry's outer map with 'readTVarIO', then reads each registered+-- state cell with 'readTVarIO' __outside__ STM. This is deliberately not one STM+-- transaction over all cells: a single transaction would put every subscription's+-- state cell in the reader's STM read set, and since each worker writes its cell+-- ~once per batch, any such write would force the whole scan to re-run — a+-- reader-side cost that scales with subscription count. The named consumers (a+-- Prometheus scrape, an admin listing) read independent values and do not need a+-- globally atomic snapshot, so each entry is read as its own freshest value.+--+-- A subscription appears here from the moment 'subscribe' returns its handle until+-- its worker exits (stop, cancel, or crash), at which point its key is removed; a+-- stopped/cancelled/crashed subscription is represented by __absence__, never by a+-- @"stopped"@ phase (the FSM never writes 'Stopped' into the cell). This map of+-- view records is the committed surface the future Prometheus exporter and admin+-- tool consume. subscriptionStates :: KirokuStore -> IO (Map (SubscriptionName, Int32) SubscriptionStateView) subscriptionStates store = do-    cells <- readTVarIO (store ^. #subscriptionRegistry)-    Map.traverseWithKey-        ( \(nm, mbr) (_tok, cell) -> do-            st <- readTVarIO cell-            pure-                SubscriptionStateView-                    { subscriptionName = nm-                    , member = mbr-                    , state = st-                    , statePhase = stateName st-                    , cursor = stateCursor st-                    }-        )-        cells--{- | Read one subscription's dead letters, newest first by global position and-then dead-letter id. Echo the page's 'nextCursor' as 'after' for an exclusive-next page. 'consumerGroupMember' Nothing includes all historical members; Just-selects one (ungrouped subscriptions use 0). All-member work is bounded by the-number of historical members times the page size; use member-scoped polling for-large groups. One statement and one pool checkout serve a page.+  cells <- readTVarIO (store ^. #subscriptionRegistry)+  Map.traverseWithKey+    ( \(nm, mbr) (_tok, cell) -> do+        st <- readTVarIO cell+        pure+          SubscriptionStateView+            { subscriptionName = nm,+              member = mbr,+              state = st,+              statePhase = stateName st,+              cursor = stateCursor st+            }+    )+    cells -The reason JSON is returned unchanged, without an event decoding hook. An empty-page means no dead letters were recorded for this query. Hard-deleting source-streams can remove rows, but their cursors remain valid. This operation neither-writes a checkpoint nor changes the worker or cleanup paths.--}+-- | Read one subscription's dead letters, newest first by global position and+-- then dead-letter id. Echo the page's 'nextCursor' as 'after' for an exclusive+-- next page. 'consumerGroupMember' Nothing includes all historical members; Just+-- selects one (ungrouped subscriptions use 0). All-member work is bounded by the+-- number of historical members times the page size; use member-scoped polling for+-- large groups. One statement and one pool checkout serve a page.+--+-- The reason JSON is returned unchanged, without an event decoding hook. An empty+-- page means no dead letters were recorded for this query. Hard-deleting source+-- streams can remove rows, but their cursors remain valid. This operation neither+-- writes a checkpoint nor changes the worker or cleanup paths. subscriptionDeadLetters :: (HasCallStack, Store :> es) => SubscriptionDeadLetterQuery -> Eff es SubscriptionDeadLetterPage subscriptionDeadLetters = send . ListSubscriptionDeadLetters
src/Kiroku/Store/Subscription/Checkpoint.hs view
@@ -1,17 +1,17 @@-{- | Explicit mutation operations for durable subscription checkpoints.--Ordinary subscription checkpoint saves are monotonic. This module owns the-separate reset, resize and rebind operations for callers that need to move-persisted progress backward or forward as part of a larger transaction.--}-module Kiroku.Store.Subscription.Checkpoint (-    SubscriptionCheckpointResetReport (..),+-- | Explicit mutation operations for durable subscription checkpoints.+--+-- Ordinary subscription checkpoint saves are monotonic. This module owns the+-- separate reset, resize and rebind operations for callers that need to move+-- persisted progress backward or forward as part of a larger transaction.+module Kiroku.Store.Subscription.Checkpoint+  ( SubscriptionCheckpointResetReport (..),     resetSubscriptionCheckpointsTx,     ConsumerGroupResizeReport (..),     resizeConsumerGroupTx,     SubscriptionTargetRebindReport (..),     rebindSubscriptionTargetTx,-) where+  )+where  import Data.Int (Int32) import Data.List (nub, sort)@@ -22,122 +22,117 @@ import GHC.Generics (Generic) import Hasql.Transaction qualified as Tx import Kiroku.Store.Subscription.Checkpoint.SQL qualified as SQL-import Kiroku.Store.Subscription.Types (-    ConsumerGroupSize,+import Kiroku.Store.Subscription.Types+  ( ConsumerGroupSize,     SubscriptionCheckpointKey (..),     SubscriptionName (..),     SubscriptionTarget,     consumerGroupSizeValue,- )+  ) import Kiroku.Store.Types (GlobalPosition (..)) -{- | Exact result of resetting a non-empty set of subscription names.--'resetCheckpointKeys' contains every persisted @(name, member)@ row that was-updated. 'missingSubscriptionNames' contains requested names for which no row-existed. Both vectors are sorted by subscription name (and then member for-keys); duplicate requested names appear only once in the report.--}+-- | Exact result of resetting a non-empty set of subscription names.+--+-- 'resetCheckpointKeys' contains every persisted @(name, member)@ row that was+-- updated. 'missingSubscriptionNames' contains requested names for which no row+-- existed. Both vectors are sorted by subscription name (and then member for+-- keys); duplicate requested names appear only once in the report. data SubscriptionCheckpointResetReport = SubscriptionCheckpointResetReport-    { resetCheckpointKeys :: !(Vector SubscriptionCheckpointKey)-    , missingSubscriptionNames :: !(Vector SubscriptionName)-    }-    deriving stock (Eq, Show, Generic)--{- | Set every existing checkpoint member for the requested subscription names-to the exact target position and return complete deterministic evidence.+  { resetCheckpointKeys :: !(Vector SubscriptionCheckpointKey),+    missingSubscriptionNames :: !(Vector SubscriptionName)+  }+  deriving stock (Eq, Show, Generic) -The operation treats duplicate requested names as one name, updates all-persisted members for each name, and never creates checkpoint rows for missing-names. Unlike ordinary worker saves, this operation can move a checkpoint-backward. It is a 'Tx.Transaction' combinator so a caller can atomically compose-the reset with its own projection fence and target preparation; condemning that-surrounding transaction rolls back all of those writes together.--}+-- | Set every existing checkpoint member for the requested subscription names+-- to the exact target position and return complete deterministic evidence.+--+-- The operation treats duplicate requested names as one name, updates all+-- persisted members for each name, and never creates checkpoint rows for missing+-- names. Unlike ordinary worker saves, this operation can move a checkpoint+-- backward. It is a 'Tx.Transaction' combinator so a caller can atomically compose+-- the reset with its own projection fence and target preparation; condemning that+-- surrounding transaction rolls back all of those writes together. resetSubscriptionCheckpointsTx ::-    NonEmpty SubscriptionName ->-    GlobalPosition ->-    Tx.Transaction SubscriptionCheckpointResetReport+  NonEmpty SubscriptionName ->+  GlobalPosition ->+  Tx.Transaction SubscriptionCheckpointResetReport resetSubscriptionCheckpointsTx names (GlobalPosition position) = do-    rows <--        Tx.statement-            ( Vector.fromList-                [name | SubscriptionName name <- NonEmpty.toList names]-            , position-            )-            SQL.resetSubscriptionCheckpointsStmt-    pure-        SubscriptionCheckpointResetReport-            { resetCheckpointKeys = Vector.mapMaybe resetKey rows-            , missingSubscriptionNames = Vector.mapMaybe missingName rows-            }+  rows <-+    Tx.statement+      ( Vector.fromList+          [name | SubscriptionName name <- NonEmpty.toList names],+        position+      )+      SQL.resetSubscriptionCheckpointsStmt+  pure+    SubscriptionCheckpointResetReport+      { resetCheckpointKeys = Vector.mapMaybe resetKey rows,+        missingSubscriptionNames = Vector.mapMaybe missingName rows+      }   where     resetKey (name, Just member) =-        Just (SubscriptionCheckpointKey (SubscriptionName name) member)+      Just (SubscriptionCheckpointKey (SubscriptionName name) member)     resetKey (_, Nothing) = Nothing     missingName (name, Nothing) = Just (SubscriptionName name)     missingName (_, Just _) = Nothing -{- | Evidence of explicit topology equalization. Previous sizes are sorted and-distinct; all new members resume from 'resumePosition'. A missing group starts-at zero. A repeat reports the now-equalized topology without moving progress.--}+-- | Evidence of explicit topology equalization. Previous sizes are sorted and+-- distinct; all new members resume from 'resumePosition'. A missing group starts+-- at zero. A repeat reports the now-equalized topology without moving progress. data ConsumerGroupResizeReport = ConsumerGroupResizeReport-    { previousSizes :: !(Vector Int32)-    , previousMemberCount :: !Int-    , newSize :: !ConsumerGroupSize-    , resumePosition :: !GlobalPosition-    }-    deriving stock (Eq, Show, Generic)+  { previousSizes :: !(Vector Int32),+    previousMemberCount :: !Int,+    newSize :: !ConsumerGroupSize,+    resumePosition :: !GlobalPosition+  }+  deriving stock (Eq, Show, Generic) -{- | Stop every worker for the name before calling this operation. Lock its-checkpoint set, rewind every new member to the old minimum, and remove obsolete-members atomically. Existing member identities are retained. A hash-assignment-change also requires this equalization even when the group size is unchanged.-The caller can compose or roll back the resize with application-owned SQL.--}+-- | Stop every worker for the name before calling this operation. Lock its+-- checkpoint set, rewind every new member to the old minimum, and remove obsolete+-- members atomically. Existing member identities are retained. A hash-assignment+-- change also requires this equalization even when the group size is unchanged.+-- The caller can compose or roll back the resize with application-owned SQL. resizeConsumerGroupTx ::-    SubscriptionName -> ConsumerGroupSize -> Tx.Transaction ConsumerGroupResizeReport+  SubscriptionName -> ConsumerGroupSize -> Tx.Transaction ConsumerGroupResizeReport resizeConsumerGroupTx (SubscriptionName name) newSize = do-    Tx.statement name SQL.lockCheckpointNameStmt-    rows <- Tx.statement name SQL.lockCheckpointRowsStmt-    let position = if Vector.null rows then 0 else Vector.minimum (Vector.map (\(_, _, p, _) -> p) rows)-        previousSizes = Vector.fromList . sort . nub $ [n | (_, n, _, _) <- Vector.toList rows]-    let binding = if Vector.null rows then Nothing else let (_, _, _, b) = Vector.head rows in b-        (kind, category) = SQL.targetColumns binding-    Tx.statement (name, consumerGroupSizeValue newSize, position, kind, category) SQL.resizeCheckpointMembersStmt-    pure-        ConsumerGroupResizeReport-            { previousSizes = previousSizes-            , previousMemberCount = Vector.length rows-            , newSize = newSize-            , resumePosition = GlobalPosition position-            }+  Tx.statement name SQL.lockCheckpointNameStmt+  rows <- Tx.statement name SQL.lockCheckpointRowsStmt+  let position = if Vector.null rows then 0 else Vector.minimum (Vector.map (\(_, _, p, _) -> p) rows)+      previousSizes = Vector.fromList . sort . nub $ [n | (_, n, _, _) <- Vector.toList rows]+  let binding = if Vector.null rows then Nothing else let (_, _, _, b) = Vector.head rows in b+      (kind, category) = SQL.targetColumns binding+  Tx.statement (name, consumerGroupSizeValue newSize, position, kind, category) SQL.resizeCheckpointMembersStmt+  pure+    ConsumerGroupResizeReport+      { previousSizes = previousSizes,+        previousMemberCount = Vector.length rows,+        newSize = newSize,+        resumePosition = GlobalPosition position+      }  -- | Rebind evidence includes every distinct prior binding; Nothing denotes legacy rows. data SubscriptionTargetRebindReport = SubscriptionTargetRebindReport-    { reboundMemberCount :: !Int-    , previousBindings :: !(Vector (Maybe SubscriptionTarget))-    , reboundTarget :: !SubscriptionTarget-    , reboundPosition :: !GlobalPosition-    }-    deriving stock (Eq, Show)+  { reboundMemberCount :: !Int,+    previousBindings :: !(Vector (Maybe SubscriptionTarget)),+    reboundTarget :: !SubscriptionTarget,+    reboundPosition :: !GlobalPosition+  }+  deriving stock (Eq, Show) -{- | Stop all workers first. Bind every existing member and explicitly reset its-position in the caller's transaction. A missing name fails the transaction;-repeating the operation leaves target and progress unchanged.--}+-- | Stop all workers first. Bind every existing member and explicitly reset its+-- position in the caller's transaction. A missing name fails the transaction;+-- repeating the operation leaves target and progress unchanged. rebindSubscriptionTargetTx ::-    SubscriptionName -> SubscriptionTarget -> GlobalPosition -> Tx.Transaction SubscriptionTargetRebindReport+  SubscriptionName -> SubscriptionTarget -> GlobalPosition -> Tx.Transaction SubscriptionTargetRebindReport rebindSubscriptionTargetTx (SubscriptionName name) target position@(GlobalPosition pos) = do-    Tx.statement name SQL.lockCheckpointNameStmt-    rows <- Tx.statement name SQL.lockCheckpointRowsStmt-    let (kind, category) = SQL.targetColumns (Just target)-    count <- Tx.statement (name, kind, category, pos) SQL.rebindCheckpointTargetStmt-    pure-        SubscriptionTargetRebindReport-            { reboundMemberCount = fromIntegral count-            , previousBindings = Vector.fromList . nub $ [binding | (_, _, _, binding) <- Vector.toList rows]-            , reboundTarget = target-            , reboundPosition = position-            }+  Tx.statement name SQL.lockCheckpointNameStmt+  rows <- Tx.statement name SQL.lockCheckpointRowsStmt+  let (kind, category) = SQL.targetColumns (Just target)+  count <- Tx.statement (name, kind, category, pos) SQL.rebindCheckpointTargetStmt+  pure+    SubscriptionTargetRebindReport+      { reboundMemberCount = fromIntegral count,+        previousBindings = Vector.fromList . nub $ [binding | (_, _, _, binding) <- Vector.toList rows],+        reboundTarget = target,+        reboundPosition = position+      }
src/Kiroku/Store/Subscription/Checkpoint/SQL.hs view
@@ -2,8 +2,8 @@ {-# LANGUAGE RankNTypes #-}  -- | Package-internal SQL for subscription checkpoint lifecycle operations.-module Kiroku.Store.Subscription.Checkpoint.SQL (-    initializeSubscriptionCheckpointSession,+module Kiroku.Store.Subscription.Checkpoint.SQL+  ( initializeSubscriptionCheckpointSession,     initializeWorkerCheckpointSession,     lockCheckpointNameStmt,     lockCheckpointRowsStmt,@@ -12,7 +12,8 @@     targetColumns,     saveBoundCheckpointSession,     rebindCheckpointTargetStmt,-) where+  )+where  import Contravariant.Extras (contrazip2, contrazip4, contrazip5, contrazip6) import Data.Int (Int32, Int64)@@ -27,8 +28,8 @@ import Hasql.Transaction qualified as Tx import Hasql.Transaction.Sessions qualified as TxSessions import Kiroku.Store.SQL qualified as SQL-import Kiroku.Store.Subscription.Types (-    CheckpointInitialization (..),+import Kiroku.Store.Subscription.Types+  ( CheckpointInitialization (..),     ConsumerGroupSizeMismatch (..),     MissingCheckpointPolicy (..),     SomeSubscriptionStartupFailure (..),@@ -38,202 +39,211 @@     SubscriptionTarget (..),     SubscriptionTargetMismatch (..),     TargetBindingPolicy (..),- )+  ) import Kiroku.Store.Types (CategoryName (..), GlobalPosition (..)) -{- | Resolve one checkpoint key in a Hasql session.--The first statement inserts the policy-selected position with @ON CONFLICT DO-NOTHING@ and then reads the winning row. PostgreSQL can report no row to that-final read when another transaction committed the conflicting insert after the-statement snapshot was taken. For an initializing policy, a second statement-therefore reads the now-committed winner on a fresh snapshot. 'FailIfMissing'-does not retry because it never attempts an insert.--}+-- | Resolve one checkpoint key in a Hasql session.+--+-- The name lock serializes topology adoption with worker startup, resize and+-- rebind. A newly inserted member inherits uniform sibling size and binding;+-- empty or inconsistent sets retain the size-1/unbound defaults. Existing rows+-- always win and sibling rows are never repaired here.+--+-- The insert statement inserts the policy-selected position with @ON CONFLICT DO+-- NOTHING@ and then reads the winning row. PostgreSQL can report no row to that+-- final read when another transaction committed the conflicting insert after the+-- statement snapshot was taken. For an initializing policy, a second statement+-- therefore reads the now-committed winner on a fresh snapshot. 'FailIfMissing'+-- does not retry because it never attempts an insert. initializeSubscriptionCheckpointSession ::-    SubscriptionName ->-    Int32 ->-    MissingCheckpointPolicy ->-    Session.Session (Either SubscriptionCheckpointMissing CheckpointInitialization)-initializeSubscriptionCheckpointSession name member policy =-    initializeCheckpointWith Session.statement name member 1 Nothing policy+  SubscriptionName ->+  Int32 ->+  MissingCheckpointPolicy ->+  Session.Session (Either SubscriptionCheckpointMissing CheckpointInitialization)+initializeSubscriptionCheckpointSession subscriptionName@(SubscriptionName name) member policy =+  TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+    Tx.statement name lockCheckpointNameStmt+    rows <- Tx.statement name readCheckpointRowsStmt+    let topologies = nub [(size, siblingBinding) | (_, size, _, siblingBinding) <- Vector.toList rows]+        (groupSize, binding) = case topologies of+          [uniform] -> uniform+          _ -> (1, Nothing)+    initializeCheckpointWith Tx.statement subscriptionName member groupSize binding policy -{- | Startup-only validation and insertion share one checkout and one transaction.-The name lock serializes competing topologies, including an initially absent-row set. It is never acquired by ordinary checkpoint saves or event appends.--}+-- | Startup-only validation and insertion share one checkout and one transaction.+-- The name lock serializes competing topologies, including an initially absent+-- row set. It is never acquired by ordinary checkpoint saves or event appends. initializeWorkerCheckpointSession ::-    SubscriptionName ->-    Int32 ->-    Int32 ->-    SubscriptionTarget ->-    TargetBindingPolicy ->-    MissingCheckpointPolicy ->-    Session.Session (Either SomeSubscriptionStartupFailure (CheckpointInitialization, Bool))+  SubscriptionName ->+  Int32 ->+  Int32 ->+  SubscriptionTarget ->+  TargetBindingPolicy ->+  MissingCheckpointPolicy ->+  Session.Session (Either SomeSubscriptionStartupFailure (CheckpointInitialization, Bool)) initializeWorkerCheckpointSession subscriptionName@(SubscriptionName name) member configured target bindingPolicy policy =-    TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do-        Tx.statement name lockCheckpointNameStmt-        rows <- Tx.statement name readCheckpointRowsStmt-        let sizes = Vector.fromList . sort . nub . fmap (\(_, n, _, _) -> n) $ Vector.toList rows-            bindings = Vector.fromList . nub $ [binding | (_, _, _, binding) <- Vector.toList rows]-            unbound = not (Vector.null rows) && bindings == Vector.singleton Nothing-            matches = Vector.all (== Just target) bindings-        if Vector.any (/= configured) sizes-            then pure (Left (SomeSubscriptionStartupFailure (ConsumerGroupSizeMismatch subscriptionName configured sizes)))-            else-                if not matches && not (unbound && bindingPolicy == AdoptUnbound)-                    then pure (Left (SomeSubscriptionStartupFailure (SubscriptionTargetMismatch subscriptionName target bindings)))-                    else do-                        -- Check exact-key absence before adoption so a refused startup never-                        -- mutates sibling bindings. Both operations share the name lock.-                        resolution <- initializeCheckpointWith Tx.statement subscriptionName member configured (Just target) policy-                        case resolution of-                            Left missing -> pure (Left (SomeSubscriptionStartupFailure missing))-                            Right initialized -> do-                                if unbound then Tx.statement (name, targetColumns (Just target)) adoptCheckpointTargetStmt else pure ()-                                pure (Right (initialized, unbound))+  TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+    Tx.statement name lockCheckpointNameStmt+    rows <- Tx.statement name readCheckpointRowsStmt+    let sizes = Vector.fromList . sort . nub . fmap (\(_, n, _, _) -> n) $ Vector.toList rows+        bindings = Vector.fromList . nub $ [binding | (_, _, _, binding) <- Vector.toList rows]+        unbound = not (Vector.null rows) && bindings == Vector.singleton Nothing+        matches = Vector.all (== Just target) bindings+    if Vector.any (/= configured) sizes+      then pure (Left (SomeSubscriptionStartupFailure (ConsumerGroupSizeMismatch subscriptionName configured sizes)))+      else+        if not matches && not (unbound && bindingPolicy == AdoptUnbound)+          then pure (Left (SomeSubscriptionStartupFailure (SubscriptionTargetMismatch subscriptionName target bindings)))+          else do+            -- Check exact-key absence before adoption so a refused startup never+            -- mutates sibling bindings. Both operations share the name lock.+            resolution <- initializeCheckpointWith Tx.statement subscriptionName member configured (Just target) policy+            case resolution of+              Left missing -> pure (Left (SomeSubscriptionStartupFailure missing))+              Right initialized -> do+                if unbound then Tx.statement (name, targetColumns (Just target)) adoptCheckpointTargetStmt else pure ()+                pure (Right (initialized, unbound))  initializeCheckpointWith ::-    (Monad m) =>-    (forall a b. a -> Statement a b -> m b) ->-    SubscriptionName ->-    Int32 ->-    Int32 ->-    Maybe SubscriptionTarget ->-    MissingCheckpointPolicy ->-    m (Either SubscriptionCheckpointMissing CheckpointInitialization)+  (Monad m) =>+  (forall a b. a -> Statement a b -> m b) ->+  SubscriptionName ->+  Int32 ->+  Int32 ->+  Maybe SubscriptionTarget ->+  MissingCheckpointPolicy ->+  m (Either SubscriptionCheckpointMissing CheckpointInitialization) initializeCheckpointWith statement subscriptionName@(SubscriptionName name) member groupSize binding policy = do-    first <- statement (name, member, policyCode policy, groupSize, fst (targetColumns binding), snd (targetColumns binding)) initializeSubscriptionCheckpointStmt-    case first of-        Just result -> pure (Right (decodeResult result))-        Nothing -> case policy of-            FailIfMissing -> pure (Left missing)-            _ -> do-                -- The insert lost a concurrent unique-key race after this-                -- statement's snapshot. A fresh statement snapshot observes-                -- the committed winner; singleRow turns a violated invariant-                -- into a structured Hasql session error.-                position <- statement (name, member) readInitializedCheckpointStmt-                pure (Right (ExistingCheckpoint key (GlobalPosition position)))+  first <- statement (name, member, policyCode policy, groupSize, fst (targetColumns binding), snd (targetColumns binding)) initializeSubscriptionCheckpointStmt+  case first of+    Just result -> pure (Right (decodeResult result))+    Nothing -> case policy of+      FailIfMissing -> pure (Left missing)+      _ -> do+        -- The insert lost a concurrent unique-key race after this+        -- statement's snapshot. A fresh statement snapshot observes+        -- the committed winner; singleRow turns a violated invariant+        -- into a structured Hasql session error.+        position <- statement (name, member) readInitializedCheckpointStmt+        pure (Right (ExistingCheckpoint key (GlobalPosition position)))   where     key = SubscriptionCheckpointKey subscriptionName member     missing = SubscriptionCheckpointMissing key     decodeResult (position, inserted)-        | inserted = InitializedCheckpoint policy key (GlobalPosition position)-        | otherwise = ExistingCheckpoint key (GlobalPosition position)+      | inserted = InitializedCheckpoint policy key (GlobalPosition position)+      | otherwise = ExistingCheckpoint key (GlobalPosition position)  policyCode :: MissingCheckpointPolicy -> Text policyCode = \case-    FromBeginning -> "from_beginning"-    FromCurrentHead -> "from_current_head"-    FailIfMissing -> "fail_if_missing"+  FromBeginning -> "from_beginning"+  FromCurrentHead -> "from_current_head"+  FailIfMissing -> "fail_if_missing"  initializeSubscriptionCheckpointStmt :: Statement (Text, Int32, Text, Int32, Text, Maybe Text) (Maybe (Int64, Bool)) initializeSubscriptionCheckpointStmt =-    preparable-        """-        WITH desired AS (-          SELECT CASE $3-                   WHEN 'from_beginning' THEN 0::bigint-                   WHEN 'from_current_head' THEN (-                     SELECT stream_version FROM streams WHERE stream_id = 0-                   )-                   ELSE NULL::bigint-                 END AS last_seen-        ),-        inserted AS (-          INSERT INTO subscriptions-            (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category)-          SELECT $1, $2, desired.last_seen, now(), $4, $5, $6-          FROM desired-          WHERE desired.last_seen IS NOT NULL-          ON CONFLICT (subscription_name, consumer_group_member) DO NOTHING-          RETURNING last_seen-        )-        SELECT inserted.last_seen, TRUE AS initialized-        FROM inserted-        UNION ALL-        SELECT subscriptions.last_seen, FALSE AS initialized-        FROM subscriptions-        WHERE subscription_name = $1-          AND consumer_group_member = $2-        LIMIT 1-        """-        ( contrazip6-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.text))-            (E.param (E.nullable E.text))-        )-        ( D.rowMaybe $-            (,)-                <$> D.column (D.nonNullable D.int8)-                <*> D.column (D.nonNullable D.bool)-        )+  preparable+    """+    WITH desired AS (+      SELECT CASE $3+               WHEN 'from_beginning' THEN 0::bigint+               WHEN 'from_current_head' THEN (+                 SELECT stream_version FROM streams WHERE stream_id = 0+               )+               ELSE NULL::bigint+             END AS last_seen+    ),+    inserted AS (+      INSERT INTO subscriptions+        (subscription_name, consumer_group_member, last_seen, updated_at, consumer_group_size, target_kind, target_category)+      SELECT $1, $2, desired.last_seen, now(), $4, $5, $6+      FROM desired+      WHERE desired.last_seen IS NOT NULL+      ON CONFLICT (subscription_name, consumer_group_member) DO NOTHING+      RETURNING last_seen+    )+    SELECT inserted.last_seen, TRUE AS initialized+    FROM inserted+    UNION ALL+    SELECT subscriptions.last_seen, FALSE AS initialized+    FROM subscriptions+    WHERE subscription_name = $1+      AND consumer_group_member = $2+    LIMIT 1+    """+    ( contrazip6+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.text))+        (E.param (E.nullable E.text))+    )+    ( D.rowMaybe $+        (,)+          <$> D.column (D.nonNullable D.int8)+          <*> D.column (D.nonNullable D.bool)+    )  readInitializedCheckpointStmt :: Statement (Text, Int32) Int64 readInitializedCheckpointStmt =-    preparable-        """-        SELECT last_seen-        FROM subscriptions-        WHERE subscription_name = $1-          AND consumer_group_member = $2-        """-        ( contrazip2-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-        )-        (D.singleRow (D.column (D.nonNullable D.int8)))--{- | Reset every persisted member belonging to the requested subscription-names. The input is treated as a set by PostgreSQL. Each returned row contains-either one reset member or a requested name with no persisted rows, and the-result is deterministically ordered by name and member.+  preparable+    """+    SELECT last_seen+    FROM subscriptions+    WHERE subscription_name = $1+      AND consumer_group_member = $2+    """+    ( contrazip2+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+    )+    (D.singleRow (D.column (D.nonNullable D.int8))) -This statement deliberately assigns @last_seen@ directly. Ordinary worker-saves retain their separate @GREATEST(...)@ monotonicity contract.--}+-- | Reset every persisted member belonging to the requested subscription+-- names. The input is treated as a set by PostgreSQL. Each returned row contains+-- either one reset member or a requested name with no persisted rows, and the+-- result is deterministically ordered by name and member.+--+-- This statement deliberately assigns @last_seen@ directly. Ordinary worker+-- saves retain their separate @GREATEST(...)@ monotonicity contract. resetSubscriptionCheckpointsStmt ::-    Statement (Vector Text, Int64) (Vector (Text, Maybe Int32))+  Statement (Vector Text, Int64) (Vector (Text, Maybe Int32)) resetSubscriptionCheckpointsStmt =-    preparable-        """-        WITH requested AS (-          SELECT DISTINCT requested_name AS subscription_name-          FROM unnest($1::text[]) AS requested_name-        ),-        updated AS (-          UPDATE subscriptions AS checkpoint-          SET last_seen = $2, updated_at = now()-          FROM requested-          WHERE checkpoint.subscription_name = requested.subscription_name-          RETURNING checkpoint.subscription_name, checkpoint.consumer_group_member-        )-        SELECT requested.subscription_name, updated.consumer_group_member-        FROM requested-        LEFT JOIN updated USING (subscription_name)-        ORDER BY requested.subscription_name, updated.consumer_group_member-        """-        ( contrazip2-            (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))-            (E.param (E.nonNullable E.int8))-        )-        ( D.rowVector $-            (,)-                <$> D.column (D.nonNullable D.text)-                <*> D.column (D.nullable D.int4)-        )+  preparable+    """+    WITH requested AS (+      SELECT DISTINCT requested_name AS subscription_name+      FROM unnest($1::text[]) AS requested_name+    ),+    updated AS (+      UPDATE subscriptions AS checkpoint+      SET last_seen = $2, updated_at = now()+      FROM requested+      WHERE checkpoint.subscription_name = requested.subscription_name+      RETURNING checkpoint.subscription_name, checkpoint.consumer_group_member+    )+    SELECT requested.subscription_name, updated.consumer_group_member+    FROM requested+    LEFT JOIN updated USING (subscription_name)+    ORDER BY requested.subscription_name, updated.consumer_group_member+    """+    ( contrazip2+        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+        (E.param (E.nonNullable E.int8))+    )+    ( D.rowVector $+        (,)+          <$> D.column (D.nonNullable D.text)+          <*> D.column (D.nullable D.int4)+    )  -- | A separate key domain from the optional member guard. lockCheckpointNameStmt :: Statement Text () lockCheckpointNameStmt =-    preparable-        "SELECT pg_advisory_xact_lock(hashtextextended('kiroku:checkpoint-topology:' || $1, 0))"-        (E.param (E.nonNullable E.text))-        D.noResult+  preparable+    "SELECT pg_advisory_xact_lock(hashtextextended('kiroku:checkpoint-topology:' || $1, 0))"+    (E.param (E.nonNullable E.text))+    D.noResult  readCheckpointRowsStmt :: Statement Text (Vector (Int32, Int32, Int64, Maybe SubscriptionTarget)) readCheckpointRowsStmt = checkpointRowsStmt ""@@ -243,52 +253,53 @@  checkpointRowsStmt :: Text -> Statement Text (Vector (Int32, Int32, Int64, Maybe SubscriptionTarget)) checkpointRowsStmt suffix =-    preparable-        ("SELECT consumer_group_member, consumer_group_size, last_seen, target_kind, target_category FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member" <> suffix)-        (E.param (E.nonNullable E.text))-        (D.rowVector ((,,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int8) <*> targetBindingRow))+  preparable+    ("SELECT consumer_group_member, consumer_group_size, last_seen, target_kind, target_category FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member" <> suffix)+    (E.param (E.nonNullable E.text))+    (D.rowVector ((,,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int8) <*> targetBindingRow)) -{- | Keep existing row identities, equalize every new member, remove obsolete-members. Caller already holds the name and row locks; workers must be stopped.--}+-- | Keep existing row identities, equalize every new member, remove obsolete+-- members. Caller already holds the name and row locks; workers must be stopped. resizeCheckpointMembersStmt :: Statement (Text, Int32, Int64, Text, Maybe Text) () resizeCheckpointMembersStmt =-    preparable-        """-        WITH removed AS (-          DELETE FROM subscriptions-          WHERE subscription_name = $1 AND consumer_group_member >= $2-        )-        INSERT INTO subscriptions-          (subscription_name, consumer_group_member, consumer_group_size, last_seen, updated_at, target_kind, target_category)-        SELECT $1, member, $2, $3, now(), $4, $5 FROM generate_series(0, $2 - 1) AS member-        ON CONFLICT (subscription_name, consumer_group_member) DO UPDATE-        SET consumer_group_size = EXCLUDED.consumer_group_size,-            last_seen = EXCLUDED.last_seen, updated_at = now()-        """-        ( contrazip5-            (E.param (E.nonNullable E.text))-            (E.param (E.nonNullable E.int4))-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.text))-            (E.param (E.nullable E.text))-        )-        D.noResult+  preparable+    """+    WITH removed AS (+      DELETE FROM subscriptions+      WHERE subscription_name = $1 AND consumer_group_member >= $2+    )+    INSERT INTO subscriptions+      (subscription_name, consumer_group_member, consumer_group_size, last_seen, updated_at, target_kind, target_category)+    SELECT $1, member, $2, $3, now(), $4, $5 FROM generate_series(0, $2 - 1) AS member+    ON CONFLICT (subscription_name, consumer_group_member) DO UPDATE+    SET consumer_group_size = EXCLUDED.consumer_group_size,+        last_seen = EXCLUDED.last_seen, updated_at = now()+    """+    ( contrazip5+        (E.param (E.nonNullable E.text))+        (E.param (E.nonNullable E.int4))+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.text))+        (E.param (E.nullable E.text))+    )+    D.noResult -{- | Select a fixed-kind statement once for the configured target. Both forms-write the full binding and retain the same unconditional monotonic upsert.--}+-- | Select a fixed-kind statement once for the configured target. Both forms+-- write the full binding and retain the same unconditional monotonic upsert. saveBoundCheckpointSession :: SubscriptionTarget -> Text -> Int32 -> Int64 -> Int32 -> Session.Session () saveBoundCheckpointSession AllStreams name member position groupSize =-    Session.statement (name, member, position, groupSize) SQL.saveAllCheckpointMemberStmt+  Session.statement (name, member, position, groupSize) SQL.saveAllCheckpointMemberStmt saveBoundCheckpointSession (Category (CategoryName category)) name member position groupSize =-    Session.statement (name, member, position, groupSize, category) SQL.saveCategoryCheckpointMemberStmt+  Session.statement (name, member, position, groupSize, category) SQL.saveCategoryCheckpointMemberStmt+saveBoundCheckpointSession (CategoryNamespace namespace) name member position groupSize =+  Session.statement (name, member, position, groupSize, namespace) SQL.saveNamespaceCheckpointMemberStmt  -- | One encoder for every checkpoint target write, including legacy provisioning. targetColumns :: Maybe SubscriptionTarget -> (Text, Maybe Text) targetColumns Nothing = ("unbound", Nothing) targetColumns (Just AllStreams) = ("all", Nothing) targetColumns (Just (Category (CategoryName category))) = ("category", Just category)+targetColumns (Just (CategoryNamespace namespace)) = ("category_namespace", Just namespace)  -- The CHECK constraints make this decoder total over valid database rows. targetBindingRow :: D.Row (Maybe SubscriptionTarget)@@ -296,27 +307,28 @@   where     decode "all" Nothing = Just AllStreams     decode "category" (Just category) = Just (Category (CategoryName category))+    decode "category_namespace" (Just namespace) = Just (CategoryNamespace namespace)     decode _ _ = Nothing  adoptCheckpointTargetStmt :: Statement (Text, (Text, Maybe Text)) () adoptCheckpointTargetStmt =-    preparable-        "UPDATE subscriptions SET target_kind = $2, target_category = $3 WHERE subscription_name = $1"-        (contrazip2 (E.param (E.nonNullable E.text)) (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text))))-        D.noResult+  preparable+    "UPDATE subscriptions SET target_kind = $2, target_category = $3 WHERE subscription_name = $1"+    (contrazip2 (E.param (E.nonNullable E.text)) (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text))))+    D.noResult  -- A singleRow decoder refuses an absent name and aborts the surrounding transaction. rebindCheckpointTargetStmt :: Statement (Text, Text, Maybe Text, Int64) Int64 rebindCheckpointTargetStmt =-    preparable-        """-        WITH rebound AS (-          UPDATE subscriptions-          SET target_kind = $2, target_category = $3, last_seen = $4, updated_at = now()-          WHERE subscription_name = $1-          RETURNING subscription_id-        )-        SELECT count(*) FROM rebound HAVING count(*) > 0-        """-        (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int8)))-        (D.singleRow (D.column (D.nonNullable D.int8)))+  preparable+    """+    WITH rebound AS (+      UPDATE subscriptions+      SET target_kind = $2, target_category = $3, last_seen = $4, updated_at = now()+      WHERE subscription_name = $1+      RETURNING subscription_id+    )+    SELECT count(*) FROM rebound HAVING count(*) > 0+    """+    (contrazip4 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.text)) (E.param (E.nullable E.text)) (E.param (E.nonNullable E.int8)))+    (D.singleRow (D.column (D.nonNullable D.int8)))
src/Kiroku/Store/Subscription/CheckpointInventory/SQL.hs view
@@ -1,8 +1,9 @@ {-# LANGUAGE MultilineStrings #-} -module Kiroku.Store.Subscription.CheckpointInventory.SQL (-    getSubscriptionCheckpointInventoryStmt,-) where+module Kiroku.Store.Subscription.CheckpointInventory.SQL+  ( getSubscriptionCheckpointInventoryStmt,+  )+where  import Data.Int (Int32, Int64) import Data.Text (Text)@@ -13,102 +14,102 @@ import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable) import Hasql.Statement qualified as Statement-import Kiroku.Store.Subscription.Types (-    SubscriptionCheckpoint (..),+import Kiroku.Store.Subscription.Types+  ( SubscriptionCheckpoint (..),     SubscriptionCheckpointInventory (..),     SubscriptionName (..),- )+  ) import Kiroku.Store.Types (GlobalPosition (..))  data InventoryRow = InventoryRow-    { rowStorePosition :: !Int64-    , rowSubscriptionName :: !(Maybe Text)-    , rowConsumerGroupMember :: !(Maybe Int32)-    , rowCheckpointPosition :: !(Maybe Int64)-    , rowCheckpointUpdatedAt :: !(Maybe UTCTime)-    }+  { rowStorePosition :: !Int64,+    rowSubscriptionName :: !(Maybe Text),+    rowConsumerGroupMember :: !(Maybe Int32),+    rowCheckpointPosition :: !(Maybe Int64),+    rowCheckpointUpdatedAt :: !(Maybe UTCTime)+  }  getSubscriptionCheckpointInventoryStmt :: Statement () SubscriptionCheckpointInventory getSubscriptionCheckpointInventoryStmt =-    Statement.refineResult finalizeInventory $-        preparable-            """-            SELECT store_head.stream_version,-                   checkpoint.subscription_name,-                   checkpoint.consumer_group_member,-                   checkpoint.last_seen,-                   checkpoint.updated_at-            FROM streams AS store_head-            LEFT JOIN subscriptions AS checkpoint ON TRUE-            WHERE store_head.stream_id = 0-            ORDER BY checkpoint.subscription_name ASC,-                     checkpoint.consumer_group_member ASC-            """-            E.noParams-            (D.rowVector inventoryRow)+  Statement.refineResult finalizeInventory $+    preparable+      """+      SELECT store_head.stream_version,+             checkpoint.subscription_name,+             checkpoint.consumer_group_member,+             checkpoint.last_seen,+             checkpoint.updated_at+      FROM streams AS store_head+      LEFT JOIN subscriptions AS checkpoint ON TRUE+      WHERE store_head.stream_id = 0+      ORDER BY checkpoint.subscription_name ASC,+               checkpoint.consumer_group_member ASC+      """+      E.noParams+      (D.rowVector inventoryRow)  inventoryRow :: D.Row InventoryRow inventoryRow =-    InventoryRow-        <$> D.column (D.nonNullable D.int8)-        <*> D.column (D.nullable D.text)-        <*> D.column (D.nullable D.int4)-        <*> D.column (D.nullable D.int8)-        <*> D.column (D.nullable D.timestamptz)+  InventoryRow+    <$> D.column (D.nonNullable D.int8)+    <*> D.column (D.nullable D.text)+    <*> D.column (D.nullable D.int4)+    <*> D.column (D.nullable D.int8)+    <*> D.column (D.nullable D.timestamptz)  finalizeInventory :: Vector InventoryRow -> Either Text SubscriptionCheckpointInventory finalizeInventory rows = case V.uncons rows of-    Nothing -> Left "subscription checkpoint inventory: missing $all stream row"-    Just (firstRow, remainingRows) ->-        let capturedPosition = rowStorePosition firstRow-         in case checkpointColumns firstRow of-                EmptyCheckpoint-                    | V.null remainingRows ->-                        Right $-                            SubscriptionCheckpointInventory-                                (GlobalPosition capturedPosition)-                                V.empty-                    | otherwise ->-                        Left "subscription checkpoint inventory: empty checkpoint row was not the only result"-                PartialCheckpoint ->-                    Left "subscription checkpoint inventory: partially null checkpoint row"-                CompleteCheckpoint ->-                    SubscriptionCheckpointInventory (GlobalPosition capturedPosition)-                        <$> V.mapM (decodeCheckpoint capturedPosition) rows+  Nothing -> Left "subscription checkpoint inventory: missing $all stream row"+  Just (firstRow, remainingRows) ->+    let capturedPosition = rowStorePosition firstRow+     in case checkpointColumns firstRow of+          EmptyCheckpoint+            | V.null remainingRows ->+                Right $+                  SubscriptionCheckpointInventory+                    (GlobalPosition capturedPosition)+                    V.empty+            | otherwise ->+                Left "subscription checkpoint inventory: empty checkpoint row was not the only result"+          PartialCheckpoint ->+            Left "subscription checkpoint inventory: partially null checkpoint row"+          CompleteCheckpoint ->+            SubscriptionCheckpointInventory (GlobalPosition capturedPosition)+              <$> V.mapM (decodeCheckpoint capturedPosition) rows  data CheckpointColumns-    = EmptyCheckpoint-    | PartialCheckpoint-    | CompleteCheckpoint+  = EmptyCheckpoint+  | PartialCheckpoint+  | CompleteCheckpoint  checkpointColumns :: InventoryRow -> CheckpointColumns checkpointColumns row =-    case ( rowSubscriptionName row-         , rowConsumerGroupMember row-         , rowCheckpointPosition row-         , rowCheckpointUpdatedAt row-         ) of-        (Nothing, Nothing, Nothing, Nothing) -> EmptyCheckpoint-        (Just _, Just _, Just _, Just _) -> CompleteCheckpoint-        _ -> PartialCheckpoint+  case ( rowSubscriptionName row,+         rowConsumerGroupMember row,+         rowCheckpointPosition row,+         rowCheckpointUpdatedAt row+       ) of+    (Nothing, Nothing, Nothing, Nothing) -> EmptyCheckpoint+    (Just _, Just _, Just _, Just _) -> CompleteCheckpoint+    _ -> PartialCheckpoint  decodeCheckpoint :: Int64 -> InventoryRow -> Either Text SubscriptionCheckpoint decodeCheckpoint capturedPosition row-    | rowStorePosition row /= capturedPosition =-        Left "subscription checkpoint inventory: inconsistent repeated store position"-    | otherwise =-        case ( rowSubscriptionName row-             , rowConsumerGroupMember row-             , rowCheckpointPosition row-             , rowCheckpointUpdatedAt row-             ) of-            (Just name, Just member, Just position, Just updatedAt) ->-                Right $-                    SubscriptionCheckpoint-                        (SubscriptionName name)-                        member-                        (GlobalPosition position)-                        updatedAt-            (Nothing, Nothing, Nothing, Nothing) ->-                Left "subscription checkpoint inventory: unexpected empty checkpoint row"-            _ -> Left "subscription checkpoint inventory: partially null checkpoint row"+  | rowStorePosition row /= capturedPosition =+      Left "subscription checkpoint inventory: inconsistent repeated store position"+  | otherwise =+      case ( rowSubscriptionName row,+             rowConsumerGroupMember row,+             rowCheckpointPosition row,+             rowCheckpointUpdatedAt row+           ) of+        (Just name, Just member, Just position, Just updatedAt) ->+          Right $+            SubscriptionCheckpoint+              (SubscriptionName name)+              member+              (GlobalPosition position)+              updatedAt+        (Nothing, Nothing, Nothing, Nothing) ->+          Left "subscription checkpoint inventory: unexpected empty checkpoint row"+        _ -> Left "subscription checkpoint inventory: partially null checkpoint row"
src/Kiroku/Store/Subscription/DeadLetter/SQL.hs view
@@ -1,12 +1,13 @@ {-# LANGUAGE MultilineStrings #-} -module Kiroku.Store.Subscription.DeadLetter.SQL (-    listSubscriptionDeadLettersSession,+module Kiroku.Store.Subscription.DeadLetter.SQL+  ( listSubscriptionDeadLettersSession,     listSubscriptionDeadLettersStmt,     listSubscriptionDeadLettersFromStartStmt,     listSubscriptionMemberDeadLettersStmt,     listSubscriptionMemberDeadLettersFromStartStmt,-) where+  )+where  import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5) import Control.Lens ((^.))@@ -25,118 +26,120 @@  listSubscriptionDeadLettersFromStartStmt :: Statement (Text, Int32) (Vector SubscriptionDeadLetter) listSubscriptionDeadLettersFromStartStmt =-    preparable-        """-        WITH RECURSIVE members(member) AS (-            SELECT (SELECT consumer_group_member FROM dead_letters-                    WHERE subscription_name = $1 ORDER BY consumer_group_member LIMIT 1)-            UNION ALL-            SELECT (SELECT consumer_group_member FROM dead_letters-                    WHERE subscription_name = $1 AND consumer_group_member > m.member-                    ORDER BY consumer_group_member LIMIT 1)-            FROM members m WHERE m.member IS NOT NULL-        )-        SELECT d.dead_letter_id, d.subscription_name, d.consumer_group_member, d.global_position, d.event_id, d.reason, d.reason_summary, d.attempt_count, d.created_at-        FROM members m-        CROSS JOIN LATERAL (-        SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at-        FROM dead_letters-        WHERE subscription_name = $1 AND consumer_group_member = m.member+  preparable+    """+    WITH RECURSIVE members(member) AS (+        SELECT (SELECT consumer_group_member FROM dead_letters+                WHERE subscription_name = $1 ORDER BY consumer_group_member LIMIT 1)+        UNION ALL+        SELECT (SELECT consumer_group_member FROM dead_letters+                WHERE subscription_name = $1 AND consumer_group_member > m.member+                ORDER BY consumer_group_member LIMIT 1)+        FROM members m WHERE m.member IS NOT NULL+    )+    SELECT d.dead_letter_id, d.subscription_name, d.consumer_group_member, d.global_position, d.event_id, d.reason, d.reason_summary, d.attempt_count, d.created_at+    FROM members m+    CROSS JOIN LATERAL (+    SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at+    FROM dead_letters+    WHERE subscription_name = $1 AND consumer_group_member = m.member -        ORDER BY global_position DESC, dead_letter_id DESC LIMIT $2-        ) d-        WHERE m.member IS NOT NULL-        ORDER BY d.global_position DESC, d.dead_letter_id DESC LIMIT $2-        """-        (contrazip2 text int4)-        (D.rowVector deadLetterRow)+    ORDER BY global_position DESC, dead_letter_id DESC LIMIT $2+    ) d+    WHERE m.member IS NOT NULL+    ORDER BY d.global_position DESC, d.dead_letter_id DESC LIMIT $2+    """+    (contrazip2 text int4)+    (D.rowVector deadLetterRow)  listSubscriptionDeadLettersStmt :: Statement (Text, Int64, Int64, Int32) (Vector SubscriptionDeadLetter) listSubscriptionDeadLettersStmt =-    preparable-        """-        WITH RECURSIVE members(member) AS (-            SELECT (SELECT consumer_group_member FROM dead_letters-                    WHERE subscription_name = $1 ORDER BY consumer_group_member LIMIT 1)-            UNION ALL-            SELECT (SELECT consumer_group_member FROM dead_letters-                    WHERE subscription_name = $1 AND consumer_group_member > m.member-                    ORDER BY consumer_group_member LIMIT 1)-            FROM members m WHERE m.member IS NOT NULL-        )-        SELECT d.dead_letter_id, d.subscription_name, d.consumer_group_member, d.global_position, d.event_id, d.reason, d.reason_summary, d.attempt_count, d.created_at-        FROM members m-        CROSS JOIN LATERAL (-        SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at-        FROM dead_letters-        WHERE subscription_name = $1 AND consumer_group_member = m.member-        AND (global_position, dead_letter_id) < ($2, $3)-        ORDER BY global_position DESC, dead_letter_id DESC LIMIT $4-        ) d-        WHERE m.member IS NOT NULL-        ORDER BY d.global_position DESC, d.dead_letter_id DESC LIMIT $4-        """-        (contrazip4 text int8 int8 int4)-        (D.rowVector deadLetterRow)+  preparable+    """+    WITH RECURSIVE members(member) AS (+        SELECT (SELECT consumer_group_member FROM dead_letters+                WHERE subscription_name = $1 ORDER BY consumer_group_member LIMIT 1)+        UNION ALL+        SELECT (SELECT consumer_group_member FROM dead_letters+                WHERE subscription_name = $1 AND consumer_group_member > m.member+                ORDER BY consumer_group_member LIMIT 1)+        FROM members m WHERE m.member IS NOT NULL+    )+    SELECT d.dead_letter_id, d.subscription_name, d.consumer_group_member, d.global_position, d.event_id, d.reason, d.reason_summary, d.attempt_count, d.created_at+    FROM members m+    CROSS JOIN LATERAL (+    SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at+    FROM dead_letters+    WHERE subscription_name = $1 AND consumer_group_member = m.member+    AND (global_position, dead_letter_id) < ($2, $3)+    ORDER BY global_position DESC, dead_letter_id DESC LIMIT $4+    ) d+    WHERE m.member IS NOT NULL+    ORDER BY d.global_position DESC, d.dead_letter_id DESC LIMIT $4+    """+    (contrazip4 text int8 int8 int4)+    (D.rowVector deadLetterRow)  listSubscriptionMemberDeadLettersFromStartStmt :: Statement (Text, Int32, Int32) (Vector SubscriptionDeadLetter) listSubscriptionMemberDeadLettersFromStartStmt =-    preparable-        """-        SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at-        FROM dead_letters-        WHERE subscription_name = $1 AND consumer_group_member = $2+  preparable+    """+    SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at+    FROM dead_letters+    WHERE subscription_name = $1 AND consumer_group_member = $2 -        ORDER BY global_position DESC, dead_letter_id DESC LIMIT $3-        """-        (contrazip3 text int4 int4)-        (D.rowVector deadLetterRow)+    ORDER BY global_position DESC, dead_letter_id DESC LIMIT $3+    """+    (contrazip3 text int4 int4)+    (D.rowVector deadLetterRow)  listSubscriptionMemberDeadLettersStmt :: Statement (Text, Int32, Int64, Int64, Int32) (Vector SubscriptionDeadLetter) listSubscriptionMemberDeadLettersStmt =-    preparable-        """-        SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at-        FROM dead_letters-        WHERE subscription_name = $1 AND consumer_group_member = $2-        AND (global_position, dead_letter_id) < ($3, $4)-        ORDER BY global_position DESC, dead_letter_id DESC LIMIT $5-        """-        (contrazip5 text int4 int8 int8 int4)-        (D.rowVector deadLetterRow)+  preparable+    """+    SELECT dead_letter_id, subscription_name, consumer_group_member, global_position, event_id, reason, reason_summary, attempt_count, created_at+    FROM dead_letters+    WHERE subscription_name = $1 AND consumer_group_member = $2+    AND (global_position, dead_letter_id) < ($3, $4)+    ORDER BY global_position DESC, dead_letter_id DESC LIMIT $5+    """+    (contrazip5 text int4 int8 int8 int4)+    (D.rowVector deadLetterRow)  text :: E.Params Text text = E.param (E.nonNullable E.text)+ int4 :: E.Params Int32 int4 = E.param (E.nonNullable E.int4)+ int8 :: E.Params Int64 int8 = E.param (E.nonNullable E.int8)  deadLetterRow :: D.Row SubscriptionDeadLetter deadLetterRow =-    SubscriptionDeadLetter-        <$> D.column (D.nonNullable D.int8)-        <*> (SubscriptionName <$> D.column (D.nonNullable D.text))-        <*> D.column (D.nonNullable D.int4)-        <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))-        <*> (EventId <$> D.column (D.nonNullable D.uuid))-        <*> D.column (D.nonNullable D.jsonb)-        <*> D.column (D.nonNullable D.text)-        <*> D.column (D.nonNullable D.int4)-        <*> D.column (D.nonNullable D.timestamptz)+  SubscriptionDeadLetter+    <$> D.column (D.nonNullable D.int8)+    <*> (SubscriptionName <$> D.column (D.nonNullable D.text))+    <*> D.column (D.nonNullable D.int4)+    <*> (GlobalPosition <$> D.column (D.nonNullable D.int8))+    <*> (EventId <$> D.column (D.nonNullable D.uuid))+    <*> D.column (D.nonNullable D.jsonb)+    <*> D.column (D.nonNullable D.text)+    <*> D.column (D.nonNullable D.int4)+    <*> D.column (D.nonNullable D.timestamptz)  listSubscriptionDeadLettersSession :: SubscriptionDeadLetterQuery -> Session SubscriptionDeadLetterPage listSubscriptionDeadLettersSession query = do-    let SubscriptionName name = query ^. #subscriptionName-        pageSize = subscriptionDeadLetterLimitValue (query ^. #limit)-        fetch = pageSize + 1-    rows <- case (query ^. #consumerGroupMember, query ^. #after) of-        (Nothing, Nothing) -> Session.statement (name, fetch) listSubscriptionDeadLettersFromStartStmt-        (Just selectedMember, Nothing) -> Session.statement (name, selectedMember, fetch) listSubscriptionMemberDeadLettersFromStartStmt-        (Nothing, Just (SubscriptionDeadLetterCursor (GlobalPosition position) ident)) ->-            Session.statement (name, position, ident, fetch) listSubscriptionDeadLettersStmt-        (Just selectedMember, Just (SubscriptionDeadLetterCursor (GlobalPosition position) ident)) ->-            Session.statement (name, selectedMember, position, ident, fetch) listSubscriptionMemberDeadLettersStmt-    let page = V.take (fromIntegral pageSize) rows-        cursor = if V.length rows > fromIntegral pageSize then Just (subscriptionDeadLetterCursor (V.last page)) else Nothing-    pure (SubscriptionDeadLetterPage page cursor)+  let SubscriptionName name = query ^. #subscriptionName+      pageSize = subscriptionDeadLetterLimitValue (query ^. #limit)+      fetch = pageSize + 1+  rows <- case (query ^. #consumerGroupMember, query ^. #after) of+    (Nothing, Nothing) -> Session.statement (name, fetch) listSubscriptionDeadLettersFromStartStmt+    (Just selectedMember, Nothing) -> Session.statement (name, selectedMember, fetch) listSubscriptionMemberDeadLettersFromStartStmt+    (Nothing, Just (SubscriptionDeadLetterCursor (GlobalPosition position) ident)) ->+      Session.statement (name, position, ident, fetch) listSubscriptionDeadLettersStmt+    (Just selectedMember, Just (SubscriptionDeadLetterCursor (GlobalPosition position) ident)) ->+      Session.statement (name, selectedMember, position, ident, fetch) listSubscriptionMemberDeadLettersStmt+  let page = V.take (fromIntegral pageSize) rows+      cursor = if V.length rows > fromIntegral pageSize then Just (subscriptionDeadLetterCursor (V.last page)) else Nothing+  pure (SubscriptionDeadLetterPage page cursor)
src/Kiroku/Store/Subscription/Effect.hs view
@@ -1,7 +1,7 @@ {-# LANGUAGE TypeFamilies #-} -module Kiroku.Store.Subscription.Effect (-    -- * The Subscription effect+module Kiroku.Store.Subscription.Effect+  ( -- * The Subscription effect     Subscription (..),      -- * Convenience wrappers@@ -11,7 +11,8 @@     -- * Interpreters     runSubscription,     runSubscriptionResource,-) where+  )+where  import Control.Monad.IO.Class (liftIO) import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, Limit (..), Persistence (..), UnliftStrategy (..), (:>))@@ -26,13 +27,12 @@ -- Subscription effect -- --------------------------------------------------------------------------- -{- | The Subscription effect — subscribe to event streams.--This is a higher-order effect: the handler inside the config runs in the-caller's effect monad @m@, which is @Eff localEs@ at the call site.--}+-- | The Subscription effect — subscribe to event streams.+--+-- This is a higher-order effect: the handler inside the config runs in the+-- caller's effect monad @m@, which is @Eff localEs@ at the call site. data Subscription :: Effect where-    Subscribe :: SubscriptionConfigM m -> Subscription m SubscriptionHandle+  Subscribe :: SubscriptionConfigM m -> Subscription m SubscriptionHandle  type instance DispatchOf Subscription = Dynamic @@ -40,103 +40,100 @@ -- Convenience wrapper -- --------------------------------------------------------------------------- -{- | Subscribe to an event stream (effectful API).--The handler inside the config runs in @Eff es@, so it can use any effects-available in the caller's stack.--__Lifecycle note:__ The returned handle must be canceled before the effect-scope exits. The subscription thread captures the effect environment; if the-@Eff@ computation finishes while the thread is still running, the environment-becomes invalid. Prefer 'withSubscription' (also exported from this module)-which wraps subscribe/cancel in a @bracket@ and is exception-safe.--=== Delivery semantics--Identical to 'Kiroku.Store.Subscription.subscribe': events are delivered-__at least once__, the checkpoint is advanced __per batch__, and handlers-must be idempotent. See the Haddock on-'Kiroku.Store.Subscription.subscribe' for the full enumeration of replay-boundaries (cancel-after-Continue, mid-batch cancellation, transient-publisher pool errors) and the failure-mode table for-'Kiroku.Store.Subscription.Types.SubscriptionHandleM.wait'.--The effectful interpreter wraps the user's handler with the-@ConcUnlift Persistent (Limited 1)@ unlift strategy (see 'runSubscription').-This preserves the worker's single-threaded contract: only one handler-invocation is in flight at a time, and the effect environment outlives-all handler calls so 'Effectful.State.Static.Local.State' /-'Effectful.Reader.Static.Reader' contents remain consistent across the-subscription.--}+-- | Subscribe to an event stream (effectful API).+--+-- The handler inside the config runs in @Eff es@, so it can use any effects+-- available in the caller's stack.+--+-- __Lifecycle note:__ The returned handle must be canceled before the effect+-- scope exits. The subscription thread captures the effect environment; if the+-- @Eff@ computation finishes while the thread is still running, the environment+-- becomes invalid. Prefer 'withSubscription' (also exported from this module)+-- which wraps subscribe/cancel in a @bracket@ and is exception-safe.+--+-- === Delivery semantics+--+-- Identical to 'Kiroku.Store.Subscription.subscribe': events are delivered+-- __at least once__, the checkpoint is advanced __per batch__, and handlers+-- must be idempotent. See the Haddock on+-- 'Kiroku.Store.Subscription.subscribe' for the full enumeration of replay+-- boundaries (cancel-after-Continue, mid-batch cancellation, transient+-- publisher pool errors) and the failure-mode table for+-- 'Kiroku.Store.Subscription.Types.SubscriptionHandleM.wait'.+--+-- The effectful interpreter wraps the user's handler with the+-- @ConcUnlift Persistent (Limited 1)@ unlift strategy (see 'runSubscription').+-- This preserves the worker's single-threaded contract: only one handler+-- invocation is in flight at a time, and the effect environment outlives+-- all handler calls so 'Effectful.State.Static.Local.State' /+-- 'Effectful.Reader.Static.Reader' contents remain consistent across the+-- subscription. subscribe :: (HasCallStack, Subscription :> es) => SubscriptionConfigM (Eff es) -> Eff es SubscriptionHandle subscribe config = send (Subscribe config) -{- | Bracket-style subscription lifecycle for the effectful API.--Starts a subscription, runs the body, and cancels the worker on either-normal exit or an exception thrown inside the body. This is the-exception-safe form of 'subscribe' — the `Eff`-based 'subscribe' captures-the caller's effect environment in the worker thread, so a leaking thread-can refer to an environment that has already been torn down.--The body runs in @Eff es@; the underlying 'SubscriptionHandle' is IO-based,-so 'cancel' is invoked via 'liftIO'. 'Effectful.Exception.bracket' guarantees-the cancel runs even on async exceptions.--}+-- | Bracket-style subscription lifecycle for the effectful API.+--+-- Starts a subscription, runs the body, and cancels the worker on either+-- normal exit or an exception thrown inside the body. This is the+-- exception-safe form of 'subscribe' — the `Eff`-based 'subscribe' captures+-- the caller's effect environment in the worker thread, so a leaking thread+-- can refer to an environment that has already been torn down.+--+-- The body runs in @Eff es@; the underlying 'SubscriptionHandle' is IO-based,+-- so 'cancel' is invoked via 'liftIO'. 'Effectful.Exception.bracket' guarantees+-- the cancel runs even on async exceptions. withSubscription ::-    (HasCallStack, Subscription :> es, IOE :> es) =>-    SubscriptionConfigM (Eff es) ->-    (SubscriptionHandle -> Eff es a) ->-    Eff es a+  (HasCallStack, Subscription :> es, IOE :> es) =>+  SubscriptionConfigM (Eff es) ->+  (SubscriptionHandle -> Eff es a) ->+  Eff es a withSubscription config = bracket (subscribe config) (liftIO . cancel)  -- --------------------------------------------------------------------------- -- Interpreters -- --------------------------------------------------------------------------- -{- | Interpret 'Subscription' by delegating to the @MonadIO@-based-'Kiroku.Store.Subscription.subscribe'.--The unlift strategy is @ConcUnlift Persistent (Limited 1)@:--* @Persistent@ — the effect environment must outlive each individual-  handler invocation. The worker thread calls the handler many times-  over its lifetime; an @Ephemeral@ unlift would reset the environment-  between calls and lose any 'Effectful.State.Static.Local.State' /-  'Effectful.Reader.Static.Reader' contents.-* @Limited 1@ — only one concurrent unlift is in flight at any time.-  The worker thread is single-threaded by construction (events are-  processed sequentially within a subscription), so a higher bound-  would be wasteful. A @Limited 0@ would prevent any unlift and break-  the handler entirely.--Do not relax these bounds without restructuring the worker.--}+-- | Interpret 'Subscription' by delegating to the @MonadIO@-based+-- 'Kiroku.Store.Subscription.subscribe'.+--+-- The unlift strategy is @ConcUnlift Persistent (Limited 1)@:+--+-- * @Persistent@ — the effect environment must outlive each individual+--   handler invocation. The worker thread calls the handler many times+--   over its lifetime; an @Ephemeral@ unlift would reset the environment+--   between calls and lose any 'Effectful.State.Static.Local.State' /+--   'Effectful.Reader.Static.Reader' contents.+-- * @Limited 1@ — only one concurrent unlift is in flight at any time.+--   The worker thread is single-threaded by construction (events are+--   processed sequentially within a subscription), so a higher bound+--   would be wasteful. A @Limited 0@ would prevent any unlift and break+--   the handler entirely.+--+-- Do not relax these bounds without restructuring the worker. runSubscription ::-    (IOE :> es) =>-    KirokuStore ->-    Eff (Subscription : es) a ->-    Eff es a+  (IOE :> es) =>+  KirokuStore ->+  Eff (Subscription : es) a ->+  Eff es a runSubscription store = interpret $ \env -> \case-    Subscribe config ->-        localUnliftIO env (ConcUnlift Persistent (Limited 1)) $ \unlift -> do-            -- Record update intentionally: policy fields are preserved; both callbacks are unlifted,-            -- including 'consumerGroup' and 'consumerGroupGuard' (added in EP-2). Do not-            -- switch to a full record literal here — that would silently reset new fields-            -- and drop a caller's consumer-group membership.-            let ioConfig =-                    config-                        { handler = \evt -> unlift (handler config evt)-                        , undecodableHandler = fmap (\callback event failure -> unlift (callback event failure)) (undecodableHandler config)-                        }-            Sub.subscribe store ioConfig+  Subscribe config ->+    localUnliftIO env (ConcUnlift Persistent (Limited 1)) $ \unlift -> do+      -- Record update intentionally: policy fields are preserved; both callbacks are unlifted,+      -- including 'consumerGroup' and 'consumerGroupGuard' (added in EP-2). Do not+      -- switch to a full record literal here — that would silently reset new fields+      -- and drop a caller's consumer-group membership.+      let ioConfig =+            config+              { handler = \evt -> unlift (handler config evt),+                undecodableHandler = fmap (\callback event failure -> unlift (callback event failure)) (undecodableHandler config)+              }+      Sub.subscribe store ioConfig  -- | Interpret Subscription by reading the store handle from 'KirokuStoreResource'. runSubscriptionResource ::-    (IOE :> es, KirokuStoreResource :> es) =>-    Eff (Subscription : es) a ->-    Eff es a+  (IOE :> es, KirokuStoreResource :> es) =>+  Eff (Subscription : es) a ->+  Eff es a runSubscriptionResource action = do-    store <- getKirokuStore-    runSubscription store action+  store <- getKirokuStore+  runSubscription store action
src/Kiroku/Store/Subscription/EventPublisher.hs view
@@ -1,24 +1,23 @@-{- | The centralized @$all@ broadcaster shared by queue-consuming live clients.--A single 'EventPublisher' reads new events from the global stream once when at-least one 'Subscriber' is registered and fans them out through each-subscriber's bounded 'Control.Concurrent.STM.TBQueue'. When the registry is-empty, it does not fetch full event rows; it advances 'lastPublished' from the-@$all@ tail with a single-row query so DB-driven subscriptions can still use the-position as their live gate. 'startPublisher' launches the loop,-'subscribePublisher' registers a subscriber (returning its queue, a status-'TVar', and an unsubscribe action), 'publisherPosition' reports the last-published cursor, and 'stopPublisher' tears the loop down.--When a subscriber's queue fills, the publisher applies that subscriber's-'Kiroku.Store.Subscription.Types.OverflowPolicy' by setting its-'SubscriberStatus': under the default @PauseAndResume@ it marks the subscriber-'Paused' and stops pushing (the worker drains and re-catches-up losslessly),-under @DropSubscription@ it marks it 'Overflowed', and under @DropOldest@ it-evicts the oldest batch and counts the drop in 'subDropped'. The publisher itself never blocks on a slow consumer.--}-module Kiroku.Store.Subscription.EventPublisher (-    EventPublisher (..),+-- | The centralized @$all@ broadcaster shared by queue-consuming live clients.+--+-- A single 'EventPublisher' reads new events from the global stream once when at+-- least one 'Subscriber' is registered and fans them out through each+-- subscriber's bounded 'Control.Concurrent.STM.TBQueue'. When the registry is+-- empty, it does not fetch full event rows; it advances 'lastPublished' from the+-- @$all@ tail with a single-row query so DB-driven subscriptions can still use the+-- position as their live gate. 'startPublisher' launches the loop,+-- 'subscribePublisher' registers a subscriber (returning its queue, a status+-- 'TVar', and an unsubscribe action), 'publisherPosition' reports the last+-- published cursor, and 'stopPublisher' tears the loop down.+--+-- When a subscriber's queue fills, the publisher applies that subscriber's+-- 'Kiroku.Store.Subscription.Types.OverflowPolicy' by setting its+-- 'SubscriberStatus': under the default @PauseAndResume@ it marks the subscriber+-- 'Paused' and stops pushing (the worker drains and re-catches-up losslessly),+-- under @DropSubscription@ it marks it 'Overflowed', and under @DropOldest@ it+-- evicts the oldest batch and counts the drop in 'subDropped'. The publisher itself never blocks on a slow consumer.+module Kiroku.Store.Subscription.EventPublisher+  ( EventPublisher (..),     Subscriber (..),     SubscriberStatus (..),     startPublisher,@@ -27,12 +26,13 @@     PublisherSubscription (..),     subscribePublisherWith,     publisherPosition,-) where+  )+where  import Control.Concurrent.Async (Async) import Control.Concurrent.Async qualified as Async-import Control.Concurrent.STM (-    STM,+import Control.Concurrent.STM+  ( STM,     TBQueue,     TChan,     TVar,@@ -51,7 +51,7 @@     tryReadTChan,     writeTBQueue,     writeTVar,- )+  ) import Control.Concurrent.STM qualified as STM import Control.Exception (SomeAsyncException, SomeException, asyncExceptionFromException, catch, throwIO) import Control.Monad (when)@@ -72,62 +72,57 @@ import Kiroku.Store.Types (GlobalPosition (..), RecordedEvent (..)) import Numeric.Natural (Natural) -{- | The centralized EventPublisher. With registered subscribers, reads events-from the database once per notification and fans them out to a registry of-bounded per-subscriber queues. With an empty registry, fetches only the current-@$all@ tail to keep 'lastPublished' moving without decoding event rows.--}+-- | The centralized EventPublisher. With registered subscribers, reads events+-- from the database once per notification and fans them out to a registry of+-- bounded per-subscriber queues. With an empty registry, fetches only the current+-- @$all@ tail to keep 'lastPublished' moving without decoding event rows. data EventPublisher = EventPublisher-    { subscribers :: !(TVar (IntMap Subscriber))-    -- ^ Active subscriber registry, keyed by an internal id.-    , nextSubscriberId :: !(TVar Int)-    , publisherThread :: !(Async ())-    , lastPublished :: !(TVar GlobalPosition)-    -- ^ Last-published position; workers read this to know when catch-up is done-    }+  { -- | Active subscriber registry, keyed by an internal id.+    subscribers :: !(TVar (IntMap Subscriber)),+    nextSubscriberId :: !(TVar Int),+    publisherThread :: !(Async ()),+    -- | Last-published position; workers read this to know when catch-up is done+    lastPublished :: !(TVar GlobalPosition)+  } -{- | A registered subscriber. The publisher owns one of these per-'Kiroku.Store.Subscription.subscribe' call. The worker reads from-'subQueue' under normal operation; on overflow under 'DropSubscription'-the publisher flips 'subStatus' to 'Overflowed' and the worker-terminates the subscription with 'SubscriptionOverflowed'.--}+-- | A registered subscriber. The publisher owns one of these per+-- 'Kiroku.Store.Subscription.subscribe' call. The worker reads from+-- 'subQueue' under normal operation; on overflow under 'DropSubscription'+-- the publisher flips 'subStatus' to 'Overflowed' and the worker+-- terminates the subscription with 'SubscriptionOverflowed'. data Subscriber = Subscriber-    { subQueue :: !(TBQueue DecodedBatch)-    , subStatus :: !(TVar SubscriberStatus)-    , subPolicy :: !OverflowPolicy-    , subDropped :: !(TVar Word64)-    -- ^ Dropped batches under DropOldest; compare successive readings (modulo Word64).-    }+  { subQueue :: !(TBQueue DecodedBatch),+    subStatus :: !(TVar SubscriberStatus),+    subPolicy :: !OverflowPolicy,+    -- | Dropped batches under DropOldest; compare successive readings (modulo Word64).+    subDropped :: !(TVar Word64)+  }  -- | A bounded publisher subscription with its exact dropped-batch counter. data PublisherSubscription = PublisherSubscription-    { subscriptionQueue :: !(TBQueue DecodedBatch)-    , subscriptionStatus :: !(TVar SubscriberStatus)-    , subscriptionDropped :: !(TVar Word64)-    , unsubscribe :: !(IO ())-    -- ^ Idempotent deregistration; invoke on every exit.-    }+  { subscriptionQueue :: !(TBQueue DecodedBatch),+    subscriptionStatus :: !(TVar SubscriberStatus),+    subscriptionDropped :: !(TVar Word64),+    -- | Idempotent deregistration; invoke on every exit.+    unsubscribe :: !(IO ())+  }  -- | Subscriber lifecycle status as observed by its worker. data SubscriberStatus-    = -- | Healthy; worker reads from the queue normally.-      Active-    | {- | Recoverable backpressure under 'PauseAndResume': the queue was full-      so the publisher stopped pushing and set this flag. The worker observes-      it, drains the stale queue, clears the flag back to 'Active', and-      re-catches-up from its checkpoint to recover any skipped events.-      -}-      Paused-    | {- | Publisher signalled overflow under 'DropSubscription'; worker should-      surface a structured error.-      -}-      Overflowed-    deriving stock (Eq, Show)+  = -- | Healthy; worker reads from the queue normally.+    Active+  | -- | Recoverable backpressure under 'PauseAndResume': the queue was full+    --       so the publisher stopped pushing and set this flag. The worker observes+    --       it, drains the stale queue, clears the flag back to 'Active', and+    --       re-catches-up from its checkpoint to recover any skipped events.+    Paused+  | -- | Publisher signalled overflow under 'DropSubscription'; worker should+    --       surface a structured error.+    Overflowed+  deriving stock (Eq, Show) -{- | Publisher batch size — larger than subscriber batch size because the-Publisher serves all subscribers.--}+-- | Publisher batch size — larger than subscriber batch size because the+-- Publisher serves all subscribers. publisherBatchSize :: Int32 publisherBatchSize = 1000 @@ -135,86 +130,83 @@ safetyPollMicros :: Int safetyPollMicros = 30_000_000 -{- | Start the EventPublisher. Spawns a thread that waits for ticks from the-Notifier (or a 30-second safety poll timeout). With at least one registered-subscriber, the thread queries full event rows and delivers them to every active-subscriber; with an empty registry, it advances 'lastPublished' from the @$all@-tail using a single-row query and fetches no payload rows. Per-subscriber-overflow handling is determined by each subscription's 'OverflowPolicy'.--If an 'eventHandler' callback is supplied, the publisher emits-'Kiroku.Store.Observability.KirokuEventPublisherPoolError' when its read-query returns a 'Pool.UsageError'. The publisher continues to run on the-30-second safety poll regardless of the event firing; the event is the-operator's only structured signal that pool exhaustion or a server-error stalled the broadcast.--}+-- | Start the EventPublisher. Spawns a thread that waits for ticks from the+-- Notifier (or a 30-second safety poll timeout). With at least one registered+-- subscriber, the thread queries full event rows and delivers them to every active+-- subscriber; with an empty registry, it advances 'lastPublished' from the @$all@+-- tail using a single-row query and fetches no payload rows. Per-subscriber+-- overflow handling is determined by each subscription's 'OverflowPolicy'.+--+-- If an 'eventHandler' callback is supplied, the publisher emits+-- 'Kiroku.Store.Observability.KirokuEventPublisherPoolError' when its read+-- query returns a 'Pool.UsageError'. The publisher continues to run on the+-- 30-second safety poll regardless of the event firing; the event is the+-- operator's only structured signal that pool exhaustion or a server+-- error stalled the broadcast. startPublisher ::-    (MonadIO m) =>-    Pool ->-    TChan () ->-    -- | optional event handler for publisher-side observability-    Maybe (KirokuEvent -> IO ()) ->-    {- | interpreter-level event hooks; 'Kiroku.Store.Settings.decodeHook'-    runs once per fetched batch before fan-out to subscribers.-    -}-    StoreSettings ->-    m EventPublisher+  (MonadIO m) =>+  Pool ->+  TChan () ->+  -- | optional event handler for publisher-side observability+  Maybe (KirokuEvent -> IO ()) ->+  -- | interpreter-level event hooks; 'Kiroku.Store.Settings.decodeHook'+  --     runs once per fetched batch before fan-out to subscribers.+  StoreSettings ->+  m EventPublisher startPublisher pool notifierChan mHandler stSettings = liftIO $ do-    subsVar <- newTVarIO IntMap.empty-    nextIdVar <- newTVarIO 0-    -- Duplicate the notifier channel before reading the tail so ticks arriving-    -- during startup are redundant rather than lost until the safety poll.-    tickChan <- atomically (dupTChan notifierChan)-    tailResult <- Pool.use pool (Session.statement () SQL.currentGlobalPositionStmt)-    tailPos <- either throwIO pure tailResult-    pos <- newTVarIO (GlobalPosition tailPos)-    thread <- Async.async (publisherLoop pool tickChan subsVar pos mHandler stSettings)-    pure-        EventPublisher-            { subscribers = subsVar-            , nextSubscriberId = nextIdVar-            , publisherThread = thread-            , lastPublished = pos-            }+  subsVar <- newTVarIO IntMap.empty+  nextIdVar <- newTVarIO 0+  -- Duplicate the notifier channel before reading the tail so ticks arriving+  -- during startup are redundant rather than lost until the safety poll.+  tickChan <- atomically (dupTChan notifierChan)+  tailResult <- Pool.use pool (Session.statement () SQL.currentGlobalPositionStmt)+  tailPos <- either throwIO pure tailResult+  pos <- newTVarIO (GlobalPosition tailPos)+  thread <- Async.async (publisherLoop pool tickChan subsVar pos mHandler stSettings)+  pure+    EventPublisher+      { subscribers = subsVar,+        nextSubscriberId = nextIdVar,+        publisherThread = thread,+        lastPublished = pos+      }  -- | Stop the EventPublisher thread. stopPublisher :: (MonadIO m) => EventPublisher -> m () stopPublisher pub = liftIO $ do-    Async.cancel (publisherThread pub)-    () <$ Async.waitCatch (publisherThread pub)+  Async.cancel (publisherThread pub)+  () <$ Async.waitCatch (publisherThread pub) -{- | Register a new subscriber with the given queue capacity and overflow-policy. Returns the bounded queue, the subscriber's status TVar, and an-@unsubscribe@ action that the caller must invoke when the subscription-ends (worker exits, cancellation). Failing to unsubscribe causes the-publisher to keep delivering to a queue with no reader, which will fill-and trigger the configured policy unnecessarily.--}+-- | Register a new subscriber with the given queue capacity and overflow+-- policy. Returns the bounded queue, the subscriber's status TVar, and an+-- @unsubscribe@ action that the caller must invoke when the subscription+-- ends (worker exits, cancellation). Failing to unsubscribe causes the+-- publisher to keep delivering to a queue with no reader, which will fill+-- and trigger the configured policy unnecessarily. subscribePublisher ::-    EventPublisher ->-    -- | Queue capacity (number of batches)-    Natural ->-    OverflowPolicy ->-    STM (TBQueue DecodedBatch, TVar SubscriberStatus, IO ())+  EventPublisher ->+  -- | Queue capacity (number of batches)+  Natural ->+  OverflowPolicy ->+  STM (TBQueue DecodedBatch, TVar SubscriberStatus, IO ()) subscribePublisher pub cap policy = do-    sub <- subscribePublisherWith pub cap policy-    pure (subscriptionQueue sub, subscriptionStatus sub, unsubscribe sub)+  sub <- subscribePublisherWith pub cap policy+  pure (subscriptionQueue sub, subscriptionStatus sub, unsubscribe sub)  -- | Register a subscriber, also exposing the DropOldest counter (initially zero). subscribePublisherWith :: EventPublisher -> Natural -> OverflowPolicy -> STM PublisherSubscription subscribePublisherWith pub cap policy = do-    queue <- newTBQueue cap-    status <- newTVar Active-    dropped <- newTVar 0-    sid <- readTVar (nextSubscriberId pub)-    writeTVar (nextSubscriberId pub) (sid + 1)-    let sub = Subscriber{subQueue = queue, subStatus = status, subPolicy = policy, subDropped = dropped}-    modifyTVar' (subscribers pub) (IntMap.insert sid sub)-    let deregister =-            atomically $-                modifyTVar' (subscribers pub) (IntMap.delete sid)-    pure (PublisherSubscription queue status dropped deregister)+  queue <- newTBQueue cap+  status <- newTVar Active+  dropped <- newTVar 0+  sid <- readTVar (nextSubscriberId pub)+  writeTVar (nextSubscriberId pub) (sid + 1)+  let sub = Subscriber {subQueue = queue, subStatus = status, subPolicy = policy, subDropped = dropped}+  modifyTVar' (subscribers pub) (IntMap.insert sid sub)+  let deregister =+        atomically $+          modifyTVar' (subscribers pub) (IntMap.delete sid)+  pure (PublisherSubscription queue status dropped deregister)  -- | Read the last-published global position. publisherPosition :: EventPublisher -> STM GlobalPosition@@ -222,144 +214,144 @@  -- Internal: the publisher loop. publisherLoop ::-    Pool ->-    TChan () ->-    TVar (IntMap Subscriber) ->-    TVar GlobalPosition ->-    Maybe (KirokuEvent -> IO ()) ->-    StoreSettings ->-    IO ()+  Pool ->+  TChan () ->+  TVar (IntMap Subscriber) ->+  TVar GlobalPosition ->+  Maybe (KirokuEvent -> IO ()) ->+  StoreSettings ->+  IO () publisherLoop pool tickChan subsVar posVar mHandler stSettings = loop   where     loop = do-        -- Wait for a tick OR safety poll timeout-        waitForWakeup tickChan-        -- Drain all pending ticks (debouncing)-        drainTicks tickChan-        -- Fetch and broadcast. If a synchronous callback failure occurs after-        -- partial delivery, lastPublished is not advanced, so the next tick-        -- re-fetches under Kiroku's at-least-once delivery contract.-        fetchAndBroadcast `catch` \(e :: SomeException) ->-            case asyncExceptionFromException e of-                Just (ae :: SomeAsyncException) -> throwIO ae-                Nothing -> emitOrDrop mHandler (KirokuEventPublisherLoopError e)-        loop+      -- Wait for a tick OR safety poll timeout+      waitForWakeup tickChan+      -- Drain all pending ticks (debouncing)+      drainTicks tickChan+      -- Fetch and broadcast. If a synchronous callback failure occurs after+      -- partial delivery, lastPublished is not advanced, so the next tick+      -- re-fetches under Kiroku's at-least-once delivery contract.+      fetchAndBroadcast `catch` \(e :: SomeException) ->+        case asyncExceptionFromException e of+          Just (ae :: SomeAsyncException) -> throwIO ae+          Nothing -> emitOrDrop mHandler (KirokuEventPublisherLoopError e)+      loop      fetchAndBroadcast = do-        subs <- readTVarIO subsVar-        if IntMap.null subs-            then cheapAdvance-            else fullFetch+      subs <- readTVarIO subsVar+      if IntMap.null subs+        then cheapAdvance+        else fullFetch      cheapAdvance = do-        result <- Pool.use pool (Session.statement () SQL.currentGlobalPositionStmt)-        case result of-            Left err -> do-                -- Surface the pool error so operators see why the publisher-                -- position has stalled; the 30-second safety poll will retry.-                emitOrDrop mHandler (KirokuEventPublisherPoolError err)-            Right tailPos -> do-                -- Re-check registry emptiness in the same STM transaction as-                -- the position write. If a queue subscriber registered after-                -- our snapshot, fall through to a full fetch so no event skips-                -- a queue that now exists.-                raced <- atomically $ do-                    subs' <- readTVar subsVar-                    if IntMap.null subs'-                        then do-                            GlobalPosition cur <- readTVar posVar-                            let nextPos = max cur tailPos-                            -- Do not store a chain of lazy max expressions that-                            -- retains earlier tail-query results.-                            nextPos `seq` writeTVar posVar (GlobalPosition nextPos)-                            pure False-                        else pure True-                when raced fullFetch+      result <- Pool.use pool (Session.statement () SQL.currentGlobalPositionStmt)+      case result of+        Left err -> do+          -- Surface the pool error so operators see why the publisher+          -- position has stalled; the 30-second safety poll will retry.+          emitOrDrop mHandler (KirokuEventPublisherPoolError err)+        Right tailPos -> do+          -- Re-check registry emptiness in the same STM transaction as+          -- the position write. If a queue subscriber registered after+          -- our snapshot, fall through to a full fetch so no event skips+          -- a queue that now exists.+          raced <- atomically $ do+            subs' <- readTVar subsVar+            if IntMap.null subs'+              then do+                GlobalPosition cur <- readTVar posVar+                let nextPos = max cur tailPos+                -- Do not store a chain of lazy max expressions that+                -- retains earlier tail-query results.+                nextPos `seq` writeTVar posVar (GlobalPosition nextPos)+                pure False+              else pure True+          when raced fullFetch      fullFetch = do-        GlobalPosition pos <- readTVarIO posVar-        result <- Pool.use pool (Session.statement (pos, publisherBatchSize) SQL.readAllForwardStmt)-        case result of-            Left err -> do-                -- Surface the pool error so operators see why broadcast-                -- has stalled; the 30-second safety poll will retry.-                emitOrDrop mHandler (KirokuEventPublisherPoolError err)-            Right rawEvents-                | V.null rawEvents -> pure ()-                | otherwise -> do-                    -- Apply the decodeHook once per batch — every subscriber-                    -- observes the same transformed view, and the cost is-                    -- paid in one place instead of per-subscriber.-                    events <- decodeEvents stSettings rawEvents-                    case events of-                        UnchangedBatch _ -> pure ()-                        TransformedBatch decoded -> for_ decoded $ \case-                            Decoded _ -> pure ()-                            Undecodable event failure -> emitOrDrop mHandler (KirokuEventPublisherDecodeFailed (globalPosition event) (eventId event) failure)-                    let lastEvent = V.last rawEvents-                        newPos = globalPosition lastEvent-                    -- Snapshot the current subscriber set, then deliver outside-                    -- the snapshot's STM transaction. Each delivery is its own-                    -- atomic step so a slow subscriber under DropSubscription-                    -- cannot rollback another's enqueue.-                    subs <- readTVarIO subsVar-                    for_ (IntMap.elems subs) (deliverBatch events)-                    -- Advance posVar only after attempting delivery to the-                    -- snapshot cohort. In the same transaction, re-read the-                    -- registry and offer the in-flight batch to subscribers that-                    -- registered after the snapshot. This closes the attach race:-                    -- every subscriber either received the batch before the-                    -- advance or registered after the advanced position was-                    -- visible and will cover the range through SQL catch-up.-                    atomically $ do-                        subs' <- readTVar subsVar-                        for_ (IntMap.elems (subs' `IntMap.difference` subs)) (deliverBatchSTM events)-                        writeTVar posVar newPos-                    -- If we got a full batch, there may be more — loop immediately-                    if V.length rawEvents >= fromIntegral publisherBatchSize-                        then fullFetch-                        else pure ()+      GlobalPosition pos <- readTVarIO posVar+      result <- Pool.use pool (Session.statement (pos, publisherBatchSize) SQL.readAllForwardStmt)+      case result of+        Left err -> do+          -- Surface the pool error so operators see why broadcast+          -- has stalled; the 30-second safety poll will retry.+          emitOrDrop mHandler (KirokuEventPublisherPoolError err)+        Right rawEvents+          | V.null rawEvents -> pure ()+          | otherwise -> do+              -- Apply the decodeHook once per batch — every subscriber+              -- observes the same transformed view, and the cost is+              -- paid in one place instead of per-subscriber.+              events <- decodeEvents stSettings rawEvents+              case events of+                UnchangedBatch _ -> pure ()+                TransformedBatch decoded -> for_ decoded $ \case+                  Decoded _ -> pure ()+                  Undecodable event failure -> emitOrDrop mHandler (KirokuEventPublisherDecodeFailed (globalPosition event) (eventId event) failure)+              let lastEvent = V.last rawEvents+                  newPos = globalPosition lastEvent+              -- Snapshot the current subscriber set, then deliver outside+              -- the snapshot's STM transaction. Each delivery is its own+              -- atomic step so a slow subscriber under DropSubscription+              -- cannot rollback another's enqueue.+              subs <- readTVarIO subsVar+              for_ (IntMap.elems subs) (deliverBatch events)+              -- Advance posVar only after attempting delivery to the+              -- snapshot cohort. In the same transaction, re-read the+              -- registry and offer the in-flight batch to subscribers that+              -- registered after the snapshot. This closes the attach race:+              -- every subscriber either received the batch before the+              -- advance or registered after the advanced position was+              -- visible and will cover the range through SQL catch-up.+              atomically $ do+                subs' <- readTVar subsVar+                for_ (IntMap.elems (subs' `IntMap.difference` subs)) (deliverBatchSTM events)+                writeTVar posVar newPos+              -- If we got a full batch, there may be more — loop immediately+              if V.length rawEvents >= fromIntegral publisherBatchSize+                then fullFetch+                else pure ()      deliverBatch events sub = atomically $ do-        deliverBatchSTM events sub+      deliverBatchSTM events sub      deliverBatchSTM events sub = do-        full <- isFullTBQueue (subQueue sub)-        if not full-            then do-                -- There is space again: clear a prior 'PauseAndResume' pause so-                -- the worker (which also clears it on resume) is not left waiting.-                -- Never clear a terminal 'DropSubscription' overflow.-                status <- readTVar (subStatus sub)-                case status of-                    Paused -> writeTVar (subStatus sub) Active-                    Active -> pure ()-                    Overflowed -> pure ()-                writeTBQueue (subQueue sub) events-            else case subPolicy sub of-                -- Recoverable: signal the pause and stop pushing. Do not drop;-                -- the worker re-reads the skipped events from its checkpoint.-                PauseAndResume -> writeTVar (subStatus sub) Paused-                DropSubscription -> writeTVar (subStatus sub) Overflowed-                DropOldest -> do-                    _ <- tryReadTBQueue (subQueue sub)-                    modifyTVar' (subDropped sub) (+ 1)-                    writeTBQueue (subQueue sub) events+      full <- isFullTBQueue (subQueue sub)+      if not full+        then do+          -- There is space again: clear a prior 'PauseAndResume' pause so+          -- the worker (which also clears it on resume) is not left waiting.+          -- Never clear a terminal 'DropSubscription' overflow.+          status <- readTVar (subStatus sub)+          case status of+            Paused -> writeTVar (subStatus sub) Active+            Active -> pure ()+            Overflowed -> pure ()+          writeTBQueue (subQueue sub) events+        else case subPolicy sub of+          -- Recoverable: signal the pause and stop pushing. Do not drop;+          -- the worker re-reads the skipped events from its checkpoint.+          PauseAndResume -> writeTVar (subStatus sub) Paused+          DropSubscription -> writeTVar (subStatus sub) Overflowed+          DropOldest -> do+            _ <- tryReadTBQueue (subQueue sub)+            modifyTVar' (subDropped sub) (+ 1)+            writeTBQueue (subQueue sub) events  -- Wait for either a tick or a 30-second timeout (safety poll). waitForWakeup :: TChan () -> IO () waitForWakeup tickChan = do-    timerVar <- registerDelay safetyPollMicros-    atomically $-        (readTChan tickChan)-            `STM.orElse` (readTVar timerVar >>= STM.check)+  timerVar <- registerDelay safetyPollMicros+  atomically $+    (readTChan tickChan)+      `STM.orElse` (readTVar timerVar >>= STM.check)  -- Drain all pending ticks from the channel (debouncing). drainTicks :: TChan () -> IO () drainTicks tickChan = atomically go   where     go = do-        mTick <- tryReadTChan tickChan-        case mTick of-            Nothing -> pure ()-            Just () -> go+      mTick <- tryReadTChan tickChan+      case mTick of+        Nothing -> pure ()+        Just () -> go
src/Kiroku/Store/Subscription/Fsm.hs view
@@ -1,37 +1,36 @@ {-# OPTIONS_GHC -Werror=incomplete-patterns #-} -{- | The explicit finite state machine driving a subscription worker.--A subscription worker is, at every instant, in exactly one named-'SubscriptionState'. A single, exhaustively pattern-matched transition-function 'step' names every legal move: given the current state and an-'Input' (a thing that just happened — a batch arrived, a fetch failed, the-queue overflowed, …) it returns the next state and a list of 'Effect's the-/driver/ ('Kiroku.Store.Subscription.Worker.runWorker') must perform.--Keeping the transition table in one pure function has two payoffs:--  * It is the single, documented /extension seam/ for the sibling plan-    @docs\/plans\/40-...@ (per-event retry \/ dead-letter). When that plan adds-    a 'Kiroku.Store.Subscription.Types.SubscriptionResult' constructor it will-    also add a matching 'Input' constructor; because 'step' is exhaustively-    pattern-matched on 'Input' within each driving state and this module is-    compiled with @-Werror=incomplete-patterns@, that addition fails to-    compile until every relevant clause handles it, rather than falling-    through silently.-  * There is exactly one place to read the current state for observability-    (the worker mirrors it into a @TVar@ — see M4).--This module is deliberately a near-leaf in the dependency graph: it depends-only on 'Kiroku.Store.Types' and @hasql-pool@, never on-'Kiroku.Store.Subscription.Types' or 'Kiroku.Store.Observability'. That keeps-the graph acyclic once 'Kiroku.Store.Subscription.Types' grows a handle field-of type 'SubscriptionState'. The terminal-reason enumeration-'SubscriptionStopReason' lives here for the same reason and is re-exported by-'Kiroku.Store.Observability' so the public event API is unchanged.--}-module Kiroku.Store.Subscription.Fsm (-    -- * State+-- | The explicit finite state machine driving a subscription worker.+--+-- A subscription worker is, at every instant, in exactly one named+-- 'SubscriptionState'. A single, exhaustively pattern-matched transition+-- function 'step' names every legal move: given the current state and an+-- 'Input' (a thing that just happened — a batch arrived, a fetch failed, the+-- queue overflowed, …) it returns the next state and a list of 'Effect's the+-- /driver/ ('Kiroku.Store.Subscription.Worker.runWorker') must perform.+--+-- Keeping the transition table in one pure function has two payoffs:+--+--   * It is the single, documented /extension seam/ for the sibling plan+--     @docs\/plans\/40-...@ (per-event retry \/ dead-letter). When that plan adds+--     a 'Kiroku.Store.Subscription.Types.SubscriptionResult' constructor it will+--     also add a matching 'Input' constructor; because 'step' is exhaustively+--     pattern-matched on 'Input' within each driving state and this module is+--     compiled with @-Werror=incomplete-patterns@, that addition fails to+--     compile until every relevant clause handles it, rather than falling+--     through silently.+--   * There is exactly one place to read the current state for observability+--     (the worker mirrors it into a @TVar@ — see M4).+--+-- This module is deliberately a near-leaf in the dependency graph: it depends+-- only on 'Kiroku.Store.Types' and @hasql-pool@, never on+-- 'Kiroku.Store.Subscription.Types' or 'Kiroku.Store.Observability'. That keeps+-- the graph acyclic once 'Kiroku.Store.Subscription.Types' grows a handle field+-- of type 'SubscriptionState'. The terminal-reason enumeration+-- 'SubscriptionStopReason' lives here for the same reason and is re-exported by+-- 'Kiroku.Store.Observability' so the public event API is unchanged.+module Kiroku.Store.Subscription.Fsm+  ( -- * State     SubscriptionState (..),     ResumeCondition (..),     stateCursor,@@ -53,7 +52,8 @@      -- * Transition function     step,-) where+  )+where  import Control.Exception (SomeException) import Data.Aeson (Value, object, (.=))@@ -64,300 +64,294 @@ import Kiroku.Store.Settings (DecodeFailure (..), DecodedBatch, decodedBatchLastEvent, decodedBatchLength) import Kiroku.Store.Types (EventId (..), GlobalPosition (..), RecordedEvent (..)) -{- | Why a subscription's worker thread stopped.--Defined here (rather than in 'Kiroku.Store.Observability') so the FSM's-'Stopped' state can carry it without 'Kiroku.Store.Subscription.Fsm' having to-import the observability module — which would close a dependency cycle once-'Kiroku.Store.Subscription.Types' references 'SubscriptionState'.-'Kiroku.Store.Observability' re-exports this type, so the @KirokuEvent@ API is-unchanged.--}+-- | Why a subscription's worker thread stopped.+--+-- Defined here (rather than in 'Kiroku.Store.Observability') so the FSM's+-- 'Stopped' state can carry it without 'Kiroku.Store.Subscription.Fsm' having to+-- import the observability module — which would close a dependency cycle once+-- 'Kiroku.Store.Subscription.Types' references 'SubscriptionState'.+-- 'Kiroku.Store.Observability' re-exports this type, so the @KirokuEvent@ API is+-- unchanged. data SubscriptionStopReason-    = {- | The handler returned 'Kiroku.Store.Subscription.Types.Stop' for some-      event. The normal completion path; the checkpoint is saved at that event.-      -}-      StopHandlerRequested-    | {- | The caller invoked @cancel@. No checkpoint advance is guaranteed;-      in-flight events replay on the next restart.-      -}-      StopCancelled-    | {- | The publisher marked the subscription overflowed under-      'Kiroku.Store.Subscription.Types.DropSubscription' and the worker-      surfaced 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed'.-      -}-      StopOverflowed-    | {- | The worker thread died from an uncaught exception (typically a handler-      exception). The 'SomeException' carries the cause.-      -}-      StopWorkerCrashed !SomeException-    | -- | Default undecodable-event retries exhausted; checkpoint remains before it.-      StopUndecodable !DecodeFailure-    deriving stock (Show)--{- | How long to wait before redelivering a retried event.+  = -- | The handler returned 'Kiroku.Store.Subscription.Types.Stop' for some+    --       event. The normal completion path; the checkpoint is saved at that event.+    StopHandlerRequested+  | -- | The caller invoked @cancel@. No checkpoint advance is guaranteed;+    --       in-flight events replay on the next restart.+    StopCancelled+  | -- | The publisher marked the subscription overflowed under+    --       'Kiroku.Store.Subscription.Types.DropSubscription' and the worker+    --       surfaced 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed'.+    StopOverflowed+  | -- | The worker thread died from an uncaught exception (typically a handler+    --       exception). The 'SomeException' carries the cause.+    StopWorkerCrashed !SomeException+  | -- | Default undecodable-event retries exhausted; checkpoint remains before it.+    StopUndecodable !DecodeFailure+  deriving stock (Show) -The handler's @Kiroku.Store.Subscription.Types.Retry@ disposition carries one of-these; the worker sleeps for it before the next redelivery. Mirrors Shibuya's-@Shibuya.Core.Ack.RetryDelay@ shape (a 'NominalDiffTime') so the-@shibuya-kiroku-adapter@ can translate @AckRetry@ without loss, but is a-Kiroku-owned type so @kiroku-store@ does not depend on @shibuya-core@.--}+-- | How long to wait before redelivering a retried event.+--+-- The handler's @Kiroku.Store.Subscription.Types.Retry@ disposition carries one of+-- these; the worker sleeps for it before the next redelivery. Mirrors Shibuya's+-- @Shibuya.Core.Ack.RetryDelay@ shape (a 'NominalDiffTime') so the+-- @shibuya-kiroku-adapter@ can translate @AckRetry@ without loss, but is a+-- Kiroku-owned type so @kiroku-store@ does not depend on @shibuya-core@. newtype RetryDelay = RetryDelay NominalDiffTime-    deriving stock (Eq, Show)+  deriving stock (Eq, Show) -{- | The retry delay in microseconds, clamped at @0@, suitable for-'Control.Concurrent.threadDelay'. Fractional seconds are rounded.--}+-- | The retry delay in microseconds, clamped at @0@, suitable for+-- 'Control.Concurrent.threadDelay'. Fractional seconds are rounded. retryDelayMicros :: RetryDelay -> Int retryDelayMicros (RetryDelay d) = max 0 (round (realToFrac d * 1_000_000 :: Double)) -{- | Why an event was dead-lettered (recorded in @kiroku.dead_letters@ while the-subscription advances past it). Kiroku-owned so @kiroku-store@ stays independent-of @shibuya-core@; the adapter converts Shibuya's @DeadLetterReason@ into one of-these. 'deadLetterSummary' produces the @reason_summary@ text column and-'deadLetterReasonJson' the structured @reason@ JSONB column.--}+-- | Why an event was dead-lettered (recorded in @kiroku.dead_letters@ while the+-- subscription advances past it). Kiroku-owned so @kiroku-store@ stays independent+-- of @shibuya-core@; the adapter converts Shibuya's @DeadLetterReason@ into one of+-- these. 'deadLetterSummary' produces the @reason_summary@ text column and+-- 'deadLetterReasonJson' the structured @reason@ JSONB column. data DeadLetterReason-    = -- | Permanently unprocessable; the 'Text' is operator-facing detail.-      DeadLetterPoison !Text-    | -- | The event payload failed validation/parsing; the 'Text' is the detail.-      DeadLetterInvalid !Text-    | -- | The bounded retry budget was exhausted after the given number of total deliveries.-      DeadLetterMaxAttempts !Int-    | -- | A custom reason: a summary plus structured JSON detail.-      DeadLetterOther !Text !Value-    | -- | A consumer explicitly chose to skip an undecodable event.-      DeadLetterDecodeFailure !DecodeFailure-    deriving stock (Eq, Show)+  = -- | Permanently unprocessable; the 'Text' is operator-facing detail.+    DeadLetterPoison !Text+  | -- | The event payload failed validation/parsing; the 'Text' is the detail.+    DeadLetterInvalid !Text+  | -- | The bounded retry budget was exhausted after the given number of total deliveries.+    DeadLetterMaxAttempts !Int+  | -- | A custom reason: a summary plus structured JSON detail.+    DeadLetterOther !Text !Value+  | -- | A consumer explicitly chose to skip an undecodable event.+    DeadLetterDecodeFailure !DecodeFailure+  deriving stock (Eq, Show)  -- | The short operator-facing summary stored in @dead_letters.reason_summary@. deadLetterSummary :: DeadLetterReason -> Text deadLetterSummary = \case-    DeadLetterPoison detail -> "poison: " <> detail-    DeadLetterInvalid detail -> "invalid payload: " <> detail-    DeadLetterMaxAttempts n -> "max retry attempts exceeded (" <> T.pack (show n) <> ")"-    DeadLetterOther summary _ -> summary-    DeadLetterDecodeFailure failure -> "decode failure: " <> decodeFailureReason failure+  DeadLetterPoison detail -> "poison: " <> detail+  DeadLetterInvalid detail -> "invalid payload: " <> detail+  DeadLetterMaxAttempts n -> "max retry attempts exceeded (" <> T.pack (show n) <> ")"+  DeadLetterOther summary _ -> summary+  DeadLetterDecodeFailure failure -> "decode failure: " <> decodeFailureReason failure  -- | The structured JSON detail stored in @dead_letters.reason@ (JSONB). deadLetterReasonJson :: DeadLetterReason -> Value deadLetterReasonJson = \case-    DeadLetterPoison detail -> object ["kind" .= ("poison" :: Text), "detail" .= detail]-    DeadLetterInvalid detail -> object ["kind" .= ("invalid_payload" :: Text), "detail" .= detail]-    DeadLetterMaxAttempts n -> object ["kind" .= ("max_attempts_exceeded" :: Text), "attempts" .= n]-    DeadLetterOther summary detail -> object ["kind" .= ("other" :: Text), "summary" .= summary, "detail" .= detail]-    DeadLetterDecodeFailure failure -> object ["kind" .= ("decode_failure" :: Text), "event_id" .= (case decodeFailureEventId failure of EventId eid -> show eid), "detail" .= decodeFailureReason failure]--{- | What unblocks a 'Paused' worker.+  DeadLetterPoison detail -> object ["kind" .= ("poison" :: Text), "detail" .= detail]+  DeadLetterInvalid detail -> object ["kind" .= ("invalid_payload" :: Text), "detail" .= detail]+  DeadLetterMaxAttempts n -> object ["kind" .= ("max_attempts_exceeded" :: Text), "attempts" .= n]+  DeadLetterOther summary detail -> object ["kind" .= ("other" :: Text), "summary" .= summary, "detail" .= detail]+  DeadLetterDecodeFailure failure -> object ["kind" .= ("decode_failure" :: Text), "event_id" .= (case decodeFailureEventId failure of EventId eid -> show eid), "detail" .= decodeFailureReason failure] -In M1 there is one resume condition: the bounded queue has drained enough that-the publisher cleared the subscriber's @Paused@ flag. The constructor is named-rather than a bare unit so a future backpressure source (e.g. an explicit-resume signal) can be added without changing the 'SubscriptionState' shape.--}+-- | What unblocks a 'Paused' worker.+--+-- In M1 there is one resume condition: the bounded queue has drained enough that+-- the publisher cleared the subscriber's @Paused@ flag. The constructor is named+-- rather than a bare unit so a future backpressure source (e.g. an explicit+-- resume signal) can be added without changing the 'SubscriptionState' shape. data ResumeCondition-    = -- | Resume once the consumer drains its bounded queue.-      ResumeOnDrain-    deriving stock (Eq, Show)--{- | The named state a subscription worker is in.+  = -- | Resume once the consumer drains its bounded queue.+    ResumeOnDrain+  deriving stock (Eq, Show) -  * 'CatchingUp' — reading history directly from PostgreSQL in batches from-    @cursor@ until it reaches the publisher's last-published position.-    @attempt@ counts consecutive failed catch-up fetches and drives the-    capped exponential backoff; it resets to 0 on any successful fetch.-  * 'Live' — caught up; receiving new events (from the publisher's bounded-    queue for AllStreams, or by re-querying for Category / consumer-group-    members).-  * 'Paused' — recoverable backpressure: a slow consumer filled its bounded-    queue, so the worker stopped pulling and waits for 'resumeWhen' (added in-    M2; in M1 this state is defined but never entered).-  * 'Reconnecting' — recovering from a lost database connection while live;-    backs off and re-enters 'CatchingUp' from @cursor@ (added in M3; in M1-    defined but never entered).-  * 'Retrying' — a handler returned-    'Kiroku.Store.Subscription.Types.Retry' for the event at @cursor@; the-    worker is redelivering it (@attempt@ counts redeliveries so far) and has not-    advanced the checkpoint past it. Added by the sibling retry\/dead-letter plan-    (@docs\/plans\/40-...@). It is a /surfaced observability state/: the worker's-    delivery primitive writes it into the observable state @TVar@ while a-    redelivery is pending and restores the prior driving state afterward, so it is-    visible through @currentState@ but is never itself a driving state fed back to-    'step' (the @Retrying@ clause in 'step' is therefore defensive).-  * 'Stopped' — terminal, carrying the 'SubscriptionStopReason'.--}+-- | The named state a subscription worker is in.+--+--   * 'CatchingUp' — reading history directly from PostgreSQL in batches from+--     @cursor@ until it reaches the publisher's last-published position.+--     @attempt@ counts consecutive failed catch-up fetches and drives the+--     capped exponential backoff; it resets to 0 on any successful fetch.+--   * 'Live' — caught up; receiving new events (from the publisher's bounded+--     queue for AllStreams, or by re-querying for Category / consumer-group+--     members).+--   * 'Paused' — recoverable backpressure: a slow consumer filled its bounded+--     queue, so the worker stopped pulling and waits for 'resumeWhen' (added in+--     M2; in M1 this state is defined but never entered).+--   * 'Reconnecting' — recovering from a lost database connection while live;+--     backs off and re-enters 'CatchingUp' from @cursor@ (added in M3; in M1+--     defined but never entered).+--   * 'Retrying' — a handler returned+--     'Kiroku.Store.Subscription.Types.Retry' for the event at @cursor@; the+--     worker is redelivering it (@attempt@ counts redeliveries so far) and has not+--     advanced the checkpoint past it. Added by the sibling retry\/dead-letter plan+--     (@docs\/plans\/40-...@). It is a /surfaced observability state/: the worker's+--     delivery primitive writes it into the observable state @TVar@ while a+--     redelivery is pending and restores the prior driving state afterward, so it is+--     visible through @currentState@ but is never itself a driving state fed back to+--     'step' (the @Retrying@ clause in 'step' is therefore defensive).+--   * 'Stopped' — terminal, carrying the 'SubscriptionStopReason'. data SubscriptionState-    = CatchingUp {cursor :: !GlobalPosition, attempt :: !Int}-    | Live {cursor :: !GlobalPosition}-    | Paused {cursor :: !GlobalPosition, resumeWhen :: !ResumeCondition}-    | Reconnecting {cursor :: !GlobalPosition, attempt :: !Int}-    | Retrying {cursor :: !GlobalPosition, attempt :: !Int}-    | Stopped {reason :: !SubscriptionStopReason}-    deriving stock (Show)+  = CatchingUp {cursor :: !GlobalPosition, attempt :: !Int}+  | Live {cursor :: !GlobalPosition}+  | Paused {cursor :: !GlobalPosition, resumeWhen :: !ResumeCondition}+  | Reconnecting {cursor :: !GlobalPosition, attempt :: !Int}+  | Retrying {cursor :: !GlobalPosition, attempt :: !Int}+  | Stopped {reason :: !SubscriptionStopReason}+  deriving stock (Show) -{- | The cursor of a non-terminal state, or 'GlobalPosition' @0@ for 'Stopped'.-Used by the driver to attach the current position to emitted events.--}+-- | The cursor of a non-terminal state, or 'GlobalPosition' @0@ for 'Stopped'.+-- Used by the driver to attach the current position to emitted events. stateCursor :: SubscriptionState -> GlobalPosition stateCursor = \case-    CatchingUp c _ -> c-    Live c -> c-    Paused c _ -> c-    Reconnecting c _ -> c-    Retrying c _ -> c-    Stopped _ -> GlobalPosition 0+  CatchingUp c _ -> c+  Live c -> c+  Paused c _ -> c+  Reconnecting c _ -> c+  Retrying c _ -> c+  Stopped _ -> GlobalPosition 0 -{- | A stable, low-cardinality label for the state's /name/, independent of its-payload (cursor, attempt, reason). Suitable as a metric label value or an admin-column. The strings are fixed identifiers, not the derived 'Show' output, so-they will not drift if a constructor's fields change.--}+-- | A stable, low-cardinality label for the state's /name/, independent of its+-- payload (cursor, attempt, reason). Suitable as a metric label value or an admin+-- column. The strings are fixed identifiers, not the derived 'Show' output, so+-- they will not drift if a constructor's fields change. stateName :: SubscriptionState -> Text stateName = \case-    CatchingUp{} -> "catching_up"-    Live{} -> "live"-    Paused{} -> "paused"-    Reconnecting{} -> "reconnecting"-    Retrying{} -> "retrying"-    Stopped{} -> "stopped"--{- | A thing that just happened, fed to 'step' to compute the next state.+  CatchingUp {} -> "catching_up"+  Live {} -> "live"+  Paused {} -> "paused"+  Reconnecting {} -> "reconnecting"+  Retrying {} -> "retrying"+  Stopped {} -> "stopped" -The set is closed in M1; the sibling retry\/dead-letter plan extends it. Adding-a constructor here forces a compile error in every driving-state clause of-'step' that does not yet handle it (this module is @-Werror=incomplete-patterns@).--}+-- | A thing that just happened, fed to 'step' to compute the next state.+--+-- The set is closed in M1; the sibling retry\/dead-letter plan extends it. Adding+-- a constructor here forces a compile error in every driving-state clause of+-- 'step' that does not yet handle it (this module is @-Werror=incomplete-patterns@). data Input-    = -- | A non-empty history\/live batch arrived.-      BatchFetched !DecodedBatch-    | -- | A fetch returned no rows (catch-up is complete).-      FetchEmpty-    | -- | A fetch hit a database error.-      FetchFailed !Pool.UsageError-    | -- | The cursor reached the publisher position; switch to live.-      CaughtUp-    | -- | The handler returned 'Stop' at this position.-      HandlerStopped !GlobalPosition-    | {- | The bounded queue filled under the fail-fast 'DropSubscription' policy-      (terminal).-      -}-      QueueOverflowed-    | {- | The bounded queue filled under the recoverable 'PauseAndResume' policy:-      pause rather than terminate.-      -}-      QueueBackpressured-    | -- | The worker drained the stale queue and is ready to recover (re-catch-up).-      QueueDrained-    | -- | The worker lost its database pool while live.-      ConnectionLost !GlobalPosition !Pool.UsageError-    | -- | The caller cancelled the worker.-      Cancelled-    deriving stock (Show)--{- | A side effect the driver must perform after a transition.+  = -- | A non-empty history\/live batch arrived.+    BatchFetched !DecodedBatch+  | -- | A scanned namespace window; progress remains speculative until delivery.+    NamespaceWindowFetched !DecodedBatch !GlobalPosition !Bool+  | -- | Every applicable disposition in the window has completed.+    NamespaceWindowCompleted !GlobalPosition !Bool+  | -- | A fetch returned no rows (catch-up is complete).+    FetchEmpty+  | -- | A fetch hit a database error.+    FetchFailed !Pool.UsageError+  | -- | The cursor reached the publisher position; switch to live.+    CaughtUp+  | -- | The handler returned 'Stop' at this position.+    HandlerStopped !GlobalPosition+  | -- | The bounded queue filled under the fail-fast 'DropSubscription' policy+    --       (terminal).+    QueueOverflowed+  | -- | The bounded queue filled under the recoverable 'PauseAndResume' policy:+    --       pause rather than terminate.+    QueueBackpressured+  | -- | The worker drained the stale queue and is ready to recover (re-catch-up).+    QueueDrained+  | -- | The worker lost its database pool while live.+    ConnectionLost !GlobalPosition !Pool.UsageError+  | -- | The caller cancelled the worker.+    Cancelled+  deriving stock (Show) -Some constructors (e.g. 'FetchHistory', 'RunLive') describe work the driver-already performs by inspecting the next state; they exist so the effect list is-a complete description of the transition for future drivers and for readers.--}+-- | A side effect the driver must perform after a transition.+--+-- Some constructors (e.g. 'FetchHistory', 'RunLive') describe work the driver+-- already performs by inspecting the next state; they exist so the effect list is+-- a complete description of the transition for future drivers and for readers. data Effect-    = -- | Read a catch-up batch from the given cursor.-      FetchHistory !GlobalPosition-    | -- | Obtain the next live batch via the active live strategy.-      RunLive-    | -- | Call the handler per event, checkpointing at the batch tail.-      DeliverBatch !DecodedBatch-    | {- | Persist the checkpoint at this position. (Named 'Checkpoint' rather-      than @SaveCheckpoint@ to avoid clashing with the-      'Kiroku.Store.Observability.SubscriptionDbPhase' constructor of that name.)-      -}-      Checkpoint !GlobalPosition-    | -- | Block until the consumer drains the queue.-      WaitForDrain-    | -- | Sleep for the backoff schedule of this attempt number.-      Backoff !Int-    | -- | Emit @KirokuEventSubscriptionCaughtUp@.-      EmitCaughtUp-    | -- | Emit @KirokuEventSubscriptionPaused@ (M4).-      EmitPaused-    | -- | Emit @KirokuEventSubscriptionResumed@ (M4).-      EmitResumed-    | -- | Emit @KirokuEventSubscriptionReconnecting@ with this attempt (M4).-      EmitReconnecting !Int-    | -- | Terminal: stop the driver with this reason (return or rethrow).-      Halt !SubscriptionStopReason-    deriving stock (Show)+  = -- | Read a catch-up batch from the given cursor.+    FetchHistory !GlobalPosition+  | -- | Obtain the next live batch via the active live strategy.+    RunLive+  | -- | Call the handler per event, checkpointing at the batch tail.+    DeliverBatch !DecodedBatch+  | -- | Deliver before publishing the window frontier as completed state.+    DeliverNamespaceWindow !DecodedBatch !GlobalPosition !Bool+  | -- | Persist the checkpoint at this position. (Named 'Checkpoint' rather+    --       than @SaveCheckpoint@ to avoid clashing with the+    --       'Kiroku.Store.Observability.SubscriptionDbPhase' constructor of that name.)+    Checkpoint !GlobalPosition+  | -- | Block until the consumer drains the queue.+    WaitForDrain+  | -- | Sleep for the backoff schedule of this attempt number.+    Backoff !Int+  | -- | Emit @KirokuEventSubscriptionCaughtUp@.+    EmitCaughtUp+  | -- | Emit @KirokuEventSubscriptionPaused@ (M4).+    EmitPaused+  | -- | Emit @KirokuEventSubscriptionResumed@ (M4).+    EmitResumed+  | -- | Emit @KirokuEventSubscriptionReconnecting@ with this attempt (M4).+    EmitReconnecting !Int+  | -- | Terminal: stop the driver with this reason (return or rethrow).+    Halt !SubscriptionStopReason+  deriving stock (Show)  -- The last event's position in a batch, falling back to the given cursor when -- the batch is empty (the driver never feeds 'step' an empty 'BatchFetched', -- so the fallback is defensive). lastPos :: GlobalPosition -> DecodedBatch -> GlobalPosition lastPos fallback evs-    | decodedBatchLength evs == 0 = fallback-    | otherwise = globalPosition (decodedBatchLastEvent evs)--{- | The single transition function. Given the current state and an input,-return the next state and the effects to perform.+  | decodedBatchLength evs == 0 = fallback+  | otherwise = globalPosition (decodedBatchLastEvent evs) -Exhaustively pattern-matched on 'Input' within the two /driving/ states-('CatchingUp', 'Live') so that extending 'Input' (the retry\/dead-letter-sibling plan) forces a compile error here. The recoverable states 'Paused' and-'Reconnecting' carry their M2\/M3 transitions plus defensive self-loops; the-terminal 'Stopped' state ignores further input.--}+-- | The single transition function. Given the current state and an input,+-- return the next state and the effects to perform.+--+-- Exhaustively pattern-matched on 'Input' within the two /driving/ states+-- ('CatchingUp', 'Live') so that extending 'Input' (the retry\/dead-letter+-- sibling plan) forces a compile error here. The recoverable states 'Paused' and+-- 'Reconnecting' carry their M2\/M3 transitions plus defensive self-loops; the+-- terminal 'Stopped' state ignores further input. step :: SubscriptionState -> Input -> (SubscriptionState, [Effect]) step st input = case st of-    CatchingUp c n -> case input of-        BatchFetched evs -> (CatchingUp (lastPos c evs) 0, [DeliverBatch evs])-        FetchEmpty -> (Live c, [EmitCaughtUp])-        FetchFailed _ -> (CatchingUp c (n + 1), [Backoff n])-        CaughtUp -> (Live c, [EmitCaughtUp])-        HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])-        QueueOverflowed -> (Stopped StopOverflowed, [Halt StopOverflowed])-        QueueBackpressured -> (CatchingUp c n, []) -- defensive: catch-up reads the DB, not the queue-        QueueDrained -> (CatchingUp c n, [])-        ConnectionLost observed _ -> (Reconnecting (max c observed) 1, [EmitReconnecting 1, Backoff 1])-        Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])-    Live c -> case input of-        BatchFetched evs -> (Live (lastPos c evs), [DeliverBatch evs])-        FetchEmpty -> (Live c, [RunLive])-        FetchFailed _ -> (Reconnecting c 1, [EmitReconnecting 1, Backoff 1])-        CaughtUp -> (Live c, [])-        HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])-        QueueOverflowed -> (Stopped StopOverflowed, [Halt StopOverflowed])-        QueueBackpressured -> (Paused c ResumeOnDrain, [EmitPaused])-        QueueDrained -> (Live c, [RunLive])-        ConnectionLost observed _ -> (Reconnecting (max c observed) 1, [EmitReconnecting 1, Backoff 1])-        Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])-    Paused c rc -> case input of-        -- The worker drained the stale queue and cleared the pause flag; recover-        -- by re-catching-up from the checkpoint so any events the publisher-        -- skipped while full are re-read from the database. No loss; the live-        -- queue's stale filter drops the now-superseded queued entries on return.-        QueueDrained -> (CatchingUp c 0, [EmitResumed])-        Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])-        HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])-        _ -> (Paused c rc, [])-    Reconnecting c n -> case input of-        BatchFetched evs -> (CatchingUp (lastPos c evs) 0, [DeliverBatch evs])-        FetchEmpty -> (Live c, [])-        FetchFailed _ -> (Reconnecting c (n + 1), [EmitReconnecting (n + 1), Backoff (n + 1)])-        ConnectionLost observed _ -> (Reconnecting (max c observed) (n + 1), [EmitReconnecting (n + 1), Backoff (n + 1)])-        CaughtUp -> (CatchingUp c 0, [])-        HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])-        Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])-        QueueOverflowed -> (Reconnecting c n, [])-        QueueBackpressured -> (Reconnecting c n, [])-        QueueDrained -> (Reconnecting c n, [])-    -- 'Retrying' is a surfaced observability state managed inside the worker's-    -- delivery primitive (it sets the observable @TVar@ to 'Retrying' while a-    -- redelivery is pending and restores the prior driving state afterward); it-    -- is never a driving state fed back to 'step'. This clause is therefore-    -- defensive — only cancellation / a handler stop could plausibly race in, and-    -- both terminate; anything else returns to 'Live' at the same cursor.-    Retrying c _ -> case input of-        Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])-        HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])-        _ -> (Live c, [])-    Stopped r -> (Stopped r, [])+  CatchingUp c n -> case input of+    NamespaceWindowFetched evs frontier done -> (CatchingUp c n, [DeliverNamespaceWindow evs frontier done])+    NamespaceWindowCompleted frontier done -> if done then (Live frontier, [EmitCaughtUp]) else (CatchingUp frontier 0, [])+    BatchFetched evs -> (CatchingUp (lastPos c evs) 0, [DeliverBatch evs])+    FetchEmpty -> (Live c, [EmitCaughtUp])+    FetchFailed _ -> (CatchingUp c (n + 1), [Backoff n])+    CaughtUp -> (Live c, [EmitCaughtUp])+    HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])+    QueueOverflowed -> (Stopped StopOverflowed, [Halt StopOverflowed])+    QueueBackpressured -> (CatchingUp c n, []) -- defensive: catch-up reads the DB, not the queue+    QueueDrained -> (CatchingUp c n, [])+    ConnectionLost observed _ -> (Reconnecting (max c observed) 1, [EmitReconnecting 1, Backoff 1])+    Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])+  Live c -> case input of+    NamespaceWindowFetched evs frontier done -> (Live c, [DeliverNamespaceWindow evs frontier done])+    NamespaceWindowCompleted frontier _ -> (Live frontier, [])+    BatchFetched evs -> (Live (lastPos c evs), [DeliverBatch evs])+    FetchEmpty -> (Live c, [RunLive])+    FetchFailed _ -> (Reconnecting c 1, [EmitReconnecting 1, Backoff 1])+    CaughtUp -> (Live c, [])+    HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])+    QueueOverflowed -> (Stopped StopOverflowed, [Halt StopOverflowed])+    QueueBackpressured -> (Paused c ResumeOnDrain, [EmitPaused])+    QueueDrained -> (Live c, [RunLive])+    ConnectionLost observed _ -> (Reconnecting (max c observed) 1, [EmitReconnecting 1, Backoff 1])+    Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])+  Paused c rc -> case input of+    -- The worker drained the stale queue and cleared the pause flag; recover+    -- by re-catching-up from the checkpoint so any events the publisher+    -- skipped while full are re-read from the database. No loss; the live+    -- queue's stale filter drops the now-superseded queued entries on return.+    QueueDrained -> (CatchingUp c 0, [EmitResumed])+    Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])+    HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])+    _ -> (Paused c rc, [])+  Reconnecting c n -> case input of+    NamespaceWindowFetched evs frontier done -> (CatchingUp c 0, [DeliverNamespaceWindow evs frontier done])+    NamespaceWindowCompleted frontier done -> if done then (Live frontier, [EmitCaughtUp]) else (CatchingUp frontier 0, [])+    BatchFetched evs -> (CatchingUp (lastPos c evs) 0, [DeliverBatch evs])+    FetchEmpty -> (Live c, [])+    FetchFailed _ -> (Reconnecting c (n + 1), [EmitReconnecting (n + 1), Backoff (n + 1)])+    ConnectionLost observed _ -> (Reconnecting (max c observed) (n + 1), [EmitReconnecting (n + 1), Backoff (n + 1)])+    CaughtUp -> (CatchingUp c 0, [])+    HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])+    Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])+    QueueOverflowed -> (Reconnecting c n, [])+    QueueBackpressured -> (Reconnecting c n, [])+    QueueDrained -> (Reconnecting c n, [])+  -- 'Retrying' is a surfaced observability state managed inside the worker's+  -- delivery primitive (it sets the observable @TVar@ to 'Retrying' while a+  -- redelivery is pending and restores the prior driving state afterward); it+  -- is never a driving state fed back to 'step'. This clause is therefore+  -- defensive — only cancellation / a handler stop could plausibly race in, and+  -- both terminate; anything else returns to 'Live' at the same cursor.+  Retrying c _ -> case input of+    Cancelled -> (Stopped StopCancelled, [Halt StopCancelled])+    HandlerStopped _ -> (Stopped StopHandlerRequested, [Halt StopHandlerRequested])+    _ -> (Live c, [])+  Stopped r -> (Stopped r, [])
src/Kiroku/Store/Subscription/Stream.hs view
@@ -1,32 +1,31 @@-{- | Streamly bridges that turn a push-based subscription into a pull-based-@Stream IO@, with a bounded 'Control.Concurrent.STM.TBQueue' providing-backpressure between the Kiroku worker and a downstream stream consumer.--Two bridges are provided:--  * 'subscriptionStream' — the plain bridge. The bridge handler enqueues each-    event and immediately returns 'Continue', so the worker checkpoints at the-    batch tail independently of whether the downstream consumer has processed-    the event. Use it when the stream is an in-process handoff and per-event-    acknowledgement is not required.-  * 'subscriptionAckStream' — the __ack-coupled__ bridge. Each emitted 'AckItem'-    carries a one-shot reply variable; the bridge handler blocks until the-    consumer replies with a 'SubscriptionResult', and only then does the worker-    checkpoint \/ retry \/ dead-letter. This is the path the-    @shibuya-kiroku-adapter@ builds on so a Shibuya @AckRetry@\/@AckDeadLetter@-    drives a real Kiroku disposition before the checkpoint advances.--Both honour the subscription's worker-side filters-('Kiroku.Store.Subscription.Types.eventTypeFilter' and-'Kiroku.Store.Subscription.Types.selector'): a filtered-out event never reaches-either bridge.--Both streams end normally when the subscription worker stops cleanly or is-cancelled. If the worker dies for any other reason, the next stream pull-rethrows the worker's exception to the stream consumer.--}-module Kiroku.Store.Subscription.Stream (-    -- * Plain pull stream+-- | Streamly bridges that turn a push-based subscription into a pull-based+-- @Stream IO@, with a bounded 'Control.Concurrent.STM.TBQueue' providing+-- backpressure between the Kiroku worker and a downstream stream consumer.+--+-- Two bridges are provided:+--+--   * 'subscriptionStream' — the plain bridge. The bridge handler enqueues each+--     event and immediately returns 'Continue', so the worker checkpoints at the+--     batch tail independently of whether the downstream consumer has processed+--     the event. Use it when the stream is an in-process handoff and per-event+--     acknowledgement is not required.+--   * 'subscriptionAckStream' — the __ack-coupled__ bridge. Each emitted 'AckItem'+--     carries a one-shot reply variable; the bridge handler blocks until the+--     consumer replies with a 'SubscriptionResult', and only then does the worker+--     checkpoint \/ retry \/ dead-letter. This is the path the+--     @shibuya-kiroku-adapter@ builds on so a Shibuya @AckRetry@\/@AckDeadLetter@+--     drives a real Kiroku disposition before the checkpoint advances.+--+-- Both honour the subscription's worker-side filters+-- ('Kiroku.Store.Subscription.Types.eventTypeFilter' and+-- 'Kiroku.Store.Subscription.Types.selector'): a filtered-out event never reaches+-- either bridge.+--+-- Both streams end normally when the subscription worker stops cleanly or is+-- cancelled. If the worker dies for any other reason, the next stream pull+-- rethrows the worker's exception to the stream consumer.+module Kiroku.Store.Subscription.Stream+  ( -- * Plain pull stream     subscriptionStream,      -- * Ack-coupled pull stream@@ -37,12 +36,12 @@     streamBufferSizeValue,     defaultStreamBufferSize,     subscriptionAckStream,-)+  ) where  import Control.Concurrent.Async qualified as Async-import Control.Concurrent.STM (-    STM,+import Control.Concurrent.STM+  ( STM,     TMVar,     TVar,     atomically,@@ -57,59 +56,56 @@     takeTMVar,     writeTBQueue,     writeTVar,- )+  ) import Control.Exception (SomeException, fromException, mask, onException, throwIO) import Data.IORef (atomicModifyIORef', newIORef) import Kiroku.Store.Connection (KirokuStore) import Kiroku.Store.Subscription (subscribe)-import Kiroku.Store.Subscription.Types (-    SubscriptionConfig,+import Kiroku.Store.Subscription.Types+  ( SubscriptionConfig,     SubscriptionConfigM (..),     SubscriptionHandleM (..),     SubscriptionResult (..),- )+  ) import Kiroku.Store.Types (EventId, RecordedEvent (..)) import Numeric.Natural (Natural) import Streamly.Data.Stream (Stream) import Streamly.Data.Stream qualified as Stream -{- | One ack-coupled item emitted by 'subscriptionAckStream'.--The Kiroku subscription worker enqueues an 'AckItem' for each delivered event and-then __blocks__ until the consumer writes a 'SubscriptionResult' into 'ackReply'.-Only then does the worker act on it (checkpoint on 'Continue'\/'Stop', redeliver-on 'Retry', record-and-advance on 'DeadLetter'). This is what lets a downstream-consumer (the @shibuya-kiroku-adapter@) drive Kiroku's per-event disposition from-its own acknowledgement decision.--}+-- | One ack-coupled item emitted by 'subscriptionAckStream'.+--+-- The Kiroku subscription worker enqueues an 'AckItem' for each delivered event and+-- then __blocks__ until the consumer writes a 'SubscriptionResult' into 'ackReply'.+-- Only then does the worker act on it (checkpoint on 'Continue'\/'Stop', redeliver+-- on 'Retry', record-and-advance on 'DeadLetter'). This is what lets a downstream+-- consumer (the @shibuya-kiroku-adapter@) drive Kiroku's per-event disposition from+-- its own acknowledgement decision. data AckItem = AckItem-    { ackEvent :: !RecordedEvent-    -- ^ the delivered event-    , ackAttempt :: !Word-    {- ^ zero-based redelivery count for this event: @0@ on first delivery, @1@-    on the first redelivery after a 'Retry', and so on. Tracked by the bridge-    (the worker redelivers the same event consecutively while retrying).-    -}-    , ackReply :: !(TMVar SubscriptionResult)-    -- ^ one-shot reply the consumer must fill exactly once-    }--{- | Construction error from 'mkStreamBufferSize' for a zero capacity.+  { -- | the delivered event+    ackEvent :: !RecordedEvent,+    -- | zero-based redelivery count for this event: @0@ on first delivery, @1@+    --     on the first redelivery after a 'Retry', and so on. Tracked by the bridge+    --     (the worker redelivers the same event consecutively while retrying).+    ackAttempt :: !Word,+    -- | one-shot reply the consumer must fill exactly once+    ackReply :: !(TMVar SubscriptionResult)+  } -A zero-capacity 'TBQueue' would make the bridge handler block forever on its-first delivery, before a stream consumer can ever see the event or reply to it.--}+-- | Construction error from 'mkStreamBufferSize' for a zero capacity.+--+-- A zero-capacity 'TBQueue' would make the bridge handler block forever on its+-- first delivery, before a stream consumer can ever see the event or reply to it. newtype InvalidStreamBufferSize = InvalidStreamBufferSize Natural-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  -- | Positive capacity, validated before allocating a bridge or starting a worker. newtype StreamBufferSize = StreamBufferSize Natural-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  mkStreamBufferSize :: Natural -> Either InvalidStreamBufferSize StreamBufferSize mkStreamBufferSize n-    | n >= 1 = Right (StreamBufferSize n)-    | otherwise = Left (InvalidStreamBufferSize n)+  | n >= 1 = Right (StreamBufferSize n)+  | otherwise = Left (InvalidStreamBufferSize n)  streamBufferSizeValue :: StreamBufferSize -> Natural streamBufferSizeValue (StreamBufferSize n) = n@@ -118,128 +114,126 @@ defaultStreamBufferSize = StreamBufferSize 256  data BridgeTermination-    = BridgeClosedCleanly-    | BridgeCrashed !SomeException+  = BridgeClosedCleanly+  | BridgeCrashed !SomeException  closeBridge :: TVar (Maybe BridgeTermination) -> BridgeTermination -> STM () closeBridge closedVar termination =-    readTVar closedVar >>= \case-        Nothing -> writeTVar closedVar (Just termination)-        Just _ -> pure ()--{- | Create a pull-based 'Stream' from a kiroku subscription.--A bounded 'TBQueue' sits between kiroku's push-based handler and the-returned Streamly stream. The stream terminates normally when the underlying-subscription stops cleanly or the returned cancel action runs. If the-underlying worker crashes, the next stream pull rethrows the worker exception-to the consumer.--The @handler@ field in the provided 'SubscriptionConfig' is ignored —-the bridge provides its own handler.--This is the plain, non-acknowledging bridge: every event is replied 'Continue'-as soon as the consumer pulls it, preserving the original semantics. It is-implemented in terms of 'subscriptionAckStream'; consumers that need to drive-retry\/dead-letter dispositions should use 'subscriptionAckStream' directly.--The config's-'Kiroku.Store.Subscription.Types.eventTypeFilter' is honored: the worker-applies it before the bridge handler runs, so the stream yields only matching-events while the checkpoint still advances past filtered-out ones. The stream's-element type is unchanged ('RecordedEvent') — filtering only removes elements,-it does not reshape them.+  readTVar closedVar >>= \case+    Nothing -> writeTVar closedVar (Just termination)+    Just _ -> pure () -The returned cancel action cancels the underlying subscription and wakes any-blocked reader without writing to the bounded queue.--}+-- | Create a pull-based 'Stream' from a kiroku subscription.+--+-- A bounded 'TBQueue' sits between kiroku's push-based handler and the+-- returned Streamly stream. The stream terminates normally when the underlying+-- subscription stops cleanly or the returned cancel action runs. If the+-- underlying worker crashes, the next stream pull rethrows the worker exception+-- to the consumer.+--+-- The @handler@ field in the provided 'SubscriptionConfig' is ignored —+-- the bridge provides its own handler.+--+-- This is the plain, non-acknowledging bridge: every event is replied 'Continue'+-- as soon as the consumer pulls it, preserving the original semantics. It is+-- implemented in terms of 'subscriptionAckStream'; consumers that need to drive+-- retry\/dead-letter dispositions should use 'subscriptionAckStream' directly.+--+-- The config's+-- 'Kiroku.Store.Subscription.Types.eventTypeFilter' is honored: the worker+-- applies it before the bridge handler runs, so the stream yields only matching+-- events while the checkpoint still advances past filtered-out ones. The stream's+-- element type is unchanged ('RecordedEvent') — filtering only removes elements,+-- it does not reshape them.+--+-- The returned cancel action cancels the underlying subscription and wakes any+-- blocked reader without writing to the bounded queue. subscriptionStream ::-    KirokuStore ->-    SubscriptionConfig ->-    -- | TBQueue capacity for the bridge; must be at least 1.-    StreamBufferSize ->-    IO (Stream IO RecordedEvent, IO ())+  KirokuStore ->+  SubscriptionConfig ->+  -- | TBQueue capacity for the bridge; must be at least 1.+  StreamBufferSize ->+  IO (Stream IO RecordedEvent, IO ()) subscriptionStream store config bufferSize = do-    (ackStream, cancelAction) <- subscriptionAckStream store config bufferSize-    let stream =-            Stream.mapM-                ( \item -> do-                    atomically (putTMVar (ackReply item) Continue)-                    pure (ackEvent item)-                )-                ackStream-    pure (stream, cancelAction)--{- | Create an ack-coupled pull 'Stream' from a kiroku subscription.--Each emitted 'AckItem' carries a one-shot reply variable; the underlying Kiroku-worker blocks in its handler until the consumer fills 'ackReply' with a-'SubscriptionResult'. The worker then checkpoints, redelivers, dead-letters, or-stops accordingly — so the consumer controls Kiroku checkpointing per event. The-@handler@ field in the provided 'SubscriptionConfig' is ignored; the bridge-installs its own.+  (ackStream, cancelAction) <- subscriptionAckStream store config bufferSize+  let stream =+        Stream.mapM+          ( \item -> do+              atomically (putTMVar (ackReply item) Continue)+              pure (ackEvent item)+          )+          ackStream+  pure (stream, cancelAction) -The returned cancel action cancels the underlying subscription (interrupting any-worker blocked waiting for a reply) and wakes any blocked reader without-writing to the bounded queue. A clean worker stop or cancellation ends the-stream normally; any other worker exception is rethrown from the next stream-pull. This includes overflow, handler exceptions, dead-letter database errors,-and decode-hook exceptions.--}+-- | Create an ack-coupled pull 'Stream' from a kiroku subscription.+--+-- Each emitted 'AckItem' carries a one-shot reply variable; the underlying Kiroku+-- worker blocks in its handler until the consumer fills 'ackReply' with a+-- 'SubscriptionResult'. The worker then checkpoints, redelivers, dead-letters, or+-- stops accordingly — so the consumer controls Kiroku checkpointing per event. The+-- @handler@ field in the provided 'SubscriptionConfig' is ignored; the bridge+-- installs its own.+--+-- The returned cancel action cancels the underlying subscription (interrupting any+-- worker blocked waiting for a reply) and wakes any blocked reader without+-- writing to the bounded queue. A clean worker stop or cancellation ends the+-- stream normally; any other worker exception is rethrown from the next stream+-- pull. This includes overflow, handler exceptions, dead-letter database errors,+-- and decode-hook exceptions. subscriptionAckStream ::-    KirokuStore ->-    SubscriptionConfig ->-    -- | TBQueue capacity for the bridge; must be at least 1.-    StreamBufferSize ->-    IO (Stream IO AckItem, IO ())+  KirokuStore ->+  SubscriptionConfig ->+  -- | TBQueue capacity for the bridge; must be at least 1.+  StreamBufferSize ->+  IO (Stream IO AckItem, IO ()) subscriptionAckStream store config bufferSize = mask $ \restore -> do-    queue <- newTBQueueIO (streamBufferSizeValue bufferSize)-    closedVar <- newTVarIO Nothing-    -- Tracks the previous (eventId, attempt) so a consecutive redelivery of the-    -- same event (the worker's bounded retry) is reported with an incremented-    -- attempt. The worker delivers events one at a time and blocks on the reply,-    -- so there is no concurrent access to this ref.-    attemptRef <- newIORef (Nothing :: Maybe (EventId, Word))+  queue <- newTBQueueIO (streamBufferSizeValue bufferSize)+  closedVar <- newTVarIO Nothing+  -- Tracks the previous (eventId, attempt) so a consecutive redelivery of the+  -- same event (the worker's bounded retry) is reported with an incremented+  -- attempt. The worker delivers events one at a time and blocks on the reply,+  -- so there is no concurrent access to this ref.+  attemptRef <- newIORef (Nothing :: Maybe (EventId, Word)) -    let bridgeHandler :: RecordedEvent -> IO SubscriptionResult-        bridgeHandler event = do-            attempt <- atomicModifyIORef' attemptRef $ \mPrev ->-                case mPrev of-                    Just (eid, n)-                        | eid == eventId event -> (Just (eid, n + 1), n + 1)-                    _ -> (Just (eventId event, 0), 0)-            reply <- newEmptyTMVarIO-            atomically (writeTBQueue queue (AckItem event attempt reply))-            atomically (takeTMVar reply)+  let bridgeHandler :: RecordedEvent -> IO SubscriptionResult+      bridgeHandler event = do+        attempt <- atomicModifyIORef' attemptRef $ \mPrev ->+          case mPrev of+            Just (eid, n)+              | eid == eventId event -> (Just (eid, n + 1), n + 1)+            _ -> (Just (eventId event, 0), 0)+        reply <- newEmptyTMVarIO+        atomically (writeTBQueue queue (AckItem event attempt reply))+        atomically (takeTMVar reply) -    let bridgeConfig = config{handler = bridgeHandler}+  let bridgeConfig = config {handler = bridgeHandler} -    subHandle <- restore (subscribe store bridgeConfig)-    monitor <--        ( Async.async $ do-            outcome <- wait subHandle-            atomically . closeBridge closedVar $ case outcome of-                Right () -> BridgeClosedCleanly-                Left e-                    | Just Async.AsyncCancelled <- fromException e -> BridgeClosedCleanly-                    | otherwise -> BridgeCrashed e-        )-            `onException` cancel subHandle+  subHandle <- restore (subscribe store bridgeConfig)+  monitor <-+    ( Async.async $ do+        outcome <- wait subHandle+        atomically . closeBridge closedVar $ case outcome of+          Right () -> BridgeClosedCleanly+          Left e+            | Just Async.AsyncCancelled <- fromException e -> BridgeClosedCleanly+            | otherwise -> BridgeCrashed e+    )+      `onException` cancel subHandle -    let cancelAction = do-            cancel subHandle-            Async.wait monitor-            atomically (closeBridge closedVar BridgeClosedCleanly)+  let cancelAction = do+        cancel subHandle+        Async.wait monitor+        atomically (closeBridge closedVar BridgeClosedCleanly) -    let step :: () -> IO (Maybe (AckItem, ()))-        step () = do-            next <--                atomically $-                    (Right <$> readTBQueue queue)-                        `orElse` (readTVar closedVar >>= maybe retry (pure . Left))-            case next of-                Right item -> pure (Just (item, ()))-                Left BridgeClosedCleanly -> pure Nothing-                Left (BridgeCrashed e) -> throwIO e+  let step :: () -> IO (Maybe (AckItem, ()))+      step () = do+        next <-+          atomically $+            (Right <$> readTBQueue queue)+              `orElse` (readTVar closedVar >>= maybe retry (pure . Left))+        case next of+          Right item -> pure (Just (item, ()))+          Left BridgeClosedCleanly -> pure Nothing+          Left (BridgeCrashed e) -> throwIO e -    pure (Stream.unfoldrM step (), cancelAction)+  pure (Stream.unfoldrM step (), cancelAction)
src/Kiroku/Store/Subscription/Types.hs view
@@ -1,25 +1,24 @@ {-# LANGUAGE ExistentialQuantification #-} -{- | Core configuration and result types for subscriptions.--This module defines the user-facing vocabulary for starting a subscription and-controlling it per event:--  * 'SubscriptionConfigM' (built with 'defaultSubscriptionConfig') — the-    declarative config: target, handler, batch size, overflow policy-    ('OverflowPolicy'), consumer-group membership, retry policy, and the two-    delivery filters ('EventTypeFilter' plus the opaque 'selector').-  * 'SubscriptionResult' — what the handler returns per event: 'Continue',-    'Stop', or the per-event dispositions 'Retry' and 'DeadLetter'.-  * 'SubscriptionHandleM' — the lifecycle handle ('cancel', 'wait', and the-    observable 'currentState').--The worker's state machine lives in "Kiroku.Store.Subscription.Fsm" (from which-'SubscriptionState', 'RetryDelay', and 'DeadLetterReason' are re-exported here);-this module is the configuration/result surface that drives it.--}-module Kiroku.Store.Subscription.Types (-    SubscriptionName (..),+-- | Core configuration and result types for subscriptions.+--+-- This module defines the user-facing vocabulary for starting a subscription and+-- controlling it per event:+--+--   * 'SubscriptionConfigM' (built with 'defaultSubscriptionConfig') — the+--     declarative config: target, handler, batch size, overflow policy+--     ('OverflowPolicy'), consumer-group membership, retry policy, and the two+--     delivery filters ('EventTypeFilter' plus the opaque 'selector').+--   * 'SubscriptionResult' — what the handler returns per event: 'Continue',+--     'Stop', or the per-event dispositions 'Retry' and 'DeadLetter'.+--   * 'SubscriptionHandleM' — the lifecycle handle ('cancel', 'wait', and the+--     observable 'currentState').+--+-- The worker's state machine lives in "Kiroku.Store.Subscription.Fsm" (from which+-- 'SubscriptionState', 'RetryDelay', and 'DeadLetterReason' are re-exported here);+-- this module is the configuration/result surface that drives it.+module Kiroku.Store.Subscription.Types+  ( SubscriptionName (..),     MissingCheckpointPolicy (..),     SubscriptionCheckpointKey (..),     CheckpointInitialization (..),@@ -87,7 +86,8 @@     ConsumerGroupSizeMismatch (..),     InvalidConsumerGroup (..),     ConsumerGroupGuardConflict (..),-) where+  )+where  import Control.Exception (Exception (..), SomeException) import Data.Aeson (Value)@@ -101,325 +101,313 @@ import Data.Vector (Vector) import GHC.Generics (Generic) import Kiroku.Store.Settings (DecodeFailure)-import Kiroku.Store.Subscription.Fsm (-    DeadLetterReason (..),+import Kiroku.Store.Subscription.Fsm+  ( DeadLetterReason (..),     RetryDelay (..),     SubscriptionState,     deadLetterReasonJson,     deadLetterSummary,     retryDelayMicros,- )+  ) import Kiroku.Store.Types (CategoryName, EventId, EventType, GlobalPosition, RecordedEvent (..)) import Numeric.Natural (Natural) -{- | A declarative, closed filter over event types for a subscription.--'AllEventTypes' (the default) delivers every event. @'OnlyEventTypes' s@-delivers only events whose 'eventType' is in @s@; all other events are-skipped (the handler is not called) but the subscription checkpoint still-advances past them, so a highly selective subscription never stalls on a long-run of filtered-out events.--=== Why this is a closed sum and not @RecordedEvent -> Bool@--EventStore's subscription @selector@ is an opaque @RecordedEvent -> Bool@.-Kiroku deliberately models the /common/ case — \"deliver only these event-types\" — as a closed, first-order value instead, and keeps the opaque-predicate as a separate, optional escape hatch ('selector' on-'SubscriptionConfigM'). The closed sum is preferred for the steady-state filter-because it:--  * stays __introspectable__ — an operator or a metrics endpoint can read which-    types a subscription delivers (you cannot inspect a closure);-  * is __'Eq'\/'Show'-able__ for tests and diagnostics; and-  * leaves the door open to __SQL pushdown__ on fetching subscriptions-    (@Category@ / consumer-group), where @event_type = ANY(...)@ can be lowered-    into the catch-up read so the worker stops fetching-and-discarding. A-    @RecordedEvent -> Bool@ can never be pushed down.--Reach for 'selector' only when you need a predicate the type set cannot express-(e.g. filtering on metadata during a one-off catch-up reprocess while-investigating a problem). The two compose: an event is delivered only when it-passes /both/ (see 'shouldDeliver').--This is unrelated to 'Kiroku.Store.Types.EventFilter', which filters-correlation\/causation /queries/.--}+-- | A declarative, closed filter over event types for a subscription.+--+-- 'AllEventTypes' (the default) delivers every event. @'OnlyEventTypes' s@+-- delivers only events whose 'eventType' is in @s@; all other events are+-- skipped (the handler is not called) but the subscription checkpoint still+-- advances past them, so a highly selective subscription never stalls on a long+-- run of filtered-out events.+--+-- === Why this is a closed sum and not @RecordedEvent -> Bool@+--+-- EventStore's subscription @selector@ is an opaque @RecordedEvent -> Bool@.+-- Kiroku deliberately models the /common/ case — \"deliver only these event+-- types\" — as a closed, first-order value instead, and keeps the opaque+-- predicate as a separate, optional escape hatch ('selector' on+-- 'SubscriptionConfigM'). The closed sum is preferred for the steady-state filter+-- because it:+--+--   * stays __introspectable__ — an operator or a metrics endpoint can read which+--     types a subscription delivers (you cannot inspect a closure);+--   * is __'Eq'\/'Show'-able__ for tests and diagnostics; and+--   * leaves the door open to __SQL pushdown__ on fetching subscriptions+--     (@Category@ / consumer-group), where @event_type = ANY(...)@ can be lowered+--     into the catch-up read so the worker stops fetching-and-discarding. A+--     @RecordedEvent -> Bool@ can never be pushed down.+--+-- Reach for 'selector' only when you need a predicate the type set cannot express+-- (e.g. filtering on metadata during a one-off catch-up reprocess while+-- investigating a problem). The two compose: an event is delivered only when it+-- passes /both/ (see 'shouldDeliver').+--+-- This is unrelated to 'Kiroku.Store.Types.EventFilter', which filters+-- correlation\/causation /queries/. data EventTypeFilter-    = -- | Deliver every event (the no-op default).-      AllEventTypes-    | -- | Deliver only events whose 'eventType' is in this set.-      OnlyEventTypes !(Set EventType)-    deriving stock (Eq, Show)+  = -- | Deliver every event (the no-op default).+    AllEventTypes+  | -- | Deliver only events whose 'eventType' is in this set.+    OnlyEventTypes !(Set EventType)+  deriving stock (Eq, Show) -{- | 'True' when an event passes the declarative type filter.-The worker applies this /before/ calling the handler, so a non-matching event-never reaches the handler (or the ack-coupled bridge) and is never retried or-dead-lettered.--}+-- | 'True' when an event passes the declarative type filter.+-- The worker applies this /before/ calling the handler, so a non-matching event+-- never reaches the handler (or the ack-coupled bridge) and is never retried or+-- dead-lettered. eventTypeMatches :: EventTypeFilter -> RecordedEvent -> Bool eventTypeMatches AllEventTypes _ = True-eventTypeMatches (OnlyEventTypes s) RecordedEvent{eventType = t} = Set.member t s--{- | The complete per-event delivery predicate for a subscription: an event is-delivered to the handler only when it passes __both__ the declarative-'eventTypeFilter' and the optional opaque 'selector' (an absent selector always-passes). The two compose as a logical AND — the type filter narrows by-'eventType', the selector narrows further by any predicate over the whole-'RecordedEvent'.+eventTypeMatches (OnlyEventTypes s) RecordedEvent {eventType = t} = Set.member t s -The worker applies this /before/ calling the handler, so an event rejected by-either filter never reaches the handler or the ack-coupled bridge (it is never-retried or dead-lettered), yet the checkpoint still advances past it — the-no-stall invariant holds for the selector exactly as it does for the type-filter.--}+-- | The complete per-event delivery predicate for a subscription: an event is+-- delivered to the handler only when it passes __both__ the declarative+-- 'eventTypeFilter' and the optional opaque 'selector' (an absent selector always+-- passes). The two compose as a logical AND — the type filter narrows by+-- 'eventType', the selector narrows further by any predicate over the whole+-- 'RecordedEvent'.+--+-- The worker applies this /before/ calling the handler, so an event rejected by+-- either filter never reaches the handler or the ack-coupled bridge (it is never+-- retried or dead-lettered), yet the checkpoint still advances past it — the+-- no-stall invariant holds for the selector exactly as it does for the type+-- filter. shouldDeliver :: EventTypeFilter -> Maybe (RecordedEvent -> Bool) -> RecordedEvent -> Bool shouldDeliver etf sel event =-    eventTypeMatches etf event && maybe True ($ event) sel+  eventTypeMatches etf event && maybe True ($ event) sel  -- | Unique name for a subscription (e.g., @"inventory-projection"@). newtype SubscriptionName = SubscriptionName Text-    deriving newtype (Eq, Ord, Show)+  deriving newtype (Eq, Ord, Show) -{- | What a subscription does when its exact-@('SubscriptionName', consumer-group member)@ checkpoint key has no durable-row. The policy is consulted only for absence; an existing row always wins and-is never moved by initialization.--}+-- | What a subscription does when its exact+-- @('SubscriptionName', consumer-group member)@ checkpoint key has no durable+-- row. The policy is consulted only for absence; an existing row always wins and+-- is never moved by initialization. data MissingCheckpointPolicy-    = -- | Materialize position zero and replay retained history.-      FromBeginning-    | -- | Atomically materialize the current @$all@ store head.-      FromCurrentHead-    | -- | Refuse startup without inserting a row.-      FailIfMissing-    deriving stock (Eq, Show, Generic)+  = -- | Materialize position zero and replay retained history.+    FromBeginning+  | -- | Atomically materialize the current @$all@ store head.+    FromCurrentHead+  | -- | Refuse startup without inserting a row.+    FailIfMissing+  deriving stock (Eq, Show, Generic)  -- | The durable identity of one subscription checkpoint row. data SubscriptionCheckpointKey = SubscriptionCheckpointKey-    { subscriptionName :: !SubscriptionName-    , consumerGroupMember :: !Int32-    }-    deriving stock (Eq, Ord, Show, Generic)+  { subscriptionName :: !SubscriptionName,+    consumerGroupMember :: !Int32+  }+  deriving stock (Eq, Ord, Show, Generic) -{- | The successful result of resolving one subscription checkpoint at-startup. 'ExistingCheckpoint' means a durable row already existed;-'InitializedCheckpoint' records the policy that created the row. In both-cases the position is the durable position returned by PostgreSQL.--}+-- | The successful result of resolving one subscription checkpoint at+-- startup. 'ExistingCheckpoint' means a durable row already existed;+-- 'InitializedCheckpoint' records the policy that created the row. In both+-- cases the position is the durable position returned by PostgreSQL. data CheckpointInitialization-    = ExistingCheckpoint !SubscriptionCheckpointKey !GlobalPosition-    | InitializedCheckpoint !MissingCheckpointPolicy !SubscriptionCheckpointKey !GlobalPosition-    deriving stock (Eq, Show, Generic)+  = ExistingCheckpoint !SubscriptionCheckpointKey !GlobalPosition+  | InitializedCheckpoint !MissingCheckpointPolicy !SubscriptionCheckpointKey !GlobalPosition+  deriving stock (Eq, Show, Generic)  -- | Extract the durable position from either successful initialization result. checkpointInitializationPosition :: CheckpointInitialization -> GlobalPosition checkpointInitializationPosition = \case-    ExistingCheckpoint _ position -> position-    InitializedCheckpoint _ _ position -> position+  ExistingCheckpoint _ position -> position+  InitializedCheckpoint _ _ position -> position -{- | A typed startup refusal produced by 'FailIfMissing'. No checkpoint row is-inserted and a worker throws this exception before invoking its handler.--}+-- | A typed startup refusal produced by 'FailIfMissing'. No checkpoint row is+-- inserted and a worker throws this exception before invoking its handler. newtype SubscriptionCheckpointMissing = SubscriptionCheckpointMissing-    { checkpointKey :: SubscriptionCheckpointKey-    }-    deriving stock (Eq, Show, Generic)+  { checkpointKey :: SubscriptionCheckpointKey+  }+  deriving stock (Eq, Show, Generic)  instance Exception SubscriptionCheckpointMissing where-    toException = toException . SomeSubscriptionStartupFailure-    fromException exception = do-        SomeSubscriptionStartupFailure refusal <- fromException exception-        cast refusal+  toException = toException . SomeSubscriptionStartupFailure+  fromException exception = do+    SomeSubscriptionStartupFailure refusal <- fromException exception+    cast refusal  -- | One checkpoint row that has been durably persisted by a subscription. data SubscriptionCheckpoint = SubscriptionCheckpoint-    { subscriptionName :: !SubscriptionName-    , consumerGroupMember :: !Int32-    , checkpointPosition :: !GlobalPosition-    , checkpointUpdatedAt :: !UTCTime-    }-    deriving stock (Eq, Show, Generic)+  { subscriptionName :: !SubscriptionName,+    consumerGroupMember :: !Int32,+    checkpointPosition :: !GlobalPosition,+    checkpointUpdatedAt :: !UTCTime+  }+  deriving stock (Eq, Show, Generic) -{- | A point-in-time view of the global store position and every durable-subscription checkpoint. The rows are ordered by 'subscriptionName' and then-'consumerGroupMember'.--}+-- | A point-in-time view of the global store position and every durable+-- subscription checkpoint. The rows are ordered by 'subscriptionName' and then+-- 'consumerGroupMember'. data SubscriptionCheckpointInventory = SubscriptionCheckpointInventory-    { storePosition :: !GlobalPosition-    , checkpoints :: !(Vector SubscriptionCheckpoint)-    }-    deriving stock (Eq, Show, Generic)+  { storePosition :: !GlobalPosition,+    checkpoints :: !(Vector SubscriptionCheckpoint)+  }+  deriving stock (Eq, Show, Generic)  -- | A durably parked event. The worker's structured reason JSON is unchanged. data SubscriptionDeadLetter = SubscriptionDeadLetter-    { deadLetterId :: !Int64-    , subscriptionName :: !SubscriptionName-    , consumerGroupMember :: !Int32-    , globalPosition :: !GlobalPosition-    , eventId :: !EventId-    , reason :: !Value-    , reasonSummary :: !Text-    , attemptCount :: !Int32-    , createdAt :: !UTCTime-    }-    deriving stock (Eq, Show, Generic)+  { deadLetterId :: !Int64,+    subscriptionName :: !SubscriptionName,+    consumerGroupMember :: !Int32,+    globalPosition :: !GlobalPosition,+    eventId :: !EventId,+    reason :: !Value,+    reasonSummary :: !Text,+    attemptCount :: !Int32,+    createdAt :: !UTCTime+  }+  deriving stock (Eq, Show, Generic) -{- | Exclusive cursor in descending (global position, dead-letter id) order.-It remains usable after the originating row is deleted.--}+-- | Exclusive cursor in descending (global position, dead-letter id) order.+-- It remains usable after the originating row is deleted. data SubscriptionDeadLetterCursor = SubscriptionDeadLetterCursor-    { cursorGlobalPosition :: !GlobalPosition-    , cursorDeadLetterId :: !Int64-    }-    deriving stock (Eq, Ord, Show, Generic)+  { cursorGlobalPosition :: !GlobalPosition,+    cursorDeadLetterId :: !Int64+  }+  deriving stock (Eq, Ord, Show, Generic)  subscriptionDeadLetterCursor :: SubscriptionDeadLetter -> SubscriptionDeadLetterCursor-subscriptionDeadLetterCursor SubscriptionDeadLetter{globalPosition = position, deadLetterId = ident} =-    SubscriptionDeadLetterCursor position ident+subscriptionDeadLetterCursor SubscriptionDeadLetter {globalPosition = position, deadLetterId = ident} =+  SubscriptionDeadLetterCursor position ident -{- | Validated page size, 1 through 1,000. Use the smart constructor.-No Generic or numeric instance exposes a construction bypass.--}+-- | Validated page size, 1 through 1,000. Use the smart constructor.+-- No Generic or numeric instance exposes a construction bypass. newtype SubscriptionDeadLetterLimit = SubscriptionDeadLetterLimit Int32-    deriving stock (Eq, Ord, Show)+  deriving stock (Eq, Ord, Show)  newtype SubscriptionDeadLetterLimitOutOfRange = SubscriptionDeadLetterLimitOutOfRange Int32-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)+ mkSubscriptionDeadLetterLimit :: Int32 -> Either SubscriptionDeadLetterLimitOutOfRange SubscriptionDeadLetterLimit mkSubscriptionDeadLetterLimit value-    | value < 1 || value > 1000 = Left (SubscriptionDeadLetterLimitOutOfRange value)-    | otherwise = Right (SubscriptionDeadLetterLimit value)+  | value < 1 || value > 1000 = Left (SubscriptionDeadLetterLimitOutOfRange value)+  | otherwise = Right (SubscriptionDeadLetterLimit value)+ subscriptionDeadLetterLimitValue :: SubscriptionDeadLetterLimit -> Int32 subscriptionDeadLetterLimitValue (SubscriptionDeadLetterLimit value) = value+ defaultSubscriptionDeadLetterLimit :: SubscriptionDeadLetterLimit defaultSubscriptionDeadLetterLimit = SubscriptionDeadLetterLimit 100  -- | Nothing selects all historical members, or starts at the newest row. data SubscriptionDeadLetterQuery = SubscriptionDeadLetterQuery-    { subscriptionName :: !SubscriptionName-    , consumerGroupMember :: !(Maybe Int32)-    , after :: !(Maybe SubscriptionDeadLetterCursor)-    , limit :: !SubscriptionDeadLetterLimit-    }-    deriving stock (Eq, Show, Generic)+  { subscriptionName :: !SubscriptionName,+    consumerGroupMember :: !(Maybe Int32),+    after :: !(Maybe SubscriptionDeadLetterCursor),+    limit :: !SubscriptionDeadLetterLimit+  }+  deriving stock (Eq, Show, Generic)  defaultSubscriptionDeadLetterQuery :: SubscriptionName -> SubscriptionDeadLetterQuery defaultSubscriptionDeadLetterQuery name = SubscriptionDeadLetterQuery name Nothing Nothing defaultSubscriptionDeadLetterLimit  -- | Trimmed page; a cursor is present exactly when another row exists. data SubscriptionDeadLetterPage = SubscriptionDeadLetterPage-    { deadLetters :: !(Vector SubscriptionDeadLetter)-    , nextCursor :: !(Maybe SubscriptionDeadLetterCursor)-    }-    deriving stock (Eq, Show, Generic)+  { deadLetters :: !(Vector SubscriptionDeadLetter),+    nextCursor :: !(Maybe SubscriptionDeadLetterCursor)+  }+  deriving stock (Eq, Show, Generic) --- | Which stream to subscribe to.+-- | Which events to subscribe to. data SubscriptionTarget-    = -- | Subscribe to all events in global position order.-      AllStreams-    | -- | Subscribe to events from streams matching a category prefix.-      Category !CategoryName-    deriving stock (Eq, Show)+  = -- | Subscribe to all events in global position order.+    AllStreams+  | -- | Subscribe to one exact category.+    Category !CategoryName+  | -- | A literal category namespace: @"pm"@ includes @pm@ and @pm:*@,+    -- but not @pmm@. No trimming, case folding or wildcard interpretation.+    -- Empty and trailing-colon values are literal too. Each worker scans+    -- bounded global windows; its checkpoint records completed scan progress.+    CategoryNamespace !Text+  deriving stock (Eq, Show)  -- | What the handler returns to control the subscription lifecycle. data SubscriptionResult-    = -- | Continue processing events.-      Continue-    | -- | Stop the subscription gracefully.-      Stop-    | {- | Redeliver /this same event/ after the given delay before the checkpoint-      advances past it. Redelivery is bounded by the subscription's-      'RetryPolicy' ('retryMaxAttempts'), which counts total deliveries, not-      redeliveries; once the bound is reached the worker dead-letters the event-      with 'DeadLetterMaxAttempts' and advances to the next event. The adapter-      maps Shibuya's @AckRetry@ to this.-      -}-      Retry !RetryDelay-    | {- | Record this event in @kiroku.dead_letters@ with the given reason and-      atomically advance the checkpoint past it, then continue with the next-      event. The adapter maps Shibuya's @AckDeadLetter@ to this.-      -}-      DeadLetter !DeadLetterReason-    deriving stock (Eq, Show)+  = -- | Continue processing events.+    Continue+  | -- | Stop the subscription gracefully.+    Stop+  | -- | Redeliver /this same event/ after the given delay before the checkpoint+    --       advances past it. Redelivery is bounded by the subscription's+    --       'RetryPolicy' ('retryMaxAttempts'), which counts total deliveries, not+    --       redeliveries; once the bound is reached the worker dead-letters the event+    --       with 'DeadLetterMaxAttempts' and advances to the next event. The adapter+    --       maps Shibuya's @AckRetry@ to this.+    Retry !RetryDelay+  | -- | Record this event in @kiroku.dead_letters@ with the given reason and+    --       atomically advance the checkpoint past it, then continue with the next+    --       event. The adapter maps Shibuya's @AckDeadLetter@ to this.+    DeadLetter !DeadLetterReason+  deriving stock (Eq, Show) -{- | Bounds how many total deliveries a single event gets before it is-dead-lettered. The per-redelivery delay is carried by each-'Retry' result (mirroring Shibuya's per-decision @AckRetry RetryDelay@), so the-policy only carries the attempt bound.--}+-- | Bounds how many total deliveries a single event gets before it is+-- dead-lettered. The per-redelivery delay is carried by each+-- 'Retry' result (mirroring Shibuya's per-decision @AckRetry RetryDelay@), so the+-- policy only carries the attempt bound. newtype RetryPolicy = RetryPolicy-    { retryMaxAttempts :: Int-    {- ^ Maximum total deliveries of one event before dead-lettering it. The-    first delivery counts as attempt 1. A value @<= 1@ means the first 'Retry'-    immediately dead-letters (no redelivery).-    -}-    }-    deriving stock (Eq, Show)+  { -- | Maximum total deliveries of one event before dead-lettering it. The+    --     first delivery counts as attempt 1. A value @<= 1@ means the first 'Retry'+    --     immediately dead-letters (no redelivery).+    retryMaxAttempts :: Int+  }+  deriving stock (Eq, Show) -{- | The default 'RetryPolicy': up to five total deliveries of a single event —-the first delivery plus four redeliveries — before it is dead-lettered with-'DeadLetterMaxAttempts'. Handlers that never return 'Retry' are unaffected by-this default.--}+-- | The default 'RetryPolicy': up to five total deliveries of a single event —+-- the first delivery plus four redeliveries — before it is dead-lettered with+-- 'DeadLetterMaxAttempts'. Handlers that never return 'Retry' are unaffected by+-- this default. defaultRetryPolicy :: RetryPolicy-defaultRetryPolicy = RetryPolicy{retryMaxAttempts = 5}--{- | What the publisher does when a subscriber's bounded queue is full.+defaultRetryPolicy = RetryPolicy {retryMaxAttempts = 5} -The default 'PauseAndResume' is recoverable and lossless: the publisher-stops pushing to the full subscriber (and signals it), and the worker —-once its slow handler catches up — drains the stale queue and re-reads the-events it missed directly from the database from its checkpoint, so no-event is lost and the checkpoint still advances monotonically. The-fail-fast 'DropSubscription' (terminate with 'SubscriptionOverflowed') and-the lossy 'DropOldest' remain available for consumers that prefer a hard-error or best-effort delivery respectively.--}+-- | What the publisher does when a subscriber's bounded queue is full.+--+-- The default 'PauseAndResume' is recoverable and lossless: the publisher+-- stops pushing to the full subscriber (and signals it), and the worker —+-- once its slow handler catches up — drains the stale queue and re-reads the+-- events it missed directly from the database from its checkpoint, so no+-- event is lost and the checkpoint still advances monotonically. The+-- fail-fast 'DropSubscription' (terminate with 'SubscriptionOverflowed') and+-- the lossy 'DropOldest' remain available for consumers that prefer a hard+-- error or best-effort delivery respectively. data OverflowPolicy-    = {- | Recoverable backpressure (the default). When the queue is full the-      publisher marks the subscriber 'Kiroku.Store.Subscription.EventPublisher.Paused'-      and stops pushing (it does not drop). The worker observes the pause,-      drains the stale queue, clears the flag, and re-catches-up from its-      checkpoint so every skipped event is delivered. No loss; monotonic-      checkpoint; other subscribers unaffected.-      -}-      PauseAndResume-    | {- | Mark the subscription as overflowed; the worker observes this on-      its next iteration and surfaces 'SubscriptionOverflowed' through-      'wait'. The slow subscriber is terminated; other subscribers are-      unaffected.-      -}-      DropSubscription-    | {- | Drop the oldest queued batch and enqueue the new one. The-      subscription continues but loses events. Choose only when at-least-once-      semantics are not required for this consumer.-      -}-      DropOldest-    deriving stock (Eq, Show)+  = -- | Recoverable backpressure (the default). When the queue is full the+    --       publisher marks the subscriber 'Kiroku.Store.Subscription.EventPublisher.Paused'+    --       and stops pushing (it does not drop). The worker observes the pause,+    --       drains the stale queue, clears the flag, and re-catches-up from its+    --       checkpoint so every skipped event is delivered. No loss; monotonic+    --       checkpoint; other subscribers unaffected.+    PauseAndResume+  | -- | Mark the subscription as overflowed; the worker observes this on+    --       its next iteration and surfaces 'SubscriptionOverflowed' through+    --       'wait'. The slow subscriber is terminated; other subscribers are+    --       unaffected.+    DropSubscription+  | -- | Drop the oldest queued batch and enqueue the new one. The+    --       subscription continues but loses events. Choose only when at-least-once+    --       semantics are not required for this consumer.+    DropOldest+  deriving stock (Eq, Show) -{- | Raised on a 'SubscriptionHandleM' wait when the publisher dropped the-subscription because its bounded queue overflowed (overflow policy-'DropSubscription'). The 'subscriptionName' identifies the subscription-that overflowed; the consumer is expected to investigate the slow handler-and either fix the slowness or switch to 'DropOldest'.--}+-- | Raised on a 'SubscriptionHandleM' wait when the publisher dropped the+-- subscription because its bounded queue overflowed (overflow policy+-- 'DropSubscription'). The 'subscriptionName' identifies the subscription+-- that overflowed; the consumer is expected to investigate the slow handler+-- and either fix the slowness or switch to 'DropOldest'. newtype SubscriptionOverflowed = SubscriptionOverflowed-    { subscriptionName :: SubscriptionName-    }-    deriving stock (Show)-    deriving anyclass (Exception)+  { subscriptionName :: SubscriptionName+  }+  deriving stock (Show)+  deriving anyclass (Exception) -{- | Default decode retries exhausted. Wait returns this exception and the-stopped event carries StopUndecodable; the durable checkpoint is still before-the failed event. Fix the hook and restart to replay it.--}+-- | Default decode retries exhausted. Wait returns this exception and the+-- stopped event carries StopUndecodable; the durable checkpoint is still before+-- the failed event. Fix the hook and restart to replay it. newtype SubscriptionUndecodable = SubscriptionUndecodable-    { subscriptionDecodeFailure :: DecodeFailure-    }-    deriving stock (Eq, Show)-    deriving anyclass (Exception)+  { subscriptionDecodeFailure :: DecodeFailure+  }+  deriving stock (Eq, Show)+  deriving anyclass (Exception)  -- | Handler callback invoked for each event, parameterized by monad. type EventHandlerM m = RecordedEvent -> m SubscriptionResult@@ -429,191 +417,179 @@  -- | Configuration for starting a subscription, parameterized by monad. data SubscriptionConfigM m = SubscriptionConfig-    { name :: !SubscriptionName-    , target :: !SubscriptionTarget-    , handler :: !(EventHandlerM m)-    , undecodableHandler :: !(Maybe (RecordedEvent -> DecodeFailure -> m SubscriptionResult))-    {- ^ Optional disposition of a raw undecodable event. Default Nothing:-    retry at one-second spacing and then stop without skipping the event.-    A callback uses the ordinary Continue/Stop/Retry/DeadLetter vocabulary.-    -}-    , handlerStallWarnAfter :: !(Maybe NominalDiffTime)-    {- ^ Optional positive warning interval for a handler holding one event.-    Default Nothing allocates no diagnostic cell, watchdog or timer and does-    no diagnostic work per delivery. Warnings are advisory and never finalize-    an acknowledgement or advance a checkpoint. Nonpositive values fail startup.-    -}-    , batchSize :: !BatchSize-    -- ^ Number of events to fetch per batch during catch-up (default: 100)-    , queueCapacity :: !Natural-    {- ^ Maximum number of /batches/ the publisher may enqueue for this-    subscriber before applying 'overflowPolicy'. Applies only to non-group-    'AllStreams' subscriptions, because Category and consumer-group-    subscriptions do not create publisher queues. Each batch is up to-    'EventPublisher.publisherBatchSize' events, so the effective event-    capacity is @queueCapacity * publisherBatchSize@. Default: 16 batches-    (~16,000 events at the default publisher batch size).-    -}-    , overflowPolicy :: !OverflowPolicy-    {- ^ What the publisher does when this subscriber's queue is full.-    Applies only to non-group 'AllStreams' subscriptions, because Category-    and consumer-group subscriptions are DB-driven and have no publisher-    queue. Default: 'PauseAndResume' — a slow subscriber is paused and-    then recovers losslessly (re-reading missed events from its checkpoint)-    rather than being terminated or silently growing the publisher's fan-out-    memory.-    -}-    , consumerGroup :: !(Maybe ConsumerGroup)-    {- ^ 'Nothing' (the default) = ordinary single-consumer subscription.-    'Just cg' = this worker is member 'member cg' of a group of size-    'size cg'. The invariant @size >= 1@ and @0 <= member < size@ is-    enforced at construction by 'mkConsumerGroupSize' and 'mkConsumerGroup'.-    -}-    , consumerGroupGuard :: !Bool-    {- ^ When 'True' (default 'False'), the worker performs a one-shot-    PostgreSQL advisory-lock conflict check at startup so two processes-    cannot both run the same @(name, member)@ at once. See the worker's-    @guardMember@ for the exact semantics and its documented limitation-    (a startup detection probe, not a lifetime-held lock). Ignored when-    'consumerGroup' is 'Nothing'.-    -}-    , missingCheckpointPolicy :: !MissingCheckpointPolicy-    {- ^ What startup does only when the exact @(name, member)@ checkpoint row-    is absent. 'FromBeginning' is the compatibility default and materializes-    position zero. 'FromCurrentHead' atomically seeds the current store head,-    and 'FailIfMissing' refuses startup before the handler runs. Existing rows-    always take precedence, so changing this field never rewinds or advances-    durable progress.-    -}-    , targetBindingPolicy :: !TargetBindingPolicy-    -- ^ Adopt legacy unbound checkpoints (default) or require an existing binding.-    , retryPolicy :: !RetryPolicy-    {- ^ Bounds redelivery of an event for which the handler returned-    'Retry' before the worker dead-letters it. Default: 'defaultRetryPolicy'-    (five attempts). Handlers that only return 'Continue' / 'Stop' are-    unaffected.-    -}-    , eventTypeFilter :: !EventTypeFilter-    {- ^ Which event types this subscription delivers. Default-    'AllEventTypes' (deliver everything). When 'OnlyEventTypes', the worker-    skips the handler for non-matching events but still advances the-    checkpoint past them, so the subscription never stalls on a long run of-    filtered-out events. Applied worker-side before the handler/bridge, so a-    filtered-out event is never retried or dead-lettered. Composed with-    'selector' (an event must pass both — see 'shouldDeliver').-    -}-    , selector :: !(Maybe (RecordedEvent -> Bool))-    {- ^ Optional opaque per-event predicate, the escape hatch for filtering-    that 'eventTypeFilter' cannot express (e.g. on metadata, stream name, or-    correlation\/causation ids). Default 'Nothing' (no extra filtering).--    This is EventStore's @RecordedEvent -> Bool@ @selector@. It is intentionally-    /separate/ from 'eventTypeFilter' rather than replacing it: because a-    closure is not introspectable, not 'Eq'\/'Show'-able, and not SQL-pushable,-    'eventTypeFilter' remains the preferred steady-state type filter (see its-    note), and 'selector' is reserved for ad-hoc\/operational filtering — most-    often a one-off catch-up reprocess that narrows by metadata while-    investigating a problem.--    Composes with 'eventTypeFilter' as a logical AND ('shouldDeliver'): an event-    is delivered only when it passes both. Applied worker-side before the-    handler\/bridge, so a rejected event is never retried or dead-lettered, yet-    the checkpoint still advances past it (no stall).-    -}-    }+  { name :: !SubscriptionName,+    target :: !SubscriptionTarget,+    handler :: !(EventHandlerM m),+    -- | Optional disposition of a raw undecodable event. Default Nothing:+    --     retry at one-second spacing and then stop without skipping the event.+    --     A callback uses the ordinary Continue/Stop/Retry/DeadLetter vocabulary.+    undecodableHandler :: !(Maybe (RecordedEvent -> DecodeFailure -> m SubscriptionResult)),+    -- | Optional positive warning interval for a handler holding one event.+    --     Default Nothing allocates no diagnostic cell, watchdog or timer and does+    --     no diagnostic work per delivery. Warnings are advisory and never finalize+    --     an acknowledgement or advance a checkpoint. Nonpositive values fail startup.+    handlerStallWarnAfter :: !(Maybe NominalDiffTime),+    -- | Number of events to fetch per batch during catch-up (default: 100)+    batchSize :: !BatchSize,+    -- | Maximum number of /batches/ the publisher may enqueue for this+    --     subscriber before applying 'overflowPolicy'. Applies only to non-group+    --     'AllStreams' subscriptions, because Category and consumer-group+    --     subscriptions do not create publisher queues. Each batch is up to+    --     'EventPublisher.publisherBatchSize' events, so the effective event+    --     capacity is @queueCapacity * publisherBatchSize@. Default: 16 batches+    --     (~16,000 events at the default publisher batch size).+    queueCapacity :: !Natural,+    -- | What the publisher does when this subscriber's queue is full.+    --     Applies only to non-group 'AllStreams' subscriptions, because Category+    --     and consumer-group subscriptions are DB-driven and have no publisher+    --     queue. Default: 'PauseAndResume' — a slow subscriber is paused and+    --     then recovers losslessly (re-reading missed events from its checkpoint)+    --     rather than being terminated or silently growing the publisher's fan-out+    --     memory.+    overflowPolicy :: !OverflowPolicy,+    -- | 'Nothing' (the default) = ordinary single-consumer subscription.+    --     'Just cg' = this worker is member 'member cg' of a group of size+    --     'size cg'. The invariant @size >= 1@ and @0 <= member < size@ is+    --     enforced at construction by 'mkConsumerGroupSize' and 'mkConsumerGroup'.+    consumerGroup :: !(Maybe ConsumerGroup),+    -- | When 'True' (default 'False'), the worker performs a one-shot+    --     PostgreSQL advisory-lock conflict check at startup so two processes+    --     cannot both run the same @(name, member)@ at once. See the worker's+    --     @guardMember@ for the exact semantics and its documented limitation+    --     (a startup detection probe, not a lifetime-held lock). Ignored when+    --     'consumerGroup' is 'Nothing'.+    consumerGroupGuard :: !Bool,+    -- | What startup does only when the exact @(name, member)@ checkpoint row+    --     is absent. 'FromBeginning' is the compatibility default and materializes+    --     position zero. 'FromCurrentHead' atomically seeds the current store head,+    --     and 'FailIfMissing' refuses startup before the handler runs. Existing rows+    --     always take precedence, so changing this field never rewinds or advances+    --     durable progress.+    missingCheckpointPolicy :: !MissingCheckpointPolicy,+    -- | Adopt legacy unbound checkpoints (default) or require an existing binding.+    targetBindingPolicy :: !TargetBindingPolicy,+    -- | Bounds redelivery of an event for which the handler returned+    --     'Retry' before the worker dead-letters it. Default: 'defaultRetryPolicy'+    --     (five attempts). Handlers that only return 'Continue' / 'Stop' are+    --     unaffected.+    retryPolicy :: !RetryPolicy,+    -- | Which event types this subscription delivers. Default+    --     'AllEventTypes' (deliver everything). When 'OnlyEventTypes', the worker+    --     skips the handler for non-matching events but still advances the+    --     checkpoint past them, so the subscription never stalls on a long run of+    --     filtered-out events. Applied worker-side before the handler/bridge, so a+    --     filtered-out event is never retried or dead-lettered. Composed with+    --     'selector' (an event must pass both — see 'shouldDeliver').+    eventTypeFilter :: !EventTypeFilter,+    -- | Optional opaque per-event predicate, the escape hatch for filtering+    --     that 'eventTypeFilter' cannot express (e.g. on metadata, stream name, or+    --     correlation\/causation ids). Default 'Nothing' (no extra filtering).+    --+    --     This is EventStore's @RecordedEvent -> Bool@ @selector@. It is intentionally+    --     /separate/ from 'eventTypeFilter' rather than replacing it: because a+    --     closure is not introspectable, not 'Eq'\/'Show'-able, and not SQL-pushable,+    --     'eventTypeFilter' remains the preferred steady-state type filter (see its+    --     note), and 'selector' is reserved for ad-hoc\/operational filtering — most+    --     often a one-off catch-up reprocess that narrows by metadata while+    --     investigating a problem.+    --+    --     Composes with 'eventTypeFilter' as a logical AND ('shouldDeliver'): an event+    --     is delivered only when it passes both. Applied worker-side before the+    --     handler\/bridge, so a rejected event is never retried or dead-lettered, yet+    --     the checkpoint still advances past it (no stall).+    selector :: !(Maybe (RecordedEvent -> Bool))+  }  -- | Configuration defaulting to 'IO'. type SubscriptionConfig = SubscriptionConfigM IO -{- | Build a 'SubscriptionConfig' with the recommended defaults.--The catch-up batch size defaults to 100 events per database fetch — large-enough to amortise round-trip overhead on typical projection workloads,-small enough that a single slow handler call does not stall the worker-for long. Override the 'batchSize' field on the returned record if a-different value suits the workload.--The missing-checkpoint policy defaults to 'FromBeginning' for source-compatibility. New call sites should override 'missingCheckpointPolicy'-deliberately when a future-only or pre-provisioned worker is intended.--@-let cfg = (defaultSubscriptionConfig "my-projection" AllStreams handler)-        { missingCheckpointPolicy = FromBeginning }-withSubscription store cfg $ \\h -> wait h-@--}+-- | Build a 'SubscriptionConfig' with the recommended defaults.+--+-- The catch-up batch size defaults to 100 events per database fetch — large+-- enough to amortise round-trip overhead on typical projection workloads,+-- small enough that a single slow handler call does not stall the worker+-- for long. Override the 'batchSize' field on the returned record if a+-- different value suits the workload.+--+-- The missing-checkpoint policy defaults to 'FromBeginning' for source+-- compatibility. New call sites should override 'missingCheckpointPolicy'+-- deliberately when a future-only or pre-provisioned worker is intended.+--+-- @+-- let cfg = (defaultSubscriptionConfig "my-projection" AllStreams handler)+--         { missingCheckpointPolicy = FromBeginning }+-- withSubscription store cfg $ \\h -> wait h+-- @ defaultSubscriptionConfig ::-    SubscriptionName ->-    SubscriptionTarget ->-    EventHandlerM m ->-    SubscriptionConfigM m+  SubscriptionName ->+  SubscriptionTarget ->+  EventHandlerM m ->+  SubscriptionConfigM m defaultSubscriptionConfig name' target' handler' =-    SubscriptionConfig-        { name = name'-        , target = target'-        , handler = handler'-        , undecodableHandler = Nothing-        , handlerStallWarnAfter = Nothing-        , batchSize = defaultBatchSize-        , queueCapacity = 16-        , overflowPolicy = PauseAndResume-        , consumerGroup = Nothing-        , consumerGroupGuard = False-        , missingCheckpointPolicy = FromBeginning-        , targetBindingPolicy = AdoptUnbound-        , retryPolicy = defaultRetryPolicy-        , eventTypeFilter = AllEventTypes-        , selector = Nothing-        }+  SubscriptionConfig+    { name = name',+      target = target',+      handler = handler',+      undecodableHandler = Nothing,+      handlerStallWarnAfter = Nothing,+      batchSize = defaultBatchSize,+      queueCapacity = 16,+      overflowPolicy = PauseAndResume,+      consumerGroup = Nothing,+      consumerGroupGuard = False,+      missingCheckpointPolicy = FromBeginning,+      targetBindingPolicy = AdoptUnbound,+      retryPolicy = defaultRetryPolicy,+      eventTypeFilter = AllEventTypes,+      selector = Nothing+    }  -- | Handle returned to the caller for lifecycle management, parameterized by monad. data SubscriptionHandleM m = SubscriptionHandle-    { cancel :: !(m ())-    -- ^ Cancel the subscription gracefully-    , wait :: !(m (Either SomeException ()))-    -- ^ Block until the subscription completes or fails-    , currentState :: !(m (Maybe SubscriptionState))-    {- ^ Read the worker's current FSM state-    ('Kiroku.Store.Subscription.Fsm.SubscriptionState') as of this instant,-    resolved through the store's central subscription-state registry.--    @Just s@ means the worker is live and still owns its registry entry, in state-    @s@ — one of @CatchingUp@, @Live@, @Paused@ (recoverable backpressure),-    @Reconnecting@, or @Retrying@. @Nothing@ means the subscription is __not-    currently live__: it has stopped, been cancelled, crashed (its registry key-    was removed in the worker's @finally@ cleanup), was never started, or has-    been superseded by a newer worker registered under the same @(name, member)@-    key.--    Note the invariant that a @Stopped@ state never appears here: the FSM never-    writes @Stopped@ into the cell, so a not-live subscription is represented by-    @Nothing@ — never by a @Just (Stopped …)@. This unifies one rule, "stopped =-    absent", across 'currentState' and-    'Kiroku.Store.Subscription.subscriptionStates'. For the stream of past-    transitions (including the terminal stop reason) use the @KirokuEvent@-    lifecycle events instead.-    -}-    }+  { -- | Cancel the subscription gracefully+    cancel :: !(m ()),+    -- | Block until the subscription completes or fails+    wait :: !(m (Either SomeException ())),+    -- | Read the worker's current FSM state+    --     ('Kiroku.Store.Subscription.Fsm.SubscriptionState') as of this instant,+    --     resolved through the store's central subscription-state registry.+    --+    --     @Just s@ means the worker is live and still owns its registry entry, in state+    --     @s@ — one of @CatchingUp@, @Live@, @Paused@ (recoverable backpressure),+    --     @Reconnecting@, or @Retrying@. @Nothing@ means the subscription is __not+    --     currently live__: it has stopped, been cancelled, crashed (its registry key+    --     was removed in the worker's @finally@ cleanup), was never started, or has+    --     been superseded by a newer worker registered under the same @(name, member)@+    --     key.+    --+    --     Note the invariant that a @Stopped@ state never appears here: the FSM never+    --     writes @Stopped@ into the cell, so a not-live subscription is represented by+    --     @Nothing@ — never by a @Just (Stopped …)@. This unifies one rule, "stopped =+    --     absent", across 'currentState' and+    --     'Kiroku.Store.Subscription.subscriptionStates'. For the stream of past+    --     transitions (including the terminal stop reason) use the @KirokuEvent@+    --     lifecycle events instead.+    currentState :: !(m (Maybe SubscriptionState))+  }  -- | Handle defaulting to 'IO'. type SubscriptionHandle = SubscriptionHandleM IO  -- | A positive group size, validated once before starting any workers. newtype ConsumerGroupSize = ConsumerGroupSize Int32-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  consumerGroupSizeValue :: ConsumerGroupSize -> Int32 consumerGroupSizeValue (ConsumerGroupSize n) = n  mkConsumerGroupSize :: Int32 -> Either InvalidConsumerGroup ConsumerGroupSize mkConsumerGroupSize n-    | n >= 1 = Right (ConsumerGroupSize n)-    | otherwise = Left (InvalidConsumerGroup 0 n)+  | n >= 1 = Right (ConsumerGroupSize n)+  | otherwise = Left (InvalidConsumerGroup 0 n)  -- | Static membership. Use 'mkConsumerGroup'; accessors cannot update the pair. data ConsumerGroup = ConsumerGroup !Int32 !ConsumerGroupSize-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  member :: ConsumerGroup -> Int32 member (ConsumerGroup m _) = m@@ -623,97 +599,95 @@  mkConsumerGroup :: Int32 -> ConsumerGroupSize -> Either InvalidConsumerGroup ConsumerGroup mkConsumerGroup m n-    | m >= 0 && m < consumerGroupSizeValue n = Right (ConsumerGroup m n)-    | otherwise = Left (InvalidConsumerGroup m (consumerGroupSizeValue n))+  | m >= 0 && m < consumerGroupSizeValue n = Right (ConsumerGroup m n)+  | otherwise = Left (InvalidConsumerGroup m (consumerGroupSizeValue n))  -- | Construction error; invalid configuration never reaches a worker. data InvalidConsumerGroup = InvalidConsumerGroup-    { invalidMember :: !Int32-    , invalidSize :: !Int32-    }-    deriving stock (Eq, Show)+  { invalidMember :: !Int32,+    invalidSize :: !Int32+  }+  deriving stock (Eq, Show) -{- | Stored topology disagrees with the configured size. Stop all members and-use 'Kiroku.Store.Subscription.Checkpoint.resizeConsumerGroupTx' before restart.--}+-- | Stored topology disagrees with the configured size. Stop all members and+-- use 'Kiroku.Store.Subscription.Checkpoint.resizeConsumerGroupTx' before restart. data ConsumerGroupSizeMismatch = ConsumerGroupSizeMismatch-    { mismatchName :: !SubscriptionName-    , configuredSize :: !Int32-    , observedSizes :: !(Vector Int32)-    }-    deriving stock (Eq, Show)+  { mismatchName :: !SubscriptionName,+    configuredSize :: !Int32,+    observedSizes :: !(Vector Int32)+  }+  deriving stock (Eq, Show)  instance Exception ConsumerGroupSizeMismatch where-    toException = toException . SomeSubscriptionStartupFailure-    fromException exception = do-        SomeSubscriptionStartupFailure refusal <- fromException exception-        cast refusal+  toException = toException . SomeSubscriptionStartupFailure+  fromException exception = do+    SomeSubscriptionStartupFailure refusal <- fromException exception+    cast refusal -{- | Thrown at subscription startup when 'consumerGroupGuard' is 'True' and-another holder currently holds the advisory lock for this @(name, member)@.-Indicates two processes are configured as the same group member. This is a-startup /detection/ probe, not a lifetime-held lock — see the worker's-@guardMember@.--}+-- | Thrown at subscription startup when 'consumerGroupGuard' is 'True' and+-- another holder currently holds the advisory lock for this @(name, member)@.+-- Indicates two processes are configured as the same group member. This is a+-- startup /detection/ probe, not a lifetime-held lock — see the worker's+-- @guardMember@. data ConsumerGroupGuardConflict = ConsumerGroupGuardConflict-    { conflictName :: !SubscriptionName-    , conflictMember :: !Int32-    }-    deriving stock (Eq, Show)+  { conflictName :: !SubscriptionName,+    conflictMember :: !Int32+  }+  deriving stock (Eq, Show)  instance Exception ConsumerGroupGuardConflict where-    toException = toException . SomeSubscriptionStartupFailure-    fromException exception = do-        SomeSubscriptionStartupFailure refusal <- fromException exception-        cast refusal+  toException = toException . SomeSubscriptionStartupFailure+  fromException exception = do+    SomeSubscriptionStartupFailure refusal <- fromException exception+    cast refusal  -- | A nonpositive handler-stall warning interval was configured. newtype InvalidHandlerStallWarnAfter = InvalidHandlerStallWarnAfter NominalDiffTime-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  instance Exception InvalidHandlerStallWarnAfter where-    toException = toException . SomeSubscriptionStartupFailure-    fromException exception = do-        SomeSubscriptionStartupFailure refusal <- fromException exception-        cast refusal+  toException = toException . SomeSubscriptionStartupFailure+  fromException exception = do+    SomeSubscriptionStartupFailure refusal <- fromException exception+    cast refusal  -- | A catchable family of subscription startup refusals. Concrete catches work too. data SomeSubscriptionStartupFailure = forall e. (Exception e) => SomeSubscriptionStartupFailure e  instance Show SomeSubscriptionStartupFailure where-    show (SomeSubscriptionStartupFailure e) = show e+  show (SomeSubscriptionStartupFailure e) = show e  instance Exception SomeSubscriptionStartupFailure  -- | Policy for checkpoint rows created before target identity was persisted. data TargetBindingPolicy = AdoptUnbound | RequireBound-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  -- | Stored bindings differ from the requested target, or strict startup found unbound rows. data SubscriptionTargetMismatch = SubscriptionTargetMismatch-    { targetMismatchName :: !SubscriptionName-    , configuredTarget :: !SubscriptionTarget-    , observedTargets :: !(Vector (Maybe SubscriptionTarget))-    }-    deriving stock (Eq, Show)+  { targetMismatchName :: !SubscriptionName,+    configuredTarget :: !SubscriptionTarget,+    observedTargets :: !(Vector (Maybe SubscriptionTarget))+  }+  deriving stock (Eq, Show)  instance Exception SubscriptionTargetMismatch where-    toException = toException . SomeSubscriptionStartupFailure-    fromException exception = do-        SomeSubscriptionStartupFailure refusal <- fromException exception-        cast refusal+  toException = toException . SomeSubscriptionStartupFailure+  fromException exception = do+    SomeSubscriptionStartupFailure refusal <- fromException exception+    cast refusal  -- | A positive catch-up fetch size. The constructor is deliberately hidden. newtype BatchSize = BatchSize Int32-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  newtype InvalidBatchSize = InvalidBatchSize Int32-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  mkBatchSize :: Int32 -> Either InvalidBatchSize BatchSize mkBatchSize n-    | n >= 1 = Right (BatchSize n)-    | otherwise = Left (InvalidBatchSize n)+  | n >= 1 = Right (BatchSize n)+  | otherwise = Left (InvalidBatchSize n)  batchSizeValue :: BatchSize -> Int32 batchSizeValue (BatchSize n) = n
src/Kiroku/Store/Subscription/Worker.hs view
@@ -1,944 +1,1083 @@-{- | The subscription worker: the impure driver behind a running subscription.--'runWorker' is the long-lived loop spawned by-'Kiroku.Store.Subscription.subscribe'. It is the interpreter for the pure state-machine in "Kiroku.Store.Subscription.Fsm": it supplies inputs (a batch was-fetched, the queue overflowed, the pool errored, the handler returned a-disposition) and carries out the resulting effects (deliver a batch, save the-checkpoint, back off, emit a @KirokuEvent@, halt). The worker walks the named-states — @CatchingUp@, @Live@, @Paused@ (recoverable backpressure),-@Reconnecting@ (re-catch-up after a live fetch loses the pool), @Retrying@, and-@Stopped@ — and writes each transition to the @TVar@ exposed through-'Kiroku.Store.Subscription.Types.currentState'.--Per-event delivery (including the worker-side-'Kiroku.Store.Subscription.Types.eventTypeFilter' \/-'Kiroku.Store.Subscription.Types.selector' check applied /before/ the handler,-and the bounded-retry \/ dead-letter disposition mechanics) is concentrated in-the single delivery primitive shared by every live path, so behaviour is-identical for @AllStreams@, @Category@, and consumer-group subscriptions.--'withFetchBatchHookForTest', 'withLoadCheckpointHookForTest', and-'withSaveCheckpointHookForTest' are test-only seams for controlling fetch,-checkpoint-load, and checkpoint-save boundaries.--}-module Kiroku.Store.Subscription.Worker (-    LiveSource (..),-    runWorker,-    configMember,-    withFetchBatchHookForTest,-    withLoadCheckpointHookForTest,-    withSaveCheckpointHookForTest,-) where--import Contravariant.Extras (contrazip2)-import Control.Concurrent (threadDelay)-import Control.Concurrent.Async qualified as Async-import Control.Concurrent.STM (TBQueue, TVar, atomically, check, newTVarIO, orElse, readTBQueue, readTVar, registerDelay, tryReadTBQueue, writeTVar)-import Control.Concurrent.STM qualified as STM-import Control.Exception (SomeException, bracket, finally, fromException, mask, throwIO, toException, try)-import Control.Monad (when)-import Control.Monad.IO.Class (MonadIO, liftIO)-import Data.IORef (IORef, newIORef, readIORef, writeIORef)-import Data.Int (Int32)-import Data.Map.Strict (Map)-import Data.Map.Strict qualified as Map-import Data.Text (Text)-import Data.Time (NominalDiffTime)-import Data.Vector (Vector)-import Data.Vector qualified as V-import Data.Word (Word64)-import GHC.Clock (getMonotonicTimeNSec)-import Hasql.Decoders qualified as D-import Hasql.Encoders qualified as E-import Hasql.Pool (Pool)-import Hasql.Pool qualified as Pool-import Hasql.Session qualified as Session-import Hasql.Statement (Statement, preparable)-import Kiroku.Store.Observability (-    KirokuEvent (..),-    SubscriptionDbPhase (..),-    SubscriptionDeliveryPhase (..),-    SubscriptionGroupContext (..),-    SubscriptionStopReason (..),-    emitOrDrop,- )-import Kiroku.Store.SQL qualified as SQL-import Kiroku.Store.Settings (DecodedBatch (..), DecodedEvent (..), StoreSettings, decodeEvent, decodeEvents, decodedBatchLastEvent, decodedBatchLength, decodedEventRecorded, filterDecodedBatch)-import Kiroku.Store.Subscription.Checkpoint.SQL qualified as CheckpointSQL-import Kiroku.Store.Subscription.EventPublisher (SubscriberStatus)-import Kiroku.Store.Subscription.EventPublisher qualified as Pub-import Kiroku.Store.Subscription.Fsm (-    Effect (..),-    Input (..),-    SubscriptionState (..),-    stateCursor,-    step,- )-import Kiroku.Store.Subscription.Types hiding (eventId, globalPosition)-import Kiroku.Store.Types (CategoryName (..), EventId (..), GlobalPosition (..), RecordedEvent (..))-import System.IO.Unsafe (unsafePerformIO)---- Mirror 'Kiroku.Store.Subscription.EventPublisher.safetyPollMicros': an idle--- category re-checks at most this often, reconciling NOTIFYs lost while the--- listener connection was reconnecting. An idle category therefore costs at most--- one empty fetch per safety interval, not per global publisher tick.-categorySafetyPollMicros :: Int-categorySafetyPollMicros = 30_000_000--type FetchBatchHook =-    SubscriptionConfig ->-    GlobalPosition ->-    IO (Maybe (Either Pool.UsageError (Vector RecordedEvent)))--type LoadCheckpointHook =-    SubscriptionConfig ->-    IO-        ( Maybe-            ( Either-                Pool.UsageError-                (Either SubscriptionCheckpointMissing CheckpointInitialization)-            )-        )--type SaveCheckpointHook =-    SubscriptionConfig ->-    GlobalPosition ->-    IO ()--{-# NOINLINE fetchBatchHookRef #-}-fetchBatchHookRef :: IORef (Maybe FetchBatchHook)-fetchBatchHookRef = unsafePerformIO (newIORef Nothing)--{-# NOINLINE loadCheckpointHookRef #-}-loadCheckpointHookRef :: IORef (Maybe LoadCheckpointHook)-loadCheckpointHookRef = unsafePerformIO (newIORef Nothing)--{-# NOINLINE saveCheckpointHookRef #-}-saveCheckpointHookRef :: IORef (Maybe SaveCheckpointHook)-saveCheckpointHookRef = unsafePerformIO (newIORef Nothing)--{- | Install a process-local fetch hook for tests that need deterministic-subscription-worker fault injection. Production code leaves the hook unset.--}-withFetchBatchHookForTest :: FetchBatchHook -> IO a -> IO a-withFetchBatchHookForTest hook action =-    bracket-        ( do-            previous <- readIORef fetchBatchHookRef-            writeIORef fetchBatchHookRef (Just hook)-            pure previous-        )-        (writeIORef fetchBatchHookRef)-        (const action)--{- | Install a process-local checkpoint-load hook for tests that need-deterministic subscription-startup fault injection. Production code leaves the-hook unset.--}-withLoadCheckpointHookForTest :: LoadCheckpointHook -> IO a -> IO a-withLoadCheckpointHookForTest hook action =-    bracket-        ( do-            previous <- readIORef loadCheckpointHookRef-            writeIORef loadCheckpointHookRef (Just hook)-            pure previous-        )-        (writeIORef loadCheckpointHookRef)-        (const action)--{- | Install a process-local checkpoint-save boundary hook for lifecycle tests.--The hook runs immediately before the database statement. Production code leaves-it unset.--}-withSaveCheckpointHookForTest :: SaveCheckpointHook -> IO a -> IO a-withSaveCheckpointHookForTest hook action =-    bracket-        ( do-            previous <- readIORef saveCheckpointHookRef-            writeIORef saveCheckpointHookRef (Just hook)-            pure previous-        )-        (writeIORef saveCheckpointHookRef)-        (const action)--fetchRetryDelayMicros :: Int -> Int-fetchRetryDelayMicros attempt =-    min categorySafetyPollMicros (100_000 * (2 ^ min attempt 9 :: Int))--{- | How a worker obtains live-mode batches, fixed at 'subscribe' time from the-config's (consumerGroup, target) shape.--Only 'LiveFromPublisherQueue' owns a registration with the EventPublisher. The-other shapes are DB-driven and the publisher must do no fan-out work for them.--}-data LiveSource-    = {- | Non-group AllStreams: read the publisher's bounded queue; the status-      TVar carries Paused/Overflowed backpressure signals.-      -}-      LiveFromPublisherQueue !(TBQueue DecodedBatch) !(TVar SubscriberStatus)-    | {- | Category, plain or consumer-group member: wake on the named-      category's NOTIFY generation counter and re-query the database (with the-      partition predicate, for a member).-      -}-      LiveFromCategoryNotify !Text-    | {- | Consumer-group member of AllStreams: wake when the global position-      advances and re-query with the partition predicate.-      -}-      LiveFromGroupPolling--{- | Run the subscription worker loop. Two phases:--Phase 1 (catch-up): queries database directly until reaching publisherPosition.-Phase 2 (live): for 'LiveFromPublisherQueue', reads from the bounded TBQueue-the publisher delivers to; for 'LiveFromCategoryNotify' and-'LiveFromGroupPolling', re-queries the database, and no publisher queue exists.--Runs until the handler returns 'Stop', the thread is cancelled, or the-publisher signals overflow on the subscriber's status TVar (in which-case 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' is thrown-and surfaces through 'Async.waitCatch').--If an 'eventHandler' callback is supplied, the worker emits:--* 'Kiroku.Store.Observability.KirokuEventSubscriptionCheckpointResolved' once-  when startup resumes or initializes a checkpoint, followed by-  'Kiroku.Store.Observability.KirokuEventSubscriptionStarted'. A refused-  missing checkpoint emits-  'Kiroku.Store.Observability.KirokuEventSubscriptionCheckpointMissing'-  instead and fails before delivery.-* 'Kiroku.Store.Observability.KirokuEventSubscriptionCaughtUp' when-  catch-up completes and the worker switches to live mode.-* 'Kiroku.Store.Observability.KirokuEventSubscriptionDbError' in the-  subscription database phases: 'loadCheckpoint', 'fetchBatch', and-  'saveCheckpoint'. 'fetchBatch' errors are retried at the same cursor;-  the event is the operator's structured signal.-* 'Kiroku.Store.Observability.KirokuEventSubscriptionStopped' when the-  worker exits, with a reason discriminating handler-stop, cancel,-  overflow, and worker-crash.--}-runWorker ::-    (MonadIO m) =>-    Pool ->-    LiveSource ->-    {- | the worker's current FSM state, written on every transition so callers-    can read it through 'Kiroku.Store.Subscription.Types.currentState'.-    -}-    TVar SubscriptionState ->-    TVar GlobalPosition ->-    {- | per-category wake counter from the Notifier; the @Category@ live loop-    blocks on this category's entry rather than busy-polling the global position.-    -}-    TVar (Map Text Word64) ->-    SubscriptionConfig ->-    -- | optional event handler for subscription observability-    Maybe (KirokuEvent -> IO ()) ->-    {- | interpreter-level event hooks; 'Kiroku.Store.Settings.decodeHook'-    runs on the catch-up fetch path, mirroring the publisher's-    application in live mode.-    -}-    StoreSettings ->-    m ()-runWorker pool liveSource stateVar pubPosVar catGenVar config mHandler stSettings = liftIO $-    withHandlerStallDiagnostics config (emitOrDrop mHandler) $ \deliveryConfig ->-        runWorkerBody pool liveSource stateVar pubPosVar catGenVar deliveryConfig mHandler stSettings---- Select the delivery handler once per worker. The disabled arm returns the--- original config directly, with no per-delivery diagnostic branch or work.-withHandlerStallDiagnostics :: SubscriptionConfig -> (KirokuEvent -> IO ()) -> (SubscriptionConfig -> IO a) -> IO a-withHandlerStallDiagnostics config emit action = case handlerStallWarnAfter config of-    Nothing -> action config-    Just threshold-        | threshold <= 0 -> throwIO (InvalidHandlerStallWarnAfter threshold)-        | otherwise -> do-            pending <- newTVarIO Nothing-            let tracked event = mask $ \restore -> do-                    started <- getMonotonicTimeNSec-                    let delivery = StalledDelivery (globalPosition event) (eventId event) started-                    atomically (writeTVar pending (Just delivery))-                    restore (handler config event) `finally` atomically (writeTVar pending Nothing)-            Async.withAsync (watchHandler pending threshold) $ \watchdog -> do-                Async.link watchdog-                action config{handler = tracked}-  where-    -- Park while idle. Reuse a pending interval across completed/replaced-    -- invocations, rather than abandoning a registerDelay timer per event.-    watchHandler pending threshold = awaitDelivery-      where-        awaitDelivery = do-            _ <- atomically $ readTVar pending >>= maybe STM.retry pure-            waitInterval threshold-        waitInterval delay = do-            timer <- registerDelay (durationMicros delay)-            atomically (readTVar timer >>= check)-            current <- atomically (readTVar pending)-            case current of-                Nothing -> awaitDelivery-                Just delivery@(StalledDelivery pos eid started) -> do-                    now <- getMonotonicTimeNSec-                    let elapsed = fromRational (fromIntegral (now - started) / 1_000_000_000)-                    if elapsed < threshold-                        then waitInterval (threshold - elapsed)-                        else do-                            stillPending <- atomically ((== Just delivery) <$> readTVar pending)-                            when stillPending $-                                emit (KirokuEventSubscriptionHandlerStalled (name config) pos eid elapsed (groupCtxOf config))-                            waitInterval threshold---- One cell per enabled worker, not one timer/thread per handler invocation.-data StalledDelivery = StalledDelivery !GlobalPosition !EventId !Word64-    deriving stock (Eq)---- registerDelay accepts an Int. Cap huge intervals safely and round positive--- sub-microsecond intervals up instead of creating a zero-delay polling loop.-durationMicros :: NominalDiffTime -> Int-durationMicros duration = fromInteger (max 1 (min (toInteger (maxBound :: Int)) (ceiling (duration * 1_000_000))))--runWorkerBody :: Pool -> LiveSource -> TVar SubscriptionState -> TVar GlobalPosition -> TVar (Map Text Word64) -> SubscriptionConfig -> Maybe (KirokuEvent -> IO ()) -> StoreSettings -> IO ()-runWorkerBody pool liveSource stateVar pubPosVar catGenVar config mHandler stSettings = do-    let emit = emitOrDrop mHandler-        subName = name config-        groupCtx = groupCtxOf config-    posRef <- newIORef (GlobalPosition 0)--    let body = do-            -- Optional startup guardrail: when consumerGroupGuard is on, fail fast-            -- if another holder currently holds this (name, member)'s advisory lock.-            case (consumerGroupGuard config, consumerGroup config) of-                (True, Just cg) -> guardMember pool subName (member cg)-                _ -> pure ()-            resolution <- loadCheckpoint pool config emit-            checkpoint <- case resolution of-                Left missing -> do-                    emit (KirokuEventSubscriptionCheckpointMissing missing groupCtx)-                    throwIO missing-                Right initialization -> do-                    emit (KirokuEventSubscriptionCheckpointResolved initialization groupCtx)-                    pure (checkpointInitializationPosition initialization)-            writeIORef posRef checkpoint-            emit (KirokuEventSubscriptionStarted subName checkpoint groupCtx)-            -- Drive the explicit FSM from the catch-up state. The pure 'step'-            -- (Kiroku.Store.Subscription.Fsm) decides every lifecycle transition;-            -- this driver supplies the inputs (what just happened) and interprets-            -- the effects (deliver a batch, emit an event, back off, halt). The-            -- three live strategies remain the *mechanism* for obtaining the next-            -- batch within the 'Live' state; the FSM governs the lifecycle.-            loop (CatchingUp checkpoint 0)--        -- One driver iteration: publish the current state for observability-        -- ('currentState'), discover the next 'Input' by performing the state's-        -- natural (possibly blocking) action, then hand it to 'feed'. Recording-        -- the state at loop entry means the value read while the worker blocks in-        -- 'nextInput' (e.g. waiting on the live queue) is the state it is blocked in.-        loop :: SubscriptionState -> IO ()-        loop st = do-            atomically (writeTVar stateVar st)-            nextInput st >>= feed st--        -- Apply 'step' to the (state, input) pair, interpret the resulting-        -- effects, and continue. An effect (delivery, a gate) may itself produce-        -- a follow-up 'Input' (e.g. the handler returned 'Stop'); that is fed back-        -- to 'step' immediately. Otherwise: stop when the new state is terminal,-        -- else loop on the new state.-        feed :: SubscriptionState -> Input -> IO ()-        feed st inp = do-            let (st', effs) = step st inp-            follow <- runEffects st' effs-            case follow of-                Just fInp -> feed st' fInp-                Nothing -> case st' of-                    Stopped _ -> pure ()-                    _ -> loop st'--        -- Produce the next 'Input' for a state by performing its blocking action.-        --   * CatchingUp: if caught up, 'CaughtUp'; else fetch one history batch,-        --     mapping the result to 'BatchFetched' / 'FetchEmpty' (caught up) /-        --     'FetchFailed' (the catch-up retry, escalated by the state's attempt).-        --   * Live (AllStreams, non-group): read the publisher's bounded queue;-        --     'QueueOverflowed' when the publisher signalled overflow, else the-        --     stale-filtered batch ('FetchEmpty' if all stale).-        --   * Live (Category / consumer-group): run the existing live loop to its-        --     natural termination (handler 'Stop'), then report 'HandlerStopped'.-        --     These loops retain their own NOTIFY-generation / global-position-        --     gates and per-fetch retry, exactly as before.-        nextInput :: SubscriptionState -> IO Input-        nextInput = \case-            CatchingUp c _ -> do-                writeIORef posRef c-                pubPos <- atomically (readTVar pubPosVar)-                if c >= pubPos-                    then pure CaughtUp-                    else do-                        fetchResult <- fetchBatch pool config c emit stSettings-                        case fetchResult of-                            Left err -> pure (FetchFailed err)-                            Right events-                                | decodedBatchLength events == 0 -> pure CaughtUp-                                | otherwise -> pure (BatchFetched events)-            Live c -> case liveSource of-                LiveFromPublisherQueue liveQueue statusVar -> do-                    writeIORef posRef c-                    atomically $ do-                        status <- readTVar statusVar-                        case status of-                            Pub.Overflowed -> pure QueueOverflowed-                            Pub.Paused -> pure QueueBackpressured-                            Pub.Active -> do-                                -- A subscription registers its live queue before-                                -- catch-up begins, so events appended during-                                -- catch-up may be both fetched from SQL and waiting-                                -- in the queue. Drop those stale entries so live-                                -- mode cannot replay them or rewind the checkpoint.-                                events <- readTBQueue liveQueue-                                let fresh = filterDecodedBatch ((> c) . globalPosition) events-                                pure (if decodedBatchLength fresh == 0 then FetchEmpty else BatchFetched fresh)-                LiveFromCategoryNotify cat ->-                    liveExitToInput =<< liveLoopCategoryNotify pool config stateVar catGenVar cat emit posRef c stSettings-                LiveFromGroupPolling ->-                    liveExitToInput =<< liveLoopDbDriven pool config stateVar pubPosVar emit posRef c stSettings-            -- Recoverable backpressure: the publisher set 'Paused' because this-            -- subscriber's bounded queue filled. Drain the stale queue (those-            -- events are re-read from the database by the re-catch-up that-            -- 'QueueDrained' triggers) and clear the flag back to 'Active' so the-            -- publisher resumes pushing and the worker is not left waiting. The-            -- AllStreams live path's @> cursor@ filter drops any superseded queued-            -- entries once the worker is live again.-            Paused{} -> do-                case liveSource of-                    LiveFromPublisherQueue liveQueue statusVar -> do-                        atomically $ do-                            drainQueue liveQueue-                            writeTVar statusVar Pub.Active-                        pure QueueDrained-                    -- Defensive totality: only the queue branch can produce-                    -- QueueBackpressured, so DB-driven sources should never-                    -- enter Paused.-                    LiveFromCategoryNotify{} -> pure QueueDrained-                    LiveFromGroupPolling -> pure QueueDrained-            -- Reconnecting: re-probe the database from the checkpoint. A success-            -- re-enters catch-up (delivering everything after the cursor); a-            -- failure stays in 'Reconnecting' for another backed-off attempt; an-            -- empty result means there is nothing new, so return to live.-            Reconnecting c _ -> do-                writeIORef posRef c-                fetchResult <- fetchBatch pool config c emit stSettings-                case fetchResult of-                    Left err -> pure (FetchFailed err)-                    Right events-                        | decodedBatchLength events == 0 -> pure FetchEmpty-                        | otherwise -> pure (BatchFetched events)-            -- Defensive totality: 'Retrying' is a surfaced observability state-            -- that the delivery primitive writes into the state TVar and then-            -- restores, so it is never a driving state reaching this function.-            -- Mirror 'step''s defensive 'Retrying' clause, which returns to-            -- 'Live' at the same cursor.-            Retrying c _ -> nextInput (Live c)-            Stopped{} -> pure Cancelled--        -- Interpret a transition's effects against the *new* state. Returns a-        -- follow-up 'Input' when an effect produces one (only 'DeliverBatch', when-        -- the handler returns 'Stop'); 'Nothing' otherwise. 'Halt' terminates the-        -- driver: a handler-requested stop returns cleanly (the outer handler-        -- emits the Stopped event), overflow/crash rethrow so the outer 'try'-        -- classifies and re-emits — preserving today's exact event sequence.-        runEffects :: SubscriptionState -> [Effect] -> IO (Maybe Input)-        runEffects st' = go-          where-            go [] = pure Nothing-            go (e : es) = case e of-                EmitCaughtUp -> do-                    emit (KirokuEventSubscriptionCaughtUp subName (stateCursor st') groupCtx)-                    go es-                EmitPaused -> do-                    emit (KirokuEventSubscriptionPaused subName (stateCursor st') groupCtx)-                    go es-                EmitResumed -> do-                    emit (KirokuEventSubscriptionResumed subName (stateCursor st') groupCtx)-                    go es-                EmitReconnecting n -> do-                    emit (KirokuEventSubscriptionReconnecting subName n groupCtx)-                    go es-                Backoff n -> threadDelay (fetchRetryDelayMicros n) >> go es-                WaitForDrain -> go es-                Checkpoint p -> saveCheckpoint pool config p emit >> go es-                FetchHistory _ -> go es-                RunLive -> go es-                DeliverBatch events -> do-                    result <- processEvents pool config stateVar events emit posRef stSettings-                    case result of-                        Nothing -> pure (Just (HandlerStopped (lastPosOf events)))-                        Just _ -> go es-                Halt reason -> case reason of-                    StopHandlerRequested -> pure Nothing-                    StopOverflowed -> throwIO (SubscriptionOverflowed subName)-                    StopCancelled -> throwIO Async.AsyncCancelled-                    StopWorkerCrashed ex -> throwIO ex-                    StopUndecodable failure -> throwIO (SubscriptionUndecodable failure)--        lastPosOf events = globalPosition (decodedBatchLastEvent events)--        -- Map a DB-driven live loop's exit onto the next FSM input: a clean stop-        -- becomes 'HandlerStopped' (at the last processed position); a fetch error-        -- becomes 'ConnectionLost', driving the FSM into 'Reconnecting'.-        liveExitToInput = \case-            LiveHandlerStopped -> HandlerStopped <$> readIORef posRef-            LiveFetchError err -> do-                position <- readIORef posRef-                pure (ConnectionLost position err)--        -- Read and discard every batch currently in the live queue (non-blocking).-        -- Used when resuming from 'Paused': the discarded events are re-read from-        -- the database by the subsequent re-catch-up, so nothing is lost.-        drainQueue q = do-            m <- tryReadTBQueue q-            case m of-                Nothing -> pure ()-                Just _ -> drainQueue q--    result <- try body-    pos <- readIORef posRef-    case result of-        Right () -> emit (KirokuEventSubscriptionStopped subName pos StopHandlerRequested groupCtx)-        Left (e :: SomeException) -> do-            emit (KirokuEventSubscriptionStopped subName pos (classifyStopReason e) groupCtx)-            throwIO e---- The consumer-group context for this config's lifecycle events: 'NonGroup' for--- an ordinary subscription, @GroupMember member size@ for a group member.-groupCtxOf :: SubscriptionConfig -> SubscriptionGroupContext-groupCtxOf config = maybe NonGroup (\cg -> GroupMember (member cg) (size cg)) (consumerGroup config)--{- Startup-only conflict probe for the consumer-group guardrail. Uses a-transaction-scoped advisory lock ('pg_try_advisory_xact_lock') which auto-releases-at transaction end, so it only detects a /concurrent/ holder at this instant. The-key is a stable bigint hash of the @name:member@ pair computed in SQL so all-processes agree. NOTE: this does NOT hold the lock for the worker's lifetime; full-mutual exclusion would need a session-level lock on a dedicated connection (the-'Kiroku.Store.Notification.Notifier' pattern), recorded as follow-up in EP-2's-Decision Log. On a database error the probe degrades open (treats it as "no-conflict") so a transient pool error cannot wedge startup. -}-guardMember :: Pool -> SubscriptionName -> Int32 -> IO ()-guardMember pool subName@(SubscriptionName n) mem = do-    let probe :: Statement (Text, Int32) Bool-        probe =-            preparable-                "SELECT pg_try_advisory_xact_lock(hashtextextended($1 || ':' || $2::text, 0))"-                ( contrazip2-                    (E.param (E.nonNullable E.text))-                    (E.param (E.nonNullable E.int4))-                )-                (D.singleRow (D.column (D.nonNullable D.bool)))-    result <- Pool.use pool (Session.statement (n, mem) probe)-    case result of-        Right True -> pure () -- got the lock; no concurrent holder right now-        Right False -> throwIO (ConsumerGroupGuardConflict subName mem)-        Left _ -> pure () -- DB error: degrade open (do not block startup)---- Map an exception to the 'SubscriptionStopReason' the operator should see.-classifyStopReason :: SomeException -> SubscriptionStopReason-classifyStopReason e-    | Just (_ :: SubscriptionOverflowed) <- fromException e = StopOverflowed-    | Just (_ :: Async.AsyncCancelled) <- fromException e = StopCancelled-    | Just (SubscriptionUndecodable failure) <- fromException e = StopUndecodable failure-    | otherwise = StopWorkerCrashed e---- The consumer-group member index for this config, or 0 for a non-group--- subscription. We always route checkpoints through the member-aware--- statements with member 0 for the non-group case, so there is a single code--- path: EP-1's schema guarantees pre-existing rows are consumer_group_member = 0,--- so a non-group worker reads and writes the same (name, 0) row it always did.-configMember :: SubscriptionConfig -> Int32-configMember config = maybe 0 member (consumerGroup config)--configSize :: SubscriptionConfig -> Int32-configSize config = maybe 1 size (consumerGroup config)---- Resolve the exact checkpoint key through the shared initializer. A database--- error is emitted and rethrown so startup fails loudly. A semantic--- 'FailIfMissing' result remains typed so the caller can emit the distinct--- refusal event before throwing it. Each group member resolves its own key.-loadCheckpoint ::-    Pool ->-    SubscriptionConfig ->-    (KirokuEvent -> IO ()) ->-    IO (Either SubscriptionCheckpointMissing CheckpointInitialization)-loadCheckpoint pool config emit = do-    let subName = name config-        mem = configMember config-    mHook <- readIORef loadCheckpointHookRef-    injected <- maybe (pure Nothing) (\hook -> hook config) mHook-    result <- case injected of-        Just hooked -> pure (fmap (either (Left . SomeSubscriptionStartupFailure) (Right . (,False))) hooked)-        Nothing ->-            Pool.use pool $-                CheckpointSQL.initializeWorkerCheckpointSession-                    subName-                    mem-                    (configSize config)-                    (target config)-                    (targetBindingPolicy config)-                    (missingCheckpointPolicy config)-    case result of-        Left err -> do-            emit (KirokuEventSubscriptionDbError subName LoadCheckpoint err (groupCtxOf config))-            throwIO err-        Right (Left refusal@(SomeSubscriptionStartupFailure concrete)) ->-            case fromException (toException concrete) of-                Just missing -> pure (Left missing)-                Nothing -> do-                    case fromException (toException concrete) of-                        Just mismatch -> emit (KirokuEventSubscriptionGroupSizeMismatch mismatch (groupCtxOf config))-                        Nothing -> pure ()-                    throwIO refusal-        Right (Right (resolution, adopted)) -> do-            when adopted $ emit (KirokuEventSubscriptionTargetBound subName (target config) (groupCtxOf config))-            pure (Right resolution)---- How a DB-driven live loop ('liveLoopCategoryNotify' / 'liveLoopDbDriven')--- exited. The driver maps these onto FSM inputs: a clean handler stop becomes--- 'HandlerStopped'; a fetch error becomes 'ConnectionLost', which drives the FSM--- into 'Reconnecting' (backoff + re-catch-up from the checkpoint) rather than the--- old in-loop retry. AllStreams live has no entry here because it reads the--- publisher's queue and never fetches — its reconnect is the publisher's concern.-data LiveExit-    = -- | The handler returned 'Stop'; the loop exited cleanly.-      LiveHandlerStopped-    | -- | A live-mode database fetch failed; the worker should reconnect.-      LiveFetchError !Pool.UsageError---- Phase 2: live (Category, NOTIFY-driven). Blocks on this category's generation--- counter, which the Notifier bumps on every NOTIFY for a stream in the category,--- so an idle category does ZERO DB work while other categories receive traffic.--- The generation is snapshotted BEFORE an unconditional drain so a notification--- that arrives during the drain is never missed (it leaves gen > gen0 and the loop--- drains again on the next iteration). A safety timeout (matching the publisher's--- 30s safety poll) reconciles notifications lost while the listener connection is--- reconnecting, preserving at-least-once delivery with bounded latency.------ This loop serves every `Category` subscription, plain or consumer-group. A--- member cannot tell from a NOTIFY payload whether the stream is in its slice--- (that is `hashtextextended(stream_id) % size = member`, a Postgres hash), but--- it can gate on the category: the category generation advances on every append--- to a stream of the category, a superset of the member's own streams, and--- `fetchBatch` applies the partition predicate in SQL. So a member of an idle--- category does no live database work while other categories are busy, and a--- member whose sibling received the append does one empty fetch.-liveLoopCategoryNotify ::-    Pool ->-    SubscriptionConfig ->-    TVar SubscriptionState ->-    TVar (Map Text Word64) ->-    -- | this subscription's category-    Text ->-    (KirokuEvent -> IO ()) ->-    IORef GlobalPosition ->-    GlobalPosition ->-    StoreSettings ->-    IO LiveExit-liveLoopCategoryNotify pool config stateVar catGenVar cat emit posRef startPos stSettings = go startPos-  where-    readGen = Map.findWithDefault 0 cat <$> readTVar catGenVar-    go cursor = do-        writeIORef posRef cursor-        -- Snapshot the generation BEFORE draining so a NOTIFY landing mid-drain-        -- is not lost: it leaves gen > gen0 and the gate below re-opens at once.-        gen0 <- atomically readGen-        drainResult <- drainTo cursor-        case drainResult of-            Left err -> pure (LiveFetchError err) -- reconnect: driver re-catches-up-            Right Nothing -> pure LiveHandlerStopped -- handler said Stop-            Right (Just c) -> do-                -- Block until this category is notified again OR the safety timer-                -- fires (reconciling NOTIFYs lost across a listener reconnect).-                timer <- registerDelay categorySafetyPollMicros-                atomically $-                    (readGen >>= \g -> check (g > gen0))-                        `orElse` (readTVar timer >>= check)-                go c-      where-        -- A fetch error bubbles out (no in-loop retry); the FSM's 'Reconnecting'-        -- state owns the backoff and re-catch-up. On success drain to empty.-        drainTo c = do-            fetchResult <- fetchBatch pool config c emit stSettings-            case fetchResult of-                Left err -> pure (Left err)-                Right events -> do-                    emit (KirokuEventSubscriptionFetched (name config) (decodedBatchLength events) (groupCtxOf config))-                    if decodedBatchLength events == 0-                        then pure (Right (Just c))-                        else do-                            result <- processEvents pool config stateVar events emit posRef stSettings-                            case result of-                                Nothing -> pure (Right Nothing) -- handler said Stop-                                Just newPos -> drainTo newPos---- Phase 2: live (DB-driven, consumer-group members of AllStreams). Bypasses the broadcast--- and re-queries the database when the publisher's GLOBAL position advances,--- letting `fetchBatch` apply the partition predicate baked into the consumer-group--- SQL. A partitioned member cannot read the broadcast `liveQueue` because it--- carries unfiltered $all events and there is no in-process stream-id -> member map--- to filter them with. See EP-3 F18 / EP-2 Decision Log for the rationale versus--- extending RecordedEvent or maintaining an in-process cache.------ The gate waits for the publisher to advance past the LAST OBSERVED global--- position (not past `cursor`). A member's partition cursor only moves on events in--- its slice, but `pubPosVar` moves on every append; gating on the cursor busy-loops--- whenever another partition is ahead (the original defect). Gating on the last--- observed `pubPos` blocks until genuinely new global work exists. After the gate--- opens we drain the partition to empty (not stopping at `pubPos`), which--- guarantees no lost wakeup: the $all position is strictly monotonic, so any later--- partition event lands at a position strictly greater than the observed `pubPos`--- and re-opens the gate.-liveLoopDbDriven ::-    Pool ->-    SubscriptionConfig ->-    TVar SubscriptionState ->-    TVar GlobalPosition ->-    (KirokuEvent -> IO ()) ->-    IORef GlobalPosition ->-    GlobalPosition ->-    StoreSettings ->-    IO LiveExit-liveLoopDbDriven pool config stateVar pubPosVar emit posRef startPos stSettings =-    go startPos (GlobalPosition 0)-  where-    go cursor waitFrom = do-        writeIORef posRef cursor-        pubPos <- atomically $ do-            p <- readTVar pubPosVar-            check (p > waitFrom)-            pure p-        -- A fetch error bubbles out (no in-loop retry); the FSM's 'Reconnecting'-        -- state owns the backoff and re-catch-up. On success drain to empty.-        let drainTo c = do-                fetchResult <- fetchBatch pool config c emit stSettings-                case fetchResult of-                    Left err -> pure (Left err)-                    Right events -> do-                        emit (KirokuEventSubscriptionFetched (name config) (decodedBatchLength events) (groupCtxOf config))-                        if decodedBatchLength events == 0-                            then pure (Right (Just c))-                            else do-                                result <- processEvents pool config stateVar events emit posRef stSettings-                                case result of-                                    Nothing -> pure (Right Nothing) -- handler said Stop-                                    Just newPos -> drainTo newPos-        drainResult <- drainTo cursor-        case drainResult of-            Left err -> pure (LiveFetchError err)-            Right Nothing -> pure LiveHandlerStopped-            Right (Just c) -> go c pubPos---- Fetch a batch of events from the database based on subscription target.--- Surfaces a database error through the event handler and returns the error to--- the caller so catch-up and DB-driven live loops can retry the same cursor.-fetchBatch ::-    Pool ->-    SubscriptionConfig ->-    GlobalPosition ->-    (KirokuEvent -> IO ()) ->-    StoreSettings ->-    IO (Either Pool.UsageError DecodedBatch)-fetchBatch pool config cursor@(GlobalPosition pos) emit stSettings = do-    mHook <- readIORef fetchBatchHookRef-    injected <- maybe (pure Nothing) (\hook -> hook config cursor) mHook-    case injected of-        Just result -> handle result-        Nothing ->-            case (consumerGroup config, target config) of-                (Nothing, AllStreams) -> do-                    result <- Pool.use pool (Session.statement (pos, batchSizeValue (batchSize config)) SQL.readAllForwardStmt)-                    handle result-                (Nothing, Category (CategoryName cat)) -> do-                    result <- Pool.use pool (Session.statement (pos, cat, batchSizeValue (batchSize config)) SQL.readCategoryForwardStmt)-                    handle result-                (Just cg, AllStreams) -> do-                    let m = member cg; n = size cg-                    result <- Pool.use pool (Session.statement (pos, m, n, batchSizeValue (batchSize config)) SQL.readAllForwardConsumerGroupStmt)-                    handle result-                (Just cg, Category (CategoryName cat)) -> do-                    let m = member cg; n = size cg-                    result <- Pool.use pool (Session.statement (pos, cat, m, n, batchSizeValue (batchSize config)) SQL.readCategoryForwardConsumerGroupStmt)-                    handle result-  where-    handle = \case-        Left err -> do-            emit (KirokuEventSubscriptionDbError (name config) FetchBatch err (groupCtxOf config))-            pure (Left err)-        -- Apply decodeHook on the catch-up path so catch-up batches are-        -- transformed identically to live batches (which the publisher-        -- transforms once before fan-out).-        Right events -> Right <$> decodeEvents stSettings events---- Process a batch of events through the handler, resolving each event's--- disposition. Returns the new cursor position if the batch was fully consumed--- (every event resolved to 'Continue', 'DeadLetter', or an exhausted 'Retry'),--- or 'Nothing' if the handler returned 'Stop'.------ This is the single delivery primitive shared by the FSM 'DeliverBatch' effect--- (catch-up for every target; AllStreams live) and the two DB-driven live loops,--- so the four dispositions behave identically on every path (EP-2 / MasterPlan 6--- Decision Log). Checkpointing keeps the existing per-batch model: 'Continue'--- events advance the checkpoint only at the batch tail; 'Stop' checkpoints at the--- stopping event; 'DeadLetter' (and exhausted 'Retry') atomically record the--- event and advance the checkpoint past it via--- 'SQL.insertDeadLetterAndCheckpointStmt'.------ 'Retry' redelivers the same event after its 'RetryDelay', bounded by the--- config's 'retryMaxAttempts'; while a redelivery is pending the worker's--- observable state @TVar@ shows 'Retrying' (restored to the driving state — the--- value the driver wrote before this batch — once the event resolves).-processEvents ::-    Pool ->-    SubscriptionConfig ->-    TVar SubscriptionState ->-    DecodedBatch ->-    (KirokuEvent -> IO ()) ->-    IORef GlobalPosition ->-    StoreSettings ->-    IO (Maybe GlobalPosition)-processEvents pool config stateVar batch emit posRef stSettings = do-    driving <- atomically (readTVar stateVar)-    let phase = case driving of-            CatchingUp{} -> DeliveredCatchUp-            _ -> DeliveredLive-    emit (KirokuEventSubscriptionDelivered subName (decodedBatchLength batch) phase groupCtx)-    -- Choose the vector representation once per batch. The unchanged arm walks-    -- RecordedEvent directly, with no per-event Decoded/Undecodable allocation.-    case batch of-        UnchangedBatch events -> walk events id (\event pos -> deliver driving event pos 1)-        TransformedBatch events -> walk events decodedEventRecorded (\event pos -> dispatch driving event pos 1)-  where-    subName = name config-    groupCtx = groupCtxOf config-    maxAttempts = retryMaxAttempts (retryPolicy config)--    walk :: Vector a -> (a -> RecordedEvent) -> (a -> GlobalPosition -> IO Bool) -> IO (Maybe GlobalPosition)-    walk events rawOf consume = go 0-      where-        go i-            | i >= V.length events = do-                let newPos = globalPosition (rawOf (V.last events))-                writeIORef posRef newPos-                saveCheckpoint pool config newPos emit-                pure (Just newPos)-            | otherwise = do-                let item = events V.! i-                    event = rawOf item-                    evtPos = globalPosition event-                keepGoing <--                    if shouldDeliver (eventTypeFilter config) (selector config) event-                        then consume item evtPos-                        else pure True-                if keepGoing-                    then writeIORef posRef evtPos >> go (i + 1)-                    else pure Nothing--    deliver driving event evtPos attempt = do-        writeIORef posRef evtPos-        result <- handler config event-        resolve driving event evtPos attempt result (deliver driving event evtPos (attempt + 1))--    dispatch driving outcome evtPos attempt = case outcome of-        Decoded event -> deliver driving event evtPos attempt-        Undecodable raw failure -> case undecodableHandler config of-            Nothing-                | attempt >= maxAttempts -> throwIO (SubscriptionUndecodable failure)-                | otherwise -> do-                    pause driving evtPos attempt (RetryDelay 1)-                    retryDecode driving raw evtPos (attempt + 1)-            Just callback -> do-                result <- callback raw failure-                resolve driving raw evtPos attempt result (retryDecode driving raw evtPos (attempt + 1))--    retryDecode driving raw evtPos attempt = do-        outcome <- decodeEvent stSettings raw-        case outcome of-            Decoded event | not (shouldDeliver (eventTypeFilter config) (selector config) event) -> pure True-            _ -> dispatch driving outcome evtPos attempt--    -- Ordinary and explicitly chosen undecodable dispositions share exactly-    -- one checkpoint/dead-letter/retry resolver. The absent callback never-    -- enters its exhausted-Retry dead-letter branch.-    resolve driving event evtPos attempt result retry = case result of-        Continue -> pure True-        Stop -> do-            writeIORef posRef evtPos-            saveCheckpoint pool config evtPos emit-            pure False-        DeadLetter reason -> do-            writeDeadLetter pool config evtPos event reason attempt emit-            pure True-        Retry delay-            | attempt >= maxAttempts -> do-                writeDeadLetter pool config evtPos event (DeadLetterMaxAttempts attempt) attempt emit-                pure True-            | otherwise -> pause driving evtPos attempt delay >> retry--    pause driving evtPos attempt delay = do-        atomically (writeTVar stateVar (Retrying evtPos attempt))-        emit (KirokuEventSubscriptionRetrying subName evtPos attempt groupCtx)-        threadDelay (retryDelayMicros delay)-        atomically (writeTVar stateVar driving)---- Atomically record an event in @kiroku.dead_letters@ and advance the--- subscription's checkpoint past it (one statement; the checkpoint does not--- advance if the insert fails). On a database error the worker surfaces a--- 'KirokuEventSubscriptionDbError' and rethrows, so the event is neither lost--- nor silently skipped — it replays from the unadvanced checkpoint on restart.-writeDeadLetter ::-    Pool ->-    SubscriptionConfig ->-    GlobalPosition ->-    RecordedEvent ->-    DeadLetterReason ->-    -- | attempt count to record-    Int ->-    (KirokuEvent -> IO ()) ->-    IO ()-writeDeadLetter pool config gp@(GlobalPosition pos) event reason attempt emit = do-    let subName@(SubscriptionName name') = name config-        mem = configMember config-        (kind, category) = CheckpointSQL.targetColumns (Just (target config))-        EventId uuid = eventId event-        params =-            SQL.DeadLetterParams-                { SQL.dlSubscriptionName = name'-                , SQL.dlMember = mem-                , SQL.dlTargetKind = kind-                , SQL.dlTargetCategory = category-                , SQL.dlGroupSize = configSize config-                , SQL.dlGlobalPosition = pos-                , SQL.dlEventId = uuid-                , SQL.dlReason = deadLetterReasonJson reason-                , SQL.dlReasonSummary = deadLetterSummary reason-                , SQL.dlAttemptCount = fromIntegral attempt-                }-    result <- Pool.use pool (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)-    case result of-        Left err -> do-            emit (KirokuEventSubscriptionDbError subName SaveCheckpoint err (groupCtxOf config))-            throwIO err-        Right () -> emit (KirokuEventSubscriptionDeadLettered subName gp reason (groupCtxOf config))---- Save a checkpoint to the database. Surfaces a database error through--- the event handler; the worker continues running but the next restart--- with the same name re-processes events the handler has already seen.--- Keyed by (subscription_name, member) so each group member persists its own--- position; non-group subscriptions use member 0 (see 'configMember').-saveCheckpoint ::-    Pool ->-    SubscriptionConfig ->-    GlobalPosition ->-    (KirokuEvent -> IO ()) ->-    IO ()-saveCheckpoint pool config position@(GlobalPosition pos) emit = do-    let subName@(SubscriptionName name') = name config-        mem = configMember config-    mHook <- readIORef saveCheckpointHookRef-    mapM_ (\hook -> hook config position) mHook-    result <- Pool.use pool (CheckpointSQL.saveBoundCheckpointSession (target config) name' mem pos (configSize config))-    case result of-        Left err -> emit (KirokuEventSubscriptionDbError subName SaveCheckpoint err (groupCtxOf config))-        Right () -> pure ()+-- | The subscription worker: the impure driver behind a running subscription.+--+-- 'runWorker' is the long-lived loop spawned by+-- 'Kiroku.Store.Subscription.subscribe'. It is the interpreter for the pure state+-- machine in "Kiroku.Store.Subscription.Fsm": it supplies inputs (a batch was+-- fetched, the queue overflowed, the pool errored, the handler returned a+-- disposition) and carries out the resulting effects (deliver a batch, save the+-- checkpoint, back off, emit a @KirokuEvent@, halt). The worker walks the named+-- states — @CatchingUp@, @Live@, @Paused@ (recoverable backpressure),+-- @Reconnecting@ (re-catch-up after a live fetch loses the pool), @Retrying@, and+-- @Stopped@ — and writes each transition to the @TVar@ exposed through+-- 'Kiroku.Store.Subscription.Types.currentState'.+--+-- Per-event delivery (including the worker-side+-- 'Kiroku.Store.Subscription.Types.eventTypeFilter' \/+-- 'Kiroku.Store.Subscription.Types.selector' check applied /before/ the handler,+-- and the bounded-retry \/ dead-letter disposition mechanics) is concentrated in+-- the single delivery primitive shared by every live path, so behaviour is+-- identical for @AllStreams@, @Category@, and consumer-group subscriptions.+--+-- 'withFetchBatchHookForTest', 'withLoadCheckpointHookForTest', and+-- 'withSaveCheckpointHookForTest' are test-only seams for controlling fetch,+-- checkpoint-load, and checkpoint-save boundaries.+module Kiroku.Store.Subscription.Worker+  ( LiveSource (..),+    runWorker,+    configMember,+    withFetchBatchHookForTest,+    withLoadCheckpointHookForTest,+    withSaveCheckpointHookForTest,+    withNamespaceSafetyPollForTest,+    withNamespaceWindowHookForTest,+  )+where++import Contravariant.Extras (contrazip2)+import Control.Concurrent (threadDelay)+import Control.Concurrent.Async qualified as Async+import Control.Concurrent.STM (TBQueue, TVar, atomically, check, newTVarIO, orElse, readTBQueue, readTVar, registerDelay, tryReadTBQueue, writeTVar)+import Control.Concurrent.STM qualified as STM+import Control.Exception (SomeException, bracket, finally, fromException, mask, throwIO, toException, try)+import Control.Monad (when)+import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Int (Int32)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Time (NominalDiffTime)+import Data.Vector (Vector)+import Data.Vector qualified as V+import Data.Word (Word64)+import GHC.Clock (getMonotonicTimeNSec)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool (Pool)+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Kiroku.Store.Observability+  ( KirokuEvent (..),+    SubscriptionDbPhase (..),+    SubscriptionDeliveryPhase (..),+    SubscriptionGroupContext (..),+    SubscriptionStopReason (..),+    emitOrDrop,+  )+import Kiroku.Store.SQL qualified as SQL+import Kiroku.Store.Settings (DecodedBatch (..), DecodedEvent (..), StoreSettings, decodeEvent, decodeEvents, decodedBatchLastEvent, decodedBatchLength, decodedEventRecorded, filterDecodedBatch)+import Kiroku.Store.Subscription.Checkpoint.SQL qualified as CheckpointSQL+import Kiroku.Store.Subscription.EventPublisher (SubscriberStatus)+import Kiroku.Store.Subscription.EventPublisher qualified as Pub+import Kiroku.Store.Subscription.Fsm+  ( Effect (..),+    Input (..),+    SubscriptionState (..),+    stateCursor,+    step,+  )+import Kiroku.Store.Subscription.Types hiding (eventId, globalPosition)+import Kiroku.Store.Types (CategoryName (..), EventId (..), GlobalPosition (..), RecordedEvent (..))+import System.IO.Unsafe (unsafePerformIO)++-- Mirror 'Kiroku.Store.Subscription.EventPublisher.safetyPollMicros': an idle+-- category re-checks at most this often, reconciling NOTIFYs lost while the+-- listener connection was reconnecting. An idle category therefore costs at most+-- one empty fetch per safety interval, not per global publisher tick.+categorySafetyPollMicros :: Int+categorySafetyPollMicros = 30_000_000++type FetchBatchHook =+  SubscriptionConfig ->+  GlobalPosition ->+  IO (Maybe (Either Pool.UsageError (Vector RecordedEvent)))++type LoadCheckpointHook =+  SubscriptionConfig ->+  IO+    ( Maybe+        ( Either+            Pool.UsageError+            (Either SubscriptionCheckpointMissing CheckpointInitialization)+        )+    )++type SaveCheckpointHook =+  SubscriptionConfig ->+  GlobalPosition ->+  IO ()++{-# NOINLINE fetchBatchHookRef #-}+fetchBatchHookRef :: IORef (Maybe FetchBatchHook)+fetchBatchHookRef = unsafePerformIO (newIORef Nothing)++{-# NOINLINE loadCheckpointHookRef #-}+loadCheckpointHookRef :: IORef (Maybe LoadCheckpointHook)+loadCheckpointHookRef = unsafePerformIO (newIORef Nothing)++{-# NOINLINE saveCheckpointHookRef #-}+saveCheckpointHookRef :: IORef (Maybe SaveCheckpointHook)+saveCheckpointHookRef = unsafePerformIO (newIORef Nothing)++-- | Install a process-local fetch hook for tests that need deterministic+-- subscription-worker fault injection. Production code leaves the hook unset.+withFetchBatchHookForTest :: FetchBatchHook -> IO a -> IO a+withFetchBatchHookForTest hook action =+  bracket+    ( do+        previous <- readIORef fetchBatchHookRef+        writeIORef fetchBatchHookRef (Just hook)+        pure previous+    )+    (writeIORef fetchBatchHookRef)+    (const action)++-- | Install a process-local checkpoint-load hook for tests that need+-- deterministic subscription-startup fault injection. Production code leaves the+-- hook unset.+withLoadCheckpointHookForTest :: LoadCheckpointHook -> IO a -> IO a+withLoadCheckpointHookForTest hook action =+  bracket+    ( do+        previous <- readIORef loadCheckpointHookRef+        writeIORef loadCheckpointHookRef (Just hook)+        pure previous+    )+    (writeIORef loadCheckpointHookRef)+    (const action)++-- | Install a process-local checkpoint-save boundary hook for lifecycle tests.+--+-- The hook runs immediately before the database statement. Production code leaves+-- it unset.+withSaveCheckpointHookForTest :: SaveCheckpointHook -> IO a -> IO a+withSaveCheckpointHookForTest hook action =+  bracket+    ( do+        previous <- readIORef saveCheckpointHookRef+        writeIORef saveCheckpointHookRef (Just hook)+        pure previous+    )+    (writeIORef saveCheckpointHookRef)+    (const action)++-- | Test timing and fetch injection; production uses the normal safety interval.+{-# NOINLINE namespaceSafetyPollRef #-}+namespaceSafetyPollRef :: IORef Int+namespaceSafetyPollRef = unsafePerformIO (newIORef categorySafetyPollMicros)++withNamespaceSafetyPollForTest :: Int -> IO a -> IO a+withNamespaceSafetyPollForTest micros action =+  bracket+    (readIORef namespaceSafetyPollRef <* writeIORef namespaceSafetyPollRef micros)+    (writeIORef namespaceSafetyPollRef)+    (const action)++type NamespaceWindowHook = SubscriptionConfig -> GlobalPosition -> GlobalPosition -> IO (Maybe (Either Pool.UsageError SQL.NamespaceWindow))++{-# NOINLINE namespaceWindowHookRef #-}+namespaceWindowHookRef :: IORef (Maybe NamespaceWindowHook)+namespaceWindowHookRef = unsafePerformIO (newIORef Nothing)++withNamespaceWindowHookForTest :: NamespaceWindowHook -> IO a -> IO a+withNamespaceWindowHookForTest hook action =+  bracket+    (readIORef namespaceWindowHookRef <* writeIORef namespaceWindowHookRef (Just hook))+    (writeIORef namespaceWindowHookRef)+    (const action)++fetchRetryDelayMicros :: Int -> Int+fetchRetryDelayMicros attempt =+  min categorySafetyPollMicros (100_000 * (2 ^ min attempt 9 :: Int))++-- | How a worker obtains live-mode batches, fixed at 'subscribe' time from the+-- config's (consumerGroup, target) shape.+--+-- Only 'LiveFromPublisherQueue' owns a registration with the EventPublisher. The+-- other shapes are DB-driven and the publisher must do no fan-out work for them.+data LiveSource+  = -- | Non-group AllStreams: read the publisher's bounded queue; the status+    --       TVar carries Paused/Overflowed backpressure signals.+    LiveFromPublisherQueue !(TBQueue DecodedBatch) !(TVar SubscriberStatus)+  | -- | Category, plain or consumer-group member: wake on the named+    --       category's NOTIFY generation counter and re-query the database (with the+    --       partition predicate, for a member).+    LiveFromCategoryNotify !Text+  | -- | Namespace workers share only their active namespace's wake cell.+    LiveFromNamespaceNotify !(TVar Word64)+  | -- | Consumer-group member of AllStreams: wake when the global position+    --       advances and re-query with the partition predicate.+    LiveFromGroupPolling++-- | Run the subscription worker loop. Two phases:+--+-- Phase 1 (catch-up): queries database directly until reaching publisherPosition.+-- Phase 2 (live): for 'LiveFromPublisherQueue', reads from the bounded TBQueue+-- the publisher delivers to; for 'LiveFromCategoryNotify' and+-- 'LiveFromGroupPolling', re-queries the database, and no publisher queue exists.+--+-- Runs until the handler returns 'Stop', the thread is cancelled, or the+-- publisher signals overflow on the subscriber's status TVar (in which+-- case 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' is thrown+-- and surfaces through 'Async.waitCatch').+--+-- If an 'eventHandler' callback is supplied, the worker emits:+--+-- * 'Kiroku.Store.Observability.KirokuEventSubscriptionCheckpointResolved' once+--   when startup resumes or initializes a checkpoint, followed by+--   'Kiroku.Store.Observability.KirokuEventSubscriptionStarted'. A refused+--   missing checkpoint emits+--   'Kiroku.Store.Observability.KirokuEventSubscriptionCheckpointMissing'+--   instead and fails before delivery.+-- * 'Kiroku.Store.Observability.KirokuEventSubscriptionCaughtUp' when+--   catch-up completes and the worker switches to live mode.+-- * 'Kiroku.Store.Observability.KirokuEventSubscriptionDbError' in the+--   subscription database phases: 'loadCheckpoint', 'fetchBatch', and+--   'saveCheckpoint'. 'fetchBatch' errors are retried at the same cursor;+--   the event is the operator's structured signal.+-- * 'Kiroku.Store.Observability.KirokuEventSubscriptionStopped' when the+--   worker exits, with a reason discriminating handler-stop, cancel,+--   overflow, and worker-crash.+runWorker ::+  (MonadIO m) =>+  Pool ->+  LiveSource ->+  -- | the worker's current FSM state, written on every transition so callers+  --     can read it through 'Kiroku.Store.Subscription.Types.currentState'.+  TVar SubscriptionState ->+  TVar GlobalPosition ->+  -- | per-category wake counter from the Notifier; the @Category@ live loop+  --     blocks on this category's entry rather than busy-polling the global position.+  TVar (Map Text Word64) ->+  SubscriptionConfig ->+  -- | optional event handler for subscription observability+  Maybe (KirokuEvent -> IO ()) ->+  -- | interpreter-level event hooks; 'Kiroku.Store.Settings.decodeHook'+  --     runs on the catch-up fetch path, mirroring the publisher's+  --     application in live mode.+  StoreSettings ->+  m ()+runWorker pool liveSource stateVar pubPosVar catGenVar config mHandler stSettings = liftIO $+  withHandlerStallDiagnostics config (emitOrDrop mHandler) $ \deliveryConfig ->+    runWorkerBody pool liveSource stateVar pubPosVar catGenVar deliveryConfig mHandler stSettings++-- Select the delivery handler once per worker. The disabled arm returns the+-- original config directly, with no per-delivery diagnostic branch or work.+withHandlerStallDiagnostics :: SubscriptionConfig -> (KirokuEvent -> IO ()) -> (SubscriptionConfig -> IO a) -> IO a+withHandlerStallDiagnostics config emit action = case handlerStallWarnAfter config of+  Nothing -> action config+  Just threshold+    | threshold <= 0 -> do+        let refusal = InvalidHandlerStallWarnAfter threshold+        emit (KirokuEventSubscriptionStopped (name config) (GlobalPosition 0) (StopWorkerCrashed (toException refusal)) (groupCtxOf config))+        throwIO refusal+    | otherwise -> do+        pending <- newTVarIO Nothing+        let tracked event = mask $ \restore -> do+              started <- getMonotonicTimeNSec+              let delivery = StalledDelivery (globalPosition event) (eventId event) started+              atomically (writeTVar pending (Just delivery))+              restore (handler config event) `finally` atomically (writeTVar pending Nothing)+        Async.withAsync (watchHandler pending threshold) $ \watchdog -> do+          Async.link watchdog+          action config {handler = tracked}+  where+    -- Park while idle. Reuse a pending interval across completed/replaced+    -- invocations, rather than abandoning a registerDelay timer per event.+    watchHandler pending threshold = awaitDelivery+      where+        awaitDelivery = do+          _ <- atomically $ readTVar pending >>= maybe STM.retry pure+          waitInterval threshold+        waitInterval delay = do+          timer <- registerDelay (durationMicros delay)+          atomically (readTVar timer >>= check)+          current <- atomically (readTVar pending)+          case current of+            Nothing -> awaitDelivery+            Just delivery@(StalledDelivery pos eid started) -> do+              now <- getMonotonicTimeNSec+              let elapsed = fromRational (fromIntegral (now - started) / 1_000_000_000)+              if elapsed < threshold+                then waitInterval (threshold - elapsed)+                else do+                  stillPending <- atomically ((== Just delivery) <$> readTVar pending)+                  when stillPending $+                    emit (KirokuEventSubscriptionHandlerStalled (name config) pos eid elapsed (groupCtxOf config))+                  waitInterval threshold++-- One cell per enabled worker, not one timer/thread per handler invocation.+data StalledDelivery = StalledDelivery !GlobalPosition !EventId !Word64+  deriving stock (Eq)++-- registerDelay accepts an Int. Cap huge intervals safely and round positive+-- sub-microsecond intervals up instead of creating a zero-delay polling loop.+durationMicros :: NominalDiffTime -> Int+durationMicros duration = fromInteger (max 1 (min (toInteger (maxBound :: Int)) (ceiling (duration * 1_000_000))))++-- Allocated only for namespace workers; old targets keep their startup path.+data NamespaceScanState = NamespaceScanState+  { scanSweep :: !(IORef (Maybe GlobalPosition)),+    scanGeneration :: !(IORef (Maybe Word64)),+    scanEmptyWindows :: !(IORef Int),+    scanSaved :: !(IORef GlobalPosition)+  }++runWorkerBody :: Pool -> LiveSource -> TVar SubscriptionState -> TVar GlobalPosition -> TVar (Map Text Word64) -> SubscriptionConfig -> Maybe (KirokuEvent -> IO ()) -> StoreSettings -> IO ()+runWorkerBody pool liveSource stateVar pubPosVar catGenVar config mHandler stSettings = do+  let emit = emitOrDrop mHandler+      subName = name config+      groupCtx = groupCtxOf config+  posRef <- newIORef (GlobalPosition 0)+  -- One captured database head per sweep and a generation snapshotted before it.+  namespaceState <- case liveSource of+    LiveFromNamespaceNotify _ -> Just <$> (NamespaceScanState <$> newIORef Nothing <*> newIORef Nothing <*> newIORef 0 <*> newIORef (GlobalPosition 0))+    _ -> pure Nothing++  let namespaceScan = case namespaceState of+        Just state -> state+        Nothing -> error "namespace scan state requested for a legacy target"+      body = do+        -- Optional startup guardrail: when consumerGroupGuard is on, fail fast+        -- if another holder currently holds this (name, member)'s advisory lock.+        case (consumerGroupGuard config, consumerGroup config) of+          (True, Just cg) -> guardMember pool subName (member cg)+          _ -> pure ()+        resolution <- loadCheckpoint pool config emit+        checkpoint <- case resolution of+          Left missing -> do+            emit (KirokuEventSubscriptionCheckpointMissing missing groupCtx)+            throwIO missing+          Right initialization -> do+            emit (KirokuEventSubscriptionCheckpointResolved initialization groupCtx)+            pure (checkpointInitializationPosition initialization)+        mapM_ (\scan -> writeIORef (scanSaved scan) checkpoint) namespaceState+        writeIORef posRef checkpoint+        emit (KirokuEventSubscriptionStarted subName checkpoint groupCtx)+        -- Drive the explicit FSM from the catch-up state. The pure 'step'+        -- (Kiroku.Store.Subscription.Fsm) decides every lifecycle transition;+        -- this driver supplies the inputs (what just happened) and interprets+        -- the effects (deliver a batch, emit an event, back off, halt). The+        -- three live strategies remain the *mechanism* for obtaining the next+        -- batch within the 'Live' state; the FSM governs the lifecycle.+        loop (CatchingUp checkpoint 0)++      namespaceInput :: Bool -> GlobalPosition -> TVar Word64 -> IO Input+      namespaceInput live cursor generation = do+        sweep <- readIORef (scanSweep namespaceScan)+        captured <- case sweep of+          Just headPosition -> pure (Right headPosition)+          Nothing -> do+            previous <- readIORef (scanGeneration namespaceScan)+            when live $ case previous of+              Nothing -> pure ()+              Just before -> do+                micros <- readIORef namespaceSafetyPollRef+                timer <- registerDelay micros+                atomically $+                  (readTVar generation >>= \g -> check (g /= before))+                    `orElse` (readTVar timer >>= check)+            -- Snapshot BEFORE the head read, including on initial catch-up.+            before <- atomically (readTVar generation)+            result <- Pool.use pool (Session.statement () SQL.currentGlobalPositionStmt)+            case result of+              Left err -> pure (Left err)+              Right headPosition -> do+                writeIORef (scanGeneration namespaceScan) (Just before)+                writeIORef (scanSweep namespaceScan) (Just (GlobalPosition headPosition))+                pure (Right (GlobalPosition headPosition))+        result <- case captured of+          Left err -> pure (Left err)+          Right headPosition+            | cursor >= headPosition -> pure (Right (SQL.NamespaceWindow V.empty cursor 0 True))+            | otherwise -> fetchNamespaceWindow pool config cursor headPosition+        case result of+          Left err -> do+            emit (KirokuEventSubscriptionDbError subName FetchBatch err groupCtx)+            pure (if live then ConnectionLost cursor err else FetchFailed err)+          Right window -> do+            events <- decodeEvents stSettings (SQL.namespaceEvents window)+            emit (KirokuEventSubscriptionFetched subName (decodedBatchLength events) groupCtx)+            pure (NamespaceWindowFetched events (SQL.scannedThrough window) (SQL.exhausted window))++      -- One driver iteration: publish the current state for observability+      -- ('currentState'), discover the next 'Input' by performing the state's+      -- natural (possibly blocking) action, then hand it to 'feed'. Recording+      -- the state at loop entry means the value read while the worker blocks in+      -- 'nextInput' (e.g. waiting on the live queue) is the state it is blocked in.+      loop :: SubscriptionState -> IO ()+      loop st = do+        atomically (writeTVar stateVar st)+        nextInput st >>= feed st++      -- Apply 'step' to the (state, input) pair, interpret the resulting+      -- effects, and continue. An effect (delivery, a gate) may itself produce+      -- a follow-up 'Input' (e.g. the handler returned 'Stop'); that is fed back+      -- to 'step' immediately. Otherwise: stop when the new state is terminal,+      -- else loop on the new state.+      feed :: SubscriptionState -> Input -> IO ()+      feed st inp = do+        let (st', effs) = step st inp+        follow <- runEffects st' effs+        case follow of+          Just fInp -> feed st' fInp+          Nothing -> case st' of+            Stopped _ -> pure ()+            _ -> loop st'++      -- Produce the next 'Input' for a state by performing its blocking action.+      --   * CatchingUp: if caught up, 'CaughtUp'; else fetch one history batch,+      --     mapping the result to 'BatchFetched' / 'FetchEmpty' (caught up) /+      --     'FetchFailed' (the catch-up retry, escalated by the state's attempt).+      --   * Live (AllStreams, non-group): read the publisher's bounded queue;+      --     'QueueOverflowed' when the publisher signalled overflow, else the+      --     stale-filtered batch ('FetchEmpty' if all stale).+      --   * Live (Category / consumer-group): run the existing live loop to its+      --     natural termination (handler 'Stop'), then report 'HandlerStopped'.+      --     These loops retain their own NOTIFY-generation / global-position+      --     gates and per-fetch retry, exactly as before.+      nextInput :: SubscriptionState -> IO Input+      nextInput = \case+        CatchingUp c _+          | LiveFromNamespaceNotify generation <- liveSource ->+              namespaceInput False c generation+        CatchingUp c _ -> do+          writeIORef posRef c+          pubPos <- atomically (readTVar pubPosVar)+          if c >= pubPos+            then pure CaughtUp+            else do+              fetchResult <- fetchBatch pool config c emit stSettings+              case fetchResult of+                Left err -> pure (FetchFailed err)+                Right events+                  | decodedBatchLength events == 0 -> pure CaughtUp+                  | otherwise -> pure (BatchFetched events)+        Live c -> case liveSource of+          LiveFromPublisherQueue liveQueue statusVar -> do+            writeIORef posRef c+            atomically $ do+              status <- readTVar statusVar+              case status of+                Pub.Overflowed -> pure QueueOverflowed+                Pub.Paused -> pure QueueBackpressured+                Pub.Active -> do+                  -- A subscription registers its live queue before+                  -- catch-up begins, so events appended during+                  -- catch-up may be both fetched from SQL and waiting+                  -- in the queue. Drop those stale entries so live+                  -- mode cannot replay them or rewind the checkpoint.+                  events <- readTBQueue liveQueue+                  let fresh = filterDecodedBatch ((> c) . globalPosition) events+                  pure (if decodedBatchLength fresh == 0 then FetchEmpty else BatchFetched fresh)+          LiveFromCategoryNotify cat ->+            liveExitToInput =<< liveLoopCategoryNotify pool config stateVar catGenVar cat emit posRef c stSettings+          LiveFromNamespaceNotify generation -> namespaceInput True c generation+          LiveFromGroupPolling ->+            liveExitToInput =<< liveLoopDbDriven pool config stateVar pubPosVar emit posRef c stSettings+        -- Recoverable backpressure: the publisher set 'Paused' because this+        -- subscriber's bounded queue filled. Drain the stale queue (those+        -- events are re-read from the database by the re-catch-up that+        -- 'QueueDrained' triggers) and clear the flag back to 'Active' so the+        -- publisher resumes pushing and the worker is not left waiting. The+        -- AllStreams live path's @> cursor@ filter drops any superseded queued+        -- entries once the worker is live again.+        Paused {} -> do+          case liveSource of+            LiveFromPublisherQueue liveQueue statusVar -> do+              atomically $ do+                drainQueue liveQueue+                writeTVar statusVar Pub.Active+              pure QueueDrained+            -- Defensive totality: only the queue branch can produce+            -- QueueBackpressured, so DB-driven sources should never+            -- enter Paused.+            LiveFromCategoryNotify {} -> pure QueueDrained+            LiveFromNamespaceNotify {} -> pure QueueDrained+            LiveFromGroupPolling -> pure QueueDrained+        -- Reconnecting: re-probe the database from the checkpoint. A success+        -- re-enters catch-up (delivering everything after the cursor); a+        -- failure stays in 'Reconnecting' for another backed-off attempt; an+        -- empty result means there is nothing new, so return to live.+        Reconnecting c _+          | LiveFromNamespaceNotify generation <- liveSource ->+              namespaceInput False c generation+        Reconnecting c _ -> do+          writeIORef posRef c+          fetchResult <- fetchBatch pool config c emit stSettings+          case fetchResult of+            Left err -> pure (FetchFailed err)+            Right events+              | decodedBatchLength events == 0 -> pure FetchEmpty+              | otherwise -> pure (BatchFetched events)+        -- Defensive totality: 'Retrying' is a surfaced observability state+        -- that the delivery primitive writes into the state TVar and then+        -- restores, so it is never a driving state reaching this function.+        -- Mirror 'step''s defensive 'Retrying' clause, which returns to+        -- 'Live' at the same cursor.+        Retrying c _ -> nextInput (Live c)+        Stopped {} -> pure Cancelled++      -- Interpret a transition's effects against the *new* state. Returns a+      -- follow-up 'Input' when an effect produces one (only 'DeliverBatch', when+      -- the handler returns 'Stop'); 'Nothing' otherwise. 'Halt' terminates the+      -- driver: a handler-requested stop returns cleanly (the outer handler+      -- emits the Stopped event), overflow/crash rethrow so the outer 'try'+      -- classifies and re-emits — preserving today's exact event sequence.+      runEffects :: SubscriptionState -> [Effect] -> IO (Maybe Input)+      runEffects st' = go+        where+          go [] = pure Nothing+          go (e : es) = case e of+            EmitCaughtUp -> do+              emit (KirokuEventSubscriptionCaughtUp subName (stateCursor st') groupCtx)+              go es+            EmitPaused -> do+              emit (KirokuEventSubscriptionPaused subName (stateCursor st') groupCtx)+              go es+            EmitResumed -> do+              emit (KirokuEventSubscriptionResumed subName (stateCursor st') groupCtx)+              go es+            EmitReconnecting n -> do+              emit (KirokuEventSubscriptionReconnecting subName n groupCtx)+              go es+            Backoff n -> threadDelay (fetchRetryDelayMicros n) >> go es+            WaitForDrain -> go es+            Checkpoint p -> saveCheckpoint pool config p emit >> go es+            FetchHistory _ -> go es+            RunLive -> go es+            DeliverNamespaceWindow events frontier done -> do+              result <-+                if decodedBatchLength events == 0+                  then do+                    count <- readIORef (scanEmptyWindows namespaceScan)+                    writeIORef (scanEmptyWindows namespaceScan) (count + 1)+                    saved <- readIORef (scanSaved namespaceScan)+                    when (frontier > saved && (done || count + 1 >= 32)) $ do+                      success <- saveCheckpointResult pool config frontier emit+                      when success $ do+                        writeIORef (scanSaved namespaceScan) frontier+                        writeIORef (scanEmptyWindows namespaceScan) 0+                    writeIORef posRef frontier+                    pure (Just frontier)+                  else processEventsWithCompletion (Just (frontier, (scanSaved namespaceScan))) pool config stateVar events emit posRef stSettings+              case result of+                Nothing -> Just . HandlerStopped <$> readIORef posRef+                Just completed -> do+                  -- A nonempty window already attempted one tail save; remember+                  -- pending progress after failures for a later empty sweep.+                  when (decodedBatchLength events > 0) (writeIORef (scanEmptyWindows namespaceScan) 0)+                  when done (writeIORef (scanSweep namespaceScan) Nothing)+                  pure (Just (NamespaceWindowCompleted completed done))+            DeliverBatch events -> do+              result <- processEvents pool config stateVar events emit posRef stSettings+              case result of+                Nothing -> pure (Just (HandlerStopped (lastPosOf events)))+                Just _ -> go es+            Halt reason -> case reason of+              StopHandlerRequested -> pure Nothing+              StopOverflowed -> throwIO (SubscriptionOverflowed subName)+              StopCancelled -> throwIO Async.AsyncCancelled+              StopWorkerCrashed ex -> throwIO ex+              StopUndecodable failure -> throwIO (SubscriptionUndecodable failure)++      lastPosOf events = globalPosition (decodedBatchLastEvent events)++      -- Map a DB-driven live loop's exit onto the next FSM input: a clean stop+      -- becomes 'HandlerStopped' (at the last processed position); a fetch error+      -- becomes 'ConnectionLost', driving the FSM into 'Reconnecting'.+      liveExitToInput = \case+        LiveHandlerStopped -> HandlerStopped <$> readIORef posRef+        LiveFetchError err -> do+          position <- readIORef posRef+          pure (ConnectionLost position err)++      -- Read and discard every batch currently in the live queue (non-blocking).+      -- Used when resuming from 'Paused': the discarded events are re-read from+      -- the database by the subsequent re-catch-up, so nothing is lost.+      drainQueue q = do+        m <- tryReadTBQueue q+        case m of+          Nothing -> pure ()+          Just _ -> drainQueue q++  result <- try body+  pos <- readIORef posRef+  case result of+    Right () -> emit (KirokuEventSubscriptionStopped subName pos StopHandlerRequested groupCtx)+    Left (e :: SomeException) -> do+      emit (KirokuEventSubscriptionStopped subName pos (classifyStopReason e) groupCtx)+      throwIO e++-- The consumer-group context for this config's lifecycle events: 'NonGroup' for+-- an ordinary subscription, @GroupMember member size@ for a group member.+groupCtxOf :: SubscriptionConfig -> SubscriptionGroupContext+groupCtxOf config = maybe NonGroup (\cg -> GroupMember (member cg) (size cg)) (consumerGroup config)++{- Startup-only conflict probe for the consumer-group guardrail. Uses a+transaction-scoped advisory lock ('pg_try_advisory_xact_lock') which auto-releases+at transaction end, so it only detects a /concurrent/ holder at this instant. The+key is a stable bigint hash of the @name:member@ pair computed in SQL so all+processes agree. NOTE: this does NOT hold the lock for the worker's lifetime; full+mutual exclusion would need a session-level lock on a dedicated connection (the+'Kiroku.Store.Notification.Notifier' pattern), recorded as follow-up in EP-2's+Decision Log. On a database error the probe degrades open (treats it as "no+conflict") so a transient pool error cannot wedge startup. -}+guardMember :: Pool -> SubscriptionName -> Int32 -> IO ()+guardMember pool subName@(SubscriptionName n) mem = do+  let probe :: Statement (Text, Int32) Bool+      probe =+        preparable+          "SELECT pg_try_advisory_xact_lock(hashtextextended($1 || ':' || $2::text, 0))"+          ( contrazip2+              (E.param (E.nonNullable E.text))+              (E.param (E.nonNullable E.int4))+          )+          (D.singleRow (D.column (D.nonNullable D.bool)))+  result <- Pool.use pool (Session.statement (n, mem) probe)+  case result of+    Right True -> pure () -- got the lock; no concurrent holder right now+    Right False -> throwIO (ConsumerGroupGuardConflict subName mem)+    Left _ -> pure () -- DB error: degrade open (do not block startup)++-- Map an exception to the 'SubscriptionStopReason' the operator should see.+classifyStopReason :: SomeException -> SubscriptionStopReason+classifyStopReason e+  | Just (_ :: SubscriptionOverflowed) <- fromException e = StopOverflowed+  | Just (_ :: Async.AsyncCancelled) <- fromException e = StopCancelled+  | Just (SubscriptionUndecodable failure) <- fromException e = StopUndecodable failure+  | otherwise = StopWorkerCrashed e++-- The consumer-group member index for this config, or 0 for a non-group+-- subscription. We always route checkpoints through the member-aware+-- statements with member 0 for the non-group case, so there is a single code+-- path: EP-1's schema guarantees pre-existing rows are consumer_group_member = 0,+-- so a non-group worker reads and writes the same (name, 0) row it always did.+configMember :: SubscriptionConfig -> Int32+configMember config = maybe 0 member (consumerGroup config)++configSize :: SubscriptionConfig -> Int32+configSize config = maybe 1 size (consumerGroup config)++-- Resolve the exact checkpoint key through the shared initializer. A database+-- error is emitted and rethrown so startup fails loudly. A semantic+-- 'FailIfMissing' result remains typed so the caller can emit the distinct+-- refusal event before throwing it. Each group member resolves its own key.+loadCheckpoint ::+  Pool ->+  SubscriptionConfig ->+  (KirokuEvent -> IO ()) ->+  IO (Either SubscriptionCheckpointMissing CheckpointInitialization)+loadCheckpoint pool config emit = do+  let subName = name config+      mem = configMember config+  mHook <- readIORef loadCheckpointHookRef+  injected <- maybe (pure Nothing) (\hook -> hook config) mHook+  result <- case injected of+    Just hooked -> pure (fmap (either (Left . SomeSubscriptionStartupFailure) (Right . (,False))) hooked)+    Nothing ->+      Pool.use pool $+        CheckpointSQL.initializeWorkerCheckpointSession+          subName+          mem+          (configSize config)+          (target config)+          (targetBindingPolicy config)+          (missingCheckpointPolicy config)+  case result of+    Left err -> do+      emit (KirokuEventSubscriptionDbError subName LoadCheckpoint err (groupCtxOf config))+      throwIO err+    Right (Left refusal@(SomeSubscriptionStartupFailure concrete)) ->+      case fromException (toException concrete) of+        Just missing -> pure (Left missing)+        Nothing -> do+          case fromException (toException concrete) of+            Just mismatch -> emit (KirokuEventSubscriptionGroupSizeMismatch mismatch (groupCtxOf config))+            Nothing -> pure ()+          case fromException (toException concrete) of+            Just mismatch -> emit (KirokuEventSubscriptionTargetMismatch mismatch (groupCtxOf config))+            Nothing -> pure ()+          throwIO refusal+    Right (Right (resolution, adopted)) -> do+      when adopted $ emit (KirokuEventSubscriptionTargetBound subName (target config) (groupCtxOf config))+      pure (Right resolution)++-- How a DB-driven live loop ('liveLoopCategoryNotify' / 'liveLoopDbDriven')+-- exited. The driver maps these onto FSM inputs: a clean handler stop becomes+-- 'HandlerStopped'; a fetch error becomes 'ConnectionLost', which drives the FSM+-- into 'Reconnecting' (backoff + re-catch-up from the checkpoint) rather than the+-- old in-loop retry. AllStreams live has no entry here because it reads the+-- publisher's queue and never fetches — its reconnect is the publisher's concern.+data LiveExit+  = -- | The handler returned 'Stop'; the loop exited cleanly.+    LiveHandlerStopped+  | -- | A live-mode database fetch failed; the worker should reconnect.+    LiveFetchError !Pool.UsageError++-- Phase 2: live (Category, NOTIFY-driven). Blocks on this category's generation+-- counter, which the Notifier bumps on every NOTIFY for a stream in the category,+-- so an idle category does ZERO DB work while other categories receive traffic.+-- The generation is snapshotted BEFORE an unconditional drain so a notification+-- that arrives during the drain is never missed (it leaves gen > gen0 and the loop+-- drains again on the next iteration). A safety timeout (matching the publisher's+-- 30s safety poll) reconciles notifications lost while the listener connection is+-- reconnecting, preserving at-least-once delivery with bounded latency.+--+-- This loop serves every `Category` subscription, plain or consumer-group. A+-- member cannot tell from a NOTIFY payload whether the stream is in its slice+-- (that is `hashtextextended(stream_id) % size = member`, a Postgres hash), but+-- it can gate on the category: the category generation advances on every append+-- to a stream of the category, a superset of the member's own streams, and+-- `fetchBatch` applies the partition predicate in SQL. So a member of an idle+-- category does no live database work while other categories are busy, and a+-- member whose sibling received the append does one empty fetch.+liveLoopCategoryNotify ::+  Pool ->+  SubscriptionConfig ->+  TVar SubscriptionState ->+  TVar (Map Text Word64) ->+  -- | this subscription's category+  Text ->+  (KirokuEvent -> IO ()) ->+  IORef GlobalPosition ->+  GlobalPosition ->+  StoreSettings ->+  IO LiveExit+liveLoopCategoryNotify pool config stateVar catGenVar cat emit posRef startPos stSettings = go startPos+  where+    readGen = Map.findWithDefault 0 cat <$> readTVar catGenVar+    go cursor = do+      writeIORef posRef cursor+      -- Snapshot the generation BEFORE draining so a NOTIFY landing mid-drain+      -- is not lost: it leaves gen > gen0 and the gate below re-opens at once.+      gen0 <- atomically readGen+      drainResult <- drainTo cursor+      case drainResult of+        Left err -> pure (LiveFetchError err) -- reconnect: driver re-catches-up+        Right Nothing -> pure LiveHandlerStopped -- handler said Stop+        Right (Just c) -> do+          -- Block until this category is notified again OR the safety timer+          -- fires (reconciling NOTIFYs lost across a listener reconnect).+          timer <- registerDelay categorySafetyPollMicros+          atomically $+            (readGen >>= \g -> check (g > gen0))+              `orElse` (readTVar timer >>= check)+          go c+      where+        -- A fetch error bubbles out (no in-loop retry); the FSM's 'Reconnecting'+        -- state owns the backoff and re-catch-up. On success drain to empty.+        drainTo c = do+          fetchResult <- fetchBatch pool config c emit stSettings+          case fetchResult of+            Left err -> pure (Left err)+            Right events -> do+              emit (KirokuEventSubscriptionFetched (name config) (decodedBatchLength events) (groupCtxOf config))+              if decodedBatchLength events == 0+                then pure (Right (Just c))+                else do+                  result <- processEvents pool config stateVar events emit posRef stSettings+                  case result of+                    Nothing -> pure (Right Nothing) -- handler said Stop+                    Just newPos -> drainTo newPos++-- Phase 2: live (DB-driven, consumer-group members of AllStreams). Bypasses the broadcast+-- and re-queries the database when the publisher's GLOBAL position advances,+-- letting `fetchBatch` apply the partition predicate baked into the consumer-group+-- SQL. A partitioned member cannot read the broadcast `liveQueue` because it+-- carries unfiltered $all events and there is no in-process stream-id -> member map+-- to filter them with. See EP-3 F18 / EP-2 Decision Log for the rationale versus+-- extending RecordedEvent or maintaining an in-process cache.+--+-- The gate waits for the publisher to advance past the LAST OBSERVED global+-- position (not past `cursor`). A member's partition cursor only moves on events in+-- its slice, but `pubPosVar` moves on every append; gating on the cursor busy-loops+-- whenever another partition is ahead (the original defect). Gating on the last+-- observed `pubPos` blocks until genuinely new global work exists. After the gate+-- opens we drain the partition to empty (not stopping at `pubPos`), which+-- guarantees no lost wakeup: the $all position is strictly monotonic, so any later+-- partition event lands at a position strictly greater than the observed `pubPos`+-- and re-opens the gate.+liveLoopDbDriven ::+  Pool ->+  SubscriptionConfig ->+  TVar SubscriptionState ->+  TVar GlobalPosition ->+  (KirokuEvent -> IO ()) ->+  IORef GlobalPosition ->+  GlobalPosition ->+  StoreSettings ->+  IO LiveExit+liveLoopDbDriven pool config stateVar pubPosVar emit posRef startPos stSettings =+  go startPos (GlobalPosition 0)+  where+    go cursor waitFrom = do+      writeIORef posRef cursor+      pubPos <- atomically $ do+        p <- readTVar pubPosVar+        check (p > waitFrom)+        pure p+      -- A fetch error bubbles out (no in-loop retry); the FSM's 'Reconnecting'+      -- state owns the backoff and re-catch-up. On success drain to empty.+      let drainTo c = do+            fetchResult <- fetchBatch pool config c emit stSettings+            case fetchResult of+              Left err -> pure (Left err)+              Right events -> do+                emit (KirokuEventSubscriptionFetched (name config) (decodedBatchLength events) (groupCtxOf config))+                if decodedBatchLength events == 0+                  then pure (Right (Just c))+                  else do+                    result <- processEvents pool config stateVar events emit posRef stSettings+                    case result of+                      Nothing -> pure (Right Nothing) -- handler said Stop+                      Just newPos -> drainTo newPos+      drainResult <- drainTo cursor+      case drainResult of+        Left err -> pure (LiveFetchError err)+        Right Nothing -> pure LiveHandlerStopped+        Right (Just c) -> go c pubPos++-- Fetch a batch of events from the database based on subscription target.+-- Surfaces a database error through the event handler and returns the error to+-- the caller so catch-up and DB-driven live loops can retry the same cursor.+fetchBatch ::+  Pool ->+  SubscriptionConfig ->+  GlobalPosition ->+  (KirokuEvent -> IO ()) ->+  StoreSettings ->+  IO (Either Pool.UsageError DecodedBatch)+fetchBatch pool config cursor@(GlobalPosition pos) emit stSettings = do+  mHook <- readIORef fetchBatchHookRef+  injected <- maybe (pure Nothing) (\hook -> hook config cursor) mHook+  case injected of+    Just result -> handle result+    Nothing ->+      case (consumerGroup config, target config) of+        (_, CategoryNamespace _) -> error "namespace workers fetch bounded windows"+        (Nothing, AllStreams) -> do+          result <- Pool.use pool (Session.statement (pos, batchSizeValue (batchSize config)) SQL.readAllForwardStmt)+          handle result+        (Nothing, Category (CategoryName cat)) -> do+          result <- Pool.use pool (Session.statement (pos, cat, batchSizeValue (batchSize config)) SQL.readCategoryForwardStmt)+          handle result+        (Just cg, AllStreams) -> do+          let m = member cg; n = size cg+          result <- Pool.use pool (Session.statement (pos, m, n, batchSizeValue (batchSize config)) SQL.readAllForwardConsumerGroupStmt)+          handle result+        (Just cg, Category (CategoryName cat)) -> do+          let m = member cg; n = size cg+          result <- Pool.use pool (Session.statement (pos, cat, m, n, batchSizeValue (batchSize config)) SQL.readCategoryForwardConsumerGroupStmt)+          handle result+  where+    handle = \case+      Left err -> do+        emit (KirokuEventSubscriptionDbError (name config) FetchBatch err (groupCtxOf config))+        pure (Left err)+      -- Apply decodeHook on the catch-up path so catch-up batches are+      -- transformed identically to live batches (which the publisher+      -- transforms once before fan-out).+      Right events -> Right <$> decodeEvents stSettings events++-- Fetch only one bounded window. No request is made at/beyond the captured head.+fetchNamespaceWindow :: Pool -> SubscriptionConfig -> GlobalPosition -> GlobalPosition -> IO (Either Pool.UsageError SQL.NamespaceWindow)+fetchNamespaceWindow pool config cursor@(GlobalPosition position) headPosition@(GlobalPosition upper) = do+  hook <- readIORef namespaceWindowHookRef+  injected <- maybe (pure Nothing) (\f -> f config cursor headPosition) hook+  case injected of+    Just result -> pure result+    Nothing -> case target config of+      CategoryNamespace namespace ->+        let width = min 1024 (batchSizeValue (batchSize config))+         in Pool.use pool $ case consumerGroup config of+              Nothing -> Session.statement (position, upper, namespace, width) SQL.readNamespaceWindowStmt+              Just cg -> Session.statement (position, upper, namespace, width, member cg, size cg) SQL.readNamespaceGroupWindowStmt+      _ -> error "fetchNamespaceWindow requires a namespace target"++-- Process a batch of events through the handler, resolving each event's+-- disposition. Returns the new cursor position if the batch was fully consumed+-- (every event resolved to 'Continue', 'DeadLetter', or an exhausted 'Retry'),+-- or 'Nothing' if the handler returned 'Stop'.+--+-- This is the single delivery primitive shared by the FSM 'DeliverBatch' effect+-- (catch-up for every target; AllStreams live) and the two DB-driven live loops,+-- so the four dispositions behave identically on every path (EP-2 / MasterPlan 6+-- Decision Log). Checkpointing keeps the existing per-batch model: 'Continue'+-- events advance the checkpoint only at the batch tail; 'Stop' checkpoints at the+-- stopping event; 'DeadLetter' (and exhausted 'Retry') atomically record the+-- event and advance the checkpoint past it via+-- 'SQL.insertDeadLetterAndCheckpointStmt'.+--+-- 'Retry' redelivers the same event after its 'RetryDelay', bounded by the+-- config's 'retryMaxAttempts'; while a redelivery is pending the worker's+-- observable state @TVar@ shows 'Retrying' (restored to the driving state — the+-- value the driver wrote before this batch — once the event resolves).+processEvents ::+  Pool ->+  SubscriptionConfig ->+  TVar SubscriptionState ->+  DecodedBatch ->+  (KirokuEvent -> IO ()) ->+  IORef GlobalPosition ->+  StoreSettings ->+  IO (Maybe GlobalPosition)+processEvents = processEventsWithCompletion Nothing++-- A fully resolved namespace window saves its completed scan frontier once.+-- Stop and atomic dead letters retain their per-event positions.+processEventsWithCompletion :: Maybe (GlobalPosition, IORef GlobalPosition) -> Pool -> SubscriptionConfig -> TVar SubscriptionState -> DecodedBatch -> (KirokuEvent -> IO ()) -> IORef GlobalPosition -> StoreSettings -> IO (Maybe GlobalPosition)+processEventsWithCompletion completion pool config stateVar batch emit posRef stSettings = do+  driving <- atomically (readTVar stateVar)+  let phase = case driving of+        CatchingUp {} -> DeliveredCatchUp+        _ -> DeliveredLive+  emit (KirokuEventSubscriptionDelivered subName (decodedBatchLength batch) phase groupCtx)+  -- Choose the vector representation once per batch. The unchanged arm walks+  -- RecordedEvent directly, with no per-event Decoded/Undecodable allocation.+  case batch of+    UnchangedBatch events -> walk events id (\event pos -> deliver driving event pos 1)+    TransformedBatch events -> walk events decodedEventRecorded (\event pos -> dispatch driving event pos 1)+  where+    subName = name config+    groupCtx = groupCtxOf config+    maxAttempts = retryMaxAttempts (retryPolicy config)++    walk :: Vector a -> (a -> RecordedEvent) -> (a -> GlobalPosition -> IO Bool) -> IO (Maybe GlobalPosition)+    walk events rawOf consume = go 0+      where+        go i+          | i >= V.length events = do+              let newPos = maybe (globalPosition (rawOf (V.last events))) fst completion+              writeIORef posRef newPos+              success <- saveCheckpointResult pool config newPos emit+              when success $ case completion of+                Nothing -> pure ()+                Just (_, saved) -> writeIORef saved newPos+              pure (Just newPos)+          | otherwise = do+              let item = events V.! i+                  event = rawOf item+                  evtPos = globalPosition event+              keepGoing <-+                if shouldDeliver (eventTypeFilter config) (selector config) event+                  then consume item evtPos+                  else pure True+              if keepGoing+                then writeIORef posRef evtPos >> go (i + 1)+                else pure Nothing++    deliver driving event evtPos attempt = do+      writeIORef posRef evtPos+      result <- handler config event+      resolve driving event evtPos attempt result (deliver driving event evtPos (attempt + 1))++    dispatch driving outcome evtPos attempt = case outcome of+      Decoded event -> deliver driving event evtPos attempt+      Undecodable raw failure -> case undecodableHandler config of+        Nothing+          | attempt >= maxAttempts -> throwIO (SubscriptionUndecodable failure)+          | otherwise -> do+              pause driving evtPos attempt (RetryDelay 1)+              retryDecode driving raw evtPos (attempt + 1)+        Just callback -> do+          result <- callback raw failure+          resolve driving raw evtPos attempt result (retryDecode driving raw evtPos (attempt + 1))++    retryDecode driving raw evtPos attempt = do+      outcome <- decodeEvent stSettings raw+      -- walk already accepted the initial outcome. Recovery may rewrite its+      -- type or payload; retain that decision throughout this delivery.+      dispatch driving outcome evtPos attempt++    -- Ordinary and explicitly chosen undecodable dispositions share exactly+    -- one checkpoint/dead-letter/retry resolver. The absent callback never+    -- enters its exhausted-Retry dead-letter branch.+    resolve driving event evtPos attempt result retry = case result of+      Continue -> pure True+      Stop -> do+        writeIORef posRef evtPos+        saveCheckpoint pool config evtPos emit+        pure False+      DeadLetter reason -> do+        writeDeadLetter pool config evtPos event reason attempt emit+        pure True+      Retry delay+        | attempt >= maxAttempts -> do+            writeDeadLetter pool config evtPos event (DeadLetterMaxAttempts attempt) attempt emit+            pure True+        | otherwise -> pause driving evtPos attempt delay >> retry++    pause driving evtPos attempt delay = do+      atomically (writeTVar stateVar (Retrying evtPos attempt))+      emit (KirokuEventSubscriptionRetrying subName evtPos attempt groupCtx)+      threadDelay (retryDelayMicros delay)+      atomically (writeTVar stateVar driving)++-- Atomically record an event in @kiroku.dead_letters@ and advance the+-- subscription's checkpoint past it (one statement; the checkpoint does not+-- advance if the insert fails). On a database error the worker surfaces a+-- 'KirokuEventSubscriptionDbError' and rethrows, so the event is neither lost+-- nor silently skipped — it replays from the unadvanced checkpoint on restart.+writeDeadLetter ::+  Pool ->+  SubscriptionConfig ->+  GlobalPosition ->+  RecordedEvent ->+  DeadLetterReason ->+  -- | attempt count to record+  Int ->+  (KirokuEvent -> IO ()) ->+  IO ()+writeDeadLetter pool config gp@(GlobalPosition pos) event reason attempt emit = do+  let subName@(SubscriptionName name') = name config+      mem = configMember config+      (kind, category) = CheckpointSQL.targetColumns (Just (target config))+      EventId uuid = eventId event+      params =+        SQL.DeadLetterParams+          { SQL.dlSubscriptionName = name',+            SQL.dlMember = mem,+            SQL.dlTargetKind = kind,+            SQL.dlTargetCategory = category,+            SQL.dlGroupSize = configSize config,+            SQL.dlGlobalPosition = pos,+            SQL.dlEventId = uuid,+            SQL.dlReason = deadLetterReasonJson reason,+            SQL.dlReasonSummary = deadLetterSummary reason,+            SQL.dlAttemptCount = fromIntegral attempt+          }+  result <- Pool.use pool (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)+  case result of+    Left err -> do+      emit (KirokuEventSubscriptionDbError subName SaveCheckpoint err (groupCtxOf config))+      throwIO err+    Right () -> emit (KirokuEventSubscriptionDeadLettered subName gp reason (groupCtxOf config))++-- Save a checkpoint to the database. Surfaces a database error through+-- the event handler; the worker continues running but the next restart+-- with the same name re-processes events the handler has already seen.+-- Keyed by (subscription_name, member) so each group member persists its own+-- position; non-group subscriptions use member 0 (see 'configMember').+saveCheckpoint ::+  Pool ->+  SubscriptionConfig ->+  GlobalPosition ->+  (KirokuEvent -> IO ()) ->+  IO ()+saveCheckpoint pool config position emit = do+  _ <- saveCheckpointResult pool config position emit+  pure ()++-- Failed empty-window flushes must remain pending rather than looking durable.+saveCheckpointResult :: Pool -> SubscriptionConfig -> GlobalPosition -> (KirokuEvent -> IO ()) -> IO Bool+saveCheckpointResult pool config position@(GlobalPosition pos) emit = do+  let subName@(SubscriptionName name') = name config+      mem = configMember config+  mHook <- readIORef saveCheckpointHookRef+  mapM_ (\hook -> hook config position) mHook+  result <- Pool.use pool (CheckpointSQL.saveBoundCheckpointSession (target config) name' mem pos (configSize config))+  case result of+    Left err -> emit (KirokuEventSubscriptionDbError subName SaveCheckpoint err (groupCtxOf config)) >> pure False+    Right () -> pure True
src/Kiroku/Store/Transaction.hs view
@@ -1,46 +1,45 @@-{- | Transactional combinators that compose a Kiroku event-store-operation with arbitrary additional @hasql-transaction@ work in a-single ACID transaction.--This module is the entry point for callers — most prominently the-@keiro@ projection layer — that need to atomically write to their own-SQL tables in the same transaction as an event append. Three ergonomic-levels are provided:--* 'runTransaction' / 'runTransactionNoRetry' — bare escape hatch: run-  an opaque 'Tx.Transaction' against the store's connection pool.--* 'appendToStreamTx' — a 'Tx.Transaction'-flavored single-stream-  append. Useful inside a 'runTransaction' block when the caller-  wants full control over conflict handling.--* 'runTransactionAppending' / 'runTransactionAppendingNoRetry' — the-  recommended high-level wrapper that combines a single-stream append-  with the caller's 'Tx.Transaction' continuation in one atomic-  transaction. This is the primary API for @keiro@-style projection-  consumers.--Replay-history leases and one-stream repair guards are exposed separately by-'Kiroku.Store.HistoryRetention' as @Tx.Transaction@ combinators. When composing-both in one body, acquire or renew the lease before taking the stream guard.-The transactional read deliberately bypasses the IO-only decode hook.--The @-NoRetry@ variants use-'Hasql.Transaction.Sessions.transactionNoRetry' under the hood; the-default variants use 'Hasql.Transaction.Sessions.transaction', which-automatically retries the body on PostgreSQL serialization conflicts.-The retry runs the entire 'Tx.Transaction' value more than once. This-is safe for pure-SQL bodies (each retry's prior partial work rolls back-inside Postgres before the next attempt begins) but should be-considered when the caller reasons about external observability.--Mocking: a mock 'Kiroku.Store.Effect.Store' interpreter cannot-meaningfully execute an opaque 'Tx.Transaction' against in-memory state-and is expected to reject 'Kiroku.Store.Effect.RunTransaction' /-'Kiroku.Store.Effect.RunTransactionNoRetry' at runtime.--}-module Kiroku.Store.Transaction (-    -- * Bare escape hatch+-- | Transactional combinators that compose a Kiroku event-store+-- operation with arbitrary additional @hasql-transaction@ work in a+-- single ACID transaction.+--+-- This module is the entry point for callers — most prominently the+-- @keiro@ projection layer — that need to atomically write to their own+-- SQL tables in the same transaction as an event append. Three ergonomic+-- levels are provided:+--+-- * 'runTransaction' / 'runTransactionNoRetry' — bare escape hatch: run+--   an opaque 'Tx.Transaction' against the store's connection pool.+--+-- * 'appendToStreamTx' — a 'Tx.Transaction'-flavored single-stream+--   append. Useful inside a 'runTransaction' block when the caller+--   wants full control over conflict handling.+--+-- * 'runTransactionAppending' / 'runTransactionAppendingNoRetry' — the+--   recommended high-level wrapper that combines a single-stream append+--   with the caller's 'Tx.Transaction' continuation in one atomic+--   transaction. This is the primary API for @keiro@-style projection+--   consumers.+--+-- Replay-history leases and one-stream repair guards are exposed separately by+-- 'Kiroku.Store.HistoryRetention' as @Tx.Transaction@ combinators. When composing+-- both in one body, acquire or renew the lease before taking the stream guard.+-- The transactional read deliberately bypasses the IO-only decode hook.+--+-- The @-NoRetry@ variants use+-- 'Hasql.Transaction.Sessions.transactionNoRetry' under the hood; the+-- default variants use 'Hasql.Transaction.Sessions.transaction', which+-- automatically retries the body on PostgreSQL serialization conflicts.+-- The retry runs the entire 'Tx.Transaction' value more than once. This+-- is safe for pure-SQL bodies (each retry's prior partial work rolls back+-- inside Postgres before the next attempt begins) but should be+-- considered when the caller reasons about external observability.+--+-- Mocking: a mock 'Kiroku.Store.Effect.Store' interpreter cannot+-- meaningfully execute an opaque 'Tx.Transaction' against in-memory state+-- and is expected to reject 'Kiroku.Store.Effect.RunTransaction' /+-- 'Kiroku.Store.Effect.RunTransactionNoRetry' at runtime.+module Kiroku.Store.Transaction+  ( -- * Bare escape hatch     runTransaction,     runTransactionNoRetry, @@ -61,7 +60,8 @@     -- * Append-precondition conflicts     AppendConflict (..),     appendConflictToStoreError,-) where+  )+where  import Control.Lens ((^.)) import Control.Monad.IO.Class (MonadIO, liftIO)@@ -79,295 +79,285 @@ import Kiroku.Store.Settings qualified as Settings import Kiroku.Store.Types -{- | Run an arbitrary 'Tx.Transaction' against the store's connection-pool inside a 'BEGIN'/'COMMIT' block.--The body may run /more than once/ if PostgreSQL raises a serialization-conflict — automatic retry is the default behavior of-'Hasql.Transaction.Sessions.transaction'. Use 'runTransactionNoRetry'-when retry is unacceptable (for example, the caller has been promised-exactly-once semantics that an outside observer of intermediate state-could break).--The body executes at 'Hasql.Transaction.Sessions.ReadCommitted'-isolation in 'Hasql.Transaction.Sessions.Write' mode, mirroring the-existing transactional sites in 'Kiroku.Store.Append.appendMultiStream'-and the hard-delete path.--Errors from both 'Tx.sql' scripts and 'Tx.statement' preserve PostgreSQL-SQLSTATE and message through 'UnexpectedServerError' or-'TransientTransactionFailure'. Event-ID duplicates retain 'DuplicateEvent';-opaque bodies do not fabricate stream context for other constraints.-Connection failures use 'ConnectionLost', and pool acquisition timeouts use-'PoolAcquisitionTimeout'. Unsupported driver errors retain 'ConnectionError'.-Calling 'Tx.condemn' inside the-body marks the transaction for rollback at commit; the call returns the-final value the body produced, but no SQL writes from this transaction-will be visible. Mock interpreters of 'Store' reject this constructor.--}+-- | Run an arbitrary 'Tx.Transaction' against the store's connection+-- pool inside a 'BEGIN'/'COMMIT' block.+--+-- The body may run /more than once/ if PostgreSQL raises a serialization+-- conflict — automatic retry is the default behavior of+-- 'Hasql.Transaction.Sessions.transaction'. Use 'runTransactionNoRetry'+-- when retry is unacceptable (for example, the caller has been promised+-- exactly-once semantics that an outside observer of intermediate state+-- could break).+--+-- The body executes at 'Hasql.Transaction.Sessions.ReadCommitted'+-- isolation in 'Hasql.Transaction.Sessions.Write' mode, mirroring the+-- existing transactional sites in 'Kiroku.Store.Append.appendMultiStream'+-- and the hard-delete path.+--+-- Errors from both 'Tx.sql' scripts and 'Tx.statement' preserve PostgreSQL+-- SQLSTATE and message through 'UnexpectedServerError' or+-- 'TransientTransactionFailure'. Event-ID duplicates retain 'DuplicateEvent';+-- opaque bodies do not fabricate stream context for other constraints.+-- Connection failures use 'ConnectionLost', and pool acquisition timeouts use+-- 'PoolAcquisitionTimeout'. Unsupported driver errors retain 'ConnectionError'.+-- Calling 'Tx.condemn' inside the+-- body marks the transaction for rollback at commit; the call returns the+-- final value the body produced, but no SQL writes from this transaction+-- will be visible. Mock interpreters of 'Store' reject this constructor. runTransaction ::-    (HasCallStack, Store :> es) =>-    Tx.Transaction a ->-    Eff es a+  (HasCallStack, Store :> es) =>+  Tx.Transaction a ->+  Eff es a runTransaction tx = send (RunTransaction tx) -{- | Like 'runTransaction' but uses-'Hasql.Transaction.Sessions.transactionNoRetry' — the body is executed-exactly once even on PostgreSQL serialization conflicts. Serialization-failures (@40001@) and deadlocks (@40P01@) surface as-'TransientTransactionFailure' with the original SQLSTATE and server message.-Use this entry point for a body that persistently raises those errors; the-retrying runner can keep re-executing it.--}+-- | Like 'runTransaction' but uses+-- 'Hasql.Transaction.Sessions.transactionNoRetry' — the body is executed+-- exactly once even on PostgreSQL serialization conflicts. Serialization+-- failures (@40001@) and deadlocks (@40P01@) surface as+-- 'TransientTransactionFailure' with the original SQLSTATE and server message.+-- Use this entry point for a body that persistently raises those errors; the+-- retrying runner can keep re-executing it. runTransactionNoRetry ::-    (HasCallStack, Store :> es) =>-    Tx.Transaction a ->-    Eff es a+  (HasCallStack, Store :> es) =>+  Tx.Transaction a ->+  Eff es a runTransactionNoRetry tx = send (RunTransactionNoRetry tx)  -- --------------------------------------------------------------------------- -- Tx-flavored append -- --------------------------------------------------------------------------- -{- | Generate UUIDv7s for any 'EventData' lacking a caller-supplied-'eventId' and return the prepared list, ready to feed into-'appendToStreamTx' inside a 'Tx.Transaction'.--'Tx.Transaction' has no 'MonadIO' instance, so UUID generation must-happen /before/ the transaction body runs. This helper is the IO-side-preparation step paired with 'appendToStreamTx'.--}+-- | Generate UUIDv7s for any 'EventData' lacking a caller-supplied+-- 'eventId' and return the prepared list, ready to feed into+-- 'appendToStreamTx' inside a 'Tx.Transaction'.+--+-- 'Tx.Transaction' has no 'MonadIO' instance, so UUID generation must+-- happen /before/ the transaction body runs. This helper is the IO-side+-- preparation step paired with 'appendToStreamTx'. prepareEventsIO :: (MonadIO m) => [EventData] -> m [PreparedEvent] prepareEventsIO = prepareEvents -{- | Append a batch of events to a single stream as part of a larger-'Tx.Transaction'.--The 'Tx.Transaction'-flavored counterpart to-'Kiroku.Store.Append.appendToStream'. It does /not/ enforce the @$all@-reserved-stream check — callers should validate the stream name before-entering the transaction body, or use 'runTransactionAppending', which-performs the rejection up front.--Lock-hold note: this append statement updates Kiroku's global @$all@-stream row and PostgreSQL holds that row lock until the surrounding-transaction commits or rolls back. Any statements the caller runs-after a successful 'appendToStreamTx' keep that global append lock-held, blocking other appends store-wide. Prefer the higher-level-'runTransactionAppending' wrappers unless you need custom transaction-shaping; when using this function directly, run any append-independent-work before calling it.--The combinator returns 'Either' instead of throwing because-'Tx.Transaction' has no exception channel. On 'Left' the caller decides-whether to call 'Tx.condemn' to roll back the transaction, branch-around the conflict, or commit (which would persist the rest of the-transaction body without the append).--The events list must be pre-prepared via 'prepareEventsIO' (which-generates UUIDv7s) and the @createdAt@ timestamp must be captured by-the caller prior to entering the transaction body.--Empty event lists are a programming mistake. This function returns-@'Left' ('EmptyAppendBatchConflict' …)@ without dispatching a statement;-the high-level @runTransactionAppending*@ wrappers return-@'Left' ('EmptyAppendBatch' …)@ before opening a transaction.--}+-- | Append a batch of events to a single stream as part of a larger+-- 'Tx.Transaction'.+--+-- The 'Tx.Transaction'-flavored counterpart to+-- 'Kiroku.Store.Append.appendToStream'. It does /not/ enforce the @$all@+-- reserved-stream check — callers should validate the stream name before+-- entering the transaction body, or use 'runTransactionAppending', which+-- performs the rejection up front.+--+-- Lock-hold note: this append statement updates Kiroku's global @$all@+-- stream row and PostgreSQL holds that row lock until the surrounding+-- transaction commits or rolls back. Any statements the caller runs+-- after a successful 'appendToStreamTx' keep that global append lock+-- held, blocking other appends store-wide. Prefer the higher-level+-- 'runTransactionAppending' wrappers unless you need custom transaction+-- shaping; when using this function directly, run any append-independent+-- work before calling it.+--+-- The combinator returns 'Either' instead of throwing because+-- 'Tx.Transaction' has no exception channel. On 'Left' the caller decides+-- whether to call 'Tx.condemn' to roll back the transaction, branch+-- around the conflict, or commit (which would persist the rest of the+-- transaction body without the append).+--+-- The events list must be pre-prepared via 'prepareEventsIO' (which+-- generates UUIDv7s) and the @createdAt@ timestamp must be captured by+-- the caller prior to entering the transaction body.+--+-- Empty event lists are a programming mistake. This function returns+-- @'Left' ('EmptyAppendBatchConflict' …)@ without dispatching a statement;+-- the high-level @runTransactionAppending*@ wrappers return+-- @'Left' ('EmptyAppendBatch' …)@ before opening a transaction. appendToStreamTx ::-    StreamName ->-    ExpectedVersion ->-    [PreparedEvent] ->-    UTCTime ->-    Tx.Transaction (Either AppendConflict AppendResult)+  StreamName ->+  ExpectedVersion ->+  [PreparedEvent] ->+  UTCTime ->+  Tx.Transaction (Either AppendConflict AppendResult) appendToStreamTx sn@(StreamName name) expected prepared now = do-    case prepared of-        [] ->-            pure (Left (EmptyAppendBatchConflict sn))-        _ -> do-            let params = buildAppendParams name now prepared-            mResult <- appendDispatchTx expected params-            pure $ case mResult of-                Just r -> Right r-                Nothing -> Left (emptyResultConflict sn expected)+  case prepared of+    [] ->+      pure (Left (EmptyAppendBatchConflict sn))+    _ -> do+      let params = buildAppendParams name now prepared+      mResult <- appendDispatchTx expected params+      pure $ case mResult of+        Just r -> Right r+        Nothing -> Left (emptyResultConflict sn expected)  -- --------------------------------------------------------------------------- -- Convenience wrapper -- --------------------------------------------------------------------------- -{- | Append events to a single stream and run an arbitrary-'Tx.Transaction' continuation in one ACID transaction.--This is the recommended API for callers who want to combine an event-append with their own SQL writes (canonical example: a projection row-that must land iff the append commits).--Behavior:--* If the target stream name is invalid (reserved @$all@ or longer than-  'Kiroku.Store.Error.maxStreamNameBytes'), the call rejects with-  @'Left'@ /before/ opening any transaction; the continuation is not-  invoked.-* If the event list is empty, the call rejects with-  @'Left' ('EmptyAppendBatch' …)@ /before/ opening any transaction; the-  continuation is not invoked.-* UUIDv7 ids are generated for events with a 'Nothing' 'eventId', and-  the current time is captured, before the transaction begins-  ('Tx.Transaction' has no 'MonadIO' so this prep cannot happen-  inside).-* Inside the transaction, 'appendToStreamTx' is called. On 'Left'-  (version conflict, missing stream, etc.), the transaction is-  'Tx.condemn'-ed, the continuation is /not/ run, and the call returns-  @'Left' storeErr@.-* On 'Right' the continuation runs with the resulting 'AppendResult'.-  If the continuation calls 'Tx.condemn', the transaction rolls back-  at commit time and no writes — including the event append — are-  visible.-* The transaction body may execute more than once if PostgreSQL raises-  a serialization conflict; use 'runTransactionAppendingNoRetry' to-  disable retry.--Lock-hold guidance:--* The append updates Kiroku's global @$all@ stream row. PostgreSQL-  holds that row lock until this transaction commits or rolls back.-* The continuation runs /after/ the append, so every SQL statement it-  executes extends the interval during which all other appends in the-  store are blocked on the @$all@ row.-* Keep the continuation minimal: pre-compute values before entering-  the transaction, avoid unbounded loops or per-row work that could-  have been batched, and never wait on external systems. A-  'Tx.Transaction' cannot perform arbitrary 'IO', but it can still-  contain many database round trips.-* Work that does not need the new 'AppendResult' should run before the-  append, either outside this wrapper or in a custom 'runTransaction'-  body that calls 'appendToStreamTx' last.--Connection-level failures from the @hasql-pool@ layer surface through-the surrounding @'Effectful.Error.Static.Error' 'StoreError'@ effect-('Kiroku.Store.Error.ConnectionError', 'ConnectionLost', etc.), not-through the returned 'Either' — that 'Either' is reserved for semantic-append-precondition conflicts (and the up-front reserved-stream-rejection).--This wrapper does /not/ apply the-'Kiroku.Store.Settings.enrichEvent' hook, because resolving the-'StoreSettings' requires reaching the live 'KirokuStore' handle and the-constraint set here is intentionally minimal. Callers running on a-'Kiroku.Store.Effect.Resource.KirokuStoreResource'-flavored stack and-wanting the hook should use 'runTransactionAppendingResource'; callers-on a bare stack can call 'enrichEventsIO' explicitly before invoking-this wrapper.--}+-- | Append events to a single stream and run an arbitrary+-- 'Tx.Transaction' continuation in one ACID transaction.+--+-- This is the recommended API for callers who want to combine an event+-- append with their own SQL writes (canonical example: a projection row+-- that must land iff the append commits).+--+-- Behavior:+--+-- * If the target stream name is invalid (reserved @$all@ or longer than+--   'Kiroku.Store.Error.maxStreamNameBytes'), the call rejects with+--   @'Left'@ /before/ opening any transaction; the continuation is not+--   invoked.+-- * If the event list is empty, the call rejects with+--   @'Left' ('EmptyAppendBatch' …)@ /before/ opening any transaction; the+--   continuation is not invoked.+-- * UUIDv7 ids are generated for events with a 'Nothing' 'eventId', and+--   the current time is captured, before the transaction begins+--   ('Tx.Transaction' has no 'MonadIO' so this prep cannot happen+--   inside).+-- * Inside the transaction, 'appendToStreamTx' is called. On 'Left'+--   (version conflict, missing stream, etc.), the transaction is+--   'Tx.condemn'-ed, the continuation is /not/ run, and the call returns+--   @'Left' storeErr@.+-- * On 'Right' the continuation runs with the resulting 'AppendResult'.+--   If the continuation calls 'Tx.condemn', the transaction rolls back+--   at commit time and no writes — including the event append — are+--   visible.+-- * The transaction body may execute more than once if PostgreSQL raises+--   a serialization conflict; use 'runTransactionAppendingNoRetry' to+--   disable retry.+--+-- Lock-hold guidance:+--+-- * The append updates Kiroku's global @$all@ stream row. PostgreSQL+--   holds that row lock until this transaction commits or rolls back.+-- * The continuation runs /after/ the append, so every SQL statement it+--   executes extends the interval during which all other appends in the+--   store are blocked on the @$all@ row.+-- * Keep the continuation minimal: pre-compute values before entering+--   the transaction, avoid unbounded loops or per-row work that could+--   have been batched, and never wait on external systems. A+--   'Tx.Transaction' cannot perform arbitrary 'IO', but it can still+--   contain many database round trips.+-- * Work that does not need the new 'AppendResult' should run before the+--   append, either outside this wrapper or in a custom 'runTransaction'+--   body that calls 'appendToStreamTx' last.+--+-- Connection-level failures from the @hasql-pool@ layer surface through+-- the surrounding @'Effectful.Error.Static.Error' 'StoreError'@ effect+-- ('Kiroku.Store.Error.ConnectionError', 'ConnectionLost', etc.), not+-- through the returned 'Either' — that 'Either' is reserved for semantic+-- append-precondition conflicts (and the up-front reserved-stream+-- rejection).+--+-- This wrapper does /not/ apply the+-- 'Kiroku.Store.Settings.enrichEvent' hook, because resolving the+-- 'StoreSettings' requires reaching the live 'KirokuStore' handle and the+-- constraint set here is intentionally minimal. Callers running on a+-- 'Kiroku.Store.Effect.Resource.KirokuStoreResource'-flavored stack and+-- wanting the hook should use 'runTransactionAppendingResource'; callers+-- on a bare stack can call 'enrichEventsIO' explicitly before invoking+-- this wrapper. runTransactionAppending ::-    (HasCallStack, IOE :> es, Store :> es) =>-    StreamName ->-    ExpectedVersion ->-    [EventData] ->-    (AppendResult -> Tx.Transaction a) ->-    Eff es (Either StoreError a)+  (HasCallStack, IOE :> es, Store :> es) =>+  StreamName ->+  ExpectedVersion ->+  [EventData] ->+  (AppendResult -> Tx.Transaction a) ->+  Eff es (Either StoreError a) runTransactionAppending = runTransactionAppendingWith RunTransaction -{- | Like 'runTransactionAppending' but uses-'Hasql.Transaction.Sessions.transactionNoRetry' under the hood — the-transaction body runs exactly once even on PostgreSQL serialization-conflicts.--Like 'runTransactionAppending', this wrapper bypasses the-'Kiroku.Store.Settings.enrichEvent' hook; see that function's-Haddock for guidance on hooking under different stacks and for the-@$all@ lock-hold warning that applies equally here.--}+-- | Like 'runTransactionAppending' but uses+-- 'Hasql.Transaction.Sessions.transactionNoRetry' under the hood — the+-- transaction body runs exactly once even on PostgreSQL serialization+-- conflicts.+--+-- Like 'runTransactionAppending', this wrapper bypasses the+-- 'Kiroku.Store.Settings.enrichEvent' hook; see that function's+-- Haddock for guidance on hooking under different stacks and for the+-- @$all@ lock-hold warning that applies equally here. runTransactionAppendingNoRetry ::-    (HasCallStack, IOE :> es, Store :> es) =>-    StreamName ->-    ExpectedVersion ->-    [EventData] ->-    (AppendResult -> Tx.Transaction a) ->-    Eff es (Either StoreError a)+  (HasCallStack, IOE :> es, Store :> es) =>+  StreamName ->+  ExpectedVersion ->+  [EventData] ->+  (AppendResult -> Tx.Transaction a) ->+  Eff es (Either StoreError a) runTransactionAppendingNoRetry = runTransactionAppendingWith RunTransactionNoRetry -{- | Hook-aware variant of 'runTransactionAppending' for callers running-under a 'Kiroku.Store.Effect.Resource.KirokuStoreResource' effect.--Behaves exactly like 'runTransactionAppending' except that the-'Kiroku.Store.Settings.enrichEvent' hook (if any) is applied to every-'EventData' before the transaction body opens. The hook fires in-'IO', /outside/ the transaction; subsequent retries of a serialization-conflict do not re-invoke it (the enriched events are already prepared).--This is the recommended path for callers — most prominently the-@keiro@ projection layer — that want trace-context or PII-injection-hooks to fire uniformly across both direct @'appendToStream'@ calls-and transactional append+projection sites. The continuation still runs-after the append; see 'runTransactionAppending' for the @$all@-lock-hold warning and keep resource-aware continuations just as small.--}+-- | Hook-aware variant of 'runTransactionAppending' for callers running+-- under a 'Kiroku.Store.Effect.Resource.KirokuStoreResource' effect.+--+-- Behaves exactly like 'runTransactionAppending' except that the+-- 'Kiroku.Store.Settings.enrichEvent' hook (if any) is applied to every+-- 'EventData' before the transaction body opens. The hook fires in+-- 'IO', /outside/ the transaction; subsequent retries of a serialization+-- conflict do not re-invoke it (the enriched events are already prepared).+--+-- This is the recommended path for callers — most prominently the+-- @keiro@ projection layer — that want trace-context or PII-injection+-- hooks to fire uniformly across both direct @'appendToStream'@ calls+-- and transactional append+projection sites. The continuation still runs+-- after the append; see 'runTransactionAppending' for the @$all@+-- lock-hold warning and keep resource-aware continuations just as small. runTransactionAppendingResource ::-    (HasCallStack, IOE :> es, KirokuStoreResource :> es, Store :> es) =>-    StreamName ->-    ExpectedVersion ->-    [EventData] ->-    (AppendResult -> Tx.Transaction a) ->-    Eff es (Either StoreError a)+  (HasCallStack, IOE :> es, KirokuStoreResource :> es, Store :> es) =>+  StreamName ->+  ExpectedVersion ->+  [EventData] ->+  (AppendResult -> Tx.Transaction a) ->+  Eff es (Either StoreError a) runTransactionAppendingResource sn expected events k = do-    store <- getKirokuStore-    events' <- liftIO $ enrichEventsIO store events-    runTransactionAppendingWith RunTransaction sn expected events' k+  store <- getKirokuStore+  events' <- liftIO $ enrichEventsIO store events+  runTransactionAppendingWith RunTransaction sn expected events' k -{- | Like 'runTransactionAppendingResource' but uses-'Hasql.Transaction.Sessions.transactionNoRetry' under the hood. The-@$all@ lock-hold guidance from 'runTransactionAppending' applies-unchanged.--}+-- | Like 'runTransactionAppendingResource' but uses+-- 'Hasql.Transaction.Sessions.transactionNoRetry' under the hood. The+-- @$all@ lock-hold guidance from 'runTransactionAppending' applies+-- unchanged. runTransactionAppendingResourceNoRetry ::-    (HasCallStack, IOE :> es, KirokuStoreResource :> es, Store :> es) =>-    StreamName ->-    ExpectedVersion ->-    [EventData] ->-    (AppendResult -> Tx.Transaction a) ->-    Eff es (Either StoreError a)+  (HasCallStack, IOE :> es, KirokuStoreResource :> es, Store :> es) =>+  StreamName ->+  ExpectedVersion ->+  [EventData] ->+  (AppendResult -> Tx.Transaction a) ->+  Eff es (Either StoreError a) runTransactionAppendingResourceNoRetry sn expected events k = do-    store <- getKirokuStore-    events' <- liftIO $ enrichEventsIO store events-    runTransactionAppendingWith RunTransactionNoRetry sn expected events' k--{- | Apply the store's 'Kiroku.Store.Settings.enrichEvent' hook (if any)-to a list of 'EventData' values.--Use this when calling 'appendToStreamTx' directly — that lower-level-API does not see the interpreter and therefore does not run the hook-on its own. Calling 'enrichEventsIO' yourself before-'prepareEventsIO' restores hook coverage for that path.+  store <- getKirokuStore+  events' <- liftIO $ enrichEventsIO store events+  runTransactionAppendingWith RunTransactionNoRetry sn expected events' k -When 'Kiroku.Store.Settings.enrichEvent' is 'Nothing' the function-returns the input list unchanged with no traversal.--}+-- | Apply the store's 'Kiroku.Store.Settings.enrichEvent' hook (if any)+-- to a list of 'EventData' values.+--+-- Use this when calling 'appendToStreamTx' directly — that lower-level+-- API does not see the interpreter and therefore does not run the hook+-- on its own. Calling 'enrichEventsIO' yourself before+-- 'prepareEventsIO' restores hook coverage for that path.+--+-- When 'Kiroku.Store.Settings.enrichEvent' is 'Nothing' the function+-- returns the input list unchanged with no traversal. enrichEventsIO :: KirokuStore -> [EventData] -> IO [EventData] enrichEventsIO store = Settings.enrichEvents (store ^. #storeSettings) -{- | Shared implementation for 'runTransactionAppending' and-'runTransactionAppendingNoRetry'. The only difference between the-two wrappers is which 'Store' constructor is sent.--}+-- | Shared implementation for 'runTransactionAppending' and+-- 'runTransactionAppendingNoRetry'. The only difference between the+-- two wrappers is which 'Store' constructor is sent. runTransactionAppendingWith ::-    (IOE :> es, Store :> es) =>-    (forall x. Tx.Transaction x -> Store (Eff es) x) ->-    StreamName ->-    ExpectedVersion ->-    [EventData] ->-    (AppendResult -> Tx.Transaction a) ->-    Eff es (Either StoreError a)+  (IOE :> es, Store :> es) =>+  (forall x. Tx.Transaction x -> Store (Eff es) x) ->+  StreamName ->+  ExpectedVersion ->+  [EventData] ->+  (AppendResult -> Tx.Transaction a) ->+  Eff es (Either StoreError a) runTransactionAppendingWith ctor sn@(StreamName _) expected events k-    | Left err <- validateStreamName sn = pure (Left err)-    | null events = pure (Left (EmptyAppendBatch sn))-    | otherwise = do-        prepared <- prepareEventsIO events-        now <- liftIO getCurrentTime-        let body = do-                outcome <- appendToStreamTx sn expected prepared now-                case outcome of-                    Left conflict ->-                        Tx.condemn $> Left (appendConflictToStoreError conflict)-                    Right ar ->-                        Right <$> k ar-        send (ctor body)+  | Left err <- validateStreamName sn = pure (Left err)+  | null events = pure (Left (EmptyAppendBatch sn))+  | otherwise = do+      prepared <- prepareEventsIO events+      now <- liftIO getCurrentTime+      let body = do+            outcome <- appendToStreamTx sn expected prepared now+            case outcome of+              Left conflict ->+                Tx.condemn $> Left (appendConflictToStoreError conflict)+              Right ar ->+                Right <$> k ar+      send (ctor body)
src/Kiroku/Store/Types.hs view
@@ -1,5 +1,5 @@-module Kiroku.Store.Types (-    StreamName (..),+module Kiroku.Store.Types+  ( StreamName (..),     StreamId (..),     EventId (..),     EventType (..),@@ -19,7 +19,8 @@     BrowsePageSizeError (..),     mkBrowsePageSize,     browsePageSizeValue,-) where+  )+where  import Data.Aeson (Value) import Data.Int (Int32, Int64)@@ -31,330 +32,294 @@  -- Stream identification -{- | The human-readable name of a stream (e.g. @"orders-1"@). Stream names-are unique per store; the substring before the first @-@ is the-stream's category, used by 'CategoryName'-targeted reads and-subscriptions.--All names except @$all@ are ordinary application stream names. Names-such as @"invoice-payment"@, @"$invoice-payment"@, names containing-commas, and names without a dash are accepted by the store. The exact-name @$all@ is reserved for the global read stream and mutating APIs-reject it with 'Kiroku.Store.Error.ReservedStreamName'; use-'Kiroku.Store.Read.readAllForward' or-'Kiroku.Store.Read.readAllBackward' to read the global sequence.--}+-- | The human-readable name of a stream (e.g. @"orders-1"@). Stream names+-- are unique per store; the substring before the first @-@ is the+-- stream's category, used by 'CategoryName'-targeted reads and+-- subscriptions.+--+-- All names except @$all@ are ordinary application stream names. Names+-- such as @"invoice-payment"@, @"$invoice-payment"@, names containing+-- commas, and names without a dash are accepted by the store. The exact+-- name @$all@ is reserved for the global read stream and mutating APIs+-- reject it with 'Kiroku.Store.Error.ReservedStreamName'; use+-- 'Kiroku.Store.Read.readAllForward' or+-- 'Kiroku.Store.Read.readAllBackward' to read the global sequence. newtype StreamName = StreamName Text-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic) -{- | The database surrogate id of a stream. Stable for the lifetime of-the stream row; reused by 'hardDeleteStream' followed by re-creation-only at the database's discretion. Not generally used by application-code — prefer 'StreamName' for identification.--}+-- | The database surrogate id of a stream. Stable for the lifetime of+-- the stream row; reused by 'hardDeleteStream' followed by re-creation+-- only at the database's discretion. Not generally used by application+-- code — prefer 'StreamName' for identification. newtype StreamId = StreamId Int64-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic)  -- Event identification -{- | The store's identifier for an event. Generated by the store as a-UUIDv7 unless the caller supplies one in 'EventData.eventId' (used for-idempotent retries — see 'appendToStream').--The 'Ord' instance compares by raw UUID byte order, /not/ by-generation time. UUIDv7 is timestamp-prefixed in its high bytes, so-byte order roughly matches generation order, but two ids generated in-the same millisecond can compare in either direction. For-time-ordering use 'RecordedEvent.globalPosition'.--}+-- | The store's identifier for an event. Generated by the store as a+-- UUIDv7 unless the caller supplies one in 'EventData.eventId' (used for+-- idempotent retries — see 'appendToStream').+--+-- The 'Ord' instance compares by raw UUID byte order, /not/ by+-- generation time. UUIDv7 is timestamp-prefixed in its high bytes, so+-- byte order roughly matches generation order, but two ids generated in+-- the same millisecond can compare in either direction. For+-- time-ordering use 'RecordedEvent.globalPosition'. newtype EventId = EventId UUID-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic) -{- | The application-level discriminator for an event payload (e.g.-@"OrderCreated"@). Free-form text; the store does not interpret it.--}+-- | The application-level discriminator for an event payload (e.g.+-- @"OrderCreated"@). Free-form text; the store does not interpret it. newtype EventType = EventType Text-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic)  -- Positions -{- | Monotonic per-stream version counter. Starts at 0 for a freshly-created empty stream and increments by one per appended event. After-@N@ events, the stream's version is @N@; the @i@-th event has-@streamVersion = i@ (one-indexed within a stream).--}+-- | Monotonic per-stream version counter. Starts at 0 for a freshly+-- created empty stream and increments by one per appended event. After+-- @N@ events, the stream's version is @N@; the @i@-th event has+-- @streamVersion = i@ (one-indexed within a stream). newtype StreamVersion = StreamVersion Int64-    deriving stock (Eq, Ord, Show, Generic)+  deriving stock (Eq, Ord, Show, Generic) -{- | Global position of an event in the @$all@ ordering, shared across all-streams. __Contract:__ strictly increasing per successful append, and totally-ordered — nothing more. Treat values as opaque cursors: construct a-'GlobalPosition' only from @0@ (the beginning of the store) or from a value-previously returned by this store; never derive one by arithmetic, and never-assume positions are dense (@pos + 1@ may not exist). The current-implementation happens to assign contiguous positions (see EP-1's audit), but-contiguity is an implementation detail, not an API guarantee, and is the part-that would change under a sequence-based allocation scheme — see-docs/architecture/global-position-migration-path.md. Used by the @$all@ read-path and by subscriptions as the cursor key.--}+-- | Global position of an event in the @$all@ ordering, shared across all+-- streams. __Contract:__ strictly increasing per successful append, and totally+-- ordered — nothing more. Treat values as opaque cursors: construct a+-- 'GlobalPosition' only from @0@ (the beginning of the store) or from a value+-- previously returned by this store; never derive one by arithmetic, and never+-- assume positions are dense (@pos + 1@ may not exist). The current+-- implementation happens to assign contiguous positions (see EP-1's audit), but+-- contiguity is an implementation detail, not an API guarantee, and is the part+-- that would change under a sequence-based allocation scheme — see+-- docs/architecture/global-position-migration-path.md. Used by the @$all@ read+-- path and by subscriptions as the cursor key. newtype GlobalPosition = GlobalPosition Int64-    deriving stock (Eq, Ord, Show, Generic)--{- | Version expectations for appends.+  deriving stock (Eq, Ord, Show, Generic) -The store enforces optimistic concurrency at the stream level: each-'appendToStream' call carries an 'ExpectedVersion' that the-@stream_version@ row must satisfy at commit time. A mismatch produces-'Kiroku.Store.Error.WrongExpectedVersion' or-'Kiroku.Store.Error.StreamAlreadyExists' depending on the constructor.--}+-- | Version expectations for appends.+--+-- The store enforces optimistic concurrency at the stream level: each+-- 'appendToStream' call carries an 'ExpectedVersion' that the+-- @stream_version@ row must satisfy at commit time. A mismatch produces+-- 'Kiroku.Store.Error.WrongExpectedVersion' or+-- 'Kiroku.Store.Error.StreamAlreadyExists' depending on the constructor. data ExpectedVersion-    = {- | The stream must not exist yet. Creates the stream and appends-      the events. Fails with-      'Kiroku.Store.Error.StreamAlreadyExists' if the stream is-      already present, including soft-deleted streams. Use this for-      aggregate creation.-      -}-      NoStream-    | {- | The stream must already exist (and not be soft-deleted), but-      its current version does not matter. Fails with-      'Kiroku.Store.Error.StreamNotFound' if the stream is missing-      or soft-deleted.-      -}-      StreamExists-    | {- | The stream's current version must match exactly. Use this-      when the caller has read the stream and wants to append only-      if no concurrent writer has advanced it. Fails with-      'Kiroku.Store.Error.WrongExpectedVersion' on mismatch.-      -}-      ExactVersion !StreamVersion-    | {- | Upsert: create the stream if missing, append otherwise. Does-      not check the existing version. Maps to a single-      @INSERT ... ON CONFLICT DO UPDATE@ at the SQL layer. The name-      is historical — \"create-or-append\" is the working-      description.-      -}-      AnyVersion-    deriving stock (Eq, Show, Generic)--{- | What the caller provides when appending events.+  = -- | The stream must not exist yet. Creates the stream and appends+    --       the events. Fails with+    --       'Kiroku.Store.Error.StreamAlreadyExists' if the stream is+    --       already present, including soft-deleted streams. Use this for+    --       aggregate creation.+    NoStream+  | -- | The stream must already exist (and not be soft-deleted), but+    --       its current version does not matter. Fails with+    --       'Kiroku.Store.Error.StreamNotFound' if the stream is missing+    --       or soft-deleted.+    StreamExists+  | -- | The stream's current version must match exactly. Use this+    --       when the caller has read the stream and wants to append only+    --       if no concurrent writer has advanced it. Fails with+    --       'Kiroku.Store.Error.WrongExpectedVersion' on mismatch.+    ExactVersion !StreamVersion+  | -- | Upsert: create the stream if missing, append otherwise. Does+    --       not check the existing version. Maps to a single+    --       @INSERT ... ON CONFLICT DO UPDATE@ at the SQL layer. The name+    --       is historical — \"create-or-append\" is the working+    --       description.+    AnyVersion+  deriving stock (Eq, Show, Generic) -Field accessors are intended to be used through @generic-lens@ labels-(@event ^. #eventType@); the cabal common stanza enables-@DuplicateRecordFields@ and @OverloadedLabels@ so the same field names-can appear on both 'EventData' and 'RecordedEvent'.--}+-- | What the caller provides when appending events.+--+-- Field accessors are intended to be used through @generic-lens@ labels+-- (@event ^. #eventType@); the cabal common stanza enables+-- @DuplicateRecordFields@ and @OverloadedLabels@ so the same field names+-- can appear on both 'EventData' and 'RecordedEvent'. data EventData = EventData-    { eventId :: !(Maybe EventId)-    {- ^ 'Nothing' lets the store generate a UUIDv7 at append time. 'Just'-    lets the caller supply an id, primarily to make appends idempotent-    across transient failures: a retry with the same id either succeeds-    (because the previous attempt did not commit) or surfaces as-    'Kiroku.Store.Error.DuplicateEvent' (because it did).-    -}-    , eventType :: !EventType-    {- ^ Application-level discriminator. Free-form; the store does not-    interpret it but it is indexed for queries.-    -}-    , payload :: !Value-    -- ^ The event's JSONB payload.-    , metadata :: !(Maybe Value)-    {- ^ Optional JSONB metadata. Often used for tenant ids, trace-    contexts, and correlation data.-    -}-    , causationId :: !(Maybe UUID)-    {- ^ Optional id of the event that caused this one (the immediate-    cause). Used to reconstruct chains.-    -}-    , correlationId :: !(Maybe UUID)-    {- ^ Optional correlation id (the saga / transaction this event-    belongs to). Distinct from @causationId@.-    -}-    }-    deriving stock (Show, Generic)--{- | Stream metadata returned by 'getStream'.+  { -- | 'Nothing' lets the store generate a UUIDv7 at append time. 'Just'+    --     lets the caller supply an id, primarily to make appends idempotent+    --     across transient failures: a retry with the same id either succeeds+    --     (because the previous attempt did not commit) or surfaces as+    --     'Kiroku.Store.Error.DuplicateEvent' (because it did).+    eventId :: !(Maybe EventId),+    -- | Application-level discriminator. Free-form; the store does not+    --     interpret it but it is indexed for queries.+    eventType :: !EventType,+    -- | The event's JSONB payload.+    payload :: !Value,+    -- | Optional JSONB metadata. Often used for tenant ids, trace+    --     contexts, and correlation data.+    metadata :: !(Maybe Value),+    -- | Optional id of the event that caused this one (the immediate+    --     cause). Used to reconstruct chains.+    causationId :: !(Maybe UUID),+    -- | Optional correlation id (the saga / transaction this event+    --     belongs to). Distinct from @causationId@.+    correlationId :: !(Maybe UUID)+  }+  deriving stock (Show, Generic) -The 'id' field shadows 'Prelude.id' under 'DuplicateRecordFields'; access-it via @info ^. #id@ to disambiguate. 'Prelude.id' remains available as-@Data.Function.id@ if needed.--}+-- | Stream metadata returned by 'getStream'.+--+-- The 'id' field shadows 'Prelude.id' under 'DuplicateRecordFields'; access+-- it via @info ^. #id@ to disambiguate. 'Prelude.id' remains available as+-- @Data.Function.id@ if needed. data StreamInfo = StreamInfo-    { id :: !StreamId-    {- ^ The stream's database surrogate id. Stable for the row's-    lifetime.-    -}-    , name :: !StreamName-    -- ^ The stream's human-readable name as supplied at creation.-    , version :: !StreamVersion-    -- ^ The stream's current version (count of appended events).-    , createdAt :: !UTCTime-    -- ^ When the stream row was first inserted.-    , deletedAt :: !(Maybe UTCTime)-    {- ^ 'Just' the soft-delete timestamp if the stream has been-    soft-deleted, 'Nothing' otherwise. Hard-deleted streams do not-    appear in 'getStream' at all.-    -}-    , truncateBefore :: !StreamVersion-    {- ^ The logical truncate-before marker. Ordered per-stream reads-    ('Kiroku.Store.Read.readStreamForward' / 'readStreamBackward') return only-    events whose per-stream version is >= this value. 0 (the default) keeps the-    whole stream. Does not affect the @$all@ global log, category reads, or-    subscriptions. Set with 'Kiroku.Store.Lifecycle.setStreamTruncateBefore'.-    -}-    }-    deriving stock (Eq, Show, Generic)--{- | What comes back from reading events.+  { -- | The stream's database surrogate id. Stable for the row's+    --     lifetime.+    id :: !StreamId,+    -- | The stream's human-readable name as supplied at creation.+    name :: !StreamName,+    -- | The stream's current version (count of appended events).+    version :: !StreamVersion,+    -- | When the stream row was first inserted.+    createdAt :: !UTCTime,+    -- | 'Just' the soft-delete timestamp if the stream has been+    --     soft-deleted, 'Nothing' otherwise. Hard-deleted streams do not+    --     appear in 'getStream' at all.+    deletedAt :: !(Maybe UTCTime),+    -- | The logical truncate-before marker. Ordered per-stream reads+    --     ('Kiroku.Store.Read.readStreamForward' / 'readStreamBackward') return only+    --     events whose per-stream version is >= this value. 0 (the default) keeps the+    --     whole stream. Does not affect the @$all@ global log, category reads, or+    --     subscriptions. Set with 'Kiroku.Store.Lifecycle.setStreamTruncateBefore'.+    truncateBefore :: !StreamVersion+  }+  deriving stock (Eq, Show, Generic) -For events read from their /source/ stream, @streamVersion@ and-@originalVersion@ agree, and @originalStreamId@ matches the stream being-read. For events read from a /linked/ target stream (one populated by-'linkToStream'), @streamVersion@ is the target's version (the position-of the link in the target stream) while @originalVersion@ is the-source's version (where the event lived when first appended) and-@originalStreamId@ is the source's id. Subscriptions and category reads-return events at their source positions.--}+-- | What comes back from reading events.+--+-- For events read from their /source/ stream, @streamVersion@ and+-- @originalVersion@ agree, and @originalStreamId@ matches the stream being+-- read. For events read from a /linked/ target stream (one populated by+-- 'linkToStream'), @streamVersion@ is the target's version (the position+-- of the link in the target stream) while @originalVersion@ is the+-- source's version (where the event lived when first appended) and+-- @originalStreamId@ is the source's id. Subscriptions and category reads+-- return events at their source positions. data RecordedEvent = RecordedEvent-    { eventId :: !EventId-    -- ^ The event's stable id (UUIDv7 by default).-    , eventType :: !EventType-    -- ^ The application-level type discriminator.-    , streamVersion :: !StreamVersion-    {- ^ Position in the stream being read. For source-stream reads this-    equals @originalVersion@; for linked-target reads it is the-    link's position in the target.-    -}-    , globalPosition :: !GlobalPosition-    {- ^ Position in the global @$all@ sequence at original-append time.-    Stable across links; used as the subscription cursor.-    -}-    , originalStreamId :: !StreamId-    {- ^ The surrogate id of the stream the event was first appended to. For-    fan-in reads (@$all@, categories, causation\/correlation queries,-    subscriptions) this is the only stream identifier on the event; resolve a-    batch of these to human-readable 'StreamName's with-    'Kiroku.Store.Read.lookupStreamNames'.-    -}-    , originalVersion :: !StreamVersion-    -- ^ The position the event was assigned in its source stream.-    , payload :: !Value-    , metadata :: !(Maybe Value)-    , causationId :: !(Maybe UUID)-    , correlationId :: !(Maybe UUID)-    , createdAt :: !UTCTime-    }-    deriving stock (Eq, Show, Generic)+  { -- | The event's stable id (UUIDv7 by default).+    eventId :: !EventId,+    -- | The application-level type discriminator.+    eventType :: !EventType,+    -- | Position in the stream being read. For source-stream reads this+    --     equals @originalVersion@; for linked-target reads it is the+    --     link's position in the target.+    streamVersion :: !StreamVersion,+    -- | Position in the global @$all@ sequence at original-append time.+    --     Stable across links; used as the subscription cursor.+    globalPosition :: !GlobalPosition,+    -- | The surrogate id of the stream the event was first appended to. For+    --     fan-in reads (@$all@, categories, causation\/correlation queries,+    --     subscriptions) this is the only stream identifier on the event; resolve a+    --     batch of these to human-readable 'StreamName's with+    --     'Kiroku.Store.Read.lookupStreamNames'.+    originalStreamId :: !StreamId,+    -- | The position the event was assigned in its source stream.+    originalVersion :: !StreamVersion,+    payload :: !Value,+    metadata :: !(Maybe Value),+    causationId :: !(Maybe UUID),+    correlationId :: !(Maybe UUID),+    createdAt :: !UTCTime+  }+  deriving stock (Eq, Show, Generic)  -- | Result of a successful 'appendToStream' or 'appendMultiStream' call. data AppendResult = AppendResult-    { streamId :: !StreamId-    {- ^ The id of the stream that was appended to (created if it did-    not exist).-    -}-    , streamVersion :: !StreamVersion-    {- ^ The stream's version after the append (the position of the-    /last/ event in the batch).-    -}-    , globalPosition :: !GlobalPosition-    -- ^ The global position assigned to the /last/ event in the batch.-    }-    deriving stock (Eq, Show, Generic)--{- | Result of a successful 'linkToStream' call.+  { -- | The id of the stream that was appended to (created if it did+    --     not exist).+    streamId :: !StreamId,+    -- | The stream's version after the append (the position of the+    --     /last/ event in the batch).+    streamVersion :: !StreamVersion,+    -- | The global position assigned to the /last/ event in the batch.+    globalPosition :: !GlobalPosition+  }+  deriving stock (Eq, Show, Generic) -There is no @globalPosition@ field because linking does not advance the-global counter — the linked events keep their original-'globalPosition'. To enumerate the new positions of linked events, read-the target stream after the call.--}+-- | Result of a successful 'linkToStream' call.+--+-- There is no @globalPosition@ field because linking does not advance the+-- global counter — the linked events keep their original+-- 'globalPosition'. To enumerate the new positions of linked events, read+-- the target stream after the call. data LinkResult = LinkResult-    { streamId :: !StreamId-    -- ^ The id of the link target (created if it did not exist).-    , streamVersion :: !StreamVersion-    {- ^ The target's version after the link (position of the /last/-    linked event in the target).-    -}-    }-    deriving stock (Eq, Show, Generic)+  { -- | The id of the link target (created if it did not exist).+    streamId :: !StreamId,+    -- | The target's version after the link (position of the /last/+    --     linked event in the target).+    streamVersion :: !StreamVersion+  }+  deriving stock (Eq, Show, Generic) -{- | The category prefix of a stream name (the part before the first-@-@). E.g., @StreamName "orders-1"@ has @CategoryName "orders"@. Used by-'readCategory' and category-targeted subscriptions to fan-in events from-many streams that share a prefix.--}+-- | The category prefix of a stream name (the part before the first+-- @-@). E.g., @StreamName "orders-1"@ has @CategoryName "orders"@. Used by+-- 'readCategory' and category-targeted subscriptions to fan-in events from+-- many streams that share a prefix. newtype CategoryName = CategoryName Text-    deriving stock (Eq, Ord, Show, Generic)--{- | The category of a stream name: the substring before the first @-@. This-is the canonical Haskell mirror of the-@streams.category GENERATED ALWAYS AS split_part(stream_name,'-',1)@ column and-of the category rule used by 'Kiroku.Store.Read.readCategory' and-category-targeted subscriptions. A category never contains @-@, so this is-exact.-->>> categoryName (StreamName "orders-1")-CategoryName "orders"--A name without a dash is its own category:+  deriving stock (Eq, Ord, Show, Generic) ->>> categoryName (StreamName "singleton")-CategoryName "singleton"--}+-- | The category of a stream name: the substring before the first @-@. This+-- is the canonical Haskell mirror of the+-- @streams.category GENERATED ALWAYS AS split_part(stream_name,'-',1)@ column and+-- of the category rule used by 'Kiroku.Store.Read.readCategory' and+-- category-targeted subscriptions. A category never contains @-@, so this is+-- exact.+--+-- >>> categoryName (StreamName "orders-1")+-- CategoryName "orders"+--+-- A name without a dash is its own category:+--+-- >>> categoryName (StreamName "singleton")+-- CategoryName "singleton" categoryName :: StreamName -> CategoryName categoryName (StreamName t) = CategoryName (Text.takeWhile (/= '-') t) -{- | Build the stream name for a category and an id segment, i.e.-@\<category\>-\<id\>@. The dual of 'categoryName': for any dash-free category,-@'categoryName' ('streamNameInCategory' cat seg) == cat@.--This is permissive — it does not reject a hyphenated 'CategoryName' or an empty-segment (the store accepts such names); opinionated validation is the caller's-concern.-->>> streamNameInCategory (CategoryName "orders") "1"-StreamName "orders-1"--}+-- | Build the stream name for a category and an id segment, i.e.+-- @\<category\>-\<id\>@. The dual of 'categoryName': for any dash-free category,+-- @'categoryName' ('streamNameInCategory' cat seg) == cat@.+--+-- This is permissive — it does not reject a hyphenated 'CategoryName' or an empty+-- segment (the store accepts such names); opinionated validation is the caller's+-- concern.+--+-- >>> streamNameInCategory (CategoryName "orders") "1"+-- StreamName "orders-1" streamNameInCategory :: CategoryName -> Text -> StreamName streamNameInCategory (CategoryName cat) seg = StreamName (cat <> "-" <> seg) -{- | Filter passed to the 'Kiroku.Store.Effect.FindEvents' constructor and the-public smart constructors in "Kiroku.Store.Causation". Each constructor maps-to one SQL statement in "Kiroku.Store.SQL".--The sum is closed: mock interpreters that implement 'Kiroku.Store.Effect.Store'-can pattern-match exhaustively. Adding a new filter is a breaking change for-downstream interpreters and is captured by GHC's exhaustiveness check.--}+-- | Filter passed to the 'Kiroku.Store.Effect.FindEvents' constructor and the+-- public smart constructors in "Kiroku.Store.Causation". Each constructor maps+-- to one SQL statement in "Kiroku.Store.SQL".+--+-- The sum is closed: mock interpreters that implement 'Kiroku.Store.Effect.Store'+-- can pattern-match exhaustively. Adding a new filter is a breaking change for+-- downstream interpreters and is captured by GHC's exhaustiveness check. data EventFilter-    = -- | Match every event whose @correlation_id@ equals the supplied UUID.-      FilterCorrelation !UUID-    | {- | Match the seed event and every event whose @causation_id@ chain-      walks back to it. The seed event itself is included as the depth-0-      row when it exists.-      -}-      FilterCausationDescendants !EventId-    | {- | Match the seed event and every event reachable by walking-      @causation_id@ upward from the seed. The seed itself is included.-      -}-      FilterCausationAncestors !EventId-    deriving stock (Eq, Show, Generic)+  = -- | Match every event whose @correlation_id@ equals the supplied UUID.+    FilterCorrelation !UUID+  | -- | Match the seed event and every event whose @causation_id@ chain+    --       walks back to it. The seed event itself is included as the depth-0+    --       row when it exists.+    FilterCausationDescendants !EventId+  | -- | Match the seed event and every event reachable by walking+    --       @causation_id@ upward from the seed. The seed itself is included.+    FilterCausationAncestors !EventId+  deriving stock (Eq, Show, Generic) -{- | Validated catalog page size: 1–1001, including one HTTP over-fetch row.-The constructor is private; arithmetic on untrusted limits cannot overflow.--}+-- | Validated catalog page size: 1–1001, including one HTTP over-fetch row.+-- The constructor is private; arithmetic on untrusted limits cannot overflow. newtype BrowsePageSize = BrowsePageSize Int32-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  data BrowsePageSizeError = InvalidBrowsePageSize !Int-    deriving stock (Eq, Show)+  deriving stock (Eq, Show)  mkBrowsePageSize :: Int -> Either BrowsePageSizeError BrowsePageSize mkBrowsePageSize n-    | n >= 1 && n <= 1001 = Right (BrowsePageSize (fromIntegral n))-    | otherwise = Left (InvalidBrowsePageSize n)+  | n >= 1 && n <= 1001 = Right (BrowsePageSize (fromIntegral n))+  | otherwise = Left (InvalidBrowsePageSize n)  browsePageSizeValue :: BrowsePageSize -> Int32 browsePageSizeValue (BrowsePageSize n) = n
test/Main.hs view
@@ -45,2189 +45,2193 @@ import Test.HistoryRetentionMock qualified as HistoryRetentionMock import Test.Hspec import Test.InterpreterHooks qualified as InterpreterHooks-import Test.NotifyGuard qualified as NotifyGuard-import Test.PerformanceStructure qualified as PerformanceStructure-import Test.Properties qualified as Properties-import Test.PublisherCallbackResilience qualified as PublisherCallbackResilience-import Test.PublisherDropCounter qualified as PublisherDropCounter-import Test.PublisherIdleAdvance qualified as PublisherIdleAdvance-import Test.PublisherRestartNoRebroadcast qualified as PublisherRestartNoRebroadcast-import Test.ReadStream qualified as ReadStream-import Test.ScriptSessionErrors qualified as ScriptSessionErrors-import Test.StartupFailureSurfacing qualified as StartupFailureSurfacing-import Test.StreamBridgeTermination qualified as StreamBridgeTermination-import Test.StreamHead qualified as StreamHead-import Test.StreamHeadIsolation qualified as StreamHeadIsolation-import Test.StreamHeadMock qualified as StreamHeadMock-import Test.StreamHistoryGuard qualified as StreamHistoryGuard-import Test.StreamNameLookup qualified as StreamNameLookup-import Test.SubscriptionCheckpointInitialization qualified as SubscriptionCheckpointInitialization-import Test.SubscriptionCheckpointInitializationMock qualified as SubscriptionCheckpointInitializationMock-import Test.SubscriptionCheckpointInventory qualified as SubscriptionCheckpointInventory-import Test.SubscriptionCheckpointInventoryMock qualified as SubscriptionCheckpointInventoryMock-import Test.SubscriptionCheckpointReset qualified as SubscriptionCheckpointReset-import Test.SubscriptionCheckpointWorker qualified as SubscriptionCheckpointWorker-import Test.SubscriptionDeadLetters qualified as SubscriptionDeadLetters-import Test.SubscriptionDeadLettersMock qualified as SubscriptionDeadLettersMock-import Test.SubscriptionPauseResume qualified as SubscriptionPauseResume-import Test.SubscriptionReconnect qualified as SubscriptionReconnect-import Test.SubscriptionRegistry qualified as SubscriptionRegistry-import Test.SubscriptionRetryDeadLetter qualified as SubscriptionRetryDeadLetter-import Test.SubscriptionState qualified as SubscriptionState-import Test.SubscriptionTarget qualified as SubscriptionTarget-import Test.Transaction qualified as Transaction-import Test.TruncateBefore qualified as TruncateBefore-import Test.UniqueViolationMapping qualified as UniqueViolationMapping-import Test.VisibleGlobalHeadPosition qualified as VisibleGlobalHeadPosition-import Test.VisibleGlobalHeadPositionMock qualified as VisibleGlobalHeadPositionMock--main :: IO ()-main = withSharedMigratedPostgres $ hspec $ do-    BrowseReads.spec-    BrowseReadsMock.spec-    SubscriptionDeadLetters.spec-    SubscriptionDeadLettersMock.spec-    UniqueViolationMapping.spec-    SubscriptionTarget.spec-    Category.spec-    Properties.spec-    Concurrency.spec-    FailureInjection.spec-    HistoryRetention.spec-    HistoryRetentionMock.spec-    Transaction.spec-    ScriptSessionErrors.spec-    TruncateBefore.spec-    ReadStream.spec-    StreamNameLookup.spec-    StreamBridgeTermination.spec-    StreamHistoryGuard.spec-    InterpreterHooks.spec-    Causation.spec-    ConsumerGroupSql.spec-    ConsumerGroupResize.spec-    ConsumerGroup.spec-    ConsumerGroupEffect.spec-    describe "performance structure" $ do-        BrowseQueryPlans.spec-        StreamHeadIsolation.spec-        PerformanceStructure.spec-        NotifyGuard.spec-        StreamNameLookup.noOpSpec-    CategoryIdleNoSpin.spec-    HandlerStall.spec-    PublisherCallbackResilience.spec-    PublisherDropCounter.spec-    PublisherIdleAdvance.spec-    PublisherRestartNoRebroadcast.spec-    CatchupDbErrorNoPrematureSwitch.spec-    SubscriptionPauseResume.spec-    SubscriptionCheckpointInitialization.spec-    SubscriptionCheckpointInitializationMock.spec-    SubscriptionCheckpointWorker.spec-    SubscriptionCheckpointInventory.spec-    SubscriptionCheckpointInventoryMock.spec-    SubscriptionCheckpointReset.spec-    SubscriptionReconnect.spec-    StartupFailureSurfacing.spec-    SubscriptionState.spec-    SubscriptionRegistry.spec-    SubscriptionRetryDeadLetter.spec-    EventTypeFilter.spec-    StreamHead.spec-    StreamHeadMock.spec-    VisibleGlobalHeadPosition.spec-    VisibleGlobalHeadPositionMock.spec-    around withTestStore $ do-        describe "schema migrations" $ do-            it "installs every Kiroku table under the kiroku schema" $ \store -> do-                let tables = ["streams", "events", "stream_events", "subscriptions"]-                results <- mapM (\t -> tableExists store ("kiroku." <> t)) tables-                results `shouldBe` replicate (length tables) True--            it "leaves the public schema free of Kiroku tables" $ \store -> do-                let tables = ["streams", "events", "stream_events", "subscriptions"]-                results <- mapM (\t -> tableExists store ("public." <> t)) tables-                results `shouldBe` replicate (length tables) False--            it "provides a UUIDv7 database default for direct event inserts" $ \store -> do-                eventIdText <- insertEventUsingDefaultId store-                version <- serverVersionNum store-                let isV7 = T.length eventIdText > 14 && T.index eventIdText 14 == '7'-                unless isV7 $-                    expectationFailure-                        ( "expected database-generated event_id to be UUIDv7 on PostgreSQL "-                            <> T.unpack version-                            <> ", got "-                            <> T.unpack eventIdText-                        )--        describe "appendToStream" $ do-            describe "NoStream" $ do-                it "creates a new stream and appends events" $ \store -> do-                    let event = makeEvent "OrderCreated" (Aeson.object [("orderId", Aeson.String "123")])-                    result <- runStoreIO store $ appendToStream (StreamName "order-123") NoStream [event]-                    case result of-                        Left err -> expectationFailure ("Expected success, got: " <> show err)-                        Right r -> do-                            (r ^. #streamVersion) `shouldBe` StreamVersion 1-                            (r ^. #globalPosition) `shouldBe` GlobalPosition 1--                it "fails when stream already exists" $ \store -> do-                    _ <- runStoreIO store $ appendToStream (StreamName "order-456") NoStream [makeEvent "OrderCreated" (Aeson.object [])]-                    result <- runStoreIO store $ appendToStream (StreamName "order-456") NoStream [makeEvent "OrderUpdated" (Aeson.object [])]-                    case result of-                        Left (StreamAlreadyExists _) -> pure ()-                        other -> expectationFailure ("Expected StreamAlreadyExists, got: " <> show other)--            describe "empty event batches" $ do-                it "rejects NoStream without creating a phantom stream or advancing $all" $ \store -> do-                    Right allBefore <- runStoreIO store $ readAllForward (GlobalPosition 0) 10-                    result <- runStoreIO store $ appendToStream (StreamName "empty-nostream") NoStream []-                    result `shouldBe` Left (EmptyAppendBatch (StreamName "empty-nostream"))-                    runStoreIO store (getStream (StreamName "empty-nostream")) `shouldReturn` Right Nothing-                    Right allAfter <- runStoreIO store $ readAllForward (GlobalPosition 0) 10-                    fmap (^. #globalPosition) (V.toList allAfter) `shouldBe` fmap (^. #globalPosition) (V.toList allBefore)--                it "rejects empty batches for existing-stream expectations without changing version" $ \store -> do-                    Right _ <- runStoreIO store $ appendToStream (StreamName "empty-existing") NoStream [makeEvent "Created" (Aeson.object [])]-                    let assertEmptyRejected expected = do-                            result <- runStoreIO store $ appendToStream (StreamName "empty-existing") expected []-                            result `shouldBe` Left (EmptyAppendBatch (StreamName "empty-existing"))-                            Right (Just info) <- runStoreIO store $ getStream (StreamName "empty-existing")-                            (info ^. #version) `shouldBe` StreamVersion 1-                    assertEmptyRejected (ExactVersion (StreamVersion 1))-                    assertEmptyRejected StreamExists-                    assertEmptyRejected AnyVersion--                it "rejects an empty per-stream batch in appendMultiStream without committing any stream" $ \store -> do-                    let ok = StreamName "empty-multi-ok"-                        bad = StreamName "empty-multi-bad"-                    result <--                        runStoreIO store $-                            appendMultiStream-                                [ (ok, NoStream, [makeEvent "Created" (Aeson.object [])])-                                , (bad, NoStream, [])-                                ]-                    result `shouldBe` Left (EmptyAppendBatch bad)-                    runStoreIO store (getStream ok) `shouldReturn` Right Nothing-                    runStoreIO store (getStream bad) `shouldReturn` Right Nothing--                it "treats an empty appendMultiStream operation list as a no-op" $ \store -> do-                    result <- runStoreIO store $ appendMultiStream []-                    result `shouldBe` Right []--            describe "ExactVersion" $ do-                it "appends when version matches" $ \store -> do-                    Right r1 <- runStoreIO store $ appendToStream (StreamName "order-789") NoStream [makeEvent "OrderCreated" (Aeson.object [])]-                    (r1 ^. #streamVersion) `shouldBe` StreamVersion 1--                    result <- runStoreIO store $ appendToStream (StreamName "order-789") (ExactVersion (StreamVersion 1)) [makeEvent "OrderUpdated" (Aeson.object [])]-                    case result of-                        Left err -> expectationFailure ("Expected success, got: " <> show err)-                        Right r -> do-                            (r ^. #streamVersion) `shouldBe` StreamVersion 2-                            (r ^. #globalPosition) `shouldBe` GlobalPosition 2--                it "fails on version conflict" $ \store -> do-                    _ <- runStoreIO store $ appendToStream (StreamName "order-conflict") NoStream [makeEvent "OrderCreated" (Aeson.object [])]-                    result <- runStoreIO store $ appendToStream (StreamName "order-conflict") (ExactVersion (StreamVersion 0)) [makeEvent "OrderUpdated" (Aeson.object [])]-                    case result of-                        Left (WrongExpectedVersion _ _ _) -> pure ()-                        other -> expectationFailure ("Expected WrongExpectedVersion, got: " <> show other)--            describe "StreamExists" $ do-                it "appends to an existing stream" $ \store -> do-                    _ <- runStoreIO store $ appendToStream (StreamName "stream-exists-test") NoStream [makeEvent "Created" (Aeson.object [])]-                    result <- runStoreIO store $ appendToStream (StreamName "stream-exists-test") StreamExists [makeEvent "Updated" (Aeson.object [])]-                    case result of-                        Left err -> expectationFailure ("Expected success, got: " <> show err)-                        Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 2--                it "fails when stream does not exist" $ \store -> do-                    result <- runStoreIO store $ appendToStream (StreamName "nonexistent-stream") StreamExists [makeEvent "Created" (Aeson.object [])]-                    case result of-                        Left (StreamNotFound _) -> pure ()-                        other -> expectationFailure ("Expected StreamNotFound, got: " <> show other)--            describe "AnyVersion" $ do-                it "creates a new stream" $ \store -> do-                    result <- runStoreIO store $ appendToStream (StreamName "any-new") AnyVersion [makeEvent "Created" (Aeson.object [])]-                    case result of-                        Left err -> expectationFailure ("Expected success, got: " <> show err)-                        Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 1--                it "appends to an existing stream" $ \store -> do-                    _ <- runStoreIO store $ appendToStream (StreamName "any-existing") AnyVersion [makeEvent "Created" (Aeson.object [])]-                    result <- runStoreIO store $ appendToStream (StreamName "any-existing") AnyVersion [makeEvent "Updated" (Aeson.object [])]-                    case result of-                        Left err -> expectationFailure ("Expected success, got: " <> show err)-                        Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 2--            describe "batch append" $ do-                it "appends multiple events with sequential versions" $ \store -> do-                    let events =-                            [ makeEvent "Event1" (Aeson.object [("n", Aeson.Number 1)])-                            , makeEvent "Event2" (Aeson.object [("n", Aeson.Number 2)])-                            , makeEvent "Event3" (Aeson.object [("n", Aeson.Number 3)])-                            ]-                    result <- runStoreIO store $ appendToStream (StreamName "batch-test") NoStream events-                    case result of-                        Left err -> expectationFailure ("Expected success, got: " <> show err)-                        Right r -> do-                            (r ^. #streamVersion) `shouldBe` StreamVersion 3-                            (r ^. #globalPosition) `shouldBe` GlobalPosition 3--            describe "global position contiguity" $ do-                it "assigns contiguous global positions across streams" $ \store -> do-                    Right r1 <- runStoreIO store $ appendToStream (StreamName "stream-a") NoStream [makeEvent "A" (Aeson.object [])]-                    (r1 ^. #globalPosition) `shouldBe` GlobalPosition 1--                    Right r2 <- runStoreIO store $ appendToStream (StreamName "stream-b") NoStream [makeEvent "B" (Aeson.object [])]-                    (r2 ^. #globalPosition) `shouldBe` GlobalPosition 2--                    Right r3 <- runStoreIO store $ appendToStream (StreamName "stream-c") NoStream [makeEvent "C" (Aeson.object [])]-                    (r3 ^. #globalPosition) `shouldBe` GlobalPosition 3--            describe "duplicate event ID" $ do-                it "returns the caller event id on same-stream retry and leaves one stored event" $ \store -> do-                    let eid = EventId (UUID.fromWords 0x01234567 0x89ab7def 0x80123456 0x7890abcd)-                        event = makeEvent "Created" (Aeson.object []) & #eventId .~ Just eid-                        stream = StreamName "same-stream-duplicate"-                    Right _ <- runStoreIO store $ appendToStream stream NoStream [event]-                    forM_ [AnyVersion, StreamExists, ExactVersion (StreamVersion 1)] $ \expected -> do-                        result <- runStoreIO store $ appendToStream stream expected [event]-                        result `shouldBe` Left (DuplicateEvent (Just eid))-                        countEvents store `shouldReturn` 1-                        Right stored <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 100-                        Right global <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-                        map (\e -> e ^. #eventId) (V.toList stored) `shouldBe` [eid]-                        map (\e -> e ^. #eventId) (V.toList global) `shouldBe` [eid]-                        Right info <- runStoreIO store $ getStream stream-                        fmap (\s -> s ^. #version) info `shouldBe` Just (StreamVersion 1)--                it "rejects duplicate event IDs" $ \store -> do-                    let eid = EventId (case UUID.fromString "01234567-89ab-7def-8012-34567890abcd" of Just u -> u; Nothing -> error "bad uuid")-                    let event1 =-                            EventData-                                { eventId = Just eid-                                , eventType = EventType "Created"-                                , payload = Aeson.object []-                                , metadata = Nothing-                                , causationId = Nothing-                                , correlationId = Nothing-                                }-                    Right _ <- runStoreIO store $ appendToStream (StreamName "dup-test-1") NoStream [event1]--                    let event2 =-                            EventData-                                { eventId = Just eid-                                , eventType = EventType "Created"-                                , payload = Aeson.object []-                                , metadata = Nothing-                                , causationId = Nothing-                                , correlationId = Nothing-                                }-                    result <- runStoreIO store $ appendToStream (StreamName "dup-test-2") NoStream [event2]-                    case result of-                        Left (DuplicateEvent _) -> pure ()-                        other -> expectationFailure ("Expected DuplicateEvent, got: " <> show other)--            describe "stream-name contract" $ do-                it "treats system-looking names other than $all as ordinary streams" $ \store -> do-                    let names =-                            [ StreamName "invoice-payment"-                            , StreamName "$invoice-payment"-                            , StreamName "invoice,payment"-                            , StreamName "invoicepayment"-                            ]-                    mapM_-                        ( \name -> do-                            Right r <- runStoreIO store $ appendToStream name NoStream [makeEvent "StreamNameContract" (Aeson.object [])]-                            (r ^. #streamVersion) `shouldBe` StreamVersion 1-                            Right events <- runStoreIO store $ readStreamForward name (StreamVersion 0) 10-                            V.length events `shouldBe` 1-                        )-                        names--                it "rejects $all as an application append target" $ \store -> do-                    result <- runStoreIO store $ appendToStream (StreamName "$all") AnyVersion [makeEvent "BadAllAppend" (Aeson.object [])]-                    case result of-                        Left (ReservedStreamName (StreamName "$all")) -> pure ()-                        other -> expectationFailure ("Expected ReservedStreamName for $all append, got: " <> show other)-                    Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 10-                    V.length allEvents `shouldBe` 0--                it "rejects $all as a multi-stream append target without partial commit" $ \store -> do-                    result <--                        runStoreIO store $-                            appendMultiStream-                                [ (StreamName "multi-reserved-ok", NoStream, [makeEvent "ShouldRollback" (Aeson.object [])])-                                , (StreamName "$all", AnyVersion, [makeEvent "BadAllMulti" (Aeson.object [])])-                                ]-                    case result of-                        Left (ReservedStreamName (StreamName "$all")) -> pure ()-                        other -> expectationFailure ("Expected ReservedStreamName for $all multi-stream append, got: " <> show other)-                    Right info <- runStoreIO store $ getStream (StreamName "multi-reserved-ok")-                    info `shouldBe` Nothing--                it "rejects $all as a link target" $ \store -> do-                    Right _ <- runStoreIO store $ appendToStream (StreamName "reserved-link-source") NoStream [makeEvent "Source" (Aeson.object [])]-                    Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "reserved-link-source") (StreamVersion 0) 10-                    let eid = V.head srcEvents ^. #eventId-                    result <- runStoreIO store $ linkToStream (StreamName "$all") [eid]-                    case result of-                        Left (ReservedStreamName (StreamName "$all")) -> pure ()-                        other -> expectationFailure ("Expected ReservedStreamName for $all link, got: " <> show other)-                    Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 10-                    V.length allEvents `shouldBe` 1--                it "rejects lifecycle operations against $all" $ \store -> do-                    soft <- runStoreIO store $ softDeleteStream (StreamName "$all")-                    hard <- runStoreIO store $ hardDeleteStream (StreamName "$all")-                    undel <- runStoreIO store $ undeleteStream (StreamName "$all")-                    let shouldBeReserved label result =-                            case result of-                                Left (ReservedStreamName (StreamName "$all")) -> pure ()-                                other -> expectationFailure ("Expected ReservedStreamName for " <> label <> ", got: " <> show other)-                    shouldBeReserved "softDeleteStream $all" soft-                    shouldBeReserved "hardDeleteStream $all" hard-                    shouldBeReserved "undeleteStream $all" undel-                    Right info <- runStoreIO store $ getStream (StreamName "$all")-                    case info of-                        Just si -> (si ^. #deletedAt) `shouldBe` Nothing-                        Nothing -> expectationFailure "Expected reserved $all stream row to remain present"--                it "rejects stream names longer than 512 UTF-8 bytes before creating streams" $ \store -> do-                    let overAscii = StreamName (T.replicate 513 "a")-                        overUtf8 = StreamName (T.replicate 171 (T.singleton '\12354'))-                        assertTooLong sn = do-                            result <- runStoreIO store $ appendToStream sn NoStream [makeEvent "TooLong" (Aeson.object [])]-                            result `shouldBe` Left (StreamNameTooLong sn 513)-                            runStoreIO store (getStream sn) `shouldReturn` Right Nothing-                    assertTooLong overAscii-                    assertTooLong overUtf8--                it "rejects oversized link targets without creating the target stream" $ \store -> do-                    let over = StreamName (T.replicate 513 "l")-                    Right _ <- runStoreIO store $ appendToStream (StreamName "length-link-source") NoStream [makeEvent "Source" (Aeson.object [])]-                    Right events <- runStoreIO store $ readStreamForward (StreamName "length-link-source") (StreamVersion 0) 10-                    let eid = V.head events ^. #eventId-                    result <- runStoreIO store $ linkToStream over [eid]-                    result `shouldBe` Left (StreamNameTooLong over 513)-                    runStoreIO store (getStream over) `shouldReturn` Right Nothing--                it "rejects oversized multi-stream targets without partial commit" $ \store -> do-                    let ok = StreamName "length-multi-ok"-                        over = StreamName (T.replicate 513 "m")-                    result <--                        runStoreIO store $-                            appendMultiStream-                                [ (ok, NoStream, [makeEvent "ShouldRollback" (Aeson.object [])])-                                , (over, NoStream, [makeEvent "TooLong" (Aeson.object [])])-                                ]-                    result `shouldBe` Left (StreamNameTooLong over 513)-                    runStoreIO store (getStream ok) `shouldReturn` Right Nothing-                    runStoreIO store (getStream over) `shouldReturn` Right Nothing--                it "accepts a 512-byte stream name" $ \store -> do-                    let exact = StreamName (T.replicate 512 "e")-                    result <- runStoreIO store $ appendToStream exact NoStream [makeEvent "ExactLength" (Aeson.object [])]-                    case result of-                        Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 1-                        Left err -> expectationFailure ("Expected 512-byte stream name to append, got: " <> show err)--        describe "readStreamForward" $ do-            it "reads events in forward order (read-your-own-writes)" $ \store -> do-                let events =-                        [ makeEvent "A" (Aeson.object [("n", Aeson.Number 1)])-                        , makeEvent "B" (Aeson.object [("n", Aeson.Number 2)])-                        , makeEvent "C" (Aeson.object [("n", Aeson.Number 3)])-                        ]-                Right _ <- runStoreIO store $ appendToStream (StreamName "read-fwd") NoStream events-                Right result <- runStoreIO store $ readStreamForward (StreamName "read-fwd") (StreamVersion 0) 100-                V.length result `shouldBe` 3-                (V.head result ^. #eventType) `shouldBe` EventType "A"-                (result V.! 1 ^. #eventType) `shouldBe` EventType "B"-                (result V.! 2 ^. #eventType) `shouldBe` EventType "C"-                (V.head result ^. #streamVersion) `shouldBe` StreamVersion 1-                (result V.! 1 ^. #streamVersion) `shouldBe` StreamVersion 2-                (result V.! 2 ^. #streamVersion) `shouldBe` StreamVersion 3--        describe "readStreamBackward" $ do-            it "reads events in backward order" $ \store -> do-                let events =-                        [ makeEvent "A" (Aeson.object [])-                        , makeEvent "B" (Aeson.object [])-                        , makeEvent "C" (Aeson.object [])-                        ]-                Right _ <- runStoreIO store $ appendToStream (StreamName "read-bwd") NoStream events-                Right result <- runStoreIO store $ readStreamBackward (StreamName "read-bwd") (StreamVersion 0) 100-                V.length result `shouldBe` 3-                (V.head result ^. #eventType) `shouldBe` EventType "C"-                (result V.! 1 ^. #eventType) `shouldBe` EventType "B"-                (result V.! 2 ^. #eventType) `shouldBe` EventType "A"-                (V.head result ^. #streamVersion) `shouldBe` StreamVersion 3-                (result V.! 1 ^. #streamVersion) `shouldBe` StreamVersion 2-                (result V.! 2 ^. #streamVersion) `shouldBe` StreamVersion 1--            it "paginates backward using stream version as an exclusive cursor" $ \store -> do-                let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]-                Right _ <- runStoreIO store $ appendToStream (StreamName "read-bwd-page") NoStream events--                Right page1 <- runStoreIO store $ readStreamBackward (StreamName "read-bwd-page") (StreamVersion 0) 2-                fmap (^. #streamVersion) (V.toList page1) `shouldBe` [StreamVersion 5, StreamVersion 4]--                let cursor1 = V.last page1 ^. #streamVersion-                Right page2 <- runStoreIO store $ readStreamBackward (StreamName "read-bwd-page") cursor1 2-                fmap (^. #streamVersion) (V.toList page2) `shouldBe` [StreamVersion 3, StreamVersion 2]--                let cursor2 = V.last page2 ^. #streamVersion-                Right page3 <- runStoreIO store $ readStreamBackward (StreamName "read-bwd-page") cursor2 2-                fmap (^. #streamVersion) (V.toList page3) `shouldBe` [StreamVersion 1]--                Right page4 <- runStoreIO store $ readStreamBackward (StreamName "read-bwd-page") (V.last page3 ^. #streamVersion) 2-                V.length page4 `shouldBe` 0--        describe "cursor-based pagination" $ do-            it "paginates using stream version as cursor" $ \store -> do-                let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]-                Right _ <- runStoreIO store $ appendToStream (StreamName "read-page") NoStream events-                -- Read first page of 2-                Right page1 <- runStoreIO store $ readStreamForward (StreamName "read-page") (StreamVersion 0) 2-                V.length page1 `shouldBe` 2-                let cursor1 = page1 V.! 1 ^. #streamVersion-                cursor1 `shouldBe` StreamVersion 2-                -- Read second page from cursor-                Right page2 <- runStoreIO store $ readStreamForward (StreamName "read-page") cursor1 2-                V.length page2 `shouldBe` 2-                (V.head page2 ^. #streamVersion) `shouldBe` StreamVersion 3-                -- Read third page — only 1 event left-                let cursor2 = page2 V.! 1 ^. #streamVersion-                Right page3 <- runStoreIO store $ readStreamForward (StreamName "read-page") cursor2 2-                V.length page3 `shouldBe` 1-                -- Read past end — empty-                let cursor3 = V.head page3 ^. #streamVersion-                Right page4 <- runStoreIO store $ readStreamForward (StreamName "read-page") cursor3 2-                V.length page4 `shouldBe` 0--        describe "readAllForward" $ do-            it "reads events from $all in global order" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "all-s1") NoStream [makeEvent "X" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "all-s2") NoStream [makeEvent "Y" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "all-s3") NoStream [makeEvent "Z" (Aeson.object [])]-                Right result <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-                V.length result `shouldBe` 3-                (V.head result ^. #eventType) `shouldBe` EventType "X"-                (result V.! 1 ^. #eventType) `shouldBe` EventType "Y"-                (result V.! 2 ^. #eventType) `shouldBe` EventType "Z"-                -- Global positions should be contiguous-                (V.head result ^. #globalPosition) `shouldBe` GlobalPosition 1-                (result V.! 1 ^. #globalPosition) `shouldBe` GlobalPosition 2-                (result V.! 2 ^. #globalPosition) `shouldBe` GlobalPosition 3--        describe "readAllBackward" $ do-            it "reads events from $all in reverse order" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "allb-s1") NoStream [makeEvent "X" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "allb-s2") NoStream [makeEvent "Y" (Aeson.object [])]-                Right result <- runStoreIO store $ readAllBackward (GlobalPosition 0) 100-                V.length result `shouldBe` 2-                (V.head result ^. #eventType) `shouldBe` EventType "Y"-                (result V.! 1 ^. #eventType) `shouldBe` EventType "X"--            it "paginates backward using global position as an exclusive cursor" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "allb-page-s1") NoStream [makeEvent "X" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "allb-page-s2") NoStream [makeEvent "Y" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "allb-page-s3") NoStream [makeEvent "Z" (Aeson.object [])]--                Right page1 <- runStoreIO store $ readAllBackward (GlobalPosition 0) 2-                fmap (^. #globalPosition) (V.toList page1) `shouldBe` [GlobalPosition 3, GlobalPosition 2]--                Right page2 <- runStoreIO store $ readAllBackward (GlobalPosition 2) 2-                fmap (^. #globalPosition) (V.toList page2) `shouldBe` [GlobalPosition 1]--                Right page3 <- runStoreIO store $ readAllBackward (GlobalPosition 1) 2-                V.length page3 `shouldBe` 0--        describe "read empty/nonexistent stream" $ do-            it "returns empty Vector for nonexistent stream" $ \store -> do-                Right result <- runStoreIO store $ readStreamForward (StreamName "no-such-stream") (StreamVersion 0) 100-                V.length result `shouldBe` 0--        describe "getStream" $ do-            it "returns metadata for existing stream" $ \store -> do-                Right _ <--                    runStoreIO store $-                        appendToStream-                            (StreamName "meta-test")-                            NoStream-                            [ makeEvent "A" (Aeson.object [])-                            , makeEvent "B" (Aeson.object [])-                            ]-                Right info <- runStoreIO store $ getStream (StreamName "meta-test")-                case info of-                    Just si -> do-                        (si ^. #name) `shouldBe` StreamName "meta-test"-                        (si ^. #version) `shouldBe` StreamVersion 2-                    Nothing -> expectationFailure "Expected Just StreamInfo, got Nothing"--            it "returns Nothing for nonexistent stream" $ \store -> do-                Right info <- runStoreIO store $ getStream (StreamName "no-such-stream")-                info `shouldBe` Nothing--        describe "lookupStreamId" $ do-            it "returns the same id as getStream for a live stream" $ \store -> do-                Right _ <--                    runStoreIO store $-                        appendToStream-                            (StreamName "lookup-live")-                            NoStream-                            [makeEvent "A" (Aeson.object [])]-                Right mInfo <- runStoreIO store $ getStream (StreamName "lookup-live")-                Right mSid <- runStoreIO store $ lookupStreamId (StreamName "lookup-live")-                case (mInfo, mSid) of-                    (Just info, Just sid) ->-                        (info ^. #id) `shouldBe` sid-                    _ ->-                        expectationFailure "Expected both getStream and lookupStreamId to return Just"--            it "returns Nothing for a stream that has never been created" $ \store -> do-                Right mSid <- runStoreIO store $ lookupStreamId (StreamName "lookup-missing")-                mSid `shouldBe` Nothing--            it "returns Just the same id for a soft-deleted stream" $ \store -> do-                Right _ <--                    runStoreIO store $-                        appendToStream-                            (StreamName "lookup-soft")-                            NoStream-                            [makeEvent "A" (Aeson.object [])]-                Right mSidBefore <- runStoreIO store $ lookupStreamId (StreamName "lookup-soft")-                Right _ <- runStoreIO store $ softDeleteStream (StreamName "lookup-soft")-                Right mSidAfter <- runStoreIO store $ lookupStreamId (StreamName "lookup-soft")-                mSidAfter `shouldBe` mSidBefore-                Right mInfo <- runStoreIO store $ getStream (StreamName "lookup-soft")-                case (mInfo, mSidAfter) of-                    (Just info, Just sid) -> (info ^. #id) `shouldBe` sid-                    _ -> expectationFailure "Expected Just on a soft-deleted stream"--        describe "integration: full lifecycle through withStore" $ do-            it "append, read forward, read $all, getStream — all through public API" $ \store -> do-                -- Append events to two streams-                Right _ <--                    runStoreIO store $-                        appendToStream-                            (StreamName "integ-orders")-                            NoStream-                            [ makeEvent "OrderCreated" (Aeson.object [("id", Aeson.String "1")])-                            , makeEvent "OrderShipped" (Aeson.object [("id", Aeson.String "1")])-                            ]-                Right _ <--                    runStoreIO store $-                        appendToStream-                            (StreamName "integ-users")-                            NoStream-                            [ makeEvent "UserRegistered" (Aeson.object [("name", Aeson.String "alice")])-                            ]--                -- Read from a named stream-                Right orders <- runStoreIO store $ readStreamForward (StreamName "integ-orders") (StreamVersion 0) 100-                V.length orders `shouldBe` 2-                (V.head orders ^. #eventType) `shouldBe` EventType "OrderCreated"-                (orders V.! 1 ^. #eventType) `shouldBe` EventType "OrderShipped"--                -- Read from $all — should see all 3 events in global order-                Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-                V.length allEvents `shouldBe` 3-                (V.head allEvents ^. #globalPosition) `shouldBe` GlobalPosition 1-                (allEvents V.! 2 ^. #globalPosition) `shouldBe` GlobalPosition 3--                -- Query stream metadata-                Right orderInfo <- runStoreIO store $ getStream (StreamName "integ-orders")-                case orderInfo of-                    Just si -> (si ^. #version) `shouldBe` StreamVersion 2-                    Nothing -> expectationFailure "Expected stream info"--                Right noInfo <- runStoreIO store $ getStream (StreamName "nonexistent")-                noInfo `shouldBe` Nothing--        -- =================================================================-        -- Link tests (M5.8)-        -- =================================================================-        describe "linkToStream" $ do-            it "links a single event to a new stream" $ \store -> do-                let event = makeEvent "OrderCreated" (Aeson.object [("id", Aeson.String "1")])-                Right appendR <- runStoreIO store $ appendToStream (StreamName "source-1") NoStream [event]-                -- Read back to get the event ID-                Right events <- runStoreIO store $ readStreamForward (StreamName "source-1") (StreamVersion 0) 100-                let eid = V.head events ^. #eventId-                -- Link it-                Right linkR <- runStoreIO store $ linkToStream (StreamName "linked-1") [eid]-                (linkR ^. #streamVersion) `shouldBe` StreamVersion 1-                -- Read the linked stream-                Right linked <- runStoreIO store $ readStreamForward (StreamName "linked-1") (StreamVersion 0) 100-                V.length linked `shouldBe` 1-                (V.head linked ^. #eventId) `shouldBe` eid-                (V.head linked ^. #originalStreamId) `shouldBe` (appendR ^. #streamId)-                (V.head linked ^. #originalVersion) `shouldBe` StreamVersion 1--            it "links multiple events with sequential versions" $ \store -> do-                let events =-                        [ makeEvent "A" (Aeson.object [])-                        , makeEvent "B" (Aeson.object [])-                        , makeEvent "C" (Aeson.object [])-                        ]-                Right _ <- runStoreIO store $ appendToStream (StreamName "source-multi") NoStream events-                Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "source-multi") (StreamVersion 0) 100-                let eids = V.toList (V.map (^. #eventId) srcEvents)-                Right linkR <- runStoreIO store $ linkToStream (StreamName "linked-multi") eids-                (linkR ^. #streamVersion) `shouldBe` StreamVersion 3-                Right linked <- runStoreIO store $ readStreamForward (StreamName "linked-multi") (StreamVersion 0) 100-                V.length linked `shouldBe` 3-                (V.head linked ^. #streamVersion) `shouldBe` StreamVersion 1-                (linked V.! 1 ^. #streamVersion) `shouldBe` StreamVersion 2-                (linked V.! 2 ^. #streamVersion) `shouldBe` StreamVersion 3--            it "links events to an existing stream and bumps version" $ \store -> do-                let events1 = [makeEvent "A" (Aeson.object [])]-                    events2 = [makeEvent "B" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "src-a") NoStream events1-                Right _ <- runStoreIO store $ appendToStream (StreamName "src-b") NoStream events2-                Right evA <- runStoreIO store $ readStreamForward (StreamName "src-a") (StreamVersion 0) 100-                Right evB <- runStoreIO store $ readStreamForward (StreamName "src-b") (StreamVersion 0) 100-                let eidA = V.head evA ^. #eventId-                    eidB = V.head evB ^. #eventId-                -- Link first event-                Right r1 <- runStoreIO store $ linkToStream (StreamName "linked-existing") [eidA]-                (r1 ^. #streamVersion) `shouldBe` StreamVersion 1-                -- Link second event to same stream-                Right r2 <- runStoreIO store $ linkToStream (StreamName "linked-existing") [eidB]-                (r2 ^. #streamVersion) `shouldBe` StreamVersion 2-                -- Read all linked events-                Right linked <- runStoreIO store $ readStreamForward (StreamName "linked-existing") (StreamVersion 0) 100-                V.length linked `shouldBe` 2--            it "linked events still appear in $all at original global positions" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "orig-all") NoStream [makeEvent "X" (Aeson.object [])]-                Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "orig-all") (StreamVersion 0) 100-                let eid = V.head srcEvents ^. #eventId-                _ <- runStoreIO store $ linkToStream (StreamName "linked-all") [eid]-                -- Read $all — should have exactly 1 event (not duplicated)-                Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-                let matchingEvents = V.filter (\e -> (e ^. #eventId) == eid) allEvents-                V.length matchingEvents `shouldBe` 1--            it "rejects linking the same event to the same stream twice" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "src-dup") NoStream [makeEvent "X" (Aeson.object [])]-                Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "src-dup") (StreamVersion 0) 100-                let eid = V.head srcEvents ^. #eventId-                Right _ <- runStoreIO store $ linkToStream (StreamName "linked-dup") [eid]-                result <- runStoreIO store $ linkToStream (StreamName "linked-dup") [eid]-                case result of-                    Left (EventAlreadyLinked (StreamName "linked-dup") mEid) ->-                        mEid `shouldBe` Just eid-                    Left err -> expectationFailure ("Expected EventAlreadyLinked, got: " <> show err)-                    Right _ -> expectationFailure "Expected error for duplicate link"--            -- F3 regression — silent version gap when source events do not exist.-            -- Before the fix, the CTE's `JOIN LATERAL` silently dropped link rows for-            -- event_ids that had no `stream_events` row (e.g. never existed, or were-            -- hard-deleted). The `stream_upsert` had already bumped `stream_version`,-            -- so the stream advanced by N but only some link rows were inserted.-            it "rejects link when the source event does not exist" $ \store -> do-                let bogus = EventId (case UUID.fromString "11111111-1111-7111-8111-111111111111" of Just u -> u; Nothing -> error "bad uuid")-                result <- runStoreIO store $ linkToStream (StreamName "f3-bogus") [bogus]-                case result of-                    Left (LinkSourceEventMissing (StreamName "f3-bogus")) -> pure ()-                    Left err -> expectationFailure ("Expected LinkSourceEventMissing, got: " <> show err)-                    Right r -> expectationFailure ("Expected error for missing event, got: " <> show r)-                -- Target stream must not have been left in a half-created state.-                Right info <- runStoreIO store $ getStream (StreamName "f3-bogus")-                info `shouldBe` Nothing--            it "rejects link with a mix of valid and missing events (no partial commit)" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "f3-mixed-src") NoStream [makeEvent "Real" (Aeson.object [])]-                Right realEvents <- runStoreIO store $ readStreamForward (StreamName "f3-mixed-src") (StreamVersion 0) 100-                let realId = V.head realEvents ^. #eventId-                let bogus = EventId (case UUID.fromString "22222222-2222-7222-8222-222222222222" of Just u -> u; Nothing -> error "bad uuid")-                result <- runStoreIO store $ linkToStream (StreamName "f3-mixed-tgt") [realId, bogus]-                case result of-                    Left (LinkSourceEventMissing (StreamName "f3-mixed-tgt")) -> pure ()-                    Left err -> expectationFailure ("Expected LinkSourceEventMissing, got: " <> show err)-                    Right r -> expectationFailure ("Expected error for partial-missing batch, got: " <> show r)-                Right info <- runStoreIO store $ getStream (StreamName "f3-mixed-tgt")-                info `shouldBe` Nothing--            -- F5 regression — linkToStream against a soft-deleted target stream.-            -- Symmetric with the appendAnyVersion soft-delete check from F2: the-            -- DO UPDATE clause's WHERE filters on deleted_at IS NULL, so the-            -- upsert returns no row and the interpreter maps that to StreamNotFound.-            it "rejects link to a soft-deleted target stream" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "f5-src") NoStream [makeEvent "X" (Aeson.object [])]-                Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "f5-src") (StreamVersion 0) 100-                let eid = V.head srcEvents ^. #eventId-                Right _ <- runStoreIO store $ appendToStream (StreamName "f5-target") NoStream [makeEvent "Init" (Aeson.object [])]-                Right _ <- runStoreIO store $ softDeleteStream (StreamName "f5-target")-                result <- runStoreIO store $ linkToStream (StreamName "f5-target") [eid]-                case result of-                    Left (StreamNotFound _) -> pure ()-                    other -> expectationFailure ("Expected StreamNotFound for soft-deleted target, got: " <> show other)-                -- Soft-deleted target's version must not have advanced.-                Right info <- runStoreIO store $ getStream (StreamName "f5-target")-                case info of-                    Just si -> (si ^. #version) `shouldBe` StreamVersion 1-                    Nothing -> expectationFailure "soft-deleted stream row should still exist"--        -- =================================================================-        -- Category read tests (M5.8)-        -- =================================================================-        describe "readCategory" $ do-            it "reads events from matching category streams in global position order" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "order-1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "order-2") NoStream [makeEvent "OrderShipped" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "user-1") NoStream [makeEvent "UserRegistered" (Aeson.object [])]-                Right result <- runStoreIO store $ readCategory (CategoryName "order") (GlobalPosition 0) 100-                V.length result `shouldBe` 2-                (V.head result ^. #eventType) `shouldBe` EventType "OrderCreated"-                (result V.! 1 ^. #eventType) `shouldBe` EventType "OrderShipped"-                -- Verify global positions are ascending-                (V.head result ^. #globalPosition) `shouldSatisfy` (< (result V.! 1 ^. #globalPosition))--            it "supports pagination with start position and limit" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "cat-1") NoStream [makeEvent "A" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "cat-2") NoStream [makeEvent "B" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "cat-3") NoStream [makeEvent "C" (Aeson.object [])]-                -- Read first 2-                Right page1 <- runStoreIO store $ readCategory (CategoryName "cat") (GlobalPosition 0) 2-                V.length page1 `shouldBe` 2-                -- Read from cursor-                let cursor = page1 V.! 1 ^. #globalPosition-                Right page2 <- runStoreIO store $ readCategory (CategoryName "cat") cursor 100-                V.length page2 `shouldBe` 1--            it "returns empty for nonexistent category" $ \store -> do-                Right result <- runStoreIO store $ readCategory (CategoryName "nope") (GlobalPosition 0) 100-                V.length result `shouldBe` 0--            it "includes linked events that originate from category streams" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "evt-1") NoStream [makeEvent "EventA" (Aeson.object [])]-                Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "evt-1") (StreamVersion 0) 100-                let eid = V.head srcEvents ^. #eventId-                -- Link into a different-category stream-                Right _ <- runStoreIO store $ linkToStream (StreamName "projection-1") [eid]-                -- Category "evt" should still return the event (it originates from evt-1)-                Right result <- runStoreIO store $ readCategory (CategoryName "evt") (GlobalPosition 0) 100-                V.length result `shouldBe` 1-                (V.head result ^. #eventType) `shouldBe` EventType "EventA"--        -- =================================================================-        -- Multi-stream transaction tests (M5.8)-        -- =================================================================-        describe "appendMultiStream" $ do-            it "atomically appends to two streams" $ \store -> do-                let ops =-                        [ (StreamName "multi-a", NoStream, [makeEvent "A" (Aeson.object [])])-                        , (StreamName "multi-b", NoStream, [makeEvent "B" (Aeson.object [])])-                        ]-                Right results <- runStoreIO store $ appendMultiStream ops-                length results `shouldBe` 2-                ((results !! 0) ^. #streamVersion) `shouldBe` StreamVersion 1-                ((results !! 1) ^. #streamVersion) `shouldBe` StreamVersion 1--            it "rolls back all streams on version conflict" $ \store -> do-                -- Create stream multi-c-                Right _ <- runStoreIO store $ appendToStream (StreamName "multi-c") NoStream [makeEvent "C" (Aeson.object [])]-                let ops =-                        [ (StreamName "multi-d", NoStream, [makeEvent "D" (Aeson.object [])])-                        , (StreamName "multi-c", NoStream, [makeEvent "C2" (Aeson.object [])]) -- conflict: already exists-                        ]-                result <- runStoreIO store $ appendMultiStream ops-                case result of-                    Left _ -> do-                        -- Verify multi-d was NOT created (rollback)-                        Right info <- runStoreIO store $ getStream (StreamName "multi-d")-                        info `shouldBe` Nothing-                    Right _ -> expectationFailure "Expected error for version conflict"--            it "appends to three streams with different expected versions" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "tri-1") NoStream [makeEvent "X" (Aeson.object [])]-                let ops =-                        [ (StreamName "tri-1", ExactVersion (StreamVersion 1), [makeEvent "X2" (Aeson.object [])])-                        , (StreamName "tri-2", NoStream, [makeEvent "Y" (Aeson.object [])])-                        , (StreamName "tri-3", AnyVersion, [makeEvent "Z" (Aeson.object [])])-                        ]-                Right results <- runStoreIO store $ appendMultiStream ops-                length results `shouldBe` 3-                ((results !! 0) ^. #streamVersion) `shouldBe` StreamVersion 2-                ((results !! 1) ^. #streamVersion) `shouldBe` StreamVersion 1-                ((results !! 2) ^. #streamVersion) `shouldBe` StreamVersion 1--            -- F4 regression — pre-lock pass over named streams in stream_id order.-            -- The deterministic deadlock-prevention scenario (two concurrent calls-            -- touching the same streams in opposite orders) is covered by EP-6's-            -- planned concurrency-test harness; this test verifies that the-            -- pre-lock does not change user-visible ordering of global positions-            -- within a single multi-stream call.-            it "preserves user-supplied ordering of global positions" $ \store -> do-                let ops =-                        [ (StreamName "f4-zzz", NoStream, [makeEvent "Z" (Aeson.object [])])-                        , (StreamName "f4-mmm", NoStream, [makeEvent "M" (Aeson.object [])])-                        , (StreamName "f4-aaa", NoStream, [makeEvent "A" (Aeson.object [])])-                        ]-                Right results <- runStoreIO store $ appendMultiStream ops-                length results `shouldBe` 3-                let p0 = (results !! 0) ^. #globalPosition-                    p1 = (results !! 1) ^. #globalPosition-                    p2 = (results !! 2) ^. #globalPosition-                p0 `shouldSatisfy` (< p1)-                p1 `shouldSatisfy` (< p2)-                -- Read $all and confirm the events appear in user-supplied order.-                Right allEvts <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-                let names = V.toList (V.map (^. #eventType) allEvts)-                names `shouldBe` [EventType "Z", EventType "M", EventType "A"]--        -- =================================================================-        -- Soft delete tests (M6.8)-        -- =================================================================-        describe "softDeleteStream" $ do-            it "soft-deletes a stream and getStream shows deletedAt" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "del-1") NoStream [makeEvent "A" (Aeson.object [])]-                Right mId <- runStoreIO store $ softDeleteStream (StreamName "del-1")-                mId `shouldSatisfy` (/= Nothing)-                Right info <- runStoreIO store $ getStream (StreamName "del-1")-                case info of-                    Just si -> (si ^. #deletedAt) `shouldSatisfy` (/= Nothing)-                    Nothing -> expectationFailure "Expected stream info with deletedAt set"--            it "returns empty for reads from a soft-deleted stream" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "del-read") NoStream [makeEvent "A" (Aeson.object [])]-                Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-read")-                Right fwd <- runStoreIO store $ readStreamForward (StreamName "del-read") (StreamVersion 0) 100-                V.length fwd `shouldBe` 0-                Right bwd <- runStoreIO store $ readStreamBackward (StreamName "del-read") (StreamVersion 0) 100-                V.length bwd `shouldBe` 0--            it "rejects appends to a soft-deleted stream" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "del-append") NoStream [makeEvent "A" (Aeson.object [])]-                Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-append")-                result <- runStoreIO store $ appendToStream (StreamName "del-append") StreamExists [makeEvent "B" (Aeson.object [])]-                case result of-                    Left (StreamNotFound _) -> pure ()-                    other -> expectationFailure ("Expected StreamNotFound, got: " <> show other)--            it "returns Nothing for nonexistent stream" $ \store -> do-                Right mId <- runStoreIO store $ softDeleteStream (StreamName "no-such")-                mId `shouldBe` Nothing--            it "returns Nothing for already-deleted stream" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "del-twice") NoStream [makeEvent "A" (Aeson.object [])]-                Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-twice")-                Right mId <- runStoreIO store $ softDeleteStream (StreamName "del-twice")-                mId `shouldBe` Nothing--            it "events from soft-deleted stream still appear in $all" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "del-all") NoStream [makeEvent "KeepInAll" (Aeson.object [])]-                Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-all")-                Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-                let matching = V.filter (\e -> (e ^. #eventType) == EventType "KeepInAll") allEvents-                V.length matching `shouldBe` 1--            -- F2 regression — the protection against writes to a soft-deleted stream is-            -- enforced by a `deleted_at IS NULL` filter inside each append CTE, not by a-            -- pre-check that races concurrent soft-deletes. These tests exercise every-            -- append variant so a reader can see at a glance which constructor maps to-            -- which error after a soft-delete.-            it "ExactVersion append rejected against soft-deleted stream" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "f2-cte-exv") NoStream [makeEvent "Init" (Aeson.object [])]-                Right _ <- runStoreIO store $ softDeleteStream (StreamName "f2-cte-exv")-                result <- runStoreIO store $ appendToStream (StreamName "f2-cte-exv") (ExactVersion (StreamVersion 1)) [makeEvent "X" (Aeson.object [])]-                case result of-                    Left (WrongExpectedVersion _ _ _) -> pure ()-                    Left (StreamNotFound _) -> pure ()-                    other -> expectationFailure ("Expected WrongExpectedVersion or StreamNotFound, got: " <> show other)--            it "AnyVersion append rejected against soft-deleted stream" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "f2-cte-any") NoStream [makeEvent "Init" (Aeson.object [])]-                Right _ <- runStoreIO store $ softDeleteStream (StreamName "f2-cte-any")-                result <- runStoreIO store $ appendToStream (StreamName "f2-cte-any") AnyVersion [makeEvent "X" (Aeson.object [])]-                case result of-                    Left (StreamNotFound _) -> pure ()-                    other -> expectationFailure ("Expected StreamNotFound, got: " <> show other)--            it "NoStream append rejected against soft-deleted stream" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "f2-cte-no") NoStream [makeEvent "Init" (Aeson.object [])]-                Right _ <- runStoreIO store $ softDeleteStream (StreamName "f2-cte-no")-                result <- runStoreIO store $ appendToStream (StreamName "f2-cte-no") NoStream [makeEvent "X" (Aeson.object [])]-                case result of-                    Left (StreamAlreadyExists _) -> pure ()-                    other -> expectationFailure ("Expected StreamAlreadyExists, got: " <> show other)--        -- =================================================================-        -- Undelete tests (M6.8)-        -- =================================================================-        describe "undeleteStream" $ do-            it "restores a soft-deleted stream" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "undel-1") NoStream [makeEvent "A" (Aeson.object [])]-                Right _ <- runStoreIO store $ softDeleteStream (StreamName "undel-1")-                Right mId <- runStoreIO store $ undeleteStream (StreamName "undel-1")-                mId `shouldSatisfy` (/= Nothing)-                Right info <- runStoreIO store $ getStream (StreamName "undel-1")-                case info of-                    Just si -> (si ^. #deletedAt) `shouldBe` Nothing-                    Nothing -> expectationFailure "Expected stream info"--            it "reads work after undelete" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "undel-read") NoStream [makeEvent "A" (Aeson.object [])]-                Right _ <- runStoreIO store $ softDeleteStream (StreamName "undel-read")-                Right _ <- runStoreIO store $ undeleteStream (StreamName "undel-read")-                Right events <- runStoreIO store $ readStreamForward (StreamName "undel-read") (StreamVersion 0) 100-                V.length events `shouldBe` 1--            it "appends work after undelete" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "undel-append") NoStream [makeEvent "A" (Aeson.object [])]-                Right _ <- runStoreIO store $ softDeleteStream (StreamName "undel-append")-                Right _ <- runStoreIO store $ undeleteStream (StreamName "undel-append")-                Right r <- runStoreIO store $ appendToStream (StreamName "undel-append") StreamExists [makeEvent "B" (Aeson.object [])]-                (r ^. #streamVersion) `shouldBe` StreamVersion 2--            it "returns Nothing for non-deleted stream" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "undel-noop") NoStream [makeEvent "A" (Aeson.object [])]-                Right mId <- runStoreIO store $ undeleteStream (StreamName "undel-noop")-                mId `shouldBe` Nothing--        -- =================================================================-        -- Hard delete tests (M6.8)-        -- =================================================================-        describe "hardDeleteStream" $ do-            it "hard-deletes a stream completely" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "hard-1") NoStream [makeEvent "A" (Aeson.object [])]-                Right mId <- runStoreIO store $ hardDeleteStream (StreamName "hard-1")-                mId `shouldSatisfy` (/= Nothing)-                Right info <- runStoreIO store $ getStream (StreamName "hard-1")-                info `shouldBe` Nothing--            it "events from hard-deleted stream no longer appear in $all" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "hard-all") NoStream [makeEvent "HardDelEvent" (Aeson.object [])]-                Right _ <- runStoreIO store $ hardDeleteStream (StreamName "hard-all")-                Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-                let matching = V.filter (\e -> (e ^. #eventType) == EventType "HardDelEvent") allEvents-                V.length matching `shouldBe` 0--            it "returns Nothing for nonexistent stream" $ \store -> do-                Right mId <- runStoreIO store $ hardDeleteStream (StreamName "hard-no-such")-                mId `shouldBe` Nothing--            -- F6 regression — TRUNCATE on protected tables must be gated by the-            -- same GUC as DELETE. Without the BEFORE TRUNCATE triggers added in-            -- EP-1 F6, an operator could TRUNCATE events / stream_events / streams-            -- without setting kiroku.enable_hard_deletes, bypassing the row-level-            -- protect_deletion check.-            it "TRUNCATE on events is rejected without the GUC" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "f6-truncate") NoStream [makeEvent "X" (Aeson.object [])]-                rejected <- truncateRejected store "events"-                rejected `shouldBe` True--            it "TRUNCATE on stream_events is rejected without the GUC" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "f6-truncate-se") NoStream [makeEvent "X" (Aeson.object [])]-                rejected <- truncateRejected store "stream_events"-                rejected `shouldBe` True--            it "TRUNCATE on streams is rejected without the GUC" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "f6-truncate-s") NoStream [makeEvent "X" (Aeson.object [])]-                rejected <- truncateRejected store "streams"-                rejected `shouldBe` True--            -- F1 regression — events orphaned in `events` table after hard-delete.-            it "removes orphan event payloads from the events table" $ \store -> do-                let evts = map (\i -> makeEvent ("F1Orphan" <> T.pack (show i)) (Aeson.object [])) [1 .. 3 :: Int]-                Right _ <- runStoreIO store $ appendToStream (StreamName "f1-orphan") NoStream evts-                countBefore <- countEvents store-                Right _ <- runStoreIO store $ hardDeleteStream (StreamName "f1-orphan")-                countAfter <- countEvents store-                (countBefore - countAfter) `shouldBe` 3--            it "removes dead letters for orphaned events before deleting their payloads" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "dl-hard-source") NoStream [makeEvent "DeadLettered" (Aeson.object [])]-                Right events <- runStoreIO store $ readStreamForward (StreamName "dl-hard-source") (StreamVersion 0) 10-                let event = V.head events-                    eid = event ^. #eventId-                insertDeadLetterForEvent store "dl-hard-subscription" event-                countDeadLettersForEvents store [eid] `shouldReturn` 1--                Right mId <- runStoreIO store $ hardDeleteStream (StreamName "dl-hard-source")-                mId `shouldSatisfy` (/= Nothing)-                countDeadLettersForEvents store [eid] `shouldReturn` 0-                Right remaining <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-                V.filter (\recorded -> (recorded ^. #eventId) == eid) remaining `shouldBe` V.empty--            it "keeps dead letters for linked-in events that survive a hard delete" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "dl-origin") NoStream [makeEvent "Survivor" (Aeson.object [])]-                Right originEvents <- runStoreIO store $ readStreamForward (StreamName "dl-origin") (StreamVersion 0) 10-                let event = V.head originEvents-                    eid = event ^. #eventId-                Right _ <- runStoreIO store $ linkToStream (StreamName "dl-link-target") [eid]-                insertDeadLetterForEvent store "dl-survivor-subscription" event-                countDeadLettersForEvents store [eid] `shouldReturn` 1--                Right _ <- runStoreIO store $ hardDeleteStream (StreamName "dl-link-target")-                countDeadLettersForEvents store [eid] `shouldReturn` 1-                Right afterTargetDelete <- runStoreIO store $ readStreamForward (StreamName "dl-origin") (StreamVersion 0) 10-                fmap (^. #eventId) (V.toList afterTargetDelete) `shouldBe` [eid]--                Right _ <- runStoreIO store $ hardDeleteStream (StreamName "dl-origin")-                countDeadLettersForEvents store [eid] `shouldReturn` 0-                Right afterOriginDelete <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-                V.filter (\recorded -> (recorded ^. #eventId) == eid) afterOriginDelete `shouldBe` V.empty--            -- F1 regression — events that are linked to other streams must survive-            -- hard-delete of their source stream's owner if any non-target junctions remain.-            it "preserves events still linked to non-target streams" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "f1-keep-src") NoStream [makeEvent "Keep" (Aeson.object [])]-                Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "f1-keep-src") (StreamVersion 0) 100-                let eid = V.head srcEvents ^. #eventId-                -- Append another stream that gets a different event so we can hard-delete keep-src-                -- without touching the linked stream's source. Then link f1-keep-src's event to a-                -- third stream we will leave alone.-                Right _ <- runStoreIO store $ appendToStream (StreamName "f1-other") NoStream [makeEvent "Other" (Aeson.object [])]-                -- Link via appendToStream + linkToStream to a different stream-                _ <- runStoreIO store $ linkToStream (StreamName "f1-keep-link") [eid]-                -- Hard-deleting f1-keep-src removes the source's events even though the link-                -- referenced them: link rows have original_stream_id = f1-keep-src's id, so they-                -- match the junction-delete WHERE clause. The contract is documented in F1's fix-                -- commit: hard-delete cascades through link junctions of the deleted stream's-                -- original events. (See SQL.hs hard-delete documentation.)-                Right _ <- runStoreIO store $ hardDeleteStream (StreamName "f1-keep-src")-                -- f1-other's event must still exist (not affected at all).-                Right otherEvents <- runStoreIO store $ readStreamForward (StreamName "f1-other") (StreamVersion 0) 100-                V.length otherEvents `shouldBe` 1--        -- =================================================================-        -- Subscription tests (M7.7)-        -- =================================================================-        describe "subscribe" $ do-            it "catches up from position 0 on a store with existing events" $ \store -> do-                -- Append 10 events-                let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]-                Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-1") NoStream events-                -- Wait until the EventPublisher has ingested all 10 events.-                waitForPublisher store (GlobalPosition 10)-                -- Subscribe from position 0-                ref <- newIORef ([] :: [RecordedEvent])-                countRef <- newTVarIO (0 :: Int)-                let handler' evt = do-                        modifyIORef' ref (evt :)-                        n <- atomically $ do-                            c <- readTVar countRef-                            let c' = c + 1-                            writeTVar countRef c'-                            pure c'-                        if n >= 10-                            then pure Stop-                            else pure Continue-                let cfg =-                        SubscriptionConfig-                            { name = SubscriptionName "catchup-test"-                            , target = AllStreams-                            , handler = handler'-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                handle <- subscribe store cfg-                result <- waitWithTimeout 10_000_000 handle-                case result of-                    Left timeout -> expectationFailure timeout-                    Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                    Right (Right ()) -> pure ()-                collected <- readIORef ref-                length collected `shouldBe` 10--            it "receives live events appended after subscription starts" $ \store -> do-                ref <- newIORef ([] :: [RecordedEvent])-                countRef <- newTVarIO (0 :: Int)-                let handler' evt = do-                        modifyIORef' ref (evt :)-                        n <- atomically $ do-                            c <- readTVar countRef-                            let c' = c + 1-                            writeTVar countRef c'-                            pure c'-                        if n >= 5-                            then pure Stop-                            else pure Continue-                let cfg =-                        SubscriptionConfig-                            { name = SubscriptionName "live-test"-                            , target = AllStreams-                            , handler = handler'-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                handle <- subscribe store cfg-                -- Give subscription time to start-                threadDelay 100_000-                -- Append 5 events-                let events = map (\i -> makeEvent ("Live" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]-                Right _ <- runStoreIO store $ appendToStream (StreamName "live-1") NoStream events-                -- Wait for subscription to complete-                result <- waitWithTimeout 10_000_000 handle-                case result of-                    Left timeout -> expectationFailure timeout-                    Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                    Right (Right ()) -> pure ()-                collected <- readIORef ref-                length collected `shouldBe` 5--            it "persists checkpoint and resumes from saved position" $ \store -> do-                -- Append 10 events-                let events = map (\i -> makeEvent ("CP" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]-                Right _ <- runStoreIO store $ appendToStream (StreamName "ckpt-1") NoStream events-                waitForPublisher store (GlobalPosition 10)-                -- First subscription: process 5 events then stop-                countRef1 <- newTVarIO (0 :: Int)-                let handler1 _evt = do-                        n <- atomically $ do-                            c <- readTVar countRef1-                            let c' = c + 1-                            writeTVar countRef1 c'-                            pure c'-                        if n >= 5-                            then pure Stop-                            else pure Continue-                let cfg1 =-                        SubscriptionConfig-                            { name = SubscriptionName "ckpt-test"-                            , target = AllStreams-                            , handler = handler1-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                h1 <- subscribe store cfg1-                _ <- waitWithTimeout 10_000_000 h1-                -- Second subscription with same name: should resume from position 5-                ref2 <- newIORef ([] :: [RecordedEvent])-                countRef2 <- newTVarIO (0 :: Int)-                let handler2 evt = do-                        modifyIORef' ref2 (evt :)-                        n <- atomically $ do-                            c <- readTVar countRef2-                            let c' = c + 1-                            writeTVar countRef2 c'-                            pure c'-                        if n >= 5-                            then pure Stop-                            else pure Continue-                let cfg2 =-                        SubscriptionConfig-                            { name = SubscriptionName "ckpt-test"-                            , target = AllStreams-                            , handler = handler2-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                h2 <- subscribe store cfg2-                _ <- waitWithTimeout 10_000_000 h2-                collected <- readIORef ref2-                -- Should receive events 6–10 only-                length collected `shouldBe` 5--            it "does not replay catch-up events when switching to all-stream live mode" $ \store -> do-                let seedEvents = map (\i -> makeEvent ("TransitionSeed" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]-                Right _ <- runStoreIO store $ appendToStream (StreamName "transition-seed") NoStream seedEvents-                waitForPublisher store (GlobalPosition 5)--                firstSeen <- newEmptyMVar-                releaseFirst <- newEmptyMVar-                countRef <- newTVarIO (0 :: Int)-                ref <- newIORef ([] :: [RecordedEvent])-                let stopType = EventType "TransitionStopAfterLive"-                    handler' evt = do-                        modifyIORef' ref (evt :)-                        n <- atomically $ do-                            c <- readTVar countRef-                            let c' = c + 1-                            writeTVar countRef c'-                            pure c'-                        if n == 1-                            then do-                                putMVar firstSeen ()-                                takeMVar releaseFirst-                            else pure ()-                        if evt ^. #eventType == stopType-                            then pure Stop-                            else pure Continue-                let cfg =-                        SubscriptionConfig-                            { name = SubscriptionName "transition-no-duplicates"-                            , target = AllStreams-                            , handler = handler'-                            , batchSize = either (error . show) Prelude.id (mkBatchSize 2)-                            , queueCapacity = 32-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                handle <- subscribe store cfg-                takeMVar firstSeen--                mapM_-                    ( \i -> do-                        let sn = StreamName ("transition-during-" <> T.pack (show (i :: Int)))-                        Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent ("TransitionDuring" <> T.pack (show i)) (Aeson.object [])]-                        pure ()-                    )-                    [6 .. 10]-                waitForPublisher store (GlobalPosition 10)-                putMVar releaseFirst ()-                atomically $ do-                    c <- readTVar countRef-                    check (c >= 10)--                Right _ <- runStoreIO store $ appendToStream (StreamName "transition-stop") NoStream [makeEvent "TransitionStopAfterLive" (Aeson.object [])]-                result <- waitWithTimeout 10_000_000 handle-                case result of-                    Left timeout -> expectationFailure timeout-                    Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                    Right (Right ()) -> pure ()--                collected <- reverse <$> readIORef ref-                map (^. #globalPosition) collected `shouldBe` map GlobalPosition [1 .. 11]--            it "does not skip an event when cancelled before checkpoint save" $ \store -> do-                let events = map (\i -> makeEvent ("CancelReplay" <> T.pack (show i)) (Aeson.object [])) [1 .. 3 :: Int]-                Right _ <- runStoreIO store $ appendToStream (StreamName "cancel-replay") NoStream events-                waitForPublisher store (GlobalPosition 3)--                firstSeen <- newEmptyMVar-                block <- newEmptyMVar-                firstRef <- newIORef ([] :: [RecordedEvent])-                let handler1 evt = do-                        modifyIORef' firstRef (evt :)-                        putMVar firstSeen ()-                        _ <- takeMVar block-                        pure Continue-                    cfg1 =-                        SubscriptionConfig-                            { name = SubscriptionName "cancel-replay-test"-                            , target = AllStreams-                            , handler = handler1-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                h1 <- subscribe store cfg1-                takeMVar firstSeen-                cancel h1-                _ <- waitWithTimeout 5_000_000 h1--                ref2 <- newIORef ([] :: [RecordedEvent])-                let handler2 evt = do-                        modifyIORef' ref2 (evt :)-                        pure Stop-                    cfg2 =-                        SubscriptionConfig-                            { name = SubscriptionName "cancel-replay-test"-                            , target = AllStreams-                            , handler = handler2-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                h2 <- subscribe store cfg2-                result <- waitWithTimeout 10_000_000 h2-                case result of-                    Left timeout -> expectationFailure timeout-                    Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                    Right (Right ()) -> pure ()-                replayed <- reverse <$> readIORef ref2-                map (^. #globalPosition) replayed `shouldBe` [GlobalPosition 1]--            it "saves checkpoints at Stop boundaries without skipping the next event" $ \store -> do-                let events = map (\i -> makeEvent ("StopBoundary" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]-                Right _ <- runStoreIO store $ appendToStream (StreamName "stop-boundary") NoStream events-                waitForPublisher store (GlobalPosition 5)--                countRef1 <- newTVarIO (0 :: Int)-                let handler1 _evt = do-                        n <- atomically $ do-                            c <- readTVar countRef1-                            let c' = c + 1-                            writeTVar countRef1 c'-                            pure c'-                        if n >= 3 then pure Stop else pure Continue-                    cfg1 =-                        SubscriptionConfig-                            { name = SubscriptionName "stop-boundary-test"-                            , target = AllStreams-                            , handler = handler1-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                h1 <- subscribe store cfg1-                _ <- waitWithTimeout 10_000_000 h1--                ref2 <- newIORef ([] :: [RecordedEvent])-                countRef2 <- newTVarIO (0 :: Int)-                let handler2 evt = do-                        modifyIORef' ref2 (evt :)-                        n <- atomically $ do-                            c <- readTVar countRef2-                            let c' = c + 1-                            writeTVar countRef2 c'-                            pure c'-                        if n >= 2 then pure Stop else pure Continue-                    cfg2 =-                        SubscriptionConfig-                            { name = SubscriptionName "stop-boundary-test"-                            , target = AllStreams-                            , handler = handler2-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                h2 <- subscribe store cfg2-                result <- waitWithTimeout 10_000_000 h2-                case result of-                    Left timeout -> expectationFailure timeout-                    Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                    Right (Right ()) -> pure ()-                replayed <- reverse <$> readIORef ref2-                map (^. #globalPosition) replayed `shouldBe` [GlobalPosition 4, GlobalPosition 5]--            -- F18 regression — Category subscriptions in live mode previously-            -- received unfiltered events from $all because `filterEvents` was-            -- a no-op for Category. The fix routes Category live-mode through-            -- a DB-driven loop that re-uses the SQL category filter. Before-            -- the fix, the handler would observe the UserNoise event below.-            it "delivers only category-matching events during live mode (F18)" $ \store -> do-                seedSeen <- newEmptyMVar-                ref <- newIORef ([] :: [RecordedEvent])-                countRef <- newTVarIO (0 :: Int)-                let handler' evt = do-                        modifyIORef' ref (evt :)-                        n <- atomically $ do-                            c <- readTVar countRef-                            let c' = c + 1-                            writeTVar countRef c'-                            pure c'-                        -- After the catch-up seed event, signal that we are-                        -- entering live mode. Subsequent appends exercise-                        -- the live path under the new DB-driven loop.-                        if n == 1-                            then putMVar seedSeen ()-                            else pure ()-                        if n >= 3 then pure Stop else pure Continue-                -- Pre-seed an order event so catch-up fires the handler once.-                Right _ <--                    runStoreIO store $-                        appendToStream-                            (StreamName "order-seed")-                            NoStream-                            [makeEvent "OrderSeed" (Aeson.object [])]-                let cfg =-                        SubscriptionConfig-                            { name = SubscriptionName "f18-live-test"-                            , target = Category (CategoryName "order")-                            , handler = handler'-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                handle <- subscribe store cfg-                -- Block until catch-up has delivered the seed event — the-                -- worker has finished catch-up and is about to enter live-                -- mode.-                takeMVar seedSeen-                -- Append a non-matching user event followed by two matching-                -- order events. Under the bug, UserNoise would slip through-                -- the live broadcast unfiltered.-                Right _ <- runStoreIO store $ appendToStream (StreamName "user-1") NoStream [makeEvent "UserNoise" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "order-2") NoStream [makeEvent "OrderTwo" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "order-3") NoStream [makeEvent "OrderThree" (Aeson.object [])]-                result <- waitWithTimeout 10_000_000 handle-                case result of-                    Left timeout -> expectationFailure timeout-                    Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                    Right (Right ()) -> pure ()-                collected <- readIORef ref-                length collected `shouldBe` 3-                -- No UserNoise event should have been delivered.-                mapM_-                    (\e -> (e ^. #eventType) `shouldNotBe` EventType "UserNoise")-                    collected--            it "delivers only category-matching events during catch-up" $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "order-1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "order-2") NoStream [makeEvent "OrderShipped" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "user-1") NoStream [makeEvent "UserRegistered" (Aeson.object [])]-                waitForPublisher store (GlobalPosition 3)-                ref <- newIORef ([] :: [RecordedEvent])-                countRef <- newTVarIO (0 :: Int)-                let handler' evt = do-                        modifyIORef' ref (evt :)-                        n <- atomically $ do-                            c <- readTVar countRef-                            let c' = c + 1-                            writeTVar countRef c'-                            pure c'-                        if n >= 2-                            then pure Stop-                            else pure Continue-                let cfg =-                        SubscriptionConfig-                            { name = SubscriptionName "cat-sub-test"-                            , target = Category (CategoryName "order")-                            , handler = handler'-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                handle <- subscribe store cfg-                _ <- waitWithTimeout 10_000_000 handle-                collected <- readIORef ref-                length collected `shouldBe` 2-                -- All events should be order events-                mapM_ (\e -> (e ^. #eventType) `shouldSatisfy` (\(EventType t) -> T.isPrefixOf "Order" t)) collected--            it "preserves category subscription order under mixed invoice-payment writes" $ \store -> do-                let appendOne sn typ =-                        runStoreIO store (appendToStream sn AnyVersion [makeEvent typ (Aeson.object [])]) >>= \result ->-                            case result of-                                Right _ -> pure ()-                                Left err -> expectationFailure ("append failed: " <> show err)-                appendOne (StreamName "invoice-payment") "InvoicePaymentStarted"-                appendOne (StreamName "user-1") "UserNoiseOne"-                appendOne (StreamName "invoice-reminder") "InvoiceReminderSent"-                appendOne (StreamName "order-1") "OrderNoiseOne"-                appendOne (StreamName "invoice-payment") "InvoicePaymentFinished"-                appendOne (StreamName "user-2") "UserNoiseTwo"-                appendOne (StreamName "invoice-refund") "InvoiceRefundFinished"-                waitForPublisher store (GlobalPosition 7)--                ref <- newIORef ([] :: [RecordedEvent])-                countRef <- newTVarIO (0 :: Int)-                let handler' evt = do-                        modifyIORef' ref (evt :)-                        n <- atomically $ do-                            c <- readTVar countRef-                            let c' = c + 1-                            writeTVar countRef c'-                            pure c'-                        if n >= 4 then pure Stop else pure Continue-                    cfg =-                        SubscriptionConfig-                            { name = SubscriptionName "invoice-category-ordering"-                            , target = Category (CategoryName "invoice")-                            , handler = handler'-                            , batchSize = either (error . show) Prelude.id (mkBatchSize 2)-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                handle <- subscribe store cfg-                result <- waitWithTimeout 10_000_000 handle-                case result of-                    Left timeout -> expectationFailure timeout-                    Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                    Right (Right ()) -> pure ()-                collected <- reverse <$> readIORef ref-                map (^. #globalPosition) collected `shouldBe` [GlobalPosition 1, GlobalPosition 3, GlobalPosition 5, GlobalPosition 7]-                map (^. #eventType) collected-                    `shouldBe` [ EventType "InvoicePaymentStarted"-                               , EventType "InvoiceReminderSent"-                               , EventType "InvoicePaymentFinished"-                               , EventType "InvoiceRefundFinished"-                               ]--            it "cancels a running subscription cleanly" $ \store -> do-                let handler' _evt = pure Continue-                let cfg =-                        SubscriptionConfig-                            { name = SubscriptionName "cancel-test"-                            , target = AllStreams-                            , handler = handler'-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                handle <- subscribe store cfg-                -- Give it time to start-                threadDelay 100_000-                cancel handle-                result <- waitWithTimeout 5_000_000 handle-                -- Should exit cleanly (AsyncCancelled or Right ())-                case result of-                    Left _timeout -> expectationFailure "Cancel did not terminate in time"-                    Right (Left _) -> pure () -- AsyncCancelled is expected-                    Right (Right ()) -> pure ()--            it "receives events appended to an initially empty store" $ \store -> do-                ref <- newIORef ([] :: [RecordedEvent])-                let handler' evt = do-                        modifyIORef' ref (evt :)-                        pure Stop-                let cfg =-                        SubscriptionConfig-                            { name = SubscriptionName "empty-store-test"-                            , target = AllStreams-                            , handler = handler'-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                handle <- subscribe store cfg-                -- Give subscription time to start in live mode-                threadDelay 100_000-                -- Append one event-                Right _ <- runStoreIO store $ appendToStream (StreamName "empty-1") NoStream [makeEvent "First" (Aeson.object [])]-                result <- waitWithTimeout 10_000_000 handle-                case result of-                    Left timeout -> expectationFailure timeout-                    Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                    Right (Right ()) -> pure ()-                collected <- readIORef ref-                length collected `shouldBe` 1--            it "handles rapid appends without losing events (debouncing)" $ \store -> do-                ref <- newIORef ([] :: [RecordedEvent])-                countRef <- newTVarIO (0 :: Int)-                let handler' evt = do-                        modifyIORef' ref (evt :)-                        n <- atomically $ do-                            c <- readTVar countRef-                            let c' = c + 1-                            writeTVar countRef c'-                            pure c'-                        if n >= 50-                            then pure Stop-                            else pure Continue-                let cfg =-                        SubscriptionConfig-                            { name = SubscriptionName "debounce-test"-                            , target = AllStreams-                            , handler = handler'-                            , batchSize = defaultBatchSize-                            , -- Sized to absorb 50 publisher batches without-                              -- backpressure overflow. The default 16 was-                              -- intermittently overrun under load (the-                              -- publisher emits one batch per append because-                              -- there is no inter-append debounce window in-                              -- this loop), surfacing as-                              -- SubscriptionOverflowed in flaky CI runs.-                              -- Coverage of the bounded-queue policy lives-                              -- in the F6 overflow test below, not here.-                              queueCapacity = 64-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                handle <- subscribe store cfg-                threadDelay 100_000-                -- Append 50 events rapidly to different streams-                mapM_-                    ( \i -> do-                        let sn = StreamName ("rapid-" <> T.pack (show (i :: Int)))-                        Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent ("R" <> T.pack (show i)) (Aeson.object [])]-                        pure ()-                    )-                    [1 .. 50]-                result <- waitWithTimeout 30_000_000 handle-                case result of-                    Left timeout -> expectationFailure timeout-                    Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                    Right (Right ()) -> pure ()-                collected <- readIORef ref-                length collected `shouldBe` 50--            -- F6 regression — the publisher's broadcast was an unbounded TChan;-            -- a slow subscriber's dupTChan grew without limit until the host-            -- ran out of memory. The fix replaces the broadcast with a-            -- per-subscriber bounded TBQueue plus an overflow policy. With-            -- DropSubscription, the publisher signals overflow on the-            -- subscriber's status TVar; the worker observes it on its next-            -- STM read and surfaces SubscriptionOverflowed via wait/waitCatch.-            it "surfaces SubscriptionOverflowed when a slow subscriber overruns its queue (F6)" $ \store -> do-                firstSeen <- newEmptyMVar-                release <- newEmptyMVar-                seenCount <- newTVarIO (0 :: Int)-                let handler' _evt = do-                        n <- atomically $ do-                            c <- readTVar seenCount-                            let c' = c + 1-                            writeTVar seenCount c'-                            pure c'-                        if n == 1-                            then do-                                putMVar firstSeen ()-                                takeMVar release-                            else pure ()-                        pure Continue-                let cfg =-                        SubscriptionConfig-                            { name = SubscriptionName "f6-overflow-test"-                            , target = AllStreams-                            , handler = handler'-                            , batchSize = defaultBatchSize-                            , queueCapacity = 1-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                handle <- subscribe store cfg-                -- This fixture requires a live handler; catch-up could otherwise-                -- consume the queued range before observing overflow.-                let awaitLive = do-                        st <- currentState handle-                        case st of-                            Just Fsm.Live{} -> pure ()-                            _ -> threadDelay 1_000 >> awaitLive-                Async.race (threadDelay 5_000_000) awaitLive >>= (`shouldBe` Right ())-                -- First append: triggers handler, which blocks on the release MVar.-                Right _ <- runStoreIO store $ appendToStream (StreamName "f6-1") NoStream [makeEvent "E1" (Aeson.object [])]-                takeMVar firstSeen-                -- While the worker is stuck inside the handler, append events one-                -- at a time and wait for the publisher to fetch each individually.-                -- Each append corresponds to a separate publisher batch written-                -- to the subscriber's queue. Capacity is 1, so the second-                -- write fills it and the third triggers DropSubscription.-                let waitForPub n = atomically $ do-                        GlobalPosition p <- publisherPosition (store ^. #publisher)-                        check (p >= n)-                let appendOne i = do-                        let sn = StreamName ("f6-" <> T.pack (show (i :: Int)))-                        Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]-                        waitForPub (fromIntegral i)-                -- Append 4 more events with publisher synchronisation. Combined-                -- with the first event, that is 5 publisher batches.-                appendOne 2-                appendOne 3-                appendOne 4-                appendOne 5-                -- Release the handler. Worker exits processEvents and on the-                -- next STM read observes Overflowed.-                putMVar release ()-                result <- waitWithTimeout 10_000_000 handle-                case result of-                    Left timeout -> expectationFailure timeout-                    Right (Right ()) -> expectationFailure "expected SubscriptionOverflowed, got clean exit"-                    Right (Left e) ->-                        case Control.Exception.fromException e of-                            Just (SubscriptionOverflowed sn) ->-                                sn `shouldBe` SubscriptionName "f6-overflow-test"-                            Nothing ->-                                expectationFailure ("expected SubscriptionOverflowed, got: " <> show e)--                ref2 <- newIORef ([] :: [RecordedEvent])-                countRef2 <- newTVarIO (0 :: Int)-                let handler2 evt = do-                        modifyIORef' ref2 (evt :)-                        n <- atomically $ do-                            c <- readTVar countRef2-                            let c' = c + 1-                            writeTVar countRef2 c'-                            pure c'-                        if n >= 4 then pure Stop else pure Continue-                    cfg2 =-                        SubscriptionConfig-                            { name = SubscriptionName "f6-overflow-test"-                            , target = AllStreams-                            , handler = handler2-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                h2 <- subscribe store cfg2-                replayResult <- waitWithTimeout 10_000_000 h2-                case replayResult of-                    Left timeout -> expectationFailure timeout-                    Right (Left err) -> expectationFailure ("Subscription failed after overflow restart: " <> show err)-                    Right (Right ()) -> pure ()-                replayed <- reverse <$> readIORef ref2-                map (^. #globalPosition) replayed `shouldBe` map GlobalPosition [2 .. 5]--            it "catches up with an Eff-based handler via the effectful API" $ \store -> do-                -- Append 10 events-                let events = map (\i -> makeEvent ("Eff" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]-                Right _ <- runStoreIO store $ appendToStream (StreamName "eff-sub-1") NoStream events-                waitForPublisher store (GlobalPosition 10)-                -- Subscribe using the effectful API with an Eff-based handler-                ref <- newIORef ([] :: [RecordedEvent])-                countRef <- newTVarIO (0 :: Int)-                result <- runEff . runErrorNoCallStack @StoreError . runStorePool store . runSubscription store $ do-                    let effHandler :: (IOE :> es) => RecordedEvent -> Eff es SubscriptionResult-                        effHandler evt = do-                            liftIO $ modifyIORef' ref (evt :)-                            n <- liftIO $ atomically $ do-                                c <- readTVar countRef-                                let c' = c + 1-                                writeTVar countRef c'-                                pure c'-                            if n >= 10-                                then pure Stop-                                else pure Continue-                    let cfg =-                            SubscriptionConfig-                                { name = SubscriptionName "eff-catchup-test"-                                , target = AllStreams-                                , handler = effHandler-                                , batchSize = defaultBatchSize-                                , queueCapacity = 16-                                , overflowPolicy = DropSubscription-                                , consumerGroup = Nothing-                                , consumerGroupGuard = False-                                , missingCheckpointPolicy = FromBeginning-                                , targetBindingPolicy = AdoptUnbound-                                , undecodableHandler = Nothing-                                , handlerStallWarnAfter = Nothing-                                , retryPolicy = defaultRetryPolicy-                                , eventTypeFilter = AllEventTypes-                                , selector = Nothing-                                }-                    handle <- SubEff.subscribe cfg-                    liftIO $ do-                        r <- waitWithTimeout 10_000_000 handle-                        case r of-                            Left timeout -> expectationFailure timeout-                            Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                            Right (Right ()) -> pure ()-                case result of-                    Left err -> expectationFailure ("Store error: " <> show err)-                    Right () -> pure ()-                collected <- readIORef ref-                length collected `shouldBe` 10--        -- =================================================================-        -- withSubscription bracket tests (EP-2 F25)-        -- =================================================================-        describe "withSubscription" $ do-            -- F25 regression — bracket cancels the worker on normal scope exit.-            it "cancels the worker on normal scope exit" $ \store -> do-                handleRef <- newIORef Nothing-                let cfg =-                        SubscriptionConfig-                            { name = SubscriptionName "withsub-normal"-                            , target = AllStreams-                            , handler = \_ -> pure Continue-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                withSubscription store cfg $ \h -> do-                    writeIORef handleRef (Just h)-                    threadDelay 100_000-                Just h <- readIORef handleRef-                -- Worker must terminate now that the scope has exited.-                outcome <- Async.race (threadDelay 2_000_000) (wait h)-                case outcome of-                    Left () -> expectationFailure "worker did not exit after withSubscription scope"-                    Right _ -> pure ()--            -- F25 regression — bracket cancels the worker even when the body throws.-            it "cancels the worker when the body throws" $ \store -> do-                handleRef <- newIORef Nothing-                let cfg =-                        SubscriptionConfig-                            { name = SubscriptionName "withsub-throw"-                            , target = AllStreams-                            , handler = \_ -> pure Continue-                            , batchSize = defaultBatchSize-                            , queueCapacity = 16-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                result <--                    Control.Exception.try @SomeException $-                        withSubscription store cfg $ \h -> do-                            writeIORef handleRef (Just h)-                            threadDelay 100_000-                            error "withSubscription body deliberately throws"-                case result of-                    Left _ -> pure ()-                    Right () -> expectationFailure "expected exception to propagate"-                Just h <- readIORef handleRef-                outcome <- Async.race (threadDelay 2_000_000) (wait h)-                case outcome of-                    Left () -> expectationFailure "worker did not exit after exception in withSubscription body"-                    Right _ -> pure ()--    -- =================================================================-    -- Pure helpers (no database fixture)-    -- =================================================================--    -- F1 regression — pure detail parser used by attributeMultiStreamError.-    -- The integration scenario that would exercise this code path-    -- (ix_streams_stream_name 23505 raised inside an appendMultiStream txn)-    -- is unreachable via current SQL because every append CTE uses ON CONFLICT-    -- DO NOTHING / DO UPDATE. The pure helper is unit-tested here so a future-    -- schema change that introduces such a path arrives with the attribution-    -- already correct.-    describe "extractStreamNameFromDetail" $ do-        it "extracts the stream name from a typical PostgreSQL detail" $ do-            extractStreamNameFromDetail "Key (stream_name)=(orders-1) already exists."-                `shouldBe` Just "orders-1"-        it "extracts a stream name containing dashes and digits" $ do-            extractStreamNameFromDetail "Key (stream_name)=(multi-c-2) already exists."-                `shouldBe` Just "multi-c-2"-        it "returns Nothing when the detail is empty" $ do-            extractStreamNameFromDetail "" `shouldBe` Nothing-        it "returns Nothing when the detail does not contain '=('" $ do-            extractStreamNameFromDetail "no key here" `shouldBe` Nothing-        it "returns Nothing for an empty parenthesised value" $ do-            extractStreamNameFromDetail "Key (stream_name)=() already exists."-                `shouldBe` Nothing--    describe "isTransientSerializationError" $ do-        let serverUsage code =-                SessionUsageError $-                    Errors.StatementSessionError-                        1-                        0-                        ""-                        []-                        True-                        ( Errors.ServerStatementError $-                            Errors.ServerError code "server error" Nothing Nothing Nothing-                        )-        it "recognizes only serialization and deadlock SQLSTATEs" $ do-            isTransientSerializationError (serverUsage "40P01") `shouldBe` True-            isTransientSerializationError (serverUsage "40001") `shouldBe` True-            isTransientSerializationError (serverUsage "23505") `shouldBe` False-            isTransientSerializationError AcquisitionTimeoutUsageError `shouldBe` False--        -- The store retries an append once on these codes, so a caller only-        -- sees one when the conflict repeated. It must arrive as the retryable-        -- constructor: 'UnexpectedServerError' documents itself as "not-        -- generally retryable — investigate", which would send a caller to-        -- diagnose a condition whose correct handling is to retry.-        it "maps both transient SQLSTATEs to TransientTransactionFailure" $ do-            forM_ ["40001", "40P01"] $ \code -> do-                mapUsageError "orders-1" AnyVersion (serverUsage code)-                    `shouldBe` TransientTransactionFailure code "server error"-                mapGenericUsageError (serverUsage code)-                    `shouldBe` TransientTransactionFailure code "server error"-                mapTransactionUsageError (serverUsage code)-                    `shouldBe` TransientTransactionFailure code "server error"-                mapLinkUsageError (StreamName "orders-1") (serverUsage code)-                    `shouldBe` TransientTransactionFailure code "server error"-                attributeMultiStreamError-                    [(StreamName "orders-1", AnyVersion)]-                    (serverUsage code)-                    `shouldBe` TransientTransactionFailure code "server error"--        it "leaves non-transient server codes on UnexpectedServerError" $ do-            mapGenericUsageError (serverUsage "42883")-                `shouldBe` UnexpectedServerError "42883" "server error"--    -- =================================================================-    -- Notifier reconnection tests (EP-3 F1)-    -- =================================================================-    describe "Notifier reconnection" $ do-        -- F1 regression — the listener loop reconnects on backend termination,-        -- and `stopNotifier` must release the *current* (post-reconnect)-        -- connection. Without the fix, the original Notifier.listenerConn was-        -- a frozen reference to the first conn and the reconnected conn leaked-        -- past `withStore` exit. We assert no kiroku-listener backend remains-        -- in pg_stat_activity after the store shuts down.-        it "releases the reconnected listener connection on shutdown" $ \() -> do-            n <- withMigratedTestDatabase $ \connStr -> do-                let settings = defaultConnectionSettings connStr-                withStore settings $ \store -> do-                    pid1 <- waitForListenerPid (store ^. #pool) 5_000_000-                    terminateBackend (store ^. #pool) pid1-                    pid2 <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000-                    pid2 `shouldNotBe` pid1-                -- After withStore exits, no kiroku-listener connection remains.-                listenerCount connStr-            n `shouldBe` (0 :: Int64)--    -- =================================================================-    -- Health monitoring tests (M6.8)-    -- =================================================================-    describe "observationHandler" $ do-        it "receives observations during store operations" $ \() -> do-            ref <- newIORef ([] :: [Observation])-            let handler obs = modifyIORef' ref (obs :)-            withTestStoreSettings-                (\settings -> settings{observationHandler = Just handler})-                $ \store -> do-                    Right _ <- runStoreIO store $ appendToStream (StreamName "obs-test") NoStream [makeEvent "X" (Aeson.object [])]-                    pure ()-            observations <- readIORef ref-            length observations `shouldSatisfy` (> 0)--    -- =================================================================-    -- KirokuEvent observation surface (EP-5 F1, F13, F14)-    -- =================================================================-    describe "KirokuEvent observation" $ do-        -- EP-5 F1 regression — terminating the listener backend triggers-        -- KirokuEventNotifierReconnecting then KirokuEventNotifierReconnected.-        -- Before EP-5 the reconnect path emitted no signal at all.-        it "emits notifier reconnect events on backend termination (F1)" $ \() -> do-            ref <- newIORef ([] :: [KirokuEvent])-            let evtHandler e = modifyIORef' ref (e :)-            withMigratedTestDatabase $ \connStr -> do-                let settings =-                        defaultConnectionSettings connStr-                            & #eventHandler .~ Just evtHandler-                withStore settings $ \store -> do-                    pid1 <- waitForListenerPid (store ^. #pool) 5_000_000-                    terminateBackend (store ^. #pool) pid1-                    _ <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000-                    pure ()-            evts <- readIORef ref-            let isReconnecting (KirokuEventNotifierReconnecting _ _) = True-                isReconnecting _ = False-                isReconnected KirokuEventNotifierReconnected = True-                isReconnected _ = False-            any isReconnecting evts `shouldBe` True-            any isReconnected evts `shouldBe` True--        -- EP-5 F14 regression — subscriptions emit started, caught-up, and-        -- stopped events. Before EP-5 the lifecycle was invisible.-        ---        -- Subscribe before appending so the worker enters catch-up at-        -- position 0, immediately reaches the publisher's position 0,-        -- emits CaughtUp, transitions to live mode, then receives the-        -- two appended events and stops.-        it "emits subscription lifecycle events (F14)" $ \() -> do-            ref <- newIORef ([] :: [KirokuEvent])-            let evtHandler e = modifyIORef' ref (e :)-            withTestStoreSettings-                (& #eventHandler .~ Just evtHandler)-                $ \store -> do-                    countRef <- newTVarIO (0 :: Int)-                    let h _ = do-                            n <- atomically $ do-                                c <- readTVar countRef-                                writeTVar countRef (c + 1)-                                pure (c + 1)-                            if n >= 2 then pure Stop else pure Continue-                    let cfg =-                            SubscriptionConfig-                                { name = SubscriptionName "lifecycle-test"-                                , target = AllStreams-                                , handler = h-                                , batchSize = defaultBatchSize-                                , queueCapacity = 16-                                , overflowPolicy = DropSubscription-                                , consumerGroup = Nothing-                                , consumerGroupGuard = False-                                , missingCheckpointPolicy = FromBeginning-                                , targetBindingPolicy = AdoptUnbound-                                , undecodableHandler = Nothing-                                , handlerStallWarnAfter = Nothing-                                , retryPolicy = defaultRetryPolicy-                                , eventTypeFilter = AllEventTypes-                                , selector = Nothing-                                }-                    handle <- subscribe store cfg-                    -- Give the worker a moment to enter live mode.-                    threadDelay 200_000-                    Right _ <--                        runStoreIO store $-                            appendToStream (StreamName "lifecycle-1") NoStream [makeEvent "X" (Aeson.object [])]-                    Right _ <--                        runStoreIO store $-                            appendToStream (StreamName "lifecycle-1") (ExactVersion (StreamVersion 1)) [makeEvent "Y" (Aeson.object [])]-                    _ <- waitWithTimeout 10_000_000 handle-                    pure ()-            evts <- readIORef ref-            let isStarted (KirokuEventSubscriptionStarted (SubscriptionName "lifecycle-test") _ _) = True-                isStarted _ = False-                isCaughtUp (KirokuEventSubscriptionCaughtUp (SubscriptionName "lifecycle-test") _ _) = True-                isCaughtUp _ = False-                isStopped (KirokuEventSubscriptionStopped (SubscriptionName "lifecycle-test") _ StopHandlerRequested _) = True-                isStopped _ = False-            any isStarted evts `shouldBe` True-            any isCaughtUp evts `shouldBe` True-            any isStopped evts `shouldBe` True--        -- EP-5 F13 regression — hard-delete emits a fail-safe audit event.-        -- Before EP-5 there was no in-band audit signal.-        it "emits a hard-delete event when the stream existed (F13)" $ \() -> do-            ref <- newIORef ([] :: [KirokuEvent])-            let evtHandler e = modifyIORef' ref (e :)-            withTestStoreSettings-                (& #eventHandler .~ Just evtHandler)-                $ \store -> do-                    Right _ <--                        runStoreIO store $-                            appendToStream (StreamName "hard-delete-evt") NoStream [makeEvent "X" (Aeson.object [])]-                    Right _ <- runStoreIO store $ hardDeleteStream (StreamName "hard-delete-evt")-                    -- A hard-delete on a non-existent stream must not emit-                    -- the event — verify by issuing a second one.-                    Right _ <- runStoreIO store $ hardDeleteStream (StreamName "never-existed")-                    pure ()-            evts <- readIORef ref-            let hardDeletes =-                    [ name'-                    | KirokuEventHardDeleteIssued (StreamName name') _ <- evts-                    ]-            hardDeletes `shouldBe` ["hard-delete-evt"]+import Test.NamespaceQueryPlans qualified as NamespaceQueryPlans+import Test.NotifyGuard qualified as NotifyGuard+import Test.PerformanceStructure qualified as PerformanceStructure+import Test.Properties qualified as Properties+import Test.PublisherCallbackResilience qualified as PublisherCallbackResilience+import Test.PublisherDropCounter qualified as PublisherDropCounter+import Test.PublisherIdleAdvance qualified as PublisherIdleAdvance+import Test.PublisherRestartNoRebroadcast qualified as PublisherRestartNoRebroadcast+import Test.ReadStream qualified as ReadStream+import Test.ScriptSessionErrors qualified as ScriptSessionErrors+import Test.StartupFailureSurfacing qualified as StartupFailureSurfacing+import Test.StreamBridgeTermination qualified as StreamBridgeTermination+import Test.StreamHead qualified as StreamHead+import Test.StreamHeadIsolation qualified as StreamHeadIsolation+import Test.StreamHeadMock qualified as StreamHeadMock+import Test.StreamHistoryGuard qualified as StreamHistoryGuard+import Test.StreamNameLookup qualified as StreamNameLookup+import Test.SubscriptionCheckpointInitialization qualified as SubscriptionCheckpointInitialization+import Test.SubscriptionCheckpointInitializationMock qualified as SubscriptionCheckpointInitializationMock+import Test.SubscriptionCheckpointInventory qualified as SubscriptionCheckpointInventory+import Test.SubscriptionCheckpointInventoryMock qualified as SubscriptionCheckpointInventoryMock+import Test.SubscriptionCheckpointReset qualified as SubscriptionCheckpointReset+import Test.SubscriptionCheckpointWorker qualified as SubscriptionCheckpointWorker+import Test.SubscriptionDeadLetters qualified as SubscriptionDeadLetters+import Test.SubscriptionDeadLettersMock qualified as SubscriptionDeadLettersMock+import Test.SubscriptionNamespace qualified as SubscriptionNamespace+import Test.SubscriptionPauseResume qualified as SubscriptionPauseResume+import Test.SubscriptionReconnect qualified as SubscriptionReconnect+import Test.SubscriptionRegistry qualified as SubscriptionRegistry+import Test.SubscriptionRetryDeadLetter qualified as SubscriptionRetryDeadLetter+import Test.SubscriptionState qualified as SubscriptionState+import Test.SubscriptionTarget qualified as SubscriptionTarget+import Test.Transaction qualified as Transaction+import Test.TruncateBefore qualified as TruncateBefore+import Test.UniqueViolationMapping qualified as UniqueViolationMapping+import Test.VisibleGlobalHeadPosition qualified as VisibleGlobalHeadPosition+import Test.VisibleGlobalHeadPositionMock qualified as VisibleGlobalHeadPositionMock++main :: IO ()+main = withSharedMigratedPostgres $ hspec $ do+  BrowseReads.spec+  BrowseReadsMock.spec+  SubscriptionDeadLetters.spec+  SubscriptionDeadLettersMock.spec+  UniqueViolationMapping.spec+  SubscriptionTarget.spec+  SubscriptionNamespace.spec+  NamespaceQueryPlans.spec+  Category.spec+  Properties.spec+  Concurrency.spec+  FailureInjection.spec+  HistoryRetention.spec+  HistoryRetentionMock.spec+  Transaction.spec+  ScriptSessionErrors.spec+  TruncateBefore.spec+  ReadStream.spec+  StreamNameLookup.spec+  StreamBridgeTermination.spec+  StreamHistoryGuard.spec+  InterpreterHooks.spec+  Causation.spec+  ConsumerGroupSql.spec+  ConsumerGroupResize.spec+  ConsumerGroup.spec+  ConsumerGroupEffect.spec+  describe "performance structure" $ do+    BrowseQueryPlans.spec+    StreamHeadIsolation.spec+    PerformanceStructure.spec+    NotifyGuard.spec+    StreamNameLookup.noOpSpec+  CategoryIdleNoSpin.spec+  HandlerStall.spec+  PublisherCallbackResilience.spec+  PublisherDropCounter.spec+  PublisherIdleAdvance.spec+  PublisherRestartNoRebroadcast.spec+  CatchupDbErrorNoPrematureSwitch.spec+  SubscriptionPauseResume.spec+  SubscriptionCheckpointInitialization.spec+  SubscriptionCheckpointInitializationMock.spec+  SubscriptionCheckpointWorker.spec+  SubscriptionCheckpointInventory.spec+  SubscriptionCheckpointInventoryMock.spec+  SubscriptionCheckpointReset.spec+  SubscriptionReconnect.spec+  StartupFailureSurfacing.spec+  SubscriptionState.spec+  SubscriptionRegistry.spec+  SubscriptionRetryDeadLetter.spec+  EventTypeFilter.spec+  StreamHead.spec+  StreamHeadMock.spec+  VisibleGlobalHeadPosition.spec+  VisibleGlobalHeadPositionMock.spec+  around withTestStore $ do+    describe "schema migrations" $ do+      it "installs every Kiroku table under the kiroku schema" $ \store -> do+        let tables = ["streams", "events", "stream_events", "subscriptions"]+        results <- mapM (\t -> tableExists store ("kiroku." <> t)) tables+        results `shouldBe` replicate (length tables) True++      it "leaves the public schema free of Kiroku tables" $ \store -> do+        let tables = ["streams", "events", "stream_events", "subscriptions"]+        results <- mapM (\t -> tableExists store ("public." <> t)) tables+        results `shouldBe` replicate (length tables) False++      it "provides a UUIDv7 database default for direct event inserts" $ \store -> do+        eventIdText <- insertEventUsingDefaultId store+        version <- serverVersionNum store+        let isV7 = T.length eventIdText > 14 && T.index eventIdText 14 == '7'+        unless isV7 $+          expectationFailure+            ( "expected database-generated event_id to be UUIDv7 on PostgreSQL "+                <> T.unpack version+                <> ", got "+                <> T.unpack eventIdText+            )++    describe "appendToStream" $ do+      describe "NoStream" $ do+        it "creates a new stream and appends events" $ \store -> do+          let event = makeEvent "OrderCreated" (Aeson.object [("orderId", Aeson.String "123")])+          result <- runStoreIO store $ appendToStream (StreamName "order-123") NoStream [event]+          case result of+            Left err -> expectationFailure ("Expected success, got: " <> show err)+            Right r -> do+              (r ^. #streamVersion) `shouldBe` StreamVersion 1+              (r ^. #globalPosition) `shouldBe` GlobalPosition 1++        it "fails when stream already exists" $ \store -> do+          _ <- runStoreIO store $ appendToStream (StreamName "order-456") NoStream [makeEvent "OrderCreated" (Aeson.object [])]+          result <- runStoreIO store $ appendToStream (StreamName "order-456") NoStream [makeEvent "OrderUpdated" (Aeson.object [])]+          case result of+            Left (StreamAlreadyExists _) -> pure ()+            other -> expectationFailure ("Expected StreamAlreadyExists, got: " <> show other)++      describe "empty event batches" $ do+        it "rejects NoStream without creating a phantom stream or advancing $all" $ \store -> do+          Right allBefore <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+          result <- runStoreIO store $ appendToStream (StreamName "empty-nostream") NoStream []+          result `shouldBe` Left (EmptyAppendBatch (StreamName "empty-nostream"))+          runStoreIO store (getStream (StreamName "empty-nostream")) `shouldReturn` Right Nothing+          Right allAfter <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+          fmap (^. #globalPosition) (V.toList allAfter) `shouldBe` fmap (^. #globalPosition) (V.toList allBefore)++        it "rejects empty batches for existing-stream expectations without changing version" $ \store -> do+          Right _ <- runStoreIO store $ appendToStream (StreamName "empty-existing") NoStream [makeEvent "Created" (Aeson.object [])]+          let assertEmptyRejected expected = do+                result <- runStoreIO store $ appendToStream (StreamName "empty-existing") expected []+                result `shouldBe` Left (EmptyAppendBatch (StreamName "empty-existing"))+                Right (Just info) <- runStoreIO store $ getStream (StreamName "empty-existing")+                (info ^. #version) `shouldBe` StreamVersion 1+          assertEmptyRejected (ExactVersion (StreamVersion 1))+          assertEmptyRejected StreamExists+          assertEmptyRejected AnyVersion++        it "rejects an empty per-stream batch in appendMultiStream without committing any stream" $ \store -> do+          let ok = StreamName "empty-multi-ok"+              bad = StreamName "empty-multi-bad"+          result <-+            runStoreIO store $+              appendMultiStream+                [ (ok, NoStream, [makeEvent "Created" (Aeson.object [])]),+                  (bad, NoStream, [])+                ]+          result `shouldBe` Left (EmptyAppendBatch bad)+          runStoreIO store (getStream ok) `shouldReturn` Right Nothing+          runStoreIO store (getStream bad) `shouldReturn` Right Nothing++        it "treats an empty appendMultiStream operation list as a no-op" $ \store -> do+          result <- runStoreIO store $ appendMultiStream []+          result `shouldBe` Right []++      describe "ExactVersion" $ do+        it "appends when version matches" $ \store -> do+          Right r1 <- runStoreIO store $ appendToStream (StreamName "order-789") NoStream [makeEvent "OrderCreated" (Aeson.object [])]+          (r1 ^. #streamVersion) `shouldBe` StreamVersion 1++          result <- runStoreIO store $ appendToStream (StreamName "order-789") (ExactVersion (StreamVersion 1)) [makeEvent "OrderUpdated" (Aeson.object [])]+          case result of+            Left err -> expectationFailure ("Expected success, got: " <> show err)+            Right r -> do+              (r ^. #streamVersion) `shouldBe` StreamVersion 2+              (r ^. #globalPosition) `shouldBe` GlobalPosition 2++        it "fails on version conflict" $ \store -> do+          _ <- runStoreIO store $ appendToStream (StreamName "order-conflict") NoStream [makeEvent "OrderCreated" (Aeson.object [])]+          result <- runStoreIO store $ appendToStream (StreamName "order-conflict") (ExactVersion (StreamVersion 0)) [makeEvent "OrderUpdated" (Aeson.object [])]+          case result of+            Left (WrongExpectedVersion _ _ _) -> pure ()+            other -> expectationFailure ("Expected WrongExpectedVersion, got: " <> show other)++      describe "StreamExists" $ do+        it "appends to an existing stream" $ \store -> do+          _ <- runStoreIO store $ appendToStream (StreamName "stream-exists-test") NoStream [makeEvent "Created" (Aeson.object [])]+          result <- runStoreIO store $ appendToStream (StreamName "stream-exists-test") StreamExists [makeEvent "Updated" (Aeson.object [])]+          case result of+            Left err -> expectationFailure ("Expected success, got: " <> show err)+            Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 2++        it "fails when stream does not exist" $ \store -> do+          result <- runStoreIO store $ appendToStream (StreamName "nonexistent-stream") StreamExists [makeEvent "Created" (Aeson.object [])]+          case result of+            Left (StreamNotFound _) -> pure ()+            other -> expectationFailure ("Expected StreamNotFound, got: " <> show other)++      describe "AnyVersion" $ do+        it "creates a new stream" $ \store -> do+          result <- runStoreIO store $ appendToStream (StreamName "any-new") AnyVersion [makeEvent "Created" (Aeson.object [])]+          case result of+            Left err -> expectationFailure ("Expected success, got: " <> show err)+            Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 1++        it "appends to an existing stream" $ \store -> do+          _ <- runStoreIO store $ appendToStream (StreamName "any-existing") AnyVersion [makeEvent "Created" (Aeson.object [])]+          result <- runStoreIO store $ appendToStream (StreamName "any-existing") AnyVersion [makeEvent "Updated" (Aeson.object [])]+          case result of+            Left err -> expectationFailure ("Expected success, got: " <> show err)+            Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 2++      describe "batch append" $ do+        it "appends multiple events with sequential versions" $ \store -> do+          let events =+                [ makeEvent "Event1" (Aeson.object [("n", Aeson.Number 1)]),+                  makeEvent "Event2" (Aeson.object [("n", Aeson.Number 2)]),+                  makeEvent "Event3" (Aeson.object [("n", Aeson.Number 3)])+                ]+          result <- runStoreIO store $ appendToStream (StreamName "batch-test") NoStream events+          case result of+            Left err -> expectationFailure ("Expected success, got: " <> show err)+            Right r -> do+              (r ^. #streamVersion) `shouldBe` StreamVersion 3+              (r ^. #globalPosition) `shouldBe` GlobalPosition 3++      describe "global position contiguity" $ do+        it "assigns contiguous global positions across streams" $ \store -> do+          Right r1 <- runStoreIO store $ appendToStream (StreamName "stream-a") NoStream [makeEvent "A" (Aeson.object [])]+          (r1 ^. #globalPosition) `shouldBe` GlobalPosition 1++          Right r2 <- runStoreIO store $ appendToStream (StreamName "stream-b") NoStream [makeEvent "B" (Aeson.object [])]+          (r2 ^. #globalPosition) `shouldBe` GlobalPosition 2++          Right r3 <- runStoreIO store $ appendToStream (StreamName "stream-c") NoStream [makeEvent "C" (Aeson.object [])]+          (r3 ^. #globalPosition) `shouldBe` GlobalPosition 3++      describe "duplicate event ID" $ do+        it "returns the caller event id on same-stream retry and leaves one stored event" $ \store -> do+          let eid = EventId (UUID.fromWords 0x01234567 0x89ab7def 0x80123456 0x7890abcd)+              event = makeEvent "Created" (Aeson.object []) & #eventId .~ Just eid+              stream = StreamName "same-stream-duplicate"+          Right _ <- runStoreIO store $ appendToStream stream NoStream [event]+          forM_ [AnyVersion, StreamExists, ExactVersion (StreamVersion 1)] $ \expected -> do+            result <- runStoreIO store $ appendToStream stream expected [event]+            result `shouldBe` Left (DuplicateEvent (Just eid))+            countEvents store `shouldReturn` 1+            Right stored <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 100+            Right global <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+            map (\e -> e ^. #eventId) (V.toList stored) `shouldBe` [eid]+            map (\e -> e ^. #eventId) (V.toList global) `shouldBe` [eid]+            Right info <- runStoreIO store $ getStream stream+            fmap (\s -> s ^. #version) info `shouldBe` Just (StreamVersion 1)++        it "rejects duplicate event IDs" $ \store -> do+          let eid = EventId (case UUID.fromString "01234567-89ab-7def-8012-34567890abcd" of Just u -> u; Nothing -> error "bad uuid")+          let event1 =+                EventData+                  { eventId = Just eid,+                    eventType = EventType "Created",+                    payload = Aeson.object [],+                    metadata = Nothing,+                    causationId = Nothing,+                    correlationId = Nothing+                  }+          Right _ <- runStoreIO store $ appendToStream (StreamName "dup-test-1") NoStream [event1]++          let event2 =+                EventData+                  { eventId = Just eid,+                    eventType = EventType "Created",+                    payload = Aeson.object [],+                    metadata = Nothing,+                    causationId = Nothing,+                    correlationId = Nothing+                  }+          result <- runStoreIO store $ appendToStream (StreamName "dup-test-2") NoStream [event2]+          case result of+            Left (DuplicateEvent _) -> pure ()+            other -> expectationFailure ("Expected DuplicateEvent, got: " <> show other)++      describe "stream-name contract" $ do+        it "treats system-looking names other than $all as ordinary streams" $ \store -> do+          let names =+                [ StreamName "invoice-payment",+                  StreamName "$invoice-payment",+                  StreamName "invoice,payment",+                  StreamName "invoicepayment"+                ]+          mapM_+            ( \name -> do+                Right r <- runStoreIO store $ appendToStream name NoStream [makeEvent "StreamNameContract" (Aeson.object [])]+                (r ^. #streamVersion) `shouldBe` StreamVersion 1+                Right events <- runStoreIO store $ readStreamForward name (StreamVersion 0) 10+                V.length events `shouldBe` 1+            )+            names++        it "rejects $all as an application append target" $ \store -> do+          result <- runStoreIO store $ appendToStream (StreamName "$all") AnyVersion [makeEvent "BadAllAppend" (Aeson.object [])]+          case result of+            Left (ReservedStreamName (StreamName "$all")) -> pure ()+            other -> expectationFailure ("Expected ReservedStreamName for $all append, got: " <> show other)+          Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+          V.length allEvents `shouldBe` 0++        it "rejects $all as a multi-stream append target without partial commit" $ \store -> do+          result <-+            runStoreIO store $+              appendMultiStream+                [ (StreamName "multi-reserved-ok", NoStream, [makeEvent "ShouldRollback" (Aeson.object [])]),+                  (StreamName "$all", AnyVersion, [makeEvent "BadAllMulti" (Aeson.object [])])+                ]+          case result of+            Left (ReservedStreamName (StreamName "$all")) -> pure ()+            other -> expectationFailure ("Expected ReservedStreamName for $all multi-stream append, got: " <> show other)+          Right info <- runStoreIO store $ getStream (StreamName "multi-reserved-ok")+          info `shouldBe` Nothing++        it "rejects $all as a link target" $ \store -> do+          Right _ <- runStoreIO store $ appendToStream (StreamName "reserved-link-source") NoStream [makeEvent "Source" (Aeson.object [])]+          Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "reserved-link-source") (StreamVersion 0) 10+          let eid = V.head srcEvents ^. #eventId+          result <- runStoreIO store $ linkToStream (StreamName "$all") [eid]+          case result of+            Left (ReservedStreamName (StreamName "$all")) -> pure ()+            other -> expectationFailure ("Expected ReservedStreamName for $all link, got: " <> show other)+          Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+          V.length allEvents `shouldBe` 1++        it "rejects lifecycle operations against $all" $ \store -> do+          soft <- runStoreIO store $ softDeleteStream (StreamName "$all")+          hard <- runStoreIO store $ hardDeleteStream (StreamName "$all")+          undel <- runStoreIO store $ undeleteStream (StreamName "$all")+          let shouldBeReserved label result =+                case result of+                  Left (ReservedStreamName (StreamName "$all")) -> pure ()+                  other -> expectationFailure ("Expected ReservedStreamName for " <> label <> ", got: " <> show other)+          shouldBeReserved "softDeleteStream $all" soft+          shouldBeReserved "hardDeleteStream $all" hard+          shouldBeReserved "undeleteStream $all" undel+          Right info <- runStoreIO store $ getStream (StreamName "$all")+          case info of+            Just si -> (si ^. #deletedAt) `shouldBe` Nothing+            Nothing -> expectationFailure "Expected reserved $all stream row to remain present"++        it "rejects stream names longer than 512 UTF-8 bytes before creating streams" $ \store -> do+          let overAscii = StreamName (T.replicate 513 "a")+              overUtf8 = StreamName (T.replicate 171 (T.singleton '\12354'))+              assertTooLong sn = do+                result <- runStoreIO store $ appendToStream sn NoStream [makeEvent "TooLong" (Aeson.object [])]+                result `shouldBe` Left (StreamNameTooLong sn 513)+                runStoreIO store (getStream sn) `shouldReturn` Right Nothing+          assertTooLong overAscii+          assertTooLong overUtf8++        it "rejects oversized link targets without creating the target stream" $ \store -> do+          let over = StreamName (T.replicate 513 "l")+          Right _ <- runStoreIO store $ appendToStream (StreamName "length-link-source") NoStream [makeEvent "Source" (Aeson.object [])]+          Right events <- runStoreIO store $ readStreamForward (StreamName "length-link-source") (StreamVersion 0) 10+          let eid = V.head events ^. #eventId+          result <- runStoreIO store $ linkToStream over [eid]+          result `shouldBe` Left (StreamNameTooLong over 513)+          runStoreIO store (getStream over) `shouldReturn` Right Nothing++        it "rejects oversized multi-stream targets without partial commit" $ \store -> do+          let ok = StreamName "length-multi-ok"+              over = StreamName (T.replicate 513 "m")+          result <-+            runStoreIO store $+              appendMultiStream+                [ (ok, NoStream, [makeEvent "ShouldRollback" (Aeson.object [])]),+                  (over, NoStream, [makeEvent "TooLong" (Aeson.object [])])+                ]+          result `shouldBe` Left (StreamNameTooLong over 513)+          runStoreIO store (getStream ok) `shouldReturn` Right Nothing+          runStoreIO store (getStream over) `shouldReturn` Right Nothing++        it "accepts a 512-byte stream name" $ \store -> do+          let exact = StreamName (T.replicate 512 "e")+          result <- runStoreIO store $ appendToStream exact NoStream [makeEvent "ExactLength" (Aeson.object [])]+          case result of+            Right r -> (r ^. #streamVersion) `shouldBe` StreamVersion 1+            Left err -> expectationFailure ("Expected 512-byte stream name to append, got: " <> show err)++    describe "readStreamForward" $ do+      it "reads events in forward order (read-your-own-writes)" $ \store -> do+        let events =+              [ makeEvent "A" (Aeson.object [("n", Aeson.Number 1)]),+                makeEvent "B" (Aeson.object [("n", Aeson.Number 2)]),+                makeEvent "C" (Aeson.object [("n", Aeson.Number 3)])+              ]+        Right _ <- runStoreIO store $ appendToStream (StreamName "read-fwd") NoStream events+        Right result <- runStoreIO store $ readStreamForward (StreamName "read-fwd") (StreamVersion 0) 100+        V.length result `shouldBe` 3+        (V.head result ^. #eventType) `shouldBe` EventType "A"+        (result V.! 1 ^. #eventType) `shouldBe` EventType "B"+        (result V.! 2 ^. #eventType) `shouldBe` EventType "C"+        (V.head result ^. #streamVersion) `shouldBe` StreamVersion 1+        (result V.! 1 ^. #streamVersion) `shouldBe` StreamVersion 2+        (result V.! 2 ^. #streamVersion) `shouldBe` StreamVersion 3++    describe "readStreamBackward" $ do+      it "reads events in backward order" $ \store -> do+        let events =+              [ makeEvent "A" (Aeson.object []),+                makeEvent "B" (Aeson.object []),+                makeEvent "C" (Aeson.object [])+              ]+        Right _ <- runStoreIO store $ appendToStream (StreamName "read-bwd") NoStream events+        Right result <- runStoreIO store $ readStreamBackward (StreamName "read-bwd") (StreamVersion 0) 100+        V.length result `shouldBe` 3+        (V.head result ^. #eventType) `shouldBe` EventType "C"+        (result V.! 1 ^. #eventType) `shouldBe` EventType "B"+        (result V.! 2 ^. #eventType) `shouldBe` EventType "A"+        (V.head result ^. #streamVersion) `shouldBe` StreamVersion 3+        (result V.! 1 ^. #streamVersion) `shouldBe` StreamVersion 2+        (result V.! 2 ^. #streamVersion) `shouldBe` StreamVersion 1++      it "paginates backward using stream version as an exclusive cursor" $ \store -> do+        let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]+        Right _ <- runStoreIO store $ appendToStream (StreamName "read-bwd-page") NoStream events++        Right page1 <- runStoreIO store $ readStreamBackward (StreamName "read-bwd-page") (StreamVersion 0) 2+        fmap (^. #streamVersion) (V.toList page1) `shouldBe` [StreamVersion 5, StreamVersion 4]++        let cursor1 = V.last page1 ^. #streamVersion+        Right page2 <- runStoreIO store $ readStreamBackward (StreamName "read-bwd-page") cursor1 2+        fmap (^. #streamVersion) (V.toList page2) `shouldBe` [StreamVersion 3, StreamVersion 2]++        let cursor2 = V.last page2 ^. #streamVersion+        Right page3 <- runStoreIO store $ readStreamBackward (StreamName "read-bwd-page") cursor2 2+        fmap (^. #streamVersion) (V.toList page3) `shouldBe` [StreamVersion 1]++        Right page4 <- runStoreIO store $ readStreamBackward (StreamName "read-bwd-page") (V.last page3 ^. #streamVersion) 2+        V.length page4 `shouldBe` 0++    describe "cursor-based pagination" $ do+      it "paginates using stream version as cursor" $ \store -> do+        let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]+        Right _ <- runStoreIO store $ appendToStream (StreamName "read-page") NoStream events+        -- Read first page of 2+        Right page1 <- runStoreIO store $ readStreamForward (StreamName "read-page") (StreamVersion 0) 2+        V.length page1 `shouldBe` 2+        let cursor1 = page1 V.! 1 ^. #streamVersion+        cursor1 `shouldBe` StreamVersion 2+        -- Read second page from cursor+        Right page2 <- runStoreIO store $ readStreamForward (StreamName "read-page") cursor1 2+        V.length page2 `shouldBe` 2+        (V.head page2 ^. #streamVersion) `shouldBe` StreamVersion 3+        -- Read third page — only 1 event left+        let cursor2 = page2 V.! 1 ^. #streamVersion+        Right page3 <- runStoreIO store $ readStreamForward (StreamName "read-page") cursor2 2+        V.length page3 `shouldBe` 1+        -- Read past end — empty+        let cursor3 = V.head page3 ^. #streamVersion+        Right page4 <- runStoreIO store $ readStreamForward (StreamName "read-page") cursor3 2+        V.length page4 `shouldBe` 0++    describe "readAllForward" $ do+      it "reads events from $all in global order" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "all-s1") NoStream [makeEvent "X" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "all-s2") NoStream [makeEvent "Y" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "all-s3") NoStream [makeEvent "Z" (Aeson.object [])]+        Right result <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+        V.length result `shouldBe` 3+        (V.head result ^. #eventType) `shouldBe` EventType "X"+        (result V.! 1 ^. #eventType) `shouldBe` EventType "Y"+        (result V.! 2 ^. #eventType) `shouldBe` EventType "Z"+        -- Global positions should be contiguous+        (V.head result ^. #globalPosition) `shouldBe` GlobalPosition 1+        (result V.! 1 ^. #globalPosition) `shouldBe` GlobalPosition 2+        (result V.! 2 ^. #globalPosition) `shouldBe` GlobalPosition 3++    describe "readAllBackward" $ do+      it "reads events from $all in reverse order" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "allb-s1") NoStream [makeEvent "X" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "allb-s2") NoStream [makeEvent "Y" (Aeson.object [])]+        Right result <- runStoreIO store $ readAllBackward (GlobalPosition 0) 100+        V.length result `shouldBe` 2+        (V.head result ^. #eventType) `shouldBe` EventType "Y"+        (result V.! 1 ^. #eventType) `shouldBe` EventType "X"++      it "paginates backward using global position as an exclusive cursor" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "allb-page-s1") NoStream [makeEvent "X" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "allb-page-s2") NoStream [makeEvent "Y" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "allb-page-s3") NoStream [makeEvent "Z" (Aeson.object [])]++        Right page1 <- runStoreIO store $ readAllBackward (GlobalPosition 0) 2+        fmap (^. #globalPosition) (V.toList page1) `shouldBe` [GlobalPosition 3, GlobalPosition 2]++        Right page2 <- runStoreIO store $ readAllBackward (GlobalPosition 2) 2+        fmap (^. #globalPosition) (V.toList page2) `shouldBe` [GlobalPosition 1]++        Right page3 <- runStoreIO store $ readAllBackward (GlobalPosition 1) 2+        V.length page3 `shouldBe` 0++    describe "read empty/nonexistent stream" $ do+      it "returns empty Vector for nonexistent stream" $ \store -> do+        Right result <- runStoreIO store $ readStreamForward (StreamName "no-such-stream") (StreamVersion 0) 100+        V.length result `shouldBe` 0++    describe "getStream" $ do+      it "returns metadata for existing stream" $ \store -> do+        Right _ <-+          runStoreIO store $+            appendToStream+              (StreamName "meta-test")+              NoStream+              [ makeEvent "A" (Aeson.object []),+                makeEvent "B" (Aeson.object [])+              ]+        Right info <- runStoreIO store $ getStream (StreamName "meta-test")+        case info of+          Just si -> do+            (si ^. #name) `shouldBe` StreamName "meta-test"+            (si ^. #version) `shouldBe` StreamVersion 2+          Nothing -> expectationFailure "Expected Just StreamInfo, got Nothing"++      it "returns Nothing for nonexistent stream" $ \store -> do+        Right info <- runStoreIO store $ getStream (StreamName "no-such-stream")+        info `shouldBe` Nothing++    describe "lookupStreamId" $ do+      it "returns the same id as getStream for a live stream" $ \store -> do+        Right _ <-+          runStoreIO store $+            appendToStream+              (StreamName "lookup-live")+              NoStream+              [makeEvent "A" (Aeson.object [])]+        Right mInfo <- runStoreIO store $ getStream (StreamName "lookup-live")+        Right mSid <- runStoreIO store $ lookupStreamId (StreamName "lookup-live")+        case (mInfo, mSid) of+          (Just info, Just sid) ->+            (info ^. #id) `shouldBe` sid+          _ ->+            expectationFailure "Expected both getStream and lookupStreamId to return Just"++      it "returns Nothing for a stream that has never been created" $ \store -> do+        Right mSid <- runStoreIO store $ lookupStreamId (StreamName "lookup-missing")+        mSid `shouldBe` Nothing++      it "returns Just the same id for a soft-deleted stream" $ \store -> do+        Right _ <-+          runStoreIO store $+            appendToStream+              (StreamName "lookup-soft")+              NoStream+              [makeEvent "A" (Aeson.object [])]+        Right mSidBefore <- runStoreIO store $ lookupStreamId (StreamName "lookup-soft")+        Right _ <- runStoreIO store $ softDeleteStream (StreamName "lookup-soft")+        Right mSidAfter <- runStoreIO store $ lookupStreamId (StreamName "lookup-soft")+        mSidAfter `shouldBe` mSidBefore+        Right mInfo <- runStoreIO store $ getStream (StreamName "lookup-soft")+        case (mInfo, mSidAfter) of+          (Just info, Just sid) -> (info ^. #id) `shouldBe` sid+          _ -> expectationFailure "Expected Just on a soft-deleted stream"++    describe "integration: full lifecycle through withStore" $ do+      it "append, read forward, read $all, getStream — all through public API" $ \store -> do+        -- Append events to two streams+        Right _ <-+          runStoreIO store $+            appendToStream+              (StreamName "integ-orders")+              NoStream+              [ makeEvent "OrderCreated" (Aeson.object [("id", Aeson.String "1")]),+                makeEvent "OrderShipped" (Aeson.object [("id", Aeson.String "1")])+              ]+        Right _ <-+          runStoreIO store $+            appendToStream+              (StreamName "integ-users")+              NoStream+              [ makeEvent "UserRegistered" (Aeson.object [("name", Aeson.String "alice")])+              ]++        -- Read from a named stream+        Right orders <- runStoreIO store $ readStreamForward (StreamName "integ-orders") (StreamVersion 0) 100+        V.length orders `shouldBe` 2+        (V.head orders ^. #eventType) `shouldBe` EventType "OrderCreated"+        (orders V.! 1 ^. #eventType) `shouldBe` EventType "OrderShipped"++        -- Read from $all — should see all 3 events in global order+        Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+        V.length allEvents `shouldBe` 3+        (V.head allEvents ^. #globalPosition) `shouldBe` GlobalPosition 1+        (allEvents V.! 2 ^. #globalPosition) `shouldBe` GlobalPosition 3++        -- Query stream metadata+        Right orderInfo <- runStoreIO store $ getStream (StreamName "integ-orders")+        case orderInfo of+          Just si -> (si ^. #version) `shouldBe` StreamVersion 2+          Nothing -> expectationFailure "Expected stream info"++        Right noInfo <- runStoreIO store $ getStream (StreamName "nonexistent")+        noInfo `shouldBe` Nothing++    -- =================================================================+    -- Link tests (M5.8)+    -- =================================================================+    describe "linkToStream" $ do+      it "links a single event to a new stream" $ \store -> do+        let event = makeEvent "OrderCreated" (Aeson.object [("id", Aeson.String "1")])+        Right appendR <- runStoreIO store $ appendToStream (StreamName "source-1") NoStream [event]+        -- Read back to get the event ID+        Right events <- runStoreIO store $ readStreamForward (StreamName "source-1") (StreamVersion 0) 100+        let eid = V.head events ^. #eventId+        -- Link it+        Right linkR <- runStoreIO store $ linkToStream (StreamName "linked-1") [eid]+        (linkR ^. #streamVersion) `shouldBe` StreamVersion 1+        -- Read the linked stream+        Right linked <- runStoreIO store $ readStreamForward (StreamName "linked-1") (StreamVersion 0) 100+        V.length linked `shouldBe` 1+        (V.head linked ^. #eventId) `shouldBe` eid+        (V.head linked ^. #originalStreamId) `shouldBe` (appendR ^. #streamId)+        (V.head linked ^. #originalVersion) `shouldBe` StreamVersion 1++      it "links multiple events with sequential versions" $ \store -> do+        let events =+              [ makeEvent "A" (Aeson.object []),+                makeEvent "B" (Aeson.object []),+                makeEvent "C" (Aeson.object [])+              ]+        Right _ <- runStoreIO store $ appendToStream (StreamName "source-multi") NoStream events+        Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "source-multi") (StreamVersion 0) 100+        let eids = V.toList (V.map (^. #eventId) srcEvents)+        Right linkR <- runStoreIO store $ linkToStream (StreamName "linked-multi") eids+        (linkR ^. #streamVersion) `shouldBe` StreamVersion 3+        Right linked <- runStoreIO store $ readStreamForward (StreamName "linked-multi") (StreamVersion 0) 100+        V.length linked `shouldBe` 3+        (V.head linked ^. #streamVersion) `shouldBe` StreamVersion 1+        (linked V.! 1 ^. #streamVersion) `shouldBe` StreamVersion 2+        (linked V.! 2 ^. #streamVersion) `shouldBe` StreamVersion 3++      it "links events to an existing stream and bumps version" $ \store -> do+        let events1 = [makeEvent "A" (Aeson.object [])]+            events2 = [makeEvent "B" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "src-a") NoStream events1+        Right _ <- runStoreIO store $ appendToStream (StreamName "src-b") NoStream events2+        Right evA <- runStoreIO store $ readStreamForward (StreamName "src-a") (StreamVersion 0) 100+        Right evB <- runStoreIO store $ readStreamForward (StreamName "src-b") (StreamVersion 0) 100+        let eidA = V.head evA ^. #eventId+            eidB = V.head evB ^. #eventId+        -- Link first event+        Right r1 <- runStoreIO store $ linkToStream (StreamName "linked-existing") [eidA]+        (r1 ^. #streamVersion) `shouldBe` StreamVersion 1+        -- Link second event to same stream+        Right r2 <- runStoreIO store $ linkToStream (StreamName "linked-existing") [eidB]+        (r2 ^. #streamVersion) `shouldBe` StreamVersion 2+        -- Read all linked events+        Right linked <- runStoreIO store $ readStreamForward (StreamName "linked-existing") (StreamVersion 0) 100+        V.length linked `shouldBe` 2++      it "linked events still appear in $all at original global positions" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "orig-all") NoStream [makeEvent "X" (Aeson.object [])]+        Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "orig-all") (StreamVersion 0) 100+        let eid = V.head srcEvents ^. #eventId+        _ <- runStoreIO store $ linkToStream (StreamName "linked-all") [eid]+        -- Read $all — should have exactly 1 event (not duplicated)+        Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+        let matchingEvents = V.filter (\e -> (e ^. #eventId) == eid) allEvents+        V.length matchingEvents `shouldBe` 1++      it "rejects linking the same event to the same stream twice" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "src-dup") NoStream [makeEvent "X" (Aeson.object [])]+        Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "src-dup") (StreamVersion 0) 100+        let eid = V.head srcEvents ^. #eventId+        Right _ <- runStoreIO store $ linkToStream (StreamName "linked-dup") [eid]+        result <- runStoreIO store $ linkToStream (StreamName "linked-dup") [eid]+        case result of+          Left (EventAlreadyLinked (StreamName "linked-dup") mEid) ->+            mEid `shouldBe` Just eid+          Left err -> expectationFailure ("Expected EventAlreadyLinked, got: " <> show err)+          Right _ -> expectationFailure "Expected error for duplicate link"++      -- F3 regression — silent version gap when source events do not exist.+      -- Before the fix, the CTE's `JOIN LATERAL` silently dropped link rows for+      -- event_ids that had no `stream_events` row (e.g. never existed, or were+      -- hard-deleted). The `stream_upsert` had already bumped `stream_version`,+      -- so the stream advanced by N but only some link rows were inserted.+      it "rejects link when the source event does not exist" $ \store -> do+        let bogus = EventId (case UUID.fromString "11111111-1111-7111-8111-111111111111" of Just u -> u; Nothing -> error "bad uuid")+        result <- runStoreIO store $ linkToStream (StreamName "f3-bogus") [bogus]+        case result of+          Left (LinkSourceEventMissing (StreamName "f3-bogus")) -> pure ()+          Left err -> expectationFailure ("Expected LinkSourceEventMissing, got: " <> show err)+          Right r -> expectationFailure ("Expected error for missing event, got: " <> show r)+        -- Target stream must not have been left in a half-created state.+        Right info <- runStoreIO store $ getStream (StreamName "f3-bogus")+        info `shouldBe` Nothing++      it "rejects link with a mix of valid and missing events (no partial commit)" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "f3-mixed-src") NoStream [makeEvent "Real" (Aeson.object [])]+        Right realEvents <- runStoreIO store $ readStreamForward (StreamName "f3-mixed-src") (StreamVersion 0) 100+        let realId = V.head realEvents ^. #eventId+        let bogus = EventId (case UUID.fromString "22222222-2222-7222-8222-222222222222" of Just u -> u; Nothing -> error "bad uuid")+        result <- runStoreIO store $ linkToStream (StreamName "f3-mixed-tgt") [realId, bogus]+        case result of+          Left (LinkSourceEventMissing (StreamName "f3-mixed-tgt")) -> pure ()+          Left err -> expectationFailure ("Expected LinkSourceEventMissing, got: " <> show err)+          Right r -> expectationFailure ("Expected error for partial-missing batch, got: " <> show r)+        Right info <- runStoreIO store $ getStream (StreamName "f3-mixed-tgt")+        info `shouldBe` Nothing++      -- F5 regression — linkToStream against a soft-deleted target stream.+      -- Symmetric with the appendAnyVersion soft-delete check from F2: the+      -- DO UPDATE clause's WHERE filters on deleted_at IS NULL, so the+      -- upsert returns no row and the interpreter maps that to StreamNotFound.+      it "rejects link to a soft-deleted target stream" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "f5-src") NoStream [makeEvent "X" (Aeson.object [])]+        Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "f5-src") (StreamVersion 0) 100+        let eid = V.head srcEvents ^. #eventId+        Right _ <- runStoreIO store $ appendToStream (StreamName "f5-target") NoStream [makeEvent "Init" (Aeson.object [])]+        Right _ <- runStoreIO store $ softDeleteStream (StreamName "f5-target")+        result <- runStoreIO store $ linkToStream (StreamName "f5-target") [eid]+        case result of+          Left (StreamNotFound _) -> pure ()+          other -> expectationFailure ("Expected StreamNotFound for soft-deleted target, got: " <> show other)+        -- Soft-deleted target's version must not have advanced.+        Right info <- runStoreIO store $ getStream (StreamName "f5-target")+        case info of+          Just si -> (si ^. #version) `shouldBe` StreamVersion 1+          Nothing -> expectationFailure "soft-deleted stream row should still exist"++    -- =================================================================+    -- Category read tests (M5.8)+    -- =================================================================+    describe "readCategory" $ do+      it "reads events from matching category streams in global position order" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "order-1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "order-2") NoStream [makeEvent "OrderShipped" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "user-1") NoStream [makeEvent "UserRegistered" (Aeson.object [])]+        Right result <- runStoreIO store $ readCategory (CategoryName "order") (GlobalPosition 0) 100+        V.length result `shouldBe` 2+        (V.head result ^. #eventType) `shouldBe` EventType "OrderCreated"+        (result V.! 1 ^. #eventType) `shouldBe` EventType "OrderShipped"+        -- Verify global positions are ascending+        (V.head result ^. #globalPosition) `shouldSatisfy` (< (result V.! 1 ^. #globalPosition))++      it "supports pagination with start position and limit" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "cat-1") NoStream [makeEvent "A" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "cat-2") NoStream [makeEvent "B" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "cat-3") NoStream [makeEvent "C" (Aeson.object [])]+        -- Read first 2+        Right page1 <- runStoreIO store $ readCategory (CategoryName "cat") (GlobalPosition 0) 2+        V.length page1 `shouldBe` 2+        -- Read from cursor+        let cursor = page1 V.! 1 ^. #globalPosition+        Right page2 <- runStoreIO store $ readCategory (CategoryName "cat") cursor 100+        V.length page2 `shouldBe` 1++      it "returns empty for nonexistent category" $ \store -> do+        Right result <- runStoreIO store $ readCategory (CategoryName "nope") (GlobalPosition 0) 100+        V.length result `shouldBe` 0++      it "includes linked events that originate from category streams" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "evt-1") NoStream [makeEvent "EventA" (Aeson.object [])]+        Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "evt-1") (StreamVersion 0) 100+        let eid = V.head srcEvents ^. #eventId+        -- Link into a different-category stream+        Right _ <- runStoreIO store $ linkToStream (StreamName "projection-1") [eid]+        -- Category "evt" should still return the event (it originates from evt-1)+        Right result <- runStoreIO store $ readCategory (CategoryName "evt") (GlobalPosition 0) 100+        V.length result `shouldBe` 1+        (V.head result ^. #eventType) `shouldBe` EventType "EventA"++    -- =================================================================+    -- Multi-stream transaction tests (M5.8)+    -- =================================================================+    describe "appendMultiStream" $ do+      it "atomically appends to two streams" $ \store -> do+        let ops =+              [ (StreamName "multi-a", NoStream, [makeEvent "A" (Aeson.object [])]),+                (StreamName "multi-b", NoStream, [makeEvent "B" (Aeson.object [])])+              ]+        Right results <- runStoreIO store $ appendMultiStream ops+        length results `shouldBe` 2+        ((results !! 0) ^. #streamVersion) `shouldBe` StreamVersion 1+        ((results !! 1) ^. #streamVersion) `shouldBe` StreamVersion 1++      it "rolls back all streams on version conflict" $ \store -> do+        -- Create stream multi-c+        Right _ <- runStoreIO store $ appendToStream (StreamName "multi-c") NoStream [makeEvent "C" (Aeson.object [])]+        let ops =+              [ (StreamName "multi-d", NoStream, [makeEvent "D" (Aeson.object [])]),+                (StreamName "multi-c", NoStream, [makeEvent "C2" (Aeson.object [])]) -- conflict: already exists+              ]+        result <- runStoreIO store $ appendMultiStream ops+        case result of+          Left _ -> do+            -- Verify multi-d was NOT created (rollback)+            Right info <- runStoreIO store $ getStream (StreamName "multi-d")+            info `shouldBe` Nothing+          Right _ -> expectationFailure "Expected error for version conflict"++      it "appends to three streams with different expected versions" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "tri-1") NoStream [makeEvent "X" (Aeson.object [])]+        let ops =+              [ (StreamName "tri-1", ExactVersion (StreamVersion 1), [makeEvent "X2" (Aeson.object [])]),+                (StreamName "tri-2", NoStream, [makeEvent "Y" (Aeson.object [])]),+                (StreamName "tri-3", AnyVersion, [makeEvent "Z" (Aeson.object [])])+              ]+        Right results <- runStoreIO store $ appendMultiStream ops+        length results `shouldBe` 3+        ((results !! 0) ^. #streamVersion) `shouldBe` StreamVersion 2+        ((results !! 1) ^. #streamVersion) `shouldBe` StreamVersion 1+        ((results !! 2) ^. #streamVersion) `shouldBe` StreamVersion 1++      -- F4 regression — pre-lock pass over named streams in stream_id order.+      -- The deterministic deadlock-prevention scenario (two concurrent calls+      -- touching the same streams in opposite orders) is covered by EP-6's+      -- planned concurrency-test harness; this test verifies that the+      -- pre-lock does not change user-visible ordering of global positions+      -- within a single multi-stream call.+      it "preserves user-supplied ordering of global positions" $ \store -> do+        let ops =+              [ (StreamName "f4-zzz", NoStream, [makeEvent "Z" (Aeson.object [])]),+                (StreamName "f4-mmm", NoStream, [makeEvent "M" (Aeson.object [])]),+                (StreamName "f4-aaa", NoStream, [makeEvent "A" (Aeson.object [])])+              ]+        Right results <- runStoreIO store $ appendMultiStream ops+        length results `shouldBe` 3+        let p0 = (results !! 0) ^. #globalPosition+            p1 = (results !! 1) ^. #globalPosition+            p2 = (results !! 2) ^. #globalPosition+        p0 `shouldSatisfy` (< p1)+        p1 `shouldSatisfy` (< p2)+        -- Read $all and confirm the events appear in user-supplied order.+        Right allEvts <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+        let names = V.toList (V.map (^. #eventType) allEvts)+        names `shouldBe` [EventType "Z", EventType "M", EventType "A"]++    -- =================================================================+    -- Soft delete tests (M6.8)+    -- =================================================================+    describe "softDeleteStream" $ do+      it "soft-deletes a stream and getStream shows deletedAt" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "del-1") NoStream [makeEvent "A" (Aeson.object [])]+        Right mId <- runStoreIO store $ softDeleteStream (StreamName "del-1")+        mId `shouldSatisfy` (/= Nothing)+        Right info <- runStoreIO store $ getStream (StreamName "del-1")+        case info of+          Just si -> (si ^. #deletedAt) `shouldSatisfy` (/= Nothing)+          Nothing -> expectationFailure "Expected stream info with deletedAt set"++      it "returns empty for reads from a soft-deleted stream" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "del-read") NoStream [makeEvent "A" (Aeson.object [])]+        Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-read")+        Right fwd <- runStoreIO store $ readStreamForward (StreamName "del-read") (StreamVersion 0) 100+        V.length fwd `shouldBe` 0+        Right bwd <- runStoreIO store $ readStreamBackward (StreamName "del-read") (StreamVersion 0) 100+        V.length bwd `shouldBe` 0++      it "rejects appends to a soft-deleted stream" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "del-append") NoStream [makeEvent "A" (Aeson.object [])]+        Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-append")+        result <- runStoreIO store $ appendToStream (StreamName "del-append") StreamExists [makeEvent "B" (Aeson.object [])]+        case result of+          Left (StreamNotFound _) -> pure ()+          other -> expectationFailure ("Expected StreamNotFound, got: " <> show other)++      it "returns Nothing for nonexistent stream" $ \store -> do+        Right mId <- runStoreIO store $ softDeleteStream (StreamName "no-such")+        mId `shouldBe` Nothing++      it "returns Nothing for already-deleted stream" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "del-twice") NoStream [makeEvent "A" (Aeson.object [])]+        Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-twice")+        Right mId <- runStoreIO store $ softDeleteStream (StreamName "del-twice")+        mId `shouldBe` Nothing++      it "events from soft-deleted stream still appear in $all" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "del-all") NoStream [makeEvent "KeepInAll" (Aeson.object [])]+        Right _ <- runStoreIO store $ softDeleteStream (StreamName "del-all")+        Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+        let matching = V.filter (\e -> (e ^. #eventType) == EventType "KeepInAll") allEvents+        V.length matching `shouldBe` 1++      -- F2 regression — the protection against writes to a soft-deleted stream is+      -- enforced by a `deleted_at IS NULL` filter inside each append CTE, not by a+      -- pre-check that races concurrent soft-deletes. These tests exercise every+      -- append variant so a reader can see at a glance which constructor maps to+      -- which error after a soft-delete.+      it "ExactVersion append rejected against soft-deleted stream" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "f2-cte-exv") NoStream [makeEvent "Init" (Aeson.object [])]+        Right _ <- runStoreIO store $ softDeleteStream (StreamName "f2-cte-exv")+        result <- runStoreIO store $ appendToStream (StreamName "f2-cte-exv") (ExactVersion (StreamVersion 1)) [makeEvent "X" (Aeson.object [])]+        case result of+          Left (WrongExpectedVersion _ _ _) -> pure ()+          Left (StreamNotFound _) -> pure ()+          other -> expectationFailure ("Expected WrongExpectedVersion or StreamNotFound, got: " <> show other)++      it "AnyVersion append rejected against soft-deleted stream" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "f2-cte-any") NoStream [makeEvent "Init" (Aeson.object [])]+        Right _ <- runStoreIO store $ softDeleteStream (StreamName "f2-cte-any")+        result <- runStoreIO store $ appendToStream (StreamName "f2-cte-any") AnyVersion [makeEvent "X" (Aeson.object [])]+        case result of+          Left (StreamNotFound _) -> pure ()+          other -> expectationFailure ("Expected StreamNotFound, got: " <> show other)++      it "NoStream append rejected against soft-deleted stream" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "f2-cte-no") NoStream [makeEvent "Init" (Aeson.object [])]+        Right _ <- runStoreIO store $ softDeleteStream (StreamName "f2-cte-no")+        result <- runStoreIO store $ appendToStream (StreamName "f2-cte-no") NoStream [makeEvent "X" (Aeson.object [])]+        case result of+          Left (StreamAlreadyExists _) -> pure ()+          other -> expectationFailure ("Expected StreamAlreadyExists, got: " <> show other)++    -- =================================================================+    -- Undelete tests (M6.8)+    -- =================================================================+    describe "undeleteStream" $ do+      it "restores a soft-deleted stream" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "undel-1") NoStream [makeEvent "A" (Aeson.object [])]+        Right _ <- runStoreIO store $ softDeleteStream (StreamName "undel-1")+        Right mId <- runStoreIO store $ undeleteStream (StreamName "undel-1")+        mId `shouldSatisfy` (/= Nothing)+        Right info <- runStoreIO store $ getStream (StreamName "undel-1")+        case info of+          Just si -> (si ^. #deletedAt) `shouldBe` Nothing+          Nothing -> expectationFailure "Expected stream info"++      it "reads work after undelete" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "undel-read") NoStream [makeEvent "A" (Aeson.object [])]+        Right _ <- runStoreIO store $ softDeleteStream (StreamName "undel-read")+        Right _ <- runStoreIO store $ undeleteStream (StreamName "undel-read")+        Right events <- runStoreIO store $ readStreamForward (StreamName "undel-read") (StreamVersion 0) 100+        V.length events `shouldBe` 1++      it "appends work after undelete" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "undel-append") NoStream [makeEvent "A" (Aeson.object [])]+        Right _ <- runStoreIO store $ softDeleteStream (StreamName "undel-append")+        Right _ <- runStoreIO store $ undeleteStream (StreamName "undel-append")+        Right r <- runStoreIO store $ appendToStream (StreamName "undel-append") StreamExists [makeEvent "B" (Aeson.object [])]+        (r ^. #streamVersion) `shouldBe` StreamVersion 2++      it "returns Nothing for non-deleted stream" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "undel-noop") NoStream [makeEvent "A" (Aeson.object [])]+        Right mId <- runStoreIO store $ undeleteStream (StreamName "undel-noop")+        mId `shouldBe` Nothing++    -- =================================================================+    -- Hard delete tests (M6.8)+    -- =================================================================+    describe "hardDeleteStream" $ do+      it "hard-deletes a stream completely" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "hard-1") NoStream [makeEvent "A" (Aeson.object [])]+        Right mId <- runStoreIO store $ hardDeleteStream (StreamName "hard-1")+        mId `shouldSatisfy` (/= Nothing)+        Right info <- runStoreIO store $ getStream (StreamName "hard-1")+        info `shouldBe` Nothing++      it "events from hard-deleted stream no longer appear in $all" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "hard-all") NoStream [makeEvent "HardDelEvent" (Aeson.object [])]+        Right _ <- runStoreIO store $ hardDeleteStream (StreamName "hard-all")+        Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+        let matching = V.filter (\e -> (e ^. #eventType) == EventType "HardDelEvent") allEvents+        V.length matching `shouldBe` 0++      it "returns Nothing for nonexistent stream" $ \store -> do+        Right mId <- runStoreIO store $ hardDeleteStream (StreamName "hard-no-such")+        mId `shouldBe` Nothing++      -- F6 regression — TRUNCATE on protected tables must be gated by the+      -- same GUC as DELETE. Without the BEFORE TRUNCATE triggers added in+      -- EP-1 F6, an operator could TRUNCATE events / stream_events / streams+      -- without setting kiroku.enable_hard_deletes, bypassing the row-level+      -- protect_deletion check.+      it "TRUNCATE on events is rejected without the GUC" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "f6-truncate") NoStream [makeEvent "X" (Aeson.object [])]+        rejected <- truncateRejected store "events"+        rejected `shouldBe` True++      it "TRUNCATE on stream_events is rejected without the GUC" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "f6-truncate-se") NoStream [makeEvent "X" (Aeson.object [])]+        rejected <- truncateRejected store "stream_events"+        rejected `shouldBe` True++      it "TRUNCATE on streams is rejected without the GUC" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "f6-truncate-s") NoStream [makeEvent "X" (Aeson.object [])]+        rejected <- truncateRejected store "streams"+        rejected `shouldBe` True++      -- F1 regression — events orphaned in `events` table after hard-delete.+      it "removes orphan event payloads from the events table" $ \store -> do+        let evts = map (\i -> makeEvent ("F1Orphan" <> T.pack (show i)) (Aeson.object [])) [1 .. 3 :: Int]+        Right _ <- runStoreIO store $ appendToStream (StreamName "f1-orphan") NoStream evts+        countBefore <- countEvents store+        Right _ <- runStoreIO store $ hardDeleteStream (StreamName "f1-orphan")+        countAfter <- countEvents store+        (countBefore - countAfter) `shouldBe` 3++      it "removes dead letters for orphaned events before deleting their payloads" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "dl-hard-source") NoStream [makeEvent "DeadLettered" (Aeson.object [])]+        Right events <- runStoreIO store $ readStreamForward (StreamName "dl-hard-source") (StreamVersion 0) 10+        let event = V.head events+            eid = event ^. #eventId+        insertDeadLetterForEvent store "dl-hard-subscription" event+        countDeadLettersForEvents store [eid] `shouldReturn` 1++        Right mId <- runStoreIO store $ hardDeleteStream (StreamName "dl-hard-source")+        mId `shouldSatisfy` (/= Nothing)+        countDeadLettersForEvents store [eid] `shouldReturn` 0+        Right remaining <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+        V.filter (\recorded -> (recorded ^. #eventId) == eid) remaining `shouldBe` V.empty++      it "keeps dead letters for linked-in events that survive a hard delete" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "dl-origin") NoStream [makeEvent "Survivor" (Aeson.object [])]+        Right originEvents <- runStoreIO store $ readStreamForward (StreamName "dl-origin") (StreamVersion 0) 10+        let event = V.head originEvents+            eid = event ^. #eventId+        Right _ <- runStoreIO store $ linkToStream (StreamName "dl-link-target") [eid]+        insertDeadLetterForEvent store "dl-survivor-subscription" event+        countDeadLettersForEvents store [eid] `shouldReturn` 1++        Right _ <- runStoreIO store $ hardDeleteStream (StreamName "dl-link-target")+        countDeadLettersForEvents store [eid] `shouldReturn` 1+        Right afterTargetDelete <- runStoreIO store $ readStreamForward (StreamName "dl-origin") (StreamVersion 0) 10+        fmap (^. #eventId) (V.toList afterTargetDelete) `shouldBe` [eid]++        Right _ <- runStoreIO store $ hardDeleteStream (StreamName "dl-origin")+        countDeadLettersForEvents store [eid] `shouldReturn` 0+        Right afterOriginDelete <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+        V.filter (\recorded -> (recorded ^. #eventId) == eid) afterOriginDelete `shouldBe` V.empty++      -- F1 regression — events that are linked to other streams must survive+      -- hard-delete of their source stream's owner if any non-target junctions remain.+      it "preserves events still linked to non-target streams" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "f1-keep-src") NoStream [makeEvent "Keep" (Aeson.object [])]+        Right srcEvents <- runStoreIO store $ readStreamForward (StreamName "f1-keep-src") (StreamVersion 0) 100+        let eid = V.head srcEvents ^. #eventId+        -- Append another stream that gets a different event so we can hard-delete keep-src+        -- without touching the linked stream's source. Then link f1-keep-src's event to a+        -- third stream we will leave alone.+        Right _ <- runStoreIO store $ appendToStream (StreamName "f1-other") NoStream [makeEvent "Other" (Aeson.object [])]+        -- Link via appendToStream + linkToStream to a different stream+        _ <- runStoreIO store $ linkToStream (StreamName "f1-keep-link") [eid]+        -- Hard-deleting f1-keep-src removes the source's events even though the link+        -- referenced them: link rows have original_stream_id = f1-keep-src's id, so they+        -- match the junction-delete WHERE clause. The contract is documented in F1's fix+        -- commit: hard-delete cascades through link junctions of the deleted stream's+        -- original events. (See SQL.hs hard-delete documentation.)+        Right _ <- runStoreIO store $ hardDeleteStream (StreamName "f1-keep-src")+        -- f1-other's event must still exist (not affected at all).+        Right otherEvents <- runStoreIO store $ readStreamForward (StreamName "f1-other") (StreamVersion 0) 100+        V.length otherEvents `shouldBe` 1++    -- =================================================================+    -- Subscription tests (M7.7)+    -- =================================================================+    describe "subscribe" $ do+      it "catches up from position 0 on a store with existing events" $ \store -> do+        -- Append 10 events+        let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]+        Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-1") NoStream events+        -- Wait until the EventPublisher has ingested all 10 events.+        waitForPublisher store (GlobalPosition 10)+        -- Subscribe from position 0+        ref <- newIORef ([] :: [RecordedEvent])+        countRef <- newTVarIO (0 :: Int)+        let handler' evt = do+              modifyIORef' ref (evt :)+              n <- atomically $ do+                c <- readTVar countRef+                let c' = c + 1+                writeTVar countRef c'+                pure c'+              if n >= 10+                then pure Stop+                else pure Continue+        let cfg =+              SubscriptionConfig+                { name = SubscriptionName "catchup-test",+                  target = AllStreams,+                  handler = handler',+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        handle <- subscribe store cfg+        result <- waitWithTimeout 10_000_000 handle+        case result of+          Left timeout -> expectationFailure timeout+          Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+          Right (Right ()) -> pure ()+        collected <- readIORef ref+        length collected `shouldBe` 10++      it "receives live events appended after subscription starts" $ \store -> do+        ref <- newIORef ([] :: [RecordedEvent])+        countRef <- newTVarIO (0 :: Int)+        let handler' evt = do+              modifyIORef' ref (evt :)+              n <- atomically $ do+                c <- readTVar countRef+                let c' = c + 1+                writeTVar countRef c'+                pure c'+              if n >= 5+                then pure Stop+                else pure Continue+        let cfg =+              SubscriptionConfig+                { name = SubscriptionName "live-test",+                  target = AllStreams,+                  handler = handler',+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        handle <- subscribe store cfg+        -- Give subscription time to start+        threadDelay 100_000+        -- Append 5 events+        let events = map (\i -> makeEvent ("Live" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]+        Right _ <- runStoreIO store $ appendToStream (StreamName "live-1") NoStream events+        -- Wait for subscription to complete+        result <- waitWithTimeout 10_000_000 handle+        case result of+          Left timeout -> expectationFailure timeout+          Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+          Right (Right ()) -> pure ()+        collected <- readIORef ref+        length collected `shouldBe` 5++      it "persists checkpoint and resumes from saved position" $ \store -> do+        -- Append 10 events+        let events = map (\i -> makeEvent ("CP" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]+        Right _ <- runStoreIO store $ appendToStream (StreamName "ckpt-1") NoStream events+        waitForPublisher store (GlobalPosition 10)+        -- First subscription: process 5 events then stop+        countRef1 <- newTVarIO (0 :: Int)+        let handler1 _evt = do+              n <- atomically $ do+                c <- readTVar countRef1+                let c' = c + 1+                writeTVar countRef1 c'+                pure c'+              if n >= 5+                then pure Stop+                else pure Continue+        let cfg1 =+              SubscriptionConfig+                { name = SubscriptionName "ckpt-test",+                  target = AllStreams,+                  handler = handler1,+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        h1 <- subscribe store cfg1+        _ <- waitWithTimeout 10_000_000 h1+        -- Second subscription with same name: should resume from position 5+        ref2 <- newIORef ([] :: [RecordedEvent])+        countRef2 <- newTVarIO (0 :: Int)+        let handler2 evt = do+              modifyIORef' ref2 (evt :)+              n <- atomically $ do+                c <- readTVar countRef2+                let c' = c + 1+                writeTVar countRef2 c'+                pure c'+              if n >= 5+                then pure Stop+                else pure Continue+        let cfg2 =+              SubscriptionConfig+                { name = SubscriptionName "ckpt-test",+                  target = AllStreams,+                  handler = handler2,+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        h2 <- subscribe store cfg2+        _ <- waitWithTimeout 10_000_000 h2+        collected <- readIORef ref2+        -- Should receive events 6–10 only+        length collected `shouldBe` 5++      it "does not replay catch-up events when switching to all-stream live mode" $ \store -> do+        let seedEvents = map (\i -> makeEvent ("TransitionSeed" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]+        Right _ <- runStoreIO store $ appendToStream (StreamName "transition-seed") NoStream seedEvents+        waitForPublisher store (GlobalPosition 5)++        firstSeen <- newEmptyMVar+        releaseFirst <- newEmptyMVar+        countRef <- newTVarIO (0 :: Int)+        ref <- newIORef ([] :: [RecordedEvent])+        let stopType = EventType "TransitionStopAfterLive"+            handler' evt = do+              modifyIORef' ref (evt :)+              n <- atomically $ do+                c <- readTVar countRef+                let c' = c + 1+                writeTVar countRef c'+                pure c'+              if n == 1+                then do+                  putMVar firstSeen ()+                  takeMVar releaseFirst+                else pure ()+              if evt ^. #eventType == stopType+                then pure Stop+                else pure Continue+        let cfg =+              SubscriptionConfig+                { name = SubscriptionName "transition-no-duplicates",+                  target = AllStreams,+                  handler = handler',+                  batchSize = either (error . show) Prelude.id (mkBatchSize 2),+                  queueCapacity = 32,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        handle <- subscribe store cfg+        takeMVar firstSeen++        mapM_+          ( \i -> do+              let sn = StreamName ("transition-during-" <> T.pack (show (i :: Int)))+              Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent ("TransitionDuring" <> T.pack (show i)) (Aeson.object [])]+              pure ()+          )+          [6 .. 10]+        waitForPublisher store (GlobalPosition 10)+        putMVar releaseFirst ()+        atomically $ do+          c <- readTVar countRef+          check (c >= 10)++        Right _ <- runStoreIO store $ appendToStream (StreamName "transition-stop") NoStream [makeEvent "TransitionStopAfterLive" (Aeson.object [])]+        result <- waitWithTimeout 10_000_000 handle+        case result of+          Left timeout -> expectationFailure timeout+          Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+          Right (Right ()) -> pure ()++        collected <- reverse <$> readIORef ref+        map (^. #globalPosition) collected `shouldBe` map GlobalPosition [1 .. 11]++      it "does not skip an event when cancelled before checkpoint save" $ \store -> do+        let events = map (\i -> makeEvent ("CancelReplay" <> T.pack (show i)) (Aeson.object [])) [1 .. 3 :: Int]+        Right _ <- runStoreIO store $ appendToStream (StreamName "cancel-replay") NoStream events+        waitForPublisher store (GlobalPosition 3)++        firstSeen <- newEmptyMVar+        block <- newEmptyMVar+        firstRef <- newIORef ([] :: [RecordedEvent])+        let handler1 evt = do+              modifyIORef' firstRef (evt :)+              putMVar firstSeen ()+              _ <- takeMVar block+              pure Continue+            cfg1 =+              SubscriptionConfig+                { name = SubscriptionName "cancel-replay-test",+                  target = AllStreams,+                  handler = handler1,+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        h1 <- subscribe store cfg1+        takeMVar firstSeen+        cancel h1+        _ <- waitWithTimeout 5_000_000 h1++        ref2 <- newIORef ([] :: [RecordedEvent])+        let handler2 evt = do+              modifyIORef' ref2 (evt :)+              pure Stop+            cfg2 =+              SubscriptionConfig+                { name = SubscriptionName "cancel-replay-test",+                  target = AllStreams,+                  handler = handler2,+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        h2 <- subscribe store cfg2+        result <- waitWithTimeout 10_000_000 h2+        case result of+          Left timeout -> expectationFailure timeout+          Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+          Right (Right ()) -> pure ()+        replayed <- reverse <$> readIORef ref2+        map (^. #globalPosition) replayed `shouldBe` [GlobalPosition 1]++      it "saves checkpoints at Stop boundaries without skipping the next event" $ \store -> do+        let events = map (\i -> makeEvent ("StopBoundary" <> T.pack (show i)) (Aeson.object [])) [1 .. 5 :: Int]+        Right _ <- runStoreIO store $ appendToStream (StreamName "stop-boundary") NoStream events+        waitForPublisher store (GlobalPosition 5)++        countRef1 <- newTVarIO (0 :: Int)+        let handler1 _evt = do+              n <- atomically $ do+                c <- readTVar countRef1+                let c' = c + 1+                writeTVar countRef1 c'+                pure c'+              if n >= 3 then pure Stop else pure Continue+            cfg1 =+              SubscriptionConfig+                { name = SubscriptionName "stop-boundary-test",+                  target = AllStreams,+                  handler = handler1,+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        h1 <- subscribe store cfg1+        _ <- waitWithTimeout 10_000_000 h1++        ref2 <- newIORef ([] :: [RecordedEvent])+        countRef2 <- newTVarIO (0 :: Int)+        let handler2 evt = do+              modifyIORef' ref2 (evt :)+              n <- atomically $ do+                c <- readTVar countRef2+                let c' = c + 1+                writeTVar countRef2 c'+                pure c'+              if n >= 2 then pure Stop else pure Continue+            cfg2 =+              SubscriptionConfig+                { name = SubscriptionName "stop-boundary-test",+                  target = AllStreams,+                  handler = handler2,+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        h2 <- subscribe store cfg2+        result <- waitWithTimeout 10_000_000 h2+        case result of+          Left timeout -> expectationFailure timeout+          Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+          Right (Right ()) -> pure ()+        replayed <- reverse <$> readIORef ref2+        map (^. #globalPosition) replayed `shouldBe` [GlobalPosition 4, GlobalPosition 5]++      -- F18 regression — Category subscriptions in live mode previously+      -- received unfiltered events from $all because `filterEvents` was+      -- a no-op for Category. The fix routes Category live-mode through+      -- a DB-driven loop that re-uses the SQL category filter. Before+      -- the fix, the handler would observe the UserNoise event below.+      it "delivers only category-matching events during live mode (F18)" $ \store -> do+        seedSeen <- newEmptyMVar+        ref <- newIORef ([] :: [RecordedEvent])+        countRef <- newTVarIO (0 :: Int)+        let handler' evt = do+              modifyIORef' ref (evt :)+              n <- atomically $ do+                c <- readTVar countRef+                let c' = c + 1+                writeTVar countRef c'+                pure c'+              -- After the catch-up seed event, signal that we are+              -- entering live mode. Subsequent appends exercise+              -- the live path under the new DB-driven loop.+              if n == 1+                then putMVar seedSeen ()+                else pure ()+              if n >= 3 then pure Stop else pure Continue+        -- Pre-seed an order event so catch-up fires the handler once.+        Right _ <-+          runStoreIO store $+            appendToStream+              (StreamName "order-seed")+              NoStream+              [makeEvent "OrderSeed" (Aeson.object [])]+        let cfg =+              SubscriptionConfig+                { name = SubscriptionName "f18-live-test",+                  target = Category (CategoryName "order"),+                  handler = handler',+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        handle <- subscribe store cfg+        -- Block until catch-up has delivered the seed event — the+        -- worker has finished catch-up and is about to enter live+        -- mode.+        takeMVar seedSeen+        -- Append a non-matching user event followed by two matching+        -- order events. Under the bug, UserNoise would slip through+        -- the live broadcast unfiltered.+        Right _ <- runStoreIO store $ appendToStream (StreamName "user-1") NoStream [makeEvent "UserNoise" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "order-2") NoStream [makeEvent "OrderTwo" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "order-3") NoStream [makeEvent "OrderThree" (Aeson.object [])]+        result <- waitWithTimeout 10_000_000 handle+        case result of+          Left timeout -> expectationFailure timeout+          Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+          Right (Right ()) -> pure ()+        collected <- readIORef ref+        length collected `shouldBe` 3+        -- No UserNoise event should have been delivered.+        mapM_+          (\e -> (e ^. #eventType) `shouldNotBe` EventType "UserNoise")+          collected++      it "delivers only category-matching events during catch-up" $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "order-1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "order-2") NoStream [makeEvent "OrderShipped" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "user-1") NoStream [makeEvent "UserRegistered" (Aeson.object [])]+        waitForPublisher store (GlobalPosition 3)+        ref <- newIORef ([] :: [RecordedEvent])+        countRef <- newTVarIO (0 :: Int)+        let handler' evt = do+              modifyIORef' ref (evt :)+              n <- atomically $ do+                c <- readTVar countRef+                let c' = c + 1+                writeTVar countRef c'+                pure c'+              if n >= 2+                then pure Stop+                else pure Continue+        let cfg =+              SubscriptionConfig+                { name = SubscriptionName "cat-sub-test",+                  target = Category (CategoryName "order"),+                  handler = handler',+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        handle <- subscribe store cfg+        _ <- waitWithTimeout 10_000_000 handle+        collected <- readIORef ref+        length collected `shouldBe` 2+        -- All events should be order events+        mapM_ (\e -> (e ^. #eventType) `shouldSatisfy` (\(EventType t) -> T.isPrefixOf "Order" t)) collected++      it "preserves category subscription order under mixed invoice-payment writes" $ \store -> do+        let appendOne sn typ =+              runStoreIO store (appendToStream sn AnyVersion [makeEvent typ (Aeson.object [])]) >>= \result ->+                case result of+                  Right _ -> pure ()+                  Left err -> expectationFailure ("append failed: " <> show err)+        appendOne (StreamName "invoice-payment") "InvoicePaymentStarted"+        appendOne (StreamName "user-1") "UserNoiseOne"+        appendOne (StreamName "invoice-reminder") "InvoiceReminderSent"+        appendOne (StreamName "order-1") "OrderNoiseOne"+        appendOne (StreamName "invoice-payment") "InvoicePaymentFinished"+        appendOne (StreamName "user-2") "UserNoiseTwo"+        appendOne (StreamName "invoice-refund") "InvoiceRefundFinished"+        waitForPublisher store (GlobalPosition 7)++        ref <- newIORef ([] :: [RecordedEvent])+        countRef <- newTVarIO (0 :: Int)+        let handler' evt = do+              modifyIORef' ref (evt :)+              n <- atomically $ do+                c <- readTVar countRef+                let c' = c + 1+                writeTVar countRef c'+                pure c'+              if n >= 4 then pure Stop else pure Continue+            cfg =+              SubscriptionConfig+                { name = SubscriptionName "invoice-category-ordering",+                  target = Category (CategoryName "invoice"),+                  handler = handler',+                  batchSize = either (error . show) Prelude.id (mkBatchSize 2),+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        handle <- subscribe store cfg+        result <- waitWithTimeout 10_000_000 handle+        case result of+          Left timeout -> expectationFailure timeout+          Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+          Right (Right ()) -> pure ()+        collected <- reverse <$> readIORef ref+        map (^. #globalPosition) collected `shouldBe` [GlobalPosition 1, GlobalPosition 3, GlobalPosition 5, GlobalPosition 7]+        map (^. #eventType) collected+          `shouldBe` [ EventType "InvoicePaymentStarted",+                       EventType "InvoiceReminderSent",+                       EventType "InvoicePaymentFinished",+                       EventType "InvoiceRefundFinished"+                     ]++      it "cancels a running subscription cleanly" $ \store -> do+        let handler' _evt = pure Continue+        let cfg =+              SubscriptionConfig+                { name = SubscriptionName "cancel-test",+                  target = AllStreams,+                  handler = handler',+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        handle <- subscribe store cfg+        -- Give it time to start+        threadDelay 100_000+        cancel handle+        result <- waitWithTimeout 5_000_000 handle+        -- Should exit cleanly (AsyncCancelled or Right ())+        case result of+          Left _timeout -> expectationFailure "Cancel did not terminate in time"+          Right (Left _) -> pure () -- AsyncCancelled is expected+          Right (Right ()) -> pure ()++      it "receives events appended to an initially empty store" $ \store -> do+        ref <- newIORef ([] :: [RecordedEvent])+        let handler' evt = do+              modifyIORef' ref (evt :)+              pure Stop+        let cfg =+              SubscriptionConfig+                { name = SubscriptionName "empty-store-test",+                  target = AllStreams,+                  handler = handler',+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        handle <- subscribe store cfg+        -- Give subscription time to start in live mode+        threadDelay 100_000+        -- Append one event+        Right _ <- runStoreIO store $ appendToStream (StreamName "empty-1") NoStream [makeEvent "First" (Aeson.object [])]+        result <- waitWithTimeout 10_000_000 handle+        case result of+          Left timeout -> expectationFailure timeout+          Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+          Right (Right ()) -> pure ()+        collected <- readIORef ref+        length collected `shouldBe` 1++      it "handles rapid appends without losing events (debouncing)" $ \store -> do+        ref <- newIORef ([] :: [RecordedEvent])+        countRef <- newTVarIO (0 :: Int)+        let handler' evt = do+              modifyIORef' ref (evt :)+              n <- atomically $ do+                c <- readTVar countRef+                let c' = c + 1+                writeTVar countRef c'+                pure c'+              if n >= 50+                then pure Stop+                else pure Continue+        let cfg =+              SubscriptionConfig+                { name = SubscriptionName "debounce-test",+                  target = AllStreams,+                  handler = handler',+                  batchSize = defaultBatchSize,+                  -- Sized to absorb 50 publisher batches without+                  -- backpressure overflow. The default 16 was+                  -- intermittently overrun under load (the+                  -- publisher emits one batch per append because+                  -- there is no inter-append debounce window in+                  -- this loop), surfacing as+                  -- SubscriptionOverflowed in flaky CI runs.+                  -- Coverage of the bounded-queue policy lives+                  -- in the F6 overflow test below, not here.+                  queueCapacity = 64,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        handle <- subscribe store cfg+        threadDelay 100_000+        -- Append 50 events rapidly to different streams+        mapM_+          ( \i -> do+              let sn = StreamName ("rapid-" <> T.pack (show (i :: Int)))+              Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent ("R" <> T.pack (show i)) (Aeson.object [])]+              pure ()+          )+          [1 .. 50]+        result <- waitWithTimeout 30_000_000 handle+        case result of+          Left timeout -> expectationFailure timeout+          Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+          Right (Right ()) -> pure ()+        collected <- readIORef ref+        length collected `shouldBe` 50++      -- F6 regression — the publisher's broadcast was an unbounded TChan;+      -- a slow subscriber's dupTChan grew without limit until the host+      -- ran out of memory. The fix replaces the broadcast with a+      -- per-subscriber bounded TBQueue plus an overflow policy. With+      -- DropSubscription, the publisher signals overflow on the+      -- subscriber's status TVar; the worker observes it on its next+      -- STM read and surfaces SubscriptionOverflowed via wait/waitCatch.+      it "surfaces SubscriptionOverflowed when a slow subscriber overruns its queue (F6)" $ \store -> do+        firstSeen <- newEmptyMVar+        release <- newEmptyMVar+        seenCount <- newTVarIO (0 :: Int)+        let handler' _evt = do+              n <- atomically $ do+                c <- readTVar seenCount+                let c' = c + 1+                writeTVar seenCount c'+                pure c'+              if n == 1+                then do+                  putMVar firstSeen ()+                  takeMVar release+                else pure ()+              pure Continue+        let cfg =+              SubscriptionConfig+                { name = SubscriptionName "f6-overflow-test",+                  target = AllStreams,+                  handler = handler',+                  batchSize = defaultBatchSize,+                  queueCapacity = 1,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        handle <- subscribe store cfg+        -- This fixture requires a live handler; catch-up could otherwise+        -- consume the queued range before observing overflow.+        let awaitLive = do+              st <- currentState handle+              case st of+                Just Fsm.Live {} -> pure ()+                _ -> threadDelay 1_000 >> awaitLive+        Async.race (threadDelay 5_000_000) awaitLive >>= (`shouldBe` Right ())+        -- First append: triggers handler, which blocks on the release MVar.+        Right _ <- runStoreIO store $ appendToStream (StreamName "f6-1") NoStream [makeEvent "E1" (Aeson.object [])]+        takeMVar firstSeen+        -- While the worker is stuck inside the handler, append events one+        -- at a time and wait for the publisher to fetch each individually.+        -- Each append corresponds to a separate publisher batch written+        -- to the subscriber's queue. Capacity is 1, so the second+        -- write fills it and the third triggers DropSubscription.+        let waitForPub n = atomically $ do+              GlobalPosition p <- publisherPosition (store ^. #publisher)+              check (p >= n)+        let appendOne i = do+              let sn = StreamName ("f6-" <> T.pack (show (i :: Int)))+              Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]+              waitForPub (fromIntegral i)+        -- Append 4 more events with publisher synchronisation. Combined+        -- with the first event, that is 5 publisher batches.+        appendOne 2+        appendOne 3+        appendOne 4+        appendOne 5+        -- Release the handler. Worker exits processEvents and on the+        -- next STM read observes Overflowed.+        putMVar release ()+        result <- waitWithTimeout 10_000_000 handle+        case result of+          Left timeout -> expectationFailure timeout+          Right (Right ()) -> expectationFailure "expected SubscriptionOverflowed, got clean exit"+          Right (Left e) ->+            case Control.Exception.fromException e of+              Just (SubscriptionOverflowed sn) ->+                sn `shouldBe` SubscriptionName "f6-overflow-test"+              Nothing ->+                expectationFailure ("expected SubscriptionOverflowed, got: " <> show e)++        ref2 <- newIORef ([] :: [RecordedEvent])+        countRef2 <- newTVarIO (0 :: Int)+        let handler2 evt = do+              modifyIORef' ref2 (evt :)+              n <- atomically $ do+                c <- readTVar countRef2+                let c' = c + 1+                writeTVar countRef2 c'+                pure c'+              if n >= 4 then pure Stop else pure Continue+            cfg2 =+              SubscriptionConfig+                { name = SubscriptionName "f6-overflow-test",+                  target = AllStreams,+                  handler = handler2,+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        h2 <- subscribe store cfg2+        replayResult <- waitWithTimeout 10_000_000 h2+        case replayResult of+          Left timeout -> expectationFailure timeout+          Right (Left err) -> expectationFailure ("Subscription failed after overflow restart: " <> show err)+          Right (Right ()) -> pure ()+        replayed <- reverse <$> readIORef ref2+        map (^. #globalPosition) replayed `shouldBe` map GlobalPosition [2 .. 5]++      it "catches up with an Eff-based handler via the effectful API" $ \store -> do+        -- Append 10 events+        let events = map (\i -> makeEvent ("Eff" <> T.pack (show i)) (Aeson.object [])) [1 .. 10 :: Int]+        Right _ <- runStoreIO store $ appendToStream (StreamName "eff-sub-1") NoStream events+        waitForPublisher store (GlobalPosition 10)+        -- Subscribe using the effectful API with an Eff-based handler+        ref <- newIORef ([] :: [RecordedEvent])+        countRef <- newTVarIO (0 :: Int)+        result <- runEff . runErrorNoCallStack @StoreError . runStorePool store . runSubscription store $ do+          let effHandler :: (IOE :> es) => RecordedEvent -> Eff es SubscriptionResult+              effHandler evt = do+                liftIO $ modifyIORef' ref (evt :)+                n <- liftIO $ atomically $ do+                  c <- readTVar countRef+                  let c' = c + 1+                  writeTVar countRef c'+                  pure c'+                if n >= 10+                  then pure Stop+                  else pure Continue+          let cfg =+                SubscriptionConfig+                  { name = SubscriptionName "eff-catchup-test",+                    target = AllStreams,+                    handler = effHandler,+                    batchSize = defaultBatchSize,+                    queueCapacity = 16,+                    overflowPolicy = DropSubscription,+                    consumerGroup = Nothing,+                    consumerGroupGuard = False,+                    missingCheckpointPolicy = FromBeginning,+                    targetBindingPolicy = AdoptUnbound,+                    undecodableHandler = Nothing,+                    handlerStallWarnAfter = Nothing,+                    retryPolicy = defaultRetryPolicy,+                    eventTypeFilter = AllEventTypes,+                    selector = Nothing+                  }+          handle <- SubEff.subscribe cfg+          liftIO $ do+            r <- waitWithTimeout 10_000_000 handle+            case r of+              Left timeout -> expectationFailure timeout+              Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+              Right (Right ()) -> pure ()+        case result of+          Left err -> expectationFailure ("Store error: " <> show err)+          Right () -> pure ()+        collected <- readIORef ref+        length collected `shouldBe` 10++    -- =================================================================+    -- withSubscription bracket tests (EP-2 F25)+    -- =================================================================+    describe "withSubscription" $ do+      -- F25 regression — bracket cancels the worker on normal scope exit.+      it "cancels the worker on normal scope exit" $ \store -> do+        handleRef <- newIORef Nothing+        let cfg =+              SubscriptionConfig+                { name = SubscriptionName "withsub-normal",+                  target = AllStreams,+                  handler = \_ -> pure Continue,+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        withSubscription store cfg $ \h -> do+          writeIORef handleRef (Just h)+          threadDelay 100_000+        Just h <- readIORef handleRef+        -- Worker must terminate now that the scope has exited.+        outcome <- Async.race (threadDelay 2_000_000) (wait h)+        case outcome of+          Left () -> expectationFailure "worker did not exit after withSubscription scope"+          Right _ -> pure ()++      -- F25 regression — bracket cancels the worker even when the body throws.+      it "cancels the worker when the body throws" $ \store -> do+        handleRef <- newIORef Nothing+        let cfg =+              SubscriptionConfig+                { name = SubscriptionName "withsub-throw",+                  target = AllStreams,+                  handler = \_ -> pure Continue,+                  batchSize = defaultBatchSize,+                  queueCapacity = 16,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        result <-+          Control.Exception.try @SomeException $+            withSubscription store cfg $ \h -> do+              writeIORef handleRef (Just h)+              threadDelay 100_000+              error "withSubscription body deliberately throws"+        case result of+          Left _ -> pure ()+          Right () -> expectationFailure "expected exception to propagate"+        Just h <- readIORef handleRef+        outcome <- Async.race (threadDelay 2_000_000) (wait h)+        case outcome of+          Left () -> expectationFailure "worker did not exit after exception in withSubscription body"+          Right _ -> pure ()++  -- =================================================================+  -- Pure helpers (no database fixture)+  -- =================================================================++  -- F1 regression — pure detail parser used by attributeMultiStreamError.+  -- The integration scenario that would exercise this code path+  -- (ix_streams_stream_name 23505 raised inside an appendMultiStream txn)+  -- is unreachable via current SQL because every append CTE uses ON CONFLICT+  -- DO NOTHING / DO UPDATE. The pure helper is unit-tested here so a future+  -- schema change that introduces such a path arrives with the attribution+  -- already correct.+  describe "extractStreamNameFromDetail" $ do+    it "extracts the stream name from a typical PostgreSQL detail" $ do+      extractStreamNameFromDetail "Key (stream_name)=(orders-1) already exists."+        `shouldBe` Just "orders-1"+    it "extracts a stream name containing dashes and digits" $ do+      extractStreamNameFromDetail "Key (stream_name)=(multi-c-2) already exists."+        `shouldBe` Just "multi-c-2"+    it "returns Nothing when the detail is empty" $ do+      extractStreamNameFromDetail "" `shouldBe` Nothing+    it "returns Nothing when the detail does not contain '=('" $ do+      extractStreamNameFromDetail "no key here" `shouldBe` Nothing+    it "returns Nothing for an empty parenthesised value" $ do+      extractStreamNameFromDetail "Key (stream_name)=() already exists."+        `shouldBe` Nothing++  describe "isTransientSerializationError" $ do+    let serverUsage code =+          SessionUsageError $+            Errors.StatementSessionError+              1+              0+              ""+              []+              True+              ( Errors.ServerStatementError $+                  Errors.ServerError code "server error" Nothing Nothing Nothing+              )+    it "recognizes only serialization and deadlock SQLSTATEs" $ do+      isTransientSerializationError (serverUsage "40P01") `shouldBe` True+      isTransientSerializationError (serverUsage "40001") `shouldBe` True+      isTransientSerializationError (serverUsage "23505") `shouldBe` False+      isTransientSerializationError AcquisitionTimeoutUsageError `shouldBe` False++    -- The store retries an append once on these codes, so a caller only+    -- sees one when the conflict repeated. It must arrive as the retryable+    -- constructor: 'UnexpectedServerError' documents itself as "not+    -- generally retryable — investigate", which would send a caller to+    -- diagnose a condition whose correct handling is to retry.+    it "maps both transient SQLSTATEs to TransientTransactionFailure" $ do+      forM_ ["40001", "40P01"] $ \code -> do+        mapUsageError "orders-1" AnyVersion (serverUsage code)+          `shouldBe` TransientTransactionFailure code "server error"+        mapGenericUsageError (serverUsage code)+          `shouldBe` TransientTransactionFailure code "server error"+        mapTransactionUsageError (serverUsage code)+          `shouldBe` TransientTransactionFailure code "server error"+        mapLinkUsageError (StreamName "orders-1") (serverUsage code)+          `shouldBe` TransientTransactionFailure code "server error"+        attributeMultiStreamError+          [(StreamName "orders-1", AnyVersion)]+          (serverUsage code)+          `shouldBe` TransientTransactionFailure code "server error"++    it "leaves non-transient server codes on UnexpectedServerError" $ do+      mapGenericUsageError (serverUsage "42883")+        `shouldBe` UnexpectedServerError "42883" "server error"++  -- =================================================================+  -- Notifier reconnection tests (EP-3 F1)+  -- =================================================================+  describe "Notifier reconnection" $ do+    -- F1 regression — the listener loop reconnects on backend termination,+    -- and `stopNotifier` must release the *current* (post-reconnect)+    -- connection. Without the fix, the original Notifier.listenerConn was+    -- a frozen reference to the first conn and the reconnected conn leaked+    -- past `withStore` exit. We assert no kiroku-listener backend remains+    -- in pg_stat_activity after the store shuts down.+    it "releases the reconnected listener connection on shutdown" $ \() -> do+      n <- withMigratedTestDatabase $ \connStr -> do+        let settings = defaultConnectionSettings connStr+        withStore settings $ \store -> do+          pid1 <- waitForListenerPid (store ^. #pool) 5_000_000+          terminateBackend (store ^. #pool) pid1+          pid2 <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000+          pid2 `shouldNotBe` pid1+        -- After withStore exits, no kiroku-listener connection remains.+        listenerCount connStr+      n `shouldBe` (0 :: Int64)++  -- =================================================================+  -- Health monitoring tests (M6.8)+  -- =================================================================+  describe "observationHandler" $ do+    it "receives observations during store operations" $ \() -> do+      ref <- newIORef ([] :: [Observation])+      let handler obs = modifyIORef' ref (obs :)+      withTestStoreSettings+        (\settings -> settings {observationHandler = Just handler})+        $ \store -> do+          Right _ <- runStoreIO store $ appendToStream (StreamName "obs-test") NoStream [makeEvent "X" (Aeson.object [])]+          pure ()+      observations <- readIORef ref+      length observations `shouldSatisfy` (> 0)++  -- =================================================================+  -- KirokuEvent observation surface (EP-5 F1, F13, F14)+  -- =================================================================+  describe "KirokuEvent observation" $ do+    -- EP-5 F1 regression — terminating the listener backend triggers+    -- KirokuEventNotifierReconnecting then KirokuEventNotifierReconnected.+    -- Before EP-5 the reconnect path emitted no signal at all.+    it "emits notifier reconnect events on backend termination (F1)" $ \() -> do+      ref <- newIORef ([] :: [KirokuEvent])+      let evtHandler e = modifyIORef' ref (e :)+      withMigratedTestDatabase $ \connStr -> do+        let settings =+              defaultConnectionSettings connStr+                & #eventHandler .~ Just evtHandler+        withStore settings $ \store -> do+          pid1 <- waitForListenerPid (store ^. #pool) 5_000_000+          terminateBackend (store ^. #pool) pid1+          _ <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000+          pure ()+      evts <- readIORef ref+      let isReconnecting (KirokuEventNotifierReconnecting _ _) = True+          isReconnecting _ = False+          isReconnected KirokuEventNotifierReconnected = True+          isReconnected _ = False+      any isReconnecting evts `shouldBe` True+      any isReconnected evts `shouldBe` True++    -- EP-5 F14 regression — subscriptions emit started, caught-up, and+    -- stopped events. Before EP-5 the lifecycle was invisible.+    --+    -- Subscribe before appending so the worker enters catch-up at+    -- position 0, immediately reaches the publisher's position 0,+    -- emits CaughtUp, transitions to live mode, then receives the+    -- two appended events and stops.+    it "emits subscription lifecycle events (F14)" $ \() -> do+      ref <- newIORef ([] :: [KirokuEvent])+      let evtHandler e = modifyIORef' ref (e :)+      withTestStoreSettings+        (& #eventHandler .~ Just evtHandler)+        $ \store -> do+          countRef <- newTVarIO (0 :: Int)+          let h _ = do+                n <- atomically $ do+                  c <- readTVar countRef+                  writeTVar countRef (c + 1)+                  pure (c + 1)+                if n >= 2 then pure Stop else pure Continue+          let cfg =+                SubscriptionConfig+                  { name = SubscriptionName "lifecycle-test",+                    target = AllStreams,+                    handler = h,+                    batchSize = defaultBatchSize,+                    queueCapacity = 16,+                    overflowPolicy = DropSubscription,+                    consumerGroup = Nothing,+                    consumerGroupGuard = False,+                    missingCheckpointPolicy = FromBeginning,+                    targetBindingPolicy = AdoptUnbound,+                    undecodableHandler = Nothing,+                    handlerStallWarnAfter = Nothing,+                    retryPolicy = defaultRetryPolicy,+                    eventTypeFilter = AllEventTypes,+                    selector = Nothing+                  }+          handle <- subscribe store cfg+          -- Give the worker a moment to enter live mode.+          threadDelay 200_000+          Right _ <-+            runStoreIO store $+              appendToStream (StreamName "lifecycle-1") NoStream [makeEvent "X" (Aeson.object [])]+          Right _ <-+            runStoreIO store $+              appendToStream (StreamName "lifecycle-1") (ExactVersion (StreamVersion 1)) [makeEvent "Y" (Aeson.object [])]+          _ <- waitWithTimeout 10_000_000 handle+          pure ()+      evts <- readIORef ref+      let isStarted (KirokuEventSubscriptionStarted (SubscriptionName "lifecycle-test") _ _) = True+          isStarted _ = False+          isCaughtUp (KirokuEventSubscriptionCaughtUp (SubscriptionName "lifecycle-test") _ _) = True+          isCaughtUp _ = False+          isStopped (KirokuEventSubscriptionStopped (SubscriptionName "lifecycle-test") _ StopHandlerRequested _) = True+          isStopped _ = False+      any isStarted evts `shouldBe` True+      any isCaughtUp evts `shouldBe` True+      any isStopped evts `shouldBe` True++    -- EP-5 F13 regression — hard-delete emits a fail-safe audit event.+    -- Before EP-5 there was no in-band audit signal.+    it "emits a hard-delete event when the stream existed (F13)" $ \() -> do+      ref <- newIORef ([] :: [KirokuEvent])+      let evtHandler e = modifyIORef' ref (e :)+      withTestStoreSettings+        (& #eventHandler .~ Just evtHandler)+        $ \store -> do+          Right _ <-+            runStoreIO store $+              appendToStream (StreamName "hard-delete-evt") NoStream [makeEvent "X" (Aeson.object [])]+          Right _ <- runStoreIO store $ hardDeleteStream (StreamName "hard-delete-evt")+          -- A hard-delete on a non-existent stream must not emit+          -- the event — verify by issuing a second one.+          Right _ <- runStoreIO store $ hardDeleteStream (StreamName "never-existed")+          pure ()+      evts <- readIORef ref+      let hardDeletes =+            [ name'+            | KirokuEventHardDeleteIssued (StreamName name') _ <- evts+            ]+      hardDeletes `shouldBe` ["hard-delete-evt"]
test/Test/BrowseQueryPlans.hs view
@@ -25,72 +25,74 @@  spec :: Spec spec = describe "browse reads prepared query work" $ do-    around withTestStore $ it "bounds production generic and custom index seeks on a 40000-stream catalog" preparedChecks-    it "uses the same bounded production statements in an English ICU database" $ withIcuStore $ \store -> do-        preparedChecks store-        Right _ <- runStoreIO store $ appendToStream (StreamName "orders-éclair") NoStream [EventData Nothing (EventType "Created") (Aeson.object []) Nothing Nothing Nothing]-        Right _ <- runStoreIO store $ appendToStream (StreamName "orders-éclair") NoStream [EventData Nothing (EventType "Created") (Aeson.object []) Nothing Nothing Nothing]-        let page = either (error . show) (\size -> size) (mkBrowsePageSize 11)-        Right rows <- runStoreIO store $ listStreams (Just (CategoryName "orders")) (Just "orders-é") Nothing page-        map (\row -> row ^. #name) (V.toList rows) `shouldBe` [StreamName "orders-éclair"]+  around withTestStore $ it "bounds production generic and custom index seeks on a 40000-stream catalog" preparedChecks+  it "uses the same bounded production statements in an English ICU database" $ withIcuStore $ \store -> do+    preparedChecks store+    Right _ <- runStoreIO store $ appendToStream (StreamName "orders-éclair") NoStream [EventData Nothing (EventType "Created") (Aeson.object []) Nothing Nothing Nothing]+    Right _ <- runStoreIO store $ appendToStream (StreamName "orders-éclair") NoStream [EventData Nothing (EventType "Created") (Aeson.object []) Nothing Nothing Nothing]+    let page = either (error . show) (\size -> size) (mkBrowsePageSize 11)+    Right rows <- runStoreIO store $ listStreams (Just (CategoryName "orders")) (Just "orders-é") Nothing page+    map (\row -> row ^. #name) (V.toList rows) `shouldBe` [StreamName "orders-éclair"]  preparedChecks :: KirokuStore -> IO () preparedChecks store = do-    seeded <--        Pool.use (store ^. #pool) $-            Session.script-                "INSERT INTO streams(stream_name) SELECT c || '-' || lpad(n::text,10,'0') FROM unnest(ARRAY['orders','noise']) c CROSS JOIN generate_series(1,20000) n; ANALYZE streams;"-    seeded `shouldBe` Right ()-    forM_ ["force_generic_plan", "force_custom_plan"] $ \mode -> do-        forM_ [(SQL.InclusiveLower, "'orders-', 'orders.', 11"), (SQL.ExclusiveLower, "'orders-0000019990', 'orders.', 11"), (SQL.InclusiveLower, "'absent', 'absenu', 11"), (SQL.InclusiveLower, "'', NULL, 11")] $ \(bound, args) -> do-            plan <- explainPrepared store mode "text,text,int4" args (SQL.listStreamsRangeStmt bound)-            expectBounded plan 11-        plan <- explainPrepared store mode "text,text,text,text,int4" "'orders', NULL, 'orders-', 'orders.', 11" (SQL.listStreamsPairStmt SQL.ExactName SQL.InclusiveLower)-        expectBounded plan 22-        forM_ [(False, "NULL, 11"), (True, "'orders', 11")] $ \(hasCursor, args) -> do-            categoryPlan <- explainPrepared store mode "text,int4" args (SQL.listCategoriesStmt hasCursor)-            indexNames categoryPlan `shouldContain` ["ix_streams_category"]-            -- Each loose-index seek returns at most one distinct category.-            scannedRows categoryPlan `shouldSatisfy` (<= 12)+  seeded <-+    Pool.use (store ^. #pool) $+      Session.script+        "INSERT INTO streams(stream_name) SELECT c || '-' || lpad(n::text,10,'0') FROM unnest(ARRAY['orders','noise']) c CROSS JOIN generate_series(1,20000) n; ANALYZE streams;"+  seeded `shouldBe` Right ()+  forM_ ["force_generic_plan", "force_custom_plan"] $ \mode -> do+    forM_ [(SQL.InclusiveLower, "'orders-', 'orders.', 11"), (SQL.ExclusiveLower, "'orders-0000019990', 'orders.', 11"), (SQL.InclusiveLower, "'absent', 'absenu', 11"), (SQL.InclusiveLower, "'', NULL, 11")] $ \(bound, args) -> do+      plan <- explainPrepared store mode "text,text,int4" args (SQL.listStreamsRangeStmt bound)+      expectBounded plan 11+    plan <- explainPrepared store mode "text,text,text,text,int4" "'orders', NULL, 'orders-', 'orders.', 11" (SQL.listStreamsPairStmt SQL.ExactName SQL.InclusiveLower)+    expectBounded plan 22+    forM_ [(False, "NULL, 11"), (True, "'orders', 11")] $ \(hasCursor, args) -> do+      categoryPlan <- explainPrepared store mode "text,int4" args (SQL.listCategoriesStmt hasCursor)+      indexNames categoryPlan `shouldContain` ["ix_streams_category"]+      -- Each loose-index seek returns at most one distinct category.+      scannedRows categoryPlan `shouldSatisfy` (<= 12)  explainPrepared :: KirokuStore -> Text -> Text -> Text -> Statement p r -> IO Value explainPrepared store mode types args statement = do-    result <- Pool.use (store ^. #pool) $ do-        Session.script ("SET plan_cache_mode=" <> mode <> "; PREPARE mp13_browse(" <> types <> ") AS " <> Statement.toSql statement)-        bytes <- Session.statement () (unpreparable ("EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) EXECUTE mp13_browse(" <> args <> ")") E.noParams (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right)))))-        Session.script "DEALLOCATE mp13_browse; RESET plan_cache_mode"-        pure bytes-    case result of-        Left err -> expectationFailure (show err) >> fail "EXPLAIN failed"-        Right bytes -> decodePlan bytes+  result <- Pool.use (store ^. #pool) $ do+    Session.script ("SET plan_cache_mode=" <> mode <> "; PREPARE mp13_browse(" <> types <> ") AS " <> Statement.toSql statement)+    bytes <- Session.statement () (unpreparable ("EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) EXECUTE mp13_browse(" <> args <> ")") E.noParams (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right)))))+    Session.script "DEALLOCATE mp13_browse; RESET plan_cache_mode"+    pure bytes+  case result of+    Left err -> expectationFailure (show err) >> fail "EXPLAIN failed"+    Right bytes -> decodePlan bytes   where     decodePlan :: ByteString -> IO Value     decodePlan bytes = either fail pure (Aeson.eitherDecodeStrict' bytes)  expectBounded :: Value -> Double -> Expectation expectBounded plan budget = do-    indexNames plan `shouldContain` ["ix_streams_browse_name"]-    scannedRows plan `shouldSatisfy` (<= budget)-    case plan of-        Array entries-            | Object entry <- V.head entries-            , Just (Object top) <- KM.lookup "Plan" entry ->-                number "Shared Hit Blocks" top + number "Shared Read Blocks" top `shouldSatisfy` (<= 64)-        _ -> expectationFailure (show plan)+  indexNames plan `shouldContain` ["ix_streams_browse_name"]+  scannedRows plan `shouldSatisfy` (<= budget)+  case plan of+    Array entries+      | Object entry <- V.head entries,+        Just (Object top) <- KM.lookup "Plan" entry ->+          number "Shared Hit Blocks" top + number "Shared Read Blocks" top `shouldSatisfy` (<= 64)+    _ -> expectationFailure (show plan)  -- Count rows examined at relation scans (including filters), not only results. scannedRows :: Value -> Double scannedRows (Object fields) = current + sum (map scannedRows (KM.elems fields))   where     current = case KM.lookup "Relation Name" fields of-        Just (String "streams") -> (number "Actual Rows" fields + number "Rows Removed by Filter" fields + number "Rows Removed by Index Recheck" fields) * number "Actual Loops" fields-        _ -> 0+      Just (String "streams") -> (number "Actual Rows" fields + number "Rows Removed by Filter" fields + number "Rows Removed by Index Recheck" fields) * number "Actual Loops" fields+      _ -> 0 scannedRows (Array values) = sum (map scannedRows (foldr (:) [] values)) scannedRows _ = 0+ indexNames :: Value -> [Text] indexNames (Object fields) = [name | Just (String name) <- [KM.lookup "Index Name" fields]] <> concatMap indexNames (KM.elems fields) indexNames (Array values) = concatMap indexNames (foldr (:) [] values) indexNames _ = []+ number :: Aeson.Key -> Aeson.Object -> Double number key fields = case KM.lookup key fields of Just (Number n) -> realToFrac n; _ -> 0 @@ -98,12 +100,12 @@ -- rather than altering existing columns or using a different hand-written query. withIcuStore :: (KirokuStore -> IO ()) -> IO () withIcuStore action = withMigratedTestDatabase $ \connection ->-    withStore (defaultConnectionSettings connection) $ \admin -> do-        let sql command = Pool.use (admin ^. #pool) (Session.script command) >>= either (fail . show) pure-            newConnection = T.unwords (filter (not . T.isPrefixOf "dbname=") (T.words connection) <> ["dbname=mp13_browse_icu"])-        bracket_-            (sql "CREATE DATABASE mp13_browse_icu TEMPLATE template0 LOCALE_PROVIDER icu ICU_LOCALE 'en'")-            (sql "DROP DATABASE mp13_browse_icu")-            $ do-                migrateTestDatabase newConnection-                withStore (defaultConnectionSettings newConnection) action+  withStore (defaultConnectionSettings connection) $ \admin -> do+    let sql command = Pool.use (admin ^. #pool) (Session.script command) >>= either (fail . show) pure+        newConnection = T.unwords (filter (not . T.isPrefixOf "dbname=") (T.words connection) <> ["dbname=mp13_browse_icu"])+    bracket_+      (sql "CREATE DATABASE mp13_browse_icu TEMPLATE template0 LOCALE_PROVIDER icu ICU_LOCALE 'en'")+      (sql "DROP DATABASE mp13_browse_icu")+      $ do+        migrateTestDatabase newConnection+        withStore (defaultConnectionSettings newConnection) action
test/Test/BrowseReads.hs view
@@ -22,73 +22,73 @@  seed :: KirokuStore -> [Text] -> IO () seed store = mapM_ $ \name -> do-    result <- runStoreIO store $ appendToStream (StreamName name) NoStream [makeEvent "Created" (Aeson.object [])]-    result `shouldSatisfy` either (const False) (const True)+  result <- runStoreIO store $ appendToStream (StreamName name) NoStream [makeEvent "Created" (Aeson.object [])]+  result `shouldSatisfy` either (const False) (const True)  spec :: Spec spec = describe "browse reads" $ do-    it "validates bounded page sizes" $ do-        forM_ [minBound, -1, 0, 1002, maxBound] $ \n -> mkBrowsePageSize n `shouldBe` Left (InvalidBrowsePageSize n)-        forM_ [1, 1000, 1001] $ \n -> fmap browsePageSizeValue (mkBrowsePageSize n) `shouldBe` Right (fromIntegral n)-    around withTestStore $ do-        it "excludes only the reserved all row and pages in UTF-8 byte order" $ \store -> do-            runStoreIO store (listStreams Nothing Nothing Nothing (page 10)) `shouldReturn` Right V.empty-            let values = ["orders-2", "orders", "orders-1", "$all-x", "singleton", "é-1", "Z-1", "-empty-category"]-            seed store values-            Right first <- runStoreIO store $ listStreams Nothing Nothing Nothing (page 3)-            names first `shouldBe` take 3 (sort values)-            Right rest <- runStoreIO store $ listStreams Nothing Nothing (Just (StreamName (last (names first)))) (page 100)-            names first <> names rest `shouldBe` sort values-            runStoreIO store (listStreams Nothing Nothing (Just (StreamName (last (sort values)))) (page 10)) `shouldReturn` Right V.empty-        it "intersects exact categories, literal prefixes and exclusive cursors" $ \store -> do-            let maxScalar = T.singleton (chr 0x10ffff)-                values = ["orders", "orders-1", "orders-2", "orders:other-1", "orders_-1", "orders%-1", "$all-x", "-a", "é-a", "é-a", maxScalar, maxScalar <> "-a", "\xD7FF-a", "\xE000-a"]-            seed store values-            forM_ [Nothing, Just (CategoryName "orders"), Just (CategoryName ""), Just (CategoryName "$all"), Just (CategoryName "not-a-category"), Just (CategoryName maxScalar)] $ \category ->-                forM_ [Nothing, Just "", Just "orders", Just "orders-", Just "orders%", Just "orders_", Just "absent", Just "é", Just maxScalar, Just "\xD7FF"] $ \prefix ->-                    forM_ [Nothing, Just (StreamName "orders"), Just (StreamName "orders-1"), Just (StreamName "zzz")] $ \cursor -> do-                        let expected = sort [v | v <- values, maybe True (== categoryName (StreamName v)) category, maybe True (`T.isPrefixOf` v) prefix, maybe True (\(StreamName c) -> v > c) cursor]-                        Right actual <- runStoreIO store $ listStreams category prefix cursor (page 100)-                        names actual `shouldBe` expected-        it "enumerates distinct categories including bare and empty categories" $ \store -> do-            seed store ["orders", "orders-1", "orders-2", "$all-x", "-a", "singleton"]-            Right first <- runStoreIO store $ listCategories Nothing (page 2)-            V.toList first `shouldBe` [CategoryName "", CategoryName "$all"]-            Right rest <- runStoreIO store $ listCategories (Just (V.last first)) (page 10)-            V.toList rest `shouldBe` [CategoryName "orders", CategoryName "singleton"]-        it "keeps soft-deleted summaries and removes hard-deleted summaries" $ \store -> do-            seed store ["orders-soft", "orders-hard"]-            void $ runStoreIO store $ softDeleteStream (StreamName "orders-soft")-            void $ runStoreIO store $ setStreamTruncateBefore (StreamName "orders-soft") (StreamVersion 1)-            void $ runStoreIO store $ hardDeleteStream (StreamName "orders-hard")-            Right rows <- runStoreIO store $ listStreams Nothing Nothing Nothing (page 10)-            names rows `shouldBe` ["orders-soft"]-            (V.head rows ^. #deletedAt) `shouldSatisfy` maybe False (const True)-        it "returns the canonical global row once even when linked, then nothing after hard deletion" $ \store -> do-            seed store ["orders-1"]-            Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10-            let event = V.head events-            Right _ <- runStoreIO store $ linkToStream (StreamName "links-1") [event ^. #eventId]-            runStoreIO store (getEvent (event ^. #eventId)) `shouldReturn` Right (Just event)-            runStoreIO store (getEvent (EventId UUID.nil)) `shouldReturn` Right Nothing-            Right _ <- runStoreIO store $ hardDeleteStream (StreamName "orders-1")-            runStoreIO store (getEvent (event ^. #eventId)) `shouldReturn` Right Nothing-    it "applies the configured decode hook to event lookup" $ do-        let marker = Aeson.object ["decoded" Aeson..= True]-            hook event = pure (Right (event & #metadata .~ Just marker))-        withTestStoreSettings (\settings -> settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}) $ \store -> do-            seed store ["orders-1"]-            Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10-            Right (Just event) <- runStoreIO store $ getEvent (V.head events ^. #eventId)-            (event ^. #metadata) `shouldBe` Just marker+  it "validates bounded page sizes" $ do+    forM_ [minBound, -1, 0, 1002, maxBound] $ \n -> mkBrowsePageSize n `shouldBe` Left (InvalidBrowsePageSize n)+    forM_ [1, 1000, 1001] $ \n -> fmap browsePageSizeValue (mkBrowsePageSize n) `shouldBe` Right (fromIntegral n)+  around withTestStore $ do+    it "excludes only the reserved all row and pages in UTF-8 byte order" $ \store -> do+      runStoreIO store (listStreams Nothing Nothing Nothing (page 10)) `shouldReturn` Right V.empty+      let values = ["orders-2", "orders", "orders-1", "$all-x", "singleton", "é-1", "Z-1", "-empty-category"]+      seed store values+      Right first <- runStoreIO store $ listStreams Nothing Nothing Nothing (page 3)+      names first `shouldBe` take 3 (sort values)+      Right rest <- runStoreIO store $ listStreams Nothing Nothing (Just (StreamName (last (names first)))) (page 100)+      names first <> names rest `shouldBe` sort values+      runStoreIO store (listStreams Nothing Nothing (Just (StreamName (last (sort values)))) (page 10)) `shouldReturn` Right V.empty+    it "intersects exact categories, literal prefixes and exclusive cursors" $ \store -> do+      let maxScalar = T.singleton (chr 0x10ffff)+          values = ["orders", "orders-1", "orders-2", "orders:other-1", "orders_-1", "orders%-1", "$all-x", "-a", "é-a", "é-a", maxScalar, maxScalar <> "-a", "\xD7FF-a", "\xE000-a"]+      seed store values+      forM_ [Nothing, Just (CategoryName "orders"), Just (CategoryName ""), Just (CategoryName "$all"), Just (CategoryName "not-a-category"), Just (CategoryName maxScalar)] $ \category ->+        forM_ [Nothing, Just "", Just "orders", Just "orders-", Just "orders%", Just "orders_", Just "absent", Just "é", Just maxScalar, Just "\xD7FF"] $ \prefix ->+          forM_ [Nothing, Just (StreamName "orders"), Just (StreamName "orders-1"), Just (StreamName "zzz")] $ \cursor -> do+            let expected = sort [v | v <- values, maybe True (== categoryName (StreamName v)) category, maybe True (`T.isPrefixOf` v) prefix, maybe True (\(StreamName c) -> v > c) cursor]+            Right actual <- runStoreIO store $ listStreams category prefix cursor (page 100)+            names actual `shouldBe` expected+    it "enumerates distinct categories including bare and empty categories" $ \store -> do+      seed store ["orders", "orders-1", "orders-2", "$all-x", "-a", "singleton"]+      Right first <- runStoreIO store $ listCategories Nothing (page 2)+      V.toList first `shouldBe` [CategoryName "", CategoryName "$all"]+      Right rest <- runStoreIO store $ listCategories (Just (V.last first)) (page 10)+      V.toList rest `shouldBe` [CategoryName "orders", CategoryName "singleton"]+    it "keeps soft-deleted summaries and removes hard-deleted summaries" $ \store -> do+      seed store ["orders-soft", "orders-hard"]+      void $ runStoreIO store $ softDeleteStream (StreamName "orders-soft")+      void $ runStoreIO store $ setStreamTruncateBefore (StreamName "orders-soft") (StreamVersion 1)+      void $ runStoreIO store $ hardDeleteStream (StreamName "orders-hard")+      Right rows <- runStoreIO store $ listStreams Nothing Nothing Nothing (page 10)+      names rows `shouldBe` ["orders-soft"]+      (V.head rows ^. #deletedAt) `shouldSatisfy` maybe False (const True)+    it "returns the canonical global row once even when linked, then nothing after hard deletion" $ \store -> do+      seed store ["orders-1"]+      Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+      let event = V.head events+      Right _ <- runStoreIO store $ linkToStream (StreamName "links-1") [event ^. #eventId]+      runStoreIO store (getEvent (event ^. #eventId)) `shouldReturn` Right (Just event)+      runStoreIO store (getEvent (EventId UUID.nil)) `shouldReturn` Right Nothing+      Right _ <- runStoreIO store $ hardDeleteStream (StreamName "orders-1")+      runStoreIO store (getEvent (event ^. #eventId)) `shouldReturn` Right Nothing+  it "applies the configured decode hook to event lookup" $ do+    let marker = Aeson.object ["decoded" Aeson..= True]+        hook event = pure (Right (event & #metadata .~ Just marker))+    withTestStoreSettings (\settings -> settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}) $ \store -> do+      seed store ["orders-1"]+      Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+      Right (Just event) <- runStoreIO store $ getEvent (V.head events ^. #eventId)+      (event ^. #metadata) `shouldBe` Just marker -    it "propagates typed decode failure from event lookup" $ do-        let hook event = pure (Left (DecodeFailure (event ^. #eventId) "failure"))-        withTestStoreSettings (\settings -> settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}) $ \store -> do-            seed store ["orders-1"]-            Right rows <- runStoreIO store $ readStreamForward (StreamName "missing") (StreamVersion 0) 1-            rows `shouldBe` V.empty-            -- Use the append-provided id to avoid decoding a fixture through the failing hook.-            let event = makeEvent "Created" (Aeson.object []) & #eventId .~ Just (EventId UUID.nil)-            Right _ <- runStoreIO store $ appendToStream (StreamName "orders-2") NoStream [event]-            runStoreIO store (getEvent (EventId UUID.nil)) `shouldReturn` Left (EventDecodeFailed (DecodeFailure (EventId UUID.nil) "failure"))+  it "propagates typed decode failure from event lookup" $ do+    let hook event = pure (Left (DecodeFailure (event ^. #eventId) "failure"))+    withTestStoreSettings (\settings -> settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}) $ \store -> do+      seed store ["orders-1"]+      Right rows <- runStoreIO store $ readStreamForward (StreamName "missing") (StreamVersion 0) 1+      rows `shouldBe` V.empty+      -- Use the append-provided id to avoid decoding a fixture through the failing hook.+      let event = makeEvent "Created" (Aeson.object []) & #eventId .~ Just (EventId UUID.nil)+      Right _ <- runStoreIO store $ appendToStream (StreamName "orders-2") NoStream [event]+      runStoreIO store (getEvent (EventId UUID.nil)) `shouldReturn` Left (EventDecodeFailed (DecodeFailure (EventId UUID.nil) "failure"))
test/Test/BrowseReadsMock.hs view
@@ -13,24 +13,24 @@  spec :: Spec spec = describe "browse reads mock" $ it "dispatches each public wrapper exactly once with its parameters" $ do-    calls <- newIORef ([] :: [String])-    let page = either (error . show) (\size -> size) (mkBrowsePageSize 7)-        runner = interpret_ $ \case-            ListStreams category prefix cursor size -> do-                liftIO $ (category, prefix, cursor, size) `shouldBe` (Just (CategoryName "orders"), Just "orders-", Just (StreamName "orders-1"), page)-                liftIO $ modifyIORef' calls (<> ["streams"])-                pure V.empty-            ListCategories cursor size -> do-                liftIO $ (cursor, size) `shouldBe` (Just (CategoryName "orders"), page)-                liftIO $ modifyIORef' calls (<> ["categories"])-                pure V.empty-            GetEvent eid -> do-                liftIO $ eid `shouldBe` EventId UUID.nil-                liftIO $ modifyIORef' calls (<> ["event"])-                pure Nothing-            _ -> error "unexpected browse mock operation"-    _ <- runEff $ runner $ do-        _ <- listStreams (Just (CategoryName "orders")) (Just "orders-") (Just (StreamName "orders-1")) page-        _ <- listCategories (Just (CategoryName "orders")) page-        getEvent (EventId UUID.nil)-    readIORef calls `shouldReturn` ["streams", "categories", "event"]+  calls <- newIORef ([] :: [String])+  let page = either (error . show) (\size -> size) (mkBrowsePageSize 7)+      runner = interpret_ $ \case+        ListStreams category prefix cursor size -> do+          liftIO $ (category, prefix, cursor, size) `shouldBe` (Just (CategoryName "orders"), Just "orders-", Just (StreamName "orders-1"), page)+          liftIO $ modifyIORef' calls (<> ["streams"])+          pure V.empty+        ListCategories cursor size -> do+          liftIO $ (cursor, size) `shouldBe` (Just (CategoryName "orders"), page)+          liftIO $ modifyIORef' calls (<> ["categories"])+          pure V.empty+        GetEvent eid -> do+          liftIO $ eid `shouldBe` EventId UUID.nil+          liftIO $ modifyIORef' calls (<> ["event"])+          pure Nothing+        _ -> error "unexpected browse mock operation"+  _ <- runEff $ runner $ do+    _ <- listStreams (Just (CategoryName "orders")) (Just "orders-") (Just (StreamName "orders-1")) page+    _ <- listCategories (Just (CategoryName "orders")) page+    getEvent (EventId UUID.nil)+  readIORef calls `shouldReturn` ["streams", "categories", "event"]
test/Test/CatchupDbErrorNoPrematureSwitch.hs view
@@ -12,91 +12,91 @@ import Kiroku.Store import Kiroku.Store.Subscription.Types qualified as SubTypes import Kiroku.Store.Subscription.Worker (withFetchBatchHookForTest)-import Test.Helpers (-    caughtUpEventHandler,+import Test.Helpers+  ( caughtUpEventHandler,     makeEvent,     waitForPublisher,     waitForSubscriptionLive,     withTestStoreSettings,- )+  ) import Test.Hspec  spec :: Spec spec =-    describe "catch-up DB error handling" $-        it "retries a transient fetch error instead of switching to live mode at a stale cursor" $ \() -> do-            deliveredRef <- newIORef ([] :: [GlobalPosition])-            failedOnce <- newIORef False-            eventRef <- newIORef ([] :: [KirokuEvent])-            seenCount <- newTVarIO (0 :: Int)-            caughtUp <- newEmptyMVar+  describe "catch-up DB error handling" $+    it "retries a transient fetch error instead of switching to live mode at a stale cursor" $ \() -> do+      deliveredRef <- newIORef ([] :: [GlobalPosition])+      failedOnce <- newIORef False+      eventRef <- newIORef ([] :: [KirokuEvent])+      seenCount <- newTVarIO (0 :: Int)+      caughtUp <- newEmptyMVar -            let subName = SubscriptionName "catchup-db-error-no-gap"-                targetTail = GlobalPosition 3-                handler' evt = do-                    modifyIORef' deliveredRef ((evt ^. #globalPosition) :)-                    atomically $ do-                        n <- readTVar seenCount-                        writeTVar seenCount (n + 1)-                    pure Continue-                cfg =-                    SubscriptionConfig-                        { name = subName-                        , target = AllStreams-                        , handler = handler'-                        , batchSize = defaultBatchSize-                        , queueCapacity = 16-                        , overflowPolicy = DropSubscription-                        , consumerGroup = Nothing-                        , consumerGroupGuard = False-                        , missingCheckpointPolicy = FromBeginning-                        , targetBindingPolicy = AdoptUnbound-                        , undecodableHandler = Nothing-                        , handlerStallWarnAfter = Nothing-                        , retryPolicy = defaultRetryPolicy-                        , eventTypeFilter = AllEventTypes-                        , selector = Nothing-                        }-                observe evt = do-                    modifyIORef' eventRef (evt :)-                    caughtUpEventHandler subName caughtUp Nothing evt-                injectOnce config cursor-                    | SubTypes.name config == subName && cursor == GlobalPosition 0 = do-                        alreadyFailed <- readIORef failedOnce-                        if alreadyFailed-                            then pure Nothing-                            else do-                                writeIORef failedOnce True-                                pure (Just (Left Pool.AcquisitionTimeoutUsageError))-                    | otherwise = pure Nothing+      let subName = SubscriptionName "catchup-db-error-no-gap"+          targetTail = GlobalPosition 3+          handler' evt = do+            modifyIORef' deliveredRef ((evt ^. #globalPosition) :)+            atomically $ do+              n <- readTVar seenCount+              writeTVar seenCount (n + 1)+            pure Continue+          cfg =+            SubscriptionConfig+              { name = subName,+                target = AllStreams,+                handler = handler',+                batchSize = defaultBatchSize,+                queueCapacity = 16,+                overflowPolicy = DropSubscription,+                consumerGroup = Nothing,+                consumerGroupGuard = False,+                missingCheckpointPolicy = FromBeginning,+                targetBindingPolicy = AdoptUnbound,+                undecodableHandler = Nothing,+                handlerStallWarnAfter = Nothing,+                retryPolicy = defaultRetryPolicy,+                eventTypeFilter = AllEventTypes,+                selector = Nothing+              }+          observe evt = do+            modifyIORef' eventRef (evt :)+            caughtUpEventHandler subName caughtUp Nothing evt+          injectOnce config cursor+            | SubTypes.name config == subName && cursor == GlobalPosition 0 = do+                alreadyFailed <- readIORef failedOnce+                if alreadyFailed+                  then pure Nothing+                  else do+                    writeIORef failedOnce True+                    pure (Just (Left Pool.AcquisitionTimeoutUsageError))+            | otherwise = pure Nothing -            withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-db-error-1") NoStream [makeEvent "One" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-db-error-2") NoStream [makeEvent "Two" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-db-error-3") NoStream [makeEvent "Three" (Aeson.object [])]-                waitForPublisher store targetTail+      withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-db-error-1") NoStream [makeEvent "One" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-db-error-2") NoStream [makeEvent "Two" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "catchup-db-error-3") NoStream [makeEvent "Three" (Aeson.object [])]+        waitForPublisher store targetTail -                withFetchBatchHookForTest injectOnce $ do-                    handle <- subscribe store cfg-                    waitForSubscriptionLive caughtUp-                    atomically $ do-                        n <- readTVar seenCount-                        check (n >= 3)-                    cancel handle+        withFetchBatchHookForTest injectOnce $ do+          handle <- subscribe store cfg+          waitForSubscriptionLive caughtUp+          atomically $ do+            n <- readTVar seenCount+            check (n >= 3)+          cancel handle -            delivered <- readIORef deliveredRef-            List.sort delivered `shouldBe` [GlobalPosition 1, GlobalPosition 2, GlobalPosition 3]+      delivered <- readIORef deliveredRef+      List.sort delivered `shouldBe` [GlobalPosition 1, GlobalPosition 2, GlobalPosition 3] -            observed <- readIORef eventRef-            let isFetchDbError = \case-                    KirokuEventSubscriptionDbError n FetchBatch Pool.AcquisitionTimeoutUsageError _-                        | n == subName -> True-                    _ -> False-                caughtUpPositions =-                    [ pos-                    | KirokuEventSubscriptionCaughtUp n pos _ <- observed-                    , n == subName-                    ]+      observed <- readIORef eventRef+      let isFetchDbError = \case+            KirokuEventSubscriptionDbError n FetchBatch Pool.AcquisitionTimeoutUsageError _+              | n == subName -> True+            _ -> False+          caughtUpPositions =+            [ pos+            | KirokuEventSubscriptionCaughtUp n pos _ <- observed,+              n == subName+            ] -            any isFetchDbError observed `shouldBe` True-            V.fromList caughtUpPositions `shouldBe` V.singleton targetTail+      any isFetchDbError observed `shouldBe` True+      V.fromList caughtUpPositions `shouldBe` V.singleton targetTail
test/Test/Category.hs view
@@ -1,14 +1,13 @@-{- | Pure laws for the category accessor and safe stream-name constructor-('Kiroku.Store.Types.categoryName' and 'streamNameInCategory').--These pin the "category = substring before the first @-@" rule — enforced by-the @streams.category GENERATED ALWAYS AS split_part(stream_name,'-',1)@ column-and re-derived in 'Kiroku.Store.Notification' — so the public accessor cannot-drift from it. The round-trip law is the contract keiro's typed @Category@ API-(ExecPlan #66) builds on.--Pure; no database. Runs inside hspec via 'hspec-hedgehog'.--}+-- | Pure laws for the category accessor and safe stream-name constructor+-- ('Kiroku.Store.Types.categoryName' and 'streamNameInCategory').+--+-- These pin the "category = substring before the first @-@" rule — enforced by+-- the @streams.category GENERATED ALWAYS AS split_part(stream_name,'-',1)@ column+-- and re-derived in 'Kiroku.Store.Notification' — so the public accessor cannot+-- drift from it. The round-trip law is the contract keiro's typed @Category@ API+-- (ExecPlan #66) builds on.+--+-- Pure; no database. Runs inside hspec via 'hspec-hedgehog'. module Test.Category (spec) where  import Data.Text (Text)@@ -20,17 +19,15 @@ import Test.Hspec import Test.Hspec.Hedgehog (hedgehog) -{- | A non-empty, dash-free category. Includes @:@ and @$@ to exercise the-sub-namespacing convention (e.g. @"wf:orders"@) without the @-@ boundary.--}+-- | A non-empty, dash-free category. Includes @:@ and @$@ to exercise the+-- sub-namespacing convention (e.g. @"wf:orders"@) without the @-@ boundary. genCategoryText :: Gen Text genCategoryText = Gen.text (Range.linear 1 16) (Gen.element categoryChars)   where     categoryChars = ['a' .. 'z'] <> ['0' .. '9'] <> [':', '$', '_'] -{- | An arbitrary id segment. May contain @-@: the id lives after the first-@-@, so embedded dashes must not change the leading category.--}+-- | An arbitrary id segment. May contain @-@: the id lives after the first+-- @-@, so embedded dashes must not change the leading category. genSegment :: Gen Text genSegment = Gen.text (Range.linear 0 16) (Gen.element segmentChars)   where@@ -44,27 +41,27 @@  spec :: Spec spec = describe "Kiroku.Store.Types category rule" $ do-    describe "categoryName" $ do-        it "takes the substring before the first dash" $-            categoryName (StreamName "orders-1") `shouldBe` CategoryName "orders"+  describe "categoryName" $ do+    it "takes the substring before the first dash" $+      categoryName (StreamName "orders-1") `shouldBe` CategoryName "orders" -        it "treats a dashless name as its own category" $-            categoryName (StreamName "singleton") `shouldBe` CategoryName "singleton"+    it "treats a dashless name as its own category" $+      categoryName (StreamName "singleton") `shouldBe` CategoryName "singleton" -        it "stops at the first dash even with later dashes" $-            categoryName (StreamName "orders-a-b-c") `shouldBe` CategoryName "orders"+    it "stops at the first dash even with later dashes" $+      categoryName (StreamName "orders-a-b-c") `shouldBe` CategoryName "orders" -        it "never yields a category containing a dash" $ hedgehog $ do-            name <- forAll genStreamNameText-            let CategoryName cat = categoryName (StreamName name)-            assert (not (T.isInfixOf "-" cat))+    it "never yields a category containing a dash" $ hedgehog $ do+      name <- forAll genStreamNameText+      let CategoryName cat = categoryName (StreamName name)+      assert (not (T.isInfixOf "-" cat)) -    describe "streamNameInCategory" $ do-        it "joins category and segment with a dash" $-            streamNameInCategory (CategoryName "orders") "1" `shouldBe` StreamName "orders-1"+  describe "streamNameInCategory" $ do+    it "joins category and segment with a dash" $+      streamNameInCategory (CategoryName "orders") "1" `shouldBe` StreamName "orders-1" -    describe "round-trip" $ do-        it "categoryName . streamNameInCategory == id, for dash-free categories" $ hedgehog $ do-            cat <- forAll genCategoryText-            seg <- forAll genSegment-            categoryName (streamNameInCategory (CategoryName cat) seg) === CategoryName cat+  describe "round-trip" $ do+    it "categoryName . streamNameInCategory == id, for dash-free categories" $ hedgehog $ do+      cat <- forAll genCategoryText+      seg <- forAll genSegment+      categoryName (streamNameInCategory (CategoryName cat) seg) === CategoryName cat
test/Test/CategoryIdleNoSpin.hs view
@@ -1,41 +1,40 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Regression tests for ExecPlan 37 — the live DB-driven subscription loops must-not busy-spin when idle while the store's global @$all@ position advances for-/other/ categories or partitions.--Two paths are covered:--  * Non-group @Category@ subscription-    ('Kiroku.Store.Subscription.Worker.liveLoopCategoryNotify'): an idle category-    blocks on its own per-category NOTIFY generation and does __zero__ database-    fetches while a different category receives sustained traffic. A real append-    to the subscribed category still wakes it (liveness).-  * Consumer-group members of a @Category@ (plan 91 M5): they share the plain-    category loop and its per-category generation, so both members of an idle-    category do __zero__ fetches while another category is busy, and an append-    to the category wakes them and the owning member delivers it.-  * Consumer-group member of @AllStreams@-    ('Kiroku.Store.Subscription.Worker.liveLoopDbDriven', corrected gate): the-    member gates on the /last observed global position/ rather than its-    per-partition cursor, so it wakes at most once per global advance — a bounded-    number of fetches — instead of the unbounded spin the original cursor-gate-    produced.--The deterministic signal is the additive-'Kiroku.Store.Observability.KirokuEventSubscriptionFetched' event, emitted once-per live DB-driven fetch. The store's @eventHandler@ counts those whose-'SubscriptionName' matches the subscriber under test; the subscription's own-handler counts deliveries. (@pg_stat_statements@ is not available in this suite —-see the plan's Decision Log.)--To confirm these specs actually pin the regression: temporarily restore the old-cursor-gated 'liveLoopDbDriven' body and route @Category@ subscriptions back-through it — the idle fetch counts then explode and the assertions fail. Routing-only @(Just _, Category{})@ back through the corrected 'liveLoopDbDriven' fails-the zero-fetch assertion for group category members.--}+-- | Regression tests for ExecPlan 37 — the live DB-driven subscription loops must+-- not busy-spin when idle while the store's global @$all@ position advances for+-- /other/ categories or partitions.+--+-- Two paths are covered:+--+--   * Non-group @Category@ subscription+--     ('Kiroku.Store.Subscription.Worker.liveLoopCategoryNotify'): an idle category+--     blocks on its own per-category NOTIFY generation and does __zero__ database+--     fetches while a different category receives sustained traffic. A real append+--     to the subscribed category still wakes it (liveness).+--   * Consumer-group members of a @Category@ (plan 91 M5): they share the plain+--     category loop and its per-category generation, so both members of an idle+--     category do __zero__ fetches while another category is busy, and an append+--     to the category wakes them and the owning member delivers it.+--   * Consumer-group member of @AllStreams@+--     ('Kiroku.Store.Subscription.Worker.liveLoopDbDriven', corrected gate): the+--     member gates on the /last observed global position/ rather than its+--     per-partition cursor, so it wakes at most once per global advance — a bounded+--     number of fetches — instead of the unbounded spin the original cursor-gate+--     produced.+--+-- The deterministic signal is the additive+-- 'Kiroku.Store.Observability.KirokuEventSubscriptionFetched' event, emitted once+-- per live DB-driven fetch. The store's @eventHandler@ counts those whose+-- 'SubscriptionName' matches the subscriber under test; the subscription's own+-- handler counts deliveries. (@pg_stat_statements@ is not available in this suite —+-- see the plan's Decision Log.)+--+-- To confirm these specs actually pin the regression: temporarily restore the old+-- cursor-gated 'liveLoopDbDriven' body and route @Category@ subscriptions back+-- through it — the idle fetch counts then explode and the assertions fail. Routing+-- only @(Just _, Category{})@ back through the corrected 'liveLoopDbDriven' fails+-- the zero-fetch assertion for group category members. module Test.CategoryIdleNoSpin (spec) where  import Control.Concurrent (threadDelay)@@ -53,162 +52,162 @@ -- | Poll an @IO Bool@ predicate until it holds or the microsecond budget runs out. waitUntil :: Int -> IO Bool -> IO () waitUntil budget act-    | budget <= 0 = pure ()-    | otherwise = do-        ok <- act-        if ok-            then pure ()-            else do-                threadDelay 20_000-                waitUntil (budget - 20_000) act+  | budget <= 0 = pure ()+  | otherwise = do+      ok <- act+      if ok+        then pure ()+        else do+          threadDelay 20_000+          waitUntil (budget - 20_000) act  -- | Append one event to each named stream, failing the test on a store error. appendEach :: KirokuStore -> [T.Text] -> T.Text -> IO () appendEach store names typ =-    mapM_-        ( \sn -> do-            r <- runStoreIO store $ appendToStream (StreamName sn) NoStream [makeEvent typ (Aeson.object [])]-            either (error . ("append failed: " <>) . show) (const (pure ())) r-        )-        names+  mapM_+    ( \sn -> do+        r <- runStoreIO store $ appendToStream (StreamName sn) NoStream [makeEvent typ (Aeson.object [])]+        either (error . ("append failed: " <>) . show) (const (pure ())) r+    )+    names  spec :: Spec spec = describe "live loops do not busy-spin while idle (plan 37)" $ do-    it "an idle Category subscriber does zero fetches while another category is active, then wakes on its own event" $ do-        let subName = SubscriptionName "alpha-sub"-        deliveredVar <- newTVarIO (0 :: Int)-        fetchVar <- newTVarIO (0 :: Int)-        liveBarrier <- newEmptyMVar-        let countFetch evt = case evt of-                KirokuEventSubscriptionFetched n _ _-                    | n == subName -> atomically (modifyTVar' fetchVar (+ 1))-                _ -> pure ()-            obsHandler = caughtUpEventHandler subName liveBarrier (Just countFetch)-        withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store -> do-            let deliver _evt = do-                    atomically (modifyTVar' deliveredVar (+ 1))-                    pure Continue-                cfg = defaultSubscriptionConfig subName (Category (CategoryName "alpha")) deliver-            bracket (subscribe store cfg) cancel $ \_handle -> do-                -- Wait until the worker reaches live mode and completes its initial-                -- post-catch-up drain (one empty fetch), so the baseline is settled.-                waitForSubscriptionLive liveBarrier-                waitUntil 5_000_000 ((>= 1) <$> readTVarIO fetchVar)-                base <- readTVarIO fetchVar+  it "an idle Category subscriber does zero fetches while another category is active, then wakes on its own event" $ do+    let subName = SubscriptionName "alpha-sub"+    deliveredVar <- newTVarIO (0 :: Int)+    fetchVar <- newTVarIO (0 :: Int)+    liveBarrier <- newEmptyMVar+    let countFetch evt = case evt of+          KirokuEventSubscriptionFetched n _ _+            | n == subName -> atomically (modifyTVar' fetchVar (+ 1))+          _ -> pure ()+        obsHandler = caughtUpEventHandler subName liveBarrier (Just countFetch)+    withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store -> do+      let deliver _evt = do+            atomically (modifyTVar' deliveredVar (+ 1))+            pure Continue+          cfg = defaultSubscriptionConfig subName (Category (CategoryName "alpha")) deliver+      bracket (subscribe store cfg) cancel $ \_handle -> do+        -- Wait until the worker reaches live mode and completes its initial+        -- post-catch-up drain (one empty fetch), so the baseline is settled.+        waitForSubscriptionLive liveBarrier+        waitUntil 5_000_000 ((>= 1) <$> readTVarIO fetchVar)+        base <- readTVarIO fetchVar -                -- Drive a DIFFERENT category hard: the global tail races far ahead of-                -- alpha's cursor. Under the old gate alpha woke per global tick and-                -- spun one empty fetch each time; under the fix it never wakes (its-                -- category generation is untouched).-                let betaCount = 20 :: Int-                    betaStreams = ["beta-" <> T.pack (show i) | i <- [1 .. betaCount]]-                appendEach store betaStreams "Beta"-                waitForPublisher store (GlobalPosition (fromIntegral betaCount))-                -- Give a (hypothetical) spinning worker ample time to accumulate fetches.-                threadDelay 500_000+        -- Drive a DIFFERENT category hard: the global tail races far ahead of+        -- alpha's cursor. Under the old gate alpha woke per global tick and+        -- spun one empty fetch each time; under the fix it never wakes (its+        -- category generation is untouched).+        let betaCount = 20 :: Int+            betaStreams = ["beta-" <> T.pack (show i) | i <- [1 .. betaCount]]+        appendEach store betaStreams "Beta"+        waitForPublisher store (GlobalPosition (fromIntegral betaCount))+        -- Give a (hypothetical) spinning worker ample time to accumulate fetches.+        threadDelay 500_000 -                afterIdle <- readTVarIO fetchVar-                deliveredIdle <- readTVarIO deliveredVar-                (afterIdle - base) `shouldBe` 0-                deliveredIdle `shouldBe` 0+        afterIdle <- readTVarIO fetchVar+        deliveredIdle <- readTVarIO deliveredVar+        (afterIdle - base) `shouldBe` 0+        deliveredIdle `shouldBe` 0 -                -- Liveness: a real append to alpha must wake the worker and deliver.-                appendEach store ["alpha-1"] "Alpha"-                waitUntil 5_000_000 ((>= 1) <$> readTVarIO deliveredVar)-                deliveredFinal <- readTVarIO deliveredVar-                deliveredFinal `shouldBe` 1+        -- Liveness: a real append to alpha must wake the worker and deliver.+        appendEach store ["alpha-1"] "Alpha"+        waitUntil 5_000_000 ((>= 1) <$> readTVarIO deliveredVar)+        deliveredFinal <- readTVarIO deliveredVar+        deliveredFinal `shouldBe` 1 -    it "idle consumer-group category members do zero fetches while another category is active, then the owner wakes on its own event" $ do-        -- Two members (0 and 1 of size 2) over category "quiet". Each runs under-        -- its own subscription name so each has its own live barrier and fetch-        -- counter; the partition predicate depends only on (member, size).-        let names = [SubscriptionName "quiet-grp-0", SubscriptionName "quiet-grp-1"]-        deliveredVar <- newTVarIO (0 :: Int)-        fetchVars <- mapM (const (newTVarIO (0 :: Int))) names-        barriers <- mapM (const newEmptyMVar) names-        let countFetch evt = case evt of-                KirokuEventSubscriptionFetched n _ _ ->-                    sequence_-                        [ atomically (modifyTVar' v (+ 1))-                        | (subName, v) <- zip names fetchVars-                        , n == subName-                        ]-                _ -> pure ()-            obsHandler evt = do-                sequence_ [caughtUpEventHandler subName barrier Nothing evt | (subName, barrier) <- zip names barriers]-                countFetch evt-            deliver _evt = do-                atomically (modifyTVar' deliveredVar (+ 1))-                pure Continue-            memberConfig subName member =-                (defaultSubscriptionConfig subName (Category (CategoryName "quiet")) deliver)-                    { consumerGroup = Just (validConsumerGroup member 2)-                    }-        withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store ->-            bracket (subscribe store (memberConfig (names !! 0) 0)) cancel $ \_ ->-                bracket (subscribe store (memberConfig (names !! 1) 1)) cancel $ \_ -> do-                    -- Settle: each member reaches live mode and finishes its initial-                    -- post-catch-up drain (one empty fetch).-                    mapM_ waitForSubscriptionLive barriers-                    mapM_ (\v -> waitUntil 5_000_000 ((>= 1) <$> readTVarIO v)) fetchVars-                    bases <- mapM readTVarIO fetchVars+  it "idle consumer-group category members do zero fetches while another category is active, then the owner wakes on its own event" $ do+    -- Two members (0 and 1 of size 2) over category "quiet". Each runs under+    -- its own subscription name so each has its own live barrier and fetch+    -- counter; the partition predicate depends only on (member, size).+    let names = [SubscriptionName "quiet-grp-0", SubscriptionName "quiet-grp-1"]+    deliveredVar <- newTVarIO (0 :: Int)+    fetchVars <- mapM (const (newTVarIO (0 :: Int))) names+    barriers <- mapM (const newEmptyMVar) names+    let countFetch evt = case evt of+          KirokuEventSubscriptionFetched n _ _ ->+            sequence_+              [ atomically (modifyTVar' v (+ 1))+              | (subName, v) <- zip names fetchVars,+                n == subName+              ]+          _ -> pure ()+        obsHandler evt = do+          sequence_ [caughtUpEventHandler subName barrier Nothing evt | (subName, barrier) <- zip names barriers]+          countFetch evt+        deliver _evt = do+          atomically (modifyTVar' deliveredVar (+ 1))+          pure Continue+        memberConfig subName member =+          (defaultSubscriptionConfig subName (Category (CategoryName "quiet")) deliver)+            { consumerGroup = Just (validConsumerGroup member 2)+            }+    withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store ->+      bracket (subscribe store (memberConfig (names !! 0) 0)) cancel $ \_ ->+        bracket (subscribe store (memberConfig (names !! 1) 1)) cancel $ \_ -> do+          -- Settle: each member reaches live mode and finishes its initial+          -- post-catch-up drain (one empty fetch).+          mapM_ waitForSubscriptionLive barriers+          mapM_ (\v -> waitUntil 5_000_000 ((>= 1) <$> readTVarIO v)) fetchVars+          bases <- mapM readTVarIO fetchVars -                    -- Drive a different category. Before plan 91 M5 each member woke on-                    -- every global advance and ran one empty fetch per wake; now both-                    -- wait on the "quiet" category's generation, which never moves.-                    let busyCount = 20 :: Int-                        busyStreams = ["busy-" <> T.pack (show i) | i <- [1 .. busyCount]]-                    appendEach store busyStreams "Busy"-                    waitForPublisher store (GlobalPosition (fromIntegral busyCount))-                    threadDelay 500_000+          -- Drive a different category. Before plan 91 M5 each member woke on+          -- every global advance and ran one empty fetch per wake; now both+          -- wait on the "quiet" category's generation, which never moves.+          let busyCount = 20 :: Int+              busyStreams = ["busy-" <> T.pack (show i) | i <- [1 .. busyCount]]+          appendEach store busyStreams "Busy"+          waitForPublisher store (GlobalPosition (fromIntegral busyCount))+          threadDelay 500_000 -                    afterIdle <- mapM readTVarIO fetchVars-                    zipWith (-) afterIdle bases `shouldBe` [0, 0]-                    readTVarIO deliveredVar `shouldReturn` 0+          afterIdle <- mapM readTVarIO fetchVars+          zipWith (-) afterIdle bases `shouldBe` [0, 0]+          readTVarIO deliveredVar `shouldReturn` 0 -                    -- Liveness: an append to the quiet category wakes both members;-                    -- exactly the owner of "quiet-1" delivers it.-                    appendEach store ["quiet-1"] "Quiet"-                    waitUntil 5_000_000 ((>= 1) <$> readTVarIO deliveredVar)-                    threadDelay 200_000-                    readTVarIO deliveredVar `shouldReturn` 1+          -- Liveness: an append to the quiet category wakes both members;+          -- exactly the owner of "quiet-1" delivers it.+          appendEach store ["quiet-1"] "Quiet"+          waitUntil 5_000_000 ((>= 1) <$> readTVarIO deliveredVar)+          threadDelay 200_000+          readTVarIO deliveredVar `shouldReturn` 1 -    it "an idle AllStreams consumer-group member wakes a bounded number of times, not a busy spin" $ do-        let subName = SubscriptionName "grp-all-sub"-        deliveredVar <- newTVarIO (0 :: Int)-        fetchVar <- newTVarIO (0 :: Int)-        liveBarrier <- newEmptyMVar-        let countFetch evt = case evt of-                KirokuEventSubscriptionFetched n _ _-                    | n == subName -> atomically (modifyTVar' fetchVar (+ 1))-                _ -> pure ()-            obsHandler = caughtUpEventHandler subName liveBarrier (Just countFetch)-        withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store -> do-            let deliver _evt = do-                    atomically (modifyTVar' deliveredVar (+ 1))-                    pure Continue-                -- Member 0 of 3 over $all. Its fetches apply the partition-                -- predicate, so it receives only its slice of the flood, but every-                -- append advances the global position it gates on.-                cfg =-                    (defaultSubscriptionConfig subName AllStreams deliver)-                        { consumerGroup = Just (validConsumerGroup 0 3)-                        }-            bracket (subscribe store cfg) cancel $ \_handle -> do-                waitForSubscriptionLive liveBarrier+  it "an idle AllStreams consumer-group member wakes a bounded number of times, not a busy spin" $ do+    let subName = SubscriptionName "grp-all-sub"+    deliveredVar <- newTVarIO (0 :: Int)+    fetchVar <- newTVarIO (0 :: Int)+    liveBarrier <- newEmptyMVar+    let countFetch evt = case evt of+          KirokuEventSubscriptionFetched n _ _+            | n == subName -> atomically (modifyTVar' fetchVar (+ 1))+          _ -> pure ()+        obsHandler = caughtUpEventHandler subName liveBarrier (Just countFetch)+    withTestStoreSettings (\s -> s & #eventHandler .~ Just obsHandler) $ \store -> do+      let deliver _evt = do+            atomically (modifyTVar' deliveredVar (+ 1))+            pure Continue+          -- Member 0 of 3 over $all. Its fetches apply the partition+          -- predicate, so it receives only its slice of the flood, but every+          -- append advances the global position it gates on.+          cfg =+            (defaultSubscriptionConfig subName AllStreams deliver)+              { consumerGroup = Just (validConsumerGroup 0 3)+              }+      bracket (subscribe store cfg) cancel $ \_handle -> do+        waitForSubscriptionLive liveBarrier -                let floodCount = 20 :: Int-                    floodStreams = ["flood-" <> T.pack (show i) | i <- [1 .. floodCount]]-                appendEach store floodStreams "Flood"-                waitForPublisher store (GlobalPosition (fromIntegral floodCount))-                threadDelay 500_000+        let floodCount = 20 :: Int+            floodStreams = ["flood-" <> T.pack (show i) | i <- [1 .. floodCount]]+        appendEach store floodStreams "Flood"+        waitForPublisher store (GlobalPosition (fromIntegral floodCount))+        threadDelay 500_000 -                afterIdle <- readTVarIO fetchVar-                delivered <- readTVarIO deliveredVar-                -- The corrected gate wakes at most once per observed global position-                -- and drains to empty, so fetches stay bounded by the flood size-                -- (plus the drain's empty fetch per wake) — NOT the unbounded-                -- busy-spin of the old cursor gate, which racks up thousands of-                -- empty fetches in 500ms.-                afterIdle `shouldSatisfy` (< 50)-                delivered `shouldSatisfy` (<= floodCount)+        afterIdle <- readTVarIO fetchVar+        delivered <- readTVarIO deliveredVar+        -- The corrected gate wakes at most once per observed global position+        -- and drains to empty, so fetches stay bounded by the flood size+        -- (plus the drain's empty fetch per wake) — NOT the unbounded+        -- busy-spin of the old cursor gate, which racks up thousands of+        -- empty fetches in 500ms.+        afterIdle `shouldSatisfy` (< 50)+        delivered `shouldSatisfy` (<= floodCount)
test/Test/Causation.hs view
@@ -1,15 +1,14 @@-{- | Tests for "Kiroku.Store.Causation" — the causation- and-correlation-walking helpers.--Three groups of tests exercise the three exported functions:--  * 'findCausationDescendants' over a 5-deep chain @A -> B -> C -> D -> E@-    spread across three streams, plus single-element and empty cases.-  * 'findCausationAncestors' over the same chain, asserting leaf-first-    depth ordering.-  * 'findByCorrelation' fanning in across multiple streams, plus a noise-    correlation and an empty case.--}+-- | Tests for "Kiroku.Store.Causation" — the causation- and+-- correlation-walking helpers.+--+-- Three groups of tests exercise the three exported functions:+--+--   * 'findCausationDescendants' over a 5-deep chain @A -> B -> C -> D -> E@+--     spread across three streams, plus single-element and empty cases.+--   * 'findCausationAncestors' over the same chain, asserting leaf-first+--     depth ordering.+--   * 'findByCorrelation' fanning in across multiple streams, plus a noise+--     correlation and an empty case. module Test.Causation (spec) where  import Control.Lens ((^.))@@ -28,87 +27,87 @@  spec :: Spec spec = around withTestStore $ do-    describe "findCausationDescendants" $ do-        it "returns the seed event and every descendant in global-position order" $ \store -> do-            uuids@[uA, _uB, _uC, _uD, _uE] <- replicateUuids 5-            appendChain store uuids-            Right found <- runStoreIO store $ findCausationDescendants (EventId uA)-            eventIds found `shouldBe` map EventId uuids-            globalPositionsStrictlyIncreasing found `shouldBe` True+  describe "findCausationDescendants" $ do+    it "returns the seed event and every descendant in global-position order" $ \store -> do+      uuids@[uA, _uB, _uC, _uD, _uE] <- replicateUuids 5+      appendChain store uuids+      Right found <- runStoreIO store $ findCausationDescendants (EventId uA)+      eventIds found `shouldBe` map EventId uuids+      globalPositionsStrictlyIncreasing found `shouldBe` True -        it "on an eventId with no descendants returns a vector of length 1 (the seed only)" $ \store -> do-            uId <- UUIDv4.nextRandom-            let ev = mkEventWithIds "Solo" (Just uId) Nothing Nothing-            Right _ <- runStoreIO store $ appendToStream (StreamName "solo-stream") NoStream [ev]-            Right found <- runStoreIO store $ findCausationDescendants (EventId uId)-            V.length found `shouldBe` 1-            (V.head found ^. #eventId) `shouldBe` EventId uId+    it "on an eventId with no descendants returns a vector of length 1 (the seed only)" $ \store -> do+      uId <- UUIDv4.nextRandom+      let ev = mkEventWithIds "Solo" (Just uId) Nothing Nothing+      Right _ <- runStoreIO store $ appendToStream (StreamName "solo-stream") NoStream [ev]+      Right found <- runStoreIO store $ findCausationDescendants (EventId uId)+      V.length found `shouldBe` 1+      (V.head found ^. #eventId) `shouldBe` EventId uId -        it "on a non-existent eventId returns an empty vector" $ \store -> do-            -- Make sure the store has *some* events so we know we're not-            -- silently returning the whole table.-            let ev = makeEvent "Other" Aeson.Null-            Right _ <- runStoreIO store $ appendToStream (StreamName "other-stream") NoStream [ev]-            Right found <- runStoreIO store $ findCausationDescendants (EventId UUID.nil)-            V.null found `shouldBe` True+    it "on a non-existent eventId returns an empty vector" $ \store -> do+      -- Make sure the store has *some* events so we know we're not+      -- silently returning the whole table.+      let ev = makeEvent "Other" Aeson.Null+      Right _ <- runStoreIO store $ appendToStream (StreamName "other-stream") NoStream [ev]+      Right found <- runStoreIO store $ findCausationDescendants (EventId UUID.nil)+      V.null found `shouldBe` True -    describe "findCausationAncestors" $ do-        it "walks from a leaf back to the root, leaf first" $ \store -> do-            uuids@[uA, uB, uC, uD, uE] <- replicateUuids 5-            appendChain store uuids-            Right found <- runStoreIO store $ findCausationAncestors (EventId uE)-            eventIds found `shouldBe` map EventId [uE, uD, uC, uB, uA]+  describe "findCausationAncestors" $ do+    it "walks from a leaf back to the root, leaf first" $ \store -> do+      uuids@[uA, uB, uC, uD, uE] <- replicateUuids 5+      appendChain store uuids+      Right found <- runStoreIO store $ findCausationAncestors (EventId uE)+      eventIds found `shouldBe` map EventId [uE, uD, uC, uB, uA] -    describe "findByCorrelation" $ do-        it "returns every event with the given correlation across multiple streams in global-position order" $ \store -> do-            cMain <- UUIDv4.nextRandom-            cOther <- UUIDv4.nextRandom-            -- 7 events with correlation = cMain across 4 streams.-            let mkEv :: Int -> EventData-                mkEv i =-                    mkEventWithIds-                        (mkName "Main" i)-                        Nothing-                        Nothing-                        (Just cMain)-                mainPlan =-                    [ ("saga-a", NoStream, [mkEv 1, mkEv 2])-                    , ("saga-b", NoStream, [mkEv 3])-                    , ("saga-c", NoStream, [mkEv 4, mkEv 5, mkEv 6])-                    , ("saga-d", NoStream, [mkEv 7])-                    ]-            mapM_-                ( \(sn, ev, evts) -> do-                    Right _ <- runStoreIO store $ appendToStream (StreamName sn) ev evts-                    pure ()-                )-                mainPlan-            -- Noise: one event with no correlation.-            Right _ <- runStoreIO store $ appendToStream (StreamName "noise-stream") NoStream [makeEvent "Noise" Aeson.Null]-            -- A separate correlation set we should not see.-            let mkOther :: Int -> EventData-                mkOther i =-                    mkEventWithIds-                        (mkName "Other" i)-                        Nothing-                        Nothing-                        (Just cOther)-            Right _ <- runStoreIO store $ appendToStream (StreamName "other-saga") NoStream [mkOther i | i <- [1 .. 5 :: Int]]+  describe "findByCorrelation" $ do+    it "returns every event with the given correlation across multiple streams in global-position order" $ \store -> do+      cMain <- UUIDv4.nextRandom+      cOther <- UUIDv4.nextRandom+      -- 7 events with correlation = cMain across 4 streams.+      let mkEv :: Int -> EventData+          mkEv i =+            mkEventWithIds+              (mkName "Main" i)+              Nothing+              Nothing+              (Just cMain)+          mainPlan =+            [ ("saga-a", NoStream, [mkEv 1, mkEv 2]),+              ("saga-b", NoStream, [mkEv 3]),+              ("saga-c", NoStream, [mkEv 4, mkEv 5, mkEv 6]),+              ("saga-d", NoStream, [mkEv 7])+            ]+      mapM_+        ( \(sn, ev, evts) -> do+            Right _ <- runStoreIO store $ appendToStream (StreamName sn) ev evts+            pure ()+        )+        mainPlan+      -- Noise: one event with no correlation.+      Right _ <- runStoreIO store $ appendToStream (StreamName "noise-stream") NoStream [makeEvent "Noise" Aeson.Null]+      -- A separate correlation set we should not see.+      let mkOther :: Int -> EventData+          mkOther i =+            mkEventWithIds+              (mkName "Other" i)+              Nothing+              Nothing+              (Just cOther)+      Right _ <- runStoreIO store $ appendToStream (StreamName "other-saga") NoStream [mkOther i | i <- [1 .. 5 :: Int]] -            Right found <- runStoreIO store $ findByCorrelation cMain-            V.length found `shouldBe` 7-            all (\e -> e ^. #correlationId == Just cMain) found `shouldBe` True-            globalPositionsStrictlyIncreasing found `shouldBe` True+      Right found <- runStoreIO store $ findByCorrelation cMain+      V.length found `shouldBe` 7+      all (\e -> e ^. #correlationId == Just cMain) found `shouldBe` True+      globalPositionsStrictlyIncreasing found `shouldBe` True -        it "on an unknown correlation returns an empty vector" $ \store -> do-            -- Insert *something* so we're not just observing an empty store.-            let noise = mkEventWithIds "Noise" Nothing Nothing (Just UUID.nil)-            Right _ <--                runStoreIO store $-                    appendToStream (StreamName "noise-stream") NoStream [noise]-            unknown <- UUIDv4.nextRandom-            Right found <- runStoreIO store $ findByCorrelation unknown-            V.null found `shouldBe` True+    it "on an unknown correlation returns an empty vector" $ \store -> do+      -- Insert *something* so we're not just observing an empty store.+      let noise = mkEventWithIds "Noise" Nothing Nothing (Just UUID.nil)+      Right _ <-+        runStoreIO store $+          appendToStream (StreamName "noise-stream") NoStream [noise]+      unknown <- UUIDv4.nextRandom+      Right found <- runStoreIO store $ findByCorrelation unknown+      V.null found `shouldBe` True  -- --------------------------------------------------------------------------- -- Local helpers@@ -117,43 +116,41 @@ replicateUuids :: Int -> IO [UUID] replicateUuids n = mapM (const UUIDv4.nextRandom) [1 .. n] -{- | Build an 'EventData' from explicit event-id, causation-id, and-correlation-id arguments. Wraps the field updates in a typed binding so-'DuplicateRecordFields' does not complain about ambiguous record-updates ('eventId', 'causationId', and 'correlationId' all appear on-both 'EventData' and 'RecordedEvent').--}+-- | Build an 'EventData' from explicit event-id, causation-id, and+-- correlation-id arguments. Wraps the field updates in a typed binding so+-- 'DuplicateRecordFields' does not complain about ambiguous record+-- updates ('eventId', 'causationId', and 'correlationId' all appear on+-- both 'EventData' and 'RecordedEvent'). mkEventWithIds :: Text -> Maybe UUID -> Maybe UUID -> Maybe UUID -> EventData mkEventWithIds typ mEid mCause mCorr =-    EventData-        { eventId = fmap EventId mEid-        , eventType = EventType typ-        , payload = Aeson.Null-        , metadata = Nothing-        , causationId = mCause-        , correlationId = mCorr-        }--{- | Append a 5-event causation chain @A -> B -> C -> D -> E@ across three-streams (@pm-trigger@, @pm-cmd@, @pm-result@). Each event's-@causationId@ points at the previous event's @eventId@.+  EventData+    { eventId = fmap EventId mEid,+      eventType = EventType typ,+      payload = Aeson.Null,+      metadata = Nothing,+      causationId = mCause,+      correlationId = mCorr+    } -Expects exactly five UUIDs; calls 'error' on any other length so the-caller's signature stays simple.--}+-- | Append a 5-event causation chain @A -> B -> C -> D -> E@ across three+-- streams (@pm-trigger@, @pm-cmd@, @pm-result@). Each event's+-- @causationId@ points at the previous event's @eventId@.+--+-- Expects exactly five UUIDs; calls 'error' on any other length so the+-- caller's signature stays simple. appendChain :: KirokuStore -> [UUID] -> IO () appendChain store [uA, uB, uC, uD, uE] = do-    let evA = mkEventWithIds "A" (Just uA) Nothing Nothing-        evB = mkEventWithIds "B" (Just uB) (Just uA) Nothing-        evC = mkEventWithIds "C" (Just uC) (Just uB) Nothing-        evD = mkEventWithIds "D" (Just uD) (Just uC) Nothing-        evE = mkEventWithIds "E" (Just uE) (Just uD) Nothing-    Right _ <- runStoreIO store $ appendToStream (StreamName "pm-trigger") NoStream [evA]-    Right _ <- runStoreIO store $ appendToStream (StreamName "pm-cmd") NoStream [evB]-    Right _ <- runStoreIO store $ appendToStream (StreamName "pm-cmd") AnyVersion [evC]-    Right _ <- runStoreIO store $ appendToStream (StreamName "pm-result") NoStream [evD]-    Right _ <- runStoreIO store $ appendToStream (StreamName "pm-result") AnyVersion [evE]-    pure ()+  let evA = mkEventWithIds "A" (Just uA) Nothing Nothing+      evB = mkEventWithIds "B" (Just uB) (Just uA) Nothing+      evC = mkEventWithIds "C" (Just uC) (Just uB) Nothing+      evD = mkEventWithIds "D" (Just uD) (Just uC) Nothing+      evE = mkEventWithIds "E" (Just uE) (Just uD) Nothing+  Right _ <- runStoreIO store $ appendToStream (StreamName "pm-trigger") NoStream [evA]+  Right _ <- runStoreIO store $ appendToStream (StreamName "pm-cmd") NoStream [evB]+  Right _ <- runStoreIO store $ appendToStream (StreamName "pm-cmd") AnyVersion [evC]+  Right _ <- runStoreIO store $ appendToStream (StreamName "pm-result") NoStream [evD]+  Right _ <- runStoreIO store $ appendToStream (StreamName "pm-result") AnyVersion [evE]+  pure () appendChain _ _ = error "appendChain: expected exactly 5 UUIDs"  eventIds :: Vector RecordedEvent -> [EventId]@@ -161,8 +158,8 @@  globalPositionsStrictlyIncreasing :: Vector RecordedEvent -> Bool globalPositionsStrictlyIncreasing v =-    let ps = V.toList (V.map (\e -> let GlobalPosition p = e ^. #globalPosition in p) v)-     in and (zipWith (<) ps (drop 1 ps))+  let ps = V.toList (V.map (\e -> let GlobalPosition p = e ^. #globalPosition in p) v)+   in and (zipWith (<) ps (drop 1 ps))  mkName :: String -> Int -> Text mkName tag i = T.pack (tag <> "-" <> show i)
test/Test/Concurrency.hs view
@@ -1,13 +1,12 @@-{- | Deterministic concurrency tests for kiroku-store.--Each test spawns multiple threads that race on append paths the-single-threaded scenario suite cannot exercise. The tests target the-EP-6 M1 Concurrency Scenarios (F9–F11) and verify EP-1 F4 (the sorted-@SELECT … FOR UPDATE@ pre-pass that prevents multi-stream deadlocks)-under deliberate adversarial ordering.--Each test acquires a fresh ephemeral PostgreSQL via 'withTestStore'.--}+-- | Deterministic concurrency tests for kiroku-store.+--+-- Each test spawns multiple threads that race on append paths the+-- single-threaded scenario suite cannot exercise. The tests target the+-- EP-6 M1 Concurrency Scenarios (F9–F11) and verify EP-1 F4 (the sorted+-- @SELECT … FOR UPDATE@ pre-pass that prevents multi-stream deadlocks)+-- under deliberate adversarial ordering.+--+-- Each test acquires a fresh ephemeral PostgreSQL via 'withTestStore'. module Test.Concurrency (spec) where  import Control.Concurrent.Async qualified as Async@@ -28,301 +27,301 @@  spec :: Spec spec = describe "kiroku-store concurrency (deterministic)" $ do-    it "many AnyVersion writers to one stream preserve stream and global order" $-        withTestStore $ \store -> do-            let writerCount = 24-                stream = StreamName "stress-one-stream"-            results <--                Async.forConcurrently [1 .. writerCount] $ \i -> do-                    runStoreIO store $-                        appendToStream-                            stream-                            AnyVersion-                            [makeEvent ("Stress" <> T.pack (show i)) (Aeson.object [])]-            mapM_ assertRightAppend results-            Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 1000-            V.length streamEvents `shouldBe` writerCount-            streamVersions streamEvents `shouldBe` [1 .. fromIntegral writerCount]-            Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000-            V.length allEvents `shouldBe` writerCount-            globalPositions allEvents `shouldBe` [1 .. fromIntegral writerCount]+  it "many AnyVersion writers to one stream preserve stream and global order" $+    withTestStore $ \store -> do+      let writerCount = 24+          stream = StreamName "stress-one-stream"+      results <-+        Async.forConcurrently [1 .. writerCount] $ \i -> do+          runStoreIO store $+            appendToStream+              stream+              AnyVersion+              [makeEvent ("Stress" <> T.pack (show i)) (Aeson.object [])]+      mapM_ assertRightAppend results+      Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 1000+      V.length streamEvents `shouldBe` writerCount+      streamVersions streamEvents `shouldBe` [1 .. fromIntegral writerCount]+      Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000+      V.length allEvents `shouldBe` writerCount+      globalPositions allEvents `shouldBe` [1 .. fromIntegral writerCount] -    it "many AnyVersion writers to invoice-payment preserve stream and global order" $-        withTestStore $ \store -> do-            let writerCount = 32-                stream = StreamName "invoice-payment"-            results <--                Async.forConcurrently [1 .. writerCount] $ \i -> do-                    runStoreIO store $-                        appendToStream-                            stream-                            AnyVersion-                            [makeEvent ("InvoicePayment" <> T.pack (show i)) (Aeson.object [])]-            mapM_ assertRightAppend results-            Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 1000-            V.length streamEvents `shouldBe` writerCount-            streamVersions streamEvents `shouldBe` [1 .. fromIntegral writerCount]-            Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000-            V.length allEvents `shouldBe` writerCount-            globalPositions allEvents `shouldBe` [1 .. fromIntegral writerCount]-            Set.fromList (eventIds streamEvents) `shouldBe` Set.fromList (eventIds allEvents)+  it "many AnyVersion writers to invoice-payment preserve stream and global order" $+    withTestStore $ \store -> do+      let writerCount = 32+          stream = StreamName "invoice-payment"+      results <-+        Async.forConcurrently [1 .. writerCount] $ \i -> do+          runStoreIO store $+            appendToStream+              stream+              AnyVersion+              [makeEvent ("InvoicePayment" <> T.pack (show i)) (Aeson.object [])]+      mapM_ assertRightAppend results+      Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 1000+      V.length streamEvents `shouldBe` writerCount+      streamVersions streamEvents `shouldBe` [1 .. fromIntegral writerCount]+      Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000+      V.length allEvents `shouldBe` writerCount+      globalPositions allEvents `shouldBe` [1 .. fromIntegral writerCount]+      Set.fromList (eventIds streamEvents) `shouldBe` Set.fromList (eventIds allEvents) -    it "many batched writers to different streams preserve batch-local and global order" $-        withTestStore $ \store -> do-            let streamCount = 12-                batchSize = 10-                totalEvents = streamCount * batchSize-                mkStream i = StreamName ("stress-batch-" <> T.pack (show i))-                mkBatch i =-                    [ makeEvent ("Batch" <> T.pack (show i) <> "-" <> T.pack (show j)) (Aeson.object [])-                    | j <- [1 .. batchSize]-                    ]-            results <--                Async.forConcurrently [1 .. streamCount] $ \i -> do-                    runStoreIO store $ appendToStream (mkStream i) NoStream (mkBatch i)-            mapM_ assertRightAppend results-            forM_ [1 .. streamCount] $ \i -> do-                Right streamEvents <- runStoreIO store $ readStreamForward (mkStream i) (StreamVersion 0) 1000-                streamVersions streamEvents `shouldBe` [1 .. fromIntegral batchSize]-            Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000-            V.length allEvents `shouldBe` totalEvents-            globalPositions allEvents `shouldBe` [1 .. fromIntegral totalEvents]+  it "many batched writers to different streams preserve batch-local and global order" $+    withTestStore $ \store -> do+      let streamCount = 12+          batchSize = 10+          totalEvents = streamCount * batchSize+          mkStream i = StreamName ("stress-batch-" <> T.pack (show i))+          mkBatch i =+            [ makeEvent ("Batch" <> T.pack (show i) <> "-" <> T.pack (show j)) (Aeson.object [])+            | j <- [1 .. batchSize]+            ]+      results <-+        Async.forConcurrently [1 .. streamCount] $ \i -> do+          runStoreIO store $ appendToStream (mkStream i) NoStream (mkBatch i)+      mapM_ assertRightAppend results+      forM_ [1 .. streamCount] $ \i -> do+        Right streamEvents <- runStoreIO store $ readStreamForward (mkStream i) (StreamVersion 0) 1000+        streamVersions streamEvents `shouldBe` [1 .. fromIntegral batchSize]+      Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000+      V.length allEvents `shouldBe` totalEvents+      globalPositions allEvents `shouldBe` [1 .. fromIntegral totalEvents] -    it "large append batches return the final stream and global positions" $-        withTestStore $ \store -> do-            let stream = StreamName "stress-large-batches"-                mkBatch label n =-                    [ makeEvent (label <> "-" <> T.pack (show i)) (Aeson.object [])-                    | i <- [1 .. n]-                    ]-            Right r10 <- runStoreIO store $ appendToStream stream NoStream (mkBatch "Batch10" (10 :: Int))-            (r10 ^. #streamVersion) `shouldBe` StreamVersion 10-            (r10 ^. #globalPosition) `shouldBe` GlobalPosition 10-            Right r100 <- runStoreIO store $ appendToStream stream AnyVersion (mkBatch "Batch100" (100 :: Int))-            (r100 ^. #streamVersion) `shouldBe` StreamVersion 110-            (r100 ^. #globalPosition) `shouldBe` GlobalPosition 110-            Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 200-            streamVersions streamEvents `shouldBe` [1 .. 110]-            Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 200-            globalPositions allEvents `shouldBe` [1 .. 110]+  it "large append batches return the final stream and global positions" $+    withTestStore $ \store -> do+      let stream = StreamName "stress-large-batches"+          mkBatch label n =+            [ makeEvent (label <> "-" <> T.pack (show i)) (Aeson.object [])+            | i <- [1 .. n]+            ]+      Right r10 <- runStoreIO store $ appendToStream stream NoStream (mkBatch "Batch10" (10 :: Int))+      (r10 ^. #streamVersion) `shouldBe` StreamVersion 10+      (r10 ^. #globalPosition) `shouldBe` GlobalPosition 10+      Right r100 <- runStoreIO store $ appendToStream stream AnyVersion (mkBatch "Batch100" (100 :: Int))+      (r100 ^. #streamVersion) `shouldBe` StreamVersion 110+      (r100 ^. #globalPosition) `shouldBe` GlobalPosition 110+      Right streamEvents <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 200+      streamVersions streamEvents `shouldBe` [1 .. 110]+      Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 200+      globalPositions allEvents `shouldBe` [1 .. 110] -    it "overlapping appendMultiStream stress preserves all-or-nothing ordering" $-        withTestStore $ \store -> do-            let streams = [StreamName "stress-multi-a", StreamName "stress-multi-b", StreamName "stress-multi-c"]-                rotations =-                    [ streams-                    , [StreamName "stress-multi-b", StreamName "stress-multi-c", StreamName "stress-multi-a"]-                    , [StreamName "stress-multi-c", StreamName "stress-multi-a", StreamName "stress-multi-b"]-                    ]-                opsFor i =-                    [ (sn, AnyVersion, [makeEvent ("Multi" <> T.pack (show i) <> "-" <> labelStream sn) (Aeson.object [])])-                    | sn <- rotations !! (i `mod` length rotations)-                    ]-            forM_ streams $ \sn -> do-                result <- runStoreIO store $ appendToStream sn NoStream [makeEvent "init" (Aeson.object [])]-                assertRightAppend result-            results <--                Async.forConcurrently [1 .. 9] $ \i -> do-                    runStoreIO store $ appendMultiStream (opsFor i)-            mapM_ assertRightMulti results-            forM_ streams $ \sn -> do-                Right streamEvents <- runStoreIO store $ readStreamForward sn (StreamVersion 0) 100-                streamVersions streamEvents `shouldBe` [1 .. 10]-            Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-            V.length allEvents `shouldBe` 30-            globalPositions allEvents `shouldBe` [1 .. 30]+  it "overlapping appendMultiStream stress preserves all-or-nothing ordering" $+    withTestStore $ \store -> do+      let streams = [StreamName "stress-multi-a", StreamName "stress-multi-b", StreamName "stress-multi-c"]+          rotations =+            [ streams,+              [StreamName "stress-multi-b", StreamName "stress-multi-c", StreamName "stress-multi-a"],+              [StreamName "stress-multi-c", StreamName "stress-multi-a", StreamName "stress-multi-b"]+            ]+          opsFor i =+            [ (sn, AnyVersion, [makeEvent ("Multi" <> T.pack (show i) <> "-" <> labelStream sn) (Aeson.object [])])+            | sn <- rotations !! (i `mod` length rotations)+            ]+      forM_ streams $ \sn -> do+        result <- runStoreIO store $ appendToStream sn NoStream [makeEvent "init" (Aeson.object [])]+        assertRightAppend result+      results <-+        Async.forConcurrently [1 .. 9] $ \i -> do+          runStoreIO store $ appendMultiStream (opsFor i)+      mapM_ assertRightMulti results+      forM_ streams $ \sn -> do+        Right streamEvents <- runStoreIO store $ readStreamForward sn (StreamVersion 0) 100+        streamVersions streamEvents `shouldBe` [1 .. 10]+      Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+      V.length allEvents `shouldBe` 30+      globalPositions allEvents `shouldBe` [1 .. 30] -    it "duplicate event failure leaves touched streams and $all unchanged" $-        withTestStore $ \store -> do-            let duplicate =-                    EventId $-                        case UUID.fromString "01234567-89ab-7def-8012-34567890abce" of-                            Just u -> u-                            Nothing -> error "bad uuid"-                duplicateEvent =-                    EventData-                        { eventId = Just duplicate-                        , eventType = EventType "Duplicate"-                        , payload = Aeson.object []-                        , metadata = Nothing-                        , causationId = Nothing-                        , correlationId = Nothing-                        }-            Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-seed") NoStream [duplicateEvent]-            Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-a") NoStream [makeEvent "init-a" (Aeson.object [])]-            Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-b") NoStream [makeEvent "init-b" (Aeson.object [])]-            beforeCount <- countEvents store-            Right allBefore <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-            result <--                runStoreIO store $-                    appendMultiStream-                        [ (StreamName "rollback-a", AnyVersion, [duplicateEvent])-                        , (StreamName "rollback-b", AnyVersion, [makeEvent "should-not-commit" (Aeson.object [])])-                        ]-            case result of-                Left (DuplicateEvent _) -> pure ()-                other -> expectationFailure ("duplicate event should abort the multi-stream transaction, got: " <> show other)-            countEvents store `shouldReturn` beforeCount-            Right allAfter <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-            globalPositions allAfter `shouldBe` globalPositions allBefore-            Right streamA <- runStoreIO store $ readStreamForward (StreamName "rollback-a") (StreamVersion 0) 100-            Right streamB <- runStoreIO store $ readStreamForward (StreamName "rollback-b") (StreamVersion 0) 100-            streamVersions streamA `shouldBe` [1]-            streamVersions streamB `shouldBe` [1]+  it "duplicate event failure leaves touched streams and $all unchanged" $+    withTestStore $ \store -> do+      let duplicate =+            EventId $+              case UUID.fromString "01234567-89ab-7def-8012-34567890abce" of+                Just u -> u+                Nothing -> error "bad uuid"+          duplicateEvent =+            EventData+              { eventId = Just duplicate,+                eventType = EventType "Duplicate",+                payload = Aeson.object [],+                metadata = Nothing,+                causationId = Nothing,+                correlationId = Nothing+              }+      Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-seed") NoStream [duplicateEvent]+      Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-a") NoStream [makeEvent "init-a" (Aeson.object [])]+      Right _ <- runStoreIO store $ appendToStream (StreamName "rollback-b") NoStream [makeEvent "init-b" (Aeson.object [])]+      beforeCount <- countEvents store+      Right allBefore <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+      result <-+        runStoreIO store $+          appendMultiStream+            [ (StreamName "rollback-a", AnyVersion, [duplicateEvent]),+              (StreamName "rollback-b", AnyVersion, [makeEvent "should-not-commit" (Aeson.object [])])+            ]+      case result of+        Left (DuplicateEvent _) -> pure ()+        other -> expectationFailure ("duplicate event should abort the multi-stream transaction, got: " <> show other)+      countEvents store `shouldReturn` beforeCount+      Right allAfter <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+      globalPositions allAfter `shouldBe` globalPositions allBefore+      Right streamA <- runStoreIO store $ readStreamForward (StreamName "rollback-a") (StreamVersion 0) 100+      Right streamB <- runStoreIO store $ readStreamForward (StreamName "rollback-b") (StreamVersion 0) 100+      streamVersions streamA `shouldBe` [1]+      streamVersions streamB `shouldBe` [1] -    -- F9 — Two concurrent appends to different streams. Both calls-    -- must succeed, the global positions must be unique and contiguous,-    -- and the test must not deadlock.-    it "two concurrent appends to different streams both succeed (F9)" $-        withTestStore $ \store -> do-            (rA, rB) <--                Async.concurrently-                    (runStoreIO store $ appendToStream (StreamName "f9-a") NoStream [makeEvent "A" (Aeson.object [])])-                    (runStoreIO store $ appendToStream (StreamName "f9-b") NoStream [makeEvent "B" (Aeson.object [])])-            case (rA, rB) of-                (Right resA, Right resB) -> do-                    let pA = case resA ^. #globalPosition of GlobalPosition n -> n-                        pB = case resB ^. #globalPosition of GlobalPosition n -> n-                    pA `shouldNotBe` pB-                    Set.fromList [pA, pB] `shouldBe` Set.fromList [1, 2]-                other -> expectationFailure ("F9: both should succeed, got: " <> show other)+  -- F9 — Two concurrent appends to different streams. Both calls+  -- must succeed, the global positions must be unique and contiguous,+  -- and the test must not deadlock.+  it "two concurrent appends to different streams both succeed (F9)" $+    withTestStore $ \store -> do+      (rA, rB) <-+        Async.concurrently+          (runStoreIO store $ appendToStream (StreamName "f9-a") NoStream [makeEvent "A" (Aeson.object [])])+          (runStoreIO store $ appendToStream (StreamName "f9-b") NoStream [makeEvent "B" (Aeson.object [])])+      case (rA, rB) of+        (Right resA, Right resB) -> do+          let pA = case resA ^. #globalPosition of GlobalPosition n -> n+              pB = case resB ^. #globalPosition of GlobalPosition n -> n+          pA `shouldNotBe` pB+          Set.fromList [pA, pB] `shouldBe` Set.fromList [1, 2]+        other -> expectationFailure ("F9: both should succeed, got: " <> show other) -    -- F10 — Two concurrent appends to the same stream with the same-    -- ExactVersion. Exactly one must succeed; the other must fail-    -- with WrongExpectedVersion. No deadlock. Stream is pre-created-    -- because ExactVersion 0 against a non-existent stream is itself-    -- an error in kiroku (streams start at version 1).-    it "two concurrent ExactVersion appends to same stream — one wins (F10)" $-        withTestStore $ \store -> do-            Right _ <- runStoreIO store $ appendToStream (StreamName "f10") NoStream [makeEvent "Init" (Aeson.object [])]-            (r1, r2) <--                Async.concurrently-                    (runStoreIO store $ appendToStream (StreamName "f10") (ExactVersion (StreamVersion 1)) [makeEvent "X" (Aeson.object [])])-                    (runStoreIO store $ appendToStream (StreamName "f10") (ExactVersion (StreamVersion 1)) [makeEvent "Y" (Aeson.object [])])-            case (r1, r2) of-                (Right _, Left (WrongExpectedVersion _ _ _)) -> pure ()-                (Left (WrongExpectedVersion _ _ _), Right _) -> pure ()-                other -> expectationFailure ("F10: exactly one should win with the loser returning WrongExpectedVersion, got: " <> show other)-            -- Stream must have exactly init + winner = 2 events after the race.-            Right events <- runStoreIO store $ readStreamForward (StreamName "f10") (StreamVersion 0) 100-            V.length events `shouldBe` 2+  -- F10 — Two concurrent appends to the same stream with the same+  -- ExactVersion. Exactly one must succeed; the other must fail+  -- with WrongExpectedVersion. No deadlock. Stream is pre-created+  -- because ExactVersion 0 against a non-existent stream is itself+  -- an error in kiroku (streams start at version 1).+  it "two concurrent ExactVersion appends to same stream — one wins (F10)" $+    withTestStore $ \store -> do+      Right _ <- runStoreIO store $ appendToStream (StreamName "f10") NoStream [makeEvent "Init" (Aeson.object [])]+      (r1, r2) <-+        Async.concurrently+          (runStoreIO store $ appendToStream (StreamName "f10") (ExactVersion (StreamVersion 1)) [makeEvent "X" (Aeson.object [])])+          (runStoreIO store $ appendToStream (StreamName "f10") (ExactVersion (StreamVersion 1)) [makeEvent "Y" (Aeson.object [])])+      case (r1, r2) of+        (Right _, Left (WrongExpectedVersion _ _ _)) -> pure ()+        (Left (WrongExpectedVersion _ _ _), Right _) -> pure ()+        other -> expectationFailure ("F10: exactly one should win with the loser returning WrongExpectedVersion, got: " <> show other)+      -- Stream must have exactly init + winner = 2 events after the race.+      Right events <- runStoreIO store $ readStreamForward (StreamName "f10") (StreamVersion 0) 100+      V.length events `shouldBe` 2 -    -- F11 — Two concurrent appendMultiStream calls touching the same-    -- streams in opposite order. EP-1 F4's sorted SELECT FOR UPDATE-    -- pre-pass ensures both transactions acquire row locks in the-    -- same canonical order, preventing the classic two-resource-    -- deadlock. Without the fix, this test would intermittently fail-    -- with PostgreSQL deadlock detection (40P01).-    it "two concurrent multi-stream appends in opposite order do not deadlock (F11)" $-        withTestStore $ \store -> do-            -- Pre-create both streams so the multi-stream calls take-            -- the existing-stream path (where pre-locking matters).-            Right _ <- runStoreIO store $ appendToStream (StreamName "f11-x") NoStream [makeEvent "init-x" (Aeson.object [])]-            Right _ <- runStoreIO store $ appendToStream (StreamName "f11-y") NoStream [makeEvent "init-y" (Aeson.object [])]-            let opsXY =-                    [ (StreamName "f11-x", AnyVersion, [makeEvent "Ax" (Aeson.object [])])-                    , (StreamName "f11-y", AnyVersion, [makeEvent "Ay" (Aeson.object [])])-                    ]-                opsYX =-                    [ (StreamName "f11-y", AnyVersion, [makeEvent "By" (Aeson.object [])])-                    , (StreamName "f11-x", AnyVersion, [makeEvent "Bx" (Aeson.object [])])-                    ]-            (rA, rB) <--                Async.concurrently-                    (runStoreIO store $ appendMultiStream opsXY)-                    (runStoreIO store $ appendMultiStream opsYX)-            case (rA, rB) of-                (Right _, Right _) -> pure ()-                other -> expectationFailure ("F11: both calls must succeed without deadlock, got: " <> show other)-            -- Each stream contains its init event plus one event from-            -- each of the two concurrent multi-stream appends (3 each).-            Right xs <- runStoreIO store $ readStreamForward (StreamName "f11-x") (StreamVersion 0) 100-            Right ys <- runStoreIO store $ readStreamForward (StreamName "f11-y") (StreamVersion 0) 100-            V.length xs `shouldBe` 3-            V.length ys `shouldBe` 3-            -- \$all has 6 events total (2 inits + 4 from concurrent calls).-            Right allEvts <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-            V.length allEvts `shouldBe` 6-            let positions =-                    map-                        (\e -> case e ^. #globalPosition of GlobalPosition n -> n)-                        (V.toList allEvts)-            sort positions `shouldBe` [1, 2, 3, 4, 5, 6]+  -- F11 — Two concurrent appendMultiStream calls touching the same+  -- streams in opposite order. EP-1 F4's sorted SELECT FOR UPDATE+  -- pre-pass ensures both transactions acquire row locks in the+  -- same canonical order, preventing the classic two-resource+  -- deadlock. Without the fix, this test would intermittently fail+  -- with PostgreSQL deadlock detection (40P01).+  it "two concurrent multi-stream appends in opposite order do not deadlock (F11)" $+    withTestStore $ \store -> do+      -- Pre-create both streams so the multi-stream calls take+      -- the existing-stream path (where pre-locking matters).+      Right _ <- runStoreIO store $ appendToStream (StreamName "f11-x") NoStream [makeEvent "init-x" (Aeson.object [])]+      Right _ <- runStoreIO store $ appendToStream (StreamName "f11-y") NoStream [makeEvent "init-y" (Aeson.object [])]+      let opsXY =+            [ (StreamName "f11-x", AnyVersion, [makeEvent "Ax" (Aeson.object [])]),+              (StreamName "f11-y", AnyVersion, [makeEvent "Ay" (Aeson.object [])])+            ]+          opsYX =+            [ (StreamName "f11-y", AnyVersion, [makeEvent "By" (Aeson.object [])]),+              (StreamName "f11-x", AnyVersion, [makeEvent "Bx" (Aeson.object [])])+            ]+      (rA, rB) <-+        Async.concurrently+          (runStoreIO store $ appendMultiStream opsXY)+          (runStoreIO store $ appendMultiStream opsYX)+      case (rA, rB) of+        (Right _, Right _) -> pure ()+        other -> expectationFailure ("F11: both calls must succeed without deadlock, got: " <> show other)+      -- Each stream contains its init event plus one event from+      -- each of the two concurrent multi-stream appends (3 each).+      Right xs <- runStoreIO store $ readStreamForward (StreamName "f11-x") (StreamVersion 0) 100+      Right ys <- runStoreIO store $ readStreamForward (StreamName "f11-y") (StreamVersion 0) 100+      V.length xs `shouldBe` 3+      V.length ys `shouldBe` 3+      -- \$all has 6 events total (2 inits + 4 from concurrent calls).+      Right allEvts <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+      V.length allEvts `shouldBe` 6+      let positions =+            map+              (\e -> case e ^. #globalPosition of GlobalPosition n -> n)+              (V.toList allEvts)+      sort positions `shouldBe` [1, 2, 3, 4, 5, 6] -    -- EP-4 M3 — Best-effort regression for the single-stream append retry.-    -- The race can pass vacuously on fast machines, but it must never surface-    -- PostgreSQL's transient transaction SQLSTATEs (40001/40P01) to callers as-    -- UnexpectedServerError.-    ---    -- Note what is and is not asserted. The store retries once, and no bounded-    -- retry can promise that a repeated conflict never reaches the caller ---    -- under contention this shape does deadlock, because a multi-stream append-    -- whose later stream is fresh takes $all before that stream's row while a-    -- single-stream append takes them in the opposite order (see IR-7). So a-    -- surfaced conflict is allowed; what is not allowed is surfacing it as-    -- UnexpectedServerError, which documents itself as "not generally-    -- retryable -- investigate" and would send a caller to diagnose a-    -- condition whose correct handling is to retry.-    it "single-stream append retry does not leak transient SQLSTATEs" $-        withTestStore $ \store -> do-            forM_ [1 .. 25 :: Int] $ \i -> do-                let a = StreamName ("retry-a-" <> T.pack (show i))-                    b = StreamName ("retry-b-" <> T.pack (show i))-                    multi =-                        appendMultiStream-                            [ (a, NoStream, [makeEvent "A" (Aeson.object [])])-                            , (b, AnyVersion, [makeEvent "B-multi" (Aeson.object [])])-                            ]-                    single =-                        appendToStream b NoStream [makeEvent "B-single" (Aeson.object [])]-                (multiResult, singleResult) <--                    Async.concurrently-                        (runStoreIO store multi)-                        (runStoreIO store single)-                assertNoTransientLeak ("multi round " <> show i) multiResult-                assertNoTransientLeak ("single round " <> show i) singleResult+  -- EP-4 M3 — Best-effort regression for the single-stream append retry.+  -- The race can pass vacuously on fast machines, but it must never surface+  -- PostgreSQL's transient transaction SQLSTATEs (40001/40P01) to callers as+  -- UnexpectedServerError.+  --+  -- Note what is and is not asserted. The store retries once, and no bounded+  -- retry can promise that a repeated conflict never reaches the caller --+  -- under contention this shape does deadlock, because a multi-stream append+  -- whose later stream is fresh takes $all before that stream's row while a+  -- single-stream append takes them in the opposite order (see IR-7). So a+  -- surfaced conflict is allowed; what is not allowed is surfacing it as+  -- UnexpectedServerError, which documents itself as "not generally+  -- retryable -- investigate" and would send a caller to diagnose a+  -- condition whose correct handling is to retry.+  it "single-stream append retry does not leak transient SQLSTATEs" $+    withTestStore $ \store -> do+      forM_ [1 .. 25 :: Int] $ \i -> do+        let a = StreamName ("retry-a-" <> T.pack (show i))+            b = StreamName ("retry-b-" <> T.pack (show i))+            multi =+              appendMultiStream+                [ (a, NoStream, [makeEvent "A" (Aeson.object [])]),+                  (b, AnyVersion, [makeEvent "B-multi" (Aeson.object [])])+                ]+            single =+              appendToStream b NoStream [makeEvent "B-single" (Aeson.object [])]+        (multiResult, singleResult) <-+          Async.concurrently+            (runStoreIO store multi)+            (runStoreIO store single)+        assertNoTransientLeak ("multi round " <> show i) multiResult+        assertNoTransientLeak ("single round " <> show i) singleResult  assertRightAppend :: Either StoreError AppendResult -> IO () assertRightAppend = \case-    Right _ -> pure ()-    Left err -> expectationFailure ("append should succeed, got: " <> show err)+  Right _ -> pure ()+  Left err -> expectationFailure ("append should succeed, got: " <> show err)  assertRightMulti :: Either StoreError [AppendResult] -> IO () assertRightMulti = \case-    Right _ -> pure ()-    Left err -> expectationFailure ("appendMultiStream should succeed, got: " <> show err)+  Right _ -> pure ()+  Left err -> expectationFailure ("appendMultiStream should succeed, got: " <> show err)  assertNoTransientLeak :: String -> Either StoreError a -> IO () assertNoTransientLeak label = \case-    Left (UnexpectedServerError code message)-        | code == "40P01" || code == "40001" ->-            expectationFailure-                ( label-                    <> ": transient SQLSTATE "-                    <> show code-                    <> " surfaced as UnexpectedServerError instead of "-                    <> "TransientTransactionFailure: "-                    <> show message-                )-    _ ->-        pure ()+  Left (UnexpectedServerError code message)+    | code == "40P01" || code == "40001" ->+        expectationFailure+          ( label+              <> ": transient SQLSTATE "+              <> show code+              <> " surfaced as UnexpectedServerError instead of "+              <> "TransientTransactionFailure: "+              <> show message+          )+  _ ->+    pure ()  streamVersions :: V.Vector RecordedEvent -> [Int64] streamVersions =-    map-        (\e -> case e ^. #streamVersion of StreamVersion n -> n)-        . V.toList+  map+    (\e -> case e ^. #streamVersion of StreamVersion n -> n)+    . V.toList  globalPositions :: V.Vector RecordedEvent -> [Int64] globalPositions =-    map-        (\e -> case e ^. #globalPosition of GlobalPosition n -> n)-        . V.toList+  map+    (\e -> case e ^. #globalPosition of GlobalPosition n -> n)+    . V.toList  eventIds :: V.Vector RecordedEvent -> [EventId] eventIds =-    map (^. #eventId)-        . V.toList+  map (^. #eventId)+    . V.toList  labelStream :: StreamName -> Text labelStream (StreamName name) = name
test/Test/ConsumerGroup.hs view
@@ -1,13 +1,12 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Runtime tests for consumer groups (ExecPlan 29 / EP-2). Each member runs as-one in-process subscription worker over a fresh ephemeral PostgreSQL. The-properties proven here are the user-visible contract: a group's members deliver a-/disjoint/, /complete/, /per-stream-ordered/ partition of the source; a size-1-group equals a plain subscription; and each member resumes from its /own/-checkpoint.--}+-- | Runtime tests for consumer groups (ExecPlan 29 / EP-2). Each member runs as+-- one in-process subscription worker over a fresh ephemeral PostgreSQL. The+-- properties proven here are the user-visible contract: a group's members deliver a+-- /disjoint/, /complete/, /per-stream-ordered/ partition of the source; a size-1+-- group equals a plain subscription; and each member resumes from its /own/+-- checkpoint. module Test.ConsumerGroup (spec) where  import Control.Concurrent (threadDelay)@@ -42,296 +41,294 @@ -- | Extract @(originalStreamId, globalPosition)@ as raw 'Int64's from an event. pairOf :: RecordedEvent -> (Int64, Int64) pairOf e =-    ( case e ^. #originalStreamId of StreamId s -> s-    , case e ^. #globalPosition of GlobalPosition p -> p-    )+  ( case e ^. #originalStreamId of StreamId s -> s,+    case e ^. #globalPosition of GlobalPosition p -> p+  )  -- | Append @perStream@ events to each named stream, seeding the store. seed :: KirokuStore -> [Text] -> Int -> IO () seed store names perStream =-    mapM_-        ( \sn -> do-            let evs = map (\k -> makeEvent ("E" <> T.pack (show k)) (Aeson.object [])) [1 .. perStream]-            r <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs-            case r of-                Left err -> error ("seed append failed for " <> T.unpack sn <> ": " <> show err)-                Right _ -> pure ()-        )-        names+  mapM_+    ( \sn -> do+        let evs = map (\k -> makeEvent ("E" <> T.pack (show k)) (Aeson.object [])) [1 .. perStream]+        r <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs+        case r of+          Left err -> error ("seed append failed for " <> T.unpack sn <> ": " <> show err)+          Right _ -> pure ()+    )+    names  -- | Poll an @IO Bool@ predicate until it holds or the microsecond budget runs out. waitUntil :: Int -> IO Bool -> IO () waitUntil budget act-    | budget <= 0 = pure ()-    | otherwise = do-        ok <- act-        if ok-            then pure ()-            else do-                threadDelay 20_000-                waitUntil (budget - 20_000) act+  | budget <= 0 = pure ()+  | otherwise = do+      ok <- act+      if ok+        then pure ()+        else do+          threadDelay 20_000+          waitUntil (budget - 20_000) act -{- | Assert that within each originating stream the global positions, taken in-delivery order, are strictly ascending (i.e. equal to their sorted form).--}+-- | Assert that within each originating stream the global positions, taken in+-- delivery order, are strictly ascending (i.e. equal to their sorted form). assertPerStreamAscending :: [(Int64, Int64)] -> Expectation assertPerStreamAscending chrono =-    let byStream = Map.fromListWith (\new old -> old ++ new) [(sid, [gp]) | (sid, gp) <- chrono]-     in mapM_ (\ps -> ps `shouldBe` sort ps) (Map.elems byStream)+  let byStream = Map.fromListWith (\new old -> old ++ new) [(sid, [gp]) | (sid, gp) <- chrono]+   in mapM_ (\ps -> ps `shouldBe` sort ps) (Map.elems byStream)  -- | A size-@n@ category-group config for member @m@ with the given handler. memberConfig ::-    Text -> Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig+  Text -> Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig memberConfig nm cat m n h =-    (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) h)-        { consumerGroup = Just (validConsumerGroup m n)-        }+  (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) h)+    { consumerGroup = Just (validConsumerGroup m n)+    }  -- | A size-@n@ @$all@-group config for member @m@ with the given handler. memberConfigAll ::-    Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig+  Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig memberConfigAll nm m n h =-    (defaultSubscriptionConfig (SubscriptionName nm) AllStreams h)-        { consumerGroup = Just (validConsumerGroup m n)-        }+  (defaultSubscriptionConfig (SubscriptionName nm) AllStreams h)+    { consumerGroup = Just (validConsumerGroup m n)+    } -{- | Run a subscription built from the given config-completer, collecting the-global positions it delivers and stopping the handler after @k@ events. Returns-the @k@ positions in delivery order.--}+-- | Run a subscription built from the given config-completer, collecting the+-- global positions it delivers and stopping the handler after @k@ events. Returns+-- the @k@ positions in delivery order. collectStopAfter :: KirokuStore -> (EventHandler -> SubscriptionConfig) -> Int -> IO [Int64] collectStopAfter store mkCfg k = do-    ref <- newIORef []-    countVar <- newTVarIO (0 :: Int)-    let h evt = do-            modifyIORef' ref (snd (pairOf evt) :)-            c <- atomically $ do-                c0 <- readTVar countVar-                let c1 = c0 + 1-                writeTVar countVar c1-                pure c1-            pure (if c >= k then Stop else Continue)-    handle <- subscribe store (mkCfg h)-    _ <- waitWithTimeout 15_000_000 handle-    reverse <$> readIORef ref+  ref <- newIORef []+  countVar <- newTVarIO (0 :: Int)+  let h evt = do+        modifyIORef' ref (snd (pairOf evt) :)+        c <- atomically $ do+          c0 <- readTVar countVar+          let c1 = c0 + 1+          writeTVar countVar c1+          pure c1+        pure (if c >= k then Stop else Continue)+  handle <- subscribe store (mkCfg h)+  _ <- waitWithTimeout 15_000_000 handle+  reverse <$> readIORef ref  -- | Run a Hasql session against the store pool, failing the test on a usage error. runStmtP :: KirokuStore -> Session.Session a -> IO a runStmtP store session = do-    r <- Pool.use (store ^. #pool) session-    either (error . show) pure r+  r <- Pool.use (store ^. #pool) session+  either (error . show) pure r  spec :: Spec spec = describe "consumer groups" $ do-    it "delivers a disjoint, complete, per-stream-ordered partition (size-4 category group)" $-        withTestStore $ \store -> do-            let nStreams = 40-                perStream = 3-                total = nStreams * perStream-                streams = ["acct-" <> T.pack (show i) | i <- [1 .. nStreams]]-            seed store streams perStream-            waitForPublisher store (GlobalPosition (fromIntegral total))+  it "delivers a disjoint, complete, per-stream-ordered partition (size-4 category group)" $+    withTestStore $ \store -> do+      let nStreams = 40+          perStream = 3+          total = nStreams * perStream+          streams = ["acct-" <> T.pack (show i) | i <- [1 .. nStreams]]+      seed store streams perStream+      waitForPublisher store (GlobalPosition (fromIntegral total)) -            let n = 4 :: Int32-            refs <- mapM (const (newIORef [])) [0 .. n - 1]-            handles <--                mapM-                    ( \m -> do-                        let ref = refs !! fromIntegral m-                            h evt = do-                                modifyIORef' ref (pairOf evt :)-                                pure Continue-                        subscribe store (memberConfig "cg-cat" "acct" m n h)-                    )-                    [0 .. n - 1]+      let n = 4 :: Int32+      refs <- mapM (const (newIORef [])) [0 .. n - 1]+      handles <-+        mapM+          ( \m -> do+              let ref = refs !! fromIntegral m+                  h evt = do+                    modifyIORef' ref (pairOf evt :)+                    pure Continue+              subscribe store (memberConfig "cg-cat" "acct" m n h)+          )+          [0 .. n - 1] -            let collectedCount = sum <$> mapM (fmap length . readIORef) refs-            waitUntil 15_000_000 (fmap (>= total) collectedCount)-            mapM_ cancel handles+      let collectedCount = sum <$> mapM (fmap length . readIORef) refs+      waitUntil 15_000_000 (fmap (>= total) collectedCount)+      mapM_ cancel handles -            collected <- mapM readIORef refs-            -- (1) Disjoint + complete: the multiset of all delivered positions is-            --     exactly [1..total] — no duplicate (would lengthen it) and no gap.-            let allPositions = sort (concatMap (map snd) collected)-            allPositions `shouldBe` [1 .. fromIntegral total]-            -- (2) Per-stream ordering within each member (collector prepended, so-            --     reverse to recover delivery order).-            mapM_ (assertPerStreamAscending . reverse) collected+      collected <- mapM readIORef refs+      -- (1) Disjoint + complete: the multiset of all delivered positions is+      --     exactly [1..total] — no duplicate (would lengthen it) and no gap.+      let allPositions = sort (concatMap (map snd) collected)+      allPositions `shouldBe` [1 .. fromIntegral total]+      -- (2) Per-stream ordering within each member (collector prepended, so+      --     reverse to recover delivery order).+      mapM_ (assertPerStreamAscending . reverse) collected -    it "size-1 group delivers the same set as a plain subscription" $-        withTestStore $ \store -> do-            let nStreams = 4-                perStream = 3-                total = nStreams * perStream-                streams = ["uno-" <> T.pack (show i) | i <- [1 .. nStreams]]-            seed store streams perStream-            waitForPublisher store (GlobalPosition (fromIntegral total))+  it "size-1 group delivers the same set as a plain subscription" $+    withTestStore $ \store -> do+      let nStreams = 4+          perStream = 3+          total = nStreams * perStream+          streams = ["uno-" <> T.pack (show i) | i <- [1 .. nStreams]]+      seed store streams perStream+      waitForPublisher store (GlobalPosition (fromIntegral total)) -            let runCollector cfgFor nm = do-                    ref <- newIORef []-                    countVar <- newTVarIO (0 :: Int)-                    let h evt = do-                            modifyIORef' ref (snd (pairOf evt) :)-                            c <- atomically $ do-                                c0 <- readTVar countVar-                                let c1 = c0 + 1-                                writeTVar countVar c1-                                pure c1-                            pure (if c >= total then Stop else Continue)-                    handle <- subscribe store (cfgFor nm h)-                    _ <- waitWithTimeout 15_000_000 handle-                    sort <$> readIORef ref+      let runCollector cfgFor nm = do+            ref <- newIORef []+            countVar <- newTVarIO (0 :: Int)+            let h evt = do+                  modifyIORef' ref (snd (pairOf evt) :)+                  c <- atomically $ do+                    c0 <- readTVar countVar+                    let c1 = c0 + 1+                    writeTVar countVar c1+                    pure c1+                  pure (if c >= total then Stop else Continue)+            handle <- subscribe store (cfgFor nm h)+            _ <- waitWithTimeout 15_000_000 handle+            sort <$> readIORef ref -            plain <--                runCollector-                    (\nm h -> defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName "uno")) h)-                    "uno-plain"-            grouped <--                runCollector-                    (\nm h -> memberConfig nm "uno" 0 1 h)-                    "uno-group"+      plain <-+        runCollector+          (\nm h -> defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName "uno")) h)+          "uno-plain"+      grouped <-+        runCollector+          (\nm h -> memberConfig nm "uno" 0 1 h)+          "uno-group" -            plain `shouldBe` [1 .. fromIntegral total]-            grouped `shouldBe` plain+      plain `shouldBe` [1 .. fromIntegral total]+      grouped `shouldBe` plain -    it "$all group partitions the whole store across members" $-        withTestStore $ \store -> do-            let cats = ["acct", "user", "order"]-                perCat = 10 :: Int-                perStream = 2-                streams = [c <> "-" <> T.pack (show i) | c <- cats, i <- [1 .. perCat]]-                total = length streams * perStream-            seed store streams perStream-            waitForPublisher store (GlobalPosition (fromIntegral total))+  it "$all group partitions the whole store across members" $+    withTestStore $ \store -> do+      let cats = ["acct", "user", "order"]+          perCat = 10 :: Int+          perStream = 2+          streams = [c <> "-" <> T.pack (show i) | c <- cats, i <- [1 .. perCat]]+          total = length streams * perStream+      seed store streams perStream+      waitForPublisher store (GlobalPosition (fromIntegral total)) -            let n = 4 :: Int32-            refs <- mapM (const (newIORef [])) [0 .. n - 1]-            handles <--                mapM-                    ( \m -> do-                        let ref = refs !! fromIntegral m-                            h evt = do-                                modifyIORef' ref (pairOf evt :)-                                pure Continue-                        subscribe store (memberConfigAll "cg-all" m n h)-                    )-                    [0 .. n - 1]+      let n = 4 :: Int32+      refs <- mapM (const (newIORef [])) [0 .. n - 1]+      handles <-+        mapM+          ( \m -> do+              let ref = refs !! fromIntegral m+                  h evt = do+                    modifyIORef' ref (pairOf evt :)+                    pure Continue+              subscribe store (memberConfigAll "cg-all" m n h)+          )+          [0 .. n - 1] -            let collectedCount = sum <$> mapM (fmap length . readIORef) refs-            waitUntil 15_000_000 (fmap (>= total) collectedCount)-            mapM_ cancel handles+      let collectedCount = sum <$> mapM (fmap length . readIORef) refs+      waitUntil 15_000_000 (fmap (>= total) collectedCount)+      mapM_ cancel handles -            collected <- mapM readIORef refs-            let allPositions = sort (concatMap (map snd) collected)-            allPositions `shouldBe` [1 .. fromIntegral total]-            mapM_ (assertPerStreamAscending . reverse) collected+      collected <- mapM readIORef refs+      let allPositions = sort (concatMap (map snd) collected)+      allPositions `shouldBe` [1 .. fromIntegral total]+      mapM_ (assertPerStreamAscending . reverse) collected -    it "resumes member 2 from its own (name, member) checkpoint" $-        withTestStore $ \store -> do-            let nStreams = 60 :: Int-                streams = ["rz-" <> T.pack (show i) | i <- [1 .. nStreams]]-            seed store streams 1-            waitForPublisher store (GlobalPosition (fromIntegral nStreams))+  it "resumes member 2 from its own (name, member) checkpoint" $+    withTestStore $ \store -> do+      let nStreams = 60 :: Int+          streams = ["rz-" <> T.pack (show i) | i <- [1 .. nStreams]]+      seed store streams 1+      waitForPublisher store (GlobalPosition (fromIntegral nStreams)) -            -- Member 2's slice positions, in order, straight from the EP-1 partition-            -- SQL — the deterministic ground truth for what member 2 should receive.-            sliceV <--                runStmtP store $-                    Session.statement-                        (0 :: Int64, "rz" :: Text, 2 :: Int32, 4 :: Int32, 100000 :: Int32)-                        SQL.readCategoryForwardConsumerGroupStmt-            let slice = map (snd . pairOf) (V.toList sliceV)-            length slice `shouldSatisfy` (>= 4)+      -- Member 2's slice positions, in order, straight from the EP-1 partition+      -- SQL — the deterministic ground truth for what member 2 should receive.+      sliceV <-+        runStmtP store $+          Session.statement+            (0 :: Int64, "rz" :: Text, 2 :: Int32, 4 :: Int32, 100000 :: Int32)+            SQL.readCategoryForwardConsumerGroupStmt+      let slice = map (snd . pairOf) (V.toList sliceV)+      length slice `shouldSatisfy` (>= 4) -            -- Run 1: member 2 stops after 2 events; checkpoint (rz-sub, 2) = slice!!1.-            run1 <- collectStopAfter store (memberConfig "rz-sub" "rz" 2 4) 2-            run1 `shouldBe` take 2 slice+      -- Run 1: member 2 stops after 2 events; checkpoint (rz-sub, 2) = slice!!1.+      run1 <- collectStopAfter store (memberConfig "rz-sub" "rz" 2 4) 2+      run1 `shouldBe` take 2 slice -            -- A competing, much higher checkpoint for a DIFFERENT member under the-            -- SAME name. If checkpoints were keyed by name only, member 2's restart-            -- would resume from here and skip its events; member-keyed checkpoints-            -- must ignore it.-            runStmtP store $-                Session.statement-                    ("rz-sub" :: Text, 0 :: Int32, 10_000_000 :: Int64, 4 :: Int32, "category", Just "rz")-                    SQL.saveCheckpointMemberStmt+      -- A competing, much higher checkpoint for a DIFFERENT member under the+      -- SAME name. If checkpoints were keyed by name only, member 2's restart+      -- would resume from here and skip its events; member-keyed checkpoints+      -- must ignore it.+      runStmtP store $+        Session.statement+          ("rz-sub" :: Text, 0 :: Int32, 10_000_000 :: Int64, 4 :: Int32, "category", Just "rz")+          SQL.saveCheckpointMemberStmt -            -- Run 2: member 2 restarts and must resume from its OWN checkpoint-            -- (slice!!1), delivering the next two member-2 events.-            run2 <- collectStopAfter store (memberConfig "rz-sub" "rz" 2 4) 2-            run2 `shouldBe` take 2 (drop 2 slice)+      -- Run 2: member 2 restarts and must resume from its OWN checkpoint+      -- (slice!!1), delivering the next two member-2 events.+      run2 <- collectStopAfter store (memberConfig "rz-sub" "rz" 2 4) 2+      run2 `shouldBe` take 2 (drop 2 slice) -    it "lifecycle events carry the consumer-group member context (GroupMember 2 4)" $ do-        ref <- newIORef ([] :: [KirokuEvent])-        let evtHandler e = modifyIORef' ref (e :)-            isStartedMember2 ev = case ev of-                KirokuEventSubscriptionStarted (SubscriptionName "obs-sub") _ (GroupMember 2 4) -> True-                _ -> False-        withTestStoreSettings (\s -> s & #eventHandler .~ Just evtHandler) $ \store -> do-            handle <- subscribe store (memberConfig "obs-sub" "obs" 2 4 (\_ -> pure Continue))-            waitUntil 5_000_000 (any isStartedMember2 <$> readIORef ref)-            cancel handle-            evts <- readIORef ref-            any isStartedMember2 evts `shouldBe` True+  it "lifecycle events carry the consumer-group member context (GroupMember 2 4)" $ do+    ref <- newIORef ([] :: [KirokuEvent])+    let evtHandler e = modifyIORef' ref (e :)+        isStartedMember2 ev = case ev of+          KirokuEventSubscriptionStarted (SubscriptionName "obs-sub") _ (GroupMember 2 4) -> True+          _ -> False+    withTestStoreSettings (\s -> s & #eventHandler .~ Just evtHandler) $ \store -> do+      handle <- subscribe store (memberConfig "obs-sub" "obs" 2 4 (\_ -> pure Continue))+      waitUntil 5_000_000 (any isStartedMember2 <$> readIORef ref)+      cancel handle+      evts <- readIORef ref+      any isStartedMember2 evts `shouldBe` True -    it "consumerGroupGuard fails fast when another holder holds the (name, member) lock" $ do-        withMigratedTestDatabase $ \cs -> do-            withStore (defaultConnectionSettings cs) $ \store -> do-                -- Holder: a dedicated connection takes the SESSION-level advisory-                -- lock for the same key the worker's guard probes, namely-                -- hashtextextended("guard-sub:3", 0). It is held until release.-                eConn <- Connection.acquire (Conn.connectionString cs)-                conn <- either (\e -> error ("guard holder connect failed: " <> show e)) pure eConn-                let holdStmt :: Statement Text ()-                    holdStmt =-                        preparable-                            "SELECT pg_advisory_lock(hashtextextended($1, 0))"-                            (E.param (E.nonNullable E.text))-                            D.noResult-                lockRes <- Connection.use conn (Session.statement "guard-sub:3" holdStmt)-                either (\e -> error ("guard holder lock failed: " <> show e)) pure lockRes+  it "consumerGroupGuard fails fast when another holder holds the (name, member) lock" $ do+    withMigratedTestDatabase $ \cs -> do+      withStore (defaultConnectionSettings cs) $ \store -> do+        -- Holder: a dedicated connection takes the SESSION-level advisory+        -- lock for the same key the worker's guard probes, namely+        -- hashtextextended("guard-sub:3", 0). It is held until release.+        eConn <- Connection.acquire (Conn.connectionString cs)+        conn <- either (\e -> error ("guard holder connect failed: " <> show e)) pure eConn+        let holdStmt :: Statement Text ()+            holdStmt =+              preparable+                "SELECT pg_advisory_lock(hashtextextended($1, 0))"+                (E.param (E.nonNullable E.text))+                D.noResult+        lockRes <- Connection.use conn (Session.statement "guard-sub:3" holdStmt)+        either (\e -> error ("guard holder lock failed: " <> show e)) pure lockRes -                let cfg =-                        (defaultSubscriptionConfig (SubscriptionName "guard-sub") (Category (CategoryName "guardcat")) (\_ -> pure Continue))-                            { consumerGroup = Just (validConsumerGroup 3 4)-                            , consumerGroupGuard = True-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                handle <- subscribe store cfg-                res <- waitWithTimeout 5_000_000 handle-                Connection.release conn-                case res of-                    Right (Left e)-                        | Just (ConsumerGroupGuardConflict (SubscriptionName "guard-sub") 3) <- fromException e ->-                            pure ()-                    other ->-                        expectationFailure ("expected ConsumerGroupGuardConflict, got: " <> show other)+        let cfg =+              (defaultSubscriptionConfig (SubscriptionName "guard-sub") (Category (CategoryName "guardcat")) (\_ -> pure Continue))+                { consumerGroup = Just (validConsumerGroup 3 4),+                  consumerGroupGuard = True,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+        handle <- subscribe store cfg+        res <- waitWithTimeout 5_000_000 handle+        Connection.release conn+        case res of+          Right (Left e)+            | Just (ConsumerGroupGuardConflict (SubscriptionName "guard-sub") 3) <- fromException e ->+                pure ()+          other ->+            expectationFailure ("expected ConsumerGroupGuardConflict, got: " <> show other) -    it "subscriptionStream forwards the consumer-group field (member 0 of 2 sees its slice)" $-        withTestStore $ \store -> do-            let nStreams = 20-                perStream = 2-                total = nStreams * perStream-                streams = ["bridge-" <> T.pack (show i) | i <- [1 .. nStreams]]-            seed store streams perStream-            waitForPublisher store (GlobalPosition (fromIntegral total))+  it "subscriptionStream forwards the consumer-group field (member 0 of 2 sees its slice)" $+    withTestStore $ \store -> do+      let nStreams = 20+          perStream = 2+          total = nStreams * perStream+          streams = ["bridge-" <> T.pack (show i) | i <- [1 .. nStreams]]+      seed store streams perStream+      waitForPublisher store (GlobalPosition (fromIntegral total)) -            sliceV <--                runStmtP store $-                    Session.statement-                        (0 :: Int64, "bridge" :: Text, 0 :: Int32, 2 :: Int32, 100000 :: Int32)-                        SQL.readCategoryForwardConsumerGroupStmt-            let slicePos = sort (map (snd . pairOf) (V.toList sliceV))-                k = length slicePos-            -- A proper, non-trivial subset of the whole category.-            k `shouldSatisfy` (\x -> x > 0 && x < total)+      sliceV <-+        runStmtP store $+          Session.statement+            (0 :: Int64, "bridge" :: Text, 0 :: Int32, 2 :: Int32, 100000 :: Int32)+            SQL.readCategoryForwardConsumerGroupStmt+      let slicePos = sort (map (snd . pairOf) (V.toList sliceV))+          k = length slicePos+      -- A proper, non-trivial subset of the whole category.+      k `shouldSatisfy` (\x -> x > 0 && x < total) -            (stream, cancelStream) <--                subscriptionStream store (memberConfig "bridge-sub" "bridge" 0 2 (\_ -> pure Continue)) (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 64))-            pulled <- Stream.toList (Stream.take k stream)-            cancelStream-            sort (map (snd . pairOf) pulled) `shouldBe` slicePos+      (stream, cancelStream) <-+        subscriptionStream store (memberConfig "bridge-sub" "bridge" 0 2 (\_ -> pure Continue)) (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 64))+      pulled <- Stream.toList (Stream.take k stream)+      cancelStream+      sort (map (snd . pairOf) pulled) `shouldBe` slicePos
test/Test/ConsumerGroupEffect.hs view
@@ -2,35 +2,34 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TypeApplications #-} -{- | Consumer-group tests for the /effectful/ subscription API (ExecPlan 30 /-EP-3). EP-2's 'Test.ConsumerGroup' proves the partitioning contract through the-plain-IO 'Kiroku.Store.Subscription.subscribe'; this module proves the same-contract holds through 'Kiroku.Store.Subscription.Effect' — the higher-order-'Subscription' effect interpreted by 'runSubscription' inside an @Eff@ stack.--Two properties are proven:--  * Each member of a size-2 category group, driven through @runSubscription@ /-    @subscribe@, receives exactly its hash-assigned slice; the two slices are-    /disjoint/, their union is /complete/, and each is /per-stream-ordered/.-  * The @ConcUnlift Persistent (Limited 1)@ strategy used by 'runSubscription'-    keeps the effect environment alive across handler calls: an-    'Effectful.State.Static.Local.State' counter mutated inside the handler-    accumulates @1, 2, 3, ...@ across deliveries rather than resetting to @1@-    each call (which is what an @Ephemeral@ unlift would produce).--__Why every handler returns 'Stop' rather than relying on external cancel.__-A worker started through the effectful interpreter runs its handler via the-captured @localUnliftIO@ environment. Cancelling such a worker from outside-(e.g. through the effectful 'Kiroku.Store.Subscription.Effect.withSubscription'-bracket) while it is blocked inside that unlift does not terminate cleanly — it-hangs the worker thread. Every member here therefore stops itself once it has-seen its expected number of events, so the worker exits on its own and the-@Eff@ scope closes with no live thread (the same shape as the working effectful-test in @kiroku-store/test/Main.hs@, "catches up with an Eff-based handler via-the effectful API"). See this module's Surprises note in-@docs/plans/30-consumer-group-effect-api-and-shibuya-adapter-integration.md@.--}+-- | Consumer-group tests for the /effectful/ subscription API (ExecPlan 30 /+-- EP-3). EP-2's 'Test.ConsumerGroup' proves the partitioning contract through the+-- plain-IO 'Kiroku.Store.Subscription.subscribe'; this module proves the same+-- contract holds through 'Kiroku.Store.Subscription.Effect' — the higher-order+-- 'Subscription' effect interpreted by 'runSubscription' inside an @Eff@ stack.+--+-- Two properties are proven:+--+--   * Each member of a size-2 category group, driven through @runSubscription@ /+--     @subscribe@, receives exactly its hash-assigned slice; the two slices are+--     /disjoint/, their union is /complete/, and each is /per-stream-ordered/.+--   * The @ConcUnlift Persistent (Limited 1)@ strategy used by 'runSubscription'+--     keeps the effect environment alive across handler calls: an+--     'Effectful.State.Static.Local.State' counter mutated inside the handler+--     accumulates @1, 2, 3, ...@ across deliveries rather than resetting to @1@+--     each call (which is what an @Ephemeral@ unlift would produce).+--+-- __Why every handler returns 'Stop' rather than relying on external cancel.__+-- A worker started through the effectful interpreter runs its handler via the+-- captured @localUnliftIO@ environment. Cancelling such a worker from outside+-- (e.g. through the effectful 'Kiroku.Store.Subscription.Effect.withSubscription'+-- bracket) while it is blocked inside that unlift does not terminate cleanly — it+-- hangs the worker thread. Every member here therefore stops itself once it has+-- seen its expected number of events, so the worker exits on its own and the+-- @Eff@ scope closes with no live thread (the same shape as the working effectful+-- test in @kiroku-store/test/Main.hs@, "catches up with an Eff-based handler via+-- the effectful API"). See this module's Surprises note in+-- @docs/plans/30-consumer-group-effect-api-and-shibuya-adapter-integration.md@. module Test.ConsumerGroupEffect (spec) where  import Control.Concurrent.STM (atomically, newTVarIO, readTVar, writeTVar)@@ -58,150 +57,148 @@ -- | Extract @(originalStreamId, globalPosition)@ as raw 'Int64's from an event. pairOf :: RecordedEvent -> (Int64, Int64) pairOf e =-    ( case e ^. #originalStreamId of StreamId s -> s-    , case e ^. #globalPosition of GlobalPosition p -> p-    )+  ( case e ^. #originalStreamId of StreamId s -> s,+    case e ^. #globalPosition of GlobalPosition p -> p+  )  -- | Append @perStream@ events to each named stream, seeding the store. seed :: KirokuStore -> [Text] -> Int -> IO () seed store names perStream =-    mapM_-        ( \sn -> do-            let evs = map (\k -> makeEvent ("E" <> T.pack (show k)) (Aeson.object [])) [1 .. perStream]-            r <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs-            case r of-                Left err -> error ("seed append failed for " <> T.unpack sn <> ": " <> show err)-                Right _ -> pure ()-        )-        names+  mapM_+    ( \sn -> do+        let evs = map (\k -> makeEvent ("E" <> T.pack (show k)) (Aeson.object [])) [1 .. perStream]+        r <- runStoreIO store $ appendToStream (StreamName sn) NoStream evs+        case r of+          Left err -> error ("seed append failed for " <> T.unpack sn <> ": " <> show err)+          Right _ -> pure ()+    )+    names -{- | Assert that within each originating stream the global positions, taken in-delivery order, are strictly ascending (i.e. equal to their sorted form).--}+-- | Assert that within each originating stream the global positions, taken in+-- delivery order, are strictly ascending (i.e. equal to their sorted form). assertPerStreamAscending :: [(Int64, Int64)] -> Expectation assertPerStreamAscending chrono =-    let byStream = Map.fromListWith (\new old -> old ++ new) [(sid, [gp]) | (sid, gp) <- chrono]-     in mapM_ (\ps -> ps `shouldBe` sort ps) (Map.elems byStream)+  let byStream = Map.fromListWith (\new old -> old ++ new) [(sid, [gp]) | (sid, gp) <- chrono]+   in mapM_ (\ps -> ps `shouldBe` sort ps) (Map.elems byStream)  -- | Run a Hasql session against the store pool, failing the test on error. runStmtP :: KirokuStore -> Session.Session a -> IO a runStmtP store session = do-    r <- Pool.use (store ^. #pool) session-    either (error . show) pure r+  r <- Pool.use (store ^. #pool) session+  either (error . show) pure r -{- | Drive one effectful category-group member to self-completion: it 'Stop's-after receiving exactly @k@ events, so the worker exits on its own (no external-cancel). Returns the @(streamId, globalPosition)@ pairs in delivery order. The-handler runs in @Eff es@ (needing only 'IOE'), exercising the @runSubscription@-record-update pass-through of the @consumerGroup@ field.--}+-- | Drive one effectful category-group member to self-completion: it 'Stop's+-- after receiving exactly @k@ events, so the worker exits on its own (no external+-- cancel). Returns the @(streamId, globalPosition)@ pairs in delivery order. The+-- handler runs in @Eff es@ (needing only 'IOE'), exercising the @runSubscription@+-- record-update pass-through of the @consumerGroup@ field. runEffMember :: KirokuStore -> Text -> Text -> Int32 -> Int32 -> Int -> IO [(Int64, Int64)] runEffMember store nm cat m n k = do-    ref <- newIORef []-    countVar <- newTVarIO (0 :: Int)-    let effHandler :: (IOE :> es) => RecordedEvent -> Eff es SubscriptionResult-        effHandler evt = do-            liftIO $ modifyIORef' ref (pairOf evt :)-            c <- liftIO $ atomically $ do-                c0 <- readTVar countVar-                writeTVar countVar (c0 + 1)-                pure (c0 + 1)-            pure (if c >= k then Stop else Continue)-        cfg =-            (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) effHandler)-                { consumerGroup = Just (validConsumerGroup m n)-                }-    runEff $ SubEff.runSubscription store $ do-        handle <- SubEff.subscribe cfg-        liftIO $ do-            r <- waitWithTimeout 15_000_000 handle-            case r of-                Left timeout -> expectationFailure timeout-                Right (Left err) -> expectationFailure ("effectful member failed: " <> show err)-                Right (Right ()) -> pure ()-    reverse <$> readIORef ref+  ref <- newIORef []+  countVar <- newTVarIO (0 :: Int)+  let effHandler :: (IOE :> es) => RecordedEvent -> Eff es SubscriptionResult+      effHandler evt = do+        liftIO $ modifyIORef' ref (pairOf evt :)+        c <- liftIO $ atomically $ do+          c0 <- readTVar countVar+          writeTVar countVar (c0 + 1)+          pure (c0 + 1)+        pure (if c >= k then Stop else Continue)+      cfg =+        (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) effHandler)+          { consumerGroup = Just (validConsumerGroup m n)+          }+  runEff $ SubEff.runSubscription store $ do+    handle <- SubEff.subscribe cfg+    liftIO $ do+      r <- waitWithTimeout 15_000_000 handle+      case r of+        Left timeout -> expectationFailure timeout+        Right (Left err) -> expectationFailure ("effectful member failed: " <> show err)+        Right (Right ()) -> pure ()+  reverse <$> readIORef ref  spec :: Spec spec = describe "consumer groups (effectful)" $ do-    it "routes each member through the effectful API to its own disjoint, complete, per-stream-ordered slice (size-2 category group)" $-        withTestStore $ \store -> do-            let nStreams = 40-                perStream = 3-                total = nStreams * perStream-                streams = ["effcg-" <> T.pack (show i) | i <- [1 .. nStreams]]-            seed store streams perStream-            waitForPublisher store (GlobalPosition (fromIntegral total))+  it "routes each member through the effectful API to its own disjoint, complete, per-stream-ordered slice (size-2 category group)" $+    withTestStore $ \store -> do+      let nStreams = 40+          perStream = 3+          total = nStreams * perStream+          streams = ["effcg-" <> T.pack (show i) | i <- [1 .. nStreams]]+      seed store streams perStream+      waitForPublisher store (GlobalPosition (fromIntegral total)) -            -- Deterministic ground truth: each member's slice size straight from the-            -- EP-1 partition SQL. The effectful workers must reproduce exactly these.-            let sliceSize m =-                    V.length-                        <$> runStmtP-                            store-                            ( Session.statement-                                (0 :: Int64, "effcg" :: Text, m, 2 :: Int32, 100000 :: Int32)-                                SQL.readCategoryForwardConsumerGroupStmt-                            )-            k0 <- sliceSize 0-            k1 <- sliceSize 1-            k0 `shouldSatisfy` (> 0)-            k1 `shouldSatisfy` (> 0)-            (k0 + k1) `shouldBe` total+      -- Deterministic ground truth: each member's slice size straight from the+      -- EP-1 partition SQL. The effectful workers must reproduce exactly these.+      let sliceSize m =+            V.length+              <$> runStmtP+                store+                ( Session.statement+                    (0 :: Int64, "effcg" :: Text, m, 2 :: Int32, 100000 :: Int32)+                    SQL.readCategoryForwardConsumerGroupStmt+                )+      k0 <- sliceSize 0+      k1 <- sliceSize 1+      k0 `shouldSatisfy` (> 0)+      k1 `shouldSatisfy` (> 0)+      (k0 + k1) `shouldBe` total -            -- Each member is driven through runSubscription/subscribe and self-exits.-            -- Same subscription name, distinct members → independent (name, member)-            -- checkpoints, so running them in sequence is safe.-            m0 <- runEffMember store "eff-cg-cat" "effcg" 0 2 k0-            m1 <- runEffMember store "eff-cg-cat" "effcg" 1 2 k1+      -- Each member is driven through runSubscription/subscribe and self-exits.+      -- Same subscription name, distinct members → independent (name, member)+      -- checkpoints, so running them in sequence is safe.+      m0 <- runEffMember store "eff-cg-cat" "effcg" 0 2 k0+      m1 <- runEffMember store "eff-cg-cat" "effcg" 1 2 k1 -            -- (1) Disjoint + complete: the union of both members' delivered positions-            --     is exactly [1..total] — no duplicate and no gap.-            let allPositions = sort (map snd m0 ++ map snd m1)-            allPositions `shouldBe` [1 .. fromIntegral total]-            -- (2) Per-stream ordering preserved within each member.-            assertPerStreamAscending m0-            assertPerStreamAscending m1+      -- (1) Disjoint + complete: the union of both members' delivered positions+      --     is exactly [1..total] — no duplicate and no gap.+      let allPositions = sort (map snd m0 ++ map snd m1)+      allPositions `shouldBe` [1 .. fromIntegral total]+      -- (2) Per-stream ordering preserved within each member.+      assertPerStreamAscending m0+      assertPerStreamAscending m1 -    it "preserves State across handler calls within one member (Persistent unlift)" $-        withTestStore $ \store -> do-            -- Five events on one stream of category "effstate"; a size-1 member sees all.-            seed store ["effstate-1"] 5-            waitForPublisher store (GlobalPosition 5)+  it "preserves State across handler calls within one member (Persistent unlift)" $+    withTestStore $ \store -> do+      -- Five events on one stream of category "effstate"; a size-1 member sees all.+      seed store ["effstate-1"] 5+      waitForPublisher store (GlobalPosition 5) -            -- 'seenRef' records the Effectful State value observed after each-            -- increment; 'nVar' counts deliveries and drives the Stop (so self-exit-            -- never depends on the property under test). With the Persistent unlift-            -- the cloned environment is reused across calls and the counter-            -- accumulates 1,2,3,4,5; an Ephemeral unlift would record 1 every time.-            seenRef <- newIORef ([] :: [Int])-            nVar <- newTVarIO (0 :: Int)-            runEff $-                SubEff.runSubscription store $-                    State.evalState (0 :: Int) $ do-                        let cfg =-                                ( defaultSubscriptionConfig-                                    (SubscriptionName "eff-state-sub")-                                    (Category (CategoryName "effstate"))-                                    ( \_ -> do-                                        State.modify @Int (+ 1)-                                        s <- State.get @Int-                                        liftIO (modifyIORef' seenRef (s :))-                                        c <- liftIO $ atomically $ do-                                            c0 <- readTVar nVar-                                            writeTVar nVar (c0 + 1)-                                            pure (c0 + 1)-                                        pure (if c >= 5 then Stop else Continue)-                                    )-                                )-                                    { consumerGroup = Just (validConsumerGroup 0 1)-                                    }-                        handle <- SubEff.subscribe cfg-                        liftIO $ do-                            r <- waitWithTimeout 10_000_000 handle-                            case r of-                                Left timeout -> expectationFailure timeout-                                Right (Left err) -> expectationFailure ("effectful state member failed: " <> show err)-                                Right (Right ()) -> pure ()+      -- 'seenRef' records the Effectful State value observed after each+      -- increment; 'nVar' counts deliveries and drives the Stop (so self-exit+      -- never depends on the property under test). With the Persistent unlift+      -- the cloned environment is reused across calls and the counter+      -- accumulates 1,2,3,4,5; an Ephemeral unlift would record 1 every time.+      seenRef <- newIORef ([] :: [Int])+      nVar <- newTVarIO (0 :: Int)+      runEff $+        SubEff.runSubscription store $+          State.evalState (0 :: Int) $ do+            let cfg =+                  ( defaultSubscriptionConfig+                      (SubscriptionName "eff-state-sub")+                      (Category (CategoryName "effstate"))+                      ( \_ -> do+                          State.modify @Int (+ 1)+                          s <- State.get @Int+                          liftIO (modifyIORef' seenRef (s :))+                          c <- liftIO $ atomically $ do+                            c0 <- readTVar nVar+                            writeTVar nVar (c0 + 1)+                            pure (c0 + 1)+                          pure (if c >= 5 then Stop else Continue)+                      )+                  )+                    { consumerGroup = Just (validConsumerGroup 0 1)+                    }+            handle <- SubEff.subscribe cfg+            liftIO $ do+              r <- waitWithTimeout 10_000_000 handle+              case r of+                Left timeout -> expectationFailure timeout+                Right (Left err) -> expectationFailure ("effectful state member failed: " <> show err)+                Right (Right ()) -> pure () -            seen <- reverse <$> readIORef seenRef-            seen `shouldBe` [1, 2, 3, 4, 5]+      seen <- reverse <$> readIORef seenRef+      seen `shouldBe` [1, 2, 3, 4, 5]
test/Test/ConsumerGroupResize.hs view
@@ -32,174 +32,174 @@  spec :: Spec spec = describe "consumer-group resize" $ do-    it "rejects invalid sizes and member indices at construction" $ do-        mkConsumerGroupSize 0 `shouldBe` Left (InvalidConsumerGroup 0 0)-        mkConsumerGroupSize (-1) `shouldBe` Left (InvalidConsumerGroup 0 (-1))-        let size2 = groupSize 2-        mkConsumerGroup (-1) size2 `shouldBe` Left (InvalidConsumerGroup (-1) 2)-        mkConsumerGroup 2 size2 `shouldBe` Left (InvalidConsumerGroup 2 2)-        mkConsumerGroup 1 size2 `shouldBe` Right (validConsumerGroup 1 2)+  it "rejects invalid sizes and member indices at construction" $ do+    mkConsumerGroupSize 0 `shouldBe` Left (InvalidConsumerGroup 0 0)+    mkConsumerGroupSize (-1) `shouldBe` Left (InvalidConsumerGroup 0 (-1))+    let size2 = groupSize 2+    mkConsumerGroup (-1) size2 `shouldBe` Left (InvalidConsumerGroup (-1) 2)+    mkConsumerGroup 2 size2 `shouldBe` Left (InvalidConsumerGroup 2 2)+    mkConsumerGroup 1 size2 `shouldBe` Right (validConsumerGroup 1 2) -    it "refuses a configured size that disagrees with stored topology before creating a member" $ do-        refusals <- newIORef []-        let observe = \case-                KirokuEventSubscriptionGroupSizeMismatch refusal group -> modifyIORef' refusals ((refusal, group) :)-                _ -> pure ()-        withTestStoreSettings (\settings -> settings{eventHandler = Just observe}) $ \store -> do-            seedCheckpoint store "mismatch" 0 2 5-            called <- newIORef False-            handle <- subscribe store $ config "mismatch" 2 3 $ \_ -> writeIORef called True >> pure Continue-            expectMismatch handle "mismatch" 3 [2]-            readIORef called `shouldReturn` False-            rows store "mismatch" `shouldReturn` [(0, 2, 5)]-            readIORef refusals `shouldReturn` [(ConsumerGroupSizeMismatch (SubscriptionName "mismatch") 3 (Vector.singleton 2), GroupMember 2 3)]+  it "refuses a configured size that disagrees with stored topology before creating a member" $ do+    refusals <- newIORef []+    let observe = \case+          KirokuEventSubscriptionGroupSizeMismatch refusal group -> modifyIORef' refusals ((refusal, group) :)+          _ -> pure ()+    withTestStoreSettings (\settings -> settings {eventHandler = Just observe}) $ \store -> do+      seedCheckpoint store "mismatch" 0 2 5+      called <- newIORef False+      handle <- subscribe store $ config "mismatch" 2 3 $ \_ -> writeIORef called True >> pure Continue+      expectMismatch handle "mismatch" 3 [2]+      readIORef called `shouldReturn` False+      rows store "mismatch" `shouldReturn` [(0, 2, 5)]+      readIORef refusals `shouldReturn` [(ConsumerGroupSizeMismatch (SubscriptionName "mismatch") 3 (Vector.singleton 2), GroupMember 2 3)] -    it "also refuses an ordinary subscription over an existing larger group" $-        withTestStore $ \store -> do-            seedCheckpoint store "ordinary-mismatch" 0 2 0-            handle <- subscribe store (defaultSubscriptionConfig (SubscriptionName "ordinary-mismatch") AllStreams (\_ -> pure Continue))-            expectMismatch handle "ordinary-mismatch" 1 [2]+  it "also refuses an ordinary subscription over an existing larger group" $+    withTestStore $ \store -> do+      seedCheckpoint store "ordinary-mismatch" 0 2 0+      handle <- subscribe store (defaultSubscriptionConfig (SubscriptionName "ordinary-mismatch") AllStreams (\_ -> pure Continue))+      expectMismatch handle "ordinary-mismatch" 1 [2] -    it "refuses an underestimated derived topology until resized" $-        withTestStore $ \store -> do-            -- The upgrade test proves a lone legacy member derives size 1.-            seedCheckpoint store "underestimated" 0 1 8-            refused <- subscribe store (config "underestimated" 0 2 (\_ -> pure Continue))-            expectMismatch refused "underestimated" 2 [1]-            void $ resize store "underestimated" 2-            accepted <- subscribe store (config "underestimated" 0 2 (\_ -> pure Continue))-            awaitLive accepted-            cancel accepted-            rows store "underestimated" `shouldReturn` [(0, 2, 8), (1, 2, 8)]+  it "refuses an underestimated derived topology until resized" $+    withTestStore $ \store -> do+      -- The upgrade test proves a lone legacy member derives size 1.+      seedCheckpoint store "underestimated" 0 1 8+      refused <- subscribe store (config "underestimated" 0 2 (\_ -> pure Continue))+      expectMismatch refused "underestimated" 2 [1]+      void $ resize store "underestimated" 2+      accepted <- subscribe store (config "underestimated" 0 2 (\_ -> pure Continue))+      awaitLive accepted+      cancel accepted+      rows store "underestimated" `shouldReturn` [(0, 2, 8), (1, 2, 8)] -    it "refuses mixed stored sizes even when its own row agrees" $-        withTestStore $ \store -> do-            seedCheckpoint store "mixed" 0 2 0-            seedCheckpoint store "mixed" 1 3 0-            handle <- subscribe store (config "mixed" 0 2 (\_ -> pure Continue))-            expectMismatch handle "mixed" 2 [2, 3]+  it "refuses mixed stored sizes even when its own row agrees" $+    withTestStore $ \store -> do+      seedCheckpoint store "mixed" 0 2 0+      seedCheckpoint store "mixed" 1 3 0+      handle <- subscribe store (config "mixed" 0 2 (\_ -> pure Continue))+      expectMismatch handle "mixed" 2 [2, 3] -    it "serializes concurrent initializers with competing group sizes" $-        withTestStore $ \store -> do-            [firstHandle, secondHandle] <- Async.mapConcurrently (\n -> subscribe store (config "concurrent" 0 n (\_ -> pure Continue))) [2, 3]-            -- The winner reaches Live; the loser exits with a typed refusal.-            result <- timeout 5_000_000 $ Async.race (wait firstHandle) (wait secondHandle)-            case result of-                Just (Left outcome) -> assertMismatch outcome-                Just (Right outcome) -> assertMismatch outcome-                Nothing -> expectationFailure "competing topology startup timed out"-            mapM_ cancel [firstHandle, secondHandle]-            stored <- rows store "concurrent"-            length stored `shouldBe` 1-            stored `shouldSatisfy` \case [(0, n, 0)] -> n == 2 || n == 3; _ -> False+  it "serializes concurrent initializers with competing group sizes" $+    withTestStore $ \store -> do+      [firstHandle, secondHandle] <- Async.mapConcurrently (\n -> subscribe store (config "concurrent" 0 n (\_ -> pure Continue))) [2, 3]+      -- The winner reaches Live; the loser exits with a typed refusal.+      result <- timeout 5_000_000 $ Async.race (wait firstHandle) (wait secondHandle)+      case result of+        Just (Left outcome) -> assertMismatch outcome+        Just (Right outcome) -> assertMismatch outcome+        Nothing -> expectationFailure "competing topology startup timed out"+      mapM_ cancel [firstHandle, secondHandle]+      stored <- rows store "concurrent"+      length stored `shouldBe` 1+      stored `shouldSatisfy` \case [(0, n, 0)] -> n == 2 || n == 3; _ -> False -    it "persists configured topology on initialization, ordinary saves and dead-letter saves" $-        withTestStore $ \store -> do-            void $ runStoreIO store $ appendToStream (StreamName "persist-1") NoStream [makeEvent "E" (Aeson.object [])]-            -- A size-1 group exercises all rows deterministically. Size >1 is-            -- checked below via explicit ordinary and dead-letter SQL paths.-            handle <- subscribe store (config "saved" 0 1 (\_ -> pure Stop))-            expectClean handle-            rows store "saved" `shouldReturn` [(0, 1, 1)]-            live <- subscribe store ((config "initialized" 2 3 (\_ -> pure Continue)){missingCheckpointPolicy = FromCurrentHead})-            awaitLive live-            cancel live-            rows store "initialized" `shouldReturn` [(2, 3, 1)]-            seedCheckpoint store "saved-size" 1 4 20-            seedCheckpoint store "saved-size" 1 4 10-            rows store "saved-size" `shouldReturn` [(1, 4, 20)]-            Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 10)-            let event = Vector.head events-                EventId eid = event ^. #eventId-                params = SQL.DeadLetterParams "dead-letter-size" 1 "unbound" Nothing 4 1 eid (Aeson.object []) "test" 1-            runSession store (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)-            rows store "dead-letter-size" `shouldReturn` [(1, 4, 1)]-            let StreamId sid = event ^. #originalStreamId-                ownerStmt =-                    preparable-                        "SELECT (((hashtextextended($1::bigint::text, 0) % 4) + 4) % 4)::int4"-                        (E.param (E.nonNullable E.int8))-                        (D.singleRow (D.column (D.nonNullable D.int4)))-            owner <- runSession store (Session.statement sid ownerStmt)-            saved <- subscribe store (config "worker-save-size" owner 4 (\_ -> pure Stop))-            expectClean saved-            rows store "worker-save-size" `shouldReturn` [(owner, 4, 1)]-            deadLettered <- subscribe store (config "worker-dead-letter-size" owner 4 (\_ -> pure (DeadLetter (DeadLetterPoison "explicit consumer decision"))))-            awaitLive deadLettered-            cancel deadLettered-            rows store "worker-dead-letter-size" `shouldReturn` [(owner, 4, 1)]+  it "persists configured topology on initialization, ordinary saves and dead-letter saves" $+    withTestStore $ \store -> do+      void $ runStoreIO store $ appendToStream (StreamName "persist-1") NoStream [makeEvent "E" (Aeson.object [])]+      -- A size-1 group exercises all rows deterministically. Size >1 is+      -- checked below via explicit ordinary and dead-letter SQL paths.+      handle <- subscribe store (config "saved" 0 1 (\_ -> pure Stop))+      expectClean handle+      rows store "saved" `shouldReturn` [(0, 1, 1)]+      live <- subscribe store ((config "initialized" 2 3 (\_ -> pure Continue)) {missingCheckpointPolicy = FromCurrentHead})+      awaitLive live+      cancel live+      rows store "initialized" `shouldReturn` [(2, 3, 1)]+      seedCheckpoint store "saved-size" 1 4 20+      seedCheckpoint store "saved-size" 1 4 10+      rows store "saved-size" `shouldReturn` [(1, 4, 20)]+      Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 10)+      let event = Vector.head events+          EventId eid = event ^. #eventId+          params = SQL.DeadLetterParams "dead-letter-size" 1 "unbound" Nothing 4 1 eid (Aeson.object []) "test" 1+      runSession store (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)+      rows store "dead-letter-size" `shouldReturn` [(1, 4, 1)]+      let StreamId sid = event ^. #originalStreamId+          ownerStmt =+            preparable+              "SELECT (((hashtextextended($1::bigint::text, 0) % 4) + 4) % 4)::int4"+              (E.param (E.nonNullable E.int8))+              (D.singleRow (D.column (D.nonNullable D.int4)))+      owner <- runSession store (Session.statement sid ownerStmt)+      saved <- subscribe store (config "worker-save-size" owner 4 (\_ -> pure Stop))+      expectClean saved+      rows store "worker-save-size" `shouldReturn` [(owner, 4, 1)]+      deadLettered <- subscribe store (config "worker-dead-letter-size" owner 4 (\_ -> pure (DeadLetter (DeadLetterPoison "explicit consumer decision"))))+      awaitLive deadLettered+      cancel deadLettered+      rows store "worker-dead-letter-size" `shouldReturn` [(owner, 4, 1)] -    it "delivers every seeded event after equalizing size 2 to size 3" $-        withTestStore $ \store -> do-            forM_ [1 .. 40 :: Int] $ \i -> do-                result <- runStoreIO store $ appendToStream (StreamName ("resize-" <> Text.pack (show i))) NoStream [makeEvent "E" (Aeson.object [])]-                result `shouldSatisfy` either (const False) (const True)-            Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 100)-            let expected = Set.fromList [event ^. #eventId | event <- Vector.toList events]-            -- Member 0 has seen nothing, member 1 has crossed the full log.-            -- Remember its actual delivered set; a naive size change loses-            -- streams that move from old member 0 to new member 1.-            oldFast <- runSession store (Session.statement (0, 1, 2, 100) SQL.readAllForwardConsumerGroupStmt)-            naive <- mapM (\(m, p) -> runSession store (Session.statement (p, m, 3, 100) SQL.readAllForwardConsumerGroupStmt)) [(0, 0), (1, 40), (2, 0)]-            let alreadySeen = Set.fromList [e ^. #eventId | e <- Vector.toList oldFast]-                naiveSeen = Set.fromList [e ^. #eventId | batch <- naive, e <- Vector.toList batch]-            Set.union alreadySeen naiveSeen `shouldNotBe` expected-            seedCheckpoint store "skewed" 0 2 0-            seedCheckpoint store "skewed" 1 2 40-            refused <- subscribe store (config "skewed" 1 3 (\_ -> pure Continue))-            expectMismatch refused "skewed" 3 [2]-            report <- resize store "skewed" 3-            report `shouldBe` ConsumerGroupResizeReport (Vector.singleton 2) 2 (groupSize 3) (GlobalPosition 0)-            delivered <- newTVarIO alreadySeen-            handles <- mapM (\m -> subscribe store (config "skewed" m 3 (\e -> atomically (modifyTVar' delivered (Set.insert (e ^. #eventId))) >> pure Continue))) [0, 1, 2]-            complete <- timeout 15_000_000 (atomically (readTVar delivered >>= check . (== expected)))-            mapM_ cancel handles-            complete `shouldBe` Just ()-            readTVarIO delivered `shouldReturn` expected+  it "delivers every seeded event after equalizing size 2 to size 3" $+    withTestStore $ \store -> do+      forM_ [1 .. 40 :: Int] $ \i -> do+        result <- runStoreIO store $ appendToStream (StreamName ("resize-" <> Text.pack (show i))) NoStream [makeEvent "E" (Aeson.object [])]+        result `shouldSatisfy` either (const False) (const True)+      Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 100)+      let expected = Set.fromList [event ^. #eventId | event <- Vector.toList events]+      -- Member 0 has seen nothing, member 1 has crossed the full log.+      -- Remember its actual delivered set; a naive size change loses+      -- streams that move from old member 0 to new member 1.+      oldFast <- runSession store (Session.statement (0, 1, 2, 100) SQL.readAllForwardConsumerGroupStmt)+      naive <- mapM (\(m, p) -> runSession store (Session.statement (p, m, 3, 100) SQL.readAllForwardConsumerGroupStmt)) [(0, 0), (1, 40), (2, 0)]+      let alreadySeen = Set.fromList [e ^. #eventId | e <- Vector.toList oldFast]+          naiveSeen = Set.fromList [e ^. #eventId | batch <- naive, e <- Vector.toList batch]+      Set.union alreadySeen naiveSeen `shouldNotBe` expected+      seedCheckpoint store "skewed" 0 2 0+      seedCheckpoint store "skewed" 1 2 40+      refused <- subscribe store (config "skewed" 1 3 (\_ -> pure Continue))+      expectMismatch refused "skewed" 3 [2]+      report <- resize store "skewed" 3+      report `shouldBe` ConsumerGroupResizeReport (Vector.singleton 2) 2 (groupSize 3) (GlobalPosition 0)+      delivered <- newTVarIO alreadySeen+      handles <- mapM (\m -> subscribe store (config "skewed" m 3 (\e -> atomically (modifyTVar' delivered (Set.insert (e ^. #eventId))) >> pure Continue))) [0, 1, 2]+      complete <- timeout 15_000_000 (atomically (readTVar delivered >>= check . (== expected)))+      mapM_ cancel handles+      complete `shouldBe` Just ()+      readTVarIO delivered `shouldReturn` expected -    it "is idempotent when repeated at the same size and retains surviving member identities" $-        withTestStore $ \store -> do-            seedCheckpoint store "repeat" 0 2 5-            seedCheckpoint store "repeat" 1 2 20-            oldIds <- memberIds store "repeat"-            void $ resize store "repeat" 3-            originalRows <- rows store "repeat"-            resizedIds <- memberIds store "repeat"-            take 2 resizedIds `shouldBe` oldIds-            second <- resize store "repeat" 3-            rows store "repeat" `shouldReturn` originalRows-            memberIds store "repeat" `shouldReturn` resizedIds-            originalRows `shouldBe` [(0, 3, 5), (1, 3, 5), (2, 3, 5)]-            resumePosition second `shouldBe` GlobalPosition 5-            previousMemberCount second `shouldBe` 3+  it "is idempotent when repeated at the same size and retains surviving member identities" $+    withTestStore $ \store -> do+      seedCheckpoint store "repeat" 0 2 5+      seedCheckpoint store "repeat" 1 2 20+      oldIds <- memberIds store "repeat"+      void $ resize store "repeat" 3+      originalRows <- rows store "repeat"+      resizedIds <- memberIds store "repeat"+      take 2 resizedIds `shouldBe` oldIds+      second <- resize store "repeat" 3+      rows store "repeat" `shouldReturn` originalRows+      memberIds store "repeat" `shouldReturn` resizedIds+      originalRows `shouldBe` [(0, 3, 5), (1, 3, 5), (2, 3, 5)]+      resumePosition second `shouldBe` GlobalPosition 5+      previousMemberCount second `shouldBe` 3 -    it "starts a missing group at zero and removes obsolete members on shrink" $-        withTestStore $ \store -> do-            report <- resize store "missing" 3-            previousMemberCount report `shouldBe` 0-            resumePosition report `shouldBe` GlobalPosition 0-            rows store "missing" `shouldReturn` [(0, 3, 0), (1, 3, 0), (2, 3, 0)]-            void $ resize store "missing" 1-            rows store "missing" `shouldReturn` [(0, 1, 0)]+  it "starts a missing group at zero and removes obsolete members on shrink" $+    withTestStore $ \store -> do+      report <- resize store "missing" 3+      previousMemberCount report `shouldBe` 0+      resumePosition report `shouldBe` GlobalPosition 0+      rows store "missing" `shouldReturn` [(0, 3, 0), (1, 3, 0), (2, 3, 0)]+      void $ resize store "missing" 1+      rows store "missing" `shouldReturn` [(0, 1, 0)] -    it "rolls back the complete resize with the caller transaction" $-        withTestStore $ \store -> do-            seedCheckpoint store "rollback" 0 2 5-            seedCheckpoint store "rollback" 1 2 20-            void $ runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do-                report <- resizeConsumerGroupTx (SubscriptionName "rollback") (groupSize 3)-                Tx.condemn-                pure report-            rows store "rollback" `shouldReturn` [(0, 2, 5), (1, 2, 20)]+  it "rolls back the complete resize with the caller transaction" $+    withTestStore $ \store -> do+      seedCheckpoint store "rollback" 0 2 5+      seedCheckpoint store "rollback" 1 2 20+      void $ runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+        report <- resizeConsumerGroupTx (SubscriptionName "rollback") (groupSize 3)+        Tx.condemn+        pure report+      rows store "rollback" `shouldReturn` [(0, 2, 5), (1, 2, 20)]  groupSize :: Int32 -> ConsumerGroupSize groupSize = either (error . show) Prelude.id . mkConsumerGroupSize  config :: Text -> Int32 -> Int32 -> EventHandler -> SubscriptionConfig config name m n handler =-    (defaultSubscriptionConfig (SubscriptionName name) AllStreams handler)-        { consumerGroup = Just (validConsumerGroup m n)-        }+  (defaultSubscriptionConfig (SubscriptionName name) AllStreams handler)+    { consumerGroup = Just (validConsumerGroup m n)+    }  seedCheckpoint :: KirokuStore -> Text -> Int32 -> Int32 -> Int64 -> IO () seedCheckpoint store name m n p = runSession store (Session.statement (name, m, p, n, "unbound", Nothing) SQL.saveCheckpointMemberStmt)@@ -211,47 +211,47 @@ rows store name = Vector.toList <$> runSession store (Session.statement name stmt)   where     stmt =-        preparable-            "SELECT consumer_group_member, consumer_group_size, last_seen FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"-            (E.param (E.nonNullable E.text))-            (D.rowVector ((,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int8)))+      preparable+        "SELECT consumer_group_member, consumer_group_size, last_seen FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"+        (E.param (E.nonNullable E.text))+        (D.rowVector ((,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int8)))  runSession :: KirokuStore -> Session.Session a -> IO a runSession store session = Pool.use (store ^. #pool) session >>= either (error . show) pure  expectMismatch :: SubscriptionHandle -> Text -> Int32 -> [Int32] -> IO () expectMismatch handle name n observed = do-    result <- waitWithTimeout 5_000_000 handle-    case result of-        Right (Left exception) -> fromException exception `shouldBe` Just (ConsumerGroupSizeMismatch (SubscriptionName name) n (Vector.fromList observed))-        other -> expectationFailure ("expected topology mismatch, got " <> show other)+  result <- waitWithTimeout 5_000_000 handle+  case result of+    Right (Left exception) -> fromException exception `shouldBe` Just (ConsumerGroupSizeMismatch (SubscriptionName name) n (Vector.fromList observed))+    other -> expectationFailure ("expected topology mismatch, got " <> show other)  assertMismatch :: Either SomeException () -> IO () assertMismatch = \case-    Left exception -> (fromException exception :: Maybe ConsumerGroupSizeMismatch) `shouldSatisfy` maybe False (const True)-    Right () -> expectationFailure "expected competing-size startup refusal"+  Left exception -> (fromException exception :: Maybe ConsumerGroupSizeMismatch) `shouldSatisfy` maybe False (const True)+  Right () -> expectationFailure "expected competing-size startup refusal"  expectClean :: SubscriptionHandle -> IO () expectClean handle =-    waitWithTimeout 5_000_000 handle >>= \case-        Right (Right ()) -> pure ()-        other -> expectationFailure (show other)+  waitWithTimeout 5_000_000 handle >>= \case+    Right (Right ()) -> pure ()+    other -> expectationFailure (show other)  awaitLive :: SubscriptionHandle -> IO () awaitLive handle = do-    result <- timeout 5_000_000 loop-    result `shouldBe` Just ()+  result <- timeout 5_000_000 loop+  result `shouldBe` Just ()   where     loop =-        currentState handle >>= \case-            Just state | stateName state == "live" -> pure ()-            _ -> threadDelay 1_000 >> loop+      currentState handle >>= \case+        Just state | stateName state == "live" -> pure ()+        _ -> threadDelay 1_000 >> loop  memberIds :: KirokuStore -> Text -> IO [Int64] memberIds store name =-    runSession store $-        Session.statement name $-            preparable-                "SELECT subscription_id FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"-                (E.param (E.nonNullable E.text))-                (D.rowList (D.column (D.nonNullable D.int8)))+  runSession store $+    Session.statement name $+      preparable+        "SELECT subscription_id FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"+        (E.param (E.nonNullable E.text))+        (D.rowList (D.column (D.nonNullable D.int8)))
test/Test/ConsumerGroupSql.hs view
@@ -1,15 +1,14 @@ {-# LANGUAGE OverloadedStrings #-} -{- | SQL-level tests for consumer-group partition routing and per-member-checkpoints (ExecPlan 28 / EP-1). These exercise the new prepared statements in-"Kiroku.Store.SQL" directly through the connection pool, with no subscription-runtime, on a fresh ephemeral PostgreSQL per test.--Terms: a /consumer group/ of /size/ N has members 0..N-1; each source stream is-assigned to exactly one member by 'Kiroku.Store.SQL'-encoded hash routing. The-properties proven here — disjointness, completeness, per-stream affinity,-determinism, and size-1 equivalence — are the contract EP-2's runtime depends on.--}+-- | SQL-level tests for consumer-group partition routing and per-member+-- checkpoints (ExecPlan 28 / EP-1). These exercise the new prepared statements in+-- "Kiroku.Store.SQL" directly through the connection pool, with no subscription+-- runtime, on a fresh ephemeral PostgreSQL per test.+--+-- Terms: a /consumer group/ of /size/ N has members 0..N-1; each source stream is+-- assigned to exactly one member by 'Kiroku.Store.SQL'-encoded hash routing. The+-- properties proven here — disjointness, completeness, per-stream affinity,+-- determinism, and size-1 equivalence — are the contract EP-2's runtime depends on. module Test.ConsumerGroupSql (spec) where  import Contravariant.Extras (contrazip2)@@ -41,26 +40,25 @@ -- | Run a statement against the store's pool, failing the test on a usage error. runStmt :: KirokuStore -> Session.Session a -> IO a runStmt store session = do-    result <- Pool.use (store ^. #pool) session-    case result of-        Left err -> error ("ConsumerGroupSql statement failed: " <> show err)-        Right a -> pure a+  result <- Pool.use (store ^. #pool) session+  case result of+    Left err -> error ("ConsumerGroupSql statement failed: " <> show err)+    Right a -> pure a -{- | Append one event to each of the named streams, seeding the store. Each-stream gets a single event; per-stream affinity is what we test, so one event-per stream is enough, but the helper appends 'n' events to vary versions.--}+-- | Append one event to each of the named streams, seeding the store. Each+-- stream gets a single event; per-stream affinity is what we test, so one event+-- per stream is enough, but the helper appends 'n' events to vary versions. seedStreams :: KirokuStore -> [Text] -> Int -> IO () seedStreams store names n =-    mapM_-        ( \name -> do-            let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. n]-            r <- runStoreIO store $ appendToStream (StreamName name) NoStream events-            case r of-                Left err -> error ("seed append failed for " <> T.unpack name <> ": " <> show err)-                Right _ -> pure ()-        )-        names+  mapM_+    ( \name -> do+        let events = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. n]+        r <- runStoreIO store $ appendToStream (StreamName name) NoStream events+        case r of+          Left err -> error ("seed append failed for " <> T.unpack name <> ": " <> show err)+          Right _ -> pure ()+    )+    names  -- | Collect the event ids from a read result, in order. eventIds :: Vector RecordedEvent -> [UUID]@@ -69,30 +67,29 @@ -- | Collect (originalStreamId, globalPosition) pairs from a read result. streamPositions :: Vector RecordedEvent -> [(Int64, Int64)] streamPositions =-    map-        ( \e ->-            ( case e ^. #originalStreamId of StreamId s -> s-            , case e ^. #globalPosition of GlobalPosition p -> p-            )+  map+    ( \e ->+        ( case e ^. #originalStreamId of StreamId s -> s,+          case e ^. #globalPosition of GlobalPosition p -> p         )-        . V.toList+    )+    . V.toList -{- | Direct call to the partition rule for one stream id and size, returning the-member index PostgreSQL computes. Mirrors IP-1 exactly so the test pins the-formula, not just its consequences.--}+-- | Direct call to the partition rule for one stream id and size, returning the+-- member index PostgreSQL computes. Mirrors IP-1 exactly so the test pins the+-- formula, not just its consequences. runMemberOf :: KirokuStore -> Int64 -> Int32 -> IO Int32 runMemberOf store streamId size = runStmt store (Session.statement (streamId, size) stmt)   where     stmt :: Statement (Int64, Int32) Int32     stmt =-        preparable-            "SELECT (((hashtextextended($1::text, 0) % $2) + $2) % $2)::int4"-            ( contrazip2-                (E.param (E.nonNullable E.int8))-                (E.param (E.nonNullable E.int4))-            )-            (D.singleRow (D.column (D.nonNullable D.int4)))+      preparable+        "SELECT (((hashtextextended($1::text, 0) % $2) + $2) % $2)::int4"+        ( contrazip2+            (E.param (E.nonNullable E.int8))+            (E.param (E.nonNullable E.int4))+        )+        (D.singleRow (D.column (D.nonNullable D.int4)))  -- | All distinct (originalStreamId) values present in the unpartitioned read. distinctStreamIds :: Vector RecordedEvent -> [Int64]@@ -100,11 +97,11 @@  spec :: Spec spec = do-    describe "ConsumerGroupSql" $ do-        categorySpec-        checkpointSpec-        allSpec-        memberOfSpec+  describe "ConsumerGroupSql" $ do+    categorySpec+    checkpointSpec+    allSpec+    memberOfSpec  -- --------------------------------------------------------------------------- -- M1: category partitioning@@ -112,66 +109,66 @@  categorySpec :: Spec categorySpec = around withTestStore $ do-    describe "category consumer-group partitioning (size 4)" $ do-        let cat = "acct"-            names = map (\i -> "acct-" <> T.pack (show i)) [1 .. 50 :: Int]-            size = 4 :: Int32+  describe "category consumer-group partitioning (size 4)" $ do+    let cat = "acct"+        names = map (\i -> "acct-" <> T.pack (show i)) [1 .. 50 :: Int]+        size = 4 :: Int32 -        let readMember store m =-                runStmt store $-                    Session.statement (0 :: Int64, cat, m, size, bigLimit) SQL.readCategoryForwardConsumerGroupStmt-            readFull store =-                runStmt store $-                    Session.statement (0 :: Int64, cat, bigLimit) SQL.readCategoryForwardStmt+    let readMember store m =+          runStmt store $+            Session.statement (0 :: Int64, cat, m, size, bigLimit) SQL.readCategoryForwardConsumerGroupStmt+        readFull store =+          runStmt store $+            Session.statement (0 :: Int64, cat, bigLimit) SQL.readCategoryForwardStmt -        it "splits a category into 4 pairwise-disjoint member slices" $ \store -> do-            seedStreams store names 2-            slices <- mapM (readMember store) [0 .. size - 1]-            let idSets = map (Set.fromList . eventIds) slices-            -- pairwise disjoint: union of sizes equals size of union-            let totalIds = sum (map Set.size idSets)-                unionIds = Set.size (Set.unions idSets)-            totalIds `shouldBe` unionIds+    it "splits a category into 4 pairwise-disjoint member slices" $ \store -> do+      seedStreams store names 2+      slices <- mapM (readMember store) [0 .. size - 1]+      let idSets = map (Set.fromList . eventIds) slices+      -- pairwise disjoint: union of sizes equals size of union+      let totalIds = sum (map Set.size idSets)+          unionIds = Set.size (Set.unions idSets)+      totalIds `shouldBe` unionIds -        it "union of all member slices equals the unpartitioned category read" $ \store -> do-            seedStreams store names 2-            slices <- mapM (readMember store) [0 .. size - 1]-            full <- readFull store-            let unionIds = Set.unions (map (Set.fromList . eventIds) slices)-                fullIds = Set.fromList (eventIds full)-            unionIds `shouldBe` fullIds+    it "union of all member slices equals the unpartitioned category read" $ \store -> do+      seedStreams store names 2+      slices <- mapM (readMember store) [0 .. size - 1]+      full <- readFull store+      let unionIds = Set.unions (map (Set.fromList . eventIds) slices)+          fullIds = Set.fromList (eventIds full)+      unionIds `shouldBe` fullIds -        it "every stream's events go to exactly one member, in ascending global position" $ \store -> do-            seedStreams store names 3-            slices <- mapM (readMember store) [0 .. size - 1]-            -- For each member slice, every stream present must have all its events-            -- in ascending global position, and no stream may appear in two slices.-            let perMemberStreams = map (Set.fromList . map fst . streamPositions) slices-            -- per-stream affinity: stream sets are pairwise disjoint-            let totalStreams = sum (map Set.size perMemberStreams)-                unionStreams = Set.size (Set.unions perMemberStreams)-            totalStreams `shouldBe` unionStreams-            -- ascending global position within each slice-            mapM_-                ( \slice -> do-                    let ps = map snd (streamPositions slice)-                    ps `shouldBe` sort ps-                )-                slices+    it "every stream's events go to exactly one member, in ascending global position" $ \store -> do+      seedStreams store names 3+      slices <- mapM (readMember store) [0 .. size - 1]+      -- For each member slice, every stream present must have all its events+      -- in ascending global position, and no stream may appear in two slices.+      let perMemberStreams = map (Set.fromList . map fst . streamPositions) slices+      -- per-stream affinity: stream sets are pairwise disjoint+      let totalStreams = sum (map Set.size perMemberStreams)+          unionStreams = Set.size (Set.unions perMemberStreams)+      totalStreams `shouldBe` unionStreams+      -- ascending global position within each slice+      mapM_+        ( \slice -> do+            let ps = map snd (streamPositions slice)+            ps `shouldBe` sort ps+        )+        slices -        it "member assignment is deterministic across repeated reads" $ \store -> do-            seedStreams store names 1-            firstReads <- mapM (\m -> eventIds <$> readMember store m) [0 .. size - 1]-            secondReads <- mapM (\m -> eventIds <$> readMember store m) [0 .. size - 1]-            firstReads `shouldBe` secondReads+    it "member assignment is deterministic across repeated reads" $ \store -> do+      seedStreams store names 1+      firstReads <- mapM (\m -> eventIds <$> readMember store m) [0 .. size - 1]+      secondReads <- mapM (\m -> eventIds <$> readMember store m) [0 .. size - 1]+      firstReads `shouldBe` secondReads -        it "size 1 is equivalent to an unpartitioned category read" $ \store -> do-            seedStreams store names 2-            one <--                runStmt store $-                    Session.statement (0 :: Int64, cat, 0 :: Int32, 1 :: Int32, bigLimit) SQL.readCategoryForwardConsumerGroupStmt-            full <- readFull store-            eventIds one `shouldBe` eventIds full+    it "size 1 is equivalent to an unpartitioned category read" $ \store -> do+      seedStreams store names 2+      one <-+        runStmt store $+          Session.statement (0 :: Int64, cat, 0 :: Int32, 1 :: Int32, bigLimit) SQL.readCategoryForwardConsumerGroupStmt+      full <- readFull store+      eventIds one `shouldBe` eventIds full  -- --------------------------------------------------------------------------- -- M2: per-member checkpoints@@ -179,34 +176,34 @@  checkpointSpec :: Spec checkpointSpec = around withTestStore $ do-    describe "per-member checkpoints" $ do-        let subName = "proj-acct" :: Text+  describe "per-member checkpoints" $ do+    let subName = "proj-acct" :: Text -        it "stores and reads independent checkpoints per member" $ \store -> do-            runStmt store $ Session.statement (subName, 0 :: Int32, 7 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt-            runStmt store $ Session.statement (subName, 1 :: Int32, 13 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt-            m0 <- runStmt store $ Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt-            m1 <- runStmt store $ Session.statement (subName, 1 :: Int32) SQL.getCheckpointMemberStmt-            m0 `shouldBe` Just 7-            m1 `shouldBe` Just 13+    it "stores and reads independent checkpoints per member" $ \store -> do+      runStmt store $ Session.statement (subName, 0 :: Int32, 7 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt+      runStmt store $ Session.statement (subName, 1 :: Int32, 13 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt+      m0 <- runStmt store $ Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt+      m1 <- runStmt store $ Session.statement (subName, 1 :: Int32) SQL.getCheckpointMemberStmt+      m0 `shouldBe` Just 7+      m1 `shouldBe` Just 13 -        it "never moves a member checkpoint backward (GREATEST monotonicity)" $ \store -> do-            runStmt store $ Session.statement (subName, 0 :: Int32, 20 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt-            runStmt store $ Session.statement (subName, 0 :: Int32, 5 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt-            m0 <- runStmt store $ Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt-            m0 `shouldBe` Just 20+    it "never moves a member checkpoint backward (GREATEST monotonicity)" $ \store -> do+      runStmt store $ Session.statement (subName, 0 :: Int32, 20 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt+      runStmt store $ Session.statement (subName, 0 :: Int32, 5 :: Int64, 2 :: Int32, "unbound", Nothing) SQL.saveCheckpointMemberStmt+      m0 <- runStmt store $ Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt+      m0 `shouldBe` Just 20 -        it "missing member checkpoint reads as Nothing" $ \store -> do-            m9 <- runStmt store $ Session.statement (subName, 9 :: Int32) SQL.getCheckpointMemberStmt-            m9 `shouldBe` Nothing+    it "missing member checkpoint reads as Nothing" $ \store -> do+      m9 <- runStmt store $ Session.statement (subName, 9 :: Int32) SQL.getCheckpointMemberStmt+      m9 `shouldBe` Nothing -        it "the existing name-keyed checkpoint statements still round-trip (as member 0)" $ \store -> do-            runStmt store $ Session.statement (subName, 42 :: Int64) SQL.saveCheckpointStmt-            -- name-keyed read returns the same row (member 0)-            byName <- runStmt store $ Session.statement subName SQL.getCheckpointStmt-            byMember0 <- runStmt store $ Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt-            byName `shouldBe` Just 42-            byMember0 `shouldBe` Just 42+    it "the existing name-keyed checkpoint statements still round-trip (as member 0)" $ \store -> do+      runStmt store $ Session.statement (subName, 42 :: Int64) SQL.saveCheckpointStmt+      -- name-keyed read returns the same row (member 0)+      byName <- runStmt store $ Session.statement subName SQL.getCheckpointStmt+      byMember0 <- runStmt store $ Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt+      byName `shouldBe` Just 42+      byMember0 `shouldBe` Just 42  -- --------------------------------------------------------------------------- -- M3: $all partitioning@@ -214,58 +211,58 @@  allSpec :: Spec allSpec = around withTestStore $ do-    describe "$all consumer-group partitioning (size 4)" $ do-        -- \$all spans several categories; partitioning is by originating stream.-        let names =-                concatMap-                    (\c -> map (\i -> c <> "-" <> T.pack (show i)) [1 .. 20 :: Int])-                    ["acct", "user", "order"]-            size = 4 :: Int32+  describe "$all consumer-group partitioning (size 4)" $ do+    -- \$all spans several categories; partitioning is by originating stream.+    let names =+          concatMap+            (\c -> map (\i -> c <> "-" <> T.pack (show i)) [1 .. 20 :: Int])+            ["acct", "user", "order"]+        size = 4 :: Int32 -        let readMember store m =-                runStmt store $-                    Session.statement (0 :: Int64, m, size, bigLimit) SQL.readAllForwardConsumerGroupStmt-            readFull store =-                runStmt store $-                    Session.statement (0 :: Int64, bigLimit) SQL.readAllForwardStmt+    let readMember store m =+          runStmt store $+            Session.statement (0 :: Int64, m, size, bigLimit) SQL.readAllForwardConsumerGroupStmt+        readFull store =+          runStmt store $+            Session.statement (0 :: Int64, bigLimit) SQL.readAllForwardStmt -        it "splits $all into 4 pairwise-disjoint member slices" $ \store -> do-            seedStreams store names 2-            slices <- mapM (readMember store) [0 .. size - 1]-            let idSets = map (Set.fromList . eventIds) slices-                totalIds = sum (map Set.size idSets)-                unionIds = Set.size (Set.unions idSets)-            totalIds `shouldBe` unionIds+    it "splits $all into 4 pairwise-disjoint member slices" $ \store -> do+      seedStreams store names 2+      slices <- mapM (readMember store) [0 .. size - 1]+      let idSets = map (Set.fromList . eventIds) slices+          totalIds = sum (map Set.size idSets)+          unionIds = Set.size (Set.unions idSets)+      totalIds `shouldBe` unionIds -        it "union of all member slices equals the unpartitioned $all read" $ \store -> do-            seedStreams store names 2-            slices <- mapM (readMember store) [0 .. size - 1]-            full <- readFull store-            let unionIds = Set.unions (map (Set.fromList . eventIds) slices)-                fullIds = Set.fromList (eventIds full)-            unionIds `shouldBe` fullIds+    it "union of all member slices equals the unpartitioned $all read" $ \store -> do+      seedStreams store names 2+      slices <- mapM (readMember store) [0 .. size - 1]+      full <- readFull store+      let unionIds = Set.unions (map (Set.fromList . eventIds) slices)+          fullIds = Set.fromList (eventIds full)+      unionIds `shouldBe` fullIds -        it "every stream's events go to exactly one member, in ascending global position" $ \store -> do-            seedStreams store names 3-            slices <- mapM (readMember store) [0 .. size - 1]-            let perMemberStreams = map (Set.fromList . map fst . streamPositions) slices-                totalStreams = sum (map Set.size perMemberStreams)-                unionStreams = Set.size (Set.unions perMemberStreams)-            totalStreams `shouldBe` unionStreams-            mapM_-                ( \slice -> do-                    let ps = map snd (streamPositions slice)-                    ps `shouldBe` sort ps-                )-                slices+    it "every stream's events go to exactly one member, in ascending global position" $ \store -> do+      seedStreams store names 3+      slices <- mapM (readMember store) [0 .. size - 1]+      let perMemberStreams = map (Set.fromList . map fst . streamPositions) slices+          totalStreams = sum (map Set.size perMemberStreams)+          unionStreams = Set.size (Set.unions perMemberStreams)+      totalStreams `shouldBe` unionStreams+      mapM_+        ( \slice -> do+            let ps = map snd (streamPositions slice)+            ps `shouldBe` sort ps+        )+        slices -        it "size 1 is equivalent to an unpartitioned $all read" $ \store -> do-            seedStreams store names 2-            one <--                runStmt store $-                    Session.statement (0 :: Int64, 0 :: Int32, 1 :: Int32, bigLimit) SQL.readAllForwardConsumerGroupStmt-            full <- readFull store-            eventIds one `shouldBe` eventIds full+    it "size 1 is equivalent to an unpartitioned $all read" $ \store -> do+      seedStreams store names 2+      one <-+        runStmt store $+          Session.statement (0 :: Int64, 0 :: Int32, 1 :: Int32, bigLimit) SQL.readAllForwardConsumerGroupStmt+      full <- readFull store+      eventIds one `shouldBe` eventIds full  -- --------------------------------------------------------------------------- -- The partition rule, pinned directly.@@ -273,15 +270,15 @@  memberOfSpec :: Spec memberOfSpec = around withTestStore $ do-    describe "member_of assignment rule" $ do-        it "returns a member index in [0, size) for every stream" $ \store -> do-            let names = map (\i -> "rule-" <> T.pack (show i)) [1 .. 30 :: Int]-            seedStreams store names 1-            full <- runStmt store $ Session.statement (0 :: Int64, bigLimit) SQL.readAllForwardStmt-            let sids = distinctStreamIds full-            mapM_-                ( \sid -> do-                    m <- runMemberOf store sid 4-                    m `shouldSatisfy` (\x -> x >= 0 && x < 4)-                )-                sids+  describe "member_of assignment rule" $ do+    it "returns a member index in [0, size) for every stream" $ \store -> do+      let names = map (\i -> "rule-" <> T.pack (show i)) [1 .. 30 :: Int]+      seedStreams store names 1+      full <- runStmt store $ Session.statement (0 :: Int64, bigLimit) SQL.readAllForwardStmt+      let sids = distinctStreamIds full+      mapM_+        ( \sid -> do+            m <- runMemberOf store sid 4+            m `shouldSatisfy` (\x -> x >= 0 && x < 4)+        )+        sids
test/Test/DeadLetterQueryPlans.hs view
@@ -25,74 +25,80 @@  spec :: Spec spec = describe "dead-letter production query plans" $ around withTestStore $ it "bounds historical-member enumeration and first/later pages in generic/custom plans as history grows" $ \store -> do-    execute store "INSERT INTO events(event_id,event_type,data,created_at) VALUES ('00000000-0000-0000-0000-000000000001','E','{}',now())"-    forM_ ["1000", "20000"] $ \history -> do-        execute store ("INSERT INTO dead_letters(subscription_name,consumer_group_member,global_position,event_id,reason,reason_summary,attempt_count) SELECT 'plans',m,n,'00000000-0000-0000-0000-000000000001','{}','fixture',1 FROM unnest(ARRAY[0,1,9]) m CROSS JOIN generate_series(1," <> history <> ") n ON CONFLICT DO NOTHING; ANALYZE dead_letters")-        forM_ ["force_generic_plan", "force_custom_plan"] $ \mode -> do-            memberFirst <- explain store mode "text,int4,int4" "'plans',9,6" SQL.listSubscriptionMemberDeadLettersFromStartStmt-            memberLater <- explain store mode "text,int4,int8,int8,int4" "'plans',9,500,9223372036854775807,6" SQL.listSubscriptionMemberDeadLettersStmt-            allFirst <- explain store mode "text,int4" "'plans',6" SQL.listSubscriptionDeadLettersFromStartStmt-            allLater <- explain store mode "text,int8,int8,int4" "'plans',500,9223372036854775807,6" SQL.listSubscriptionDeadLettersStmt-            forM_ [memberFirst, memberLater] $ \plan -> do-                expect ("index" :: Text) plan ("ix_dead_letters_subscription_position" `elem` texts "Index Name" plan)-                expect "member page work" plan (scanned plan <= 6)-                expect "no member sort" plan ("Sort" `notElem` texts "Node Type" plan)-            forM_ [allFirst, allLater] $ \plan -> do-                -- Three loose member probes plus three bounded lateral scans.-                expect "bounded member enumeration/page" plan (scanned plan <= 24)-                expect "bounded merge input" plan (sum (sortInputs plan) <= 18)-                -- The existing natural-key index has the same (name, member)-                -- prefix and can serve the loose member probes too.-                expect "existing ordered indexes only" plan (all (`elem` ["ix_dead_letters_subscription_position", "dead_letters_subscription_name_consumer_group_member_global_key"]) (texts "Index Name" plan))-                expect "ordered historical member advance" plan (any ("consumer_group_member >" `contains`) (texts "Index Cond" plan))-                expect "recency index for lateral pages" plan ("ix_dead_letters_subscription_position" `elem` texts "Index Name" plan)-                expect "no historical sequential scan" plan ("Seq Scan" `notElem` texts "Node Type" plan)-            forM_ [memberLater, allLater] $ \plan ->-                expect "tuple cursor in index condition" plan (any (\condition -> "ROW(global_position, dead_letter_id)" `contains` condition) (texts "Index Cond" plan))-            forM_ [memberFirst, memberLater, allFirst, allLater] $ \plan ->-                expect "bounded shared buffers" plan (buffers plan <= 128)-            -- Preserve the actual production EXPLAIN evidence in the test log.-            mapM_ (LBS.putStrLn . Aeson.encode) [memberFirst, memberLater, allFirst, allLater]+  execute store "INSERT INTO events(event_id,event_type,data,created_at) VALUES ('00000000-0000-0000-0000-000000000001','E','{}',now())"+  forM_ ["1000", "20000"] $ \history -> do+    execute store ("INSERT INTO dead_letters(subscription_name,consumer_group_member,global_position,event_id,reason,reason_summary,attempt_count) SELECT 'plans',m,n,'00000000-0000-0000-0000-000000000001','{}','fixture',1 FROM unnest(ARRAY[0,1,9]) m CROSS JOIN generate_series(1," <> history <> ") n ON CONFLICT DO NOTHING; ANALYZE dead_letters")+    forM_ ["force_generic_plan", "force_custom_plan"] $ \mode -> do+      memberFirst <- explain store mode "text,int4,int4" "'plans',9,6" SQL.listSubscriptionMemberDeadLettersFromStartStmt+      memberLater <- explain store mode "text,int4,int8,int8,int4" "'plans',9,500,9223372036854775807,6" SQL.listSubscriptionMemberDeadLettersStmt+      allFirst <- explain store mode "text,int4" "'plans',6" SQL.listSubscriptionDeadLettersFromStartStmt+      allLater <- explain store mode "text,int8,int8,int4" "'plans',500,9223372036854775807,6" SQL.listSubscriptionDeadLettersStmt+      forM_ [memberFirst, memberLater] $ \plan -> do+        expect ("index" :: Text) plan ("ix_dead_letters_subscription_position" `elem` texts "Index Name" plan)+        expect "member page work" plan (scanned plan <= 6)+        expect "no member sort" plan ("Sort" `notElem` texts "Node Type" plan)+      forM_ [allFirst, allLater] $ \plan -> do+        -- Three loose member probes plus three bounded lateral scans.+        expect "bounded member enumeration/page" plan (scanned plan <= 24)+        expect "bounded merge input" plan (sum (sortInputs plan) <= 18)+        -- The existing natural-key index has the same (name, member)+        -- prefix and can serve the loose member probes too.+        expect "existing ordered indexes only" plan (all (`elem` ["ix_dead_letters_subscription_position", "dead_letters_subscription_name_consumer_group_member_global_key"]) (texts "Index Name" plan))+        expect "ordered historical member advance" plan (any ("consumer_group_member >" `contains`) (texts "Index Cond" plan))+        expect "recency index for lateral pages" plan ("ix_dead_letters_subscription_position" `elem` texts "Index Name" plan)+        expect "no historical sequential scan" plan ("Seq Scan" `notElem` texts "Node Type" plan)+      forM_ [memberLater, allLater] $ \plan ->+        expect "tuple cursor in index condition" plan (any (\condition -> "ROW(global_position, dead_letter_id)" `contains` condition) (texts "Index Cond" plan))+      forM_ [memberFirst, memberLater, allFirst, allLater] $ \plan ->+        expect "bounded shared buffers" plan (buffers plan <= 128)+      -- Preserve the actual production EXPLAIN evidence in the test log.+      mapM_ (LBS.putStrLn . Aeson.encode) [memberFirst, memberLater, allFirst, allLater]   where     contains = Data.Text.isInfixOf  execute :: KirokuStore -> Text -> IO () execute store sql = Pool.use (store ^. #pool) (Session.script sql) >>= either (fail . show) pure+ explain :: KirokuStore -> Text -> Text -> Text -> Statement p r -> IO Value explain store mode types args statement = do-    Right bytes <- Pool.use (store ^. #pool) $ do-        Session.script ("SET plan_cache_mode=" <> mode <> "; PREPARE mp13_deadletters(" <> types <> ") AS " <> Statement.toSql statement)-        bytes <- Session.statement () (unpreparable ("EXPLAIN (ANALYZE,BUFFERS,TIMING OFF,FORMAT JSON) EXECUTE mp13_deadletters(" <> args <> ")") E.noParams (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right)))))-        Session.script "DEALLOCATE mp13_deadletters; RESET plan_cache_mode"-        pure bytes-    either fail pure (Aeson.eitherDecodeStrict' bytes)+  Right bytes <- Pool.use (store ^. #pool) $ do+    Session.script ("SET plan_cache_mode=" <> mode <> "; PREPARE mp13_deadletters(" <> types <> ") AS " <> Statement.toSql statement)+    bytes <- Session.statement () (unpreparable ("EXPLAIN (ANALYZE,BUFFERS,TIMING OFF,FORMAT JSON) EXECUTE mp13_deadletters(" <> args <> ")") E.noParams (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right)))))+    Session.script "DEALLOCATE mp13_deadletters; RESET plan_cache_mode"+    pure bytes+  either fail pure (Aeson.eitherDecodeStrict' bytes)+ expect :: Text -> Value -> Bool -> Expectation expect label plan ok = if ok then pure () else expectationFailure (show label <> ": " <> show plan)+ texts :: Aeson.Key -> Value -> [Text] texts key (Object fields) = [s | Just (String s) <- [KM.lookup key fields]] <> concatMap (texts key) (KM.elems fields) texts key (Array values) = concatMap (texts key) values texts _ _ = []+ number :: Aeson.Key -> Aeson.Object -> Double number key fields = case KM.lookup key fields of Just (Number n) -> realToFrac n; _ -> 0+ scanned :: Value -> Double scanned (Object fields) = current + sum (map scanned (KM.elems fields))   where     current = case KM.lookup "Relation Name" fields of-        Just (String "dead_letters") -> (number "Actual Rows" fields + number "Rows Removed by Filter" fields + number "Rows Removed by Index Recheck" fields) * number "Actual Loops" fields-        _ -> 0+      Just (String "dead_letters") -> (number "Actual Rows" fields + number "Rows Removed by Filter" fields + number "Rows Removed by Index Recheck" fields) * number "Actual Loops" fields+      _ -> 0 scanned (Array values) = sum (map scanned (foldr (:) [] values)) scanned _ = 0+ sortInputs :: Value -> [Double] sortInputs (Object fields) = current <> concatMap sortInputs (KM.elems fields)   where     current = case (KM.lookup "Node Type" fields, KM.lookup "Plans" fields) of-        (Just (String "Sort"), Just (Array children)) -> [number "Actual Rows" child * number "Actual Loops" child | Object child <- foldr (:) [] children]-        _ -> []+      (Just (String "Sort"), Just (Array children)) -> [number "Actual Rows" child * number "Actual Loops" child | Object child <- foldr (:) [] children]+      _ -> [] sortInputs (Array values) = concatMap sortInputs values sortInputs _ = []  buffers :: Value -> Double buffers (Array values) = case foldr (:) [] values of-    Object entry : _ | Just (Object top) <- KM.lookup "Plan" entry -> number "Shared Hit Blocks" top + number "Shared Read Blocks" top-    _ -> 1 / 0+  Object entry : _ | Just (Object top) <- KM.lookup "Plan" entry -> number "Shared Hit Blocks" top + number "Shared Read Blocks" top+  _ -> 1 / 0 buffers _ = 1 / 0
test/Test/EventTypeFilter.hs view
@@ -1,38 +1,37 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Regression tests for EP-43 (MasterPlan 6, child plan 4) — per-subscription-event-type filtering in @kiroku-store@.--These pin the behavioral acceptance criteria from the ExecPlan:--  * __selective delivery__: a subscription with-    @'eventTypeFilter' = 'OnlyEventTypes' {A}@ over a mixed @A@\/@B@ stream-    delivers only the @A@ events to its handler;-  * __checkpoint advances past filtered events__: the persisted @last_seen@-    equals the global position of the /last event of any type/, including a-    trailing filtered-out event, so the subscription never re-scans skipped-    events after a restart;-  * __no stall__: a long run of non-matching events (1 @A@, 1000 @B@, 1 @A@)-    does not hang the subscription — both @A@s are delivered and the checkpoint-    reaches 1002;-  * __filter before dead-letter__: a handler that dead-letters /every/ event it-    sees, behind a filter that admits no events in the stream, is never invoked,-    writes no dead-letter rows, and still advances the checkpoint.--They also cover the opaque 'selector' escape hatch (a @RecordedEvent -> Bool@-predicate the type set cannot express, e.g. on payload\/metadata):--  * __selector no-stall__: a selector over a payload tag delivers only matching-    events and advances the checkpoint past a long run of rejected ones;-  * __AND composition__: 'selector' and 'eventTypeFilter' compose as a logical-    AND — an event is delivered only when it passes both.--All use an 'AllStreams' subscription (the FSM 'DeliverBatch' delivery path).-Events are appended and the publisher is awaited before subscribing, so delivery-happens during catch-up; the worker applies the filter in 'processEvents' before-the handler call.--}+-- | Regression tests for EP-43 (MasterPlan 6, child plan 4) — per-subscription+-- event-type filtering in @kiroku-store@.+--+-- These pin the behavioral acceptance criteria from the ExecPlan:+--+--   * __selective delivery__: a subscription with+--     @'eventTypeFilter' = 'OnlyEventTypes' {A}@ over a mixed @A@\/@B@ stream+--     delivers only the @A@ events to its handler;+--   * __checkpoint advances past filtered events__: the persisted @last_seen@+--     equals the global position of the /last event of any type/, including a+--     trailing filtered-out event, so the subscription never re-scans skipped+--     events after a restart;+--   * __no stall__: a long run of non-matching events (1 @A@, 1000 @B@, 1 @A@)+--     does not hang the subscription — both @A@s are delivered and the checkpoint+--     reaches 1002;+--   * __filter before dead-letter__: a handler that dead-letters /every/ event it+--     sees, behind a filter that admits no events in the stream, is never invoked,+--     writes no dead-letter rows, and still advances the checkpoint.+--+-- They also cover the opaque 'selector' escape hatch (a @RecordedEvent -> Bool@+-- predicate the type set cannot express, e.g. on payload\/metadata):+--+--   * __selector no-stall__: a selector over a payload tag delivers only matching+--     events and advances the checkpoint past a long run of rejected ones;+--   * __AND composition__: 'selector' and 'eventTypeFilter' compose as a logical+--     AND — an event is delivered only when it passes both.+--+-- All use an 'AllStreams' subscription (the FSM 'DeliverBatch' delivery path).+-- Events are appended and the publisher is awaited before subscribing, so delivery+-- happens during catch-up; the worker applies the filter in 'processEvents' before+-- the handler call. module Test.EventTypeFilter (spec) where  import Control.Concurrent (threadDelay)@@ -57,17 +56,17 @@  readCheckpoint :: KirokuStore -> Text -> IO (Maybe Int64) readCheckpoint store subName = do-    result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)-    case result of-        Left err -> error ("readCheckpoint failed: " <> show err)-        Right mPos -> pure mPos+  result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)+  case result of+    Left err -> error ("readCheckpoint failed: " <> show err)+    Right mPos -> pure mPos  readDeadLetters :: KirokuStore -> Text -> IO [SQL.DeadLetterRecord] readDeadLetters store subName = do-    result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.readDeadLettersStmt)-    case result of-        Left err -> error ("readDeadLetters failed: " <> show err)-        Right v -> pure (V.toList v)+  result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.readDeadLettersStmt)+  case result of+    Left err -> error ("readDeadLetters failed: " <> show err)+    Right v -> pure (V.toList v)  -- Poll the checkpoint until it reaches @target@ (or ~10s elapse). Because the -- worker saves the checkpoint at the batch tail only after every handler call in@@ -77,12 +76,12 @@ waitForCheckpoint store subName target = go (0 :: Int)   where     go n-        | n > 200 = pure False-        | otherwise = do-            mPos <- readCheckpoint store subName-            case mPos of-                Just p | p >= target -> pure True-                _ -> threadDelay 50_000 >> go (n + 1)+      | n > 200 = pure False+      | otherwise = do+          mPos <- readCheckpoint store subName+          case mPos of+            Just p | p >= target -> pure True+            _ -> threadDelay 50_000 >> go (n + 1)  mkE :: Text -> EventData mkE typ = makeEvent typ (Aeson.object [])@@ -102,205 +101,205 @@  spec :: Spec spec = describe "event-type filter" $ do-    it "delivers only matching types and advances the checkpoint past filtered events" $ do-        withTestStore $ \store -> do-            let subT = "etf-mixed" :: Text-                subName = SubscriptionName subT-            -- A, B, A, B, A on one stream -> global positions 1..5.-            Right _ <--                runStoreIO store $-                    appendToStream-                        (StreamName "etf-mixed-stream")-                        NoStream-                        [mkE "A", mkE "B", mkE "A", mkE "B", mkE "A"]-            waitForPublisher store (GlobalPosition 5)+  it "delivers only matching types and advances the checkpoint past filtered events" $ do+    withTestStore $ \store -> do+      let subT = "etf-mixed" :: Text+          subName = SubscriptionName subT+      -- A, B, A, B, A on one stream -> global positions 1..5.+      Right _ <-+        runStoreIO store $+          appendToStream+            (StreamName "etf-mixed-stream")+            NoStream+            [mkE "A", mkE "B", mkE "A", mkE "B", mkE "A"]+      waitForPublisher store (GlobalPosition 5) -            deliveredRef <- newIORef ([] :: [Text])-            let handler' ev = do-                    modifyIORef' deliveredRef (<> [typeOf ev])-                    pure Continue-                cfg =-                    (defaultSubscriptionConfig subName AllStreams handler')-                        { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])-                        }-            handle <- subscribe store cfg-            reached <- waitForCheckpoint store subT 5-            cancel handle+      deliveredRef <- newIORef ([] :: [Text])+      let handler' ev = do+            modifyIORef' deliveredRef (<> [typeOf ev])+            pure Continue+          cfg =+            (defaultSubscriptionConfig subName AllStreams handler')+              { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])+              }+      handle <- subscribe store cfg+      reached <- waitForCheckpoint store subT 5+      cancel handle -            reached `shouldBe` True-            delivered <- readIORef deliveredRef-            delivered `shouldBe` ["A", "A", "A"]-            readCheckpoint store subT `shouldReturn` Just 5+      reached `shouldBe` True+      delivered <- readIORef deliveredRef+      delivered `shouldBe` ["A", "A", "A"]+      readCheckpoint store subT `shouldReturn` Just 5 -    it "advances the checkpoint past a trailing filtered-out event" $ do-        withTestStore $ \store -> do-            let subT = "etf-trailing" :: Text-                subName = SubscriptionName subT-            -- A then B: the last event (B at position 2) is filtered out.-            Right _ <--                runStoreIO store $-                    appendToStream-                        (StreamName "etf-trailing-stream")-                        NoStream-                        [mkE "A", mkE "B"]-            waitForPublisher store (GlobalPosition 2)+  it "advances the checkpoint past a trailing filtered-out event" $ do+    withTestStore $ \store -> do+      let subT = "etf-trailing" :: Text+          subName = SubscriptionName subT+      -- A then B: the last event (B at position 2) is filtered out.+      Right _ <-+        runStoreIO store $+          appendToStream+            (StreamName "etf-trailing-stream")+            NoStream+            [mkE "A", mkE "B"]+      waitForPublisher store (GlobalPosition 2) -            deliveredRef <- newIORef ([] :: [Text])-            let handler' ev = do-                    modifyIORef' deliveredRef (<> [typeOf ev])-                    pure Continue-                cfg =-                    (defaultSubscriptionConfig subName AllStreams handler')-                        { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])-                        }-            handle <- subscribe store cfg-            reached <- waitForCheckpoint store subT 2-            cancel handle+      deliveredRef <- newIORef ([] :: [Text])+      let handler' ev = do+            modifyIORef' deliveredRef (<> [typeOf ev])+            pure Continue+          cfg =+            (defaultSubscriptionConfig subName AllStreams handler')+              { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])+              }+      handle <- subscribe store cfg+      reached <- waitForCheckpoint store subT 2+      cancel handle -            reached `shouldBe` True-            delivered <- readIORef deliveredRef-            delivered `shouldBe` ["A"]-            -- last_seen is 2 (the trailing B), proving the cursor advanced past-            -- a filtered-out event rather than stopping at the last delivered A.-            readCheckpoint store subT `shouldReturn` Just 2+      reached `shouldBe` True+      delivered <- readIORef deliveredRef+      delivered `shouldBe` ["A"]+      -- last_seen is 2 (the trailing B), proving the cursor advanced past+      -- a filtered-out event rather than stopping at the last delivered A.+      readCheckpoint store subT `shouldReturn` Just 2 -    it "never stalls on a long run of non-matching events (1 A, 1000 B, 1 A)" $ do-        withTestStore $ \store -> do-            let subT = "etf-nostall" :: Text-                subName = SubscriptionName subT-                events = [mkE "A"] <> replicate 1000 (mkE "B") <> [mkE "A"]-            Right _ <--                runStoreIO store $-                    appendToStream (StreamName "etf-nostall-stream") NoStream events-            waitForPublisher store (GlobalPosition 1002)+  it "never stalls on a long run of non-matching events (1 A, 1000 B, 1 A)" $ do+    withTestStore $ \store -> do+      let subT = "etf-nostall" :: Text+          subName = SubscriptionName subT+          events = [mkE "A"] <> replicate 1000 (mkE "B") <> [mkE "A"]+      Right _ <-+        runStoreIO store $+          appendToStream (StreamName "etf-nostall-stream") NoStream events+      waitForPublisher store (GlobalPosition 1002) -            deliveredRef <- newIORef ([] :: [Text])-            let handler' ev = do-                    modifyIORef' deliveredRef (<> [typeOf ev])-                    pure Continue-                cfg =-                    (defaultSubscriptionConfig subName AllStreams handler')-                        { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])-                        , batchSize = either (error . show) Prelude.id (mkBatchSize 2000)-                        }-            handle <- subscribe store cfg-            reached <- waitForCheckpoint store subT 1002-            cancel handle+      deliveredRef <- newIORef ([] :: [Text])+      let handler' ev = do+            modifyIORef' deliveredRef (<> [typeOf ev])+            pure Continue+          cfg =+            (defaultSubscriptionConfig subName AllStreams handler')+              { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"]),+                batchSize = either (error . show) Prelude.id (mkBatchSize 2000)+              }+      handle <- subscribe store cfg+      reached <- waitForCheckpoint store subT 1002+      cancel handle -            reached `shouldBe` True-            delivered <- readIORef deliveredRef-            -- Exactly the two As; the 1000 Bs advanced the cursor without delivery.-            delivered `shouldBe` ["A", "A"]-            readCheckpoint store subT `shouldReturn` Just 1002+      reached `shouldBe` True+      delivered <- readIORef deliveredRef+      -- Exactly the two As; the 1000 Bs advanced the cursor without delivery.+      delivered `shouldBe` ["A", "A"]+      readCheckpoint store subT `shouldReturn` Just 1002 -    it "does not dead-letter a filtered-out type even when the handler would" $ do-        withTestStore $ \store -> do-            let subT = "etf-no-dl" :: Text-                subName = SubscriptionName subT-            -- Only Bs; the filter admits only A, so the handler never runs.-            Right _ <--                runStoreIO store $-                    appendToStream-                        (StreamName "etf-no-dl-stream")-                        NoStream-                        [mkE "B", mkE "B", mkE "B", mkE "B", mkE "B"]-            waitForPublisher store (GlobalPosition 5)+  it "does not dead-letter a filtered-out type even when the handler would" $ do+    withTestStore $ \store -> do+      let subT = "etf-no-dl" :: Text+          subName = SubscriptionName subT+      -- Only Bs; the filter admits only A, so the handler never runs.+      Right _ <-+        runStoreIO store $+          appendToStream+            (StreamName "etf-no-dl-stream")+            NoStream+            [mkE "B", mkE "B", mkE "B", mkE "B", mkE "B"]+      waitForPublisher store (GlobalPosition 5) -            deliveredRef <- newIORef ([] :: [Text])-            let handler' ev = do-                    -- The handler would dead-letter everything it sees — but a-                    -- filtered-out event must never reach it.-                    modifyIORef' deliveredRef (<> [typeOf ev])-                    pure (DeadLetter (DeadLetterPoison "should-never-fire"))-                cfg =-                    (defaultSubscriptionConfig subName AllStreams handler')-                        { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])-                        }-            handle <- subscribe store cfg-            reached <- waitForCheckpoint store subT 5-            cancel handle+      deliveredRef <- newIORef ([] :: [Text])+      let handler' ev = do+            -- The handler would dead-letter everything it sees — but a+            -- filtered-out event must never reach it.+            modifyIORef' deliveredRef (<> [typeOf ev])+            pure (DeadLetter (DeadLetterPoison "should-never-fire"))+          cfg =+            (defaultSubscriptionConfig subName AllStreams handler')+              { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])+              }+      handle <- subscribe store cfg+      reached <- waitForCheckpoint store subT 5+      cancel handle -            reached `shouldBe` True-            delivered <- readIORef deliveredRef-            delivered `shouldBe` []-            -- No dead-letter rows were written for the filtered-out Bs, and the-            -- checkpoint still advanced past them.-            dls <- readDeadLetters store subT-            length dls `shouldBe` 0-            readCheckpoint store subT `shouldReturn` Just 5+      reached `shouldBe` True+      delivered <- readIORef deliveredRef+      delivered `shouldBe` []+      -- No dead-letter rows were written for the filtered-out Bs, and the+      -- checkpoint still advanced past them.+      dls <- readDeadLetters store subT+      length dls `shouldBe` 0+      readCheckpoint store subT `shouldReturn` Just 5 -    -- The opaque 'selector' escape hatch: a predicate over the whole-    -- 'RecordedEvent' (here, its payload) that the 'eventTypeFilter' type set-    -- cannot express. Same no-stall / checkpoint-advances guarantees apply.-    it "delivers only events matching an opaque selector and advances past the rest (no stall)" $ do-        withTestStore $ \store -> do-            let subT = "sel-nostall" :: Text-                subName = SubscriptionName subT-                -- All one type "A" (so eventTypeFilter cannot distinguish them);-                -- only the payload tag differs. keep, 1000 skips, keep.-                events =-                    [mkTagged "A" True]-                        <> replicate 1000 (mkTagged "A" False)-                        <> [mkTagged "A" True]-            Right _ <--                runStoreIO store $-                    appendToStream (StreamName "sel-nostall-stream") NoStream events-            waitForPublisher store (GlobalPosition 1002)+  -- The opaque 'selector' escape hatch: a predicate over the whole+  -- 'RecordedEvent' (here, its payload) that the 'eventTypeFilter' type set+  -- cannot express. Same no-stall / checkpoint-advances guarantees apply.+  it "delivers only events matching an opaque selector and advances past the rest (no stall)" $ do+    withTestStore $ \store -> do+      let subT = "sel-nostall" :: Text+          subName = SubscriptionName subT+          -- All one type "A" (so eventTypeFilter cannot distinguish them);+          -- only the payload tag differs. keep, 1000 skips, keep.+          events =+            [mkTagged "A" True]+              <> replicate 1000 (mkTagged "A" False)+              <> [mkTagged "A" True]+      Right _ <-+        runStoreIO store $+          appendToStream (StreamName "sel-nostall-stream") NoStream events+      waitForPublisher store (GlobalPosition 1002) -            deliveredRef <- newIORef ([] :: [Int64])-            let handler' ev = do-                    modifyIORef' deliveredRef (<> [posOf ev])-                    pure Continue-                cfg =-                    (defaultSubscriptionConfig subName AllStreams handler')-                        { selector = Just keepOnly-                        , batchSize = either (error . show) Prelude.id (mkBatchSize 2000)-                        }-            handle <- subscribe store cfg-            reached <- waitForCheckpoint store subT 1002-            cancel handle+      deliveredRef <- newIORef ([] :: [Int64])+      let handler' ev = do+            modifyIORef' deliveredRef (<> [posOf ev])+            pure Continue+          cfg =+            (defaultSubscriptionConfig subName AllStreams handler')+              { selector = Just keepOnly,+                batchSize = either (error . show) Prelude.id (mkBatchSize 2000)+              }+      handle <- subscribe store cfg+      reached <- waitForCheckpoint store subT 1002+      cancel handle -            reached `shouldBe` True-            delivered <- readIORef deliveredRef-            -- Only the two keep events (positions 1 and 1002); the 1000 skips-            -- advanced the cursor without delivery.-            delivered `shouldBe` [1, 1002]-            readCheckpoint store subT `shouldReturn` Just 1002+      reached `shouldBe` True+      delivered <- readIORef deliveredRef+      -- Only the two keep events (positions 1 and 1002); the 1000 skips+      -- advanced the cursor without delivery.+      delivered `shouldBe` [1, 1002]+      readCheckpoint store subT `shouldReturn` Just 1002 -    it "composes the selector with eventTypeFilter as a logical AND" $ do-        withTestStore $ \store -> do-            let subT = "sel-and-type" :: Text-                subName = SubscriptionName subT-                -- A/keep, A/skip, B/keep, B/skip, A/keep -> positions 1..5.-                -- Only events that are type A AND keep survive: positions 1, 5.-                events =-                    [ mkTagged "A" True-                    , mkTagged "A" False-                    , mkTagged "B" True-                    , mkTagged "B" False-                    , mkTagged "A" True-                    ]-            Right _ <--                runStoreIO store $-                    appendToStream (StreamName "sel-and-type-stream") NoStream events-            waitForPublisher store (GlobalPosition 5)+  it "composes the selector with eventTypeFilter as a logical AND" $ do+    withTestStore $ \store -> do+      let subT = "sel-and-type" :: Text+          subName = SubscriptionName subT+          -- A/keep, A/skip, B/keep, B/skip, A/keep -> positions 1..5.+          -- Only events that are type A AND keep survive: positions 1, 5.+          events =+            [ mkTagged "A" True,+              mkTagged "A" False,+              mkTagged "B" True,+              mkTagged "B" False,+              mkTagged "A" True+            ]+      Right _ <-+        runStoreIO store $+          appendToStream (StreamName "sel-and-type-stream") NoStream events+      waitForPublisher store (GlobalPosition 5) -            deliveredRef <- newIORef ([] :: [Int64])-            let handler' ev = do-                    modifyIORef' deliveredRef (<> [posOf ev])-                    pure Continue-                cfg =-                    (defaultSubscriptionConfig subName AllStreams handler')-                        { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"])-                        , selector = Just keepOnly-                        }-            handle <- subscribe store cfg-            reached <- waitForCheckpoint store subT 5-            cancel handle+      deliveredRef <- newIORef ([] :: [Int64])+      let handler' ev = do+            modifyIORef' deliveredRef (<> [posOf ev])+            pure Continue+          cfg =+            (defaultSubscriptionConfig subName AllStreams handler')+              { eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "A"]),+                selector = Just keepOnly+              }+      handle <- subscribe store cfg+      reached <- waitForCheckpoint store subT 5+      cancel handle -            reached `shouldBe` True-            delivered <- readIORef deliveredRef-            -- B/keep (pos 3) is rejected by the type filter; A/skip (pos 2) by the-            -- selector. Only A-and-keep at 1 and 5 are delivered.-            delivered `shouldBe` [1, 5]-            readCheckpoint store subT `shouldReturn` Just 5+      reached `shouldBe` True+      delivered <- readIORef deliveredRef+      -- B/keep (pos 3) is rejected by the type filter; A/skip (pos 2) by the+      -- selector. Only A-and-keep at 1 and 5 are delivered.+      delivered `shouldBe` [1, 5]+      readCheckpoint store subT `shouldReturn` Just 5
test/Test/FailureInjection.hs view
@@ -1,24 +1,23 @@-{- | Failure-injection tests for kiroku-store.--Each test deliberately disrupts a runtime dependency (the LISTEN-connection, the connection pool) and asserts the system recovers per its-documented contract.--Currently covers:--  * F14: Listener killed during a window where an event is appended.-    The event is still delivered after the listener reconnects, because-    the publisher re-queries the database from its last position when-    the next NOTIFY arrives — no safety-poll wait needed.--F15 (pool exhaustion → 'PoolAcquisitionTimeout') is deferred until the-'acquisitionTimeout' field is exposed in 'ConnectionSettings'. The-mapping itself ('Kiroku.Store.Error.mapUsageError'-@AcquisitionTimeoutUsageError -> PoolAcquisitionTimeout@) is-straightforward; without a way to set a sub-second acquisition timeout-from the test, the test would either hang (default unbounded timeout)-or require modifying the production type. See the EP-6 plan's M2 notes.--}+-- | Failure-injection tests for kiroku-store.+--+-- Each test deliberately disrupts a runtime dependency (the LISTEN+-- connection, the connection pool) and asserts the system recovers per its+-- documented contract.+--+-- Currently covers:+--+--   * F14: Listener killed during a window where an event is appended.+--     The event is still delivered after the listener reconnects, because+--     the publisher re-queries the database from its last position when+--     the next NOTIFY arrives — no safety-poll wait needed.+--+-- F15 (pool exhaustion → 'PoolAcquisitionTimeout') is deferred until the+-- 'acquisitionTimeout' field is exposed in 'ConnectionSettings'. The+-- mapping itself ('Kiroku.Store.Error.mapUsageError'+-- @AcquisitionTimeoutUsageError -> PoolAcquisitionTimeout@) is+-- straightforward; without a way to set a sub-second acquisition timeout+-- from the test, the test would either hang (default unbounded timeout)+-- or require modifying the production type. See the EP-6 plan's M2 notes. module Test.FailureInjection (spec) where  import Control.Concurrent.STM (atomically, newTVarIO, readTVar, retry, writeTVar)@@ -27,90 +26,90 @@ import Data.Generics.Labels () import Data.IORef (modifyIORef', newIORef, readIORef) import Kiroku.Store-import Test.Helpers (-    makeEvent,+import Test.Helpers+  ( makeEvent,     terminateBackend,     waitForListenerPid,     waitForListenerPidNotEqual,     waitWithTimeout,     withTestStoreSettings,- )+  ) import Test.Hspec  spec :: Spec spec = describe "kiroku-store failure injection" $ do-    -- F14 — Listener killed during a window where an event is-    -- appended. The publisher's broadcast loop pulls events directly-    -- from PostgreSQL when the next NOTIFY arrives, so the-    -- down-window event is delivered as soon as a post-reconnect-    -- event triggers a tick. No 30-second safety-poll wait required.-    it "delivers events appended during listener-down window after reconnect (F14)" $ \() -> do-        ref <- newIORef ([] :: [RecordedEvent])-        countVar <- newTVarIO (0 :: Int)-        let handler' evt = do-                modifyIORef' ref (evt :)-                n <- atomically $ do-                    c <- readTVar countVar-                    let c' = c + 1-                    writeTVar countVar c'-                    pure c'-                if n >= 3 then pure Stop else pure Continue-        let cfg =-                SubscriptionConfig-                    { name = SubscriptionName "f14-down-window"-                    , target = AllStreams-                    , handler = handler'-                    , batchSize = defaultBatchSize-                    , queueCapacity = 16-                    , overflowPolicy = DropSubscription-                    , consumerGroup = Nothing-                    , consumerGroupGuard = False-                    , missingCheckpointPolicy = FromBeginning-                    , targetBindingPolicy = AdoptUnbound-                    , undecodableHandler = Nothing-                    , handlerStallWarnAfter = Nothing-                    , retryPolicy = defaultRetryPolicy-                    , eventTypeFilter = AllEventTypes-                    , selector = Nothing-                    }-        withTestStoreSettings (& #eventHandler .~ Nothing) $ \store -> do-            handle <- subscribe store cfg-            -- Pre-event: confirm the subscription is alive and the-            -- listener has reached pg_stat_activity.-            Right _ <- runStoreIO store $ appendToStream (StreamName "f14-pre") NoStream [makeEvent "Pre" (Aeson.object [])]-            atomically $ do-                c <- readTVar countVar-                if c >= 1 then pure () else retry-            -- Find listener pid, terminate it. The listener loop-            -- will reconnect with a fresh pid.-            ---            -- NOTE: pg_terminate_backend is the "administrator command"-            -- that makes PostgreSQL report-            --   FATAL: terminating connection due to administrator command-            -- on the targeted backend. The kiroku-listener's libpq socket-            -- (inside hasql-notifications' waitForNotifications) receives-            -- that as it dies, so that FATAL line on stderr during this-            -- test is EXPECTED — it is the fault we are injecting, not a-            -- connection leak or a teardown-ordering bug. (log_min_messages-            -- is already PANIC, so this is the client seeing a server--            -- initiated termination, not server-side logging.)-            pid1 <- waitForListenerPid (store ^. #pool) 5_000_000-            terminateBackend (store ^. #pool) pid1-            -- Append the during-down-window event. No NOTIFY reaches-            -- the publisher right now because the listener is-            -- disconnected.-            Right _ <- runStoreIO store $ appendToStream (StreamName "f14-during") NoStream [makeEvent "During" (Aeson.object [])]-            -- Wait for the listener to come back with a fresh pid.-            _ <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000-            -- Append the post-reconnect event. The new NOTIFY-            -- triggers the publisher to fetch from its last position-            -- — which picks up both the during-down event AND this-            -- one.-            Right _ <- runStoreIO store $ appendToStream (StreamName "f14-after") NoStream [makeEvent "After" (Aeson.object [])]-            result <- waitWithTimeout 30_000_000 handle-            case result of-                Left timeout -> expectationFailure timeout-                Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                Right (Right ()) -> pure ()-            collected <- readIORef ref-            length collected `shouldBe` 3+  -- F14 — Listener killed during a window where an event is+  -- appended. The publisher's broadcast loop pulls events directly+  -- from PostgreSQL when the next NOTIFY arrives, so the+  -- down-window event is delivered as soon as a post-reconnect+  -- event triggers a tick. No 30-second safety-poll wait required.+  it "delivers events appended during listener-down window after reconnect (F14)" $ \() -> do+    ref <- newIORef ([] :: [RecordedEvent])+    countVar <- newTVarIO (0 :: Int)+    let handler' evt = do+          modifyIORef' ref (evt :)+          n <- atomically $ do+            c <- readTVar countVar+            let c' = c + 1+            writeTVar countVar c'+            pure c'+          if n >= 3 then pure Stop else pure Continue+    let cfg =+          SubscriptionConfig+            { name = SubscriptionName "f14-down-window",+              target = AllStreams,+              handler = handler',+              batchSize = defaultBatchSize,+              queueCapacity = 16,+              overflowPolicy = DropSubscription,+              consumerGroup = Nothing,+              consumerGroupGuard = False,+              missingCheckpointPolicy = FromBeginning,+              targetBindingPolicy = AdoptUnbound,+              undecodableHandler = Nothing,+              handlerStallWarnAfter = Nothing,+              retryPolicy = defaultRetryPolicy,+              eventTypeFilter = AllEventTypes,+              selector = Nothing+            }+    withTestStoreSettings (& #eventHandler .~ Nothing) $ \store -> do+      handle <- subscribe store cfg+      -- Pre-event: confirm the subscription is alive and the+      -- listener has reached pg_stat_activity.+      Right _ <- runStoreIO store $ appendToStream (StreamName "f14-pre") NoStream [makeEvent "Pre" (Aeson.object [])]+      atomically $ do+        c <- readTVar countVar+        if c >= 1 then pure () else retry+      -- Find listener pid, terminate it. The listener loop+      -- will reconnect with a fresh pid.+      --+      -- NOTE: pg_terminate_backend is the "administrator command"+      -- that makes PostgreSQL report+      --   FATAL: terminating connection due to administrator command+      -- on the targeted backend. The kiroku-listener's libpq socket+      -- (inside hasql-notifications' waitForNotifications) receives+      -- that as it dies, so that FATAL line on stderr during this+      -- test is EXPECTED — it is the fault we are injecting, not a+      -- connection leak or a teardown-ordering bug. (log_min_messages+      -- is already PANIC, so this is the client seeing a server-+      -- initiated termination, not server-side logging.)+      pid1 <- waitForListenerPid (store ^. #pool) 5_000_000+      terminateBackend (store ^. #pool) pid1+      -- Append the during-down-window event. No NOTIFY reaches+      -- the publisher right now because the listener is+      -- disconnected.+      Right _ <- runStoreIO store $ appendToStream (StreamName "f14-during") NoStream [makeEvent "During" (Aeson.object [])]+      -- Wait for the listener to come back with a fresh pid.+      _ <- waitForListenerPidNotEqual (store ^. #pool) pid1 15_000_000+      -- Append the post-reconnect event. The new NOTIFY+      -- triggers the publisher to fetch from its last position+      -- — which picks up both the during-down event AND this+      -- one.+      Right _ <- runStoreIO store $ appendToStream (StreamName "f14-after") NoStream [makeEvent "After" (Aeson.object [])]+      result <- waitWithTimeout 30_000_000 handle+      case result of+        Left timeout -> expectationFailure timeout+        Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+        Right (Right ()) -> pure ()+      collected <- readIORef ref+      length collected `shouldBe` 3
test/Test/HandlerStall.hs view
@@ -7,6 +7,7 @@ import Control.Lens ((&), (.~), (^.)) import Control.Monad (forM_) import Data.Aeson qualified as Aeson+import Data.IORef (atomicModifyIORef', newIORef, readIORef) import Data.Maybe (isNothing) import Data.Time (NominalDiffTime) import Data.Vector qualified as V@@ -21,234 +22,245 @@  appendEvents :: KirokuStore -> IO () appendEvents store = do-    Right _ <--        runStoreIO store $-            appendToStream-                (StreamName "stall-events")-                NoStream-                [makeEvent "First" (Aeson.object []), makeEvent "Second" (Aeson.object [])]-    pure ()+  Right _ <-+    runStoreIO store $+      appendToStream+        (StreamName "stall-events")+        NoStream+        [makeEvent "First" (Aeson.object []), makeEvent "Second" (Aeson.object [])]+  pure ()  checkpoint :: KirokuStore -> IO GlobalPosition checkpoint store = do-    Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory-    case [savedPosition | SubscriptionCheckpoint name _ savedPosition _ <- V.toList rows, name == SubscriptionName "stall-test"] of-        [savedPosition] -> pure savedPosition-        other -> fail ("unexpected checkpoints: " <> show other)+  Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory+  case [savedPosition | SubscriptionCheckpoint name _ savedPosition _ <- V.toList rows, name == SubscriptionName "stall-test"] of+    [savedPosition] -> pure savedPosition+    other -> fail ("unexpected checkpoints: " <> show other)  spec :: Spec spec = describe "handler stall" $ do-    forM_-        [ (AllStreams, Nothing, False)-        , (AllStreams, Nothing, True)-        , (Category (CategoryName "stall"), Nothing, True)-        , (AllStreams, Just (validConsumerGroup 0 1), True)-        , (Category (CategoryName "stall"), Just (validConsumerGroup 0 1), False)-        ]-        $ \(target', group, live) ->-            it ("warns without acknowledging or checkpointing a blocked handler for " <> show (target', group, live)) $ do-                release <- newEmptyMVar-                caughtUp <- newEmptyMVar-                warning <- STM.newEmptyTMVarIO-                count <- STM.newTVarIO (0 :: Int)-                let subName = SubscriptionName "stall-test"-                    observe event = do-                        caughtUpEventHandler subName caughtUp Nothing event-                        case event of-                            KirokuEventSubscriptionHandlerStalled name pos eid elapsed groupContext | name == subName -> STM.atomically $ do-                                STM.modifyTVar' count (+ 1)-                                _ <- STM.tryPutTMVar warning (pos, eid, elapsed, groupContext)-                                pure ()-                            _ -> pure ()-                    tweak settings = settings & #eventHandler .~ Just observe-                    handler event =-                        if event ^. #globalPosition == GlobalPosition 1-                            then takeMVar release >> pure Continue-                            else pure Stop-                withTestStoreSettings tweak $ \store -> do-                    if live then pure () else appendEvents store >> waitForPublisher store (GlobalPosition 2)-                    handle <--                        subscribe-                            store-                            ( (defaultSubscriptionConfig subName target' handler)-                                { consumerGroup = group-                                , handlerStallWarnAfter = Just 0.05-                                }-                            )-                    if live then waitForSubscriptionLive caughtUp >> appendEvents store else pure ()-                    (pos, eid, elapsed, groupContext) <- within (STM.atomically (STM.readTMVar warning))-                    pos `shouldBe` GlobalPosition 1-                    Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 1)-                    eid `shouldBe` (V.head events ^. #eventId)-                    elapsed `shouldSatisfy` (>= 0.05)-                    groupContext `shouldBe` maybe NonGroup (\_ -> GroupMember 0 1) group-                    checkpoint store `shouldReturn` GlobalPosition 0-                    putMVar release ()-                    within (wait handle) >>= (`shouldSatisfy` either (const False) (const True))-                    checkpoint store `shouldReturn` GlobalPosition 2-                    stoppedCount <- STM.readTVarIO count-                    threadDelay 120_000-                    STM.readTVarIO count `shouldReturn` stoppedCount-                    currentState handle >>= (`shouldSatisfy` isNothing)--    it "warns periodically while pending and joins the watchdog on cancellation" $ do-        blocked <- newEmptyMVar-        warnings <- STM.newTQueueIO-        let observe event = case event of-                KirokuEventSubscriptionHandlerStalled _ pos _ elapsed _ -> do-                    now <- getMonotonicTimeNSec-                    STM.atomically (STM.writeTQueue warnings (pos, elapsed, now))-                _ -> pure ()-        withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do-            appendEvents store-            waitForPublisher store (GlobalPosition 2)-            handle <--                subscribe-                    store-                    ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> takeMVar blocked >> pure Stop))-                        { handlerStallWarnAfter = Just 0.05-                        }-                    )-            (_, firstElapsed, firstAt) <- within (STM.atomically (STM.readTQueue warnings))-            (_, secondElapsed, secondAt) <- within (STM.atomically (STM.readTQueue warnings))-            secondElapsed `shouldSatisfy` (> firstElapsed)-            secondAt - firstAt `shouldSatisfy` (>= 45_000_000)-            checkpoint store `shouldReturn` GlobalPosition 0-            within (cancel handle)-            _ <- STM.atomically (STM.flushTQueue warnings)-            threadDelay 120_000-            STM.atomically (STM.isEmptyTQueue warnings) `shouldReturn` True-            currentState handle >>= (`shouldSatisfy` isNothing)--    it "warns for the current invocation after replacing a quick handler" $ do+  forM_+    [ (AllStreams, Nothing, False),+      (AllStreams, Nothing, True),+      (Category (CategoryName "stall"), Nothing, True),+      (AllStreams, Just (validConsumerGroup 0 1), True),+      (Category (CategoryName "stall"), Just (validConsumerGroup 0 1), False)+    ]+    $ \(target', group, live) ->+      it ("warns without acknowledging or checkpointing a blocked handler for " <> show (target', group, live)) $ do         release <- newEmptyMVar+        caughtUp <- newEmptyMVar         warning <- STM.newEmptyTMVarIO-        let observe event = case event of-                KirokuEventSubscriptionHandlerStalled _ pos _ elapsed _ -> STM.atomically $ do-                    _ <- STM.tryPutTMVar warning (pos, elapsed)-                    pure ()+        count <- STM.newTVarIO (0 :: Int)+        let subName = SubscriptionName "stall-test"+            observe event = do+              caughtUpEventHandler subName caughtUp Nothing event+              case event of+                KirokuEventSubscriptionHandlerStalled name pos eid elapsed groupContext | name == subName -> STM.atomically $ do+                  STM.modifyTVar' count (+ 1)+                  _ <- STM.tryPutTMVar warning (pos, eid, elapsed, groupContext)+                  pure ()                 _ -> pure ()+            tweak settings = settings & #eventHandler .~ Just observe             handler event =-                if event ^. #globalPosition == GlobalPosition 1-                    then pure Continue-                    else takeMVar release >> pure Stop-        withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do-            appendEvents store-            waitForPublisher store (GlobalPosition 2)-            handle <--                subscribe-                    store-                    ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams handler)-                        { handlerStallWarnAfter = Just 0.05-                        }-                    )-            (pos, elapsed) <- within (STM.atomically (STM.readTMVar warning))-            pos `shouldBe` GlobalPosition 2-            elapsed `shouldSatisfy` (>= 0.05)-            -- Normal progress is persisted at the batch boundary.-            checkpoint store `shouldReturn` GlobalPosition 0-            putMVar release ()-            _ <- within (wait handle)+              if event ^. #globalPosition == GlobalPosition 1+                then takeMVar release >> pure Continue+                else pure Stop+        withTestStoreSettings tweak $ \store -> do+          if live then pure () else appendEvents store >> waitForPublisher store (GlobalPosition 2)+          handle <-+            subscribe+              store+              ( (defaultSubscriptionConfig subName target' handler)+                  { consumerGroup = group,+                    handlerStallWarnAfter = Just 0.05+                  }+              )+          if live then waitForSubscriptionLive caughtUp >> appendEvents store else pure ()+          (pos, eid, elapsed, groupContext) <- within (STM.atomically (STM.readTMVar warning))+          pos `shouldBe` GlobalPosition 1+          Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 1)+          eid `shouldBe` (V.head events ^. #eventId)+          elapsed `shouldSatisfy` (>= 0.05)+          groupContext `shouldBe` maybe NonGroup (\_ -> GroupMember 0 1) group+          checkpoint store `shouldReturn` GlobalPosition 0+          putMVar release ()+          within (wait handle) >>= (`shouldSatisfy` either (const False) (const True))+          checkpoint store `shouldReturn` GlobalPosition 2+          stoppedCount <- STM.readTVarIO count+          threadDelay 120_000+          STM.readTVarIO count `shouldReturn` stoppedCount+          currentState handle >>= (`shouldSatisfy` isNothing)++  it "warns periodically while pending and joins the watchdog on cancellation" $ do+    blocked <- newEmptyMVar+    warnings <- STM.newTQueueIO+    let observe event = case event of+          KirokuEventSubscriptionHandlerStalled _ pos _ elapsed _ -> do+            now <- getMonotonicTimeNSec+            STM.atomically (STM.writeTQueue warnings (pos, elapsed, now))+          _ -> pure ()+    withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+      appendEvents store+      waitForPublisher store (GlobalPosition 2)+      handle <-+        subscribe+          store+          ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> takeMVar blocked >> pure Stop))+              { handlerStallWarnAfter = Just 0.05+              }+          )+      (_, firstElapsed, firstAt) <- within (STM.atomically (STM.readTQueue warnings))+      (_, secondElapsed, secondAt) <- within (STM.atomically (STM.readTQueue warnings))+      secondElapsed `shouldSatisfy` (> firstElapsed)+      secondAt - firstAt `shouldSatisfy` (>= 45_000_000)+      checkpoint store `shouldReturn` GlobalPosition 0+      within (cancel handle)+      _ <- STM.atomically (STM.flushTQueue warnings)+      threadDelay 120_000+      STM.atomically (STM.isEmptyTQueue warnings) `shouldReturn` True+      currentState handle >>= (`shouldSatisfy` isNothing)++  it "warns for the current invocation after replacing a quick handler" $ do+    release <- newEmptyMVar+    warning <- STM.newEmptyTMVarIO+    let observe event = case event of+          KirokuEventSubscriptionHandlerStalled _ pos _ elapsed _ -> STM.atomically $ do+            _ <- STM.tryPutTMVar warning (pos, elapsed)             pure ()+          _ -> pure ()+        handler event =+          if event ^. #globalPosition == GlobalPosition 1+            then pure Continue+            else takeMVar release >> pure Stop+    withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+      appendEvents store+      waitForPublisher store (GlobalPosition 2)+      handle <-+        subscribe+          store+          ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams handler)+              { handlerStallWarnAfter = Just 0.05+              }+          )+      (pos, elapsed) <- within (STM.atomically (STM.readTMVar warning))+      pos `shouldBe` GlobalPosition 2+      elapsed `shouldSatisfy` (>= 0.05)+      -- Normal progress is persisted at the batch boundary.+      checkpoint store `shouldReturn` GlobalPosition 0+      putMVar release ()+      _ <- within (wait handle)+      pure () -    it "contains a throwing diagnostic callback and still completes the handler" $ do-        release <- newEmptyMVar-        observed <- STM.newEmptyTMVarIO-        let observe KirokuEventSubscriptionHandlerStalled{} = do-                STM.atomically $ do-                    _ <- STM.tryPutTMVar observed ()-                    pure ()-                ioError (userError "diagnostic callback failed")-            observe _ = pure ()-        withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do-            appendEvents store-            waitForPublisher store (GlobalPosition 2)-            handle <--                subscribe-                    store-                    ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> takeMVar release >> pure Stop))-                        { handlerStallWarnAfter = Just 0.05-                        }-                    )-            within (STM.atomically (STM.readTMVar observed))-            putMVar release ()-            within (wait handle) >>= (`shouldSatisfy` either (const False) (const True))-            checkpoint store `shouldReturn` GlobalPosition 1+  it "contains a throwing diagnostic callback and still completes the handler" $ do+    release <- newEmptyMVar+    observed <- STM.newEmptyTMVarIO+    let observe KirokuEventSubscriptionHandlerStalled {} = do+          STM.atomically $ do+            _ <- STM.tryPutTMVar observed ()+            pure ()+          ioError (userError "diagnostic callback failed")+        observe _ = pure ()+    withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+      appendEvents store+      waitForPublisher store (GlobalPosition 2)+      handle <-+        subscribe+          store+          ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> takeMVar release >> pure Stop))+              { handlerStallWarnAfter = Just 0.05+              }+          )+      within (STM.atomically (STM.readTMVar observed))+      putMVar release ()+      within (wait handle) >>= (`shouldSatisfy` either (const False) (const True))+      checkpoint store `shouldReturn` GlobalPosition 1 -    it "clears tracking and joins the watchdog when a handler throws" $ do-        release <- newEmptyMVar-        count <- STM.newTVarIO (0 :: Int)-        let observe KirokuEventSubscriptionHandlerStalled{} = STM.atomically (STM.modifyTVar' count (+ 1))-            observe _ = pure ()-        withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do-            appendEvents store-            waitForPublisher store (GlobalPosition 2)-            handle <--                subscribe-                    store-                    ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> takeMVar release >> ioError (userError "handler failed")))-                        { handlerStallWarnAfter = Just 0.05-                        }-                    )-            within (STM.atomically (STM.readTVar count >>= STM.check . (> 0)))-            putMVar release ()-            within (wait handle) >>= (`shouldSatisfy` either (const True) (const False))-            stoppedCount <- STM.readTVarIO count-            threadDelay 120_000-            STM.readTVarIO count `shouldReturn` stoppedCount-            checkpoint store `shouldReturn` GlobalPosition 0+  it "clears tracking and joins the watchdog when a handler throws" $ do+    release <- newEmptyMVar+    count <- STM.newTVarIO (0 :: Int)+    let observe KirokuEventSubscriptionHandlerStalled {} = STM.atomically (STM.modifyTVar' count (+ 1))+        observe _ = pure ()+    withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+      appendEvents store+      waitForPublisher store (GlobalPosition 2)+      handle <-+        subscribe+          store+          ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> takeMVar release >> ioError (userError "handler failed")))+              { handlerStallWarnAfter = Just 0.05+              }+          )+      within (STM.atomically (STM.readTVar count >>= STM.check . (> 0)))+      putMVar release ()+      within (wait handle) >>= (`shouldSatisfy` either (const True) (const False))+      stoppedCount <- STM.readTVarIO count+      threadDelay 120_000+      STM.readTVarIO count `shouldReturn` stoppedCount+      checkpoint store `shouldReturn` GlobalPosition 0 -    it "emits nothing when the handler completes before the threshold" $ do-        count <- STM.newTVarIO (0 :: Int)-        let observe KirokuEventSubscriptionHandlerStalled{} = STM.atomically (STM.modifyTVar' count (+ 1))-            observe _ = pure ()-        withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do-            appendEvents store-            waitForPublisher store (GlobalPosition 2)-            handle <--                subscribe-                    store-                    ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> pure Stop))-                        { handlerStallWarnAfter = Just 0.1-                        }-                    )-            within (wait handle) >>= (`shouldSatisfy` either (const False) (const True))-            threadDelay 150_000-            STM.readTVarIO count `shouldReturn` 0+  it "emits nothing when the handler completes before the threshold" $ do+    count <- STM.newTVarIO (0 :: Int)+    let observe KirokuEventSubscriptionHandlerStalled {} = STM.atomically (STM.modifyTVar' count (+ 1))+        observe _ = pure ()+    withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+      appendEvents store+      waitForPublisher store (GlobalPosition 2)+      handle <-+        subscribe+          store+          ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> pure Stop))+              { handlerStallWarnAfter = Just 0.1+              }+          )+      within (wait handle) >>= (`shouldSatisfy` either (const False) (const True))+      threadDelay 150_000+      STM.readTVarIO count `shouldReturn` 0 -    it "keeps warnings disabled by default even when a handler is blocked" $ do-        entered <- newEmptyMVar-        release <- newEmptyMVar-        count <- STM.newTVarIO (0 :: Int)-        let observe KirokuEventSubscriptionHandlerStalled{} = STM.atomically (STM.modifyTVar' count (+ 1))-            observe _ = pure ()-        withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do-            appendEvents store-            waitForPublisher store (GlobalPosition 2)-            let config = defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> putMVar entered () >> takeMVar release >> pure Stop)-            handlerStallWarnAfter config `shouldBe` Nothing-            handle <- subscribe store config-            within (takeMVar entered)-            threadDelay 150_000-            STM.readTVarIO count `shouldReturn` 0-            putMVar release ()-            _ <- within (wait handle)-            pure ()+  it "keeps warnings disabled by default even when a handler is blocked" $ do+    entered <- newEmptyMVar+    release <- newEmptyMVar+    count <- STM.newTVarIO (0 :: Int)+    let observe KirokuEventSubscriptionHandlerStalled {} = STM.atomically (STM.modifyTVar' count (+ 1))+        observe _ = pure ()+    withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+      appendEvents store+      waitForPublisher store (GlobalPosition 2)+      let config = defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> putMVar entered () >> takeMVar release >> pure Stop)+      handlerStallWarnAfter config `shouldBe` Nothing+      handle <- subscribe store config+      within (takeMVar entered)+      threadDelay 150_000+      STM.readTVarIO count `shouldReturn` 0+      putMVar release ()+      _ <- within (wait handle)+      pure () -    forM_ [0, -1 :: NominalDiffTime] $ \threshold ->-        it ("refuses a nonpositive warning interval before checkpoint initialization: " <> show threshold) $-            withTestStoreSettings Prelude.id $ \store -> do-                handle <--                    subscribe-                        store-                        ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> pure Stop))-                            { handlerStallWarnAfter = Just threshold-                            }-                        )-                within (wait handle) >>= \case-                    Left err -> do-                        (fromException err :: Maybe InvalidHandlerStallWarnAfter) `shouldBe` Just (InvalidHandlerStallWarnAfter threshold)-                        (fromException err :: Maybe SomeSubscriptionStartupFailure) `shouldSatisfy` (not . isNothing)-                    Right () -> expectationFailure "invalid interval was accepted"-                Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory-                V.null rows `shouldBe` True+  forM_ [0, -1 :: NominalDiffTime] $ \threshold ->+    it ("refuses a nonpositive warning interval with exactly one crashed stop before checkpoint initialization: " <> show threshold) $ do+      events <- newIORef []+      let observe event = atomicModifyIORef' events (\seen -> (event : seen, ()))+      withTestStoreSettings (\s -> s & #eventHandler .~ Just observe) $ \store -> do+        handle <-+          subscribe+            store+            ( (defaultSubscriptionConfig (SubscriptionName "stall-test") AllStreams (\_ -> pure Stop))+                { handlerStallWarnAfter = Just threshold+                }+            )+        within (wait handle) >>= \case+          Left err -> do+            (fromException err :: Maybe InvalidHandlerStallWarnAfter) `shouldBe` Just (InvalidHandlerStallWarnAfter threshold)+            (fromException err :: Maybe SomeSubscriptionStartupFailure) `shouldSatisfy` (not . isNothing)+          Right () -> expectationFailure "invalid interval was accepted"+        Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory+        V.null rows `shouldBe` True+        seen <- readIORef events+        case [(n, p, e, g) | KirokuEventSubscriptionStopped n p (StopWorkerCrashed e) g <- seen] of+          [(n, p, e, g)] -> do+            n `shouldBe` SubscriptionName "stall-test"+            p `shouldBe` GlobalPosition 0+            g `shouldBe` NonGroup+            fromException e `shouldBe` Just (InvalidHandlerStallWarnAfter threshold)+          other -> expectationFailure ("expected exactly one crashed stop, got " <> show other)+        length [() | KirokuEventSubscriptionStopped {} <- seen] `shouldBe` 1
test/Test/Helpers.hs view
@@ -1,21 +1,20 @@-{- | Shared fixtures and synchronization helpers for the kiroku-store-test suite.--Three groups of helpers live here:--  * Database fixture: 'withTestStore' brackets an ephemeral PostgreSQL-    instance and a 'KirokuStore'.-  * Event/wait helpers: 'makeEvent', 'waitWithTimeout', plus the-    listener-pid and TRUNCATE-rejection utilities used by the-    Notifier/Lifecycle regression tests.-  * Deterministic synchronization: 'waitForPublisher' and-    'waitForSubscriptionLive' replace 'threadDelay'-based sleeps in-    subscription tests with STM- and event-handler-driven barriers-    (per EP-6 F12/F14 and the threadDelay inventory in-    @docs\/plans\/6-test-and-benchmark-hardening-for-production-confidence.md@).--}-module Test.Helpers (-    -- * Database fixture+-- | Shared fixtures and synchronization helpers for the kiroku-store+-- test suite.+--+-- Three groups of helpers live here:+--+--   * Database fixture: 'withTestStore' brackets an ephemeral PostgreSQL+--     instance and a 'KirokuStore'.+--   * Event/wait helpers: 'makeEvent', 'waitWithTimeout', plus the+--     listener-pid and TRUNCATE-rejection utilities used by the+--     Notifier/Lifecycle regression tests.+--   * Deterministic synchronization: 'waitForPublisher' and+--     'waitForSubscriptionLive' replace 'threadDelay'-based sleeps in+--     subscription tests with STM- and event-handler-driven barriers+--     (per EP-6 F12/F14 and the threadDelay inventory in+--     @docs\/plans\/6-test-and-benchmark-hardening-for-production-confidence.md@).+module Test.Helpers+  ( -- * Database fixture     withSharedMigratedPostgres,     withTestStore,     withTestStoreSettings,@@ -48,7 +47,8 @@     waitForPublisher,     waitForSubscriptionLive,     caughtUpEventHandler,-) where+  )+where  import Control.Concurrent (threadDelay) import Control.Concurrent.Async qualified as Async@@ -74,61 +74,57 @@ import Kiroku.Store.Subscription.EventPublisher (publisherPosition) import Kiroku.Test.Postgres (withMigratedTestDatabase, withSharedMigratedPostgres) -{- | Bracket that creates an ephemeral PostgreSQL database, applies the-Kiroku migrations, and provides a 'KirokuStore' handle.--}+-- | Bracket that creates an ephemeral PostgreSQL database, applies the+-- Kiroku migrations, and provides a 'KirokuStore' handle. withTestStore :: (KirokuStore -> IO ()) -> IO () withTestStore = withTestStoreSettings Prelude.id -{- | Variant of 'withTestStore' that lets the caller transform the-'ConnectionSettings' before the store is opened. Used by tests that-install an 'eventHandler' or other observation hook.--}+-- | Variant of 'withTestStore' that lets the caller transform the+-- 'ConnectionSettings' before the store is opened. Used by tests that+-- install an 'eventHandler' or other observation hook. withTestStoreSettings :: (ConnectionSettings -> ConnectionSettings) -> (KirokuStore -> IO ()) -> IO () withTestStoreSettings tweak action =-    withMigratedTestDatabase $ \connStr ->-        withStore (tweak (defaultConnectionSettings connStr)) action+  withMigratedTestDatabase $ \connStr ->+    withStore (tweak (defaultConnectionSettings connStr)) action  -- | Create a simple 'EventData' with an auto-generated id. makeEvent :: Text -> Value -> EventData makeEvent typ payload =-    EventData-        { eventId = Nothing-        , eventType = EventType typ-        , payload = payload-        , metadata = Nothing-        , causationId = Nothing-        , correlationId = Nothing-        }+  EventData+    { eventId = Nothing,+      eventType = EventType typ,+      payload = payload,+      metadata = Nothing,+      causationId = Nothing,+      correlationId = Nothing+    } -{- | Wait for a subscription with a timeout (in microseconds).-Returns 'Left' on timeout, or the subscription result.--}+-- | Wait for a subscription with a timeout (in microseconds).+-- Returns 'Left' on timeout, or the subscription result. waitWithTimeout :: Int -> SubscriptionHandle -> IO (Either String (Either SomeException ())) waitWithTimeout micros handle = do-    result <- Async.race (threadDelay micros) (wait handle)-    case result of-        Left () -> do-            cancel handle-            pure (Left "Subscription timed out")-        Right r -> pure (Right r)+  result <- Async.race (threadDelay micros) (wait handle)+  case result of+    Left () -> do+      cancel handle+      pure (Left "Subscription timed out")+    Right r -> pure (Right r) -{- | Count rows in the @events@ table via raw SQL. Used by hard-delete-regression tests that need to assert event payloads are actually removed-(not just unlinked).--}+-- | Count rows in the @events@ table via raw SQL. Used by hard-delete+-- regression tests that need to assert event payloads are actually removed+-- (not just unlinked). countEvents :: KirokuStore -> IO Int64 countEvents store = do-    let stmt :: Statement () Int64-        stmt =-            preparable-                "SELECT COUNT(*) FROM events"-                E.noParams-                (D.singleRow (D.column (D.nonNullable D.int8)))-    result <- Pool.use (store ^. #pool) (Session.statement () stmt)-    case result of-        Left err -> error ("countEvents failed: " <> show err)-        Right n -> pure n+  let stmt :: Statement () Int64+      stmt =+        preparable+          "SELECT COUNT(*) FROM events"+          E.noParams+          (D.singleRow (D.column (D.nonNullable D.int8)))+  result <- Pool.use (store ^. #pool) (Session.statement () stmt)+  case result of+    Left err -> error ("countEvents failed: " <> show err)+    Right n -> pure n  -- | Insert a dead-letter row for a recorded event without running a subscription. insertDeadLetterForEvent :: KirokuStore -> Text -> RecordedEvent -> IO ()@@ -136,220 +132,212 @@  insertDeadLetterWith :: KirokuStore -> Text -> Int32 -> Aeson.Value -> Text -> Int32 -> RecordedEvent -> IO () insertDeadLetterWith store subscriptionName selectedMember reason summary attempts event = do-    let EventId eid = event ^. #eventId-        GlobalPosition globalPosition = event ^. #globalPosition-        params =-            SQL.DeadLetterParams-                { SQL.dlSubscriptionName = subscriptionName-                , SQL.dlMember = selectedMember-                , SQL.dlTargetKind = "unbound"-                , SQL.dlTargetCategory = Nothing-                , SQL.dlGroupSize = max 1 (selectedMember + 1)-                , SQL.dlGlobalPosition = globalPosition-                , SQL.dlEventId = eid-                , SQL.dlReason = reason-                , SQL.dlReasonSummary = summary-                , SQL.dlAttemptCount = attempts-                }-    result <- Pool.use (store ^. #pool) (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)-    case result of-        Left err -> error ("insertDeadLetterForEvent failed: " <> show err)-        Right () -> pure ()+  let EventId eid = event ^. #eventId+      GlobalPosition globalPosition = event ^. #globalPosition+      params =+        SQL.DeadLetterParams+          { SQL.dlSubscriptionName = subscriptionName,+            SQL.dlMember = selectedMember,+            SQL.dlTargetKind = "unbound",+            SQL.dlTargetCategory = Nothing,+            SQL.dlGroupSize = max 1 (selectedMember + 1),+            SQL.dlGlobalPosition = globalPosition,+            SQL.dlEventId = eid,+            SQL.dlReason = reason,+            SQL.dlReasonSummary = summary,+            SQL.dlAttemptCount = attempts+          }+  result <- Pool.use (store ^. #pool) (Session.statement params SQL.insertDeadLetterAndCheckpointStmt)+  case result of+    Left err -> error ("insertDeadLetterForEvent failed: " <> show err)+    Right () -> pure ()  -- | Count dead-letter rows referencing any of the supplied events. countDeadLettersForEvents :: KirokuStore -> [EventId] -> IO Int64 countDeadLettersForEvents store eventIds = do-    let uuids = fmap (\(EventId eid) -> eid) eventIds-        stmt :: Statement [UUID] Int64-        stmt =-            preparable-                "SELECT COUNT(*) FROM dead_letters WHERE event_id = ANY($1::uuid[])"-                (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))-                (D.singleRow (D.column (D.nonNullable D.int8)))-    result <- Pool.use (store ^. #pool) (Session.statement uuids stmt)-    case result of-        Left err -> error ("countDeadLettersForEvents failed: " <> show err)-        Right n -> pure n+  let uuids = fmap (\(EventId eid) -> eid) eventIds+      stmt :: Statement [UUID] Int64+      stmt =+        preparable+          "SELECT COUNT(*) FROM dead_letters WHERE event_id = ANY($1::uuid[])"+          (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+          (D.singleRow (D.column (D.nonNullable D.int8)))+  result <- Pool.use (store ^. #pool) (Session.statement uuids stmt)+  case result of+    Left err -> error ("countDeadLettersForEvents failed: " <> show err)+    Right n -> pure n -{- | Insert directly into @events@ without an @event_id@ so the database-default is responsible for UUID generation. Returns the generated UUID-text; used to prove the schema-level @uuidv7()@ fallback works.--}+-- | Insert directly into @events@ without an @event_id@ so the database+-- default is responsible for UUID generation. Returns the generated UUID+-- text; used to prove the schema-level @uuidv7()@ fallback works. insertEventUsingDefaultId :: KirokuStore -> IO Text insertEventUsingDefaultId store = do-    let stmt :: Statement () Text-        stmt =-            preparable-                "INSERT INTO events (event_type, data) VALUES ('DefaultUuidGenerated', '{}'::jsonb) RETURNING event_id::text"-                E.noParams-                (D.singleRow (D.column (D.nonNullable D.text)))-    result <- Pool.use (store ^. #pool) (Session.statement () stmt)-    case result of-        Left err -> error ("insertEventUsingDefaultId failed: " <> show err)-        Right eventIdText -> pure eventIdText+  let stmt :: Statement () Text+      stmt =+        preparable+          "INSERT INTO events (event_type, data) VALUES ('DefaultUuidGenerated', '{}'::jsonb) RETURNING event_id::text"+          E.noParams+          (D.singleRow (D.column (D.nonNullable D.text)))+  result <- Pool.use (store ^. #pool) (Session.statement () stmt)+  case result of+    Left err -> error ("insertEventUsingDefaultId failed: " <> show err)+    Right eventIdText -> pure eventIdText -{- | Report whether a schema-qualified relation exists, via-@to_regclass(name) IS NOT NULL@. The name is schema-qualified-(e.g. @"kiroku.streams"@ or @"public.streams"@), so the result does not-depend on the connection's @search_path@. Used by the schema-placement-tests that prove Kiroku objects install under @kiroku@ and not @public@.--}+-- | Report whether a schema-qualified relation exists, via+-- @to_regclass(name) IS NOT NULL@. The name is schema-qualified+-- (e.g. @"kiroku.streams"@ or @"public.streams"@), so the result does not+-- depend on the connection's @search_path@. Used by the schema-placement+-- tests that prove Kiroku objects install under @kiroku@ and not @public@. tableExists :: KirokuStore -> Text -> IO Bool tableExists store qualifiedName = do-    let stmt :: Statement Text Bool-        stmt =-            preparable-                "SELECT to_regclass($1) IS NOT NULL"-                (E.param (E.nonNullable E.text))-                (D.singleRow (D.column (D.nonNullable D.bool)))-    result <- Pool.use (store ^. #pool) (Session.statement qualifiedName stmt)-    case result of-        Left err -> error ("tableExists failed: " <> show err)-        Right exists -> pure exists+  let stmt :: Statement Text Bool+      stmt =+        preparable+          "SELECT to_regclass($1) IS NOT NULL"+          (E.param (E.nonNullable E.text))+          (D.singleRow (D.column (D.nonNullable D.bool)))+  result <- Pool.use (store ^. #pool) (Session.statement qualifiedName stmt)+  case result of+    Left err -> error ("tableExists failed: " <> show err)+    Right exists -> pure exists  -- | Return PostgreSQL's numeric server version for failure messages. serverVersionNum :: KirokuStore -> IO Text serverVersionNum store = do-    let stmt :: Statement () Text-        stmt =-            preparable-                "SELECT current_setting('server_version_num')"-                E.noParams-                (D.singleRow (D.column (D.nonNullable D.text)))-    result <- Pool.use (store ^. #pool) (Session.statement () stmt)-    case result of-        Left err -> error ("serverVersionNum failed: " <> show err)-        Right version -> pure version+  let stmt :: Statement () Text+      stmt =+        preparable+          "SELECT current_setting('server_version_num')"+          E.noParams+          (D.singleRow (D.column (D.nonNullable D.text)))+  result <- Pool.use (store ^. #pool) (Session.statement () stmt)+  case result of+    Left err -> error ("serverVersionNum failed: " <> show err)+    Right version -> pure version -{- | Try to TRUNCATE the named table without the GUC; returns 'True' if the-operation was rejected by the @protect_truncation@ trigger (the expected-behavior after EP-1 F6) and 'False' if it succeeded.--}+-- | Try to TRUNCATE the named table without the GUC; returns 'True' if the+-- operation was rejected by the @protect_truncation@ trigger (the expected+-- behavior after EP-1 F6) and 'False' if it succeeded. truncateRejected :: KirokuStore -> Text -> IO Bool truncateRejected store tableName = do-    let stmt :: Statement () ()-        stmt = unpreparable ("TRUNCATE " <> tableName) E.noParams D.noResult-    result <- Pool.use (store ^. #pool) (Session.statement () stmt)-    case result of-        Left _ -> pure True-        Right () -> pure False+  let stmt :: Statement () ()+      stmt = unpreparable ("TRUNCATE " <> tableName) E.noParams D.noResult+  result <- Pool.use (store ^. #pool) (Session.statement () stmt)+  case result of+    Left _ -> pure True+    Right () -> pure False  -- | Look up the pid of the kiroku-listener backend in @pg_stat_activity@. findListenerPid :: Pool.Pool -> IO (Maybe Int32) findListenerPid pool = do-    let stmt :: Statement () (Maybe Int32)-        stmt =-            preparable-                "SELECT pid::int4 FROM pg_stat_activity WHERE application_name = 'kiroku-listener' LIMIT 1"-                E.noParams-                (D.rowMaybe (D.column (D.nonNullable D.int4)))-    result <- Pool.use pool (Session.statement () stmt)-    case result of-        Left err -> error ("findListenerPid failed: " <> show err)-        Right mPid -> pure mPid+  let stmt :: Statement () (Maybe Int32)+      stmt =+        preparable+          "SELECT pid::int4 FROM pg_stat_activity WHERE application_name = 'kiroku-listener' LIMIT 1"+          E.noParams+          (D.rowMaybe (D.column (D.nonNullable D.int4)))+  result <- Pool.use pool (Session.statement () stmt)+  case result of+    Left err -> error ("findListenerPid failed: " <> show err)+    Right mPid -> pure mPid  -- | Wait until the listener appears in @pg_stat_activity@, or fail after the budget. waitForListenerPid :: Pool.Pool -> Int -> IO Int32 waitForListenerPid pool budgetMicros-    | budgetMicros <= 0 = error "waitForListenerPid: timeout — kiroku-listener never appeared in pg_stat_activity"-    | otherwise = do-        m <- findListenerPid pool-        case m of-            Just pid -> pure pid-            Nothing -> do-                threadDelay 50_000-                waitForListenerPid pool (budgetMicros - 50_000)+  | budgetMicros <= 0 = error "waitForListenerPid: timeout — kiroku-listener never appeared in pg_stat_activity"+  | otherwise = do+      m <- findListenerPid pool+      case m of+        Just pid -> pure pid+        Nothing -> do+          threadDelay 50_000+          waitForListenerPid pool (budgetMicros - 50_000)  -- | Wait until the listener pid in @pg_stat_activity@ differs from the supplied pid. waitForListenerPidNotEqual :: Pool.Pool -> Int32 -> Int -> IO Int32 waitForListenerPidNotEqual pool oldPid budgetMicros-    | budgetMicros <= 0 = error "waitForListenerPidNotEqual: timeout — listener did not reconnect"-    | otherwise = do-        m <- findListenerPid pool-        case m of-            Just pid | pid /= oldPid -> pure pid-            _ -> do-                threadDelay 50_000-                waitForListenerPidNotEqual pool oldPid (budgetMicros - 50_000)+  | budgetMicros <= 0 = error "waitForListenerPidNotEqual: timeout — listener did not reconnect"+  | otherwise = do+      m <- findListenerPid pool+      case m of+        Just pid | pid /= oldPid -> pure pid+        _ -> do+          threadDelay 50_000+          waitForListenerPidNotEqual pool oldPid (budgetMicros - 50_000)  -- | Terminate the named backend pid via @pg_terminate_backend@. terminateBackend :: Pool.Pool -> Int32 -> IO () terminateBackend pool pid = do-    let stmt :: Statement Int32 Bool-        stmt =-            preparable-                "SELECT pg_terminate_backend($1::int4)"-                (E.param (E.nonNullable E.int4))-                (D.singleRow (D.column (D.nonNullable D.bool)))-    result <- Pool.use pool (Session.statement pid stmt)-    case result of-        Left err -> error ("terminateBackend failed: " <> show err)-        Right _ -> pure ()+  let stmt :: Statement Int32 Bool+      stmt =+        preparable+          "SELECT pg_terminate_backend($1::int4)"+          (E.param (E.nonNullable E.int4))+          (D.singleRow (D.column (D.nonNullable D.bool)))+  result <- Pool.use pool (Session.statement pid stmt)+  case result of+    Left err -> error ("terminateBackend failed: " <> show err)+    Right _ -> pure () -{- | Count kiroku-listener connections via a fresh connection (used after-the store has shut down, when the application pool is no longer-available).--}+-- | Count kiroku-listener connections via a fresh connection (used after+-- the store has shut down, when the application pool is no longer+-- available). listenerCount :: Text -> IO Int64 listenerCount connStr = do-    eConn <- Connection.acquire (Conn.connectionString connStr)-    case eConn of-        Left err -> error ("listenerCount: failed to acquire verification conn: " <> show err)-        Right conn -> do-            let stmt :: Statement () Int64-                stmt =-                    preparable-                        "SELECT COUNT(*) FROM pg_stat_activity WHERE application_name = 'kiroku-listener'"-                        E.noParams-                        (D.singleRow (D.column (D.nonNullable D.int8)))-            r <- Connection.use conn (Session.statement () stmt)-            Connection.release conn-            case r of-                Left err -> error ("listenerCount: query failed: " <> show err)-                Right n -> pure n+  eConn <- Connection.acquire (Conn.connectionString connStr)+  case eConn of+    Left err -> error ("listenerCount: failed to acquire verification conn: " <> show err)+    Right conn -> do+      let stmt :: Statement () Int64+          stmt =+            preparable+              "SELECT COUNT(*) FROM pg_stat_activity WHERE application_name = 'kiroku-listener'"+              E.noParams+              (D.singleRow (D.column (D.nonNullable D.int8)))+      r <- Connection.use conn (Session.statement () stmt)+      Connection.release conn+      case r of+        Left err -> error ("listenerCount: query failed: " <> show err)+        Right n -> pure n -{- | Block until the EventPublisher has ingested events at or beyond the-given 'GlobalPosition'. Replaces @threadDelay 200_000@ heuristics in-subscription catch-up tests with a deterministic STM barrier.--}+-- | Block until the EventPublisher has ingested events at or beyond the+-- given 'GlobalPosition'. Replaces @threadDelay 200_000@ heuristics in+-- subscription catch-up tests with a deterministic STM barrier. waitForPublisher :: KirokuStore -> GlobalPosition -> IO () waitForPublisher store (GlobalPosition target) =-    atomically $ do-        GlobalPosition p <- publisherPosition (store ^. #publisher)-        check (p >= target)+  atomically $ do+    GlobalPosition p <- publisherPosition (store ^. #publisher)+    check (p >= target) -{- | Block until the named subscription emits-'KirokuEventSubscriptionCaughtUp'. The caller passes an 'MVar' that is-wired into the test store's @eventHandler@ via 'caughtUpEventHandler'.-Replaces @threadDelay 100_000@ "wait for subscription to enter live-mode" in subscription tests.--}+-- | Block until the named subscription emits+-- 'KirokuEventSubscriptionCaughtUp'. The caller passes an 'MVar' that is+-- wired into the test store's @eventHandler@ via 'caughtUpEventHandler'.+-- Replaces @threadDelay 100_000@ "wait for subscription to enter live+-- mode" in subscription tests. waitForSubscriptionLive :: MVar () -> IO () waitForSubscriptionLive = takeMVar -{- | Build an 'eventHandler' that opens the supplied 'MVar' the first-time 'KirokuEventSubscriptionCaughtUp' fires for the named subscription.-Subsequent emissions and other event types are ignored by the barrier-('tryPutMVar' guarantees idempotence). Composes with a passthrough-caller for tests that need to inspect the full event stream.--}+-- | Build an 'eventHandler' that opens the supplied 'MVar' the first+-- time 'KirokuEventSubscriptionCaughtUp' fires for the named subscription.+-- Subsequent emissions and other event types are ignored by the barrier+-- ('tryPutMVar' guarantees idempotence). Composes with a passthrough+-- caller for tests that need to inspect the full event stream. caughtUpEventHandler ::-    SubscriptionName ->-    MVar () ->-    Maybe (KirokuEvent -> IO ()) ->-    KirokuEvent ->-    IO ()+  SubscriptionName ->+  MVar () ->+  Maybe (KirokuEvent -> IO ()) ->+  KirokuEvent ->+  IO () caughtUpEventHandler name barrier passthrough evt = do-    case evt of-        KirokuEventSubscriptionCaughtUp n _ _-            | n == name -> () <$ tryPutMVar barrier ()-        _ -> pure ()-    case passthrough of-        Nothing -> pure ()-        Just f -> f evt+  case evt of+    KirokuEventSubscriptionCaughtUp n _ _+      | n == name -> () <$ tryPutMVar barrier ()+    _ -> pure ()+  case passthrough of+    Nothing -> pure ()+    Just f -> f evt -{- | Build known-valid membership for test fixtures; invalid cases test the-smart constructors directly rather than passing a malformed worker config.--}+-- | Build known-valid membership for test fixtures; invalid cases test the+-- smart constructors directly rather than passing a malformed worker config. validConsumerGroup :: Int32 -> Int32 -> ConsumerGroup validConsumerGroup m n = either (error . show) Prelude.id (mkConsumerGroupSize n >>= mkConsumerGroup m)
test/Test/HistoryRetention.hs view
@@ -27,242 +27,242 @@  spec :: Spec spec = describe "history retention" $ do-    it "validates UTF-8 byte lengths, duration bounds, and inventory bounds" $ do-        mkHistoryRetentionLeaseOwner "" `shouldBe` Left HistoryRetentionLeaseOwnerEmpty-        mkHistoryRetentionLeaseOwner (Text.replicate 256 "é") `shouldSatisfy` either (const False) (const True)-        mkHistoryRetentionLeaseOwner (Text.replicate 257 "é")-            `shouldBe` Left (HistoryRetentionLeaseOwnerTooLong 514)-        mkHistoryRetentionLeaseReason "" `shouldBe` Left HistoryRetentionLeaseReasonEmpty-        mkHistoryRetentionLeaseReason (Text.replicate 2049 "x")-            `shouldBe` Left (HistoryRetentionLeaseReasonTooLong 2049)-        mkHistoryRetentionLeaseDuration (secondsToDiffTime 0) `shouldSatisfy` isLeft-        mkHistoryRetentionLeaseDuration (secondsToDiffTime 1) `shouldSatisfy` either (const False) (const True)-        mkHistoryRetentionLeaseDuration (secondsToDiffTime 3600) `shouldSatisfy` either (const False) (const True)-        mkHistoryRetentionLeaseDuration (secondsToDiffTime 3601) `shouldSatisfy` isLeft-        mkHistoryRetentionInventoryLimit 0 `shouldBe` Left (HistoryRetentionInventoryLimitOutOfRange 0)-        mkHistoryRetentionInventoryLimit 1000 `shouldSatisfy` either (const False) (const True)-        mkHistoryRetentionInventoryLimit 1001 `shouldBe` Left (HistoryRetentionInventoryLimitOutOfRange 1001)+  it "validates UTF-8 byte lengths, duration bounds, and inventory bounds" $ do+    mkHistoryRetentionLeaseOwner "" `shouldBe` Left HistoryRetentionLeaseOwnerEmpty+    mkHistoryRetentionLeaseOwner (Text.replicate 256 "é") `shouldSatisfy` either (const False) (const True)+    mkHistoryRetentionLeaseOwner (Text.replicate 257 "é")+      `shouldBe` Left (HistoryRetentionLeaseOwnerTooLong 514)+    mkHistoryRetentionLeaseReason "" `shouldBe` Left HistoryRetentionLeaseReasonEmpty+    mkHistoryRetentionLeaseReason (Text.replicate 2049 "x")+      `shouldBe` Left (HistoryRetentionLeaseReasonTooLong 2049)+    mkHistoryRetentionLeaseDuration (secondsToDiffTime 0) `shouldSatisfy` isLeft+    mkHistoryRetentionLeaseDuration (secondsToDiffTime 1) `shouldSatisfy` either (const False) (const True)+    mkHistoryRetentionLeaseDuration (secondsToDiffTime 3600) `shouldSatisfy` either (const False) (const True)+    mkHistoryRetentionLeaseDuration (secondsToDiffTime 3601) `shouldSatisfy` isLeft+    mkHistoryRetentionInventoryLimit 0 `shouldBe` Left (HistoryRetentionInventoryLimitOutOfRange 0)+    mkHistoryRetentionInventoryLimit 1000 `shouldSatisfy` either (const False) (const True)+    mkHistoryRetentionInventoryLimit 1001 `shouldBe` Left (HistoryRetentionInventoryLimitOutOfRange 1001) -    it "captures the authoritative frontier and database-derived expiry" $-        withTestStore $ \store -> do-            Right appended <--                runStoreIO store $-                    appendToStream-                        (StreamName "history-retention-frontier")-                        NoStream-                        [ makeEvent "One" (Aeson.object [])-                        , makeEvent "Two" (Aeson.object [])-                        , makeEvent "Three" (Aeson.object [])-                        ]-            lease <- runTx store (acquireHistoryRetentionLeaseTx (request "rebuild" "frontier" 60))-            lease ^. #protectedThrough `shouldBe` appended ^. #globalPosition-            realToFrac (diffUTCTime (lease ^. #expiresAt) (lease ^. #createdAt))-                `shouldBe` secondsToDiffTime 60-            lease ^. #state `shouldBe` HistoryRetentionLeaseActive-            rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))-            rows `shouldBe` Vector.singleton lease+  it "captures the authoritative frontier and database-derived expiry" $+    withTestStore $ \store -> do+      Right appended <-+        runStoreIO store $+          appendToStream+            (StreamName "history-retention-frontier")+            NoStream+            [ makeEvent "One" (Aeson.object []),+              makeEvent "Two" (Aeson.object []),+              makeEvent "Three" (Aeson.object [])+            ]+      lease <- runTx store (acquireHistoryRetentionLeaseTx (request "rebuild" "frontier" 60))+      lease ^. #protectedThrough `shouldBe` appended ^. #globalPosition+      realToFrac (diffUTCTime (lease ^. #expiresAt) (lease ^. #createdAt))+        `shouldBe` secondsToDiffTime 60+      lease ^. #state `shouldBe` HistoryRetentionLeaseActive+      rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))+      rows `shouldBe` Vector.singleton lease -    it "rolls back acquisition when the surrounding transaction is condemned" $-        withTestStore $ \store -> do-            _ <- runTx store $ do-                lease <- acquireHistoryRetentionLeaseTx (request "rollback" "condemned" 60)-                Tx.condemn-                pure lease-            rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))-            rows `shouldBe` Vector.empty+  it "rolls back acquisition when the surrounding transaction is condemned" $+    withTestStore $ \store -> do+      _ <- runTx store $ do+        lease <- acquireHistoryRetentionLeaseTx (request "rollback" "condemned" 60)+        Tx.condemn+        pure lease+      rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))+      rows `shouldBe` Vector.empty -    it "renews only the matching active owner and never shortens expiry" $-        withTestStore $ \store -> do-            lease <- runTx store (acquireHistoryRetentionLeaseTx (request "owner-a" "renew" 30))-            let handle = leaseHandle lease-                wrongHandle = HistoryRetentionLeaseHandle (lease ^. #leaseId) (validatedOwner "owner-b")-            mismatch <- runTx store (renewHistoryRetentionLeaseTx wrongHandle (validatedDuration 60))-            mismatch `shouldBe` Left HistoryRetentionRenewalOwnerMismatch-            renewed <- runTx store (renewHistoryRetentionLeaseTx handle (validatedDuration 60))-            case renewed of-                Left err -> expectationFailure ("renewal failed: " <> show err)-                Right value -> do-                    value ^. #expiresAt `shouldSatisfy` (> lease ^. #expiresAt)-                    value ^. #renewedAt `shouldSatisfy` (>= lease ^. #renewedAt)+  it "renews only the matching active owner and never shortens expiry" $+    withTestStore $ \store -> do+      lease <- runTx store (acquireHistoryRetentionLeaseTx (request "owner-a" "renew" 30))+      let handle = leaseHandle lease+          wrongHandle = HistoryRetentionLeaseHandle (lease ^. #leaseId) (validatedOwner "owner-b")+      mismatch <- runTx store (renewHistoryRetentionLeaseTx wrongHandle (validatedDuration 60))+      mismatch `shouldBe` Left HistoryRetentionRenewalOwnerMismatch+      renewed <- runTx store (renewHistoryRetentionLeaseTx handle (validatedDuration 60))+      case renewed of+        Left err -> expectationFailure ("renewal failed: " <> show err)+        Right value -> do+          value ^. #expiresAt `shouldSatisfy` (> lease ^. #expiresAt)+          value ^. #renewedAt `shouldSatisfy` (>= lease ^. #renewedAt) -    it "derives expiry without a worker and refuses resurrection" $-        withTestStore $ \store -> do-            lease <- runTx store (acquireHistoryRetentionLeaseTx (request "crashed" "expiry" 1))-            threadDelay 1_100_000-            rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))-            fmap (^. #state) (Vector.toList rows) `shouldBe` [HistoryRetentionLeaseExpired]-            renewed <- runTx store (renewHistoryRetentionLeaseTx (leaseHandle lease) (validatedDuration 60))-            renewed `shouldBe` Left HistoryRetentionRenewalExpired-            released <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle lease))-            case released of-                HistoryRetentionReleaseExpired expiredLease ->-                    expiredLease ^. #state `shouldBe` HistoryRetentionLeaseExpired-                other -> expectationFailure ("expected expired release result, got " <> show other)+  it "derives expiry without a worker and refuses resurrection" $+    withTestStore $ \store -> do+      lease <- runTx store (acquireHistoryRetentionLeaseTx (request "crashed" "expiry" 1))+      threadDelay 1_100_000+      rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))+      fmap (^. #state) (Vector.toList rows) `shouldBe` [HistoryRetentionLeaseExpired]+      renewed <- runTx store (renewHistoryRetentionLeaseTx (leaseHandle lease) (validatedDuration 60))+      renewed `shouldBe` Left HistoryRetentionRenewalExpired+      released <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle lease))+      case released of+        HistoryRetentionReleaseExpired expiredLease ->+          expiredLease ^. #state `shouldBe` HistoryRetentionLeaseExpired+        other -> expectationFailure ("expected expired release result, got " <> show other) -    it "releases idempotently and leaves another simultaneous lease active" $-        withTestStore $ \store -> do-            first <- runTx store (acquireHistoryRetentionLeaseTx (request "first" "release" 60))-            second <- runTx store (acquireHistoryRetentionLeaseTx (request "second" "release" 60))-            released <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle first))-            released `shouldSatisfy` \case HistoryRetentionReleased{} -> True; _ -> False-            repeated <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle first))-            repeated `shouldSatisfy` \case HistoryRetentionAlreadyReleased{} -> True; _ -> False-            rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))-            fmap (^. #state) (Vector.toList rows)-                `shouldBe` [HistoryRetentionLeaseReleased, HistoryRetentionLeaseActive]-            Vector.last rows ^. #leaseId `shouldBe` second ^. #leaseId+  it "releases idempotently and leaves another simultaneous lease active" $+    withTestStore $ \store -> do+      first <- runTx store (acquireHistoryRetentionLeaseTx (request "first" "release" 60))+      second <- runTx store (acquireHistoryRetentionLeaseTx (request "second" "release" 60))+      released <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle first))+      released `shouldSatisfy` \case HistoryRetentionReleased {} -> True; _ -> False+      repeated <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle first))+      repeated `shouldSatisfy` \case HistoryRetentionAlreadyReleased {} -> True; _ -> False+      rows <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))+      fmap (^. #state) (Vector.toList rows)+        `shouldBe` [HistoryRetentionLeaseReleased, HistoryRetentionLeaseActive]+      Vector.last rows ^. #leaseId `shouldBe` second ^. #leaseId -    it "bounds inventory and prunes only terminal rows older than the cutoff" $-        withTestStore $ \store -> do-            first <- runTx store (acquireHistoryRetentionLeaseTx (request "first" "prune" 60))-            _ <- runTx store (acquireHistoryRetentionLeaseTx (request "second" "keep" 60))-            _ <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle first))-            bounded <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 1))-            Vector.length bounded `shouldBe` 1-            cutoff <- addUTCTime 1 <$> getCurrentTime-            pruned <- runTx store (pruneHistoryRetentionLeasesTx cutoff)-            pruned `shouldBe` HistoryRetentionPruneResult 0 1-            remaining <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))-            fmap (^. #state) (Vector.toList remaining) `shouldBe` [HistoryRetentionLeaseActive]+  it "bounds inventory and prunes only terminal rows older than the cutoff" $+    withTestStore $ \store -> do+      first <- runTx store (acquireHistoryRetentionLeaseTx (request "first" "prune" 60))+      _ <- runTx store (acquireHistoryRetentionLeaseTx (request "second" "keep" 60))+      _ <- runTx store (releaseHistoryRetentionLeaseTx (leaseHandle first))+      bounded <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 1))+      Vector.length bounded `shouldBe` 1+      cutoff <- addUTCTime 1 <$> getCurrentTime+      pruned <- runTx store (pruneHistoryRetentionLeasesTx cutoff)+      pruned `shouldBe` HistoryRetentionPruneResult 0 1+      remaining <- runTx store (historyRetentionLeaseInventoryTx (inventoryQuery 10))+      fmap (^. #state) (Vector.toList remaining) `shouldBe` [HistoryRetentionLeaseActive] -    it "emits committed effect transitions once and no false repeated-release event" $ do-        observed <- newIORef ([] :: [KirokuEvent])-        withTestStoreSettings-            (& #eventHandler .~ Just (\event -> modifyIORef' observed (event :)))-            $ \store -> do-                Right lease <- runStoreIO store $ acquireHistoryRetentionLease (request "events" "not-a-label" 60)-                Right (Right _) <--                    runStoreIO store $-                        renewHistoryRetentionLease (leaseHandle lease) (validatedDuration 120)-                Right HistoryRetentionReleased{} <--                    runStoreIO store $-                        releaseHistoryRetentionLease (leaseHandle lease)-                Right HistoryRetentionAlreadyReleased{} <--                    runStoreIO store $-                        releaseHistoryRetentionLease (leaseHandle lease)-                cutoff <- addUTCTime 1 <$> getCurrentTime-                Right (HistoryRetentionPruneResult 0 1) <--                    runStoreIO store $-                        pruneHistoryRetentionLeases cutoff-                pure ()-        events <- readIORef observed-        length [() | KirokuEventHistoryRetentionLeaseAcquired{} <- events] `shouldBe` 1-        length [() | KirokuEventHistoryRetentionLeaseRenewed{} <- events] `shouldBe` 1-        length [() | KirokuEventHistoryRetentionLeaseReleased{} <- events] `shouldBe` 1-        length [() | KirokuEventHistoryRetentionLeasesPruned{} <- events] `shouldBe` 1+  it "emits committed effect transitions once and no false repeated-release event" $ do+    observed <- newIORef ([] :: [KirokuEvent])+    withTestStoreSettings+      (& #eventHandler .~ Just (\event -> modifyIORef' observed (event :)))+      $ \store -> do+        Right lease <- runStoreIO store $ acquireHistoryRetentionLease (request "events" "not-a-label" 60)+        Right (Right _) <-+          runStoreIO store $+            renewHistoryRetentionLease (leaseHandle lease) (validatedDuration 120)+        Right HistoryRetentionReleased {} <-+          runStoreIO store $+            releaseHistoryRetentionLease (leaseHandle lease)+        Right HistoryRetentionAlreadyReleased {} <-+          runStoreIO store $+            releaseHistoryRetentionLease (leaseHandle lease)+        cutoff <- addUTCTime 1 <$> getCurrentTime+        Right (HistoryRetentionPruneResult 0 1) <-+          runStoreIO store $+            pruneHistoryRetentionLeases cutoff+        pure ()+    events <- readIORef observed+    length [() | KirokuEventHistoryRetentionLeaseAcquired {} <- events] `shouldBe` 1+    length [() | KirokuEventHistoryRetentionLeaseRenewed {} <- events] `shouldBe` 1+    length [() | KirokuEventHistoryRetentionLeaseReleased {} <- events] `shouldBe` 1+    length [() | KirokuEventHistoryRetentionLeasesPruned {} <- events] `shouldBe` 1 -    it "returns and emits a typed hard-delete conflict without changing history while any lease is active" $ do-        observed <- newIORef ([] :: [KirokuEvent])-        withTestStoreSettings-            (& #eventHandler .~ Just (\event -> modifyIORef' observed (event :)))-            $ \store -> do-                let stream = StreamName "history-retention-hard-delete"-                Right _ <- runStoreIO store $ appendToStream stream NoStream [makeEvent "Protected" (Aeson.object [])]-                countBefore <- countEvents store-                Right first <- runStoreIO store $ acquireHistoryRetentionLease (request "first" "protect" 60)-                Right second <- runStoreIO store $ acquireHistoryRetentionLease (request "second" "protect" 60)-                blocked <- runStoreIO store $ hardDeleteStream stream-                case blocked of-                    Left (HistoryRetentionActive actual HistoryRetentionConflict{activeLeaseCount}) -> do-                        actual `shouldBe` stream-                        activeLeaseCount `shouldBe` 2-                    other -> expectationFailure ("expected typed retention conflict, got " <> show other)-                countEvents store `shouldReturn` countBefore-                Right (Just _) <- runStoreIO store $ getStream stream-                Right HistoryRetentionReleased{} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle first)-                stillBlocked <- runStoreIO store $ hardDeleteStream stream-                stillBlocked `shouldSatisfy` \case Left HistoryRetentionActive{} -> True; _ -> False-                Right HistoryRetentionReleased{} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle second)-                deleted <- runStoreIO store (hardDeleteStream stream)-                deleted `shouldSatisfy` \case Right (Just _) -> True; _ -> False-        events <- readIORef observed-        let conflictCounts =-                [ activeLeaseCount-                | KirokuEventHardDeleteHistoryRetentionConflict _ HistoryRetentionConflict{activeLeaseCount} <- reverse events-                ]-        conflictCounts `shouldBe` [2, 1]+  it "returns and emits a typed hard-delete conflict without changing history while any lease is active" $ do+    observed <- newIORef ([] :: [KirokuEvent])+    withTestStoreSettings+      (& #eventHandler .~ Just (\event -> modifyIORef' observed (event :)))+      $ \store -> do+        let stream = StreamName "history-retention-hard-delete"+        Right _ <- runStoreIO store $ appendToStream stream NoStream [makeEvent "Protected" (Aeson.object [])]+        countBefore <- countEvents store+        Right first <- runStoreIO store $ acquireHistoryRetentionLease (request "first" "protect" 60)+        Right second <- runStoreIO store $ acquireHistoryRetentionLease (request "second" "protect" 60)+        blocked <- runStoreIO store $ hardDeleteStream stream+        case blocked of+          Left (HistoryRetentionActive actual HistoryRetentionConflict {activeLeaseCount}) -> do+            actual `shouldBe` stream+            activeLeaseCount `shouldBe` 2+          other -> expectationFailure ("expected typed retention conflict, got " <> show other)+        countEvents store `shouldReturn` countBefore+        Right (Just _) <- runStoreIO store $ getStream stream+        Right HistoryRetentionReleased {} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle first)+        stillBlocked <- runStoreIO store $ hardDeleteStream stream+        stillBlocked `shouldSatisfy` \case Left HistoryRetentionActive {} -> True; _ -> False+        Right HistoryRetentionReleased {} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle second)+        deleted <- runStoreIO store (hardDeleteStream stream)+        deleted `shouldSatisfy` \case Right (Just _) -> True; _ -> False+    events <- readIORef observed+    let conflictCounts =+          [ activeLeaseCount+          | KirokuEventHardDeleteHistoryRetentionConflict _ HistoryRetentionConflict {activeLeaseCount} <- reverse events+          ]+    conflictCounts `shouldBe` [2, 1] -    describe "history retention raw SQL" $ do-        it "serializes lease-first acquisition ahead of raw deletion" $-            withTestStore $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "raw-race-lease-first") NoStream [makeEvent "Raw" (Aeson.object [])]-                countBefore <- countStreamEvents store-                acquisition <- Async.async $ runStoreIO store $ runTransaction $ do-                    lease <- acquireHistoryRetentionLeaseTx (request "raw-race" "lease-first" 60)-                    _ <- Tx.statement () holdCoordinatorStmt-                    pure lease-                waitForCoordinatorPhase store 100-                deletion <- Async.async (runRawDestruction store rawDeleteStreamEventsStmt)-                threadDelay 50_000-                Async.poll deletion >>= \case-                    Nothing -> pure ()-                    Just _ -> expectationFailure "raw deletion completed while lease acquisition held the coordinator"-                acquired <- waitWithin "lease-first acquisition" acquisition-                acquired `shouldSatisfy` \case Right HistoryRetentionLease{} -> True; _ -> False-                rejected <- waitWithin "lease-first raw deletion" deletion-                rejected `shouldSatisfy` hasSqlState "KR001"-                countStreamEvents store `shouldReturn` countBefore+  describe "history retention raw SQL" $ do+    it "serializes lease-first acquisition ahead of raw deletion" $+      withTestStore $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "raw-race-lease-first") NoStream [makeEvent "Raw" (Aeson.object [])]+        countBefore <- countStreamEvents store+        acquisition <- Async.async $ runStoreIO store $ runTransaction $ do+          lease <- acquireHistoryRetentionLeaseTx (request "raw-race" "lease-first" 60)+          _ <- Tx.statement () holdCoordinatorStmt+          pure lease+        waitForCoordinatorPhase store 100+        deletion <- Async.async (runRawDestruction store rawDeleteStreamEventsStmt)+        threadDelay 50_000+        Async.poll deletion >>= \case+          Nothing -> pure ()+          Just _ -> expectationFailure "raw deletion completed while lease acquisition held the coordinator"+        acquired <- waitWithin "lease-first acquisition" acquisition+        acquired `shouldSatisfy` \case Right HistoryRetentionLease {} -> True; _ -> False+        rejected <- waitWithin "lease-first raw deletion" deletion+        rejected `shouldSatisfy` hasSqlState "KR001"+        countStreamEvents store `shouldReturn` countBefore -        it "serializes delete-first maintenance ahead of post-delete acquisition" $-            withTestStore $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "raw-race-delete-first") NoStream [makeEvent "Raw" (Aeson.object [])]-                deletion <- Async.async (runRawDestructionHeld store rawDeleteStreamEventsStmt)-                waitForCoordinatorPhase store 100-                acquisition <- Async.async $ runStoreIO store $ acquireHistoryRetentionLease (request "raw-race" "delete-first" 60)-                threadDelay 50_000-                Async.poll acquisition >>= \case-                    Nothing -> pure ()-                    Just _ -> expectationFailure "lease acquisition completed while raw deletion held the coordinator"-                waitWithin "delete-first raw deletion" deletion `shouldReturn` Right ()-                acquired <- waitWithin "delete-first acquisition" acquisition-                acquired `shouldSatisfy` \case Right HistoryRetentionLease{} -> True; _ -> False-                countStreamEvents store `shouldReturn` 0+    it "serializes delete-first maintenance ahead of post-delete acquisition" $+      withTestStore $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "raw-race-delete-first") NoStream [makeEvent "Raw" (Aeson.object [])]+        deletion <- Async.async (runRawDestructionHeld store rawDeleteStreamEventsStmt)+        waitForCoordinatorPhase store 100+        acquisition <- Async.async $ runStoreIO store $ acquireHistoryRetentionLease (request "raw-race" "delete-first" 60)+        threadDelay 50_000+        Async.poll acquisition >>= \case+          Nothing -> pure ()+          Just _ -> expectationFailure "lease acquisition completed while raw deletion held the coordinator"+        waitWithin "delete-first raw deletion" deletion `shouldReturn` Right ()+        acquired <- waitWithin "delete-first acquisition" acquisition+        acquired `shouldSatisfy` \case Right HistoryRetentionLease {} -> True; _ -> False+        countStreamEvents store `shouldReturn` 0 -        it "rejects GUC-enabled DELETE with KR001 and permits it after release" $-            withTestStore $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "raw-delete") NoStream (replicate 2 (makeEvent "Raw" (Aeson.object [])))-                countBefore <- countStreamEvents store-                Right lease <- runStoreIO store $ acquireHistoryRetentionLease (request "raw" "delete" 60)-                rejected <- runRawDestruction store rawDeleteStreamEventsStmt-                rejected `shouldSatisfy` hasSqlState "KR001"-                countStreamEvents store `shouldReturn` countBefore-                Right HistoryRetentionReleased{} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle lease)-                runRawDestruction store rawDeleteStreamEventsStmt `shouldReturn` Right ()-                countStreamEvents store `shouldReturn` 0+    it "rejects GUC-enabled DELETE with KR001 and permits it after release" $+      withTestStore $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "raw-delete") NoStream (replicate 2 (makeEvent "Raw" (Aeson.object [])))+        countBefore <- countStreamEvents store+        Right lease <- runStoreIO store $ acquireHistoryRetentionLease (request "raw" "delete" 60)+        rejected <- runRawDestruction store rawDeleteStreamEventsStmt+        rejected `shouldSatisfy` hasSqlState "KR001"+        countStreamEvents store `shouldReturn` countBefore+        Right HistoryRetentionReleased {} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle lease)+        runRawDestruction store rawDeleteStreamEventsStmt `shouldReturn` Right ()+        countStreamEvents store `shouldReturn` 0 -        it "rejects GUC-enabled TRUNCATE with KR001 and permits it after release" $-            withTestStore $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "raw-truncate") NoStream [makeEvent "Raw" (Aeson.object [])]-                countBefore <- countEvents store-                Right lease <- runStoreIO store $ acquireHistoryRetentionLease (request "raw" "truncate" 60)-                rejected <- runRawDestruction store rawTruncateDataStmt-                rejected `shouldSatisfy` hasSqlState "KR001"-                countEvents store `shouldReturn` countBefore-                Right HistoryRetentionReleased{} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle lease)-                runRawDestruction store rawTruncateDataStmt `shouldReturn` Right ()-                countEvents store `shouldReturn` 0+    it "rejects GUC-enabled TRUNCATE with KR001 and permits it after release" $+      withTestStore $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "raw-truncate") NoStream [makeEvent "Raw" (Aeson.object [])]+        countBefore <- countEvents store+        Right lease <- runStoreIO store $ acquireHistoryRetentionLease (request "raw" "truncate" 60)+        rejected <- runRawDestruction store rawTruncateDataStmt+        rejected `shouldSatisfy` hasSqlState "KR001"+        countEvents store `shouldReturn` countBefore+        Right HistoryRetentionReleased {} <- runStoreIO store $ releaseHistoryRetentionLease (leaseHandle lease)+        runRawDestruction store rawTruncateDataStmt `shouldReturn` Right ()+        countEvents store `shouldReturn` 0 -        it "permits GUC-enabled maintenance after passive expiry" $-            withTestStore $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "raw-expiry") NoStream [makeEvent "Raw" (Aeson.object [])]-                Right _ <- runStoreIO store $ acquireHistoryRetentionLease (request "raw" "expiry" 1)-                threadDelay 1_100_000-                runRawDestruction store rawDeleteStreamEventsStmt `shouldReturn` Right ()+    it "permits GUC-enabled maintenance after passive expiry" $+      withTestStore $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "raw-expiry") NoStream [makeEvent "Raw" (Aeson.object [])]+        Right _ <- runStoreIO store $ acquireHistoryRetentionLease (request "raw" "expiry" 1)+        threadDelay 1_100_000+        runRawDestruction store rawDeleteStreamEventsStmt `shouldReturn` Right ()  runTx :: KirokuStore -> Tx.Transaction value -> IO value runTx store transaction = do-    result <- runStoreIO store (runTransaction transaction)-    case result of-        Left err -> expectationFailure ("history retention transaction failed: " <> show err) >> error "unreachable"-        Right value -> pure value+  result <- runStoreIO store (runTransaction transaction)+  case result of+    Left err -> expectationFailure ("history retention transaction failed: " <> show err) >> error "unreachable"+    Right value -> pure value  request :: Text.Text -> Text.Text -> Integer -> HistoryRetentionLeaseRequest request ownerText reasonText seconds =-    HistoryRetentionLeaseRequest-        { owner = validatedOwner ownerText-        , reason = either (error . show) (\value -> value) (mkHistoryRetentionLeaseReason reasonText)-        , duration = validatedDuration seconds-        }+  HistoryRetentionLeaseRequest+    { owner = validatedOwner ownerText,+      reason = either (error . show) (\value -> value) (mkHistoryRetentionLeaseReason reasonText),+      duration = validatedDuration seconds+    }  validatedOwner :: Text.Text -> HistoryRetentionLeaseOwner validatedOwner = either (error . show) (\value -> value) . mkHistoryRetentionLeaseOwner@@ -278,99 +278,99 @@  runRawDestruction :: KirokuStore -> Statement () () -> IO (Either Pool.UsageError ()) runRawDestruction store statement =-    Pool.use (store ^. #pool) $-        TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do-            Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"-            Tx.statement () statement+  Pool.use (store ^. #pool) $+    TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+      Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"+      Tx.statement () statement  runRawDestructionHeld :: KirokuStore -> Statement () () -> IO (Either Pool.UsageError ()) runRawDestructionHeld store statement =-    Pool.use (store ^. #pool) $-        TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do-            Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"-            Tx.statement () statement-            _ <- Tx.statement () holdCoordinatorStmt-            pure ()+  Pool.use (store ^. #pool) $+    TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+      Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"+      Tx.statement () statement+      _ <- Tx.statement () holdCoordinatorStmt+      pure ()  waitForCoordinatorPhase :: KirokuStore -> Int -> IO () waitForCoordinatorPhase _ 0 = expectationFailure "coordinator holder never reached its held phase" waitForCoordinatorPhase store attempts = do-    result <- Pool.use (store ^. #pool) (Session.statement () coordinatorActiveStmt)-    case result of-        Right True -> pure ()-        Right False -> threadDelay 10_000 >> waitForCoordinatorPhase store (attempts - 1)-        Left err -> expectationFailure ("could not observe coordinator phase: " <> show err)+  result <- Pool.use (store ^. #pool) (Session.statement () coordinatorActiveStmt)+  case result of+    Right True -> pure ()+    Right False -> threadDelay 10_000 >> waitForCoordinatorPhase store (attempts - 1)+    Left err -> expectationFailure ("could not observe coordinator phase: " <> show err)  waitWithin :: String -> Async.Async value -> IO value waitWithin label action = do-    result <- Async.race (threadDelay 2_000_000) (Async.wait action)-    case result of-        Left () -> do-            Async.cancel action-            expectationFailure (label <> " timed out")-            error "unreachable"-        Right value -> pure value+  result <- Async.race (threadDelay 2_000_000) (Async.wait action)+  case result of+    Left () -> do+      Async.cancel action+      expectationFailure (label <> " timed out")+      error "unreachable"+    Right value -> pure value  hasSqlState :: Text.Text -> Either Pool.UsageError value -> Bool hasSqlState expected = \case-    Left-        ( Pool.SessionUsageError-                ( Errors.StatementSessionError-                        _-                        _-                        _-                        _-                        _-                        (Errors.ServerStatementError (Errors.ServerError actual _ _ _ _))-                    )-            ) -> actual == expected-    _ -> False+  Left+    ( Pool.SessionUsageError+        ( Errors.StatementSessionError+            _+            _+            _+            _+            _+            (Errors.ServerStatementError (Errors.ServerError actual _ _ _ _))+          )+      ) -> actual == expected+  _ -> False  countStreamEvents :: KirokuStore -> IO Int64 countStreamEvents store = do-    result <- Pool.use (store ^. #pool) (TxSessions.transaction TxSessions.ReadCommitted TxSessions.Read (Tx.statement () countStreamEventsStmt))-    case result of-        Left err -> expectationFailure ("could not count stream junctions: " <> show err) >> error "unreachable"-        Right value -> pure value+  result <- Pool.use (store ^. #pool) (TxSessions.transaction TxSessions.ReadCommitted TxSessions.Read (Tx.statement () countStreamEventsStmt))+  case result of+    Left err -> expectationFailure ("could not count stream junctions: " <> show err) >> error "unreachable"+    Right value -> pure value  countStreamEventsStmt :: Statement () Int64 countStreamEventsStmt =-    unpreparable-        "SELECT count(*) FROM stream_events"-        E.noParams-        (D.singleRow (D.column (D.nonNullable D.int8)))+  unpreparable+    "SELECT count(*) FROM stream_events"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))  rawDeleteStreamEventsStmt :: Statement () () rawDeleteStreamEventsStmt =-    unpreparable-        "DELETE FROM stream_events"-        E.noParams-        D.noResult+  unpreparable+    "DELETE FROM stream_events"+    E.noParams+    D.noResult  rawTruncateDataStmt :: Statement () () rawTruncateDataStmt =-    unpreparable-        "TRUNCATE dead_letters, stream_events, events"-        E.noParams-        D.noResult+  unpreparable+    "TRUNCATE dead_letters, stream_events, events"+    E.noParams+    D.noResult  holdCoordinatorStmt :: Statement () Bool holdCoordinatorStmt =-    preparable-        "SELECT pg_sleep(0.4) IS NULL /* history-retention-coordinator-race */"-        E.noParams-        (D.singleRow (D.column (D.nonNullable D.bool)))+  preparable+    "SELECT pg_sleep(0.4) IS NULL /* history-retention-coordinator-race */"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))  coordinatorActiveStmt :: Statement () Bool coordinatorActiveStmt =-    preparable-        """-        SELECT EXISTS (-          SELECT 1-          FROM pg_stat_activity-          WHERE state = 'active'-            AND query = 'SELECT pg_sleep(0.4) IS NULL /* history-retention-coordinator-race */'-        )-        """-        E.noParams-        (D.singleRow (D.column (D.nonNullable D.bool)))+  preparable+    """+    SELECT EXISTS (+      SELECT 1+      FROM pg_stat_activity+      WHERE state = 'active'+        AND query = 'SELECT pg_sleep(0.4) IS NULL /* history-retention-coordinator-race */'+    )+    """+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))
test/Test/HistoryRetentionMock.hs view
@@ -16,71 +16,71 @@  spec :: Spec spec = describe "history retention mock" $ do-    it "dispatches every closed lease operation exactly once" $ do-        calls <- newIORef ([] :: [Text])-        actual <- runEff $ runMock calls $ do-            acquired <- acquireHistoryRetentionLease sampleRequest-            renewed <- renewHistoryRetentionLease sampleHandle sampleDuration-            released <- releaseHistoryRetentionLease sampleHandle-            inventory <- historyRetentionLeaseInventory sampleQuery-            pruned <- pruneHistoryRetentionLeases sampleTime-            pure (acquired, renewed, released, inventory, pruned)-        actual-            `shouldBe` ( sampleLease-                       , Right sampleLease-                       , HistoryRetentionReleased sampleLease-                       , Vector.singleton sampleLease-                       , HistoryRetentionPruneResult 2 3-                       )-        readIORef calls-            `shouldReturn` ["acquire", "renew", "release", "inventory", "prune"]+  it "dispatches every closed lease operation exactly once" $ do+    calls <- newIORef ([] :: [Text])+    actual <- runEff $ runMock calls $ do+      acquired <- acquireHistoryRetentionLease sampleRequest+      renewed <- renewHistoryRetentionLease sampleHandle sampleDuration+      released <- releaseHistoryRetentionLease sampleHandle+      inventory <- historyRetentionLeaseInventory sampleQuery+      pruned <- pruneHistoryRetentionLeases sampleTime+      pure (acquired, renewed, released, inventory, pruned)+    actual+      `shouldBe` ( sampleLease,+                   Right sampleLease,+                   HistoryRetentionReleased sampleLease,+                   Vector.singleton sampleLease,+                   HistoryRetentionPruneResult 2 3+                 )+    readIORef calls+      `shouldReturn` ["acquire", "renew", "release", "inventory", "prune"]  runMock :: (IOE :> es) => IORef [Text] -> Eff (Store : es) a -> Eff es a runMock calls = interpret_ $ \case-    AcquireHistoryRetentionLease request -> do-        liftIO $ request `shouldBe` sampleRequest-        record calls "acquire"-        pure sampleLease-    RenewHistoryRetentionLease handle duration -> do-        liftIO $ (handle, duration) `shouldBe` (sampleHandle, sampleDuration)-        record calls "renew"-        pure (Right sampleLease)-    ReleaseHistoryRetentionLease handle -> do-        liftIO $ handle `shouldBe` sampleHandle-        record calls "release"-        pure (HistoryRetentionReleased sampleLease)-    GetHistoryRetentionLeaseInventory query -> do-        liftIO $ query `shouldBe` sampleQuery-        record calls "inventory"-        pure (Vector.singleton sampleLease)-    PruneHistoryRetentionLeases cutoff -> do-        liftIO $ cutoff `shouldBe` sampleTime-        record calls "prune"-        pure (HistoryRetentionPruneResult 2 3)-    _ -> error "unexpected Store operation in history retention mock"+  AcquireHistoryRetentionLease request -> do+    liftIO $ request `shouldBe` sampleRequest+    record calls "acquire"+    pure sampleLease+  RenewHistoryRetentionLease handle duration -> do+    liftIO $ (handle, duration) `shouldBe` (sampleHandle, sampleDuration)+    record calls "renew"+    pure (Right sampleLease)+  ReleaseHistoryRetentionLease handle -> do+    liftIO $ handle `shouldBe` sampleHandle+    record calls "release"+    pure (HistoryRetentionReleased sampleLease)+  GetHistoryRetentionLeaseInventory query -> do+    liftIO $ query `shouldBe` sampleQuery+    record calls "inventory"+    pure (Vector.singleton sampleLease)+  PruneHistoryRetentionLeases cutoff -> do+    liftIO $ cutoff `shouldBe` sampleTime+    record calls "prune"+    pure (HistoryRetentionPruneResult 2 3)+  _ -> error "unexpected Store operation in history retention mock"  record :: (IOE :> es) => IORef [Text] -> Text -> Eff es () record calls name = liftIO $ modifyIORef' calls (<> [name])  sampleRequest :: HistoryRetentionLeaseRequest sampleRequest =-    HistoryRetentionLeaseRequest sampleOwner sampleReason sampleDuration+  HistoryRetentionLeaseRequest sampleOwner sampleReason sampleDuration  sampleHandle :: HistoryRetentionLeaseHandle sampleHandle = HistoryRetentionLeaseHandle sampleId sampleOwner  sampleLease :: HistoryRetentionLease sampleLease =-    HistoryRetentionLease-        sampleId-        sampleOwner-        sampleReason-        (GlobalPosition 17)-        sampleTime-        sampleTime-        sampleExpiry-        Nothing-        HistoryRetentionLeaseActive+  HistoryRetentionLease+    sampleId+    sampleOwner+    sampleReason+    (GlobalPosition 17)+    sampleTime+    sampleTime+    sampleExpiry+    Nothing+    HistoryRetentionLeaseActive  sampleId :: HistoryRetentionLeaseId sampleId = HistoryRetentionLeaseId UUID.nil@@ -96,8 +96,8 @@  sampleQuery :: HistoryRetentionInventoryQuery sampleQuery =-    HistoryRetentionInventoryQuery $-        either (error . show) (\value -> value) (mkHistoryRetentionInventoryLimit 10)+  HistoryRetentionInventoryQuery $+    either (error . show) (\value -> value) (mkHistoryRetentionInventoryLimit 10)  sampleTime :: UTCTime sampleTime = UTCTime (fromGregorian 2026 8 13) 0
test/Test/InterpreterHooks.hs view
@@ -1,17 +1,16 @@-{- | Tests for the interpreter-level event-data hooks installed via-'Kiroku.Store.Settings.StoreSettings'.--Three concerns are covered (added across the plan's milestones):--  * @enrichEvent@ fires on the append path before encoding, so an-    appended event surfaces with the hook's mutation visible.-  * @decodeHook@ fires on the read and subscription paths after-    decoding, so both 'readAllForward' and a live 'subscribe' handler-    see the hook's mutation.-  * With both hooks 'Nothing' (the default), a round-trip is-    byte-identical to the input — the no-op fast path introduces no-    'pure'-wrapping artefact.--}+-- | Tests for the interpreter-level event-data hooks installed via+-- 'Kiroku.Store.Settings.StoreSettings'.+--+-- Three concerns are covered (added across the plan's milestones):+--+--   * @enrichEvent@ fires on the append path before encoding, so an+--     appended event surfaces with the hook's mutation visible.+--   * @decodeHook@ fires on the read and subscription paths after+--     decoding, so both 'readAllForward' and a live 'subscribe' handler+--     see the hook's mutation.+--   * With both hooks 'Nothing' (the default), a round-trip is+--     byte-identical to the input — the no-op fast path introduces no+--     'pure'-wrapping artefact. module Test.InterpreterHooks (spec) where  import Control.Concurrent.MVar (newEmptyMVar, takeMVar, tryPutMVar)@@ -27,9 +26,9 @@  spec :: Spec spec = describe "InterpreterHooks" $ do-    describe "enrichEvent" appendHookFiresSpec-    describe "decodeHook" readHookFiresSpec-    describe "no hooks" noHookNoEffectSpec+  describe "enrichEvent" appendHookFiresSpec+  describe "decodeHook" readHookFiresSpec+  describe "no hooks" noHookNoEffectSpec  -- --------------------------------------------------------------------------- -- enrichEvent@@ -37,50 +36,50 @@  appendHookFiresSpec :: Spec appendHookFiresSpec = do-    it "applies enrichEvent to appended events surfaced through readStreamForward" $ do-        let marker = Aeson.object [("hook", Aeson.String "applied")]-            inject ed = pure $ ed & #metadata .~ Just marker-            tweak cs =-                cs-                    & #storeSettings-                        .~ defaultStoreSettings{enrichEvent = Just inject}-        withTestStoreSettings tweak $ \store -> do-            Right _ <--                runStoreIO store $-                    appendToStream-                        (StreamName "hook-append-1")-                        NoStream-                        [makeEvent "X" (Aeson.object [("seed", Aeson.Number 1)])]-            evs <--                runStoreIO store $-                    readStreamForward (StreamName "hook-append-1") (StreamVersion 0) 10-            case evs of-                Right v-                    | V.length v == 1 ->-                        (V.head v ^. #metadata) `shouldBe` Just marker-                other -> expectationFailure ("unexpected read result: " <> show other)+  it "applies enrichEvent to appended events surfaced through readStreamForward" $ do+    let marker = Aeson.object [("hook", Aeson.String "applied")]+        inject ed = pure $ ed & #metadata .~ Just marker+        tweak cs =+          cs+            & #storeSettings+              .~ defaultStoreSettings {enrichEvent = Just inject}+    withTestStoreSettings tweak $ \store -> do+      Right _ <-+        runStoreIO store $+          appendToStream+            (StreamName "hook-append-1")+            NoStream+            [makeEvent "X" (Aeson.object [("seed", Aeson.Number 1)])]+      evs <-+        runStoreIO store $+          readStreamForward (StreamName "hook-append-1") (StreamVersion 0) 10+      case evs of+        Right v+          | V.length v == 1 ->+              (V.head v ^. #metadata) `shouldBe` Just marker+        other -> expectationFailure ("unexpected read result: " <> show other) -    it "applies enrichEvent across appendMultiStream per-stream batches" $ do-        countRef <- newIORef (0 :: Int)-        let inject ed = do-                modifyIORef' countRef (+ 1)-                pure $ ed & #metadata .~ Just (Aeson.object [("multi", Aeson.Bool True)])-            tweak cs =-                cs-                    & #storeSettings-                        .~ defaultStoreSettings{enrichEvent = Just inject}-        withTestStoreSettings tweak $ \store -> do-            let ops =-                    [ (StreamName "hook-multi-A", NoStream, [makeEvent "A1" (Aeson.object []), makeEvent "A2" (Aeson.object [])])-                    , (StreamName "hook-multi-B", NoStream, [makeEvent "B1" (Aeson.object [])])-                    ]-            Right _ <- runStoreIO store $ appendMultiStream ops-            n <- readIORef countRef-            n `shouldBe` 3-            Right vA <- runStoreIO store $ readStreamForward (StreamName "hook-multi-A") (StreamVersion 0) 10-            Right vB <- runStoreIO store $ readStreamForward (StreamName "hook-multi-B") (StreamVersion 0) 10-            for_ (V.toList vA <> V.toList vB) $ \re ->-                (re ^. #metadata) `shouldBe` Just (Aeson.object [("multi", Aeson.Bool True)])+  it "applies enrichEvent across appendMultiStream per-stream batches" $ do+    countRef <- newIORef (0 :: Int)+    let inject ed = do+          modifyIORef' countRef (+ 1)+          pure $ ed & #metadata .~ Just (Aeson.object [("multi", Aeson.Bool True)])+        tweak cs =+          cs+            & #storeSettings+              .~ defaultStoreSettings {enrichEvent = Just inject}+    withTestStoreSettings tweak $ \store -> do+      let ops =+            [ (StreamName "hook-multi-A", NoStream, [makeEvent "A1" (Aeson.object []), makeEvent "A2" (Aeson.object [])]),+              (StreamName "hook-multi-B", NoStream, [makeEvent "B1" (Aeson.object [])])+            ]+      Right _ <- runStoreIO store $ appendMultiStream ops+      n <- readIORef countRef+      n `shouldBe` 3+      Right vA <- runStoreIO store $ readStreamForward (StreamName "hook-multi-A") (StreamVersion 0) 10+      Right vB <- runStoreIO store $ readStreamForward (StreamName "hook-multi-B") (StreamVersion 0) 10+      for_ (V.toList vA <> V.toList vB) $ \re ->+        (re ^. #metadata) `shouldBe` Just (Aeson.object [("multi", Aeson.Bool True)])  -- --------------------------------------------------------------------------- -- decodeHook@@ -88,82 +87,82 @@  readHookFiresSpec :: Spec readHookFiresSpec = do-    it "retains the no-hook batch without evaluating its events" $ do-        batch <- decodeEvents defaultStoreSettings (V.replicate 100 (error "no-hook decoding traversed the vector"))-        case batch of-            UnchangedBatch events -> V.length events `shouldBe` 100-            TransformedBatch _ -> expectationFailure "default decoding allocated per-event outcomes"+  it "retains the no-hook batch without evaluating its events" $ do+    batch <- decodeEvents defaultStoreSettings (V.replicate 100 (error "no-hook decoding traversed the vector"))+    case batch of+      UnchangedBatch events -> V.length events `shouldBe` 100+      TransformedBatch _ -> expectationFailure "default decoding allocated per-event outcomes" -    it "applies decodeHook to readAllForward results" $ do-        let marker = Aeson.object [("decoded", Aeson.String "yes")]-            inject re = pure $ Right (re & #metadata .~ Just marker)-            tweak cs =-                cs-                    & #storeSettings-                        .~ defaultStoreSettings{decodeHook = Just inject}-        withTestStoreSettings tweak $ \store -> do-            Right _ <--                runStoreIO store $-                    appendToStream-                        (StreamName "hook-decode-1")-                        NoStream-                        [ makeEvent "E1" (Aeson.object [])-                        , makeEvent "E2" (Aeson.object [])-                        , makeEvent "E3" (Aeson.object [])-                        ]-            Right v <--                runStoreIO store $-                    readAllForward (GlobalPosition 0) 10-            V.length v `shouldBe` 3-            for_ (V.toList v) $ \re ->-                (re ^. #metadata) `shouldBe` Just marker+  it "applies decodeHook to readAllForward results" $ do+    let marker = Aeson.object [("decoded", Aeson.String "yes")]+        inject re = pure $ Right (re & #metadata .~ Just marker)+        tweak cs =+          cs+            & #storeSettings+              .~ defaultStoreSettings {decodeHook = Just inject}+    withTestStoreSettings tweak $ \store -> do+      Right _ <-+        runStoreIO store $+          appendToStream+            (StreamName "hook-decode-1")+            NoStream+            [ makeEvent "E1" (Aeson.object []),+              makeEvent "E2" (Aeson.object []),+              makeEvent "E3" (Aeson.object [])+            ]+      Right v <-+        runStoreIO store $+          readAllForward (GlobalPosition 0) 10+      V.length v `shouldBe` 3+      for_ (V.toList v) $ \re ->+        (re ^. #metadata) `shouldBe` Just marker -    it "applies decodeHook to subscription handlers across catch-up and live phases" $ do-        caughtUp <- newEmptyMVar-        let marker = Aeson.object [("sub", Aeson.String "tagged")]-            inject re = pure $ Right (re & #metadata .~ Just marker)-            subName = SubscriptionName "hook-sub-1"-            tweak cs =-                cs-                    & #storeSettings-                        .~ defaultStoreSettings{decodeHook = Just inject}-                    & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)-        withTestStoreSettings tweak $ \store -> do-            -- Pre-append one event so the worker's catch-up path runs-            -- before live mode kicks in.-            Right warm <--                runStoreIO store $-                    appendToStream-                        (StreamName "hook-sub-stream-1")-                        NoStream-                        [makeEvent "warm" (Aeson.object [])]-            waitForPublisher store (warm ^. #globalPosition)+  it "applies decodeHook to subscription handlers across catch-up and live phases" $ do+    caughtUp <- newEmptyMVar+    let marker = Aeson.object [("sub", Aeson.String "tagged")]+        inject re = pure $ Right (re & #metadata .~ Just marker)+        subName = SubscriptionName "hook-sub-1"+        tweak cs =+          cs+            & #storeSettings+              .~ defaultStoreSettings {decodeHook = Just inject}+            & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)+    withTestStoreSettings tweak $ \store -> do+      -- Pre-append one event so the worker's catch-up path runs+      -- before live mode kicks in.+      Right warm <-+        runStoreIO store $+          appendToStream+            (StreamName "hook-sub-stream-1")+            NoStream+            [makeEvent "warm" (Aeson.object [])]+      waitForPublisher store (warm ^. #globalPosition) -            seen <- newIORef ([] :: [Maybe Aeson.Value])-            done <- newEmptyMVar-            let handlerFn re = do-                    modifyIORef' seen ((re ^. #metadata) :)-                    n <- length <$> readIORef seen-                    if n >= 2-                        then Stop <$ tryPutMVar done ()-                        else pure Continue-                config = defaultSubscriptionConfig subName AllStreams handlerFn-            sub <- subscribe store config-            waitForSubscriptionLive caughtUp-            -- Append a second event to land in live mode.-            Right _ <--                runStoreIO store $-                    appendToStream-                        (StreamName "hook-sub-stream-2")-                        NoStream-                        [makeEvent "live" (Aeson.object [])]-            takeMVar done-            res <- waitWithTimeout 2_000_000 sub-            case res of-                Right (Right ()) -> pure ()-                other -> expectationFailure ("subscription did not finish cleanly: " <> show other)-            metas <- reverse <$> readIORef seen-            metas `shouldBe` [Just marker, Just marker]+      seen <- newIORef ([] :: [Maybe Aeson.Value])+      done <- newEmptyMVar+      let handlerFn re = do+            modifyIORef' seen ((re ^. #metadata) :)+            n <- length <$> readIORef seen+            if n >= 2+              then Stop <$ tryPutMVar done ()+              else pure Continue+          config = defaultSubscriptionConfig subName AllStreams handlerFn+      sub <- subscribe store config+      waitForSubscriptionLive caughtUp+      -- Append a second event to land in live mode.+      Right _ <-+        runStoreIO store $+          appendToStream+            (StreamName "hook-sub-stream-2")+            NoStream+            [makeEvent "live" (Aeson.object [])]+      takeMVar done+      res <- waitWithTimeout 2_000_000 sub+      case res of+        Right (Right ()) -> pure ()+        other -> expectationFailure ("subscription did not finish cleanly: " <> show other)+      metas <- reverse <$> readIORef seen+      metas `shouldBe` [Just marker, Just marker]  -- --------------------------------------------------------------------------- -- no-op fast path@@ -171,17 +170,17 @@  noHookNoEffectSpec :: Spec noHookNoEffectSpec = around withTestStore $-    it "with defaultStoreSettings, a round-trip preserves payload and metadata exactly" $ \store -> do-        let originalMeta = Just (Aeson.object [("k", Aeson.String "v")])-            originalPayload = Aeson.object [("x", Aeson.Number 42), ("y", Aeson.String "z")]-            event = makeEvent "Identity" originalPayload & #metadata .~ originalMeta-        Right _ <--            runStoreIO store $-                appendToStream (StreamName "no-hook-roundtrip") NoStream [event]-        Right v <--            runStoreIO store $-                readStreamForward (StreamName "no-hook-roundtrip") (StreamVersion 0) 10-        V.length v `shouldBe` 1-        let re = V.head v-        (re ^. #payload) `shouldBe` originalPayload-        (re ^. #metadata) `shouldBe` originalMeta+  it "with defaultStoreSettings, a round-trip preserves payload and metadata exactly" $ \store -> do+    let originalMeta = Just (Aeson.object [("k", Aeson.String "v")])+        originalPayload = Aeson.object [("x", Aeson.Number 42), ("y", Aeson.String "z")]+        event = makeEvent "Identity" originalPayload & #metadata .~ originalMeta+    Right _ <-+      runStoreIO store $+        appendToStream (StreamName "no-hook-roundtrip") NoStream [event]+    Right v <-+      runStoreIO store $+        readStreamForward (StreamName "no-hook-roundtrip") (StreamVersion 0) 10+    V.length v `shouldBe` 1+    let re = V.head v+    (re ^. #payload) `shouldBe` originalPayload+    (re ^. #metadata) `shouldBe` originalMeta
+ test/Test/NamespaceQueryPlans.hs view
@@ -0,0 +1,98 @@+module Test.NamespaceQueryPlans (spec) where++import Control.Lens ((^.))+import Control.Monad (forM_, unless)+import Data.Aeson (Value (..))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString.Lazy qualified as LBS+import Data.Generics.Labels ()+import Data.Text (Text)+import Data.Text qualified as T+import Data.Vector qualified as V+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Hasql.Statement qualified as Statement+import Kiroku.Store+import Kiroku.Store.SQL qualified as SQL+import System.Environment (lookupEnv)+import Test.Helpers (withTestStore)+import Test.Hspec++spec :: Spec+spec = describe "namespace production query bounds" $+  forM_ [1000, 20000 :: Int] $ \total ->+    it ("bounds generic/custom scans and payload probes on " <> show total <> " rows") $+      withTestStore $ \store -> do+        run store (Session.script (fixture total))+        forM_ ["force_custom_plan", "force_generic_plan"] $ \mode ->+          forM_ [32, 1024 :: Int] $ \width ->+            forM_ ["pm", "pm:sparse", "absent"] $ \namespace ->+              forM_ [0, total - 100] $ \cursor ->+                forM_ [False, True] $ \grouped -> do+                  let query = if grouped then Statement.toSql SQL.readNamespaceGroupWindowStmt else Statement.toSql SQL.readNamespaceWindowStmt+                      types = if grouped then "int8,int8,text,int4,int4,int4" else "int8,int8,text,int4"+                      args = T.intercalate "," [t cursor, t total, "'" <> namespace <> "'", t width] <> if grouped then ",0,3" else ""+                  bytes <- run store $ do+                    Session.script ("SET plan_cache_mode=" <> mode <> "; PREPARE namespace_probe(" <> types <> ") AS " <> query)+                    bytes <- Session.statement () (Statement.unpreparable ("EXPLAIN (ANALYZE,BUFFERS,TIMING OFF,FORMAT JSON) EXECUTE namespace_probe(" <> args <> ")") E.noParams (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right)))))+                    Session.script "DEALLOCATE namespace_probe; RESET plan_cache_mode"+                    pure bytes+                  plan <- either fail pure (Aeson.eitherDecodeStrict' bytes)+                  evidence <- lookupEnv "EP54_PLAN_EVIDENCE"+                  forM_ evidence $ \path ->+                    LBS.appendFile+                      path+                      ( Aeson.encode+                          ( Aeson.object+                              [ "rows" Aeson..= total,+                                "mode" Aeson..= mode,+                                "width" Aeson..= width,+                                "namespace" Aeson..= namespace,+                                "cursor" Aeson..= cursor,+                                "grouped" Aeson..= grouped,+                                "explain" Aeson..= plan+                              ]+                          )+                          <> "\n"+                      )+                  let scans = nodes plan+                      relation name = filter (\obj -> KM.lookup "Relation Name" obj == Just (String name)) scans+                      countRows name = sum [num "Actual Loops" obj * (num "Actual Rows" obj + num "Rows Removed by Filter" obj) | obj <- relation name]+                      payloads = sum [num "Actual Loops" obj * num "Actual Rows" obj | obj <- relation "events"]+                      caseLabel = T.unpack (mode <> "/" <> namespace <> "/W=" <> t width <> "/cursor=" <> t cursor <> "/group=" <> T.pack (show grouped))+                  let verify condition = unless condition (expectationFailure (caseLabel <> "\n" <> show [(KM.lookup "Relation Name" obj, KM.lookup "Node Type" obj, KM.lookup "Index Name" obj, num "Actual Rows" obj, num "Actual Loops" obj, num "Rows Removed by Filter" obj) | obj <- scans, KM.member "Relation Name" obj]))+                  verify (null (relation "streams"))+                  verify (countRows "stream_events" <= fromIntegral width)+                  verify ([index | obj <- relation "stream_events", Just (String index) <- [KM.lookup "Index Name" obj]] == ["ux_stream_events_stream_version"])+                  verify (payloads <= fromIntegral (min width (total - cursor)))+                  if namespace == "absent" then verify (payloads == 0 && all (\obj -> num "Actual Loops" obj == 0) (relation "events")) else pure ()+                  forM_ (relation "events") $ \obj ->+                    verify (num "Actual Loops" obj == 0 || KM.lookup "Node Type" obj == Just (String "Index Scan"))++fixture :: Int -> Text+fixture count =+  T.unlines+    [ "WITH source AS (INSERT INTO streams(stream_name,stream_version) SELECT (CASE WHEN n % 100 = 0 THEN 'pm:sparse-' ELSE 'pm:dense-' END) || n,1 FROM generate_series(1," <> t count <> ") n RETURNING stream_id),",+      "f AS MATERIALIZED (SELECT stream_id, row_number() OVER (ORDER BY stream_id) AS pos, uuidv7() AS id FROM source),",+      "e AS (INSERT INTO events(event_id,event_type,data) SELECT id,'Fixture','{}' FROM f RETURNING event_id),",+      "se AS (INSERT INTO stream_events(event_id,stream_id,stream_version,original_stream_id,original_stream_version) SELECT id,stream_id,1,stream_id,1 FROM f JOIN e ON event_id=id RETURNING event_id)",+      "INSERT INTO stream_events(event_id,stream_id,stream_version,original_stream_id,original_stream_version,category) SELECT id,0,pos,stream_id,1, CASE WHEN pos % 100 = 0 THEN 'pm:sparse' ELSE 'pm:dense' END FROM f JOIN se ON event_id=id;",+      "UPDATE streams SET stream_version=" <> t count <> " WHERE stream_id=0; ANALYZE streams; ANALYZE stream_events; ANALYZE events;"+    ]++nodes :: Value -> [Aeson.Object]+nodes (Object fields) = fields : concatMap nodes (KM.elems fields)+nodes (Array values) = concatMap nodes (V.toList values)+nodes _ = []++num :: Aeson.Key -> Aeson.Object -> Double+num key obj = case KM.lookup key obj of Just (Number n) -> realToFrac n; _ -> 0++t :: (Show a) => a -> Text+t = T.pack . show++run :: KirokuStore -> Session.Session a -> IO a+run store action = Pool.use (store ^. #pool) action >>= either (fail . show) pure
test/Test/NotifyGuard.hs view
@@ -21,101 +21,101 @@  spec :: Spec spec =-    describe "NOTIFY trigger guard" $-        around withNotifyStore $ do-            it "emits one unchanged append payload and no lifecycle or $all payloads" $ \(store, listenerConn) -> do-                payloads <- newTVarIO []+  describe "NOTIFY trigger guard" $+    around withNotifyStore $ do+      it "emits one unchanged append payload and no lifecycle or $all payloads" $ \(store, listenerConn) -> do+        payloads <- newTVarIO [] -                Notifications.listen listenerConn (Notifications.toPgIdentifier "kiroku.events")-                Async.withAsync-                    (Notifications.waitForNotifications (\_ payload -> atomically (modifyTVar' payloads (<> [payload]))) listenerConn)-                    $ \_listener -> do-                        let firstStream = StreamName "notify-guard-1"-                            secondStream = StreamName "notify-guard-2"+        Notifications.listen listenerConn (Notifications.toPgIdentifier "kiroku.events")+        Async.withAsync+          (Notifications.waitForNotifications (\_ payload -> atomically (modifyTVar' payloads (<> [payload]))) listenerConn)+          $ \_listener -> do+            let firstStream = StreamName "notify-guard-1"+                secondStream = StreamName "notify-guard-2" -                        Right first <--                            runStoreIO store $-                                appendToStream firstStream NoStream [makeEvent "NotifyGuardFirst" (Aeson.object [])]-                        waitForPayload payloads (expectedPayload firstStream first)+            Right first <-+              runStoreIO store $+                appendToStream firstStream NoStream [makeEvent "NotifyGuardFirst" (Aeson.object [])]+            waitForPayload payloads (expectedPayload firstStream first) -                        Right _ <- runStoreIO store $ softDeleteStream firstStream-                        Right _ <- runStoreIO store $ undeleteStream firstStream+            Right _ <- runStoreIO store $ softDeleteStream firstStream+            Right _ <- runStoreIO store $ undeleteStream firstStream -                        Right second <--                            runStoreIO store $-                                appendToStream secondStream NoStream [makeEvent "NotifyGuardSecond" (Aeson.object [])]-                        waitForPayload payloads (expectedPayload secondStream second)+            Right second <-+              runStoreIO store $+                appendToStream secondStream NoStream [makeEvent "NotifyGuardSecond" (Aeson.object [])]+            waitForPayload payloads (expectedPayload secondStream second) -                        actual <- atomically (readTVar payloads)-                        actual-                            `shouldBe` [ expectedPayload firstStream first-                                       , expectedPayload secondStream second-                                       ]-                        map payloadFieldCount actual `shouldBe` [3, 3]-                        actual `shouldSatisfy` all (not . BS.isPrefixOf "$all,")+            actual <- atomically (readTVar payloads)+            actual+              `shouldBe` [ expectedPayload firstStream first,+                           expectedPayload secondStream second+                         ]+            map payloadFieldCount actual `shouldBe` [3, 3]+            actual `shouldSatisfy` all (not . BS.isPrefixOf "$all,") -            it "emits the unchanged payload for a 512-byte stream name" $ \(store, listenerConn) -> do-                payloads <- newTVarIO []+      it "emits the unchanged payload for a 512-byte stream name" $ \(store, listenerConn) -> do+        payloads <- newTVarIO [] -                Notifications.listen listenerConn (Notifications.toPgIdentifier "kiroku.events")-                Async.withAsync-                    (Notifications.waitForNotifications (\_ payload -> atomically (modifyTVar' payloads (<> [payload]))) listenerConn)-                    $ \_listener -> do-                        let stream = StreamName (T.replicate 512 "n")+        Notifications.listen listenerConn (Notifications.toPgIdentifier "kiroku.events")+        Async.withAsync+          (Notifications.waitForNotifications (\_ payload -> atomically (modifyTVar' payloads (<> [payload]))) listenerConn)+          $ \_listener -> do+            let stream = StreamName (T.replicate 512 "n") -                        Right result <--                            runStoreIO store $-                                appendToStream stream NoStream [makeEvent "NotifyGuardMaxName" (Aeson.object [])]-                        let expected = expectedPayload stream result-                        waitForPayload payloads expected+            Right result <-+              runStoreIO store $+                appendToStream stream NoStream [makeEvent "NotifyGuardMaxName" (Aeson.object [])]+            let expected = expectedPayload stream result+            waitForPayload payloads expected -                        actual <- atomically (readTVar payloads)-                        actual `shouldBe` [expected]-                        payloadFieldCount expected `shouldBe` 3+            actual <- atomically (readTVar payloads)+            actual `shouldBe` [expected]+            payloadFieldCount expected `shouldBe` 3  withNotifyStore :: ((KirokuStore, Connection.Connection) -> IO ()) -> IO () withNotifyStore action =-    withMigratedTestDatabase $ \connStr ->-        withStore (defaultConnectionSettings connStr) $ \store ->-            withConnection connStr $ \listenerConn ->-                action (store, listenerConn)+  withMigratedTestDatabase $ \connStr ->+    withStore (defaultConnectionSettings connStr) $ \store ->+      withConnection connStr $ \listenerConn ->+        action (store, listenerConn)  withConnection :: T.Text -> (Connection.Connection -> IO a) -> IO a withConnection connStr =-    bracket acquire Connection.release+  bracket acquire Connection.release   where     acquire = do-        result <- Connection.acquire (Conn.connectionString connStr)-        case result of-            Left err -> throwIO (userError ("failed to acquire LISTEN connection: " <> show err))-            Right conn -> pure conn+      result <- Connection.acquire (Conn.connectionString connStr)+      case result of+        Left err -> throwIO (userError ("failed to acquire LISTEN connection: " <> show err))+        Right conn -> pure conn  expectedPayload :: StreamName -> AppendResult -> ByteString expectedPayload (StreamName name) result =-    let StreamId sid = result ^. #streamId-        StreamVersion version = result ^. #streamVersion-     in T.encodeUtf8 $-            T.intercalate-                ","-                [ name-                , T.pack (show sid)-                , T.pack (show version)-                ]+  let StreamId sid = result ^. #streamId+      StreamVersion version = result ^. #streamVersion+   in T.encodeUtf8 $+        T.intercalate+          ","+          [ name,+            T.pack (show sid),+            T.pack (show version)+          ]  payloadFieldCount :: ByteString -> Int payloadFieldCount = length . BS.split 44  waitForPayload :: TVar [ByteString] -> ByteString -> IO () waitForPayload payloads expected = do-    result <--        Async.race-            (threadDelay 5_000_000)-            ( atomically $ do-                seen <- readTVar payloads-                check (expected `elem` seen)-            )-    case result of-        Left () -> do-            seen <- atomically (readTVar payloads)-            expectationFailure ("timed out waiting for notification " <> show expected <> "; saw " <> show seen)-        Right () -> pure ()+  result <-+    Async.race+      (threadDelay 5_000_000)+      ( atomically $ do+          seen <- readTVar payloads+          check (expected `elem` seen)+      )+  case result of+    Left () -> do+      seen <- atomically (readTVar payloads)+      expectationFailure ("timed out waiting for notification " <> show expected <> "; saw " <> show seen)+    Right () -> pure ()
test/Test/PerformanceStructure.hs view
@@ -33,618 +33,616 @@  spec :: Spec spec = do-    noOpAppendSpec-    queryPlanSpec-    DeadLetterQueryPlans.spec-    categoryReadCostSpec-    streamHeadQuerySpec--noOpAppendSpec :: Spec-noOpAppendSpec =-    describe "no-op paths use no pooled connection" $ do-        it "rejects invalid dead-letter page sizes before pool checkout" $ do-            checkouts <- newIORef (0 :: Int)-            withObservedStore checkouts $ \_ -> do-                before <- readIORef checkouts-                forM_ [0, -1, 1001] $ \n -> mkSubscriptionDeadLetterLimit n `shouldBe` Left (SubscriptionDeadLetterLimitOutOfRange n)-                after <- readIORef checkouts-                after - before `shouldBe` 0--        it "rejects invalid batch and buffer sizes before pool checkout" $ do-            checkouts <- newIORef (0 :: Int)-            withObservedStore checkouts $ \_ -> do-                before <- readIORef checkouts-                mkBatchSize 0 `shouldBe` Left (InvalidBatchSize 0)-                mkBatchSize (-1) `shouldBe` Left (InvalidBatchSize (-1))-                Buffer.mkStreamBufferSize 0 `shouldBe` Left (Buffer.InvalidStreamBufferSize 0)-                after <- readIORef checkouts-                after - before `shouldBe` 0--        it "rejects invalid consumer-group values before pool checkout" $ do-            checkouts <- newIORef (0 :: Int)-            withObservedStore checkouts $ \_ -> do-                before <- readIORef checkouts-                mkConsumerGroupSize 0 `shouldBe` Left (InvalidConsumerGroup 0 0)-                let Right n = mkConsumerGroupSize 2-                mkConsumerGroup 2 n `shouldBe` Left (InvalidConsumerGroup 2 2)-                after <- readIORef checkouts-                after - before `shouldBe` 0--        it "refuses mismatched topology in exactly one initialization checkout with no handler" $ do-            workerThread <- newIORef Nothing-            checkouts <- newIORef (0 :: Int)-            delivered <- newIORef False-            let observe (ConnectionObservation _ InUseConnectionStatus) = do-                    thread <- myThreadId-                    selected <- readIORef workerThread-                    if selected == Just thread then modifyIORef' checkouts (+ 1) else pure ()-                observe _ = pure ()-            withTestStoreSettings (\settings -> settings{observationHandler = Just observe}) $ \store -> do-                seeded <- Pool.use (store ^. #pool) (Session.statement ("topology-checkout", 0, 0, 2, "unbound", Nothing) SQL.saveCheckpointMemberStmt)-                seeded `shouldBe` Right ()-                withLoadCheckpointHookForTest-                    ( \_ -> do-                        thread <- myThreadId-                        writeIORef workerThread (Just thread)-                        pure Nothing-                    )-                    $ do-                        let cfg =-                                (defaultSubscriptionConfig (SubscriptionName "topology-checkout") AllStreams (\_ -> writeIORef delivered True >> pure Continue))-                                    { consumerGroup = Just (validConsumerGroup 0 3)-                                    }-                        handle <- subscribe store cfg-                        waitWithTimeout 5_000_000 handle >>= \case-                            Right (Left exception) ->-                                (fromException exception :: Maybe ConsumerGroupSizeMismatch) `shouldSatisfy` maybe False (const True)-                            other -> expectationFailure ("expected mismatch, got " <> show other)-                readIORef checkouts `shouldReturn` 1-                readIORef delivered `shouldReturn` False--        it "refuses mismatched target in exactly one initialization checkout with no handler" $ do-            workerThread <- newIORef Nothing-            checkouts <- newIORef (0 :: Int)-            delivered <- newIORef False-            let observe (ConnectionObservation _ InUseConnectionStatus) = do-                    thread <- myThreadId-                    selected <- readIORef workerThread-                    if selected == Just thread then modifyIORef' checkouts (+ 1) else pure ()-                observe _ = pure ()-            withTestStoreSettings (\settings -> settings{observationHandler = Just observe}) $ \store -> do-                seeded <- Pool.use (store ^. #pool) (Session.statement ("target-checkout", 0, 0, 1, "all", Nothing) SQL.saveCheckpointMemberStmt)-                seeded `shouldBe` Right ()-                withLoadCheckpointHookForTest-                    ( \_ -> do-                        thread <- myThreadId-                        writeIORef workerThread (Just thread)-                        pure Nothing-                    )-                    $ do-                        let cfg =-                                (defaultSubscriptionConfig (SubscriptionName "target-checkout") (Category (CategoryName "other")) (\_ -> writeIORef delivered True >> pure Continue))--                        handle <- subscribe store cfg-                        waitWithTimeout 5_000_000 handle >>= \case-                            Right (Left exception) ->-                                (fromException exception :: Maybe SubscriptionTargetMismatch) `shouldSatisfy` maybe False (const True)-                            other -> expectationFailure ("expected mismatch, got " <> show other)-                readIORef checkouts `shouldReturn` 1-                readIORef delivered `shouldReturn` False--        it "keeps ordinary checkpoint saves as one unconditional monotonic upsert" $ do-            forM_ [Statement.toSql SQL.saveCheckpointMemberStmt, Statement.toSql SQL.saveAllCheckpointMemberStmt, Statement.toSql SQL.saveCategoryCheckpointMemberStmt] $ \statement -> do-                let sql = T.toLower statement-                T.count "insert into" sql `shouldBe` 1-                sql `shouldSatisfy` T.isInfixOf "greatest(subscriptions.last_seen, excluded.last_seen)"-                sql `shouldNotSatisfy` T.isInfixOf "where"-                sql `shouldNotSatisfy` T.isInfixOf "returning"--        it "rejects an empty appendToStream batch before pool checkout" $ do-            checkouts <- newIORef (0 :: Int)-            withObservedStore checkouts $ \store -> do-                checkoutsBefore <- readIORef checkouts-                result <- runStoreIO store $ appendToStream (StreamName "performance-empty-append") AnyVersion []-                checkoutsAfter <- readIORef checkouts-                result `shouldBe` Left (EmptyAppendBatch (StreamName "performance-empty-append"))-                checkoutsAfter - checkoutsBefore `shouldBe` 0--        it "returns an empty appendMultiStream result before pool checkout" $ do-            checkouts <- newIORef (0 :: Int)-            withObservedStore checkouts $ \store -> do-                checkoutsBefore <- readIORef checkouts-                result <- runStoreIO store $ appendMultiStream []-                checkoutsAfter <- readIORef checkouts-                result `shouldBe` Right []-                checkoutsAfter - checkoutsBefore `shouldBe` 0--        it "rejects invalid retention requests before pool checkout" $ do-            checkouts <- newIORef (0 :: Int)-            withObservedStore checkouts $ \_store -> do-                checkoutsBefore <- readIORef checkouts-                mkHistoryRetentionLeaseOwner "" `shouldSatisfy` either (const True) (const False)-                mkHistoryRetentionLeaseReason "" `shouldSatisfy` either (const True) (const False)-                mkHistoryRetentionInventoryLimit 0 `shouldSatisfy` either (const True) (const False)-                checkoutsAfter <- readIORef checkouts-                checkoutsAfter - checkoutsBefore `shouldBe` 0--        it "keeps every ordinary statement free of retention coordination" $ do-            let ordinarySql =-                    [ Statement.toSql SQL.appendExpectedVersion-                    , Statement.toSql SQL.appendStreamExists-                    , Statement.toSql SQL.appendNoStream-                    , Statement.toSql SQL.appendAnyVersion-                    , Statement.toSql SQL.readStreamForwardStmt-                    , Statement.toSql SQL.readAllForwardStmt-                    , Statement.toSql SQL.softDeleteStreamStmt-                    , Statement.toSql SQL.undeleteStreamStmt-                    , Statement.toSql SQL.setStreamTruncateBeforeStmt-                    ]-            forM_ ordinarySql $ \sql ->-                T.toLower sql `shouldNotSatisfy` T.isInfixOf "history_retention"--queryPlanSpec :: Spec-queryPlanSpec =-    describe "production query plans" $-        aroundAll withQueryPlanStore $ do-            it "visible global head lookup uses ux_stream_events_stream_version without Sort" $ \store -> do-                plan <--                    explainProductionStatement-                        store-                        SQL.visibleGlobalHeadPositionStmt-                        []-                expectIndex "ux_stream_events_stream_version" plan-                expectNoNodeType "Sort" plan--            it "dead-letter reads use ix_dead_letters_subscription_position without Sort" $ \store -> do-                plan <--                    explainProductionStatement-                        store-                        SQL.readDeadLettersStmt-                        [ ("$2", "0::int4")-                        , ("$1", "'performance-read'::text")-                        ]-                expectIndex "ix_dead_letters_subscription_position" plan-                expectNoNodeType "Sort" plan--            it "orphan dead-letter cleanup uses ix_dead_letters_event_id" $ \store -> do-                plan <--                    explainProductionStatement-                        store-                        SQL.deleteDeadLettersForOrphanedEventsStmt-                        [("$1", "ARRAY['00000000-0000-0000-0000-000000000001'::uuid]::uuid[]")]-                expectIndex "ix_dead_letters_event_id" plan--            it "active retention lookup uses ix_history_retention_leases_unreleased_expiry" $ \store -> do-                plan <- explainProductionStatement store activeLeaseLookupStmt []-                expectIndex "ix_history_retention_leases_unreleased_expiry" plan--            it "installs no retention trigger for INSERT or UPDATE" $ \store -> do-                result <- Pool.use (store ^. #pool) (Session.statement () retentionTriggerShapeStmt)-                case result of-                    Left err -> expectationFailure ("could not inspect retention triggers: " <> show err)-                    Right shape -> shape `shouldBe` (6, 0)--{- | BUG-2. A category read's cost must follow the rows it returns, not the-number of streams in the category. The store holds the category-scaling-fixture: @performance@ with 200 streams, @idle@ with 20,000 one-event streams,-and @noise@, head at 'categoryScalingHead'.--}-categoryReadCostSpec :: Spec-categoryReadCostSpec =-    describe "category read cost" $-        aroundAll withCategoryScalingStore $ do-            it "category high-cursor reads use ix_stream_events_all_by_category without Sort" $ \store -> do-                plan <--                    explainProductionStatement-                        store-                        SQL.readCategoryForwardStmt-                        [ ("$3", "100::int4")-                        , ("$2", "'performance'::text")-                        , ("$1", "15000::bigint")-                        ]-                expectIndex "ix_stream_events_all_by_category" plan-                expectNoNodeType "Sort" plan--            it "consumer-group category reads use ix_stream_events_all_by_category without Sort" $ \store -> do-                plan <--                    explainProductionStatement-                        store-                        SQL.readCategoryForwardConsumerGroupStmt-                        [ ("$5", "100::int4")-                        , ("$4", "2::int4")-                        , ("$3", "1::int4")-                        , ("$2", "'performance'::text")-                        , ("$1", "15000::bigint")-                        ]-                expectIndex "ix_stream_events_all_by_category" plan-                expectNoNodeType "Sort" plan--            it "category caught-up poll on 20000 idle streams reads at most 32 buffers" $ \store -> do-                let cursor = T.pack (show categoryScalingHead) <> "::bigint"-                    plainAt =-                        [ ("$3", "100::int4")-                        , ("$2", "'idle'::text")-                        , ("$1", cursor)-                        ]-                    groupAt member =-                        [ ("$5", "100::int4")-                        , ("$4", "2::int4")-                        , ("$3", T.pack (show member) <> "::int4")-                        , ("$2", "'idle'::text")-                        , ("$1", cursor)-                        ]-                    groupMembers = [0, 1 :: Int]--                plainIdle <- explainAnalyzeBuffers store SQL.readCategoryForwardStmt plainAt-                expectBufferBudget "plain caught-up poll" plainIdle-                snd plainIdle `shouldBe` 0-                groupIdle <- mapM (explainAnalyzeBuffers store SQL.readCategoryForwardConsumerGroupStmt . groupAt) groupMembers-                mapM_ (expectBufferBudget "group caught-up poll") groupIdle-                map snd groupIdle `shouldBe` [0, 0]--                appended <- runStoreIO store $ appendToStream (StreamName "idle-1") AnyVersion [makeEvent "IdleWake" Null]-                appended `shouldSatisfy` either (const False) (const True)--                plainOne <- explainAnalyzeBuffers store SQL.readCategoryForwardStmt plainAt-                expectBufferBudget "plain poll after one append" plainOne-                snd plainOne `shouldBe` 1-                groupOne <- mapM (explainAnalyzeBuffers store SQL.readCategoryForwardConsumerGroupStmt . groupAt) groupMembers-                mapM_ (expectBufferBudget "group poll after one append") groupOne-                -- Exactly one member owns idle-1.-                sum (map snd groupOne) `shouldBe` 1--withCategoryScalingStore :: (KirokuStore -> IO ()) -> IO ()-withCategoryScalingStore action =-    withTestStore $ \store -> do-        result <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)-        case result of-            Left err -> expectationFailure ("failed to seed category-scaling fixture: " <> show err)-            Right () -> action store---- | The G1 budget: a category poll that returns at most one row.-expectBufferBudget :: String -> (Int64, Int64) -> Expectation-expectBufferBudget label (buffers, _) =-    unless (buffers <= 32) $-        expectationFailure $-            label-                <> ": expected at most 32 shared buffers, but the plan read "-                <> show buffers--{- | Execute a production statement under @EXPLAIN (ANALYZE, BUFFERS)@ and-return the top plan node's shared buffers (hit plus read) and actual rows.-Planning buffers are excluded; they are not paid by a prepared statement.--}-explainAnalyzeBuffers ::-    KirokuStore ->-    Statement params result ->-    [(Text, Text)] ->-    IO (Int64, Int64)-explainAnalyzeBuffers store productionStatement replacements = do-    plan <--        explainWith-            "EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, FORMAT JSON)\n"-            store-            productionStatement-            replacements-    case plan of-        Array entries-            | Just (Object entry) <- headMay entries-            , Just (Object top) <- KeyMap.lookup "Plan" entry ->-                pure-                    ( numberField "Shared Hit Blocks" top + numberField "Shared Read Blocks" top-                    , numberField "Actual Rows" top-                    )-        _ -> expectationFailure ("unexpected EXPLAIN shape: " <> show plan) >> fail "unreachable"-  where-    headMay values = case foldr (:) [] values of-        value : _ -> Just value-        [] -> Nothing-    numberField key object = case KeyMap.lookup key object of-        Just value | Aeson.Success (number :: Double) <- Aeson.fromJSON value -> round number-        _ -> 0--withObservedStore :: IORef Int -> (KirokuStore -> IO ()) -> IO ()-withObservedStore checkouts =-    withTestStoreSettings $ \settings ->-        settings-            { observationHandler =-                Just $ \case-                    ConnectionObservation _ InUseConnectionStatus -> modifyIORef' checkouts (+ 1)-                    _ -> pure ()-            }--withQueryPlanStore :: (KirokuStore -> IO ()) -> IO ()-withQueryPlanStore action =-    withTestStore $ \store -> do-        result <- Pool.use (store ^. #pool) (Session.script queryPlanFixture)-        case result of-            Left err -> expectationFailure ("failed to seed performance query-plan fixture: " <> show err)-            Right () -> action store--queryPlanFixture :: Text-queryPlanFixture =-    """-    BEGIN;--    WITH new_streams AS (-      INSERT INTO streams (stream_name, stream_version)-      SELECT 'performance-' || n::text, 100-      FROM generate_series(1, 200) AS n-      RETURNING stream_id-    ), fixture_events AS MATERIALIZED (-      SELECT uuidv7() AS event_id,-             s.stream_id,-             per_stream_position::bigint AS stream_version,-             row_number() OVER (ORDER BY per_stream_position, s.stream_id)::bigint AS global_position-      FROM new_streams AS s-      CROSS JOIN generate_series(1, 100) AS per_stream_position-    ), inserted_events AS (-      INSERT INTO events (event_id, event_type, data)-      SELECT event_id, 'PerformanceFixture', '{}'::jsonb-      FROM fixture_events-      RETURNING event_id-    ), source_links AS (-      INSERT INTO stream_events-        (event_id, stream_id, stream_version, original_stream_id, original_stream_version)-      SELECT fixture.event_id,-             fixture.stream_id,-             fixture.stream_version,-             fixture.stream_id,-             fixture.stream_version-      FROM fixture_events AS fixture-      JOIN inserted_events USING (event_id)-      RETURNING event_id-    ), all_links AS (-      INSERT INTO stream_events-        (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)-      SELECT fixture.event_id,-             0,-             fixture.global_position,-             fixture.stream_id,-             fixture.stream_version,-             'performance'-      FROM fixture_events AS fixture-      JOIN inserted_events USING (event_id)-      RETURNING event_id-    ), advanced_all AS (-      UPDATE streams-      SET stream_version = (SELECT max(global_position) FROM fixture_events)-      WHERE stream_id = 0-      RETURNING stream_id-    ), inserted_dead_letters AS (-      INSERT INTO dead_letters-        (subscription_name, consumer_group_member, global_position, event_id,-         reason, reason_summary, attempt_count)-      SELECT CASE-               WHEN fixture.global_position % 10 = 0 THEN 'performance-read'-               ELSE 'performance-other-' || (fixture.global_position % 9)::text-             END,-             0,-             fixture.global_position,-             fixture.event_id,-             '{}'::jsonb,-             'performance fixture',-             1-      FROM fixture_events AS fixture-      JOIN inserted_events USING (event_id)-      RETURNING dead_letter_id-    )-    SELECT (SELECT count(*) FROM source_links),-           (SELECT count(*) FROM all_links),-           (SELECT count(*) FROM advanced_all),-           (SELECT count(*) FROM inserted_dead_letters);--    COMMIT;-    ANALYZE streams;-    ANALYZE events;-    ANALYZE stream_events;-    ANALYZE dead_letters;-    INSERT INTO history_retention_leases-      (owner, reason, protected_through, created_at, renewed_at, expires_at, released_at)-    SELECT 'performance-owner',-           'performance-reason',-           0,-           clock_timestamp() - interval '1 day',-           clock_timestamp() - interval '1 day',-           clock_timestamp() + interval '1 hour',-           CASE WHEN n <= 9900 THEN clock_timestamp() ELSE NULL END-    FROM generate_series(1, 10000) AS n;-    ANALYZE history_retention_leases;-    """--explainProductionStatement ::-    KirokuStore ->-    Statement params result ->-    [(Text, Text)] ->-    IO Value-explainProductionStatement = explainWith "EXPLAIN (FORMAT JSON, COSTS OFF)\n"--explainWith ::-    Text ->-    KirokuStore ->-    Statement params result ->-    [(Text, Text)] ->-    IO Value-explainWith explainPrefix store productionStatement replacements = do-    let productionSql = Statement.toSql productionStatement-        explainedSql =-            explainPrefix-                <> foldl' (\sql (placeholder, literal) -> T.replace placeholder literal sql) productionSql replacements-        explainStatement :: Statement () ByteString-        explainStatement =-            unpreparable-                explainedSql-                E.noParams-                (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right))))-    result <- Pool.use (store ^. #pool) (Session.statement () explainStatement)-    bytes <- case result of-        Left err -> expectationFailure ("EXPLAIN failed: " <> show err) >> fail "unreachable"-        Right value -> pure value-    case Aeson.eitherDecodeStrict' bytes of-        Left err -> expectationFailure ("could not decode EXPLAIN JSON: " <> err) >> fail "unreachable"-        Right value -> pure value--data PlanFacts = PlanFacts-    { nodeTypes :: [Text]-    , indexNames :: [Text]-    }-    deriving stock (Show)--instance Semigroup PlanFacts where-    PlanFacts nodeTypesA indexNamesA <> PlanFacts nodeTypesB indexNamesB =-        PlanFacts (nodeTypesA <> nodeTypesB) (indexNamesA <> indexNamesB)--instance Monoid PlanFacts where-    mempty = PlanFacts [] []--collectPlanFacts :: Value -> PlanFacts-collectPlanFacts (Object object) =-    PlanFacts-        { nodeTypes = maybe [] pure (textField "Node Type" object)-        , indexNames = maybe [] pure (textField "Index Name" object)-        }-        <> foldMap collectPlanFacts (KeyMap.elems object)-collectPlanFacts (Array values) = foldMap collectPlanFacts values-collectPlanFacts _ = mempty--textField :: Aeson.Key -> Aeson.Object -> Maybe Text-textField key object = case KeyMap.lookup key object of-    Just (String value) -> Just value-    _ -> Nothing--expectIndex :: Text -> Value -> Expectation-expectIndex expected plan = do-    let facts = collectPlanFacts plan-    unless (expected `elem` indexNames facts) $-        expectationFailure $-            "expected plan to use index "-                <> T.unpack expected-                <> ", but collected "-                <> show facts-                <> " from:\n"-                <> show plan--expectNoNodeType :: Text -> Value -> Expectation-expectNoNodeType forbidden plan = do-    let facts = collectPlanFacts plan-    unless (forbidden `notElem` nodeTypes facts) $-        expectationFailure $-            "expected plan not to contain node type "-                <> T.unpack forbidden-                <> ", but collected "-                <> show facts-                <> " from:\n"-                <> show plan--activeLeaseLookupStmt :: Statement () ()-activeLeaseLookupStmt =-    Statement.preparable-        """-        SELECT count(*), min(expires_at)-        FROM history_retention_leases-        WHERE released_at IS NULL-          AND expires_at > clock_timestamp()-        HAVING count(*) > 0-        """-        E.noParams-        D.noResult--retentionTriggerShapeStmt :: Statement () (Int64, Int64)-retentionTriggerShapeStmt =-    Statement.preparable-        """-        SELECT count(*),-               count(*) FILTER (WHERE (trigger.tgtype & 4) <> 0 OR (trigger.tgtype & 16) <> 0)-        FROM pg_catalog.pg_trigger AS trigger-        JOIN pg_catalog.pg_class AS relation ON relation.oid = trigger.tgrelid-        JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = relation.relnamespace-        WHERE namespace.nspname = 'kiroku'-          AND relation.relname IN ('events', 'stream_events', 'streams')-          AND trigger.tgname IN ('protect_replay_history_delete', 'protect_replay_history_truncate')-          AND NOT trigger.tgisinternal-        """-        E.noParams-        ( D.singleRow-            ( (,)-                <$> D.column (D.nonNullable D.int8)-                <*> D.column (D.nonNullable D.int8)-            )-        )--streamHeadQuerySpec :: Spec-streamHeadQuerySpec = describe "stream head query work" $-    aroundAll withStreamHeadStore $-        forM_ ["bench-1", "long-1", "empty-1", "missing-1"] $ \name ->-            it ("bounds literal and warmed prepared probes for " <> T.unpack name) $ \store -> do-                let literal = "'" <> name <> "'"-                    prefix = "EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) "-                direct <- explainWith prefix store SQL.getStreamWithHeadStmt [("$1", literal)]-                prepared <- explainStreamHeadPrepared store literal-                forM_ [("literal", direct), ("prepared", prepared)] $ \(kind, plan) -> do-                    -- Emit complete JSON into retained test logs, including buffers and loops.-                    putStrLn ("STREAM_HEAD_PLAN " <> T.unpack name <> " " <> kind <> " " <> show (Aeson.encode plan))-                    expectIndex "ix_stream_events_all_by_origin" plan-                    expectNoNodeType "Sort" plan-                    let nodes = planObjects plan-                        probes = filter ((== Just "stream_events") . textField "Relation Name") nodes-                        limits = filter ((== Just "Limit") . textField "Node Type") nodes-                    length probes `shouldBe` 1-                    length limits `shouldBe` 1-                    forM_ probes $ \probe -> do-                        textField "Node Type" probe `shouldSatisfy` (`elem` [Just "Index Scan", Just "Index Only Scan"])-                        textField "Scan Direction" probe `shouldBe` Just "Backward"-                        planNumber "Actual Rows" probe `shouldSatisfy` (<= 1)-                        if name == "missing-1" then planNumber "Actual Loops" probe `shouldBe` 0 else pure ()-                    forM_ limits $ \limit -> do-                        expectIndex "ix_stream_events_all_by_origin" (Object limit)-                        planNumber "Actual Rows" limit `shouldSatisfy` (<= 1)-                    map (textField "Relation Name") nodes `shouldNotContain` [Just "events"]-                    case plan of-                        Array entries-                            | Object entry : _ <- foldr (:) [] entries-                            , Just (Object top) <- KeyMap.lookup "Plan" entry -> do-                                planNumber "Shared Hit Blocks" top + planNumber "Shared Read Blocks" top `shouldSatisfy` (<= 32)-                                planNumber "Actual Rows" top `shouldBe` (if name == "missing-1" then 0 else 1)-                        _ -> expectationFailure (show plan)-                actual <- runStoreIO store (getStreamWithHead (StreamName name))-                case name of-                    "missing-1" -> actual `shouldBe` Right Nothing-                    "empty-1" -> case actual of-                        Right (Just (_, headPosition)) -> headPosition `shouldBe` Nothing-                        _ -> expectationFailure (show actual)-                    _ -> case actual of-                        Right (Just (_, Just _)) -> pure ()-                        _ -> expectationFailure (show actual)--withStreamHeadStore :: (KirokuStore -> IO ()) -> IO ()-withStreamHeadStore action = withTestStore $ \store -> do-    forM_ [streamHeadFixtureSql, "VACUUM (ANALYZE) stream_events"] $ \command ->-        Pool.use (store ^. #pool) (Session.script command) >>= either (fail . show) pure-    action store--explainStreamHeadPrepared :: KirokuStore -> Text -> IO Value-explainStreamHeadPrepared store literal = do-    result <- Pool.use (store ^. #pool) $ do-        Session.script ("PREPARE ep97_head(text) AS " <> Statement.toSql SQL.getStreamWithHeadStmt)-        -- Default auto policy, past PostgreSQL's first five custom executions.-        Session.script (T.replicate 6 ("EXECUTE ep97_head(" <> literal <> ");"))-        bytes <--            Session.statement () $-                unpreparable-                    ("EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) EXECUTE ep97_head(" <> literal <> ")")-                    E.noParams-                    (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right))))-        Session.script "DEALLOCATE ep97_head"-        pure bytes-    bytes <- either (fail . show) pure result-    either fail pure (Aeson.eitherDecodeStrict' bytes)--planObjects :: Value -> [Aeson.Object]-planObjects (Object fields) = fields : concatMap planObjects (KeyMap.elems fields)-planObjects (Array values) = concatMap planObjects (foldr (:) [] values)-planObjects _ = []--planNumber :: Aeson.Key -> Aeson.Object -> Double-planNumber key fields = case KeyMap.lookup key fields of-    Just (Number n) -> realToFrac n-    _ -> 0+  noOpAppendSpec+  queryPlanSpec+  DeadLetterQueryPlans.spec+  categoryReadCostSpec+  streamHeadQuerySpec++noOpAppendSpec :: Spec+noOpAppendSpec =+  describe "no-op paths use no pooled connection" $ do+    it "rejects invalid dead-letter page sizes before pool checkout" $ do+      checkouts <- newIORef (0 :: Int)+      withObservedStore checkouts $ \_ -> do+        before <- readIORef checkouts+        forM_ [0, -1, 1001] $ \n -> mkSubscriptionDeadLetterLimit n `shouldBe` Left (SubscriptionDeadLetterLimitOutOfRange n)+        after <- readIORef checkouts+        after - before `shouldBe` 0++    it "rejects invalid batch and buffer sizes before pool checkout" $ do+      checkouts <- newIORef (0 :: Int)+      withObservedStore checkouts $ \_ -> do+        before <- readIORef checkouts+        mkBatchSize 0 `shouldBe` Left (InvalidBatchSize 0)+        mkBatchSize (-1) `shouldBe` Left (InvalidBatchSize (-1))+        Buffer.mkStreamBufferSize 0 `shouldBe` Left (Buffer.InvalidStreamBufferSize 0)+        after <- readIORef checkouts+        after - before `shouldBe` 0++    it "rejects invalid consumer-group values before pool checkout" $ do+      checkouts <- newIORef (0 :: Int)+      withObservedStore checkouts $ \_ -> do+        before <- readIORef checkouts+        mkConsumerGroupSize 0 `shouldBe` Left (InvalidConsumerGroup 0 0)+        let Right n = mkConsumerGroupSize 2+        mkConsumerGroup 2 n `shouldBe` Left (InvalidConsumerGroup 2 2)+        after <- readIORef checkouts+        after - before `shouldBe` 0++    it "refuses mismatched topology in exactly one initialization checkout with no handler" $ do+      workerThread <- newIORef Nothing+      checkouts <- newIORef (0 :: Int)+      delivered <- newIORef False+      let observe (ConnectionObservation _ InUseConnectionStatus) = do+            thread <- myThreadId+            selected <- readIORef workerThread+            if selected == Just thread then modifyIORef' checkouts (+ 1) else pure ()+          observe _ = pure ()+      withTestStoreSettings (\settings -> settings {observationHandler = Just observe}) $ \store -> do+        seeded <- Pool.use (store ^. #pool) (Session.statement ("topology-checkout", 0, 0, 2, "unbound", Nothing) SQL.saveCheckpointMemberStmt)+        seeded `shouldBe` Right ()+        withLoadCheckpointHookForTest+          ( \_ -> do+              thread <- myThreadId+              writeIORef workerThread (Just thread)+              pure Nothing+          )+          $ do+            let cfg =+                  (defaultSubscriptionConfig (SubscriptionName "topology-checkout") AllStreams (\_ -> writeIORef delivered True >> pure Continue))+                    { consumerGroup = Just (validConsumerGroup 0 3)+                    }+            handle <- subscribe store cfg+            waitWithTimeout 5_000_000 handle >>= \case+              Right (Left exception) ->+                (fromException exception :: Maybe ConsumerGroupSizeMismatch) `shouldSatisfy` maybe False (const True)+              other -> expectationFailure ("expected mismatch, got " <> show other)+        readIORef checkouts `shouldReturn` 1+        readIORef delivered `shouldReturn` False++    it "refuses mismatched target in exactly one initialization checkout with no handler" $ do+      workerThread <- newIORef Nothing+      checkouts <- newIORef (0 :: Int)+      delivered <- newIORef False+      let observe (ConnectionObservation _ InUseConnectionStatus) = do+            thread <- myThreadId+            selected <- readIORef workerThread+            if selected == Just thread then modifyIORef' checkouts (+ 1) else pure ()+          observe _ = pure ()+      withTestStoreSettings (\settings -> settings {observationHandler = Just observe}) $ \store -> do+        seeded <- Pool.use (store ^. #pool) (Session.statement ("target-checkout", 0, 0, 1, "all", Nothing) SQL.saveCheckpointMemberStmt)+        seeded `shouldBe` Right ()+        withLoadCheckpointHookForTest+          ( \_ -> do+              thread <- myThreadId+              writeIORef workerThread (Just thread)+              pure Nothing+          )+          $ do+            let cfg =+                  (defaultSubscriptionConfig (SubscriptionName "target-checkout") (Category (CategoryName "other")) (\_ -> writeIORef delivered True >> pure Continue))++            handle <- subscribe store cfg+            waitWithTimeout 5_000_000 handle >>= \case+              Right (Left exception) ->+                (fromException exception :: Maybe SubscriptionTargetMismatch) `shouldSatisfy` maybe False (const True)+              other -> expectationFailure ("expected mismatch, got " <> show other)+        readIORef checkouts `shouldReturn` 1+        readIORef delivered `shouldReturn` False++    it "keeps ordinary checkpoint saves as one unconditional monotonic upsert" $ do+      forM_ [Statement.toSql SQL.saveCheckpointMemberStmt, Statement.toSql SQL.saveAllCheckpointMemberStmt, Statement.toSql SQL.saveCategoryCheckpointMemberStmt] $ \statement -> do+        let sql = T.toLower statement+        T.count "insert into" sql `shouldBe` 1+        sql `shouldSatisfy` T.isInfixOf "greatest(subscriptions.last_seen, excluded.last_seen)"+        sql `shouldNotSatisfy` T.isInfixOf "where"+        sql `shouldNotSatisfy` T.isInfixOf "returning"++    it "rejects an empty appendToStream batch before pool checkout" $ do+      checkouts <- newIORef (0 :: Int)+      withObservedStore checkouts $ \store -> do+        checkoutsBefore <- readIORef checkouts+        result <- runStoreIO store $ appendToStream (StreamName "performance-empty-append") AnyVersion []+        checkoutsAfter <- readIORef checkouts+        result `shouldBe` Left (EmptyAppendBatch (StreamName "performance-empty-append"))+        checkoutsAfter - checkoutsBefore `shouldBe` 0++    it "returns an empty appendMultiStream result before pool checkout" $ do+      checkouts <- newIORef (0 :: Int)+      withObservedStore checkouts $ \store -> do+        checkoutsBefore <- readIORef checkouts+        result <- runStoreIO store $ appendMultiStream []+        checkoutsAfter <- readIORef checkouts+        result `shouldBe` Right []+        checkoutsAfter - checkoutsBefore `shouldBe` 0++    it "rejects invalid retention requests before pool checkout" $ do+      checkouts <- newIORef (0 :: Int)+      withObservedStore checkouts $ \_store -> do+        checkoutsBefore <- readIORef checkouts+        mkHistoryRetentionLeaseOwner "" `shouldSatisfy` either (const True) (const False)+        mkHistoryRetentionLeaseReason "" `shouldSatisfy` either (const True) (const False)+        mkHistoryRetentionInventoryLimit 0 `shouldSatisfy` either (const True) (const False)+        checkoutsAfter <- readIORef checkouts+        checkoutsAfter - checkoutsBefore `shouldBe` 0++    it "keeps every ordinary statement free of retention coordination" $ do+      let ordinarySql =+            [ Statement.toSql SQL.appendExpectedVersion,+              Statement.toSql SQL.appendStreamExists,+              Statement.toSql SQL.appendNoStream,+              Statement.toSql SQL.appendAnyVersion,+              Statement.toSql SQL.readStreamForwardStmt,+              Statement.toSql SQL.readAllForwardStmt,+              Statement.toSql SQL.softDeleteStreamStmt,+              Statement.toSql SQL.undeleteStreamStmt,+              Statement.toSql SQL.setStreamTruncateBeforeStmt+            ]+      forM_ ordinarySql $ \sql ->+        T.toLower sql `shouldNotSatisfy` T.isInfixOf "history_retention"++queryPlanSpec :: Spec+queryPlanSpec =+  describe "production query plans" $+    aroundAll withQueryPlanStore $ do+      it "visible global head lookup uses ux_stream_events_stream_version without Sort" $ \store -> do+        plan <-+          explainProductionStatement+            store+            SQL.visibleGlobalHeadPositionStmt+            []+        expectIndex "ux_stream_events_stream_version" plan+        expectNoNodeType "Sort" plan++      it "dead-letter reads use ix_dead_letters_subscription_position without Sort" $ \store -> do+        plan <-+          explainProductionStatement+            store+            SQL.readDeadLettersStmt+            [ ("$2", "0::int4"),+              ("$1", "'performance-read'::text")+            ]+        expectIndex "ix_dead_letters_subscription_position" plan+        expectNoNodeType "Sort" plan++      it "orphan dead-letter cleanup uses ix_dead_letters_event_id" $ \store -> do+        plan <-+          explainProductionStatement+            store+            SQL.deleteDeadLettersForOrphanedEventsStmt+            [("$1", "ARRAY['00000000-0000-0000-0000-000000000001'::uuid]::uuid[]")]+        expectIndex "ix_dead_letters_event_id" plan++      it "active retention lookup uses ix_history_retention_leases_unreleased_expiry" $ \store -> do+        plan <- explainProductionStatement store activeLeaseLookupStmt []+        expectIndex "ix_history_retention_leases_unreleased_expiry" plan++      it "installs no retention trigger for INSERT or UPDATE" $ \store -> do+        result <- Pool.use (store ^. #pool) (Session.statement () retentionTriggerShapeStmt)+        case result of+          Left err -> expectationFailure ("could not inspect retention triggers: " <> show err)+          Right shape -> shape `shouldBe` (6, 0)++-- | BUG-2. A category read's cost must follow the rows it returns, not the+-- number of streams in the category. The store holds the category-scaling+-- fixture: @performance@ with 200 streams, @idle@ with 20,000 one-event streams,+-- and @noise@, head at 'categoryScalingHead'.+categoryReadCostSpec :: Spec+categoryReadCostSpec =+  describe "category read cost" $+    aroundAll withCategoryScalingStore $ do+      it "category high-cursor reads use ix_stream_events_all_by_category without Sort" $ \store -> do+        plan <-+          explainProductionStatement+            store+            SQL.readCategoryForwardStmt+            [ ("$3", "100::int4"),+              ("$2", "'performance'::text"),+              ("$1", "15000::bigint")+            ]+        expectIndex "ix_stream_events_all_by_category" plan+        expectNoNodeType "Sort" plan++      it "consumer-group category reads use ix_stream_events_all_by_category without Sort" $ \store -> do+        plan <-+          explainProductionStatement+            store+            SQL.readCategoryForwardConsumerGroupStmt+            [ ("$5", "100::int4"),+              ("$4", "2::int4"),+              ("$3", "1::int4"),+              ("$2", "'performance'::text"),+              ("$1", "15000::bigint")+            ]+        expectIndex "ix_stream_events_all_by_category" plan+        expectNoNodeType "Sort" plan++      it "category caught-up poll on 20000 idle streams reads at most 32 buffers" $ \store -> do+        let cursor = T.pack (show categoryScalingHead) <> "::bigint"+            plainAt =+              [ ("$3", "100::int4"),+                ("$2", "'idle'::text"),+                ("$1", cursor)+              ]+            groupAt member =+              [ ("$5", "100::int4"),+                ("$4", "2::int4"),+                ("$3", T.pack (show member) <> "::int4"),+                ("$2", "'idle'::text"),+                ("$1", cursor)+              ]+            groupMembers = [0, 1 :: Int]++        plainIdle <- explainAnalyzeBuffers store SQL.readCategoryForwardStmt plainAt+        expectBufferBudget "plain caught-up poll" plainIdle+        snd plainIdle `shouldBe` 0+        groupIdle <- mapM (explainAnalyzeBuffers store SQL.readCategoryForwardConsumerGroupStmt . groupAt) groupMembers+        mapM_ (expectBufferBudget "group caught-up poll") groupIdle+        map snd groupIdle `shouldBe` [0, 0]++        appended <- runStoreIO store $ appendToStream (StreamName "idle-1") AnyVersion [makeEvent "IdleWake" Null]+        appended `shouldSatisfy` either (const False) (const True)++        plainOne <- explainAnalyzeBuffers store SQL.readCategoryForwardStmt plainAt+        expectBufferBudget "plain poll after one append" plainOne+        snd plainOne `shouldBe` 1+        groupOne <- mapM (explainAnalyzeBuffers store SQL.readCategoryForwardConsumerGroupStmt . groupAt) groupMembers+        mapM_ (expectBufferBudget "group poll after one append") groupOne+        -- Exactly one member owns idle-1.+        sum (map snd groupOne) `shouldBe` 1++withCategoryScalingStore :: (KirokuStore -> IO ()) -> IO ()+withCategoryScalingStore action =+  withTestStore $ \store -> do+    result <- Pool.use (store ^. #pool) (Session.script categoryScalingFixtureSql)+    case result of+      Left err -> expectationFailure ("failed to seed category-scaling fixture: " <> show err)+      Right () -> action store++-- | The G1 budget: a category poll that returns at most one row.+expectBufferBudget :: String -> (Int64, Int64) -> Expectation+expectBufferBudget label (buffers, _) =+  unless (buffers <= 32) $+    expectationFailure $+      label+        <> ": expected at most 32 shared buffers, but the plan read "+        <> show buffers++-- | Execute a production statement under @EXPLAIN (ANALYZE, BUFFERS)@ and+-- return the top plan node's shared buffers (hit plus read) and actual rows.+-- Planning buffers are excluded; they are not paid by a prepared statement.+explainAnalyzeBuffers ::+  KirokuStore ->+  Statement params result ->+  [(Text, Text)] ->+  IO (Int64, Int64)+explainAnalyzeBuffers store productionStatement replacements = do+  plan <-+    explainWith+      "EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, FORMAT JSON)\n"+      store+      productionStatement+      replacements+  case plan of+    Array entries+      | Just (Object entry) <- headMay entries,+        Just (Object top) <- KeyMap.lookup "Plan" entry ->+          pure+            ( numberField "Shared Hit Blocks" top + numberField "Shared Read Blocks" top,+              numberField "Actual Rows" top+            )+    _ -> expectationFailure ("unexpected EXPLAIN shape: " <> show plan) >> fail "unreachable"+  where+    headMay values = case foldr (:) [] values of+      value : _ -> Just value+      [] -> Nothing+    numberField key object = case KeyMap.lookup key object of+      Just value | Aeson.Success (number :: Double) <- Aeson.fromJSON value -> round number+      _ -> 0++withObservedStore :: IORef Int -> (KirokuStore -> IO ()) -> IO ()+withObservedStore checkouts =+  withTestStoreSettings $ \settings ->+    settings+      { observationHandler =+          Just $ \case+            ConnectionObservation _ InUseConnectionStatus -> modifyIORef' checkouts (+ 1)+            _ -> pure ()+      }++withQueryPlanStore :: (KirokuStore -> IO ()) -> IO ()+withQueryPlanStore action =+  withTestStore $ \store -> do+    result <- Pool.use (store ^. #pool) (Session.script queryPlanFixture)+    case result of+      Left err -> expectationFailure ("failed to seed performance query-plan fixture: " <> show err)+      Right () -> action store++queryPlanFixture :: Text+queryPlanFixture =+  """+  BEGIN;++  WITH new_streams AS (+    INSERT INTO streams (stream_name, stream_version)+    SELECT 'performance-' || n::text, 100+    FROM generate_series(1, 200) AS n+    RETURNING stream_id+  ), fixture_events AS MATERIALIZED (+    SELECT uuidv7() AS event_id,+           s.stream_id,+           per_stream_position::bigint AS stream_version,+           row_number() OVER (ORDER BY per_stream_position, s.stream_id)::bigint AS global_position+    FROM new_streams AS s+    CROSS JOIN generate_series(1, 100) AS per_stream_position+  ), inserted_events AS (+    INSERT INTO events (event_id, event_type, data)+    SELECT event_id, 'PerformanceFixture', '{}'::jsonb+    FROM fixture_events+    RETURNING event_id+  ), source_links AS (+    INSERT INTO stream_events+      (event_id, stream_id, stream_version, original_stream_id, original_stream_version)+    SELECT fixture.event_id,+           fixture.stream_id,+           fixture.stream_version,+           fixture.stream_id,+           fixture.stream_version+    FROM fixture_events AS fixture+    JOIN inserted_events USING (event_id)+    RETURNING event_id+  ), all_links AS (+    INSERT INTO stream_events+      (event_id, stream_id, stream_version, original_stream_id, original_stream_version, category)+    SELECT fixture.event_id,+           0,+           fixture.global_position,+           fixture.stream_id,+           fixture.stream_version,+           'performance'+    FROM fixture_events AS fixture+    JOIN inserted_events USING (event_id)+    RETURNING event_id+  ), advanced_all AS (+    UPDATE streams+    SET stream_version = (SELECT max(global_position) FROM fixture_events)+    WHERE stream_id = 0+    RETURNING stream_id+  ), inserted_dead_letters AS (+    INSERT INTO dead_letters+      (subscription_name, consumer_group_member, global_position, event_id,+       reason, reason_summary, attempt_count)+    SELECT CASE+             WHEN fixture.global_position % 10 = 0 THEN 'performance-read'+             ELSE 'performance-other-' || (fixture.global_position % 9)::text+           END,+           0,+           fixture.global_position,+           fixture.event_id,+           '{}'::jsonb,+           'performance fixture',+           1+    FROM fixture_events AS fixture+    JOIN inserted_events USING (event_id)+    RETURNING dead_letter_id+  )+  SELECT (SELECT count(*) FROM source_links),+         (SELECT count(*) FROM all_links),+         (SELECT count(*) FROM advanced_all),+         (SELECT count(*) FROM inserted_dead_letters);++  COMMIT;+  ANALYZE streams;+  ANALYZE events;+  ANALYZE stream_events;+  ANALYZE dead_letters;+  INSERT INTO history_retention_leases+    (owner, reason, protected_through, created_at, renewed_at, expires_at, released_at)+  SELECT 'performance-owner',+         'performance-reason',+         0,+         clock_timestamp() - interval '1 day',+         clock_timestamp() - interval '1 day',+         clock_timestamp() + interval '1 hour',+         CASE WHEN n <= 9900 THEN clock_timestamp() ELSE NULL END+  FROM generate_series(1, 10000) AS n;+  ANALYZE history_retention_leases;+  """++explainProductionStatement ::+  KirokuStore ->+  Statement params result ->+  [(Text, Text)] ->+  IO Value+explainProductionStatement = explainWith "EXPLAIN (FORMAT JSON, COSTS OFF)\n"++explainWith ::+  Text ->+  KirokuStore ->+  Statement params result ->+  [(Text, Text)] ->+  IO Value+explainWith explainPrefix store productionStatement replacements = do+  let productionSql = Statement.toSql productionStatement+      explainedSql =+        explainPrefix+          <> foldl' (\sql (placeholder, literal) -> T.replace placeholder literal sql) productionSql replacements+      explainStatement :: Statement () ByteString+      explainStatement =+        unpreparable+          explainedSql+          E.noParams+          (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right))))+  result <- Pool.use (store ^. #pool) (Session.statement () explainStatement)+  bytes <- case result of+    Left err -> expectationFailure ("EXPLAIN failed: " <> show err) >> fail "unreachable"+    Right value -> pure value+  case Aeson.eitherDecodeStrict' bytes of+    Left err -> expectationFailure ("could not decode EXPLAIN JSON: " <> err) >> fail "unreachable"+    Right value -> pure value++data PlanFacts = PlanFacts+  { nodeTypes :: [Text],+    indexNames :: [Text]+  }+  deriving stock (Show)++instance Semigroup PlanFacts where+  PlanFacts nodeTypesA indexNamesA <> PlanFacts nodeTypesB indexNamesB =+    PlanFacts (nodeTypesA <> nodeTypesB) (indexNamesA <> indexNamesB)++instance Monoid PlanFacts where+  mempty = PlanFacts [] []++collectPlanFacts :: Value -> PlanFacts+collectPlanFacts (Object object) =+  PlanFacts+    { nodeTypes = maybe [] pure (textField "Node Type" object),+      indexNames = maybe [] pure (textField "Index Name" object)+    }+    <> foldMap collectPlanFacts (KeyMap.elems object)+collectPlanFacts (Array values) = foldMap collectPlanFacts values+collectPlanFacts _ = mempty++textField :: Aeson.Key -> Aeson.Object -> Maybe Text+textField key object = case KeyMap.lookup key object of+  Just (String value) -> Just value+  _ -> Nothing++expectIndex :: Text -> Value -> Expectation+expectIndex expected plan = do+  let facts = collectPlanFacts plan+  unless (expected `elem` indexNames facts) $+    expectationFailure $+      "expected plan to use index "+        <> T.unpack expected+        <> ", but collected "+        <> show facts+        <> " from:\n"+        <> show plan++expectNoNodeType :: Text -> Value -> Expectation+expectNoNodeType forbidden plan = do+  let facts = collectPlanFacts plan+  unless (forbidden `notElem` nodeTypes facts) $+    expectationFailure $+      "expected plan not to contain node type "+        <> T.unpack forbidden+        <> ", but collected "+        <> show facts+        <> " from:\n"+        <> show plan++activeLeaseLookupStmt :: Statement () ()+activeLeaseLookupStmt =+  Statement.preparable+    """+    SELECT count(*), min(expires_at)+    FROM history_retention_leases+    WHERE released_at IS NULL+      AND expires_at > clock_timestamp()+    HAVING count(*) > 0+    """+    E.noParams+    D.noResult++retentionTriggerShapeStmt :: Statement () (Int64, Int64)+retentionTriggerShapeStmt =+  Statement.preparable+    """+    SELECT count(*),+           count(*) FILTER (WHERE (trigger.tgtype & 4) <> 0 OR (trigger.tgtype & 16) <> 0)+    FROM pg_catalog.pg_trigger AS trigger+    JOIN pg_catalog.pg_class AS relation ON relation.oid = trigger.tgrelid+    JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = relation.relnamespace+    WHERE namespace.nspname = 'kiroku'+      AND relation.relname IN ('events', 'stream_events', 'streams')+      AND trigger.tgname IN ('protect_replay_history_delete', 'protect_replay_history_truncate')+      AND NOT trigger.tgisinternal+    """+    E.noParams+    ( D.singleRow+        ( (,)+            <$> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.int8)+        )+    )++streamHeadQuerySpec :: Spec+streamHeadQuerySpec = describe "stream head query work" $+  aroundAll withStreamHeadStore $+    forM_ ["bench-1", "long-1", "empty-1", "missing-1"] $ \name ->+      it ("bounds literal and warmed prepared probes for " <> T.unpack name) $ \store -> do+        let literal = "'" <> name <> "'"+            prefix = "EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) "+        direct <- explainWith prefix store SQL.getStreamWithHeadStmt [("$1", literal)]+        prepared <- explainStreamHeadPrepared store literal+        forM_ [("literal", direct), ("prepared", prepared)] $ \(kind, plan) -> do+          -- Emit complete JSON into retained test logs, including buffers and loops.+          putStrLn ("STREAM_HEAD_PLAN " <> T.unpack name <> " " <> kind <> " " <> show (Aeson.encode plan))+          expectIndex "ix_stream_events_all_by_origin" plan+          expectNoNodeType "Sort" plan+          let nodes = planObjects plan+              probes = filter ((== Just "stream_events") . textField "Relation Name") nodes+              limits = filter ((== Just "Limit") . textField "Node Type") nodes+          length probes `shouldBe` 1+          length limits `shouldBe` 1+          forM_ probes $ \probe -> do+            textField "Node Type" probe `shouldSatisfy` (`elem` [Just "Index Scan", Just "Index Only Scan"])+            textField "Scan Direction" probe `shouldBe` Just "Backward"+            planNumber "Actual Rows" probe `shouldSatisfy` (<= 1)+            if name == "missing-1" then planNumber "Actual Loops" probe `shouldBe` 0 else pure ()+          forM_ limits $ \limit -> do+            expectIndex "ix_stream_events_all_by_origin" (Object limit)+            planNumber "Actual Rows" limit `shouldSatisfy` (<= 1)+          map (textField "Relation Name") nodes `shouldNotContain` [Just "events"]+          case plan of+            Array entries+              | Object entry : _ <- foldr (:) [] entries,+                Just (Object top) <- KeyMap.lookup "Plan" entry -> do+                  planNumber "Shared Hit Blocks" top + planNumber "Shared Read Blocks" top `shouldSatisfy` (<= 32)+                  planNumber "Actual Rows" top `shouldBe` (if name == "missing-1" then 0 else 1)+            _ -> expectationFailure (show plan)+        actual <- runStoreIO store (getStreamWithHead (StreamName name))+        case name of+          "missing-1" -> actual `shouldBe` Right Nothing+          "empty-1" -> case actual of+            Right (Just (_, headPosition)) -> headPosition `shouldBe` Nothing+            _ -> expectationFailure (show actual)+          _ -> case actual of+            Right (Just (_, Just _)) -> pure ()+            _ -> expectationFailure (show actual)++withStreamHeadStore :: (KirokuStore -> IO ()) -> IO ()+withStreamHeadStore action = withTestStore $ \store -> do+  forM_ [streamHeadFixtureSql, "VACUUM (ANALYZE) stream_events"] $ \command ->+    Pool.use (store ^. #pool) (Session.script command) >>= either (fail . show) pure+  action store++explainStreamHeadPrepared :: KirokuStore -> Text -> IO Value+explainStreamHeadPrepared store literal = do+  result <- Pool.use (store ^. #pool) $ do+    Session.script ("PREPARE ep97_head(text) AS " <> Statement.toSql SQL.getStreamWithHeadStmt)+    -- Default auto policy, past PostgreSQL's first five custom executions.+    Session.script (T.replicate 6 ("EXECUTE ep97_head(" <> literal <> ");"))+    bytes <-+      Session.statement () $+        unpreparable+          ("EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, FORMAT JSON) EXECUTE ep97_head(" <> literal <> ")")+          E.noParams+          (D.singleRow (D.column (D.nonNullable (D.jsonBytes Right))))+    Session.script "DEALLOCATE ep97_head"+    pure bytes+  bytes <- either (fail . show) pure result+  either fail pure (Aeson.eitherDecodeStrict' bytes)++planObjects :: Value -> [Aeson.Object]+planObjects (Object fields) = fields : concatMap planObjects (KeyMap.elems fields)+planObjects (Array values) = concatMap planObjects (foldr (:) [] values)+planObjects _ = []++planNumber :: Aeson.Key -> Aeson.Object -> Double+planNumber key fields = case KeyMap.lookup key fields of+  Just (Number n) -> realToFrac n+  _ -> 0
test/Test/Properties.hs view
@@ -1,21 +1,20 @@-{- | Property-based tests for invariants identified by EP-1 through EP-5.--Each property generates a random sequence of operations and asserts that-some invariant holds after the sequence executes. The properties target-the cross-cutting invariants enumerated in EP-6 M1's Invariant List-(F2–F8 of @docs\/plans\/6-test-and-benchmark-hardening-for-production-confidence.md@):--  * F2 — global position contiguity across appends, multi-stream appends,-    and links.-  * F3 — no orphan event payloads after lifecycle operations.-  * F4 — soft-delete write barrier under arbitrary append constructors.-  * F5 — caller-supplied event-id idempotence.--These properties run inside hspec via 'hspec-hedgehog'. Each property-acquires a fresh ephemeral PostgreSQL via 'withTestStore' inside the-hedgehog test action; the operation count per property is small (10-20-ops) to keep total runtime bounded.--}+-- | Property-based tests for invariants identified by EP-1 through EP-5.+--+-- Each property generates a random sequence of operations and asserts that+-- some invariant holds after the sequence executes. The properties target+-- the cross-cutting invariants enumerated in EP-6 M1's Invariant List+-- (F2–F8 of @docs\/plans\/6-test-and-benchmark-hardening-for-production-confidence.md@):+--+--   * F2 — global position contiguity across appends, multi-stream appends,+--     and links.+--   * F3 — no orphan event payloads after lifecycle operations.+--   * F4 — soft-delete write barrier under arbitrary append constructors.+--   * F5 — caller-supplied event-id idempotence.+--+-- These properties run inside hspec via 'hspec-hedgehog'. Each property+-- acquires a fresh ephemeral PostgreSQL via 'withTestStore' inside the+-- hedgehog test action; the operation count per property is small (10-20+-- ops) to keep total runtime bounded. module Test.Properties (spec) where  import Control.Lens ((^.))@@ -39,188 +38,184 @@  -- | Operations the property generators can produce. data Op-    = OpAppend !StreamName ![Text]-    | OpAppendMulti ![(StreamName, [Text])]-    | OpSoftDelete !StreamName-    | OpUndelete !StreamName-    | OpHardDelete !StreamName-    deriving stock (Show)+  = OpAppend !StreamName ![Text]+  | OpAppendMulti ![(StreamName, [Text])]+  | OpSoftDelete !StreamName+  | OpUndelete !StreamName+  | OpHardDelete !StreamName+  deriving stock (Show)  genStreamName :: Gen StreamName genStreamName = do-    -- Bounded universe (5 streams) so generated sequences exercise reuse.-    n <- Gen.int (Range.linear 0 4)-    pure (StreamName ("prop-" <> T.pack (show n)))+  -- Bounded universe (5 streams) so generated sequences exercise reuse.+  n <- Gen.int (Range.linear 0 4)+  pure (StreamName ("prop-" <> T.pack (show n)))  genEventTypes :: Gen [Text] genEventTypes = do-    n <- Gen.int (Range.linear 1 3)-    pure (map (\i -> "Type" <> T.pack (show i)) [1 .. n])+  n <- Gen.int (Range.linear 1 3)+  pure (map (\i -> "Type" <> T.pack (show i)) [1 .. n])  genOp :: Gen Op genOp =-    Gen.frequency-        [ (60, OpAppend <$> genStreamName <*> genEventTypes)-        ,-            ( 10-            , OpAppendMulti-                <$> Gen.list-                    (Range.linear 2 3)-                    ((,) <$> genStreamName <*> genEventTypes)-            )-        , (10, OpSoftDelete <$> genStreamName)-        , (10, OpUndelete <$> genStreamName)-        , (10, OpHardDelete <$> genStreamName)-        ]+  Gen.frequency+    [ (60, OpAppend <$> genStreamName <*> genEventTypes),+      ( 10,+        OpAppendMulti+          <$> Gen.list+            (Range.linear 2 3)+            ((,) <$> genStreamName <*> genEventTypes)+      ),+      (10, OpSoftDelete <$> genStreamName),+      (10, OpUndelete <$> genStreamName),+      (10, OpHardDelete <$> genStreamName)+    ]  genOps :: Gen [Op] genOps = Gen.list (Range.linear 1 12) genOp -{- | Run a generated operation, ignoring any 'StoreError' (the property-is about the system's invariants surviving any sequence of attempts,-including ones that fail).--}+-- | Run a generated operation, ignoring any 'StoreError' (the property+-- is about the system's invariants surviving any sequence of attempts,+-- including ones that fail). runOp :: KirokuStore -> Op -> IO () runOp store = \case-    OpAppend sn types -> do-        let evts = map (\t -> makeEvent t (Aeson.object [])) types-        void $ runStoreIO store $ appendToStream sn AnyVersion evts-    OpAppendMulti ops -> do-        let triples = map (\(sn, types) -> (sn, AnyVersion, map (\t -> makeEvent t (Aeson.object [])) types)) ops-        void $ runStoreIO store $ appendMultiStream triples-    OpSoftDelete sn -> void $ runStoreIO store $ softDeleteStream sn-    OpUndelete sn -> void $ runStoreIO store $ undeleteStream sn-    OpHardDelete sn -> void $ runStoreIO store $ hardDeleteStream sn+  OpAppend sn types -> do+    let evts = map (\t -> makeEvent t (Aeson.object [])) types+    void $ runStoreIO store $ appendToStream sn AnyVersion evts+  OpAppendMulti ops -> do+    let triples = map (\(sn, types) -> (sn, AnyVersion, map (\t -> makeEvent t (Aeson.object [])) types)) ops+    void $ runStoreIO store $ appendMultiStream triples+  OpSoftDelete sn -> void $ runStoreIO store $ softDeleteStream sn+  OpUndelete sn -> void $ runStoreIO store $ undeleteStream sn+  OpHardDelete sn -> void $ runStoreIO store $ hardDeleteStream sn  -- | Read the global position cursor by counting events on `$all`. readAllCount :: KirokuStore -> IO Int readAllCount store = do-    Right v <- runStoreIO store $ readAllForward (GlobalPosition 0) 100000-    pure (V.length v)+  Right v <- runStoreIO store $ readAllForward (GlobalPosition 0) 100000+  pure (V.length v) -{- | Read all events on `$all` and return their global positions in-the order returned.--}+-- | Read all events on `$all` and return their global positions in+-- the order returned. readAllPositions :: KirokuStore -> IO [Int] readAllPositions store = do-    Right v <- runStoreIO store $ readAllForward (GlobalPosition 0) 100000-    pure (V.toList (V.map (\e -> case e ^. #globalPosition of GlobalPosition n -> fromIntegral n) v))+  Right v <- runStoreIO store $ readAllForward (GlobalPosition 0) 100000+  pure (V.toList (V.map (\e -> case e ^. #globalPosition of GlobalPosition n -> fromIntegral n) v)) -{- | Each property creates a fresh ephemeral PostgreSQL per test case,-which dominates wall-clock time. Cap iterations at 15 per property —-enough to exercise generator coverage without blowing the suite past-two minutes.--}+-- | Each property creates a fresh ephemeral PostgreSQL per test case,+-- which dominates wall-clock time. Cap iterations at 15 per property —+-- enough to exercise generator coverage without blowing the suite past+-- two minutes. spec :: Spec spec = modifyMaxSuccess (const 15) $ do-    describe "kiroku-store invariants (property-based, hedgehog)" $ do-        -- F2 — Global position contiguity. After any sequence of-        -- appends, multi-appends, soft/undelete/hard-delete operations,-        -- the global positions on $all (excluding hard-deleted events)-        -- form a strictly ascending sequence with no duplicates and no-        -- gaps relative to the events that actually exist on $all.-        it "$all global positions are strictly ascending and unique (F2)" $-            hedgehog $-                propAllPositionsAscending+  describe "kiroku-store invariants (property-based, hedgehog)" $ do+    -- F2 — Global position contiguity. After any sequence of+    -- appends, multi-appends, soft/undelete/hard-delete operations,+    -- the global positions on $all (excluding hard-deleted events)+    -- form a strictly ascending sequence with no duplicates and no+    -- gaps relative to the events that actually exist on $all.+    it "$all global positions are strictly ascending and unique (F2)" $+      hedgehog $+        propAllPositionsAscending -        -- F4 — Soft-delete write barrier. After softDeleteStream, no-        -- subsequent appendToStream of any ExpectedVersion succeeds-        -- (until undeleteStream). Generates a random append, soft-deletes,-        -- attempts further appends with each ExpectedVersion constructor,-        -- asserts each one fails.-        it "soft-deleted streams reject appends of every ExpectedVersion (F4)" $-            hedgehog $-                propSoftDeleteBarrier+    -- F4 — Soft-delete write barrier. After softDeleteStream, no+    -- subsequent appendToStream of any ExpectedVersion succeeds+    -- (until undeleteStream). Generates a random append, soft-deletes,+    -- attempts further appends with each ExpectedVersion constructor,+    -- asserts each one fails.+    it "soft-deleted streams reject appends of every ExpectedVersion (F4)" $+      hedgehog $+        propSoftDeleteBarrier -        -- F5 — Idempotent caller-supplied event ids. A batch of appends-        -- with all-unique event ids succeeds; a batch where any id is a-        -- repeat of a previously committed id fails with DuplicateEvent.-        it "duplicate caller-supplied event ids are rejected (F5)" $-            hedgehog $-                propDuplicateEventIds+    -- F5 — Idempotent caller-supplied event ids. A batch of appends+    -- with all-unique event ids succeeds; a batch where any id is a+    -- repeat of a previously committed id fails with DuplicateEvent.+    it "duplicate caller-supplied event ids are rejected (F5)" $+      hedgehog $+        propDuplicateEventIds  propAllPositionsAscending :: PropertyT IO () propAllPositionsAscending = do-    ops <- forAll genOps-    positions <- evalIO $ withTestStoreReturn $ \store -> do-        forM_ ops (runOp store)-        readAllPositions store-    annotateShow positions-    -- Strictly ascending: each position greater than the previous.-    sort positions === positions-    -- Unique: no duplicates.-    length positions === length (Set.fromList positions)-    -- The count returned by $all matches the position list length.-    cnt <- evalIO $ withTestStoreReturn $ \store -> do-        forM_ ops (runOp store)-        readAllCount store-    cnt === length positions+  ops <- forAll genOps+  positions <- evalIO $ withTestStoreReturn $ \store -> do+    forM_ ops (runOp store)+    readAllPositions store+  annotateShow positions+  -- Strictly ascending: each position greater than the previous.+  sort positions === positions+  -- Unique: no duplicates.+  length positions === length (Set.fromList positions)+  -- The count returned by $all matches the position list length.+  cnt <- evalIO $ withTestStoreReturn $ \store -> do+    forM_ ops (runOp store)+    readAllCount store+  cnt === length positions  propSoftDeleteBarrier :: PropertyT IO () propSoftDeleteBarrier = do-    streamSeed <- forAll (Gen.int (Range.linear 0 9))-    nEvents <- forAll (Gen.int (Range.linear 1 4))-    evalIO $ withTestStoreReturn $ \store -> do-        let sn = StreamName ("barrier-" <> T.pack (show streamSeed))-        let evts = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. nEvents]-        Right _ <- runStoreIO store $ appendToStream sn NoStream evts-        Right _ <- runStoreIO store $ softDeleteStream sn-        -- Every subsequent append constructor must be rejected.-        rNo <- runStoreIO store $ appendToStream sn NoStream [makeEvent "X" (Aeson.object [])]-        case rNo of-            Left (StreamAlreadyExists _) -> pure ()-            other -> error ("F4 violated: NoStream against soft-deleted should be StreamAlreadyExists, got: " <> show other)-        rExact <- runStoreIO store $ appendToStream sn (ExactVersion (StreamVersion (fromIntegral nEvents))) [makeEvent "X" (Aeson.object [])]-        case rExact of-            Left (WrongExpectedVersion _ _ _) -> pure ()-            Left (StreamNotFound _) -> pure ()-            other -> error ("F4 violated: ExactVersion against soft-deleted should fail, got: " <> show other)-        rExists <- runStoreIO store $ appendToStream sn StreamExists [makeEvent "X" (Aeson.object [])]-        case rExists of-            Left (StreamNotFound _) -> pure ()-            other -> error ("F4 violated: StreamExists against soft-deleted should be StreamNotFound, got: " <> show other)-        rAny <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "X" (Aeson.object [])]-        case rAny of-            Left (StreamNotFound _) -> pure ()-            other -> error ("F4 violated: AnyVersion against soft-deleted should be StreamNotFound, got: " <> show other)+  streamSeed <- forAll (Gen.int (Range.linear 0 9))+  nEvents <- forAll (Gen.int (Range.linear 1 4))+  evalIO $ withTestStoreReturn $ \store -> do+    let sn = StreamName ("barrier-" <> T.pack (show streamSeed))+    let evts = map (\i -> makeEvent ("E" <> T.pack (show i)) (Aeson.object [])) [1 .. nEvents]+    Right _ <- runStoreIO store $ appendToStream sn NoStream evts+    Right _ <- runStoreIO store $ softDeleteStream sn+    -- Every subsequent append constructor must be rejected.+    rNo <- runStoreIO store $ appendToStream sn NoStream [makeEvent "X" (Aeson.object [])]+    case rNo of+      Left (StreamAlreadyExists _) -> pure ()+      other -> error ("F4 violated: NoStream against soft-deleted should be StreamAlreadyExists, got: " <> show other)+    rExact <- runStoreIO store $ appendToStream sn (ExactVersion (StreamVersion (fromIntegral nEvents))) [makeEvent "X" (Aeson.object [])]+    case rExact of+      Left (WrongExpectedVersion _ _ _) -> pure ()+      Left (StreamNotFound _) -> pure ()+      other -> error ("F4 violated: ExactVersion against soft-deleted should fail, got: " <> show other)+    rExists <- runStoreIO store $ appendToStream sn StreamExists [makeEvent "X" (Aeson.object [])]+    case rExists of+      Left (StreamNotFound _) -> pure ()+      other -> error ("F4 violated: StreamExists against soft-deleted should be StreamNotFound, got: " <> show other)+    rAny <- runStoreIO store $ appendToStream sn AnyVersion [makeEvent "X" (Aeson.object [])]+    case rAny of+      Left (StreamNotFound _) -> pure ()+      other -> error ("F4 violated: AnyVersion against soft-deleted should be StreamNotFound, got: " <> show other)  propDuplicateEventIds :: PropertyT IO () propDuplicateEventIds = do-    nFirst <- forAll (Gen.int (Range.linear 1 4))-    evalIO $ withTestStoreReturn $ \store -> do-        -- Generate a batch of unique caller-supplied event ids.-        ids <- mapM (const UUIDv4.nextRandom) [1 .. nFirst]-        let mkEvent uid =-                EventData-                    { eventId = Just (EventId uid)-                    , eventType = EventType "Dup"-                    , payload = Aeson.object []-                    , metadata = Nothing-                    , causationId = Nothing-                    , correlationId = Nothing-                    }-        let evts1 = map mkEvent ids-        r1 <- runStoreIO store $ appendToStream (StreamName "dup-prop-1") NoStream evts1-        case r1 of-            Right _ -> pure ()-            other -> error ("Initial append with unique ids should succeed: " <> show other)-        -- Re-issue any one of those ids in a fresh stream — must be rejected.-        let dupId = case ids of-                (i : _) -> i-                [] -> error "propDuplicateEventIds: nFirst >= 1 by Range, ids must be non-empty"-        let evts2 = [mkEvent dupId]-        r2 <- runStoreIO store $ appendToStream (StreamName "dup-prop-2") NoStream evts2-        case r2 of-            Left (DuplicateEvent _) -> pure ()-            other -> error ("F5 violated: duplicate event id should be rejected, got: " <> show other)+  nFirst <- forAll (Gen.int (Range.linear 1 4))+  evalIO $ withTestStoreReturn $ \store -> do+    -- Generate a batch of unique caller-supplied event ids.+    ids <- mapM (const UUIDv4.nextRandom) [1 .. nFirst]+    let mkEvent uid =+          EventData+            { eventId = Just (EventId uid),+              eventType = EventType "Dup",+              payload = Aeson.object [],+              metadata = Nothing,+              causationId = Nothing,+              correlationId = Nothing+            }+    let evts1 = map mkEvent ids+    r1 <- runStoreIO store $ appendToStream (StreamName "dup-prop-1") NoStream evts1+    case r1 of+      Right _ -> pure ()+      other -> error ("Initial append with unique ids should succeed: " <> show other)+    -- Re-issue any one of those ids in a fresh stream — must be rejected.+    let dupId = case ids of+          (i : _) -> i+          [] -> error "propDuplicateEventIds: nFirst >= 1 by Range, ids must be non-empty"+    let evts2 = [mkEvent dupId]+    r2 <- runStoreIO store $ appendToStream (StreamName "dup-prop-2") NoStream evts2+    case r2 of+      Left (DuplicateEvent _) -> pure ()+      other -> error ("F5 violated: duplicate event id should be rejected, got: " <> show other)  -- | Variant of 'withTestStore' that returns the action's result. withTestStoreReturn :: (KirokuStore -> IO a) -> IO a withTestStoreReturn action = do-    ref <- newIORef (Nothing :: Maybe a)-    withTestStore $ \store -> do-        r <- action store-        atomicModifyIORef' ref (\_ -> (Just r, ()))-    readIORef ref >>= \case-        Just r -> pure r-        Nothing -> error "withTestStoreReturn: action did not produce a result"+  ref <- newIORef (Nothing :: Maybe a)+  withTestStore $ \store -> do+    r <- action store+    atomicModifyIORef' ref (\_ -> (Just r, ()))+  readIORef ref >>= \case+    Just r -> pure r+    Nothing -> error "withTestStoreReturn: action did not produce a result"
test/Test/PublisherCallbackResilience.hs view
@@ -33,484 +33,550 @@ import Test.Hspec  data CallbackBoom = CallbackBoom-    deriving stock (Show)-    deriving anyclass (Exception)+  deriving stock (Show)+  deriving anyclass (Exception)  timeoutMicros :: Int timeoutMicros = 10_000_000  within :: String -> IO a -> IO a within label action = do-    result <- Async.race (threadDelay timeoutMicros) action-    case result of-        Left () -> fail ("timed out waiting for " <> label)-        Right a -> pure a+  result <- Async.race (threadDelay timeoutMicros) action+  case result of+    Left () -> fail ("timed out waiting for " <> label)+    Right a -> pure a  failureOf :: RecordedEvent -> DecodeFailure failureOf event = DecodeFailure (event ^. #eventId) "cannot decrypt"  poisonHook :: RecordedEvent -> IO (Either DecodeFailure RecordedEvent) poisonHook event =-    pure $-        if event ^. #eventType == EventType "Boom" then Left (failureOf event) else Right event+  pure $+    if event ^. #eventType == EventType "Boom" then Left (failureOf event) else Right event  appendTypes :: KirokuStore -> Text -> [Text] -> IO () appendTypes store stream types = do-    Right _ <- runStoreIO store $ appendToStream (StreamName stream) NoStream (map (\typ -> makeEvent typ (Aeson.object [])) types)-    pure ()+  Right _ <- runStoreIO store $ appendToStream (StreamName stream) NoStream (map (\typ -> makeEvent typ (Aeson.object [])) types)+  pure ()  expectCleanStop :: SubscriptionHandle -> IO () expectCleanStop handle =-    within "clean subscription stop" (wait handle) >>= \case-        Right () -> pure ()-        Left err -> expectationFailure ("expected clean stop, got " <> show err)+  within "clean subscription stop" (wait handle) >>= \case+    Right () -> pure ()+    Left err -> expectationFailure ("expected clean stop, got " <> show err)  expectDecodeStop :: SubscriptionHandle -> IO DecodeFailure expectDecodeStop handle =-    within "typed decode stop" (wait handle) >>= \case-        Left err | Just (SubscriptionUndecodable failure) <- fromException err -> pure failure-        other -> fail ("expected SubscriptionUndecodable, got " <> show other)+  within "typed decode stop" (wait handle) >>= \case+    Left err | Just (SubscriptionUndecodable failure) <- fromException err -> pure failure+    other -> fail ("expected SubscriptionUndecodable, got " <> show other)  readCheckpoint :: KirokuStore -> Text -> IO (Maybe Int64) readCheckpoint store name' = do-    Right value <- Pool.use (store ^. #pool) (Session.statement (name', 0) SQL.getCheckpointMemberStmt)-    pure value+  Right value <- Pool.use (store ^. #pool) (Session.statement (name', 0) SQL.getCheckpointMemberStmt)+  pure value  readDeadLetters :: KirokuStore -> Text -> IO [SQL.DeadLetterRecord] readDeadLetters store name' = do-    Right values <- Pool.use (store ^. #pool) (Session.statement (name', 0) SQL.readDeadLettersStmt)-    pure (V.toList values)+  Right values <- Pool.use (store ^. #pool) (Session.statement (name', 0) SQL.readDeadLettersStmt)+  pure (V.toList values)  awaitCheckpoint :: KirokuStore -> Text -> Int64 -> IO () awaitCheckpoint store name' expected = within "durable checkpoint" loop   where     loop = do-        value <- readCheckpoint store name'-        if value == Just expected then pure () else threadDelay 1_000 >> loop+      value <- readCheckpoint store name'+      if value == Just expected then pure () else threadDelay 1_000 >> loop  spec :: Spec spec = describe "publisher callback resilience" $ do-    it "stops only the default subscriber while a sibling dead-letters and continues live" $ do-        defaultLive <- newEmptyMVar-        siblingLive <- newEmptyMVar-        siblingDone <- newEmptyMVar-        attempts <- newIORef []-        defaultSeen <- newIORef ([] :: [EventType])-        siblingSeen <- newIORef ([] :: [EventType])-        publisherFailures <- newTVarIO (0 :: Int)-        retryEvents <- newTVarIO (0 :: Int)-        stopped <- newIORef Nothing-        let defaultName = SubscriptionName "decode-default-live"-            siblingName = SubscriptionName "decode-sibling-live"-            hook event-                | event ^. #eventType == EventType "Boom" = do-                    now <- getMonotonicTimeNSec-                    modifyIORef' attempts (now :)-                    pure (Left (failureOf event))-                | otherwise = pure (Right event)-            observe event = do-                caughtUpEventHandler defaultName defaultLive Nothing event-                caughtUpEventHandler siblingName siblingLive Nothing event-                case event of-                    KirokuEventPublisherDecodeFailed{} -> atomically $ modifyTVar' publisherFailures (+ 1)-                    KirokuEventSubscriptionRetrying name' _ _ _ | name' == defaultName -> atomically $ modifyTVar' retryEvents (+ 1)-                    KirokuEventSubscriptionStopped name' _ (StopUndecodable failure) _ | name' == defaultName -> modifyIORef' stopped (const (Just failure))-                    _ -> pure ()-            tweak settings =-                settings-                    & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}-                    & #eventHandler .~ Just observe-            record ref event = modifyIORef' ref ((event ^. #eventType) :) >> pure Continue-            siblingHandler event = do-                result <- record siblingSeen event-                when (event ^. #eventType == EventType "After") $ () <$ tryPutMVar siblingDone ()-                pure result-        withTestStoreSettings tweak $ \store -> do-            defaultHandle <- subscribe store ((defaultSubscriptionConfig defaultName AllStreams (record defaultSeen)){retryPolicy = RetryPolicy 3})-            sibling <--                subscribe-                    store-                    ( (defaultSubscriptionConfig siblingName AllStreams siblingHandler)-                        { undecodableHandler = Just (\_ failure -> pure (DeadLetter (DeadLetterDecodeFailure failure)))-                        }-                    )-            waitForSubscriptionLive defaultLive-            waitForSubscriptionLive siblingLive-            appendTypes store "decode-live" ["Before", "Boom", "After"]-            failure <- expectDecodeStop defaultHandle-            decodeFailureReason failure `shouldBe` "cannot decrypt"-            readIORef stopped `shouldReturn` Just failure-            atomically (readTVar retryEvents) `shouldReturn` 2-            within "healthy sibling delivery" (waitForSubscriptionLive siblingDone)-            awaitCheckpoint store "decode-sibling-live" 3-            waitForPublisher store (GlobalPosition 3)-            atomically (readTVar publisherFailures) `shouldReturn` 1-            reverse <$> readIORef defaultSeen `shouldReturn` [EventType "Before"]-            reverse <$> readIORef siblingSeen `shouldReturn` [EventType "Before", EventType "After"]-            readCheckpoint store "decode-default-live" >>= (`shouldSatisfy` maybe False (< 2))-            (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-default-live") `shouldReturn` []-            letters <- readDeadLetters store "decode-sibling-live"-            map SQL.deadLetterGlobalPosition letters `shouldBe` [2]-            map SQL.deadLetterReasonSummary letters `shouldBe` ["decode failure: cannot decrypt"]-            map SQL.deadLetterReason letters `shouldBe` [Aeson.object ["kind" .= ("decode_failure" :: Text), "event_id" .= (case decodeFailureEventId failure of EventId eid -> show eid), "detail" .= ("cannot decrypt" :: Text)]]-            times <- reverse <$> readIORef attempts-            length times `shouldBe` 3-            zipWith (-) (drop 1 times) times `shouldSatisfy` all (>= 900_000_000)-            currentState sibling >>= \case-                Just Live{} -> pure ()-                other -> expectationFailure ("expected healthy sibling, got " <> show other)-            currentState defaultHandle >>= (`shouldSatisfy` isNothing)-            cancel sibling+  it "stops only the default subscriber while a sibling dead-letters and continues live" $ do+    defaultLive <- newEmptyMVar+    siblingLive <- newEmptyMVar+    siblingDone <- newEmptyMVar+    attempts <- newIORef []+    defaultSeen <- newIORef ([] :: [EventType])+    siblingSeen <- newIORef ([] :: [EventType])+    publisherFailures <- newTVarIO (0 :: Int)+    retryEvents <- newTVarIO (0 :: Int)+    stopped <- newIORef Nothing+    let defaultName = SubscriptionName "decode-default-live"+        siblingName = SubscriptionName "decode-sibling-live"+        hook event+          | event ^. #eventType == EventType "Boom" = do+              now <- getMonotonicTimeNSec+              modifyIORef' attempts (now :)+              pure (Left (failureOf event))+          | otherwise = pure (Right event)+        observe event = do+          caughtUpEventHandler defaultName defaultLive Nothing event+          caughtUpEventHandler siblingName siblingLive Nothing event+          case event of+            KirokuEventPublisherDecodeFailed {} -> atomically $ modifyTVar' publisherFailures (+ 1)+            KirokuEventSubscriptionRetrying name' _ _ _ | name' == defaultName -> atomically $ modifyTVar' retryEvents (+ 1)+            KirokuEventSubscriptionStopped name' _ (StopUndecodable failure) _ | name' == defaultName -> modifyIORef' stopped (const (Just failure))+            _ -> pure ()+        tweak settings =+          settings+            & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+            & #eventHandler .~ Just observe+        record ref event = modifyIORef' ref ((event ^. #eventType) :) >> pure Continue+        siblingHandler event = do+          result <- record siblingSeen event+          when (event ^. #eventType == EventType "After") $ () <$ tryPutMVar siblingDone ()+          pure result+    withTestStoreSettings tweak $ \store -> do+      defaultHandle <- subscribe store ((defaultSubscriptionConfig defaultName AllStreams (record defaultSeen)) {retryPolicy = RetryPolicy 3})+      sibling <-+        subscribe+          store+          ( (defaultSubscriptionConfig siblingName AllStreams siblingHandler)+              { undecodableHandler = Just (\_ failure -> pure (DeadLetter (DeadLetterDecodeFailure failure)))+              }+          )+      waitForSubscriptionLive defaultLive+      waitForSubscriptionLive siblingLive+      appendTypes store "decode-live" ["Before", "Boom", "After"]+      failure <- expectDecodeStop defaultHandle+      decodeFailureReason failure `shouldBe` "cannot decrypt"+      readIORef stopped `shouldReturn` Just failure+      atomically (readTVar retryEvents) `shouldReturn` 2+      within "healthy sibling delivery" (waitForSubscriptionLive siblingDone)+      awaitCheckpoint store "decode-sibling-live" 3+      waitForPublisher store (GlobalPosition 3)+      atomically (readTVar publisherFailures) `shouldReturn` 1+      reverse <$> readIORef defaultSeen `shouldReturn` [EventType "Before"]+      reverse <$> readIORef siblingSeen `shouldReturn` [EventType "Before", EventType "After"]+      readCheckpoint store "decode-default-live" >>= (`shouldSatisfy` maybe False (< 2))+      (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-default-live") `shouldReturn` []+      letters <- readDeadLetters store "decode-sibling-live"+      map SQL.deadLetterGlobalPosition letters `shouldBe` [2]+      map SQL.deadLetterReasonSummary letters `shouldBe` ["decode failure: cannot decrypt"]+      map SQL.deadLetterReason letters `shouldBe` [Aeson.object ["kind" .= ("decode_failure" :: Text), "event_id" .= (case decodeFailureEventId failure of EventId eid -> show eid), "detail" .= ("cannot decrypt" :: Text)]]+      times <- reverse <$> readIORef attempts+      length times `shouldBe` 3+      zipWith (-) (drop 1 times) times `shouldSatisfy` all (>= 900_000_000)+      currentState sibling >>= \case+        Just Live {} -> pure ()+        other -> expectationFailure ("expected healthy sibling, got " <> show other)+      currentState defaultHandle >>= (`shouldSatisfy` isNothing)+      cancel sibling -    it "shares successful live decoding across subscribers" $ do-        firstLive <- newEmptyMVar-        secondLive <- newEmptyMVar-        calls <- newIORef (0 :: Int)-        let firstName = SubscriptionName "decode-fanout-first"-            secondName = SubscriptionName "decode-fanout-second"-            hook event = modifyIORef' calls (+ 1) >> pure (Right event)-            observe event = do-                caughtUpEventHandler firstName firstLive Nothing event-                caughtUpEventHandler secondName secondLive Nothing event-            tweak settings =-                settings-                    & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}-                    & #eventHandler .~ Just observe-        withTestStoreSettings tweak $ \store -> do-            first <- subscribe store (defaultSubscriptionConfig firstName AllStreams (\_ -> pure Stop))-            second <- subscribe store (defaultSubscriptionConfig secondName AllStreams (\_ -> pure Stop))-            waitForSubscriptionLive firstLive-            waitForSubscriptionLive secondLive-            appendTypes store "decode-fanout" ["Good"]-            expectCleanStop first-            expectCleanStop second-            readIORef calls `shouldReturn` 1+  it "shares successful live decoding across subscribers" $ do+    firstLive <- newEmptyMVar+    secondLive <- newEmptyMVar+    calls <- newIORef (0 :: Int)+    let firstName = SubscriptionName "decode-fanout-first"+        secondName = SubscriptionName "decode-fanout-second"+        hook event = modifyIORef' calls (+ 1) >> pure (Right event)+        observe event = do+          caughtUpEventHandler firstName firstLive Nothing event+          caughtUpEventHandler secondName secondLive Nothing event+        tweak settings =+          settings+            & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+            & #eventHandler .~ Just observe+    withTestStoreSettings tweak $ \store -> do+      first <- subscribe store (defaultSubscriptionConfig firstName AllStreams (\_ -> pure Stop))+      second <- subscribe store (defaultSubscriptionConfig secondName AllStreams (\_ -> pure Stop))+      waitForSubscriptionLive firstLive+      waitForSubscriptionLive secondLive+      appendTypes store "decode-fanout" ["Good"]+      expectCleanStop first+      expectCleanStop second+      readIORef calls `shouldReturn` 1 -    forM_ [(Category (CategoryName "decode"), Nothing), (AllStreams, Just (validConsumerGroup 0 1))] $ \(target', group) ->-        it ("handles an undecodable event after confirmed DB-driven live transition for " <> show (target', group)) $ do-            caughtUp <- newEmptyMVar-            seen <- newIORef ([] :: [EventType])-            let subName = SubscriptionName "decode-db-live"-                tweak settings =-                    settings-                        & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}-                        & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)-                handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop-            withTestStoreSettings tweak $ \store -> do-                handle <--                    subscribe-                        store-                        ( (defaultSubscriptionConfig subName target' handler)-                            { consumerGroup = group-                            , undecodableHandler = Just (\_ failure -> pure (DeadLetter (DeadLetterDecodeFailure failure)))-                            }-                        )-                waitForSubscriptionLive caughtUp-                appendTypes store "decode-db-live" ["Boom", "After"]-                expectCleanStop handle-                readIORef seen `shouldReturn` [EventType "After"]-                readCheckpoint store "decode-db-live" `shouldReturn` Just 2-                map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-db-live" `shouldReturn` [1]+  forM_ [(Category (CategoryName "decode"), Nothing), (AllStreams, Just (validConsumerGroup 0 1))] $ \(target', group) ->+    it ("handles an undecodable event after confirmed DB-driven live transition for " <> show (target', group)) $ do+      caughtUp <- newEmptyMVar+      seen <- newIORef ([] :: [EventType])+      let subName = SubscriptionName "decode-db-live"+          tweak settings =+            settings+              & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+              & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)+          handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop+      withTestStoreSettings tweak $ \store -> do+        handle <-+          subscribe+            store+            ( (defaultSubscriptionConfig subName target' handler)+                { consumerGroup = group,+                  undecodableHandler = Just (\_ failure -> pure (DeadLetter (DeadLetterDecodeFailure failure)))+                }+            )+        waitForSubscriptionLive caughtUp+        appendTypes store "decode-db-live" ["Boom", "After"]+        expectCleanStop handle+        readIORef seen `shouldReturn` [EventType "After"]+        readCheckpoint store "decode-db-live" `shouldReturn` Just 2+        map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-db-live" `shouldReturn` [1] -    it "does not apply an undecodable disposition to an explicitly filtered-out event" $ do-        let tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}-        withTestStoreSettings tweak $ \store -> do-            appendTypes store "decode-filter" ["Boom", "After"]-            waitForPublisher store (GlobalPosition 2)-            handle <--                subscribe-                    store-                    ( ( defaultSubscriptionConfig-                            (SubscriptionName "decode-filter")-                            AllStreams-                            ( \event -> do-                                event ^. #eventType `shouldBe` EventType "After"-                                pure Stop-                            )-                      )-                        { eventTypeFilter = OnlyEventTypes (Set.singleton (EventType "After"))-                        , retryPolicy = RetryPolicy 1-                        }-                    )-            expectCleanStop handle-            readCheckpoint store "decode-filter" `shouldReturn` Just 2-            map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-filter" `shouldReturn` []+  it "does not apply an undecodable disposition to an explicitly filtered-out event" $ do+    let tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+    withTestStoreSettings tweak $ \store -> do+      appendTypes store "decode-filter" ["Boom", "After"]+      waitForPublisher store (GlobalPosition 2)+      handle <-+        subscribe+          store+          ( ( defaultSubscriptionConfig+                (SubscriptionName "decode-filter")+                AllStreams+                ( \event -> do+                    event ^. #eventType `shouldBe` EventType "After"+                    pure Stop+                )+            )+              { eventTypeFilter = OnlyEventTypes (Set.singleton (EventType "After")),+                retryPolicy = RetryPolicy 1+              }+          )+      expectCleanStop handle+      readCheckpoint store "decode-filter" `shouldReturn` Just 2+      map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-filter" `shouldReturn` [] -    it "recovers a typed one-shot failure on the default retry without a callback" $ do-        failedOnce <- newIORef False-        caughtUp <- newEmptyMVar-        seen <- newIORef ([] :: [EventType])-        let subName = SubscriptionName "decode-one-shot"-            hook event-                | event ^. #eventType == EventType "Boom" = do-                    already <- atomicModifyIORef' failedOnce (\old -> (True, old))-                    pure (if already then Right event else Left (failureOf event))-                | otherwise = pure (Right event)-            tweak settings =-                settings-                    & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}-                    & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)-            handler event = do-                modifyIORef' seen ((event ^. #eventType) :)-                pure (if event ^. #eventType == EventType "After" then Stop else Continue)-        withTestStoreSettings tweak $ \store -> do-            handle <- subscribe store (defaultSubscriptionConfig subName AllStreams handler)-            waitForSubscriptionLive caughtUp-            appendTypes store "decode-recovery" ["Boom", "After"]-            expectCleanStop handle-            reverse <$> readIORef seen `shouldReturn` [EventType "Boom", EventType "After"]-            readCheckpoint store "decode-one-shot" `shouldReturn` Just 2-            (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-one-shot") `shouldReturn` []+  forM_ [False, True] $ \fails ->+    it ("filters undecodable type rewrites on the initial outcome, failure=" <> show fails) $ do+      callbacks <- newIORef (0 :: Int)+      seen <- newIORef []+      let v1 = EventType "OrderCreatedV1"+          v2 = EventType "OrderCreatedV2"+          hook event+            | event ^. #eventType == v1 = pure (if fails then Left (failureOf event) else Right (event & #eventType .~ v2))+            | otherwise = pure (Right event)+          tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+          handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop+      withTestStoreSettings tweak $ \store -> do+        appendTypes store "decode-rewrite" ["OrderCreatedV1", "OrderCreatedV2"]+        waitForPublisher store (GlobalPosition 2)+        handle <-+          subscribe+            store+            ( (defaultSubscriptionConfig (SubscriptionName "decode-rewrite") AllStreams handler)+                { eventTypeFilter = OnlyEventTypes (Set.singleton v2),+                  undecodableHandler = Just (\_ _ -> modifyIORef' callbacks (+ 1) >> pure Stop)+                }+            )+        expectCleanStop handle+        readIORef callbacks `shouldReturn` 0+        readIORef seen `shouldReturn` [v2]+        readCheckpoint store "decode-rewrite" `shouldReturn` Just (if fails then 2 else 1) -    forM_ [AllStreams, Category (CategoryName "decode")] $ \target' ->-        forM_ [Nothing, Just (validConsumerGroup 0 1)] $ \group ->-            it ("stops a persistent undecodable event during catch-up for " <> show (target', group)) $ do-                let tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}-                    handler _ = expectationFailure "undecodable event reached ordinary handler" >> pure Stop-                withTestStoreSettings tweak $ \store -> do-                    appendTypes store "decode-catchup" ["Boom"]-                    waitForPublisher store (GlobalPosition 1)-                    handle <--                        subscribe-                            store-                            ( (defaultSubscriptionConfig (SubscriptionName "decode-catchup") target' handler)-                                { consumerGroup = group-                                , retryPolicy = RetryPolicy 1-                                }-                            )-                    _ <- expectDecodeStop handle-                    readCheckpoint store "decode-catchup" `shouldReturn` Just 0-                    (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-catchup") `shouldReturn` []+  it "retains the undecodable raw filter decision when retry recovers to an excluded type" $ do+    recover <- newIORef False+    callbacks <- newIORef (0 :: Int)+    seen <- newIORef []+    rawSeen <- newIORef []+    let v1 = EventType "OrderCreatedV1"+        v2 = EventType "OrderCreatedV2"+        hook event = do+          enabled <- readIORef recover+          pure (if enabled then Right (event & #eventType .~ v2) else Left (failureOf event))+        tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+        callback raw _ = do+          modifyIORef' callbacks (+ 1)+          modifyIORef' rawSeen (raw :)+          writeIORef recover True+          pure (Retry (RetryDelay 0))+        handler event = modifyIORef' seen (event :) >> pure Stop+    withTestStoreSettings tweak $ \store -> do+      appendTypes store "decode-recovered-filter" ["OrderCreatedV1"]+      waitForPublisher store (GlobalPosition 1)+      withSubscription+        store+        ( (defaultSubscriptionConfig (SubscriptionName "decode-recovered-filter") AllStreams handler)+            { eventTypeFilter = OnlyEventTypes (Set.singleton v1),+              undecodableHandler = Just callback+            }+        )+        $ \handle -> do+          expectCleanStop handle+          readIORef callbacks `shouldReturn` 1+          delivered <- readIORef seen+          raw <- readIORef rawSeen+          map (\e -> e ^. #eventType) delivered `shouldBe` [v2]+          map (\e -> e ^. #eventType) raw `shouldBe` [v1]+          map (\e -> (e ^. #eventId, e ^. #globalPosition)) delivered+            `shouldBe` map (\e -> (e ^. #eventId, e ^. #globalPosition)) raw+          readCheckpoint store "decode-recovered-filter" `shouldReturn` Just 1+          map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-recovered-filter" `shouldReturn` [] -    forM_ [Continue, Stop, Retry (RetryDelay 0)] $ \disposition ->-        it ("honors an explicit undecodable disposition " <> show disposition) $ do-            callbacks <- newIORef (0 :: Int)-            seen <- newIORef ([] :: [EventType])-            let tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}-                handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop-                callback _ _ = modifyIORef' callbacks (+ 1) >> pure disposition-            withTestStoreSettings tweak $ \store -> do-                appendTypes store "decode-disposition" ["Boom", "After"]-                waitForPublisher store (GlobalPosition 2)-                handle <--                    subscribe-                        store-                        ( (defaultSubscriptionConfig (SubscriptionName "decode-disposition") AllStreams handler)-                            { undecodableHandler = Just callback-                            , retryPolicy = RetryPolicy 3-                            }-                        )-                expectCleanStop handle-                case disposition of-                    Stop -> do-                        readIORef seen `shouldReturn` []-                        readCheckpoint store "decode-disposition" `shouldReturn` Just 1-                        (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-disposition") `shouldReturn` []-                    Retry _ -> do-                        readIORef callbacks `shouldReturn` 3-                        letters <- readDeadLetters store "decode-disposition"-                        map SQL.deadLetterReasonSummary letters `shouldBe` ["max retry attempts exceeded (3)"]-                        readCheckpoint store "decode-disposition" `shouldReturn` Just 2-                    _ -> do-                        readIORef callbacks `shouldReturn` 1-                        readIORef seen `shouldReturn` [EventType "After"]-                        (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-disposition") `shouldReturn` []+  it "recovers a typed one-shot failure on the default retry without a callback" $ do+    failedOnce <- newIORef False+    caughtUp <- newEmptyMVar+    seen <- newIORef ([] :: [EventType])+    let subName = SubscriptionName "decode-one-shot"+        hook event+          | event ^. #eventType == EventType "Boom" = do+              already <- atomicModifyIORef' failedOnce (\old -> (True, old))+              pure (if already then Right event else Left (failureOf event))+          | otherwise = pure (Right event)+        tweak settings =+          settings+            & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+            & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)+        handler event = do+          modifyIORef' seen ((event ^. #eventType) :)+          pure (if event ^. #eventType == EventType "After" then Stop else Continue)+    withTestStoreSettings tweak $ \store -> do+      handle <- subscribe store (defaultSubscriptionConfig subName AllStreams handler)+      waitForSubscriptionLive caughtUp+      appendTypes store "decode-recovery" ["Boom", "After"]+      expectCleanStop handle+      reverse <$> readIORef seen `shouldReturn` [EventType "Boom", EventType "After"]+      readCheckpoint store "decode-one-shot" `shouldReturn` Just 2+      (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-one-shot") `shouldReturn` [] -    it "re-applies the hook on a callback Retry and then calls the ordinary handler" $ do-        failedOnce <- newIORef False-        callbacks <- newIORef (0 :: Int)-        seen <- newIORef ([] :: [EventType])-        let hook event = do-                already <- atomicModifyIORef' failedOnce (\old -> (True, old))-                pure (if already then Right event else Left (failureOf event))-            tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}-            handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop+  forM_ [AllStreams, Category (CategoryName "decode")] $ \target' ->+    forM_ [Nothing, Just (validConsumerGroup 0 1)] $ \group ->+      it ("stops a persistent undecodable event during catch-up for " <> show (target', group)) $ do+        let tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+            handler _ = expectationFailure "undecodable event reached ordinary handler" >> pure Stop         withTestStoreSettings tweak $ \store -> do-            appendTypes store "decode-callback-retry" ["Boom"]-            waitForPublisher store (GlobalPosition 1)-            handle <--                subscribe-                    store-                    ( (defaultSubscriptionConfig (SubscriptionName "decode-callback-retry") AllStreams handler)-                        { undecodableHandler = Just (\_ _ -> modifyIORef' callbacks (+ 1) >> pure (Retry (RetryDelay 0)))-                        }-                    )-            expectCleanStop handle+          appendTypes store "decode-catchup" ["Boom"]+          waitForPublisher store (GlobalPosition 1)+          handle <-+            subscribe+              store+              ( (defaultSubscriptionConfig (SubscriptionName "decode-catchup") target' handler)+                  { consumerGroup = group,+                    retryPolicy = RetryPolicy 1+                  }+              )+          _ <- expectDecodeStop handle+          readCheckpoint store "decode-catchup" `shouldReturn` Just 0+          (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-catchup") `shouldReturn` []++  forM_ [Continue, Stop, Retry (RetryDelay 0)] $ \disposition ->+    it ("honors an explicit undecodable disposition " <> show disposition) $ do+      callbacks <- newIORef (0 :: Int)+      seen <- newIORef ([] :: [EventType])+      let tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+          handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop+          callback _ _ = modifyIORef' callbacks (+ 1) >> pure disposition+      withTestStoreSettings tweak $ \store -> do+        appendTypes store "decode-disposition" ["Boom", "After"]+        waitForPublisher store (GlobalPosition 2)+        handle <-+          subscribe+            store+            ( (defaultSubscriptionConfig (SubscriptionName "decode-disposition") AllStreams handler)+                { undecodableHandler = Just callback,+                  retryPolicy = RetryPolicy 3+                }+            )+        expectCleanStop handle+        case disposition of+          Stop -> do+            readIORef seen `shouldReturn` []+            readCheckpoint store "decode-disposition" `shouldReturn` Just 1+            (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-disposition") `shouldReturn` []+          Retry _ -> do+            readIORef callbacks `shouldReturn` 3+            letters <- readDeadLetters store "decode-disposition"+            map SQL.deadLetterReasonSummary letters `shouldBe` ["max retry attempts exceeded (3)"]+            readCheckpoint store "decode-disposition" `shouldReturn` Just 2+          _ -> do             readIORef callbacks `shouldReturn` 1-            readIORef seen `shouldReturn` [EventType "Boom"]+            readIORef seen `shouldReturn` [EventType "After"]+            (map SQL.deadLetterGlobalPosition <$> readDeadLetters store "decode-disposition") `shouldReturn` [] -    it "fails reads with EventDecodeFailed instead of returning a partial vector" $ do-        let tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}-        withTestStoreSettings tweak $ \store -> do-            appendTypes store "decode-read" ["Before", "Boom", "After"]-            forM_-                [ readAllForward (GlobalPosition 0) 10-                , readAllBackward (GlobalPosition 10) 10-                , readStreamForward (StreamName "decode-read") (StreamVersion 0) 10-                , readStreamBackward (StreamName "decode-read") (StreamVersion 10) 10-                , readCategory (CategoryName "decode") (GlobalPosition 0) 10-                ]-                $ \readEvents ->-                    runStoreIO store readEvents >>= \case-                        Left (EventDecodeFailed failure) -> decodeFailureReason failure `shouldBe` "cannot decrypt"-                        other -> expectationFailure ("expected typed read failure, got " <> show other)+  it "re-applies the hook on a callback Retry and then calls the ordinary handler" $ do+    failedOnce <- newIORef False+    callbacks <- newIORef (0 :: Int)+    seen <- newIORef ([] :: [EventType])+    let hook event = do+          already <- atomicModifyIORef' failedOnce (\old -> (True, old))+          pure (if already then Right event else Left (failureOf event))+        tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+        handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop+    withTestStoreSettings tweak $ \store -> do+      appendTypes store "decode-callback-retry" ["Boom"]+      waitForPublisher store (GlobalPosition 1)+      handle <-+        subscribe+          store+          ( (defaultSubscriptionConfig (SubscriptionName "decode-callback-retry") AllStreams handler)+              { undecodableHandler = Just (\_ _ -> modifyIORef' callbacks (+ 1) >> pure (Retry (RetryDelay 0)))+              }+          )+      expectCleanStop handle+      readIORef callbacks `shouldReturn` 1+      readIORef seen `shouldReturn` [EventType "Boom"] -    it "cancels promptly while waiting for the default decode retry" $ do-        retrying <- newEmptyMVar-        let subName = SubscriptionName "decode-cancel"-            observe event = case event of-                KirokuEventSubscriptionRetrying{} -> () <$ tryPutMVar retrying ()-                _ -> pure ()-            tweak settings =-                settings-                    & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}-                    & #eventHandler .~ Just observe-        withTestStoreSettings tweak $ \store -> do-            appendTypes store "decode-cancel" ["Boom"]-            waitForPublisher store (GlobalPosition 1)-            handle <- subscribe store (defaultSubscriptionConfig subName AllStreams (\_ -> pure Continue))-            waitForSubscriptionLive retrying-            startedAt <- getMonotonicTimeNSec-            cancel handle-            finishedAt <- getMonotonicTimeNSec-            finishedAt - startedAt `shouldSatisfy` (< 500_000_000)-            currentState handle >>= (`shouldSatisfy` isNothing)-            readCheckpoint store "decode-cancel" `shouldReturn` Just 0+  it "fails reads with EventDecodeFailed instead of returning a partial vector" $ do+    let tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+    withTestStoreSettings tweak $ \store -> do+      appendTypes store "decode-read" ["Before", "Boom", "After"]+      forM_+        [ readAllForward (GlobalPosition 0) 10,+          readAllBackward (GlobalPosition 10) 10,+          readStreamForward (StreamName "decode-read") (StreamVersion 0) 10,+          readStreamBackward (StreamName "decode-read") (StreamVersion 10) 10,+          readCategory (CategoryName "decode") (GlobalPosition 0) 10+        ]+        $ \readEvents ->+          runStoreIO store readEvents >>= \case+            Left (EventDecodeFailed failure) -> decodeFailureReason failure `shouldBe` "cannot decrypt"+            other -> expectationFailure ("expected typed read failure, got " <> show other) -    it "replays the failed event from its durable checkpoint after the hook is fixed" $ do-        repaired <- newIORef False-        seen <- newIORef ([] :: [EventType])-        let subName = SubscriptionName "decode-restart"-            hook event = do-                healthy <- readIORef repaired-                pure (if healthy then Right event else Left (failureOf event))-            tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}-            handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop-        withTestStoreSettings tweak $ \store -> do-            appendTypes store "decode-restart" ["Boom"]-            waitForPublisher store (GlobalPosition 1)-            failed <- subscribe store ((defaultSubscriptionConfig subName AllStreams handler){retryPolicy = RetryPolicy 1})-            _ <- expectDecodeStop failed-            readCheckpoint store "decode-restart" `shouldReturn` Just 0-            writeIORef repaired True-            recovered <- subscribe store (defaultSubscriptionConfig subName AllStreams handler)-            expectCleanStop recovered-            readIORef seen `shouldReturn` [EventType "Boom"]-            readCheckpoint store "decode-restart" `shouldReturn` Just 1+  it "cancels promptly while waiting for the default decode retry" $ do+    retrying <- newEmptyMVar+    let subName = SubscriptionName "decode-cancel"+        observe event = case event of+          KirokuEventSubscriptionRetrying {} -> () <$ tryPutMVar retrying ()+          _ -> pure ()+        tweak settings =+          settings+            & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+            & #eventHandler .~ Just observe+    withTestStoreSettings tweak $ \store -> do+      appendTypes store "decode-cancel" ["Boom"]+      waitForPublisher store (GlobalPosition 1)+      handle <- subscribe store (defaultSubscriptionConfig subName AllStreams (\_ -> pure Continue))+      waitForSubscriptionLive retrying+      startedAt <- getMonotonicTimeNSec+      cancel handle+      finishedAt <- getMonotonicTimeNSec+      finishedAt - startedAt `shouldSatisfy` (< 500_000_000)+      currentState handle >>= (`shouldSatisfy` isNothing)+      readCheckpoint store "decode-cancel" `shouldReturn` Just 0 -    it "releases a bracketed subscriber during a pending default decode retry" $ do-        retrying <- newEmptyMVar-        let observe event = case event of-                KirokuEventSubscriptionRetrying{} -> () <$ tryPutMVar retrying ()-                _ -> pure ()-            tweak settings =-                settings-                    & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}-                    & #eventHandler .~ Just observe-        within "bracketed store/subscription shutdown" $ withTestStoreSettings tweak $ \store -> do-            appendTypes store "decode-shutdown" ["Boom"]-            waitForPublisher store (GlobalPosition 1)-            handle <--                withSubscription-                    store-                    ((defaultSubscriptionConfig (SubscriptionName "decode-shutdown") AllStreams (\_ -> pure Continue)){retryPolicy = RetryPolicy 100})-                    (\h -> waitForSubscriptionLive retrying >> pure h)-            currentState handle >>= (`shouldSatisfy` isNothing)-            within "cancelled worker completion" (wait handle) >>= \case-                Left err | Just Async.AsyncCancelled <- fromException err -> pure ()-                other -> expectationFailure ("expected bracket cancellation, got " <> show other)+  it "replays the failed event from its durable checkpoint after the hook is fixed" $ do+    repaired <- newIORef False+    seen <- newIORef ([] :: [EventType])+    let subName = SubscriptionName "decode-restart"+        hook event = do+          healthy <- readIORef repaired+          pure (if healthy then Right event else Left (failureOf event))+        tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}+        handler event = modifyIORef' seen ((event ^. #eventType) :) >> pure Stop+    withTestStoreSettings tweak $ \store -> do+      appendTypes store "decode-restart" ["Boom"]+      waitForPublisher store (GlobalPosition 1)+      failed <- subscribe store ((defaultSubscriptionConfig subName AllStreams handler) {retryPolicy = RetryPolicy 1})+      _ <- expectDecodeStop failed+      readCheckpoint store "decode-restart" `shouldReturn` Just 0+      writeIORef repaired True+      recovered <- subscribe store (defaultSubscriptionConfig subName AllStreams handler)+      expectCleanStop recovered+      readIORef seen `shouldReturn` [EventType "Boom"]+      readCheckpoint store "decode-restart" `shouldReturn` Just 1 -    it "unlifts the undecodable callback in the same persistent effect environment as the handler" $ do-        seen <- newIORef ([] :: [Int])-        let tweak settings = settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just poisonHook}-        withTestStoreSettings tweak $ \store -> do-            appendTypes store "decode-effectful" ["Boom", "After"]-            waitForPublisher store (GlobalPosition 2)-            runEff $ SubEff.runSubscription store $ State.evalState (0 :: Int) $ do-                let config =-                        ( defaultSubscriptionConfig-                            (SubscriptionName "decode-effectful")-                            AllStreams-                            ( \_ -> do-                                State.modify @Int (+ 1)-                                count <- State.get @Int-                                liftIO (modifyIORef' seen (count :))-                                pure Stop-                            )-                        )-                            { undecodableHandler = Just (\_ _ -> State.modify @Int (+ 1) >> pure Continue)-                            }-                handle <- SubEff.subscribe config-                liftIO (expectCleanStop handle)-            readIORef seen `shouldReturn` [2]+  it "releases a bracketed subscriber during a pending default decode retry" $ do+    retrying <- newEmptyMVar+    let observe event = case event of+          KirokuEventSubscriptionRetrying {} -> () <$ tryPutMVar retrying ()+          _ -> pure ()+        tweak settings =+          settings+            & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+            & #eventHandler .~ Just observe+    within "bracketed store/subscription shutdown" $ withTestStoreSettings tweak $ \store -> do+      appendTypes store "decode-shutdown" ["Boom"]+      waitForPublisher store (GlobalPosition 1)+      handle <-+        withSubscription+          store+          ((defaultSubscriptionConfig (SubscriptionName "decode-shutdown") AllStreams (\_ -> pure Continue)) {retryPolicy = RetryPolicy 100})+          (\h -> waitForSubscriptionLive retrying >> pure h)+      currentState handle >>= (`shouldSatisfy` isNothing)+      within "cancelled worker completion" (wait handle) >>= \case+        Left err | Just Async.AsyncCancelled <- fromException err -> pure ()+        other -> expectationFailure ("expected bracket cancellation, got " <> show other) -    it "keeps the publisher alive when decodeHook throws once" $ do-        failedOnce <- newIORef False-        loopErrorSeen <- newEmptyMVar-        caughtUp <- newEmptyMVar-        delivered <- newIORef ([] :: [EventType])-        deliveredCount <- newTVarIO (0 :: Int)+  it "unlifts the undecodable callback in the same persistent effect environment as the handler" $ do+    seen <- newIORef ([] :: [Int])+    let tweak settings = settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just poisonHook}+    withTestStoreSettings tweak $ \store -> do+      appendTypes store "decode-effectful" ["Boom", "After"]+      waitForPublisher store (GlobalPosition 2)+      runEff $ SubEff.runSubscription store $ State.evalState (0 :: Int) $ do+        let config =+              ( defaultSubscriptionConfig+                  (SubscriptionName "decode-effectful")+                  AllStreams+                  ( \_ -> do+                      State.modify @Int (+ 1)+                      count <- State.get @Int+                      liftIO (modifyIORef' seen (count :))+                      pure Stop+                  )+              )+                { undecodableHandler = Just (\_ _ -> State.modify @Int (+ 1) >> pure Continue)+                }+        handle <- SubEff.subscribe config+        liftIO (expectCleanStop handle)+      readIORef seen `shouldReturn` [2] -        let subName = SubscriptionName "publisher-decode-hook-resilience"-            decodeOnce event-                | event ^. #eventType == EventType "Boom" = do-                    alreadyFailed <- atomicModifyIORef' failedOnce (\old -> (True, old))-                    if alreadyFailed-                        then pure (Right event)-                        else throwIO CallbackBoom-                | otherwise = pure (Right event)-            observe evt = do-                case evt of-                    KirokuEventPublisherLoopError{} -> () <$ tryPutMVar loopErrorSeen ()-                    _ -> pure ()-                caughtUpEventHandler subName caughtUp Nothing evt-            tweak settings =-                settings-                    & #storeSettings .~ defaultStoreSettings{decodeHook = Just decodeOnce}-                    & #eventHandler .~ Just observe-            handler event = do-                modifyIORef' delivered ((event ^. #eventType) :)-                atomically $ do-                    n <- readTVar deliveredCount-                    let n' = n + 1-                    writeTVar deliveredCount n'-                    pure $ if n' >= 2 then Stop else Continue+  it "keeps the publisher alive when decodeHook throws once" $ do+    failedOnce <- newIORef False+    loopErrorSeen <- newEmptyMVar+    caughtUp <- newEmptyMVar+    delivered <- newIORef ([] :: [EventType])+    deliveredCount <- newTVarIO (0 :: Int) -        withTestStoreSettings tweak $ \store -> do-            handle <- subscribe store (defaultSubscriptionConfig subName AllStreams handler)-            waitForSubscriptionLive caughtUp-            Right _ <- runStoreIO store $ appendToStream (StreamName "publisher-decode-boom") NoStream [makeEvent "Boom" (Aeson.object [])]-            within "publisher loop error event" (waitForSubscriptionLive loopErrorSeen)-            Right _ <- runStoreIO store $ appendToStream (StreamName "publisher-decode-ok") NoStream [makeEvent "Ok" (Aeson.object [])]-            result <- waitWithTimeout timeoutMicros handle-            case result of-                Right (Right ()) -> pure ()-                Left timeout -> expectationFailure timeout-                Right (Left e) -> expectationFailure ("expected clean stop, got: " <> show e)+    let subName = SubscriptionName "publisher-decode-hook-resilience"+        decodeOnce event+          | event ^. #eventType == EventType "Boom" = do+              alreadyFailed <- atomicModifyIORef' failedOnce (\old -> (True, old))+              if alreadyFailed+                then pure (Right event)+                else throwIO CallbackBoom+          | otherwise = pure (Right event)+        observe evt = do+          case evt of+            KirokuEventPublisherLoopError {} -> () <$ tryPutMVar loopErrorSeen ()+            _ -> pure ()+          caughtUpEventHandler subName caughtUp Nothing evt+        tweak settings =+          settings+            & #storeSettings .~ defaultStoreSettings {decodeHook = Just decodeOnce}+            & #eventHandler .~ Just observe+        handler event = do+          modifyIORef' delivered ((event ^. #eventType) :)+          atomically $ do+            n <- readTVar deliveredCount+            let n' = n + 1+            writeTVar deliveredCount n'+            pure $ if n' >= 2 then Stop else Continue -        seen <- reverse <$> readIORef delivered-        seen `shouldBe` [EventType "Boom", EventType "Ok"]+    withTestStoreSettings tweak $ \store -> do+      handle <- subscribe store (defaultSubscriptionConfig subName AllStreams handler)+      waitForSubscriptionLive caughtUp+      Right _ <- runStoreIO store $ appendToStream (StreamName "publisher-decode-boom") NoStream [makeEvent "Boom" (Aeson.object [])]+      within "publisher loop error event" (waitForSubscriptionLive loopErrorSeen)+      Right _ <- runStoreIO store $ appendToStream (StreamName "publisher-decode-ok") NoStream [makeEvent "Ok" (Aeson.object [])]+      result <- waitWithTimeout timeoutMicros handle+      case result of+        Right (Right ()) -> pure ()+        Left timeout -> expectationFailure timeout+        Right (Left e) -> expectationFailure ("expected clean stop, got: " <> show e) -    it "drops throwing eventHandler exceptions without killing publisher or worker" $ do-        deliveredCount <- newTVarIO (0 :: Int)-        let tweak settings = settings & #eventHandler .~ Just (\_ -> throwIO CallbackBoom)-            handler _ = do-                atomically $ do-                    n <- readTVar deliveredCount-                    let n' = n + 1-                    writeTVar deliveredCount n'-                    pure $ if n' >= 3 then Stop else Continue+    seen <- reverse <$> readIORef delivered+    seen `shouldBe` [EventType "Boom", EventType "Ok"] -        withTestStoreSettings tweak $ \store -> do-            handle <- subscribe store (defaultSubscriptionConfig (SubscriptionName "throwing-event-handler-resilience") AllStreams handler)-            Right _ <- runStoreIO store $ appendToStream (StreamName "throwing-handler-1") NoStream [makeEvent "One" (Aeson.object [])]-            Right _ <- runStoreIO store $ appendToStream (StreamName "throwing-handler-2") NoStream [makeEvent "Two" (Aeson.object [])]-            Right _ <- runStoreIO store $ appendToStream (StreamName "throwing-handler-3") NoStream [makeEvent "Three" (Aeson.object [])]-            result <- waitWithTimeout timeoutMicros handle-            case result of-                Right (Right ()) -> pure ()-                Left timeout -> expectationFailure timeout-                Right (Left e) -> expectationFailure ("expected clean stop, got: " <> show e)+  it "drops throwing eventHandler exceptions without killing publisher or worker" $ do+    deliveredCount <- newTVarIO (0 :: Int)+    let tweak settings = settings & #eventHandler .~ Just (\_ -> throwIO CallbackBoom)+        handler _ = do+          atomically $ do+            n <- readTVar deliveredCount+            let n' = n + 1+            writeTVar deliveredCount n'+            pure $ if n' >= 3 then Stop else Continue -        finalCount <- atomically (readTVar deliveredCount)-        finalCount `shouldBe` 3+    withTestStoreSettings tweak $ \store -> do+      handle <- subscribe store (defaultSubscriptionConfig (SubscriptionName "throwing-event-handler-resilience") AllStreams handler)+      Right _ <- runStoreIO store $ appendToStream (StreamName "throwing-handler-1") NoStream [makeEvent "One" (Aeson.object [])]+      Right _ <- runStoreIO store $ appendToStream (StreamName "throwing-handler-2") NoStream [makeEvent "Two" (Aeson.object [])]+      Right _ <- runStoreIO store $ appendToStream (StreamName "throwing-handler-3") NoStream [makeEvent "Three" (Aeson.object [])]+      result <- waitWithTimeout timeoutMicros handle+      case result of+        Right (Right ()) -> pure ()+        Left timeout -> expectationFailure timeout+        Right (Left e) -> expectationFailure ("expected clean stop, got: " <> show e)++    finalCount <- atomically (readTVar deliveredCount)+    finalCount `shouldBe` 3
test/Test/PublisherDropCounter.hs view
@@ -16,44 +16,44 @@  spec :: Spec spec = describe "publisher drop counter" $ do-    it "counts drops, keeps the newest batch and preserves the idempotent wrapper" $ withTestStore $ \store -> do-        baseline <- IntMap.size <$> readTVarIO (Pub.subscribers store.publisher)-        bracket (atomically (Pub.subscribePublisherWith store.publisher 1 DropOldest)) Pub.unsubscribe $ \sub -> do-            appendAndWait store "drop-a" 1-            readTVarIO sub.subscriptionDropped `shouldReturn` 0-            appendAndWait store "drop-b" 2-            readTVarIO sub.subscriptionDropped `shouldReturn` 1-            readTVarIO sub.subscriptionStatus `shouldReturn` Pub.Active-            newest sub.subscriptionQueue `shouldReturn` [GlobalPosition 2]-            bracket (atomically (Pub.subscribePublisher store.publisher 1 DropOldest)) (\(_, _, stop) -> stop) $ \(queue, _, stop) -> do-                appendAndWait store "drop-c" 3-                appendAndWait store "drop-d" 4-                newest queue `shouldReturn` [GlobalPosition 4]-                stop >> stop-            Pub.unsubscribe sub >> Pub.unsubscribe sub-        (IntMap.size <$> readTVarIO (Pub.subscribers store.publisher)) `shouldReturn` baseline-    it "does not count PauseAndResume or DropSubscription overflow" $ withTestStore $ \store ->-        mapM_-            ( \policy -> bracket (atomically (Pub.subscribePublisherWith store.publisher 1 policy)) Pub.unsubscribe $ \sub -> do-                Right pos <- runStoreIO store (appendToStream (StreamName "policies-a") AnyVersion [makeEvent "E" (object [])])-                bounded (waitForPublisher store pos.globalPosition)-                Right pos2 <- runStoreIO store (appendToStream (StreamName "policies-a") AnyVersion [makeEvent "E" (object [])])-                bounded (waitForPublisher store pos2.globalPosition)-                readTVarIO sub.subscriptionDropped `shouldReturn` 0-                readTVarIO sub.subscriptionStatus `shouldReturn` (if policy == PauseAndResume then Pub.Paused else Pub.Overflowed)-            )-            [PauseAndResume, DropSubscription]+  it "counts drops, keeps the newest batch and preserves the idempotent wrapper" $ withTestStore $ \store -> do+    baseline <- IntMap.size <$> readTVarIO (Pub.subscribers store.publisher)+    bracket (atomically (Pub.subscribePublisherWith store.publisher 1 DropOldest)) Pub.unsubscribe $ \sub -> do+      appendAndWait store "drop-a" 1+      readTVarIO sub.subscriptionDropped `shouldReturn` 0+      appendAndWait store "drop-b" 2+      readTVarIO sub.subscriptionDropped `shouldReturn` 1+      readTVarIO sub.subscriptionStatus `shouldReturn` Pub.Active+      newest sub.subscriptionQueue `shouldReturn` [GlobalPosition 2]+      bracket (atomically (Pub.subscribePublisher store.publisher 1 DropOldest)) (\(_, _, stop) -> stop) $ \(queue, _, stop) -> do+        appendAndWait store "drop-c" 3+        appendAndWait store "drop-d" 4+        newest queue `shouldReturn` [GlobalPosition 4]+        stop >> stop+      Pub.unsubscribe sub >> Pub.unsubscribe sub+    (IntMap.size <$> readTVarIO (Pub.subscribers store.publisher)) `shouldReturn` baseline+  it "does not count PauseAndResume or DropSubscription overflow" $ withTestStore $ \store ->+    mapM_+      ( \policy -> bracket (atomically (Pub.subscribePublisherWith store.publisher 1 policy)) Pub.unsubscribe $ \sub -> do+          Right pos <- runStoreIO store (appendToStream (StreamName "policies-a") AnyVersion [makeEvent "E" (object [])])+          bounded (waitForPublisher store pos.globalPosition)+          Right pos2 <- runStoreIO store (appendToStream (StreamName "policies-a") AnyVersion [makeEvent "E" (object [])])+          bounded (waitForPublisher store pos2.globalPosition)+          readTVarIO sub.subscriptionDropped `shouldReturn` 0+          readTVarIO sub.subscriptionStatus `shouldReturn` (if policy == PauseAndResume then Pub.Paused else Pub.Overflowed)+      )+      [PauseAndResume, DropSubscription]  appendAndWait :: KirokuStore -> Text -> Int -> IO () appendAndWait store name n = do-    Right _ <- runStoreIO store (appendToStream (StreamName name) NoStream [makeEvent "E" (object [])])-    bounded (waitForPublisher store (GlobalPosition (fromIntegral n)))+  Right _ <- runStoreIO store (appendToStream (StreamName name) NoStream [makeEvent "E" (object [])])+  bounded (waitForPublisher store (GlobalPosition (fromIntegral n)))  bounded :: IO a -> IO a bounded action = timeout 5_000_000 action >>= maybe (fail "publisher timeout") pure  newest :: TBQueue DecodedBatch -> IO [GlobalPosition] newest queue = do-    Just (UnchangedBatch events) <- atomically (tryReadTBQueue queue)-    atomically (isEmptyTBQueue queue) `shouldReturn` True-    pure (map (.globalPosition) (V.toList events))+  Just (UnchangedBatch events) <- atomically (tryReadTBQueue queue)+  atomically (isEmptyTBQueue queue) `shouldReturn` True+  pure (map (.globalPosition) (V.toList events))
test/Test/PublisherIdleAdvance.hs view
@@ -27,149 +27,149 @@  within :: String -> IO a -> IO a within label action = do-    result <- Async.race (threadDelay timeoutMicros) action-    case result of-        Left () -> fail ("timed out waiting for " <> label)-        Right a -> pure a+  result <- Async.race (threadDelay timeoutMicros) action+  case result of+    Left () -> fail ("timed out waiting for " <> label)+    Right a -> pure a  countingSettings :: IORef Int -> ConnectionSettings -> ConnectionSettings countingSettings counter settings =-    settings-        & #storeSettings-            .~ defaultStoreSettings-                { decodeHook =-                    Just $ \event -> do-                        atomicModifyIORef' counter (\n -> (n + 1, ()))-                        pure (Right event)-                }+  settings+    & #storeSettings+      .~ defaultStoreSettings+        { decodeHook =+            Just $ \event -> do+              atomicModifyIORef' counter (\n -> (n + 1, ()))+              pure (Right event)+        }  appendEvents :: KirokuStore -> Int -> String -> IO GlobalPosition appendEvents store n prefix = do-    results <--        traverse-            ( \i ->-                runStoreIO store $-                    appendToStream-                        (StreamName (fromString (prefix <> "-" <> show i)))-                        NoStream-                        [makeEvent (fromString ("E" <> show i)) (Aeson.object [])]-            )-            [1 .. n]-    case sequence results of-        Left err -> fail ("append failed: " <> show err)-        Right [] -> fail "appendEvents called with zero events"-        Right xs -> pure (last xs ^. #globalPosition)+  results <-+    traverse+      ( \i ->+          runStoreIO store $+            appendToStream+              (StreamName (fromString (prefix <> "-" <> show i)))+              NoStream+              [makeEvent (fromString ("E" <> show i)) (Aeson.object [])]+      )+      [1 .. n]+  case sequence results of+    Left err -> fail ("append failed: " <> show err)+    Right [] -> fail "appendEvents called with zero events"+    Right xs -> pure (last xs ^. #globalPosition)  publisherSubscriberCount :: KirokuStore -> IO Int publisherSubscriberCount store =-    IntMap.size <$> readTVarIO (Pub.subscribers (store ^. #publisher))+  IntMap.size <$> readTVarIO (Pub.subscribers (store ^. #publisher))  sampleLargeObjects :: IO Integer sampleLargeObjects = do-    enabled <- getRTSStatsEnabled-    when (not enabled) (fail "publisher retention test requires RTS statistics")-    performMajorGC-    fromIntegral . gcdetails_large_objects_bytes . gc <$> getRTSStats+  enabled <- getRTSStatsEnabled+  when (not enabled) (fail "publisher retention test requires RTS statistics")+  performMajorGC+  fromIntegral . gcdetails_large_objects_bytes . gc <$> getRTSStats  spec :: Spec spec = describe "publisher idle advance" $ do-    it "does not retain append results while advancing with no queue subscribers" $ do-        withTestStore $ \store -> do-            let blockSize :: Int-                blockSize = 2_000-                blocks :: Int-                blocks = 6-                event = makeEvent "Retention" (Aeson.object [])-                appendOne = do-                    result <- runStoreIO store $ appendToStream (StreamName "pubidle-retention") AnyVersion [event]-                    case result of-                        Left err -> fail ("append failed: " <> show err)-                        Right _ -> pure ()-            samples <- forM [1 .. blocks] $ \_ -> do-                sequence_ (replicate blockSize appendOne)-                -- Allow the notification-driven publisher to finish its tail-                -- query without reading (and thereby forcing) its position.-                threadDelay 100_000-                sampleLargeObjects-            publisherSubscriberCount store `shouldReturn` 0-            case samples of-                firstSample : _ -> do-                    let growth = last samples - firstSample-                    when (growth >= 3 * 1024 * 1024) $-                        expectationFailure ("large-object bytes grew by " <> show growth <> "; samples: " <> show samples)-                [] -> expectationFailure "publisher retention test collected no heap samples"+  it "does not retain append results while advancing with no queue subscribers" $ do+    withTestStore $ \store -> do+      let blockSize :: Int+          blockSize = 2_000+          blocks :: Int+          blocks = 6+          event = makeEvent "Retention" (Aeson.object [])+          appendOne = do+            result <- runStoreIO store $ appendToStream (StreamName "pubidle-retention") AnyVersion [event]+            case result of+              Left err -> fail ("append failed: " <> show err)+              Right _ -> pure ()+      samples <- forM [1 .. blocks] $ \_ -> do+        sequence_ (replicate blockSize appendOne)+        -- Allow the notification-driven publisher to finish its tail+        -- query without reading (and thereby forcing) its position.+        threadDelay 100_000+        sampleLargeObjects+      publisherSubscriberCount store `shouldReturn` 0+      case samples of+        firstSample : _ -> do+          let growth = last samples - firstSample+          when (growth >= 3 * 1024 * 1024) $+            expectationFailure ("large-object bytes grew by " <> show growth <> "; samples: " <> show samples)+        [] -> expectationFailure "publisher retention test collected no heap samples" -    it "advances lastPublished without decoding any rows when no subscriber is registered" $ do-        counter <- newIORef 0-        withTestStoreSettings (countingSettings counter) $ \store -> do-            tailPos <- appendEvents store 25 "pubidle-empty"-            waitForPublisher store tailPos-            readIORef counter `shouldReturn` 0+  it "advances lastPublished without decoding any rows when no subscriber is registered" $ do+    counter <- newIORef 0+    withTestStoreSettings (countingSettings counter) $ \store -> do+      tailPos <- appendEvents store 25 "pubidle-empty"+      waitForPublisher store tailPos+      readIORef counter `shouldReturn` 0 -    it "does not fetch full rows while only a category subscriber exists" $ do-        counter <- newIORef 0-        caughtUp <- newEmptyMVar-        delivered <- newEmptyMVar-        let subName = SubscriptionName "pubidle-category"-            observe = caughtUpEventHandler subName caughtUp Nothing-            handler event = do-                case event ^. #eventType of-                    EventType "Wake" -> () <$ tryPutMVar delivered ()-                    _ -> pure ()-                pure Continue-            tweak settings =-                countingSettings counter settings-                    & #eventHandler .~ Just observe-        withTestStoreSettings tweak $ \store ->-            bracket-                (subscribe store (defaultSubscriptionConfig subName (Category (CategoryName "pubidlea")) handler))-                cancel-                $ \_handle -> do-                    within "category subscription live" (waitForSubscriptionLive caughtUp)-                    countBefore <- readIORef counter-                    tailPos <- appendEvents store 30 "pubidleb"-                    waitForPublisher store tailPos-                    countAfter <- readIORef counter-                    (countAfter - countBefore) `shouldBe` 0-                    publisherSubscriberCount store `shouldReturn` 0+  it "does not fetch full rows while only a category subscriber exists" $ do+    counter <- newIORef 0+    caughtUp <- newEmptyMVar+    delivered <- newEmptyMVar+    let subName = SubscriptionName "pubidle-category"+        observe = caughtUpEventHandler subName caughtUp Nothing+        handler event = do+          case event ^. #eventType of+            EventType "Wake" -> () <$ tryPutMVar delivered ()+            _ -> pure ()+          pure Continue+        tweak settings =+          countingSettings counter settings+            & #eventHandler .~ Just observe+    withTestStoreSettings tweak $ \store ->+      bracket+        (subscribe store (defaultSubscriptionConfig subName (Category (CategoryName "pubidlea")) handler))+        cancel+        $ \_handle -> do+          within "category subscription live" (waitForSubscriptionLive caughtUp)+          countBefore <- readIORef counter+          tailPos <- appendEvents store 30 "pubidleb"+          waitForPublisher store tailPos+          countAfter <- readIORef counter+          (countAfter - countBefore) `shouldBe` 0+          publisherSubscriberCount store `shouldReturn` 0 -                    Right wakePos <--                        runStoreIO store $-                            appendToStream-                                (StreamName "pubidlea-1")-                                NoStream-                                [makeEvent "Wake" (Aeson.object [])]-                    waitForPublisher store (wakePos ^. #globalPosition)-                    within "category wake event" (waitForSubscriptionLive delivered)+          Right wakePos <-+            runStoreIO store $+              appendToStream+                (StreamName "pubidlea-1")+                NoStream+                [makeEvent "Wake" (Aeson.object [])]+          waitForPublisher store (wakePos ^. #globalPosition)+          within "category wake event" (waitForSubscriptionLive delivered) -    it "switches from cheap advance to all-stream delivery without gaps" $ do-        counter <- newIORef 0-        caughtUp <- newEmptyMVar-        seenRef <- newIORef ([] :: [GlobalPosition])-        seenEnough <- newEmptyMVar-        let subName = SubscriptionName "pubidle-transition"-            observe = caughtUpEventHandler subName caughtUp Nothing-            handler event = do-                let pos = event ^. #globalPosition-                count <--                    atomicModifyIORef' seenRef $ \old ->-                        let new = pos : old-                         in (new, length new)-                when (count >= 35) (() <$ tryPutMVar seenEnough ())-                pure Continue-            tweak settings =-                countingSettings counter settings-                    & #eventHandler .~ Just observe-        withTestStoreSettings tweak $ \store -> do-            tailBefore <- appendEvents store 20 "pubidle-before"-            waitForPublisher store tailBefore-            bracket-                (subscribe store (defaultSubscriptionConfig subName AllStreams handler))-                cancel-                $ \_handle -> do-                    within "all-stream subscription live" (waitForSubscriptionLive caughtUp)-                    tailAfter <- appendEvents store 15 "pubidle-after"-                    waitForPublisher store tailAfter-                    within "all-stream events" (waitForSubscriptionLive seenEnough)-                    seen <- sort <$> readIORef seenRef-                    seen `shouldBe` fmap GlobalPosition [1 .. 35]+  it "switches from cheap advance to all-stream delivery without gaps" $ do+    counter <- newIORef 0+    caughtUp <- newEmptyMVar+    seenRef <- newIORef ([] :: [GlobalPosition])+    seenEnough <- newEmptyMVar+    let subName = SubscriptionName "pubidle-transition"+        observe = caughtUpEventHandler subName caughtUp Nothing+        handler event = do+          let pos = event ^. #globalPosition+          count <-+            atomicModifyIORef' seenRef $ \old ->+              let new = pos : old+               in (new, length new)+          when (count >= 35) (() <$ tryPutMVar seenEnough ())+          pure Continue+        tweak settings =+          countingSettings counter settings+            & #eventHandler .~ Just observe+    withTestStoreSettings tweak $ \store -> do+      tailBefore <- appendEvents store 20 "pubidle-before"+      waitForPublisher store tailBefore+      bracket+        (subscribe store (defaultSubscriptionConfig subName AllStreams handler))+        cancel+        $ \_handle -> do+          within "all-stream subscription live" (waitForSubscriptionLive caughtUp)+          tailAfter <- appendEvents store 15 "pubidle-after"+          waitForPublisher store tailAfter+          within "all-stream events" (waitForSubscriptionLive seenEnough)+          seen <- sort <$> readIORef seenRef+          seen `shouldBe` fmap GlobalPosition [1 .. 35]
test/Test/PublisherRestartNoRebroadcast.hs view
@@ -17,204 +17,204 @@  spec :: Spec spec =-    describe "publisher restart tail initialization" $ do-        it "does not rebroadcast restart history into a blocked all-stream subscriber" $-            withMigratedTestDatabase $ \connStr -> do-                let settings = defaultConnectionSettings connStr-                withStore settings $ \store ->-                    appendBatch store "tail-init-overflow-seed" "TailInitSeed" 1001+  describe "publisher restart tail initialization" $ do+    it "does not rebroadcast restart history into a blocked all-stream subscriber" $+      withMigratedTestDatabase $ \connStr -> do+        let settings = defaultConnectionSettings connStr+        withStore settings $ \store ->+          appendBatch store "tail-init-overflow-seed" "TailInitSeed" 1001 -                firstSeen <- newEmptyMVar-                releaseFirst <- newEmptyMVar-                caughtUp <- newEmptyMVar-                blockedFirst <- newTVarIO True-                ref <- newIORef ([] :: [GlobalPosition])+        firstSeen <- newEmptyMVar+        releaseFirst <- newEmptyMVar+        caughtUp <- newEmptyMVar+        blockedFirst <- newTVarIO True+        ref <- newIORef ([] :: [GlobalPosition]) -                let subName = SubscriptionName "tail-init-no-rebroadcast"-                    stopType = EventType "TailInitStop"-                    handler' evt = do-                        modifyIORef' ref ((evt ^. #globalPosition) :)-                        shouldBlock <- atomically $ do-                            block <- readTVar blockedFirst-                            when block (writeTVar blockedFirst False)-                            pure block-                        when shouldBlock $ do-                            putMVar firstSeen ()-                            takeMVar releaseFirst-                        if evt ^. #eventType == stopType-                            then pure Stop-                            else pure Continue-                    cfg =-                        SubscriptionConfig-                            { name = subName-                            , target = AllStreams-                            , handler = handler'-                            , batchSize = either (error . show) Prelude.id (mkBatchSize 10)-                            , queueCapacity = 1-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                    liveSettings =-                        settings-                            & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)+        let subName = SubscriptionName "tail-init-no-rebroadcast"+            stopType = EventType "TailInitStop"+            handler' evt = do+              modifyIORef' ref ((evt ^. #globalPosition) :)+              shouldBlock <- atomically $ do+                block <- readTVar blockedFirst+                when block (writeTVar blockedFirst False)+                pure block+              when shouldBlock $ do+                putMVar firstSeen ()+                takeMVar releaseFirst+              if evt ^. #eventType == stopType+                then pure Stop+                else pure Continue+            cfg =+              SubscriptionConfig+                { name = subName,+                  target = AllStreams,+                  handler = handler',+                  batchSize = either (error . show) Prelude.id (mkBatchSize 10),+                  queueCapacity = 1,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+            liveSettings =+              settings+                & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing) -                withStore liveSettings $ \store -> do-                    handle <- subscribe store cfg-                    takeMVar firstSeen+        withStore liveSettings $ \store -> do+          handle <- subscribe store cfg+          takeMVar firstSeen -                    Right _ <--                        runStoreIO store $-                            appendToStream-                                (StreamName "tail-init-overflow-live")-                                NoStream-                                [makeEvent "TailInitDuringCatchUp" (Aeson.object [])]-                    waitForPublisher store (GlobalPosition 1002)+          Right _ <-+            runStoreIO store $+              appendToStream+                (StreamName "tail-init-overflow-live")+                NoStream+                [makeEvent "TailInitDuringCatchUp" (Aeson.object [])]+          waitForPublisher store (GlobalPosition 1002) -                    putMVar releaseFirst ()-                    waitForSubscriptionLive caughtUp+          putMVar releaseFirst ()+          waitForSubscriptionLive caughtUp -                    Right _ <--                        runStoreIO store $-                            appendToStream-                                (StreamName "tail-init-overflow-stop")-                                NoStream-                                [makeEvent "TailInitStop" (Aeson.object [])]+          Right _ <-+            runStoreIO store $+              appendToStream+                (StreamName "tail-init-overflow-stop")+                NoStream+                [makeEvent "TailInitStop" (Aeson.object [])] -                    result <- waitWithTimeout 10_000_000 handle-                    case result of-                        Left timeout -> expectationFailure timeout-                        Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                        Right (Right ()) -> pure ()+          result <- waitWithTimeout 10_000_000 handle+          case result of+            Left timeout -> expectationFailure timeout+            Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+            Right (Right ()) -> pure () -                collected <- reverse <$> readIORef ref-                collected `shouldBe` map GlobalPosition [1 .. 1003]+        collected <- reverse <$> readIORef ref+        collected `shouldBe` map GlobalPosition [1 .. 1003] -        it "delivers a live event appended after a restarted subscriber catches up" $-            withMigratedTestDatabase $ \connStr -> do-                let settings = defaultConnectionSettings connStr-                    subName = SubscriptionName "tail-init-live-after-catchup"-                withStore settings $ \store ->-                    appendBatch store "tail-init-live-seed" "TailInitLiveSeed" 3+    it "delivers a live event appended after a restarted subscriber catches up" $+      withMigratedTestDatabase $ \connStr -> do+        let settings = defaultConnectionSettings connStr+            subName = SubscriptionName "tail-init-live-after-catchup"+        withStore settings $ \store ->+          appendBatch store "tail-init-live-seed" "TailInitLiveSeed" 3 -                caughtUp <- newEmptyMVar-                ref <- newIORef ([] :: [GlobalPosition])-                let cfg =-                        SubscriptionConfig-                            { name = subName-                            , target = AllStreams-                            , handler = \evt -> do-                                modifyIORef' ref ((evt ^. #globalPosition) :)-                                if evt ^. #globalPosition == GlobalPosition 4-                                    then pure Stop-                                    else pure Continue-                            , batchSize = either (error . show) Prelude.id (mkBatchSize 10)-                            , queueCapacity = 1-                            , overflowPolicy = DropSubscription-                            , consumerGroup = Nothing-                            , consumerGroupGuard = False-                            , missingCheckpointPolicy = FromBeginning-                            , targetBindingPolicy = AdoptUnbound-                            , undecodableHandler = Nothing-                            , handlerStallWarnAfter = Nothing-                            , retryPolicy = defaultRetryPolicy-                            , eventTypeFilter = AllEventTypes-                            , selector = Nothing-                            }-                    liveSettings =-                        settings-                            & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing)+        caughtUp <- newEmptyMVar+        ref <- newIORef ([] :: [GlobalPosition])+        let cfg =+              SubscriptionConfig+                { name = subName,+                  target = AllStreams,+                  handler = \evt -> do+                    modifyIORef' ref ((evt ^. #globalPosition) :)+                    if evt ^. #globalPosition == GlobalPosition 4+                      then pure Stop+                      else pure Continue,+                  batchSize = either (error . show) Prelude.id (mkBatchSize 10),+                  queueCapacity = 1,+                  overflowPolicy = DropSubscription,+                  consumerGroup = Nothing,+                  consumerGroupGuard = False,+                  missingCheckpointPolicy = FromBeginning,+                  targetBindingPolicy = AdoptUnbound,+                  undecodableHandler = Nothing,+                  handlerStallWarnAfter = Nothing,+                  retryPolicy = defaultRetryPolicy,+                  eventTypeFilter = AllEventTypes,+                  selector = Nothing+                }+            liveSettings =+              settings+                & #eventHandler .~ Just (caughtUpEventHandler subName caughtUp Nothing) -                withStore liveSettings $ \store -> do-                    handle <- subscribe store cfg-                    waitForSubscriptionLive caughtUp-                    Right _ <--                        runStoreIO store $-                            appendToStream-                                (StreamName "tail-init-live-after-catchup")-                                NoStream-                                [makeEvent "TailInitLiveAfterCatchUp" (Aeson.object [])]-                    result <- waitWithTimeout 10_000_000 handle-                    case result of-                        Left timeout -> expectationFailure timeout-                        Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-                        Right (Right ()) -> pure ()+        withStore liveSettings $ \store -> do+          handle <- subscribe store cfg+          waitForSubscriptionLive caughtUp+          Right _ <-+            runStoreIO store $+              appendToStream+                (StreamName "tail-init-live-after-catchup")+                NoStream+                [makeEvent "TailInitLiveAfterCatchUp" (Aeson.object [])]+          result <- waitWithTimeout 10_000_000 handle+          case result of+            Left timeout -> expectationFailure timeout+            Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+            Right (Right ()) -> pure () -                collected <- reverse <$> readIORef ref-                collected `shouldBe` map GlobalPosition [1 .. 4]+        collected <- reverse <$> readIORef ref+        collected `shouldBe` map GlobalPosition [1 .. 4] -        it "keeps per-subscription checkpoints independent from the publisher tail" $-            withMigratedTestDatabase $ \connStr -> do-                let settings = defaultConnectionSettings connStr-                withStore settings $ \store ->-                    appendBatch store "tail-init-checkpoint-seed" "TailInitCheckpointSeed" 10+    it "keeps per-subscription checkpoints independent from the publisher tail" $+      withMigratedTestDatabase $ \connStr -> do+        let settings = defaultConnectionSettings connStr+        withStore settings $ \store ->+          appendBatch store "tail-init-checkpoint-seed" "TailInitCheckpointSeed" 10 -                withStore settings $ \store -> do-                    runUntil store (SubscriptionName "tail-init-sub-a") 3-                        `shouldReturn` map GlobalPosition [1 .. 3]-                    runUntil store (SubscriptionName "tail-init-sub-b") 7-                        `shouldReturn` map GlobalPosition [1 .. 7]+        withStore settings $ \store -> do+          runUntil store (SubscriptionName "tail-init-sub-a") 3+            `shouldReturn` map GlobalPosition [1 .. 3]+          runUntil store (SubscriptionName "tail-init-sub-b") 7+            `shouldReturn` map GlobalPosition [1 .. 7] -                withStore settings $ \store -> do-                    runUntil store (SubscriptionName "tail-init-sub-a") 7-                        `shouldReturn` map GlobalPosition [4 .. 10]-                    runUntil store (SubscriptionName "tail-init-sub-b") 3-                        `shouldReturn` map GlobalPosition [8 .. 10]+        withStore settings $ \store -> do+          runUntil store (SubscriptionName "tail-init-sub-a") 7+            `shouldReturn` map GlobalPosition [4 .. 10]+          runUntil store (SubscriptionName "tail-init-sub-b") 3+            `shouldReturn` map GlobalPosition [8 .. 10]  appendBatch :: KirokuStore -> T.Text -> T.Text -> Int -> IO () appendBatch store streamName typePrefix count = do-    let events =-            [ makeEvent (typePrefix <> T.pack (show i)) (Aeson.object [])-            | i <- [1 .. count]-            ]-    result <- runStoreIO store $ appendToStream (StreamName streamName) NoStream events-    case result of-        Left err -> expectationFailure ("append failed: " <> show err)-        Right _ -> pure ()+  let events =+        [ makeEvent (typePrefix <> T.pack (show i)) (Aeson.object [])+        | i <- [1 .. count]+        ]+  result <- runStoreIO store $ appendToStream (StreamName streamName) NoStream events+  case result of+    Left err -> expectationFailure ("append failed: " <> show err)+    Right _ -> pure ()  runUntil :: KirokuStore -> SubscriptionName -> Int -> IO [GlobalPosition] runUntil store subName targetCount = do-    ref <- newIORef ([] :: [GlobalPosition])-    countRef <- newTVarIO (0 :: Int)-    let handler' evt = do-            modifyIORef' ref ((evt ^. #globalPosition) :)-            n <- atomically $ do-                count <- readTVar countRef-                let count' = count + 1-                writeTVar countRef count'-                pure count'-            if n >= targetCount-                then pure Stop-                else pure Continue-        cfg =-            SubscriptionConfig-                { name = subName-                , target = AllStreams-                , handler = handler'-                , batchSize = either (error . show) Prelude.id (mkBatchSize 10)-                , queueCapacity = 1-                , overflowPolicy = DropSubscription-                , consumerGroup = Nothing-                , consumerGroupGuard = False-                , missingCheckpointPolicy = FromBeginning-                , targetBindingPolicy = AdoptUnbound-                , undecodableHandler = Nothing-                , handlerStallWarnAfter = Nothing-                , retryPolicy = defaultRetryPolicy-                , eventTypeFilter = AllEventTypes-                , selector = Nothing-                }-    handle <- subscribe store cfg-    result <- waitWithTimeout 10_000_000 handle-    case result of-        Left timeout -> expectationFailure timeout-        Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)-        Right (Right ()) -> pure ()-    reverse <$> readIORef ref+  ref <- newIORef ([] :: [GlobalPosition])+  countRef <- newTVarIO (0 :: Int)+  let handler' evt = do+        modifyIORef' ref ((evt ^. #globalPosition) :)+        n <- atomically $ do+          count <- readTVar countRef+          let count' = count + 1+          writeTVar countRef count'+          pure count'+        if n >= targetCount+          then pure Stop+          else pure Continue+      cfg =+        SubscriptionConfig+          { name = subName,+            target = AllStreams,+            handler = handler',+            batchSize = either (error . show) Prelude.id (mkBatchSize 10),+            queueCapacity = 1,+            overflowPolicy = DropSubscription,+            consumerGroup = Nothing,+            consumerGroupGuard = False,+            missingCheckpointPolicy = FromBeginning,+            targetBindingPolicy = AdoptUnbound,+            undecodableHandler = Nothing,+            handlerStallWarnAfter = Nothing,+            retryPolicy = defaultRetryPolicy,+            eventTypeFilter = AllEventTypes,+            selector = Nothing+          }+  handle <- subscribe store cfg+  result <- waitWithTimeout 10_000_000 handle+  case result of+    Left timeout -> expectationFailure timeout+    Right (Left err) -> expectationFailure ("Subscription failed: " <> show err)+    Right (Right ()) -> pure ()+  reverse <$> readIORef ref
test/Test/ReadStream.hs view
@@ -1,9 +1,8 @@-{- | Tests for "Kiroku.Store.Read".'readStreamForwardStream' — the-Streamly-shaped sibling of 'readStreamForward'. The streaming wrapper-must yield the same events in the same order as the @Vector@-returning-form, page transparently across the configured @pageSize@, terminate on-empty / nonexistent streams, and honor a non-zero starting cursor.--}+-- | Tests for "Kiroku.Store.Read".'readStreamForwardStream' — the+-- Streamly-shaped sibling of 'readStreamForward'. The streaming wrapper+-- must yield the same events in the same order as the @Vector@-returning+-- form, page transparently across the configured @pageSize@, terminate on+-- empty / nonexistent streams, and honor a non-zero starting cursor. module Test.ReadStream (spec) where  import Control.Lens ((^.))@@ -22,132 +21,132 @@  spec :: Spec spec = do-    describe "readStreamForwardStream" $ do-        around withTestStore $ do-            it "matches readStreamForward on a single-page stream" $ \store -> do-                let name = StreamName "rsfs-single-page"-                    events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]-                Right _ <- runStoreIO store $ appendToStream name NoStream events-                streamed <- runStoreIO store $ Stream.toList (readStreamForwardStream name (StreamVersion 0) 256)-                vectored <- runStoreIO store $ readStreamForward name (StreamVersion 0) 256-                case (streamed, vectored) of-                    (Right xs, Right v) -> xs `shouldBe` V.toList v-                    _ -> expectationFailure ("Unexpected error: " <> show streamed <> " / " <> show vectored)+  describe "readStreamForwardStream" $ do+    around withTestStore $ do+      it "matches readStreamForward on a single-page stream" $ \store -> do+        let name = StreamName "rsfs-single-page"+            events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]+        Right _ <- runStoreIO store $ appendToStream name NoStream events+        streamed <- runStoreIO store $ Stream.toList (readStreamForwardStream name (StreamVersion 0) 256)+        vectored <- runStoreIO store $ readStreamForward name (StreamVersion 0) 256+        case (streamed, vectored) of+          (Right xs, Right v) -> xs `shouldBe` V.toList v+          _ -> expectationFailure ("Unexpected error: " <> show streamed <> " / " <> show vectored) -            it "folds 1000 events end-to-end with Fold.length across multiple pages" $ \store -> do-                let name = StreamName "rsfs-multi-page"-                    total = 1000 :: Int-                    events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. total]]-                Right _ <- runStoreIO store $ appendToStream name NoStream events-                countResult <--                    runStoreIO store $-                        Stream.fold Fold.length (readStreamForwardStream name (StreamVersion 0) 256)-                countResult `shouldBe` Right total-                allEvents <--                    runStoreIO store $-                        Stream.toList (readStreamForwardStream name (StreamVersion 0) 256)-                case allEvents of-                    Right xs -> case NE.nonEmpty xs of-                        Just ne -> do-                            let h = NE.head ne-                                l = NE.last ne-                            (h ^. #streamVersion) `shouldBe` StreamVersion 1-                            (h ^. #eventType) `shouldBe` EventType (T.pack "E1")-                            (l ^. #streamVersion) `shouldBe` StreamVersion (fromIntegral total)-                            (l ^. #eventType) `shouldBe` EventType (T.pack ("E" <> show total))-                        Nothing -> expectationFailure "Expected non-empty events list"-                    Left err -> expectationFailure ("Unexpected error: " <> show err)+      it "folds 1000 events end-to-end with Fold.length across multiple pages" $ \store -> do+        let name = StreamName "rsfs-multi-page"+            total = 1000 :: Int+            events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. total]]+        Right _ <- runStoreIO store $ appendToStream name NoStream events+        countResult <-+          runStoreIO store $+            Stream.fold Fold.length (readStreamForwardStream name (StreamVersion 0) 256)+        countResult `shouldBe` Right total+        allEvents <-+          runStoreIO store $+            Stream.toList (readStreamForwardStream name (StreamVersion 0) 256)+        case allEvents of+          Right xs -> case NE.nonEmpty xs of+            Just ne -> do+              let h = NE.head ne+                  l = NE.last ne+              (h ^. #streamVersion) `shouldBe` StreamVersion 1+              (h ^. #eventType) `shouldBe` EventType (T.pack "E1")+              (l ^. #streamVersion) `shouldBe` StreamVersion (fromIntegral total)+              (l ^. #eventType) `shouldBe` EventType (T.pack ("E" <> show total))+            Nothing -> expectationFailure "Expected non-empty events list"+          Left err -> expectationFailure ("Unexpected error: " <> show err) -            it "preserves order and avoids duplicates / gaps when paging at pageSize 2" $ \store -> do-                let name = StreamName "rsfs-page-boundary"-                    events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]-                Right _ <- runStoreIO store $ appendToStream name NoStream events-                versions <--                    runStoreIO store $-                        Stream.toList $-                            fmap (^. #streamVersion) (readStreamForwardStream name (StreamVersion 0) 2)-                versions-                    `shouldBe` Right-                        [ StreamVersion 1-                        , StreamVersion 2-                        , StreamVersion 3-                        , StreamVersion 4-                        , StreamVersion 5-                        ]+      it "preserves order and avoids duplicates / gaps when paging at pageSize 2" $ \store -> do+        let name = StreamName "rsfs-page-boundary"+            events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]+        Right _ <- runStoreIO store $ appendToStream name NoStream events+        versions <-+          runStoreIO store $+            Stream.toList $+              fmap (^. #streamVersion) (readStreamForwardStream name (StreamVersion 0) 2)+        versions+          `shouldBe` Right+            [ StreamVersion 1,+              StreamVersion 2,+              StreamVersion 3,+              StreamVersion 4,+              StreamVersion 5+            ] -            it "terminates immediately on a nonexistent stream" $ \store -> do-                let name = StreamName "rsfs-never-created"-                result <--                    runStoreIO store $-                        Stream.toList (readStreamForwardStream name (StreamVersion 0) 256)-                result `shouldBe` Right []+      it "terminates immediately on a nonexistent stream" $ \store -> do+        let name = StreamName "rsfs-never-created"+        result <-+          runStoreIO store $+            Stream.toList (readStreamForwardStream name (StreamVersion 0) 256)+        result `shouldBe` Right [] -            it "honors a non-zero starting cursor with exclusivity" $ \store -> do-                let name = StreamName "rsfs-non-zero-cursor"-                    events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]-                Right _ <- runStoreIO store $ appendToStream name NoStream events-                tailEvents <--                    runStoreIO store $-                        Stream.toList (readStreamForwardStream name (StreamVersion 2) 256)-                case tailEvents of-                    Right xs -> do-                        length xs `shouldBe` 3-                        map (^. #streamVersion) xs-                            `shouldBe` [StreamVersion 3, StreamVersion 4, StreamVersion 5]-                    Left err -> expectationFailure ("Unexpected error: " <> show err)+      it "honors a non-zero starting cursor with exclusivity" $ \store -> do+        let name = StreamName "rsfs-non-zero-cursor"+            events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]+        Right _ <- runStoreIO store $ appendToStream name NoStream events+        tailEvents <-+          runStoreIO store $+            Stream.toList (readStreamForwardStream name (StreamVersion 2) 256)+        case tailEvents of+          Right xs -> do+            length xs `shouldBe` 3+            map (^. #streamVersion) xs+              `shouldBe` [StreamVersion 3, StreamVersion 4, StreamVersion 5]+          Left err -> expectationFailure ("Unexpected error: " <> show err) -        it "uses one pool checkout per non-empty page when the final page is short" $ do-            ref <- newIORef (0 :: Int)-            let handler (ConnectionObservation _ InUseConnectionStatus) =-                    modifyIORef' ref (+ 1)-                handler _ =-                    pure ()-            withTestStoreSettings (\settings -> settings{observationHandler = Just handler}) $ \store -> do-                let name = StreamName "rsfs-short-page-count"-                    events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]-                Right appendResult <- runStoreIO store $ appendToStream name NoStream events-                waitForPublisher store (appendResult ^. #globalPosition)-                countBefore <- readIORef ref-                streamed <- runStoreIO store $ Stream.toList (readStreamForwardStream name (StreamVersion 0) 2)-                countAfter <- readIORef ref-                case streamed of-                    Right xs -> do-                        map (^. #streamVersion) xs-                            `shouldBe` [StreamVersion 1, StreamVersion 2, StreamVersion 3, StreamVersion 4, StreamVersion 5]-                        countAfter - countBefore `shouldBe` 3-                    Left err -> expectationFailure ("Unexpected error: " <> show err)+    it "uses one pool checkout per non-empty page when the final page is short" $ do+      ref <- newIORef (0 :: Int)+      let handler (ConnectionObservation _ InUseConnectionStatus) =+            modifyIORef' ref (+ 1)+          handler _ =+            pure ()+      withTestStoreSettings (\settings -> settings {observationHandler = Just handler}) $ \store -> do+        let name = StreamName "rsfs-short-page-count"+            events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. 5 :: Int]]+        Right appendResult <- runStoreIO store $ appendToStream name NoStream events+        waitForPublisher store (appendResult ^. #globalPosition)+        countBefore <- readIORef ref+        streamed <- runStoreIO store $ Stream.toList (readStreamForwardStream name (StreamVersion 0) 2)+        countAfter <- readIORef ref+        case streamed of+          Right xs -> do+            map (^. #streamVersion) xs+              `shouldBe` [StreamVersion 1, StreamVersion 2, StreamVersion 3, StreamVersion 4, StreamVersion 5]+            countAfter - countBefore `shouldBe` 3+          Left err -> expectationFailure ("Unexpected error: " <> show err) -    describe "eventExistsInStream" $-        around withTestStore $ do-            it "finds an existing event id in its live stream" $ \store -> do-                eid <- EventId <$> V7.genUUID-                let name = StreamName "event-exists-present"-                Right _ <- runStoreIO store $ appendToStream name NoStream [eventWithId eid]-                runStoreIO store (eventExistsInStream name eid) `shouldReturn` Right True+  describe "eventExistsInStream" $+    around withTestStore $ do+      it "finds an existing event id in its live stream" $ \store -> do+        eid <- EventId <$> V7.genUUID+        let name = StreamName "event-exists-present"+        Right _ <- runStoreIO store $ appendToStream name NoStream [eventWithId eid]+        runStoreIO store (eventExistsInStream name eid) `shouldReturn` Right True -            it "returns False for absent ids and ids linked only to another stream" $ \store -> do-                eid <- EventId <$> V7.genUUID-                otherEid <- EventId <$> V7.genUUID-                let source = StreamName "event-exists-source"-                    target = StreamName "event-exists-target"-                Right _ <- runStoreIO store $ appendToStream source NoStream [eventWithId eid]-                runStoreIO store (eventExistsInStream source otherEid) `shouldReturn` Right False-                runStoreIO store (eventExistsInStream target eid) `shouldReturn` Right False+      it "returns False for absent ids and ids linked only to another stream" $ \store -> do+        eid <- EventId <$> V7.genUUID+        otherEid <- EventId <$> V7.genUUID+        let source = StreamName "event-exists-source"+            target = StreamName "event-exists-target"+        Right _ <- runStoreIO store $ appendToStream source NoStream [eventWithId eid]+        runStoreIO store (eventExistsInStream source otherEid) `shouldReturn` Right False+        runStoreIO store (eventExistsInStream target eid) `shouldReturn` Right False -            it "treats soft-deleted streams as absent" $ \store -> do-                eid <- EventId <$> V7.genUUID-                let name = StreamName "event-exists-soft-deleted"-                Right _ <- runStoreIO store $ appendToStream name NoStream [eventWithId eid]-                Right (Just _) <- runStoreIO store $ softDeleteStream name-                runStoreIO store (eventExistsInStream name eid) `shouldReturn` Right False+      it "treats soft-deleted streams as absent" $ \store -> do+        eid <- EventId <$> V7.genUUID+        let name = StreamName "event-exists-soft-deleted"+        Right _ <- runStoreIO store $ appendToStream name NoStream [eventWithId eid]+        Right (Just _) <- runStoreIO store $ softDeleteStream name+        runStoreIO store (eventExistsInStream name eid) `shouldReturn` Right False  eventWithId :: EventId -> EventData eventWithId eid =-    EventData-        { eventId = Just eid-        , eventType = EventType "Created"-        , payload = Aeson.object []-        , metadata = Nothing-        , causationId = Nothing-        , correlationId = Nothing-        }+  EventData+    { eventId = Just eid,+      eventType = EventType "Created",+      payload = Aeson.object [],+      metadata = Nothing,+      causationId = Nothing,+      correlationId = Nothing+    }
test/Test/ScriptSessionErrors.hs view
@@ -25,89 +25,89 @@  spec :: Spec spec = describe "structured script session errors" $ do-    around withTestStore $ do-        it "preserves missing-relation SQLSTATE through runTransaction" $ \store -> do-            result <- runStoreIO store (runTransaction (Tx.sql (TE.encodeUtf8 missingRelation)))-            shouldBeMissingRelation result-        it "preserves missing-relation SQLSTATE without retry" $ \store -> do-            result <- runStoreIO store (runTransactionNoRetry (Tx.sql (TE.encodeUtf8 missingRelation)))-            shouldBeMissingRelation result-        forM_ ["40001", "40P01"] $ \code -> do-            it ("preserves script SQLSTATE and message " <> show code) $ \store -> do-                result <- timeout 5_000_000 $ runStoreIO store (runTransactionNoRetry (Tx.sql (TE.encodeUtf8 (raiseCode code))))-                result `shouldBe` Just (Left (TransientTransactionFailure code fixtureMessage))-            it ("preserves prepared-statement SQLSTATE and message " <> show code) $ \store -> do-                result <--                    timeout 5_000_000 $-                        runStoreIO store $-                            runTransactionNoRetry $-                                Tx.statement () (preparable (raiseCode code) E.noParams D.noResult)-                result `shouldBe` Just (Left (TransientTransactionFailure code fixtureMessage))-        it "rolls back application writes before a script failure" $ \store -> do-            createSideTable store-            result <- runStoreIO store $ runTransactionNoRetry $ do-                Tx.sql "INSERT INTO public.script_error_rows VALUES (1)"-                Tx.sql (TE.encodeUtf8 missingRelation)-            shouldBeMissingRelation result-            countSideRows store `shouldReturn` Right 0-        it "rolls back composed appends and application writes on script failure" $ \store -> do-            createSideTable store-            result <- runStoreIO store $-                runTransactionAppendingNoRetry (StreamName "script-rollback") NoStream [makeEvent "Created" (Aeson.object [])] $ \_ -> do-                    Tx.sql "INSERT INTO public.script_error_rows VALUES (1)"-                    Tx.sql (TE.encodeUtf8 missingRelation)-            shouldBeMissingRelation result-            countSideRows store `shouldReturn` Right 0-            runStoreIO store (getStream (StreamName "script-rollback")) `shouldReturn` Right Nothing-            countEvents store `shouldReturn` 0+  around withTestStore $ do+    it "preserves missing-relation SQLSTATE through runTransaction" $ \store -> do+      result <- runStoreIO store (runTransaction (Tx.sql (TE.encodeUtf8 missingRelation)))+      shouldBeMissingRelation result+    it "preserves missing-relation SQLSTATE without retry" $ \store -> do+      result <- runStoreIO store (runTransactionNoRetry (Tx.sql (TE.encodeUtf8 missingRelation)))+      shouldBeMissingRelation result+    forM_ ["40001", "40P01"] $ \code -> do+      it ("preserves script SQLSTATE and message " <> show code) $ \store -> do+        result <- timeout 5_000_000 $ runStoreIO store (runTransactionNoRetry (Tx.sql (TE.encodeUtf8 (raiseCode code))))+        result `shouldBe` Just (Left (TransientTransactionFailure code fixtureMessage))+      it ("preserves prepared-statement SQLSTATE and message " <> show code) $ \store -> do+        result <-+          timeout 5_000_000 $+            runStoreIO store $+              runTransactionNoRetry $+                Tx.statement () (preparable (raiseCode code) E.noParams D.noResult)+        result `shouldBe` Just (Left (TransientTransactionFailure code fixtureMessage))+    it "rolls back application writes before a script failure" $ \store -> do+      createSideTable store+      result <- runStoreIO store $ runTransactionNoRetry $ do+        Tx.sql "INSERT INTO public.script_error_rows VALUES (1)"+        Tx.sql (TE.encodeUtf8 missingRelation)+      shouldBeMissingRelation result+      countSideRows store `shouldReturn` Right 0+    it "rolls back composed appends and application writes on script failure" $ \store -> do+      createSideTable store+      result <- runStoreIO store $+        runTransactionAppendingNoRetry (StreamName "script-rollback") NoStream [makeEvent "Created" (Aeson.object [])] $ \_ -> do+          Tx.sql "INSERT INTO public.script_error_rows VALUES (1)"+          Tx.sql (TE.encodeUtf8 missingRelation)+      shouldBeMissingRelation result+      countSideRows store `shouldReturn` Right 0+      runStoreIO store (getStream (StreamName "script-rollback")) `shouldReturn` Right Nothing+      countEvents store `shouldReturn` 0 -    it "preserves missing-relation SQLSTATE through the public pool-backed read" $-        withMigratedTestDatabase $ \connStr ->-            withStore (defaultConnectionSettings connStr) $ \store ->-                withPool connStr [Config.initSession (Session.script "SET search_path TO pg_catalog")] $ \pool -> do-                    result <- runStoreIO (store & #pool .~ pool) (getStream (StreamName "missing"))-                    shouldBeMissingRelation result+  it "preserves missing-relation SQLSTATE through the public pool-backed read" $+    withMigratedTestDatabase $ \connStr ->+      withStore (defaultConnectionSettings connStr) $ \store ->+        withPool connStr [Config.initSession (Session.script "SET search_path TO pg_catalog")] $ \pool -> do+          result <- runStoreIO (store & #pool .~ pool) (getStream (StreamName "missing"))+          shouldBeMissingRelation result -    it "keeps genuine connection establishment failures typed for transactions and reads" $-        withTestStore $ \store ->-            withPool "host=/nonexistent/kiroku-script-errors connect_timeout=1" [] $ \pool -> do-                let broken = store & #pool .~ pool-                tx <- timeout 5_000_000 $ runStoreIO broken (runTransactionNoRetry (Tx.sql "SELECT 1"))-                tx `shouldSatisfy` isConnectionLost-                readResult <- timeout 5_000_000 $ runStoreIO broken (getStream (StreamName "missing"))-                readResult `shouldSatisfy` isConnectionLost+  it "keeps genuine connection establishment failures typed for transactions and reads" $+    withTestStore $ \store ->+      withPool "host=/nonexistent/kiroku-script-errors connect_timeout=1" [] $ \pool -> do+        let broken = store & #pool .~ pool+        tx <- timeout 5_000_000 $ runStoreIO broken (runTransactionNoRetry (Tx.sql "SELECT 1"))+        tx `shouldSatisfy` isConnectionLost+        readResult <- timeout 5_000_000 $ runStoreIO broken (getStream (StreamName "missing"))+        readResult `shouldSatisfy` isConnectionLost -    it "routes script server errors through every contextual mapper" $ do-        forM_ ["40001", "40P01", "42P01", "40003", "23503"] $ \code -> do-            let usage = scriptUsage code fixtureMessage Nothing-                expected = if code `elem` ["40001", "40P01"] then TransientTransactionFailure code fixtureMessage else UnexpectedServerError code fixtureMessage-            mapGenericUsageError usage `shouldBe` expected-            mapTransactionUsageError usage `shouldBe` expected-            mapLinkUsageError (StreamName "target") usage `shouldBe` expected-            isTransientSerializationError usage `shouldBe` (code `elem` ["40001", "40P01"])-            if code == "23503"-                then mapUsageError "target" AnyVersion usage `shouldBe` StreamNotFound (StreamName "target")-                else do-                    mapUsageError "target" AnyVersion usage `shouldBe` expected-                    attributeMultiStreamError [(StreamName "target", AnyVersion)] usage `shouldBe` expected+  it "routes script server errors through every contextual mapper" $ do+    forM_ ["40001", "40P01", "42P01", "40003", "23503"] $ \code -> do+      let usage = scriptUsage code fixtureMessage Nothing+          expected = if code `elem` ["40001", "40P01"] then TransientTransactionFailure code fixtureMessage else UnexpectedServerError code fixtureMessage+      mapGenericUsageError usage `shouldBe` expected+      mapTransactionUsageError usage `shouldBe` expected+      mapLinkUsageError (StreamName "target") usage `shouldBe` expected+      isTransientSerializationError usage `shouldBe` (code `elem` ["40001", "40P01"])+      if code == "23503"+        then mapUsageError "target" AnyVersion usage `shouldBe` StreamNotFound (StreamName "target")+        else do+          mapUsageError "target" AnyVersion usage `shouldBe` expected+          attributeMultiStreamError [(StreamName "target", AnyVersion)] usage `shouldBe` expected -    it "retains script append and link constraint attribution" $ do-        let usage = scriptUsage "23505" "duplicate key value violates unique constraint \"stream_events_pkey\"" Nothing-        mapTransactionUsageError usage `shouldBe` DuplicateEvent Nothing-        mapUsageError "target" AnyVersion usage `shouldBe` DuplicateEvent Nothing-        mapLinkUsageError (StreamName "target") usage `shouldBe` EventAlreadyLinked (StreamName "target") Nothing-        let named = scriptUsage "23505" "duplicate key value violates unique constraint \"ix_streams_stream_name\"" (Just "Key (stream_name)=(second) already exists.")-        attributeMultiStreamError [(StreamName "first", AnyVersion), (StreamName "second", NoStream)] named-            `shouldBe` StreamAlreadyExists (StreamName "second")+  it "retains script append and link constraint attribution" $ do+    let usage = scriptUsage "23505" "duplicate key value violates unique constraint \"stream_events_pkey\"" Nothing+    mapTransactionUsageError usage `shouldBe` DuplicateEvent Nothing+    mapUsageError "target" AnyVersion usage `shouldBe` DuplicateEvent Nothing+    mapLinkUsageError (StreamName "target") usage `shouldBe` EventAlreadyLinked (StreamName "target") Nothing+    let named = scriptUsage "23505" "duplicate key value violates unique constraint \"ix_streams_stream_name\"" (Just "Key (stream_name)=(second) already exists.")+    attributeMultiStreamError [(StreamName "first", AnyVersion), (StreamName "second", NoStream)] named+      `shouldBe` StreamAlreadyExists (StreamName "second") -    it "distinguishes session connection loss, acquisition timeout and unsupported errors" $ do-        let lost = Pool.SessionUsageError (Errors.ConnectionSessionError "connection detail")-            fallback = Pool.SessionUsageError (Errors.DriverSessionError "driver detail")-        forM_ [mapGenericUsageError, mapTransactionUsageError, mapUsageError "target" AnyVersion, mapLinkUsageError (StreamName "target")] $ \mapper -> do-            mapper lost `shouldBe` ConnectionLost "connection detail"-            mapper Pool.AcquisitionTimeoutUsageError `shouldBe` PoolAcquisitionTimeout-            mapper fallback `shouldSatisfy` (\case ConnectionError _ -> True; _ -> False)-        isTransientSerializationError lost `shouldBe` False+  it "distinguishes session connection loss, acquisition timeout and unsupported errors" $ do+    let lost = Pool.SessionUsageError (Errors.ConnectionSessionError "connection detail")+        fallback = Pool.SessionUsageError (Errors.DriverSessionError "driver detail")+    forM_ [mapGenericUsageError, mapTransactionUsageError, mapUsageError "target" AnyVersion, mapLinkUsageError (StreamName "target")] $ \mapper -> do+      mapper lost `shouldBe` ConnectionLost "connection detail"+      mapper Pool.AcquisitionTimeoutUsageError `shouldBe` PoolAcquisitionTimeout+      mapper fallback `shouldSatisfy` (\case ConnectionError _ -> True; _ -> False)+    isTransientSerializationError lost `shouldBe` False  missingRelation :: Text missingRelation = "INSERT INTO public.kiroku_script_missing_relation VALUES (1)"@@ -123,8 +123,8 @@  shouldBeMissingRelation :: (Show a) => Either StoreError a -> Expectation shouldBeMissingRelation result = case result of-    Left (UnexpectedServerError "42P01" _) -> pure ()-    other -> expectationFailure ("Expected structured 42P01, got " <> show other)+  Left (UnexpectedServerError "42P01" _) -> pure ()+  other -> expectationFailure ("Expected structured 42P01, got " <> show other)  isConnectionLost :: Maybe (Either StoreError a) -> Bool isConnectionLost (Just (Left (ConnectionLost _))) = True@@ -138,6 +138,6 @@  countSideRows :: KirokuStore -> IO (Either StoreError Int64) countSideRows store =-    runStoreIO store $-        runTransactionNoRetry $-            Tx.statement () (preparable "SELECT count(*) FROM public.script_error_rows" E.noParams (D.singleRow (D.column (D.nonNullable D.int8))))+  runStoreIO store $+    runTransactionNoRetry $+      Tx.statement () (preparable "SELECT count(*) FROM public.script_error_rows" E.noParams (D.singleRow (D.column (D.nonNullable D.int8))))
test/Test/StartupFailureSurfacing.hs view
@@ -20,105 +20,105 @@  spec :: Spec spec = describe "startup failure surfacing" $ do-    it "fails subscription startup loudly when checkpoint load fails" $ do-        handlerCalled <- newTVarIO False-        observedRef <- newIORef ([] :: [KirokuEvent])-        let subName = SubscriptionName "load-checkpoint-fails-loudly"-            cfg =-                defaultSubscriptionConfig subName AllStreams $ \_ -> do-                    atomically (writeTVar handlerCalled True)-                    pure Continue-            observe evt = modifyIORef' observedRef (evt :)-            injectLoadFailure _ = pure (Just (Left Pool.AcquisitionTimeoutUsageError))-            tweak settings = settings & #eventHandler .~ Just observe+  it "fails subscription startup loudly when checkpoint load fails" $ do+    handlerCalled <- newTVarIO False+    observedRef <- newIORef ([] :: [KirokuEvent])+    let subName = SubscriptionName "load-checkpoint-fails-loudly"+        cfg =+          defaultSubscriptionConfig subName AllStreams $ \_ -> do+            atomically (writeTVar handlerCalled True)+            pure Continue+        observe evt = modifyIORef' observedRef (evt :)+        injectLoadFailure _ = pure (Just (Left Pool.AcquisitionTimeoutUsageError))+        tweak settings = settings & #eventHandler .~ Just observe -        withTestStoreSettings tweak $ \store ->-            withLoadCheckpointHookForTest injectLoadFailure $ do-                handle <- subscribe store cfg-                result <- waitWithTimeout 5_000_000 handle-                case result of-                    Right (Left e)-                        | Just Pool.AcquisitionTimeoutUsageError <- fromException e -> pure ()-                    Left timeout -> expectationFailure timeout-                    Right other -> expectationFailure ("expected checkpoint load failure, got: " <> show other)+    withTestStoreSettings tweak $ \store ->+      withLoadCheckpointHookForTest injectLoadFailure $ do+        handle <- subscribe store cfg+        result <- waitWithTimeout 5_000_000 handle+        case result of+          Right (Left e)+            | Just Pool.AcquisitionTimeoutUsageError <- fromException e -> pure ()+          Left timeout -> expectationFailure timeout+          Right other -> expectationFailure ("expected checkpoint load failure, got: " <> show other) -        wasCalled <- readTVarIO handlerCalled-        wasCalled `shouldBe` False-        observed <- reverse <$> readIORef observedRef-        any (isLoadCheckpointError subName) observed `shouldBe` True-        any (isCrashedStop subName) observed `shouldBe` True+    wasCalled <- readTVarIO handlerCalled+    wasCalled `shouldBe` False+    observed <- reverse <$> readIORef observedRef+    any (isLoadCheckpointError subName) observed `shouldBe` True+    any (isCrashedStop subName) observed `shouldBe` True -    it "refuses a missing checkpoint before Started or handler delivery" $ do-        handlerCalled <- newTVarIO False-        observedRef <- newIORef ([] :: [KirokuEvent])-        let subName = SubscriptionName "fail-if-checkpoint-missing"-            key = SubscriptionCheckpointKey subName 0-            cfg =-                ( defaultSubscriptionConfig subName AllStreams $ \_ -> do-                    atomically (writeTVar handlerCalled True)-                    pure Continue-                )-                    { missingCheckpointPolicy = FailIfMissing-                    }-            observe evt = modifyIORef' observedRef (evt :)-            tweak settings = settings & #eventHandler .~ Just observe+  it "refuses a missing checkpoint before Started or handler delivery" $ do+    handlerCalled <- newTVarIO False+    observedRef <- newIORef ([] :: [KirokuEvent])+    let subName = SubscriptionName "fail-if-checkpoint-missing"+        key = SubscriptionCheckpointKey subName 0+        cfg =+          ( defaultSubscriptionConfig subName AllStreams $ \_ -> do+              atomically (writeTVar handlerCalled True)+              pure Continue+          )+            { missingCheckpointPolicy = FailIfMissing+            }+        observe evt = modifyIORef' observedRef (evt :)+        tweak settings = settings & #eventHandler .~ Just observe -        withTestStoreSettings tweak $ \store -> do-            handle <- subscribe store cfg-            result <- waitWithTimeout 5_000_000 handle-            case result of-                Right (Left e)-                    | Just (SubscriptionCheckpointMissing actualKey) <- fromException e ->-                        actualKey `shouldBe` key-                Left timeout -> expectationFailure timeout-                Right other -> expectationFailure ("expected missing-checkpoint refusal, got: " <> show other)+    withTestStoreSettings tweak $ \store -> do+      handle <- subscribe store cfg+      result <- waitWithTimeout 5_000_000 handle+      case result of+        Right (Left e)+          | Just (SubscriptionCheckpointMissing actualKey) <- fromException e ->+              actualKey `shouldBe` key+        Left timeout -> expectationFailure timeout+        Right other -> expectationFailure ("expected missing-checkpoint refusal, got: " <> show other) -            runStoreIO store subscriptionCheckpointInventory-                `shouldReturn` Right (SubscriptionCheckpointInventory (GlobalPosition 0) mempty)+      runStoreIO store subscriptionCheckpointInventory+        `shouldReturn` Right (SubscriptionCheckpointInventory (GlobalPosition 0) mempty) -        readTVarIO handlerCalled `shouldReturn` False-        observed <- reverse <$> readIORef observedRef-        observed `shouldSatisfy` any (isMissingCheckpoint key)-        observed `shouldSatisfy` all (not . isStarted subName)+    readTVarIO handlerCalled `shouldReturn` False+    observed <- reverse <$> readIORef observedRef+    observed `shouldSatisfy` any (isMissingCheckpoint key)+    observed `shouldSatisfy` all (not . isStarted subName) -    it "leaves no publisher or subscription registry entries after a subscribe/cancel storm" $-        withTestStore $ \store -> do-            let cfg = defaultSubscriptionConfig (SubscriptionName "subscribe-cancel-storm") AllStreams (\_ -> pure Continue)-            -- 200 iterations gives async exceptions many chances to land in the-            -- pre-fork window without making the test expensive on normal runs.-            replicateM_ 200 $ do-                pendingAsync <- Async.async (subscribe store cfg)-                Async.cancel pendingAsync-                outcome <- Async.waitCatch pendingAsync-                case outcome of-                    Right handle -> cancel handle >> (() <$ wait handle)-                    Left (_ :: SomeException) -> pure ()+  it "leaves no publisher or subscription registry entries after a subscribe/cancel storm" $+    withTestStore $ \store -> do+      let cfg = defaultSubscriptionConfig (SubscriptionName "subscribe-cancel-storm") AllStreams (\_ -> pure Continue)+      -- 200 iterations gives async exceptions many chances to land in the+      -- pre-fork window without making the test expensive on normal runs.+      replicateM_ 200 $ do+        pendingAsync <- Async.async (subscribe store cfg)+        Async.cancel pendingAsync+        outcome <- Async.waitCatch pendingAsync+        case outcome of+          Right handle -> cancel handle >> (() <$ wait handle)+          Left (_ :: SomeException) -> pure () -            subs <- readTVarIO (Pub.subscribers (store ^. #publisher))-            reg <- readTVarIO (store ^. #subscriptionRegistry)-            IntMap.null subs `shouldBe` True-            Map.null reg `shouldBe` True+      subs <- readTVarIO (Pub.subscribers (store ^. #publisher))+      reg <- readTVarIO (store ^. #subscriptionRegistry)+      IntMap.null subs `shouldBe` True+      Map.null reg `shouldBe` True  isLoadCheckpointError :: SubscriptionName -> KirokuEvent -> Bool isLoadCheckpointError expected = \case-    KirokuEventSubscriptionDbError actual LoadCheckpoint Pool.AcquisitionTimeoutUsageError _-        | actual == expected -> True-    _ -> False+  KirokuEventSubscriptionDbError actual LoadCheckpoint Pool.AcquisitionTimeoutUsageError _+    | actual == expected -> True+  _ -> False  isCrashedStop :: SubscriptionName -> KirokuEvent -> Bool isCrashedStop expected = \case-    KirokuEventSubscriptionStopped actual _ (StopWorkerCrashed e) _-        | actual == expected-        , Just Pool.AcquisitionTimeoutUsageError <- fromException e ->-            True-    _ -> False+  KirokuEventSubscriptionStopped actual _ (StopWorkerCrashed e) _+    | actual == expected,+      Just Pool.AcquisitionTimeoutUsageError <- fromException e ->+        True+  _ -> False  isMissingCheckpoint :: SubscriptionCheckpointKey -> KirokuEvent -> Bool isMissingCheckpoint expected = \case-    KirokuEventSubscriptionCheckpointMissing (SubscriptionCheckpointMissing actual) _ -> actual == expected-    _ -> False+  KirokuEventSubscriptionCheckpointMissing (SubscriptionCheckpointMissing actual) _ -> actual == expected+  _ -> False  isStarted :: SubscriptionName -> KirokuEvent -> Bool isStarted expected = \case-    KirokuEventSubscriptionStarted actual _ _ -> actual == expected-    _ -> False+  KirokuEventSubscriptionStarted actual _ _ -> actual == expected+  _ -> False
test/Test/StreamBridgeTermination.hs view
@@ -20,97 +20,97 @@ import Test.Hspec  data TestBoom = TestBoom-    deriving stock (Show)-    deriving anyclass (Exception)+  deriving stock (Show)+  deriving anyclass (Exception)  timeoutMicros :: Int timeoutMicros = 10_000_000  within :: String -> IO a -> IO a within label action = do-    result <- Async.race (threadDelay timeoutMicros) action-    case result of-        Left () -> fail ("timed out waiting for " <> label)-        Right a -> pure a+  result <- Async.race (threadDelay timeoutMicros) action+  case result of+    Left () -> fail ("timed out waiting for " <> label)+    Right a -> pure a  appendOne :: KirokuStore -> StreamName -> EventType -> IO GlobalPosition appendOne store streamName (EventType eventType') = do-    Right r <- runStoreIO store $ appendToStream streamName NoStream [makeEvent eventType' (Aeson.object [])]-    pure (r ^. #globalPosition)+  Right r <- runStoreIO store $ appendToStream streamName NoStream [makeEvent eventType' (Aeson.object [])]+  pure (r ^. #globalPosition)  spec :: Spec spec = describe "stream bridge termination" $ do-    it "rejects a zero-sized bridge buffer at construction" $-        Buffer.mkStreamBufferSize 0 `shouldBe` Left (InvalidStreamBufferSize 0)+  it "rejects a zero-sized bridge buffer at construction" $+    Buffer.mkStreamBufferSize 0 `shouldBe` Left (InvalidStreamBufferSize 0) -    it "rethrows the worker exception to the consumer when the worker dies" $-        withTestStore $ \store -> do-            pos <- appendOne store (StreamName "bridge-crash") (EventType "BridgeCrash")-            waitForPublisher store pos-            let cfg = defaultSubscriptionConfig (SubscriptionName "bridge-crash-sub") AllStreams (\_ -> pure Continue)-                injectBoom _ _ = throwIO TestBoom-            withFetchBatchHookForTest injectBoom $ do-                (stream, cancelStream) <- subscriptionAckStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 16))-                pulled <- within "stream pull to throw TestBoom" (try (Stream.uncons stream))-                cancelStream-                case pulled of-                    Left e-                        | Just TestBoom <- fromException (e :: SomeException) -> pure ()-                    Left e -> expectationFailure ("expected TestBoom, got: " <> show e)-                    Right _ -> expectationFailure "expected stream pull to throw TestBoom"+  it "rethrows the worker exception to the consumer when the worker dies" $+    withTestStore $ \store -> do+      pos <- appendOne store (StreamName "bridge-crash") (EventType "BridgeCrash")+      waitForPublisher store pos+      let cfg = defaultSubscriptionConfig (SubscriptionName "bridge-crash-sub") AllStreams (\_ -> pure Continue)+          injectBoom _ _ = throwIO TestBoom+      withFetchBatchHookForTest injectBoom $ do+        (stream, cancelStream) <- subscriptionAckStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 16))+        pulled <- within "stream pull to throw TestBoom" (try (Stream.uncons stream))+        cancelStream+        case pulled of+          Left e+            | Just TestBoom <- fromException (e :: SomeException) -> pure ()+          Left e -> expectationFailure ("expected TestBoom, got: " <> show e)+          Right _ -> expectationFailure "expected stream pull to throw TestBoom" -    it "ends the stream after a clean worker stop" $-        withTestStore $ \store -> do-            let cfg = defaultSubscriptionConfig (SubscriptionName "bridge-clean-stop-sub") AllStreams (\_ -> pure Continue)-            (stream0, cancelStream) <- subscriptionAckStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 16))-            finally-                ( do-                    pos <- appendOne store (StreamName "bridge-clean-stop") (EventType "BridgeCleanStop")-                    waitForPublisher store pos-                    mFirst <- within "first bridge item" (Stream.uncons stream0)-                    (stream1, item) <- case mFirst of-                        Nothing -> fail "expected one bridge item before clean stop"-                        Just (firstItem, rest) -> pure (rest, firstItem)-                    atomically (putTMVar (ackReply item) Stop)-                    mNext <- within "stream end after clean stop" (Stream.uncons stream1)-                    case mNext of-                        Nothing -> pure ()-                        Just _ -> expectationFailure "expected stream to end after clean stop"-                )-                cancelStream+  it "ends the stream after a clean worker stop" $+    withTestStore $ \store -> do+      let cfg = defaultSubscriptionConfig (SubscriptionName "bridge-clean-stop-sub") AllStreams (\_ -> pure Continue)+      (stream0, cancelStream) <- subscriptionAckStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 16))+      finally+        ( do+            pos <- appendOne store (StreamName "bridge-clean-stop") (EventType "BridgeCleanStop")+            waitForPublisher store pos+            mFirst <- within "first bridge item" (Stream.uncons stream0)+            (stream1, item) <- case mFirst of+              Nothing -> fail "expected one bridge item before clean stop"+              Just (firstItem, rest) -> pure (rest, firstItem)+            atomically (putTMVar (ackReply item) Stop)+            mNext <- within "stream end after clean stop" (Stream.uncons stream1)+            case mNext of+              Nothing -> pure ()+              Just _ -> expectationFailure "expected stream to end after clean stop"+        )+        cancelStream -    it "cancelAction returns promptly even when the bridge queue is full" $-        withTestStore $ \store -> do-            let cfg = defaultSubscriptionConfig (SubscriptionName "bridge-full-cancel-sub") AllStreams (\_ -> pure Continue)-            (stream0, cancelStream) <- subscriptionAckStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 1))-            _pos1 <- appendOne store (StreamName "bridge-full-cancel-1") (EventType "BridgeFullCancel1")-            pos2 <- appendOne store (StreamName "bridge-full-cancel-2") (EventType "BridgeFullCancel2")-            waitForPublisher store pos2+  it "cancelAction returns promptly even when the bridge queue is full" $+    withTestStore $ \store -> do+      let cfg = defaultSubscriptionConfig (SubscriptionName "bridge-full-cancel-sub") AllStreams (\_ -> pure Continue)+      (stream0, cancelStream) <- subscriptionAckStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 1))+      _pos1 <- appendOne store (StreamName "bridge-full-cancel-1") (EventType "BridgeFullCancel1")+      pos2 <- appendOne store (StreamName "bridge-full-cancel-2") (EventType "BridgeFullCancel2")+      waitForPublisher store pos2 -            within "non-blocking cancelAction with a full queue" cancelStream+      within "non-blocking cancelAction with a full queue" cancelStream -            mFirst <- within "drained item after cancel" (Stream.uncons stream0)-            case mFirst of-                Nothing -> pure ()-                Just (item, stream1) -> do-                    atomically (putTMVar (ackReply item) Continue)-                    mNext <- within "stream end after cancel" (Stream.uncons stream1)-                    case mNext of-                        Nothing -> pure ()-                        Just _ -> expectationFailure "expected stream to end after cancel"+      mFirst <- within "drained item after cancel" (Stream.uncons stream0)+      case mFirst of+        Nothing -> pure ()+        Just (item, stream1) -> do+          atomically (putTMVar (ackReply item) Continue)+          mNext <- within "stream end after cancel" (Stream.uncons stream1)+          case mNext of+            Nothing -> pure ()+            Just _ -> expectationFailure "expected stream to end after cancel" -    it "cancelAction is idempotent and leaves no subscription thread registered" $-        withTestStore $ \store -> do-            let subscriptionName = SubscriptionName "bridge-idempotent-cancel-sub"-                cfg = defaultSubscriptionConfig subscriptionName AllStreams (\_ -> pure Continue)-                key = (subscriptionName, 0)-            (_stream, cancelStream) <- subscriptionAckStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 1))+  it "cancelAction is idempotent and leaves no subscription thread registered" $+    withTestStore $ \store -> do+      let subscriptionName = SubscriptionName "bridge-idempotent-cancel-sub"+          cfg = defaultSubscriptionConfig subscriptionName AllStreams (\_ -> pure Continue)+          key = (subscriptionName, 0)+      (_stream, cancelStream) <- subscriptionAckStream store cfg (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 1)) -            statesBefore <- subscriptionStates store-            Map.member key statesBefore `shouldBe` True+      statesBefore <- subscriptionStates store+      Map.member key statesBefore `shouldBe` True -            within "first bridge cancellation" cancelStream-            within "repeated bridge cancellation" cancelStream+      within "first bridge cancellation" cancelStream+      within "repeated bridge cancellation" cancelStream -            statesAfter <- subscriptionStates store-            Map.member key statesAfter `shouldBe` False+      statesAfter <- subscriptionStates store+      Map.member key statesAfter `shouldBe` False
test/Test/StreamHead.hs view
@@ -20,89 +20,89 @@  spec :: Spec spec = describe "stream head" $ do-    forM_ [False, True] $ \resource -> describe (if resource then "resource runner" else "direct runner") $ do-        let readHead store name =-                if resource-                    then runEff . runErrorNoCallStack @StoreError . runKirokuStoreWith store . runStoreResource $ getStreamWithHead name-                    else runStoreIO store (getStreamWithHead name)-            check store name version headPosition = do-                Right (Just metadata) <- runStoreIO store (getStream name)-                metadata ^. #version `shouldBe` StreamVersion version-                readHead store name `shouldReturn` Right (Just (metadata, headPosition))-        it "distinguishes absent, empty and reserved $all in empty and populated stores" $ withTestStore $ \store -> do-            readHead store (StreamName "missing") `shouldReturn` Right Nothing-            raw store "INSERT INTO streams(stream_name) VALUES ('empty')"-            check store (StreamName "empty") 0 Nothing-            check store (StreamName "$all") 0 Nothing-            appendOne store (StreamName "origin")-            check store (StreamName "$all") 1 Nothing-        it "captures interleaved heads, logical lifecycle and a fresh identity after hard deletion" $ withTestStore $ \store -> do-            let a = StreamName "a"; b = StreamName "b"-            mapM_ (appendOne store) [a, b, a, b]-            originated <- appendPosition store a-            originated `shouldBe` GlobalPosition 5-            check store a 3 (Just originated)-            appendOne store b-            check store a 3 (Just originated)-            Right (Just _) <- runStoreIO store (setStreamTruncateBefore a (StreamVersion 3))-            check store a 3 (Just originated)-            Right (Just _) <- runStoreIO store (softDeleteStream a)-            check store a 3 (Just originated)-            Right (Just (old, _)) <- readHead store a-            Right (Just _) <- runStoreIO store (hardDeleteStream a)-            readHead store a `shouldReturn` Right Nothing-            raw store "INSERT INTO streams(stream_name) VALUES ('a')"-            check store a 0 Nothing-            Right (Just (fresh, _)) <- readHead store a-            fresh ^. #id `shouldNotBe` (old ^. #id)-            recreated <- appendPosition store a-            recreated `shouldBe` GlobalPosition 7-            check store a 1 (Just recreated)-        it "ignores links while tracking subsequent originated appends" $ withTestStore $ \store -> do-            let mixed = StreamName "mixed"; source = StreamName "source"; linked = StreamName "linked"-            appendOne store mixed-            appendOne store source-            Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 10)-            let eid = (events V.! 1) ^. #eventId-            Right _ <- runStoreIO store (linkToStream linked [eid])-            check store linked 1 Nothing-            Right _ <- runStoreIO store (linkToStream mixed [eid])-            check store mixed 2 (Just ((events V.! 0) ^. #globalPosition))-            next <- appendPosition store mixed-            next `shouldBe` GlobalPosition 3-            check store mixed 3 (Just next)-    it "never invokes the event decode hook" $ do-        calls <- newIORef (0 :: Int)-        let hook _ = modifyIORef' calls (+ 1) >> error "unexpected event decode"-        withTestStoreSettings (\settings -> settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just hook}) $ \store -> do-            expected <- appendPosition store (StreamName "hook")-            Right (Just (_, actual)) <- runStoreIO store (getStreamWithHead (StreamName "hook"))-            actual `shouldBe` Just expected-            readIORef calls `shouldReturn` 0-    it "observes version and head from one snapshot while appends commit" $ withTestStore $ \store -> do-        let name = StreamName "concurrent"-        appendOne store name-        start <- newEmptyMVar-        let writer = takeMVar start >> replicateM_ 100 (appendOne store name)-            reader = replicateM_ 200 $ do-                Right (Just (info, Just (GlobalPosition position))) <- runStoreIO store (getStreamWithHead name)-                info ^. #version `shouldBe` StreamVersion position-        outcome <- timeout 20_000_000 $ Async.withAsync writer $ \worker -> do-            putMVar start ()-            reader-            Async.wait worker-        outcome `shouldBe` Just ()-        Right (Just (info, headPosition)) <- runStoreIO store (getStreamWithHead name)-        info ^. #version `shouldBe` StreamVersion 101-        headPosition `shouldBe` Just (GlobalPosition 101)+  forM_ [False, True] $ \resource -> describe (if resource then "resource runner" else "direct runner") $ do+    let readHead store name =+          if resource+            then runEff . runErrorNoCallStack @StoreError . runKirokuStoreWith store . runStoreResource $ getStreamWithHead name+            else runStoreIO store (getStreamWithHead name)+        check store name version headPosition = do+          Right (Just metadata) <- runStoreIO store (getStream name)+          metadata ^. #version `shouldBe` StreamVersion version+          readHead store name `shouldReturn` Right (Just (metadata, headPosition))+    it "distinguishes absent, empty and reserved $all in empty and populated stores" $ withTestStore $ \store -> do+      readHead store (StreamName "missing") `shouldReturn` Right Nothing+      raw store "INSERT INTO streams(stream_name) VALUES ('empty')"+      check store (StreamName "empty") 0 Nothing+      check store (StreamName "$all") 0 Nothing+      appendOne store (StreamName "origin")+      check store (StreamName "$all") 1 Nothing+    it "captures interleaved heads, logical lifecycle and a fresh identity after hard deletion" $ withTestStore $ \store -> do+      let a = StreamName "a"; b = StreamName "b"+      mapM_ (appendOne store) [a, b, a, b]+      originated <- appendPosition store a+      originated `shouldBe` GlobalPosition 5+      check store a 3 (Just originated)+      appendOne store b+      check store a 3 (Just originated)+      Right (Just _) <- runStoreIO store (setStreamTruncateBefore a (StreamVersion 3))+      check store a 3 (Just originated)+      Right (Just _) <- runStoreIO store (softDeleteStream a)+      check store a 3 (Just originated)+      Right (Just (old, _)) <- readHead store a+      Right (Just _) <- runStoreIO store (hardDeleteStream a)+      readHead store a `shouldReturn` Right Nothing+      raw store "INSERT INTO streams(stream_name) VALUES ('a')"+      check store a 0 Nothing+      Right (Just (fresh, _)) <- readHead store a+      fresh ^. #id `shouldNotBe` (old ^. #id)+      recreated <- appendPosition store a+      recreated `shouldBe` GlobalPosition 7+      check store a 1 (Just recreated)+    it "ignores links while tracking subsequent originated appends" $ withTestStore $ \store -> do+      let mixed = StreamName "mixed"; source = StreamName "source"; linked = StreamName "linked"+      appendOne store mixed+      appendOne store source+      Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 10)+      let eid = (events V.! 1) ^. #eventId+      Right _ <- runStoreIO store (linkToStream linked [eid])+      check store linked 1 Nothing+      Right _ <- runStoreIO store (linkToStream mixed [eid])+      check store mixed 2 (Just ((events V.! 0) ^. #globalPosition))+      next <- appendPosition store mixed+      next `shouldBe` GlobalPosition 3+      check store mixed 3 (Just next)+  it "never invokes the event decode hook" $ do+    calls <- newIORef (0 :: Int)+    let hook _ = modifyIORef' calls (+ 1) >> error "unexpected event decode"+    withTestStoreSettings (\settings -> settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just hook}) $ \store -> do+      expected <- appendPosition store (StreamName "hook")+      Right (Just (_, actual)) <- runStoreIO store (getStreamWithHead (StreamName "hook"))+      actual `shouldBe` Just expected+      readIORef calls `shouldReturn` 0+  it "observes version and head from one snapshot while appends commit" $ withTestStore $ \store -> do+    let name = StreamName "concurrent"+    appendOne store name+    start <- newEmptyMVar+    let writer = takeMVar start >> replicateM_ 100 (appendOne store name)+        reader = replicateM_ 200 $ do+          Right (Just (info, Just (GlobalPosition position))) <- runStoreIO store (getStreamWithHead name)+          info ^. #version `shouldBe` StreamVersion position+    outcome <- timeout 20_000_000 $ Async.withAsync writer $ \worker -> do+      putMVar start ()+      reader+      Async.wait worker+    outcome `shouldBe` Just ()+    Right (Just (info, headPosition)) <- runStoreIO store (getStreamWithHead name)+    info ^. #version `shouldBe` StreamVersion 101+    headPosition `shouldBe` Just (GlobalPosition 101)  appendOne :: KirokuStore -> StreamName -> IO () appendOne store name = void (appendPosition store name)  appendPosition :: KirokuStore -> StreamName -> IO GlobalPosition appendPosition store name = do-    result <- runStoreIO store (appendToStream name AnyVersion [makeEvent "StreamHeadEvent" Null])-    either (fail . show) (pure . (^. #globalPosition)) result+  result <- runStoreIO store (appendToStream name AnyVersion [makeEvent "StreamHeadEvent" Null])+  either (fail . show) (pure . (^. #globalPosition)) result  raw :: KirokuStore -> Text -> IO () raw store command = Pool.use (store ^. #pool) (Session.script command) >>= either (expectationFailure . show) pure
test/Test/StreamHeadIsolation.hs view
@@ -10,27 +10,27 @@  spec :: Spec spec = describe "stream head legacy isolation" $ do-    forM_ statements $ \(name, actual) ->-        it ("preserves " <> name <> " byte for byte") $ do-            path <- getDataFileName ("test/fixtures/stream-head-isolation/" <> name <> ".sql")-            expected <- T.readFile path-            actual `shouldBe` expected-    it "keeps the six metadata and eleven event result columns" $ do-        columnCount (Statement.toSql SQL.getStreamStmt) `shouldBe` 6-        columnCount (Statement.toSql SQL.readAllForwardStmt) `shouldBe` 11+  forM_ statements $ \(name, actual) ->+    it ("preserves " <> name <> " byte for byte") $ do+      path <- getDataFileName ("test/fixtures/stream-head-isolation/" <> name <> ".sql")+      expected <- T.readFile path+      actual `shouldBe` expected+  it "keeps the six metadata and eleven event result columns" $ do+    columnCount (Statement.toSql SQL.getStreamStmt) `shouldBe` 6+    columnCount (Statement.toSql SQL.readAllForwardStmt) `shouldBe` 11   where     columnCount = length . T.splitOn "," . fst . T.breakOn "FROM" . snd . T.breakOn "SELECT"     statements =-        [ ("getStreamStmt", Statement.toSql SQL.getStreamStmt)-        , ("readStreamForwardStmt", Statement.toSql SQL.readStreamForwardStmt)-        , ("readStreamBackwardStmt", Statement.toSql SQL.readStreamBackwardStmt)-        , ("readAllForwardStmt", Statement.toSql SQL.readAllForwardStmt)-        , ("readAllBackwardStmt", Statement.toSql SQL.readAllBackwardStmt)-        , ("readCategoryForwardStmt", Statement.toSql SQL.readCategoryForwardStmt)-        , ("readCategoryForwardConsumerGroupStmt", Statement.toSql SQL.readCategoryForwardConsumerGroupStmt)-        , ("appendExpectedVersion", Statement.toSql SQL.appendExpectedVersion)-        , ("appendStreamExists", Statement.toSql SQL.appendStreamExists)-        , ("appendNoStream", Statement.toSql SQL.appendNoStream)-        , ("appendAnyVersion", Statement.toSql SQL.appendAnyVersion)-        , ("linkToStreamStmt", Statement.toSql SQL.linkToStreamStmt)-        ]+      [ ("getStreamStmt", Statement.toSql SQL.getStreamStmt),+        ("readStreamForwardStmt", Statement.toSql SQL.readStreamForwardStmt),+        ("readStreamBackwardStmt", Statement.toSql SQL.readStreamBackwardStmt),+        ("readAllForwardStmt", Statement.toSql SQL.readAllForwardStmt),+        ("readAllBackwardStmt", Statement.toSql SQL.readAllBackwardStmt),+        ("readCategoryForwardStmt", Statement.toSql SQL.readCategoryForwardStmt),+        ("readCategoryForwardConsumerGroupStmt", Statement.toSql SQL.readCategoryForwardConsumerGroupStmt),+        ("appendExpectedVersion", Statement.toSql SQL.appendExpectedVersion),+        ("appendStreamExists", Statement.toSql SQL.appendStreamExists),+        ("appendNoStream", Statement.toSql SQL.appendNoStream),+        ("appendAnyVersion", Statement.toSql SQL.appendAnyVersion),+        ("linkToStreamStmt", Statement.toSql SQL.linkToStreamStmt)+      ]
test/Test/StreamHeadMock.hs view
@@ -10,19 +10,19 @@  spec :: Spec spec = describe "stream head mock" $ it "sends exactly one dedicated constructor and preserves all nested-Maybe outcomes" $ do-    now <- getCurrentTime-    let name = StreamName "mock"-        info = StreamInfo (StreamId 42) name (StreamVersion 3) now Nothing (StreamVersion 0)-    forM_ [Nothing, Just (info, Nothing), Just (info, Just (GlobalPosition 5))] $ \expected -> do-        calls <- newIORef (0 :: Int)-        actual <- runEff $ runMock calls name expected (getStreamWithHead name)-        actual `shouldBe` expected-        readIORef calls `shouldReturn` 1+  now <- getCurrentTime+  let name = StreamName "mock"+      info = StreamInfo (StreamId 42) name (StreamVersion 3) now Nothing (StreamVersion 0)+  forM_ [Nothing, Just (info, Nothing), Just (info, Just (GlobalPosition 5))] $ \expected -> do+    calls <- newIORef (0 :: Int)+    actual <- runEff $ runMock calls name expected (getStreamWithHead name)+    actual `shouldBe` expected+    readIORef calls `shouldReturn` 1  runMock :: (IOE :> es) => IORef Int -> StreamName -> Maybe (StreamInfo, Maybe GlobalPosition) -> Eff (Store : es) a -> Eff es a runMock calls name expected = interpret_ $ \case-    GetStreamWithHead actual -> do-        liftIO $ actual `shouldBe` name-        liftIO $ modifyIORef' calls (+ 1)-        pure expected-    _ -> error "unexpected Store operation in stream-head mock"+  GetStreamWithHead actual -> do+    liftIO $ actual `shouldBe` name+    liftIO $ modifyIORef' calls (+ 1)+    pure expected+  _ -> error "unexpected Store operation in stream-head mock"
test/Test/StreamHistoryGuard.hs view
@@ -22,184 +22,184 @@  spec :: Spec spec = describe "stream history guard" $ do-    it "returns exact metadata and pages with production cursor semantics" $-        withTestStore $ \store -> do-            let stream = StreamName "guard-metadata"-            Right _ <- runStoreIO store $ appendToStream stream NoStream (events 3)-            Right _ <- runStoreIO store $ setStreamTruncateBefore stream (StreamVersion 2)-            result <- runTx store $ do-                locked <- lockStreamHistoryForReplayTx stream-                page <- readStreamForwardTx stream (StreamVersion 0) 10-                pure (locked, page)-            case result of-                (Right info, page) -> do-                    info ^. #version `shouldBe` StreamVersion 3-                    info ^. #truncateBefore `shouldBe` StreamVersion 2-                    info ^. #deletedAt `shouldBe` Nothing-                    fmap (^. #streamVersion) (Vector.toList page)-                        `shouldBe` [StreamVersion 2, StreamVersion 3]-                (Left unavailable, _) -> expectationFailure ("guard unexpectedly unavailable: " <> show unavailable)+  it "returns exact metadata and pages with production cursor semantics" $+    withTestStore $ \store -> do+      let stream = StreamName "guard-metadata"+      Right _ <- runStoreIO store $ appendToStream stream NoStream (events 3)+      Right _ <- runStoreIO store $ setStreamTruncateBefore stream (StreamVersion 2)+      result <- runTx store $ do+        locked <- lockStreamHistoryForReplayTx stream+        page <- readStreamForwardTx stream (StreamVersion 0) 10+        pure (locked, page)+      case result of+        (Right info, page) -> do+          info ^. #version `shouldBe` StreamVersion 3+          info ^. #truncateBefore `shouldBe` StreamVersion 2+          info ^. #deletedAt `shouldBe` Nothing+          fmap (^. #streamVersion) (Vector.toList page)+            `shouldBe` [StreamVersion 2, StreamVersion 3]+        (Left unavailable, _) -> expectationFailure ("guard unexpectedly unavailable: " <> show unavailable) -    it "returns typed unavailable results for missing and reserved streams" $-        withTestStore $ \store -> do-            missing <- runTx store (lockStreamHistoryForReplayTx (StreamName "guard-missing"))-            missing `shouldBe` Left (StreamHistoryNotFound (StreamName "guard-missing"))-            reserved <- runTx store (lockStreamHistoryForReplayTx (StreamName "$all"))-            reserved `shouldBe` Left (StreamHistoryReserved (StreamName "$all"))+  it "returns typed unavailable results for missing and reserved streams" $+    withTestStore $ \store -> do+      missing <- runTx store (lockStreamHistoryForReplayTx (StreamName "guard-missing"))+      missing `shouldBe` Left (StreamHistoryNotFound (StreamName "guard-missing"))+      reserved <- runTx store (lockStreamHistoryForReplayTx (StreamName "$all"))+      reserved `shouldBe` Left (StreamHistoryReserved (StreamName "$all")) -    it "returns soft-deleted metadata and blocks undelete until guard completion" $-        withTestStore $ \store -> do-            let stream = StreamName "guard-undelete"-            Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)-            Right _ <- runStoreIO store $ softDeleteStream stream-            assertBlockedByGuard store stream $ do-                info <- runTx store (lockStreamHistoryForReplayTx stream)-                info `shouldSatisfy` \case Right StreamInfo{deletedAt = Just _} -> True; _ -> False-                runStoreIO store (undeleteStream stream)+  it "returns soft-deleted metadata and blocks undelete until guard completion" $+    withTestStore $ \store -> do+      let stream = StreamName "guard-undelete"+      Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)+      Right _ <- runStoreIO store $ softDeleteStream stream+      assertBlockedByGuard store stream $ do+        info <- runTx store (lockStreamHistoryForReplayTx stream)+        info `shouldSatisfy` \case Right StreamInfo {deletedAt = Just _} -> True; _ -> False+        runStoreIO store (undeleteStream stream) -    it "blocks append until guard completion" $-        withTestStore $ \store -> do-            let stream = StreamName "guard-append"-            Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)-            assertBlockedByGuard store stream $-                runStoreIO store (appendToStream stream AnyVersion (events 1))+  it "blocks append until guard completion" $+    withTestStore $ \store -> do+      let stream = StreamName "guard-append"+      Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)+      assertBlockedByGuard store stream $+        runStoreIO store (appendToStream stream AnyVersion (events 1)) -    it "releases the guard on rollback and lets the blocked append complete" $-        withTestStore $ \store -> do-            let stream = StreamName "guard-rollback"-            Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)-            assertBlockedByGuardEnding True store stream $-                runStoreIO store (appendToStream stream AnyVersion (events 1))+  it "releases the guard on rollback and lets the blocked append complete" $+    withTestStore $ \store -> do+      let stream = StreamName "guard-rollback"+      Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)+      assertBlockedByGuardEnding True store stream $+        runStoreIO store (appendToStream stream AnyVersion (events 1)) -    it "blocks link into the guarded stream until guard completion" $-        withTestStore $ \store -> do-            let source = StreamName "guard-link-source"-                target = StreamName "guard-link-target"-            Right _ <- runStoreIO store $ appendToStream source NoStream (events 1)-            Right _ <- runStoreIO store $ appendToStream target NoStream (events 1)-            Right sourceRows <- runStoreIO store $ readStreamForward source (StreamVersion 0) 10-            let eventId = Vector.head sourceRows ^. #eventId-            assertBlockedByGuard store target $-                runStoreIO store (linkToStream target [eventId])+  it "blocks link into the guarded stream until guard completion" $+    withTestStore $ \store -> do+      let source = StreamName "guard-link-source"+          target = StreamName "guard-link-target"+      Right _ <- runStoreIO store $ appendToStream source NoStream (events 1)+      Right _ <- runStoreIO store $ appendToStream target NoStream (events 1)+      Right sourceRows <- runStoreIO store $ readStreamForward source (StreamVersion 0) 10+      let eventId = Vector.head sourceRows ^. #eventId+      assertBlockedByGuard store target $+        runStoreIO store (linkToStream target [eventId]) -    it "blocks soft delete and logical truncate until guard completion" $-        withTestStore $ \store -> do-            let soft = StreamName "guard-soft-delete"-                truncated = StreamName "guard-truncate"-            Right _ <- runStoreIO store $ appendToStream soft NoStream (events 1)-            Right _ <- runStoreIO store $ appendToStream truncated NoStream (events 2)-            assertBlockedByGuard store soft $-                runStoreIO store (softDeleteStream soft)-            assertBlockedByGuard store truncated $-                runStoreIO store (setStreamTruncateBefore truncated (StreamVersion 2))+  it "blocks soft delete and logical truncate until guard completion" $+    withTestStore $ \store -> do+      let soft = StreamName "guard-soft-delete"+          truncated = StreamName "guard-truncate"+      Right _ <- runStoreIO store $ appendToStream soft NoStream (events 1)+      Right _ <- runStoreIO store $ appendToStream truncated NoStream (events 2)+      assertBlockedByGuard store soft $+        runStoreIO store (softDeleteStream soft)+      assertBlockedByGuard store truncated $+        runStoreIO store (setStreamTruncateBefore truncated (StreamVersion 2)) -    it "blocks hard delete of the guarded origin until guard completion" $-        withTestStore $ \store -> do-            let stream = StreamName "guard-hard-delete"-            Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)-            assertBlockedByGuard store stream $-                runStoreIO store (hardDeleteStream stream)+  it "blocks hard delete of the guarded origin until guard completion" $+    withTestStore $ \store -> do+      let stream = StreamName "guard-hard-delete"+      Right _ <- runStoreIO store $ appendToStream stream NoStream (events 1)+      assertBlockedByGuard store stream $+        runStoreIO store (hardDeleteStream stream) -    it "blocks hard delete of another origin linked into the guarded stream" $-        withTestStore $ \store -> do-            let origin = StreamName "guard-linked-origin"-                guarded = StreamName "guard-linked-target"-            Right _ <- runStoreIO store $ appendToStream origin NoStream (events 1)-            Right _ <- runStoreIO store $ appendToStream guarded NoStream (events 1)-            Right sourceRows <- runStoreIO store $ readStreamForward origin (StreamVersion 0) 10-            Right _ <- runStoreIO store $ linkToStream guarded [Vector.head sourceRows ^. #eventId]-            assertBlockedByGuard store guarded $-                runStoreIO store (hardDeleteStream origin)+  it "blocks hard delete of another origin linked into the guarded stream" $+    withTestStore $ \store -> do+      let origin = StreamName "guard-linked-origin"+          guarded = StreamName "guard-linked-target"+      Right _ <- runStoreIO store $ appendToStream origin NoStream (events 1)+      Right _ <- runStoreIO store $ appendToStream guarded NoStream (events 1)+      Right sourceRows <- runStoreIO store $ readStreamForward origin (StreamVersion 0) 10+      Right _ <- runStoreIO store $ linkToStream guarded [Vector.head sourceRows ^. #eventId]+      assertBlockedByGuard store guarded $+        runStoreIO store (hardDeleteStream origin) -    it "keeps opposing hard deletes and multi-stream appends deadlock-free" $-        withTestStore $ \store ->-            forM_ [1 .. 5 :: Int] $ \index -> do-                let suffix = Text.pack (show index)-                    first = StreamName ("guard-race-a-" <> suffix)-                    second = StreamName ("guard-race-b-" <> suffix)-                Right _ <- runStoreIO store $ appendToStream first NoStream (events 1)-                Right _ <- runStoreIO store $ appendToStream second NoStream (events 1)-                operations <--                    Async.async $-                        Async.concurrently-                            (runStoreIO store $ hardDeleteStream first)-                            ( runStoreIO store $-                                appendMultiStream-                                    [ (first, AnyVersion, events 1)-                                    , (second, AnyVersion, events 1)-                                    ]-                            )-                _ <- waitWithin "hard-delete/multi-append race" operations-                pure ()+  it "keeps opposing hard deletes and multi-stream appends deadlock-free" $+    withTestStore $ \store ->+      forM_ [1 .. 5 :: Int] $ \index -> do+        let suffix = Text.pack (show index)+            first = StreamName ("guard-race-a-" <> suffix)+            second = StreamName ("guard-race-b-" <> suffix)+        Right _ <- runStoreIO store $ appendToStream first NoStream (events 1)+        Right _ <- runStoreIO store $ appendToStream second NoStream (events 1)+        operations <-+          Async.async $+            Async.concurrently+              (runStoreIO store $ hardDeleteStream first)+              ( runStoreIO store $+                  appendMultiStream+                    [ (first, AnyVersion, events 1),+                      (second, AnyVersion, events 1)+                    ]+              )+        _ <- waitWithin "hard-delete/multi-append race" operations+        pure ()  assertBlockedByGuard :: KirokuStore -> StreamName -> IO result -> IO () assertBlockedByGuard = assertBlockedByGuardEnding False  assertBlockedByGuardEnding :: Bool -> KirokuStore -> StreamName -> IO result -> IO () assertBlockedByGuardEnding rollBack store stream mutation = do-    guard <- Async.async $ runStoreIO store $ runTransaction $ do-        locked <- lockStreamHistoryForReplayTx stream-        _ <- Tx.statement () holdGuardStmt-        when rollBack Tx.condemn-        pure locked-    waitForGuardPhase store 100-    waiter <- Async.async mutation-    threadDelay 50_000-    Async.poll waiter >>= \case-        Nothing -> pure ()-        Just _ -> expectationFailure "mutation completed while the stream-history guard was held"-    guardResult <- waitWithin "guard transaction" guard-    guardResult `shouldSatisfy` \case Right (Right _) -> True; _ -> False-    _ <- waitWithin "blocked mutation" waiter-    pure ()+  guard <- Async.async $ runStoreIO store $ runTransaction $ do+    locked <- lockStreamHistoryForReplayTx stream+    _ <- Tx.statement () holdGuardStmt+    when rollBack Tx.condemn+    pure locked+  waitForGuardPhase store 100+  waiter <- Async.async mutation+  threadDelay 50_000+  Async.poll waiter >>= \case+    Nothing -> pure ()+    Just _ -> expectationFailure "mutation completed while the stream-history guard was held"+  guardResult <- waitWithin "guard transaction" guard+  guardResult `shouldSatisfy` \case Right (Right _) -> True; _ -> False+  _ <- waitWithin "blocked mutation" waiter+  pure ()  waitForGuardPhase :: KirokuStore -> Int -> IO () waitForGuardPhase _ 0 = expectationFailure "guard never reached its held phase" waitForGuardPhase store attempts = do-    result <- Pool.use (store ^. #pool) (Session.statement () guardActiveStmt)-    case result of-        Right True -> pure ()-        Right False -> threadDelay 10_000 >> waitForGuardPhase store (attempts - 1)-        Left err -> expectationFailure ("could not observe guard phase: " <> show err)+  result <- Pool.use (store ^. #pool) (Session.statement () guardActiveStmt)+  case result of+    Right True -> pure ()+    Right False -> threadDelay 10_000 >> waitForGuardPhase store (attempts - 1)+    Left err -> expectationFailure ("could not observe guard phase: " <> show err)  waitWithin :: String -> Async.Async value -> IO value waitWithin label action = do-    result <- Async.race (threadDelay 2_000_000) (Async.wait action)-    case result of-        Left () -> do-            Async.cancel action-            expectationFailure (label <> " timed out")-            error "unreachable"-        Right value -> pure value+  result <- Async.race (threadDelay 2_000_000) (Async.wait action)+  case result of+    Left () -> do+      Async.cancel action+      expectationFailure (label <> " timed out")+      error "unreachable"+    Right value -> pure value  runTx :: KirokuStore -> Tx.Transaction value -> IO value runTx store transaction = do-    result <- runStoreIO store (runTransaction transaction)-    case result of-        Left err -> expectationFailure ("guard transaction failed: " <> show err) >> error "unreachable"-        Right value -> pure value+  result <- runStoreIO store (runTransaction transaction)+  case result of+    Left err -> expectationFailure ("guard transaction failed: " <> show err) >> error "unreachable"+    Right value -> pure value  events :: Int -> [EventData] events count =-    [makeEvent "Guarded" (Aeson.object [("n", Aeson.toJSON n)]) | n <- [1 .. count]]+  [makeEvent "Guarded" (Aeson.object [("n", Aeson.toJSON n)]) | n <- [1 .. count]]  holdGuardStmt :: Statement () Bool holdGuardStmt =-    preparable-        "SELECT pg_sleep(0.4) IS NULL"-        E.noParams-        (D.singleRow (D.column (D.nonNullable D.bool)))+  preparable+    "SELECT pg_sleep(0.4) IS NULL"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))  guardActiveStmt :: Statement () Bool guardActiveStmt =-    preparable-        """-        SELECT EXISTS (-          SELECT 1-          FROM pg_stat_activity-          WHERE state = 'active'-            AND query = 'SELECT pg_sleep(0.4) IS NULL'-        )-        """-        E.noParams-        (D.singleRow (D.column (D.nonNullable D.bool)))+  preparable+    """+    SELECT EXISTS (+      SELECT 1+      FROM pg_stat_activity+      WHERE state = 'active'+        AND query = 'SELECT pg_sleep(0.4) IS NULL'+    )+    """+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))
test/Test/StreamNameLookup.hs view
@@ -1,8 +1,7 @@-{- | Tests for 'Kiroku.Store.Read.lookupStreamNames' and 'lookupStreamName'-(plan 36). These resolve the surrogate 'RecordedEvent.originalStreamId' carried-by fan-in reads back to a human-readable 'StreamName', without every read having-to return the name (which a benchmark showed costs ~13% on @$all@ pages).--}+-- | Tests for 'Kiroku.Store.Read.lookupStreamNames' and 'lookupStreamName'+-- (plan 36). These resolve the surrogate 'RecordedEvent.originalStreamId' carried+-- by fan-in reads back to a human-readable 'StreamName', without every read having+-- to return the name (which a benchmark showed costs ~13% on @$all@ pages). module Test.StreamNameLookup (spec, noOpSpec) where  import Control.Lens ((^.))@@ -17,65 +16,65 @@  spec :: Spec spec = describe "lookupStreamNames / lookupStreamName" $ do-    around withTestStore $ do-        it "resolves originalStreamId back to the source stream for $all reads" $ \store -> do-            Right _ <--                runStoreIO store $-                    appendToStream (StreamName "orders-1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]-            Right _ <--                runStoreIO store $-                    appendToStream (StreamName "shipments-1") NoStream [makeEvent "ShipmentDispatched" (Aeson.object [])]-            Right evs <- runStoreIO store $ readAllForward (GlobalPosition 0) 100-            Right names <- runStoreIO store $ lookupStreamNames (map (^. #originalStreamId) (V.toList evs))-            let nameOf typ =-                    case V.find ((== EventType typ) . (^. #eventType)) evs of-                        Just e -> Map.lookup (e ^. #originalStreamId) names-                        Nothing -> Nothing-            nameOf "OrderCreated" `shouldBe` Just (StreamName "orders-1")-            nameOf "ShipmentDispatched" `shouldBe` Just (StreamName "shipments-1")+  around withTestStore $ do+    it "resolves originalStreamId back to the source stream for $all reads" $ \store -> do+      Right _ <-+        runStoreIO store $+          appendToStream (StreamName "orders-1") NoStream [makeEvent "OrderCreated" (Aeson.object [])]+      Right _ <-+        runStoreIO store $+          appendToStream (StreamName "shipments-1") NoStream [makeEvent "ShipmentDispatched" (Aeson.object [])]+      Right evs <- runStoreIO store $ readAllForward (GlobalPosition 0) 100+      Right names <- runStoreIO store $ lookupStreamNames (map (^. #originalStreamId) (V.toList evs))+      let nameOf typ =+            case V.find ((== EventType typ) . (^. #eventType)) evs of+              Just e -> Map.lookup (e ^. #originalStreamId) names+              Nothing -> Nothing+      nameOf "OrderCreated" `shouldBe` Just (StreamName "orders-1")+      nameOf "ShipmentDispatched" `shouldBe` Just (StreamName "shipments-1") -        it "omits ids that name no existing stream" $ \store -> do-            Right names <- runStoreIO store $ lookupStreamNames [StreamId 999999]-            Map.size names `shouldBe` 0+    it "omits ids that name no existing stream" $ \store -> do+      Right names <- runStoreIO store $ lookupStreamNames [StreamId 999999]+      Map.size names `shouldBe` 0 -        it "returns an empty map for an empty id list" $ \store -> do-            Right names <- runStoreIO store $ lookupStreamNames []-            Map.null names `shouldBe` True+    it "returns an empty map for an empty id list" $ \store -> do+      Right names <- runStoreIO store $ lookupStreamNames []+      Map.null names `shouldBe` True -        it "lookupStreamName resolves a single id, and Nothing for an unknown one" $ \store -> do-            Right _ <--                runStoreIO store $-                    appendToStream (StreamName "widgets-7") NoStream [makeEvent "WidgetMade" (Aeson.object [])]-            Right (Just sid) <- runStoreIO store $ lookupStreamId (StreamName "widgets-7")-            Right found <- runStoreIO store $ lookupStreamName sid-            found `shouldBe` Just (StreamName "widgets-7")-            Right missing <- runStoreIO store $ lookupStreamName (StreamId 888888)-            missing `shouldBe` Nothing+    it "lookupStreamName resolves a single id, and Nothing for an unknown one" $ \store -> do+      Right _ <-+        runStoreIO store $+          appendToStream (StreamName "widgets-7") NoStream [makeEvent "WidgetMade" (Aeson.object [])]+      Right (Just sid) <- runStoreIO store $ lookupStreamId (StreamName "widgets-7")+      Right found <- runStoreIO store $ lookupStreamName sid+      found `shouldBe` Just (StreamName "widgets-7")+      Right missing <- runStoreIO store $ lookupStreamName (StreamId 888888)+      missing `shouldBe` Nothing  noOpSpec :: Spec noOpSpec =-    describe "empty stream-name lookup" $-        it "short-circuits empty input without a pool checkout" $ do-            ref <- newIORef (0 :: Int)-            let handler (ConnectionObservation _ InUseConnectionStatus) =-                    modifyIORef' ref (+ 1)-                handler _ =-                    pure ()-            withTestStoreSettings (\settings -> settings{observationHandler = Just handler}) $ \store -> do-                Right appendResult <--                    runStoreIO store $-                        appendToStream (StreamName "lookup-count-1") NoStream [makeEvent "LookupCounted" (Aeson.object [])]-                waitForPublisher store (appendResult ^. #globalPosition)-                Right (Just sid) <- runStoreIO store $ lookupStreamId (StreamName "lookup-count-1")+  describe "empty stream-name lookup" $+    it "short-circuits empty input without a pool checkout" $ do+      ref <- newIORef (0 :: Int)+      let handler (ConnectionObservation _ InUseConnectionStatus) =+            modifyIORef' ref (+ 1)+          handler _ =+            pure ()+      withTestStoreSettings (\settings -> settings {observationHandler = Just handler}) $ \store -> do+        Right appendResult <-+          runStoreIO store $+            appendToStream (StreamName "lookup-count-1") NoStream [makeEvent "LookupCounted" (Aeson.object [])]+        waitForPublisher store (appendResult ^. #globalPosition)+        Right (Just sid) <- runStoreIO store $ lookupStreamId (StreamName "lookup-count-1") -                beforeEmpty <- readIORef ref-                Right emptyNames <- runStoreIO store $ lookupStreamNames []-                afterEmpty <- readIORef ref-                emptyNames `shouldBe` Map.empty-                afterEmpty - beforeEmpty `shouldBe` 0+        beforeEmpty <- readIORef ref+        Right emptyNames <- runStoreIO store $ lookupStreamNames []+        afterEmpty <- readIORef ref+        emptyNames `shouldBe` Map.empty+        afterEmpty - beforeEmpty `shouldBe` 0 -                beforeReal <- readIORef ref-                Right names <- runStoreIO store $ lookupStreamNames [sid]-                afterReal <- readIORef ref-                names `shouldBe` Map.singleton sid (StreamName "lookup-count-1")-                afterReal - beforeReal `shouldBe` 1+        beforeReal <- readIORef ref+        Right names <- runStoreIO store $ lookupStreamNames [sid]+        afterReal <- readIORef ref+        names `shouldBe` Map.singleton sid (StreamName "lookup-count-1")+        afterReal - beforeReal `shouldBe` 1
test/Test/SubscriptionCheckpointInitialization.hs view
@@ -3,134 +3,265 @@  module Test.SubscriptionCheckpointInitialization (spec) where +import Control.Concurrent (threadDelay) import Control.Concurrent.Async qualified as Async+import Control.Exception (bracket, finally)+import Control.Lens ((^.))+import Control.Monad (forM_) import Data.Aeson qualified as Aeson+import Data.Generics.Labels () import Data.Int (Int32) import Data.Text (Text) import Data.Text qualified as T import Data.Vector qualified as V import Effectful (runEff) import Effectful.Error.Static (runErrorNoCallStack)+import Hasql.Connection qualified as Connection+import Hasql.Connection.Settings qualified as Conn+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Hasql.Statement (Statement, preparable)+import Hasql.Transaction qualified as Tx+import Hasql.Transaction.Sessions qualified as TxSessions import Kiroku.Store-import Test.Helpers (makeEvent, withTestStore)+import Kiroku.Test.Postgres (withMigratedTestDatabase)+import System.Timeout (timeout)+import Test.Helpers (makeEvent, validConsumerGroup, withTestStore) import Test.Hspec  spec :: Spec spec = describe "subscription checkpoint initialization" $ do-    it "materializes position zero for FromBeginning" $-        withTestStore $ \store -> do-            let name = SubscriptionName "initialize-from-beginning"-                key = SubscriptionCheckpointKey name 0-            result <- initialize store name 0 FromBeginning-            result `shouldBe` Right (InitializedCheckpoint FromBeginning key (GlobalPosition 0))-            inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 0)]+  it "materializes position zero for FromBeginning" $+    withTestStore $ \store -> do+      let name = SubscriptionName "initialize-from-beginning"+          key = SubscriptionCheckpointKey name 0+      result <- initialize store name 0 FromBeginning+      result `shouldBe` Right (InitializedCheckpoint FromBeginning key (GlobalPosition 0))+      inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 0)]+      topology store `shouldReturn` [(0, 1, "unbound", Nothing)] -    it "atomically materializes the current store head for FromCurrentHead" $-        withTestStore $ \store -> do-            appendEvents store "initialize-current-head-events" 3-            let name = SubscriptionName "initialize-current-head"-                key = SubscriptionCheckpointKey name 0-            result <- initialize store name 0 FromCurrentHead-            result `shouldBe` Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 3))-            inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 3)]+  it "atomically materializes the current store head for FromCurrentHead" $+    withTestStore $ \store -> do+      appendEvents store "initialize-current-head-events" 3+      let name = SubscriptionName "initialize-current-head"+          key = SubscriptionCheckpointKey name 0+      result <- initialize store name 0 FromCurrentHead+      result `shouldBe` Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 3))+      inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 3)] -    it "returns a typed missing result without creating a row for FailIfMissing" $-        withTestStore $ \store -> do-            let name = SubscriptionName "initialize-fail-if-missing"-                key = SubscriptionCheckpointKey name 0-            result <- initialize store name 0 FailIfMissing-            result `shouldBe` Left (SubscriptionCheckpointMissing key)-            inventoryKeys <$> inventory store `shouldReturn` []+  it "returns a typed missing result without creating a row for FailIfMissing" $+    withTestStore $ \store -> do+      let name = SubscriptionName "initialize-fail-if-missing"+          key = SubscriptionCheckpointKey name 0+      result <- initialize store name 0 FailIfMissing+      result `shouldBe` Left (SubscriptionCheckpointMissing key)+      inventoryKeys <$> inventory store `shouldReturn` [] -    it "preserves an existing row for every configured policy" $-        withTestStore $ \store -> do-            appendEvents store "initialize-existing-events" 5-            let name = SubscriptionName "initialize-existing"-                key = SubscriptionCheckpointKey name 0-            initialize store name 0 FromCurrentHead-                `shouldReturn` Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 5))-            mapM_ (assertExisting store key) [FromBeginning, FromCurrentHead, FailIfMissing]-            inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 5)]+  it "preserves an existing row for every configured policy" $+    withTestStore $ \store -> do+      appendEvents store "initialize-existing-events" 5+      let name = SubscriptionName "initialize-existing"+          key = SubscriptionCheckpointKey name 0+      initialize store name 0 FromCurrentHead+        `shouldReturn` Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 5))+      mapM_ (assertExisting store key) [FromBeginning, FromCurrentHead, FailIfMissing]+      inventoryKeys <$> inventory store `shouldReturn` [(name, 0, GlobalPosition 5)] -    it "isolates checkpoint initialization by consumer-group member" $-        withTestStore $ \store -> do-            appendEvents store "initialize-member-events" 4-            let name = SubscriptionName "initialize-members"-            initialize store name 0 FromBeginning-                `shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 0) (GlobalPosition 0))-            initialize store name 1 FromCurrentHead-                `shouldReturn` Right (InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 1) (GlobalPosition 4))-            inventoryKeys <$> inventory store-                `shouldReturn` [ (name, 0, GlobalPosition 0)-                               , (name, 1, GlobalPosition 4)-                               ]+  it "isolates checkpoint initialization by consumer-group member" $+    withTestStore $ \store -> do+      appendEvents store "initialize-member-events" 4+      let name = SubscriptionName "initialize-members"+      initialize store name 0 FromBeginning+        `shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 0) (GlobalPosition 0))+      initialize store name 1 FromCurrentHead+        `shouldReturn` Right (InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 1) (GlobalPosition 4))+      inventoryKeys <$> inventory store+        `shouldReturn` [ (name, 0, GlobalPosition 0),+                         (name, 1, GlobalPosition 4)+                       ] -    it "converges concurrent initializers on one durable winner" $-        withTestStore $ \store -> do-            appendEvents store "initialize-race-events" 7-            let name = SubscriptionName "initialize-race"-                policies = take 20 (cycle [FromBeginning, FromCurrentHead])-            results <- Async.mapConcurrently (initialize store name 3) policies-            let successes = [initialization | Right initialization <- results]-                positions = fmap checkpointInitializationPosition successes-                initializedCount = length [() | InitializedCheckpoint{} <- successes]-            length successes `shouldBe` length policies-            initializedCount `shouldBe` 1-            positions `shouldSatisfy` \case-                [] -> False-                first : rest -> all (== first) rest-            case positions of-                [] -> expectationFailure "expected concurrent initialization results"-                winner : _ ->-                    inventoryKeys <$> inventory store-                        `shouldReturn` [(name, 3, winner)]+  it "converges concurrent initializers on one durable winner" $+    withTestStore $ \store -> do+      appendEvents store "initialize-race-events" 7+      let name = SubscriptionName "initialize-race"+          policies = take 20 (cycle [FromBeginning, FromCurrentHead])+      results <- Async.mapConcurrently (initialize store name 3) policies+      let successes = [initialization | Right initialization <- results]+          positions = fmap checkpointInitializationPosition successes+          initializedCount = length [() | InitializedCheckpoint {} <- successes]+      length successes `shouldBe` length policies+      initializedCount `shouldBe` 1+      positions `shouldSatisfy` \case+        [] -> False+        first : rest -> all (== first) rest+      case positions of+        [] -> expectationFailure "expected concurrent initialization results"+        winner : _ ->+          inventoryKeys <$> inventory store+            `shouldReturn` [(name, 3, winner)] -    it "runs through the resource-backed Store interpreter" $-        withTestStore $ \store -> do-            let name = SubscriptionName "initialize-resource"-                key = SubscriptionCheckpointKey name 2-            result <--                runEff-                    . runErrorNoCallStack @StoreError-                    . runKirokuStoreWith store-                    . runStoreResource-                    $ initializeSubscriptionCheckpoint name 2 FromBeginning-            result `shouldBe` Right (Right (InitializedCheckpoint FromBeginning key (GlobalPosition 0)))+  it "initializes an absent member with sibling size and category binding, then starts under RequireBound" $+    withTestStore $ \store -> do+      let name = SubscriptionName "initialize-bound"+          target = Category (CategoryName "orders")+          config memberIndex =+            (defaultSubscriptionConfig name target (\_ -> pure Continue))+              { consumerGroup = Just (validConsumerGroup memberIndex 2),+                targetBindingPolicy = RequireBound+              }+      withSubscription store (config 0) $ \handle -> awaitLive handle+      topology store `shouldReturn` [(0, 2, "category", Just "orders")]+      initialize store name 1 FromBeginning+        `shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 1) (GlobalPosition 0))+      topology store `shouldReturn` [(0, 2, "category", Just "orders"), (1, 2, "category", Just "orders")]+      withSubscription store (config 1) $ \handle -> awaitLive handle +  forM_ (["uniform-unbound", "mixed-size", "mixed-binding"] :: [Text]) $ \variant ->+    it ("initializes with uniform topology or legacy defaults for " <> show variant) $+      withTestStore $ \store -> do+        let secondSize = if variant == "mixed-size" then "3" else "2"+            secondBinding = if variant == "mixed-binding" then "all" else "unbound"+        runSession+          store+          ( Session.script+              ("INSERT INTO subscriptions (subscription_name, consumer_group_member, consumer_group_size, last_seen, target_kind) VALUES ('initialize-fallback', 0, 2, 7, 'unbound'), ('initialize-fallback', 1, " <> secondSize <> ", 9, '" <> secondBinding <> "')")+          )+        originalRows <- topology store+        initialize store (SubscriptionName "initialize-fallback") 2 FromBeginning+          `shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey (SubscriptionName "initialize-fallback") 2) (GlobalPosition 0))+        initializedRows <- topology store+        take 2 initializedRows `shouldBe` originalRows+        drop 2 initializedRows `shouldBe` [(2, if variant == "uniform-unbound" then 2 else 1, "unbound", Nothing)]++  it "initializes after a concurrent rebind commits, waiting on the name lock before reading topology" $+    withMigratedTestDatabase $ \connStr -> withStore (defaultConnectionSettings connStr) $ \store ->+      bracket+        (Connection.acquire (Conn.connectionString connStr) >>= either (fail . show) pure)+        Connection.release+        $ \gate -> do+          let name = SubscriptionName "initialize-rebind"+              newTarget = Category (CategoryName "new-orders")+              useGate session = Connection.use gate session >>= either (fail . show) pure+          initialize store name 0 FromBeginning+            `shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 0) (GlobalPosition 0))+          -- The session lock is a test gate. Rebind blocks at the gate after+          -- changing its rows, while still owning the transaction's name lock.+          useGate (Session.script "SELECT pg_advisory_lock(103)")+          ( Async.withAsync+              ( runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+                  Tx.sql "SET LOCAL statement_timeout = '5s'"+                  report <- rebindSubscriptionTargetTx name newTarget (GlobalPosition 0)+                  Tx.sql "SELECT pg_advisory_xact_lock(103)"+                  pure report+              )+              $ \rebinder -> do+                within $ awaitQuery store gateWaitStmt+                Async.withAsync (initialize store name 1 FromBeginning) $ \initializer -> do+                  ( do+                      within $ awaitQuery store topologyWaitStmt+                      useGate (Session.script "SELECT pg_advisory_unlock(103)")+                      _ <- within (Async.wait rebinder)+                      within (Async.wait initializer)+                        `shouldReturn` Right (InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 1) (GlobalPosition 0))+                      topology store `shouldReturn` [(0, 1, "category", Just "new-orders"), (1, 1, "category", Just "new-orders")]+                    )+                    `finally` useGate (Session.script "SELECT pg_advisory_unlock_all()")+            )+            `finally` useGate (Session.script "SELECT pg_advisory_unlock_all()")++  it "runs through the resource-backed Store interpreter" $+    withTestStore $ \store -> do+      let name = SubscriptionName "initialize-resource"+          key = SubscriptionCheckpointKey name 2+      result <-+        runEff+          . runErrorNoCallStack @StoreError+          . runKirokuStoreWith store+          . runStoreResource+          $ initializeSubscriptionCheckpoint name 2 FromBeginning+      result `shouldBe` Right (Right (InitializedCheckpoint FromBeginning key (GlobalPosition 0)))+ assertExisting :: KirokuStore -> SubscriptionCheckpointKey -> MissingCheckpointPolicy -> IO () assertExisting store key@(SubscriptionCheckpointKey name member) policy =-    initialize store name member policy-        `shouldReturn` Right (ExistingCheckpoint key (GlobalPosition 5))+  initialize store name member policy+    `shouldReturn` Right (ExistingCheckpoint key (GlobalPosition 5))  initialize ::-    KirokuStore ->-    SubscriptionName ->-    Int32 ->-    MissingCheckpointPolicy ->-    IO (Either SubscriptionCheckpointMissing CheckpointInitialization)+  KirokuStore ->+  SubscriptionName ->+  Int32 ->+  MissingCheckpointPolicy ->+  IO (Either SubscriptionCheckpointMissing CheckpointInitialization) initialize store name member policy = do-    result <- runStoreIO store (initializeSubscriptionCheckpoint name member policy)-    case result of-        Left err -> expectationFailure ("checkpoint initialization failed: " <> show err) >> error "unreachable"-        Right initialized -> pure initialized+  result <- runStoreIO store (initializeSubscriptionCheckpoint name member policy)+  case result of+    Left err -> expectationFailure ("checkpoint initialization failed: " <> show err) >> error "unreachable"+    Right initialized -> pure initialized  inventory :: KirokuStore -> IO SubscriptionCheckpointInventory inventory store = do-    result <- runStoreIO store subscriptionCheckpointInventory-    case result of-        Left err -> expectationFailure ("checkpoint inventory failed: " <> show err) >> error "unreachable"-        Right rows -> pure rows+  result <- runStoreIO store subscriptionCheckpointInventory+  case result of+    Left err -> expectationFailure ("checkpoint inventory failed: " <> show err) >> error "unreachable"+    Right rows -> pure rows  inventoryKeys :: SubscriptionCheckpointInventory -> [(SubscriptionName, Int32, GlobalPosition)] inventoryKeys (SubscriptionCheckpointInventory _ rows) =-    [ (name, member, position)-    | SubscriptionCheckpoint name member position _ <- V.toList rows-    ]+  [ (name, member, position)+  | SubscriptionCheckpoint name member position _ <- V.toList rows+  ]  appendEvents :: KirokuStore -> Text -> Int -> IO () appendEvents store stream count = do-    let events = [makeEvent ("Initialize" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]-    result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events-    case result of-        Left err -> expectationFailure ("append failed: " <> show err)-        Right _ -> pure ()+  let events = [makeEvent ("Initialize" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]+  result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events+  case result of+    Left err -> expectationFailure ("append failed: " <> show err)+    Right _ -> pure ()++within :: IO a -> IO a+within action = timeout 5_000_000 action >>= maybe (fail "checkpoint initialization test timed out") pure++awaitLive :: SubscriptionHandle -> IO ()+awaitLive handle = within loop+  where+    loop =+      currentState handle >>= \case+        Just state | stateName state == "live" -> pure ()+        _ -> threadDelay 1_000 >> loop++runSession :: KirokuStore -> Session.Session a -> IO a+runSession store session = Pool.use (store ^. #pool) session >>= either (fail . show) pure++topology :: KirokuStore -> IO [(Int32, Int32, Text, Maybe Text)]+topology store = V.toList <$> runSession store (Session.statement () stmt)+  where+    stmt =+      preparable+        "SELECT consumer_group_member, consumer_group_size, target_kind, target_category FROM subscriptions ORDER BY consumer_group_member"+        E.noParams+        (D.rowVector ((,,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text)))++awaitQuery :: KirokuStore -> Statement () Bool -> IO ()+awaitQuery store stmt = do+  ready <- runSession store (Session.statement () stmt)+  if ready then pure () else threadDelay 1_000 >> awaitQuery store stmt++-- Only this test database participates; unrelated advisory locks cannot satisfy+-- either barrier. The second query observes the initializer blocked on a lock+-- held by the rebind transaction, which is itself waiting at the test gate.+gateWaitStmt :: Statement () Bool+gateWaitStmt =+  preparable+    "SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND NOT granted AND classid = 0 AND objid = 103 AND objsubid = 1 AND database = (SELECT oid FROM pg_database WHERE datname = current_database()))"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))++topologyWaitStmt :: Statement () Bool+topologyWaitStmt =+  preparable+    "SELECT EXISTS (SELECT 1 FROM pg_locks waiting JOIN pg_locks held USING (locktype, database, classid, objid, objsubid) JOIN pg_locks gate ON gate.pid = held.pid WHERE waiting.locktype = 'advisory' AND NOT waiting.granted AND held.granted AND gate.locktype = 'advisory' AND NOT gate.granted AND gate.classid = 0 AND gate.objid = 103 AND gate.objsubid = 1 AND waiting.database = (SELECT oid FROM pg_database WHERE datname = current_database()))"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.bool)))
test/Test/SubscriptionCheckpointInitializationMock.hs view
@@ -12,33 +12,33 @@  spec :: Spec spec = describe "subscription checkpoint initialization mock interpreter" $ do-    it "carries the closed policy and typed result through one Store effect call" $ do-        calls <- newIORef (0 :: Int)-        let name = SubscriptionName "mock-initialization"-            key = SubscriptionCheckpointKey name 4-            expected = Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 23))-        actual <--            runEff $-                runInitializationMock calls name 4 FromCurrentHead expected $-                    initializeSubscriptionCheckpoint name 4 FromCurrentHead-        actual `shouldBe` expected-        readIORef calls `shouldReturn` 1+  it "carries the closed policy and typed result through one Store effect call" $ do+    calls <- newIORef (0 :: Int)+    let name = SubscriptionName "mock-initialization"+        key = SubscriptionCheckpointKey name 4+        expected = Right (InitializedCheckpoint FromCurrentHead key (GlobalPosition 23))+    actual <-+      runEff $+        runInitializationMock calls name 4 FromCurrentHead expected $+          initializeSubscriptionCheckpoint name 4 FromCurrentHead+    actual `shouldBe` expected+    readIORef calls `shouldReturn` 1  runInitializationMock ::-    (IOE :> es) =>-    IORef Int ->-    SubscriptionName ->-    Int ->-    MissingCheckpointPolicy ->-    Either SubscriptionCheckpointMissing CheckpointInitialization ->-    Eff (Store : es) a ->-    Eff es a+  (IOE :> es) =>+  IORef Int ->+  SubscriptionName ->+  Int ->+  MissingCheckpointPolicy ->+  Either SubscriptionCheckpointMissing CheckpointInitialization ->+  Eff (Store : es) a ->+  Eff es a runInitializationMock calls expectedName expectedMember expectedPolicy expected = interpret_ $ \case-    InitializeSubscriptionCheckpoint actualName actualMember actualPolicy -> do-        liftIO $ do-            actualName `shouldBe` expectedName-            fromIntegral actualMember `shouldBe` expectedMember-            actualPolicy `shouldBe` expectedPolicy-            modifyIORef' calls (+ 1)-        pure expected-    _ -> error "unexpected Store operation in checkpoint initialization mock"+  InitializeSubscriptionCheckpoint actualName actualMember actualPolicy -> do+    liftIO $ do+      actualName `shouldBe` expectedName+      fromIntegral actualMember `shouldBe` expectedMember+      actualPolicy `shouldBe` expectedPolicy+      modifyIORef' calls (+ 1)+    pure expected+  _ -> error "unexpected Store operation in checkpoint initialization mock"
test/Test/SubscriptionCheckpointInventory.hs view
@@ -29,216 +29,216 @@  spec :: Spec spec = describe "SubscriptionCheckpointInventory" $ do-    it "returns position zero and no rows for an empty migrated store" $-        withTestStore $ \store -> do-            inventory@(SubscriptionCheckpointInventory captured rows) <- readInventory store-            captured `shouldBe` GlobalPosition 0-            rows `shouldBe` V.empty-            assertSqlRelationMatchesInventory store inventory+  it "returns position zero and no rows for an empty migrated store" $+    withTestStore $ \store -> do+      inventory@(SubscriptionCheckpointInventory captured rows) <- readInventory store+      captured `shouldBe` GlobalPosition 0+      rows `shouldBe` V.empty+      assertSqlRelationMatchesInventory store inventory -    it "runs through the resource-backed Store interpreter" $-        withTestStore $ \store -> do-            result <--                runEff-                    . runErrorNoCallStack @StoreError-                    . runKirokuStoreWith store-                    . runStoreResource-                    $ subscriptionCheckpointInventory-            result `shouldBe` Right (SubscriptionCheckpointInventory (GlobalPosition 0) V.empty)+  it "runs through the resource-backed Store interpreter" $+    withTestStore $ \store -> do+      result <-+        runEff+          . runErrorNoCallStack @StoreError+          . runKirokuStoreWith store+          . runStoreResource+          $ subscriptionCheckpointInventory+      result `shouldBe` Right (SubscriptionCheckpointInventory (GlobalPosition 0) V.empty) -    it "returns the exact store position and a member-zero checkpoint" $-        withTestStore $ \store -> do-            appendEvents store "inventory-single" 3-            saveCheckpoint store "single" 0 2+  it "returns the exact store position and a member-zero checkpoint" $+    withTestStore $ \store -> do+      appendEvents store "inventory-single" 3+      saveCheckpoint store "single" 0 2 -            SubscriptionCheckpointInventory captured rows <- readInventory store-            captured `shouldBe` GlobalPosition 3-            checkpointKeys rows `shouldBe` [("single", 0, 2)]-            now <- getCurrentTime-            case V.toList rows of-                [SubscriptionCheckpoint _ _ _ updatedAt] -> updatedAt `shouldSatisfy` (<= now)-                _ -> expectationFailure "expected exactly one checkpoint"+      SubscriptionCheckpointInventory captured rows <- readInventory store+      captured `shouldBe` GlobalPosition 3+      checkpointKeys rows `shouldBe` [("single", 0, 2)]+      now <- getCurrentTime+      case V.toList rows of+        [SubscriptionCheckpoint _ _ _ updatedAt] -> updatedAt `shouldSatisfy` (<= now)+        _ -> expectationFailure "expected exactly one checkpoint" -    it "returns multiple names and members in deterministic key order" $-        withTestStore $ \store -> do-            appendEvents store "inventory-many" 20-            saveCheckpoint store "zeta" 2 7-            saveCheckpoint store "alpha" 10 3-            saveCheckpoint store "alpha" 2 5+  it "returns multiple names and members in deterministic key order" $+    withTestStore $ \store -> do+      appendEvents store "inventory-many" 20+      saveCheckpoint store "zeta" 2 7+      saveCheckpoint store "alpha" 10 3+      saveCheckpoint store "alpha" 2 5 -            inventory@(SubscriptionCheckpointInventory captured rows) <- readInventory store-            captured `shouldBe` GlobalPosition 20-            checkpointKeys rows-                `shouldBe` [ ("alpha", 2, 5)-                           , ("alpha", 10, 3)-                           , ("zeta", 2, 7)-                           ]-            assertSqlRelationMatchesInventory store inventory+      inventory@(SubscriptionCheckpointInventory captured rows) <- readInventory store+      captured `shouldBe` GlobalPosition 20+      checkpointKeys rows+        `shouldBe` [ ("alpha", 2, 5),+                     ("alpha", 10, 3),+                     ("zeta", 2, 7)+                   ]+      assertSqlRelationMatchesInventory store inventory -    it "preserves monotonic checkpoints and observes later commits on a fresh read" $-        withTestStore $ \store -> do-            appendEvents store "inventory-monotonic" 10-            saveCheckpoint store "monotonic" 0 8-            saveCheckpoint store "monotonic" 0 4+  it "preserves monotonic checkpoints and observes later commits on a fresh read" $+    withTestStore $ \store -> do+      appendEvents store "inventory-monotonic" 10+      saveCheckpoint store "monotonic" 0 8+      saveCheckpoint store "monotonic" 0 4 -            first <- readInventory store-            inventoryKeys first `shouldBe` [("monotonic", 0, 8)]+      first <- readInventory store+      inventoryKeys first `shouldBe` [("monotonic", 0, 8)] -            saveCheckpoint store "monotonic" 0 9-            second <- readInventory store-            inventoryKeys second `shouldBe` [("monotonic", 0, 9)]+      saveCheckpoint store "monotonic" 0 9+      second <- readInventory store+      inventoryKeys second `shouldBe` [("monotonic", 0, 9)] -    it "retains a durable row after the worker stops and leaves live state" $-        withTestStore $ \store -> do-            appendEvents store "inventory-stopped" 1-            waitForPublisher store (GlobalPosition 1)-            let name = SubscriptionName "stopped"-            handle <- subscribe store (defaultSubscriptionConfig name AllStreams (\_ -> pure Stop))-            waitClean handle+  it "retains a durable row after the worker stops and leaves live state" $+    withTestStore $ \store -> do+      appendEvents store "inventory-stopped" 1+      waitForPublisher store (GlobalPosition 1)+      let name = SubscriptionName "stopped"+      handle <- subscribe store (defaultSubscriptionConfig name AllStreams (\_ -> pure Stop))+      waitClean handle -            states <- subscriptionStates store-            Map.member (name, 0) states `shouldBe` False-            inventory <- readInventory store-            inventoryKeys inventory `shouldBe` [("stopped", 0, 1)]-            assertSqlRelationMatchesInventory store inventory+      states <- subscriptionStates store+      Map.member (name, 0) states `shouldBe` False+      inventory <- readInventory store+      inventoryKeys inventory `shouldBe` [("stopped", 0, 1)]+      assertSqlRelationMatchesInventory store inventory -    it "does not expose in-flight live handler progress before checkpoint commit" $ do-        caughtUp <- newEmptyMVar-        enteredHandler <- newEmptyMVar-        releaseHandler <- newEmptyMVar-        let name = SubscriptionName "in-flight"-            observe = caughtUpEventHandler name caughtUp Nothing-            handler _ = do-                putMVar enteredHandler ()-                takeMVar releaseHandler-                pure Stop-            config = defaultSubscriptionConfig name AllStreams handler-        withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do-            appendEvents store "inventory-live" 1-            saveCheckpoint store "in-flight" 0 1-            waitForPublisher store (GlobalPosition 1)-            handle <- subscribe store config-            waitForSubscriptionLive caughtUp+  it "does not expose in-flight live handler progress before checkpoint commit" $ do+    caughtUp <- newEmptyMVar+    enteredHandler <- newEmptyMVar+    releaseHandler <- newEmptyMVar+    let name = SubscriptionName "in-flight"+        observe = caughtUpEventHandler name caughtUp Nothing+        handler _ = do+          putMVar enteredHandler ()+          takeMVar releaseHandler+          pure Stop+        config = defaultSubscriptionConfig name AllStreams handler+    withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do+      appendEvents store "inventory-live" 1+      saveCheckpoint store "in-flight" 0 1+      waitForPublisher store (GlobalPosition 1)+      handle <- subscribe store config+      waitForSubscriptionLive caughtUp -            appendEventsExisting store "inventory-live" 1-            waitForMVar "live handler did not receive the event" enteredHandler-            beforeCommit <- readInventory store-            inventoryKeys beforeCommit `shouldBe` [("in-flight", 0, 1)]-            assertSqlRelationMatchesInventory store beforeCommit+      appendEventsExisting store "inventory-live" 1+      waitForMVar "live handler did not receive the event" enteredHandler+      beforeCommit <- readInventory store+      inventoryKeys beforeCommit `shouldBe` [("in-flight", 0, 1)]+      assertSqlRelationMatchesInventory store beforeCommit -            putMVar releaseHandler ()-            waitClean handle-            afterCommit <- readInventory store-            inventoryKeys afterCommit `shouldBe` [("in-flight", 0, 2)]-            assertSqlRelationMatchesInventory store afterCommit+      putMVar releaseHandler ()+      waitClean handle+      afterCommit <- readInventory store+      inventoryKeys afterCommit `shouldBe` [("in-flight", 0, 2)]+      assertSqlRelationMatchesInventory store afterCommit -    it "observes the checkpoint advanced by a dead-letter transaction" $-        withTestStore $ \store -> do-            appendEvents store "inventory-dead-letter" 1-            Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10-            let event = V.head events-            insertDeadLetterForEvent store "dead-lettered" event+  it "observes the checkpoint advanced by a dead-letter transaction" $+    withTestStore $ \store -> do+      appendEvents store "inventory-dead-letter" 1+      Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) 10+      let event = V.head events+      insertDeadLetterForEvent store "dead-lettered" event -            inventory <- readInventory store-            inventoryKeys inventory `shouldBe` [("dead-lettered", 0, 1)]+      inventory <- readInventory store+      inventoryKeys inventory `shouldBe` [("dead-lettered", 0, 1)] -    it "captures a head at or beyond every normally written checkpoint" $-        withTestStore $ \store -> do-            appendEvents store "inventory-bounds" 6-            saveCheckpoint store "bounds-a" 0 2-            saveCheckpoint store "bounds-b" 1 6+  it "captures a head at or beyond every normally written checkpoint" $+    withTestStore $ \store -> do+      appendEvents store "inventory-bounds" 6+      saveCheckpoint store "bounds-a" 0 2+      saveCheckpoint store "bounds-b" 1 6 -            SubscriptionCheckpointInventory (GlobalPosition captured) rows <- readInventory store-            let positions = [position | SubscriptionCheckpoint _ _ (GlobalPosition position) _ <- V.toList rows]-            positions `shouldSatisfy` all (<= captured)+      SubscriptionCheckpointInventory (GlobalPosition captured) rows <- readInventory store+      let positions = [position | SubscriptionCheckpoint _ _ (GlobalPosition position) _ <- V.toList rows]+      positions `shouldSatisfy` all (<= captured)  readInventory :: KirokuStore -> IO SubscriptionCheckpointInventory readInventory store = do-    result <- runStoreIO store subscriptionCheckpointInventory-    case result of-        Left err -> error ("subscriptionCheckpointInventory failed: " <> show err)-        Right inventory -> pure inventory+  result <- runStoreIO store subscriptionCheckpointInventory+  case result of+    Left err -> error ("subscriptionCheckpointInventory failed: " <> show err)+    Right inventory -> pure inventory  saveCheckpoint :: KirokuStore -> Text -> Int32 -> Int64 -> IO () saveCheckpoint store name member position = do-    result <- Pool.use (store ^. #pool) $ Session.statement (name, member, position, max 1 (member + 1), "unbound", Nothing) SQL.saveCheckpointMemberStmt-    case result of-        Left err -> error ("saveCheckpoint failed: " <> show err)-        Right () -> pure ()+  result <- Pool.use (store ^. #pool) $ Session.statement (name, member, position, max 1 (member + 1), "unbound", Nothing) SQL.saveCheckpointMemberStmt+  case result of+    Left err -> error ("saveCheckpoint failed: " <> show err)+    Right () -> pure ()  appendEvents :: KirokuStore -> Text -> Int -> IO () appendEvents store stream count = do-    let events = [makeEvent ("E" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]-    result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events-    case result of-        Left err -> error ("appendEvents failed: " <> show err)-        Right _ -> pure ()+  let events = [makeEvent ("E" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]+  result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events+  case result of+    Left err -> error ("appendEvents failed: " <> show err)+    Right _ -> pure ()  appendEventsExisting :: KirokuStore -> Text -> Int -> IO () appendEventsExisting store stream count = do-    let events = [makeEvent ("Live" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]-    result <- runStoreIO store $ appendToStream (StreamName stream) StreamExists events-    case result of-        Left err -> error ("appendEventsExisting failed: " <> show err)-        Right _ -> pure ()+  let events = [makeEvent ("Live" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]+  result <- runStoreIO store $ appendToStream (StreamName stream) StreamExists events+  case result of+    Left err -> error ("appendEventsExisting failed: " <> show err)+    Right _ -> pure ()  waitClean :: SubscriptionHandle -> IO () waitClean handle = do-    result <- waitWithTimeout 20_000_000 handle-    case result of-        Left message -> expectationFailure message-        Right (Left err) -> expectationFailure ("subscription failed: " <> show err)-        Right (Right ()) -> pure ()+  result <- waitWithTimeout 20_000_000 handle+  case result of+    Left message -> expectationFailure message+    Right (Left err) -> expectationFailure ("subscription failed: " <> show err)+    Right (Right ()) -> pure ()  waitForMVar :: String -> MVar () -> IO () waitForMVar failureMessage var = do-    result <- timeout 5_000_000 (takeMVar var)-    case result of-        Nothing -> expectationFailure failureMessage-        Just () -> pure ()+  result <- timeout 5_000_000 (takeMVar var)+  case result of+    Nothing -> expectationFailure failureMessage+    Just () -> pure ()  inventoryKeys :: SubscriptionCheckpointInventory -> [(Text, Int32, Int64)] inventoryKeys (SubscriptionCheckpointInventory _ rows) = checkpointKeys rows  checkpointKeys :: V.Vector SubscriptionCheckpoint -> [(Text, Int32, Int64)] checkpointKeys rows =-    [ (name, member, position)-    | SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) _ <- V.toList rows-    ]+  [ (name, member, position)+  | SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) _ <- V.toList rows+  ]  assertSqlRelationMatchesInventory :: KirokuStore -> SubscriptionCheckpointInventory -> Expectation assertSqlRelationMatchesInventory store (SubscriptionCheckpointInventory _ inventoryRows) = do-    result <- Pool.use (store ^. #pool) $ Session.statement () subscriptionCheckpointRelationStmt-    case result of-        Left err -> expectationFailure ("subscription checkpoint relation failed: " <> show err)-        Right sqlRows ->-            sqlRows-                `shouldBe` [ (name, member, position, updatedAt)-                           | SubscriptionCheckpoint-                                (SubscriptionName name)-                                member-                                (GlobalPosition position)-                                updatedAt <--                                V.toList inventoryRows-                           ]+  result <- Pool.use (store ^. #pool) $ Session.statement () subscriptionCheckpointRelationStmt+  case result of+    Left err -> expectationFailure ("subscription checkpoint relation failed: " <> show err)+    Right sqlRows ->+      sqlRows+        `shouldBe` [ (name, member, position, updatedAt)+                   | SubscriptionCheckpoint+                       (SubscriptionName name)+                       member+                       (GlobalPosition position)+                       updatedAt <-+                       V.toList inventoryRows+                   ]  subscriptionCheckpointRelationStmt :: Statement () [(Text, Int32, Int64, UTCTime)] subscriptionCheckpointRelationStmt =-    preparable-        """-        SELECT subscription_name,-               consumer_group_member,-               checkpoint_position,-               checkpoint_updated_at-        FROM kiroku.subscription_checkpoints_v1-        ORDER BY subscription_name, consumer_group_member-        """-        E.noParams-        ( D.rowList-            ( (,,,)-                <$> D.column (D.nonNullable D.text)-                <*> D.column (D.nonNullable D.int4)-                <*> D.column (D.nonNullable D.int8)-                <*> D.column (D.nonNullable D.timestamptz)-            )+  preparable+    """+    SELECT subscription_name,+           consumer_group_member,+           checkpoint_position,+           checkpoint_updated_at+    FROM kiroku.subscription_checkpoints_v1+    ORDER BY subscription_name, consumer_group_member+    """+    E.noParams+    ( D.rowList+        ( (,,,)+            <$> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.int4)+            <*> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.timestamptz)         )+    )
test/Test/SubscriptionCheckpointInventoryMock.hs view
@@ -15,31 +15,31 @@  spec :: Spec spec = describe "SubscriptionCheckpointInventory mock interpreter" $ do-    it "returns a public inventory through one Store effect call" $ do-        calls <- newIORef (0 :: Int)-        let updatedAt = UTCTime (fromGregorian 2026 8 9) 0-            expected =-                SubscriptionCheckpointInventory-                    (GlobalPosition 17)-                    ( V.singleton $-                        SubscriptionCheckpoint-                            (SubscriptionName "mock")-                            3-                            (GlobalPosition 11)-                            updatedAt-                    )-        actual <- runEff $ runInventoryMock calls expected subscriptionCheckpointInventory-        actual `shouldBe` expected-        readIORef calls `shouldReturn` 1+  it "returns a public inventory through one Store effect call" $ do+    calls <- newIORef (0 :: Int)+    let updatedAt = UTCTime (fromGregorian 2026 8 9) 0+        expected =+          SubscriptionCheckpointInventory+            (GlobalPosition 17)+            ( V.singleton $+                SubscriptionCheckpoint+                  (SubscriptionName "mock")+                  3+                  (GlobalPosition 11)+                  updatedAt+            )+    actual <- runEff $ runInventoryMock calls expected subscriptionCheckpointInventory+    actual `shouldBe` expected+    readIORef calls `shouldReturn` 1  runInventoryMock ::-    (IOE :> es) =>-    IORef Int ->-    SubscriptionCheckpointInventory ->-    Eff (Store : es) a ->-    Eff es a+  (IOE :> es) =>+  IORef Int ->+  SubscriptionCheckpointInventory ->+  Eff (Store : es) a ->+  Eff es a runInventoryMock calls expected = interpret_ $ \case-    GetSubscriptionCheckpointInventory -> do-        liftIO $ modifyIORef' calls (+ 1)-        pure expected-    _ -> error "unexpected Store operation in inventory mock"+  GetSubscriptionCheckpointInventory -> do+    liftIO $ modifyIORef' calls (+ 1)+    pure expected+  _ -> error "unexpected Store operation in inventory mock"
test/Test/SubscriptionCheckpointReset.hs view
@@ -24,190 +24,190 @@  spec :: Spec spec = describe "subscription checkpoint reset" $ do-    it "commits every persisted member and reports sorted affected and missing names exactly" $-        withTestStore $ \store -> do-            appendEvents store "reset-commit-events" 10-            initializeRows store [("zeta", 1), ("alpha", 2), ("alpha", 0)]-            createSentinelTable store+  it "commits every persisted member and reports sorted affected and missing names exactly" $+    withTestStore $ \store -> do+      appendEvents store "reset-commit-events" 10+      initializeRows store [("zeta", 1), ("alpha", 2), ("alpha", 0)]+      createSentinelTable store -            result <- runStoreIO store $ runTransaction $ do-                Tx.statement (1, "committed") insertSentinelStmt-                resetSubscriptionCheckpointsTx-                    ( SubscriptionName "zeta"-                        :| [ SubscriptionName "missing"-                           , SubscriptionName "alpha"-                           , SubscriptionName "alpha"-                           ]-                    )-                    (GlobalPosition 7)+      result <- runStoreIO store $ runTransaction $ do+        Tx.statement (1, "committed") insertSentinelStmt+        resetSubscriptionCheckpointsTx+          ( SubscriptionName "zeta"+              :| [ SubscriptionName "missing",+                   SubscriptionName "alpha",+                   SubscriptionName "alpha"+                 ]+          )+          (GlobalPosition 7) -            result-                `shouldBe` Right-                    SubscriptionCheckpointResetReport-                        { resetCheckpointKeys =-                            Vector.fromList-                                [ SubscriptionCheckpointKey (SubscriptionName "alpha") 0-                                , SubscriptionCheckpointKey (SubscriptionName "alpha") 2-                                , SubscriptionCheckpointKey (SubscriptionName "zeta") 1-                                ]-                        , missingSubscriptionNames = Vector.singleton (SubscriptionName "missing")-                        }-            countSentinels store `shouldReturn` 1-            inventoryKeys <$> inventory store-                `shouldReturn` [ ("alpha", 0, 7)-                               , ("alpha", 2, 7)-                               , ("zeta", 1, 7)-                               ]+      result+        `shouldBe` Right+          SubscriptionCheckpointResetReport+            { resetCheckpointKeys =+                Vector.fromList+                  [ SubscriptionCheckpointKey (SubscriptionName "alpha") 0,+                    SubscriptionCheckpointKey (SubscriptionName "alpha") 2,+                    SubscriptionCheckpointKey (SubscriptionName "zeta") 1+                  ],+              missingSubscriptionNames = Vector.singleton (SubscriptionName "missing")+            }+      countSentinels store `shouldReturn` 1+      inventoryKeys <$> inventory store+        `shouldReturn` [ ("alpha", 0, 7),+                         ("alpha", 2, 7),+                         ("zeta", 1, 7)+                       ] -    it "rolls back the reset and an application-table write when the transaction is condemned" $-        withTestStore $ \store -> do-            appendEvents store "reset-rollback-events" 10-            initializeRows store [("rollback", 0)]-            _ <- reset store (SubscriptionName "rollback" :| []) (GlobalPosition 9)-            createSentinelTable store+  it "rolls back the reset and an application-table write when the transaction is condemned" $+    withTestStore $ \store -> do+      appendEvents store "reset-rollback-events" 10+      initializeRows store [("rollback", 0)]+      _ <- reset store (SubscriptionName "rollback" :| []) (GlobalPosition 9)+      createSentinelTable store -            result <- runStoreIO store $ runTransaction $ do-                Tx.statement (2, "rolled back") insertSentinelStmt-                report <--                    resetSubscriptionCheckpointsTx-                        (SubscriptionName "rollback" :| [])-                        (GlobalPosition 3)-                Tx.condemn-                pure report+      result <- runStoreIO store $ runTransaction $ do+        Tx.statement (2, "rolled back") insertSentinelStmt+        report <-+          resetSubscriptionCheckpointsTx+            (SubscriptionName "rollback" :| [])+            (GlobalPosition 3)+        Tx.condemn+        pure report -            result-                `shouldBe` Right-                    SubscriptionCheckpointResetReport-                        { resetCheckpointKeys =-                            Vector.singleton-                                (SubscriptionCheckpointKey (SubscriptionName "rollback") 0)-                        , missingSubscriptionNames = Vector.empty-                        }-            countSentinels store `shouldReturn` 0-            inventoryKeys <$> inventory store `shouldReturn` [("rollback", 0, 9)]+      result+        `shouldBe` Right+          SubscriptionCheckpointResetReport+            { resetCheckpointKeys =+                Vector.singleton+                  (SubscriptionCheckpointKey (SubscriptionName "rollback") 0),+              missingSubscriptionNames = Vector.empty+            }+      countSentinels store `shouldReturn` 0+      inventoryKeys <$> inventory store `shouldReturn` [("rollback", 0, 9)] -    it "can rewind while later ordinary saves remain monotonic" $-        withTestStore $ \store -> do-            appendEvents store "reset-rewind-events" 10-            initializeRows store [("rewind", 0)]-            _ <- reset store (SubscriptionName "rewind" :| []) (GlobalPosition 8)-            _ <- reset store (SubscriptionName "rewind" :| []) (GlobalPosition 4)-            inventoryKeys <$> inventory store `shouldReturn` [("rewind", 0, 4)]+  it "can rewind while later ordinary saves remain monotonic" $+    withTestStore $ \store -> do+      appendEvents store "reset-rewind-events" 10+      initializeRows store [("rewind", 0)]+      _ <- reset store (SubscriptionName "rewind" :| []) (GlobalPosition 8)+      _ <- reset store (SubscriptionName "rewind" :| []) (GlobalPosition 4)+      inventoryKeys <$> inventory store `shouldReturn` [("rewind", 0, 4)] -            saveCheckpoint store "rewind" 0 2-            inventoryKeys <$> inventory store `shouldReturn` [("rewind", 0, 4)]-            saveCheckpoint store "rewind" 0 6-            inventoryKeys <$> inventory store `shouldReturn` [("rewind", 0, 6)]+      saveCheckpoint store "rewind" 0 2+      inventoryKeys <$> inventory store `shouldReturn` [("rewind", 0, 4)]+      saveCheckpoint store "rewind" 0 6+      inventoryKeys <$> inventory store `shouldReturn` [("rewind", 0, 6)] -    it "reports missing names without manufacturing checkpoint rows" $-        withTestStore $ \store -> do-            result <--                reset-                    store-                    (SubscriptionName "absent-b" :| [SubscriptionName "absent-a"])-                    (GlobalPosition 5)-            result-                `shouldBe` SubscriptionCheckpointResetReport-                    { resetCheckpointKeys = Vector.empty-                    , missingSubscriptionNames =-                        Vector.fromList-                            [SubscriptionName "absent-a", SubscriptionName "absent-b"]-                    }-            inventoryKeys <$> inventory store `shouldReturn` []+  it "reports missing names without manufacturing checkpoint rows" $+    withTestStore $ \store -> do+      result <-+        reset+          store+          (SubscriptionName "absent-b" :| [SubscriptionName "absent-a"])+          (GlobalPosition 5)+      result+        `shouldBe` SubscriptionCheckpointResetReport+          { resetCheckpointKeys = Vector.empty,+            missingSubscriptionNames =+              Vector.fromList+                [SubscriptionName "absent-a", SubscriptionName "absent-b"]+          }+      inventoryKeys <$> inventory store `shouldReturn` []  initializeRows :: KirokuStore -> [(Text, Int32)] -> IO () initializeRows store rows =-    mapM_ initialize rows+  mapM_ initialize rows   where     initialize (name, member) = do-        result <--            runStoreIO store $-                initializeSubscriptionCheckpoint-                    (SubscriptionName name)-                    member-                    FromBeginning-        case result of-            Right (Right _) -> pure ()-            other -> expectationFailure ("checkpoint initialization failed: " <> show other)+      result <-+        runStoreIO store $+          initializeSubscriptionCheckpoint+            (SubscriptionName name)+            member+            FromBeginning+      case result of+        Right (Right _) -> pure ()+        other -> expectationFailure ("checkpoint initialization failed: " <> show other)  reset ::-    KirokuStore ->-    NonEmpty SubscriptionName ->-    GlobalPosition ->-    IO SubscriptionCheckpointResetReport+  KirokuStore ->+  NonEmpty SubscriptionName ->+  GlobalPosition ->+  IO SubscriptionCheckpointResetReport reset store names position = do-    result <- runStoreIO store $ runTransaction $ resetSubscriptionCheckpointsTx names position-    case result of-        Left err -> expectationFailure ("checkpoint reset failed: " <> show err) >> error "unreachable"-        Right report -> pure report+  result <- runStoreIO store $ runTransaction $ resetSubscriptionCheckpointsTx names position+  case result of+    Left err -> expectationFailure ("checkpoint reset failed: " <> show err) >> error "unreachable"+    Right report -> pure report  inventory :: KirokuStore -> IO SubscriptionCheckpointInventory inventory store = do-    result <- runStoreIO store subscriptionCheckpointInventory-    case result of-        Left err -> expectationFailure ("checkpoint inventory failed: " <> show err) >> error "unreachable"-        Right rows -> pure rows+  result <- runStoreIO store subscriptionCheckpointInventory+  case result of+    Left err -> expectationFailure ("checkpoint inventory failed: " <> show err) >> error "unreachable"+    Right rows -> pure rows  inventoryKeys :: SubscriptionCheckpointInventory -> [(Text, Int32, Int64)] inventoryKeys (SubscriptionCheckpointInventory _ rows) =-    [ (name, member, position)-    | SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) _ <--        Vector.toList rows-    ]+  [ (name, member, position)+  | SubscriptionCheckpoint (SubscriptionName name) member (GlobalPosition position) _ <-+      Vector.toList rows+  ]  appendEvents :: KirokuStore -> Text -> Int -> IO () appendEvents store stream count = do-    let events =-            [makeEvent ("Reset" <> Text.pack (show i)) (Aeson.object []) | i <- [1 .. count]]-    result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events-    case result of-        Left err -> expectationFailure ("append failed: " <> show err)-        Right _ -> pure ()+  let events =+        [makeEvent ("Reset" <> Text.pack (show i)) (Aeson.object []) | i <- [1 .. count]]+  result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events+  case result of+    Left err -> expectationFailure ("append failed: " <> show err)+    Right _ -> pure ()  saveCheckpoint :: KirokuStore -> Text -> Int32 -> Int64 -> IO () saveCheckpoint store name member position = do-    result <--        Pool.use (store ^. #pool) $-            Session.statement (name, member, position, max 1 (member + 1), "unbound", Nothing) SQL.saveCheckpointMemberStmt-    case result of-        Left err -> expectationFailure ("ordinary checkpoint save failed: " <> show err)-        Right () -> pure ()+  result <-+    Pool.use (store ^. #pool) $+      Session.statement (name, member, position, max 1 (member + 1), "unbound", Nothing) SQL.saveCheckpointMemberStmt+  case result of+    Left err -> expectationFailure ("ordinary checkpoint save failed: " <> show err)+    Right () -> pure ()  createSentinelTable :: KirokuStore -> IO () createSentinelTable store = do-    result <- Pool.use (store ^. #pool) (Session.statement () createSentinelTableStmt)-    case result of-        Left err -> expectationFailure ("sentinel table creation failed: " <> show err)-        Right () -> pure ()+  result <- Pool.use (store ^. #pool) (Session.statement () createSentinelTableStmt)+  case result of+    Left err -> expectationFailure ("sentinel table creation failed: " <> show err)+    Right () -> pure ()  countSentinels :: KirokuStore -> IO Int64 countSentinels store = do-    result <- Pool.use (store ^. #pool) (Session.statement () countSentinelsStmt)-    case result of-        Left err -> expectationFailure ("sentinel count failed: " <> show err) >> error "unreachable"-        Right count -> pure count+  result <- Pool.use (store ^. #pool) (Session.statement () countSentinelsStmt)+  case result of+    Left err -> expectationFailure ("sentinel count failed: " <> show err) >> error "unreachable"+    Right count -> pure count  createSentinelTableStmt :: Statement () () createSentinelTableStmt =-    unpreparable-        "CREATE TABLE public.checkpoint_reset_sentinel \-        \(id BIGINT PRIMARY KEY, value TEXT NOT NULL)"-        E.noParams-        D.noResult+  unpreparable+    "CREATE TABLE public.checkpoint_reset_sentinel \+    \(id BIGINT PRIMARY KEY, value TEXT NOT NULL)"+    E.noParams+    D.noResult  insertSentinelStmt :: Statement (Int64, Text) () insertSentinelStmt =-    preparable-        "INSERT INTO public.checkpoint_reset_sentinel (id, value) VALUES ($1, $2)"-        ( contrazip2-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    "INSERT INTO public.checkpoint_reset_sentinel (id, value) VALUES ($1, $2)"+    ( contrazip2+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.text))+    )+    D.noResult  countSentinelsStmt :: Statement () Int64 countSentinelsStmt =-    preparable-        "SELECT COUNT(*) FROM public.checkpoint_reset_sentinel"-        E.noParams-        (D.singleRow (D.column (D.nonNullable D.int8)))+  preparable+    "SELECT COUNT(*) FROM public.checkpoint_reset_sentinel"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))
test/Test/SubscriptionCheckpointWorker.hs view
@@ -28,224 +28,224 @@  spec :: Spec spec = describe "subscription checkpoint worker policies" $ do-    it "starts a non-group worker FromBeginning and reports the durable seed" $ do-        eventsRef <- newIORef []-        resolutionsRef <- newIORef []-        let name = SubscriptionName "worker-from-beginning"-            observe evt = case evt of-                KirokuEventSubscriptionCheckpointResolved initialization NonGroup ->-                    modifyIORef' resolutionsRef (initialization :)-                _ -> pure ()-            tweak settings = settings & #eventHandler .~ Just observe-        withTestStoreSettings tweak $ \store -> do-            _ <- appendBatch store "worker-from-beginning-events" 3-            waitForPublisher store (GlobalPosition 3)-            let handler event = do-                    modifyIORef' eventsRef (event ^. #globalPosition :)-                    seen <- length <$> readIORef eventsRef-                    pure (if seen >= 3 then Stop else Continue)-                config =-                    (defaultSubscriptionConfig name AllStreams handler)-                        { missingCheckpointPolicy = FromBeginning-                        }-            handle <- subscribe store config-            expectClean handle+  it "starts a non-group worker FromBeginning and reports the durable seed" $ do+    eventsRef <- newIORef []+    resolutionsRef <- newIORef []+    let name = SubscriptionName "worker-from-beginning"+        observe evt = case evt of+          KirokuEventSubscriptionCheckpointResolved initialization NonGroup ->+            modifyIORef' resolutionsRef (initialization :)+          _ -> pure ()+        tweak settings = settings & #eventHandler .~ Just observe+    withTestStoreSettings tweak $ \store -> do+      _ <- appendBatch store "worker-from-beginning-events" 3+      waitForPublisher store (GlobalPosition 3)+      let handler event = do+            modifyIORef' eventsRef (event ^. #globalPosition :)+            seen <- length <$> readIORef eventsRef+            pure (if seen >= 3 then Stop else Continue)+          config =+            (defaultSubscriptionConfig name AllStreams handler)+              { missingCheckpointPolicy = FromBeginning+              }+      handle <- subscribe store config+      expectClean handle -            reverse <$> readIORef eventsRef-                `shouldReturn` fmap GlobalPosition [1, 2, 3]-            inventoryPositions store-                `shouldReturn` [(SubscriptionCheckpointKey name 0, GlobalPosition 3)]+      reverse <$> readIORef eventsRef+        `shouldReturn` fmap GlobalPosition [1, 2, 3]+      inventoryPositions store+        `shouldReturn` [(SubscriptionCheckpointKey name 0, GlobalPosition 3)] -        readIORef resolutionsRef-            `shouldReturn` [InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 0) (GlobalPosition 0)]+    readIORef resolutionsRef+      `shouldReturn` [InitializedCheckpoint FromBeginning (SubscriptionCheckpointKey name 0) (GlobalPosition 0)] -    it "forms a clean FromCurrentHead cut while appends race startup" $ do-        initializationReady <- newEmptyMVar-        deliveredRef <- newIORef []-        let name = SubscriptionName "worker-current-head-race"-            key = SubscriptionCheckpointKey name 0-            observe evt = case evt of-                KirokuEventSubscriptionCheckpointResolved initialization NonGroup-                    | initializationKey initialization == key ->-                        void (tryPutMVar initializationReady initialization)-                _ -> pure ()-            tweak settings = settings & #eventHandler .~ Just observe-        withTestStoreSettings tweak $ \store -> do-            _ <- appendBatch store "worker-current-head-race-events" 10-            waitForPublisher store (GlobalPosition 10)-            gate <- newEmptyMVar-            let handler event = do-                    modifyIORef' deliveredRef (event ^. #globalPosition :)-                    pure $ if event ^. #eventType == EventType "RaceSentinel" then Stop else Continue-                config =-                    (defaultSubscriptionConfig name AllStreams handler)-                        { missingCheckpointPolicy = FromCurrentHead-                        , batchSize = either (error . show) Prelude.id (mkBatchSize 3)-                        }-            subscribeThread <- Async.async (takeMVar gate >> subscribe store config)-            appendThread <- Async.async $ do-                takeMVar gate-                forM [1 .. 20 :: Int] $ \i ->-                    appendExisting store "worker-current-head-race-events" ("Racing" <> T.pack (show i))-            putMVar gate ()-            putMVar gate ()-            handle <- Async.wait subscribeThread-            initialization <- waitMVar "checkpoint resolution" initializationReady-            racePositions <- Async.wait appendThread-            raceTail <- case reverse racePositions of-                [] -> expectationFailure "expected racing appends" >> error "unreachable"-                position : _ -> pure position-            let seed = checkpointInitializationPosition initialization-            waitForPublisher store raceTail-            finalPosition <- appendExisting store "worker-current-head-race-events" "RaceSentinel"-            waitForPublisher store finalPosition-            expectClean handle+  it "forms a clean FromCurrentHead cut while appends race startup" $ do+    initializationReady <- newEmptyMVar+    deliveredRef <- newIORef []+    let name = SubscriptionName "worker-current-head-race"+        key = SubscriptionCheckpointKey name 0+        observe evt = case evt of+          KirokuEventSubscriptionCheckpointResolved initialization NonGroup+            | initializationKey initialization == key ->+                void (tryPutMVar initializationReady initialization)+          _ -> pure ()+        tweak settings = settings & #eventHandler .~ Just observe+    withTestStoreSettings tweak $ \store -> do+      _ <- appendBatch store "worker-current-head-race-events" 10+      waitForPublisher store (GlobalPosition 10)+      gate <- newEmptyMVar+      let handler event = do+            modifyIORef' deliveredRef (event ^. #globalPosition :)+            pure $ if event ^. #eventType == EventType "RaceSentinel" then Stop else Continue+          config =+            (defaultSubscriptionConfig name AllStreams handler)+              { missingCheckpointPolicy = FromCurrentHead,+                batchSize = either (error . show) Prelude.id (mkBatchSize 3)+              }+      subscribeThread <- Async.async (takeMVar gate >> subscribe store config)+      appendThread <- Async.async $ do+        takeMVar gate+        forM [1 .. 20 :: Int] $ \i ->+          appendExisting store "worker-current-head-race-events" ("Racing" <> T.pack (show i))+      putMVar gate ()+      putMVar gate ()+      handle <- Async.wait subscribeThread+      initialization <- waitMVar "checkpoint resolution" initializationReady+      racePositions <- Async.wait appendThread+      raceTail <- case reverse racePositions of+        [] -> expectationFailure "expected racing appends" >> error "unreachable"+        position : _ -> pure position+      let seed = checkpointInitializationPosition initialization+      waitForPublisher store raceTail+      finalPosition <- appendExisting store "worker-current-head-race-events" "RaceSentinel"+      waitForPublisher store finalPosition+      expectClean handle -            initialization `shouldBe` InitializedCheckpoint FromCurrentHead key seed-            delivered <- reverse <$> readIORef deliveredRef-            delivered `shouldBe` positionsAfter seed finalPosition+      initialization `shouldBe` InitializedCheckpoint FromCurrentHead key seed+      delivered <- reverse <$> readIORef deliveredRef+      delivered `shouldBe` positionsAfter seed finalPosition -    it "initializes each consumer-group member independently at the current head" $ do-        resolutionsVar <- newTVarIO []-        handlerCalled <- newTVarIO False-        let name = SubscriptionName "worker-current-head-members"-            observe evt = case evt of-                KirokuEventSubscriptionCheckpointResolved initialization GroupMember{} ->-                    atomically (modifyTVar' resolutionsVar (initialization :))-                _ -> pure ()-            tweak settings = settings & #eventHandler .~ Just observe-        withTestStoreSettings tweak $ \store -> do-            _ <- appendBatch store "worker-current-head-members-events" 6-            waitForPublisher store (GlobalPosition 6)-            let config member =-                    ( defaultSubscriptionConfig name AllStreams $ \_ -> do-                        atomically (writeTVar handlerCalled True)-                        pure Continue-                    )-                        { consumerGroup = Just (validConsumerGroup member 2)-                        , missingCheckpointPolicy = FromCurrentHead-                        }-            handles <- mapM (subscribe store . config) [0, 1]-            atomically $ do-                resolutions <- readTVar resolutionsVar-                check (length resolutions >= 2)-            mapM_ cancel handles-            mapM_ wait handles+  it "initializes each consumer-group member independently at the current head" $ do+    resolutionsVar <- newTVarIO []+    handlerCalled <- newTVarIO False+    let name = SubscriptionName "worker-current-head-members"+        observe evt = case evt of+          KirokuEventSubscriptionCheckpointResolved initialization GroupMember {} ->+            atomically (modifyTVar' resolutionsVar (initialization :))+          _ -> pure ()+        tweak settings = settings & #eventHandler .~ Just observe+    withTestStoreSettings tweak $ \store -> do+      _ <- appendBatch store "worker-current-head-members-events" 6+      waitForPublisher store (GlobalPosition 6)+      let config member =+            ( defaultSubscriptionConfig name AllStreams $ \_ -> do+                atomically (writeTVar handlerCalled True)+                pure Continue+            )+              { consumerGroup = Just (validConsumerGroup member 2),+                missingCheckpointPolicy = FromCurrentHead+              }+      handles <- mapM (subscribe store . config) [0, 1]+      atomically $ do+        resolutions <- readTVar resolutionsVar+        check (length resolutions >= 2)+      mapM_ cancel handles+      mapM_ wait handles -            resolutions <- sortOn initializationKey <$> readTVarIO resolutionsVar-            resolutions-                `shouldBe` [ InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 0) (GlobalPosition 6)-                           , InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 1) (GlobalPosition 6)-                           ]-            readTVarIO handlerCalled `shouldReturn` False-            inventoryPositions store-                `shouldReturn` [ (SubscriptionCheckpointKey name 0, GlobalPosition 6)-                               , (SubscriptionCheckpointKey name 1, GlobalPosition 6)-                               ]+      resolutions <- sortOn initializationKey <$> readTVarIO resolutionsVar+      resolutions+        `shouldBe` [ InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 0) (GlobalPosition 6),+                     InitializedCheckpoint FromCurrentHead (SubscriptionCheckpointKey name 1) (GlobalPosition 6)+                   ]+      readTVarIO handlerCalled `shouldReturn` False+      inventoryPositions store+        `shouldReturn` [ (SubscriptionCheckpointKey name 0, GlobalPosition 6),+                         (SubscriptionCheckpointKey name 1, GlobalPosition 6)+                       ] -    it "preserves FailIfMissing through the bracketed plain-IO entry point" $-        withTestStore $ \store -> do-            let name = SubscriptionName "worker-bracketed-missing"-                key = SubscriptionCheckpointKey name 0-                config =-                    (defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))-                        { missingCheckpointPolicy = FailIfMissing-                        }-            outcome <- withSubscription store config wait-            case outcome of-                Left err-                    | Just (SubscriptionCheckpointMissing actual) <- fromException err ->-                        actual `shouldBe` key-                other -> expectationFailure ("expected bracketed missing refusal, got: " <> show other)+  it "preserves FailIfMissing through the bracketed plain-IO entry point" $+    withTestStore $ \store -> do+      let name = SubscriptionName "worker-bracketed-missing"+          key = SubscriptionCheckpointKey name 0+          config =+            (defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))+              { missingCheckpointPolicy = FailIfMissing+              }+      outcome <- withSubscription store config wait+      case outcome of+        Left err+          | Just (SubscriptionCheckpointMissing actual) <- fromException err ->+              actual `shouldBe` key+        other -> expectationFailure ("expected bracketed missing refusal, got: " <> show other) -    it "preserves FromCurrentHead through the higher-order effect entry point" $ do-        initializationReady <- newEmptyMVar-        deliveredRef <- newIORef []-        let name = SubscriptionName "worker-effect-current-head"-            key = SubscriptionCheckpointKey name 0-            observe evt = case evt of-                KirokuEventSubscriptionCheckpointResolved initialization NonGroup-                    | initializationKey initialization == key ->-                        void (tryPutMVar initializationReady initialization)-                _ -> pure ()-            tweak settings = settings & #eventHandler .~ Just observe-        withTestStoreSettings tweak $ \store -> do-            _ <- appendBatch store "worker-effect-current-head-events" 5-            waitForPublisher store (GlobalPosition 5)-            runEff $ SubEff.runSubscription store $ do-                let config =-                        ( defaultSubscriptionConfig name AllStreams $ \event -> do-                            liftIO (modifyIORef' deliveredRef (event ^. #globalPosition :))-                            pure Stop-                        )-                            { missingCheckpointPolicy = FromCurrentHead-                            }-                handle <- SubEff.subscribe config-                initialization <- liftIO (waitMVar "effect checkpoint resolution" initializationReady)-                liftIO $ initialization `shouldBe` InitializedCheckpoint FromCurrentHead key (GlobalPosition 5)-                position <- liftIO $ appendExisting store "worker-effect-current-head-events" "EffectFuture"-                liftIO (waitForPublisher store position)-                liftIO (expectClean handle)-            reverse <$> readIORef deliveredRef `shouldReturn` [GlobalPosition 6]+  it "preserves FromCurrentHead through the higher-order effect entry point" $ do+    initializationReady <- newEmptyMVar+    deliveredRef <- newIORef []+    let name = SubscriptionName "worker-effect-current-head"+        key = SubscriptionCheckpointKey name 0+        observe evt = case evt of+          KirokuEventSubscriptionCheckpointResolved initialization NonGroup+            | initializationKey initialization == key ->+                void (tryPutMVar initializationReady initialization)+          _ -> pure ()+        tweak settings = settings & #eventHandler .~ Just observe+    withTestStoreSettings tweak $ \store -> do+      _ <- appendBatch store "worker-effect-current-head-events" 5+      waitForPublisher store (GlobalPosition 5)+      runEff $ SubEff.runSubscription store $ do+        let config =+              ( defaultSubscriptionConfig name AllStreams $ \event -> do+                  liftIO (modifyIORef' deliveredRef (event ^. #globalPosition :))+                  pure Stop+              )+                { missingCheckpointPolicy = FromCurrentHead+                }+        handle <- SubEff.subscribe config+        initialization <- liftIO (waitMVar "effect checkpoint resolution" initializationReady)+        liftIO $ initialization `shouldBe` InitializedCheckpoint FromCurrentHead key (GlobalPosition 5)+        position <- liftIO $ appendExisting store "worker-effect-current-head-events" "EffectFuture"+        liftIO (waitForPublisher store position)+        liftIO (expectClean handle)+      reverse <$> readIORef deliveredRef `shouldReturn` [GlobalPosition 6] -    it "preserves FailIfMissing through the Streamly bridge" $-        withTestStore $ \store -> do-            let name = SubscriptionName "worker-streamly-missing"-                key = SubscriptionCheckpointKey name 0-                config =-                    (defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))-                        { missingCheckpointPolicy = FailIfMissing-                        }-            (stream, cancelStream) <- subscriptionAckStream store config (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 1))-            pulled <- finally (try (Stream.uncons stream)) cancelStream-            case pulled of-                Left err-                    | Just (SubscriptionCheckpointMissing actual) <- fromException (err :: SomeException) ->-                        actual `shouldBe` key-                Left err -> expectationFailure ("expected typed Streamly refusal, got: " <> show err)-                Right _ -> expectationFailure "expected Streamly bridge startup to fail"+  it "preserves FailIfMissing through the Streamly bridge" $+    withTestStore $ \store -> do+      let name = SubscriptionName "worker-streamly-missing"+          key = SubscriptionCheckpointKey name 0+          config =+            (defaultSubscriptionConfig name AllStreams (\_ -> pure Continue))+              { missingCheckpointPolicy = FailIfMissing+              }+      (stream, cancelStream) <- subscriptionAckStream store config (either (error . show) Prelude.id (Buffer.mkStreamBufferSize 1))+      pulled <- finally (try (Stream.uncons stream)) cancelStream+      case pulled of+        Left err+          | Just (SubscriptionCheckpointMissing actual) <- fromException (err :: SomeException) ->+              actual `shouldBe` key+        Left err -> expectationFailure ("expected typed Streamly refusal, got: " <> show err)+        Right _ -> expectationFailure "expected Streamly bridge startup to fail"  initializationKey :: CheckpointInitialization -> SubscriptionCheckpointKey initializationKey = \case-    ExistingCheckpoint key _ -> key-    InitializedCheckpoint _ key _ -> key+  ExistingCheckpoint key _ -> key+  InitializedCheckpoint _ key _ -> key  positionsAfter :: GlobalPosition -> GlobalPosition -> [GlobalPosition] positionsAfter (GlobalPosition start) (GlobalPosition end) =-    fmap GlobalPosition [start + 1 .. end]+  fmap GlobalPosition [start + 1 .. end]  inventoryPositions :: KirokuStore -> IO [(SubscriptionCheckpointKey, GlobalPosition)] inventoryPositions store = do-    Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory-    pure-        [ (SubscriptionCheckpointKey name member, position)-        | SubscriptionCheckpoint name member position _ <- V.toList rows-        ]+  Right (SubscriptionCheckpointInventory _ rows) <- runStoreIO store subscriptionCheckpointInventory+  pure+    [ (SubscriptionCheckpointKey name member, position)+    | SubscriptionCheckpoint name member position _ <- V.toList rows+    ]  appendBatch :: KirokuStore -> Text -> Int -> IO GlobalPosition appendBatch store stream count = do-    let events = [makeEvent ("History" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]-    Right result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events-    pure (result ^. #globalPosition)+  let events = [makeEvent ("History" <> T.pack (show i)) (Aeson.object []) | i <- [1 .. count]]+  Right result <- runStoreIO store $ appendToStream (StreamName stream) NoStream events+  pure (result ^. #globalPosition)  appendExisting :: KirokuStore -> Text -> Text -> IO GlobalPosition appendExisting store stream typ = do-    Right result <--        runStoreIO store $-            appendToStream (StreamName stream) StreamExists [makeEvent typ (Aeson.object [])]-    pure (result ^. #globalPosition)+  Right result <-+    runStoreIO store $+      appendToStream (StreamName stream) StreamExists [makeEvent typ (Aeson.object [])]+  pure (result ^. #globalPosition)  expectClean :: SubscriptionHandle -> IO () expectClean handle = do-    result <- waitWithTimeout 15_000_000 handle-    case result of-        Left message -> expectationFailure message-        Right (Left err) -> expectationFailure ("subscription failed: " <> show err)-        Right (Right ()) -> pure ()+  result <- waitWithTimeout 15_000_000 handle+  case result of+    Left message -> expectationFailure message+    Right (Left err) -> expectationFailure ("subscription failed: " <> show err)+    Right (Right ()) -> pure ()  waitMVar :: String -> MVar a -> IO a waitMVar label var = do-    result <- Async.race (threadDelay 10_000_000) (takeMVar var)-    case result of-        Left () -> expectationFailure ("timed out waiting for " <> label) >> error "unreachable"-        Right value -> pure value+  result <- Async.race (threadDelay 10_000_000) (takeMVar var)+  case result of+    Left () -> expectationFailure ("timed out waiting for " <> label) >> error "unreachable"+    Right value -> pure value
test/Test/SubscriptionDeadLetters.hs view
@@ -21,106 +21,109 @@  spec :: Spec spec = describe "SubscriptionDeadLetters" $ do-    it "validates page sizes including both boundaries" $ do-        forM_ [0, -1, 1001] $ \n -> mkSubscriptionDeadLetterLimit n `shouldBe` Left (SubscriptionDeadLetterLimitOutOfRange n)-        forM_ [1, 1000] $ \n -> fmap subscriptionDeadLetterLimitValue (mkSubscriptionDeadLetterLimit n) `shouldBe` Right n-        subscriptionDeadLetterLimitValue defaultSubscriptionDeadLetterLimit `shouldBe` 100-    around withTestStore $ do-        it "returns empty pages through both Store interpreters" $ \store -> do-            let query = defaultSubscriptionDeadLetterQuery (SubscriptionName "absent")-            runStoreIO store (subscriptionDeadLetters query) `shouldReturn` Right (SubscriptionDeadLetterPage V.empty Nothing)-            (runEff . runErrorNoCallStack @StoreError . runKirokuStoreWith store . runStoreResource $ subscriptionDeadLetters query)-                `shouldReturn` Right (SubscriptionDeadLetterPage V.empty Nothing)-        it "pages without omissions, isolates names and matches the internal member read" $ \store -> do-            events <- seedEvents store 7-            mapM_ (insertDeadLetterForEvent store "page") (take 5 events)-            mapM_ (insertDeadLetterForEvent store "other") (drop 5 events)-            let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "page")){limit = validLimit 2}-            first <- page store query-            second <- page store query{after = first ^. #nextCursor}-            third <- page store query{after = second ^. #nextCursor}-            map positions [first, second, third] `shouldBe` [[5, 4], [3, 2], [1]]-            third ^. #nextCursor `shouldBe` Nothing-            Right internal <- Pool.use (store ^. #pool) (Session.statement ("page", 0) SQL.readDeadLettersStmt)-            map SQL.deadLetterGlobalPosition (V.toList internal) `shouldBe` [5, 4, 3, 2, 1]-            forM_ [5, 1000] $ \n -> do-                final <- page store query{limit = validLimit n}-                positions final `shouldBe` [5, 4, 3, 2, 1]-                final ^. #nextCursor `shouldBe` Nothing-        it "merges historical members and resolves same-position ties with an exclusive cursor" $ \store -> do-            [event] <- seedEvents store 1-            forM_ [0, 1, 9] $ \member -> insertDeadLetterWith store "ties" member (object []) "tie" 2 event-            let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "ties")){limit = validLimit 1}-            first <- page store query-            second <- page store query{after = first ^. #nextCursor}-            third <- page store query{after = second ^. #nextCursor}-            map (\p -> map (\row -> row ^. #consumerGroupMember) (V.toList (p ^. #deadLetters))) [first, second, third] `shouldBe` [[9], [1], [0]]-            third ^. #nextCursor `shouldBe` Nothing-            forM_ [0, 1, 9, 7] $ \member -> do-                selected <- page store (query & #consumerGroupMember .~ Just member)-                positions selected `shouldBe` if member == 7 then [] else [1]-        it "preserves arbitrary and decode-failure JSON unchanged" $ \store -> do-            events <- seedEvents store 2-            let reasons = [object ["kind" .= ("other" :: T.Text), "detail" .= object ["code" .= (42 :: Int)]], object ["kind" .= ("decode_failure" :: T.Text), "detail" .= ("bad version" :: T.Text)]]-            forM_ (zip events reasons) $ \(event, reason) -> insertDeadLetterWith store "json" 0 reason "summary" 3 event-            result <- page store (defaultSubscriptionDeadLetterQuery (SubscriptionName "json"))-            map (\row -> row ^. #reason) (V.toList (result ^. #deadLetters)) `shouldBe` reverse reasons-            now <- getCurrentTime-            forM_ (V.toList (result ^. #deadLetters)) $ \row -> do-                row ^. #attemptCount `shouldBe` 3-                row ^. #createdAt `shouldSatisfy` (<= now)-        it "never invokes a configured event decode hook for dead-letter reads" $ \_ ->-            withTestStoreSettings (\settings -> (settings & #storeSettings .~ defaultStoreSettings{decodeHook = Just (\_ -> fail "dead-letter read invoked decode hook")})) $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "hook-1") NoStream [makeEvent "E" (object [])]-                Right () <- Pool.use (store ^. #pool) (Session.script "INSERT INTO dead_letters(subscription_name,global_position,event_id,reason,reason_summary,attempt_count) SELECT 'hook',1,event_id,'{}','fixture',1 FROM events")-                result <- page store (defaultSubscriptionDeadLetterQuery (SubscriptionName "hook"))-                positions result `shouldBe` [1]+  it "validates page sizes including both boundaries" $ do+    forM_ [0, -1, 1001] $ \n -> mkSubscriptionDeadLetterLimit n `shouldBe` Left (SubscriptionDeadLetterLimitOutOfRange n)+    forM_ [1, 1000] $ \n -> fmap subscriptionDeadLetterLimitValue (mkSubscriptionDeadLetterLimit n) `shouldBe` Right n+    subscriptionDeadLetterLimitValue defaultSubscriptionDeadLetterLimit `shouldBe` 100+  around withTestStore $ do+    it "returns empty pages through both Store interpreters" $ \store -> do+      let query = defaultSubscriptionDeadLetterQuery (SubscriptionName "absent")+      runStoreIO store (subscriptionDeadLetters query) `shouldReturn` Right (SubscriptionDeadLetterPage V.empty Nothing)+      (runEff . runErrorNoCallStack @StoreError . runKirokuStoreWith store . runStoreResource $ subscriptionDeadLetters query)+        `shouldReturn` Right (SubscriptionDeadLetterPage V.empty Nothing)+    it "pages without omissions, isolates names and matches the internal member read" $ \store -> do+      events <- seedEvents store 7+      mapM_ (insertDeadLetterForEvent store "page") (take 5 events)+      mapM_ (insertDeadLetterForEvent store "other") (drop 5 events)+      let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "page")) {limit = validLimit 2}+      first <- page store query+      second <- page store query {after = first ^. #nextCursor}+      third <- page store query {after = second ^. #nextCursor}+      map positions [first, second, third] `shouldBe` [[5, 4], [3, 2], [1]]+      third ^. #nextCursor `shouldBe` Nothing+      Right internal <- Pool.use (store ^. #pool) (Session.statement ("page", 0) SQL.readDeadLettersStmt)+      map SQL.deadLetterGlobalPosition (V.toList internal) `shouldBe` [5, 4, 3, 2, 1]+      forM_ [5, 1000] $ \n -> do+        final <- page store query {limit = validLimit n}+        positions final `shouldBe` [5, 4, 3, 2, 1]+        final ^. #nextCursor `shouldBe` Nothing+    it "merges historical members and resolves same-position ties with an exclusive cursor" $ \store -> do+      [event] <- seedEvents store 1+      forM_ [0, 1, 9] $ \member -> insertDeadLetterWith store "ties" member (object []) "tie" 2 event+      let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "ties")) {limit = validLimit 1}+      first <- page store query+      second <- page store query {after = first ^. #nextCursor}+      third <- page store query {after = second ^. #nextCursor}+      map (\p -> map (\row -> row ^. #consumerGroupMember) (V.toList (p ^. #deadLetters))) [first, second, third] `shouldBe` [[9], [1], [0]]+      third ^. #nextCursor `shouldBe` Nothing+      forM_ [0, 1, 9, 7] $ \member -> do+        selected <- page store (query & #consumerGroupMember .~ Just member)+        positions selected `shouldBe` if member == 7 then [] else [1]+    it "preserves arbitrary and decode-failure JSON unchanged" $ \store -> do+      events <- seedEvents store 2+      let reasons = [object ["kind" .= ("other" :: T.Text), "detail" .= object ["code" .= (42 :: Int)]], object ["kind" .= ("decode_failure" :: T.Text), "detail" .= ("bad version" :: T.Text)]]+      forM_ (zip events reasons) $ \(event, reason) -> insertDeadLetterWith store "json" 0 reason "summary" 3 event+      result <- page store (defaultSubscriptionDeadLetterQuery (SubscriptionName "json"))+      map (\row -> row ^. #reason) (V.toList (result ^. #deadLetters)) `shouldBe` reverse reasons+      now <- getCurrentTime+      forM_ (V.toList (result ^. #deadLetters)) $ \row -> do+        row ^. #attemptCount `shouldBe` 3+        row ^. #createdAt `shouldSatisfy` (<= now)+    it "never invokes a configured event decode hook for dead-letter reads" $ \_ ->+      withTestStoreSettings (\settings -> (settings & #storeSettings .~ defaultStoreSettings {decodeHook = Just (\_ -> fail "dead-letter read invoked decode hook")})) $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "hook-1") NoStream [makeEvent "E" (object [])]+        Right () <- Pool.use (store ^. #pool) (Session.script "INSERT INTO dead_letters(subscription_name,global_position,event_id,reason,reason_summary,attempt_count) SELECT 'hook',1,event_id,'{}','fixture',1 FROM events")+        result <- page store (defaultSubscriptionDeadLetterQuery (SubscriptionName "hook"))+        positions result `shouldBe` [1] -        it "keeps a cursor valid after its event and row are hard-deleted" $ \store -> do-            events <- seedEvents store 3-            mapM_ (insertDeadLetterForEvent store "delete") events-            let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "delete")){limit = validLimit 1}-            first <- page store query-            Right _ <- runStoreIO store $ hardDeleteStream (StreamName "dead-3")-            older <- page store query{after = first ^. #nextCursor, limit = validLimit 10}-            positions older `shouldBe` [2, 1]-        it "includes legal maxBound pairs on first pages" $ \store -> do-            [event] <- seedEvents store 1-            insertDeadLetterForEvent store "boundary" event-            Right () <- Pool.use (store ^. #pool) (Session.script "UPDATE dead_letters SET global_position=9223372036854775807, dead_letter_id=9223372036854775807 WHERE subscription_name='boundary'")-            let query = defaultSubscriptionDeadLetterQuery (SubscriptionName "boundary")-            forM_ [Nothing, Just 0] $ \member -> do-                result <- page store (query & #consumerGroupMember .~ member)-                positions result `shouldBe` [maxBound]-                older <- page store (query & #consumerGroupMember .~ member & #after .~ Just (SubscriptionDeadLetterCursor (GlobalPosition maxBound) maxBound))-                positions older `shouldBe` []-        it "reads a real worker-produced poison reason without changing its checkpoint" $ \store -> do-            events <- seedEvents store 3-            waitForPublisher store (GlobalPosition 3)-            handle <- subscribe store $ defaultSubscriptionConfig (SubscriptionName "worker") AllStreams $ \event -> pure $ case event ^. #globalPosition of-                GlobalPosition 2 -> DeadLetter (DeadLetterPoison "boom")-                GlobalPosition 3 -> Stop-                _ -> Continue-            waitWithTimeout 10_000_000 handle >>= \case-                Right (Right ()) -> pure ()-                other -> expectationFailure (show other)-            result <- page store (defaultSubscriptionDeadLetterQuery (SubscriptionName "worker"))-            case V.toList (result ^. #deadLetters) of-                [row] -> do-                    row ^. #eventId `shouldBe` (events !! 1) ^. #eventId-                    row ^. #reason `shouldBe` object ["kind" .= ("poison" :: T.Text), "detail" .= ("boom" :: T.Text)]-                    row ^. #reasonSummary `shouldBe` "poison: boom"-                _ -> expectationFailure (show result)-            Pool.use (store ^. #pool) (Session.statement ("worker", 0) SQL.getCheckpointMemberStmt) `shouldReturn` Right (Just 3)+    it "keeps a cursor valid after its event and row are hard-deleted" $ \store -> do+      events <- seedEvents store 3+      mapM_ (insertDeadLetterForEvent store "delete") events+      let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "delete")) {limit = validLimit 1}+      first <- page store query+      Right _ <- runStoreIO store $ hardDeleteStream (StreamName "dead-3")+      older <- page store query {after = first ^. #nextCursor, limit = validLimit 10}+      positions older `shouldBe` [2, 1]+    it "includes legal maxBound pairs on first pages" $ \store -> do+      [event] <- seedEvents store 1+      insertDeadLetterForEvent store "boundary" event+      Right () <- Pool.use (store ^. #pool) (Session.script "UPDATE dead_letters SET global_position=9223372036854775807, dead_letter_id=9223372036854775807 WHERE subscription_name='boundary'")+      let query = defaultSubscriptionDeadLetterQuery (SubscriptionName "boundary")+      forM_ [Nothing, Just 0] $ \member -> do+        result <- page store (query & #consumerGroupMember .~ member)+        positions result `shouldBe` [maxBound]+        older <- page store (query & #consumerGroupMember .~ member & #after .~ Just (SubscriptionDeadLetterCursor (GlobalPosition maxBound) maxBound))+        positions older `shouldBe` []+    it "reads a real worker-produced poison reason without changing its checkpoint" $ \store -> do+      events <- seedEvents store 3+      waitForPublisher store (GlobalPosition 3)+      handle <- subscribe store $ defaultSubscriptionConfig (SubscriptionName "worker") AllStreams $ \event -> pure $ case event ^. #globalPosition of+        GlobalPosition 2 -> DeadLetter (DeadLetterPoison "boom")+        GlobalPosition 3 -> Stop+        _ -> Continue+      waitWithTimeout 10_000_000 handle >>= \case+        Right (Right ()) -> pure ()+        other -> expectationFailure (show other)+      result <- page store (defaultSubscriptionDeadLetterQuery (SubscriptionName "worker"))+      case V.toList (result ^. #deadLetters) of+        [row] -> do+          row ^. #eventId `shouldBe` (events !! 1) ^. #eventId+          row ^. #reason `shouldBe` object ["kind" .= ("poison" :: T.Text), "detail" .= ("boom" :: T.Text)]+          row ^. #reasonSummary `shouldBe` "poison: boom"+        _ -> expectationFailure (show result)+      Pool.use (store ^. #pool) (Session.statement ("worker", 0) SQL.getCheckpointMemberStmt) `shouldReturn` Right (Just 3)  validLimit :: Int32 -> SubscriptionDeadLetterLimit validLimit = either (error . show) (\value -> value) . mkSubscriptionDeadLetterLimit+ page :: KirokuStore -> SubscriptionDeadLetterQuery -> IO SubscriptionDeadLetterPage page store query = runStoreIO store (subscriptionDeadLetters query) >>= either (fail . show) pure+ positions :: SubscriptionDeadLetterPage -> [Int64] positions result = map (\row -> let GlobalPosition p = row ^. #globalPosition in p) (V.toList (result ^. #deadLetters))+ seedEvents :: KirokuStore -> Int -> IO [RecordedEvent] seedEvents store count = do-    forM_ [1 .. count] $ \n -> void $ runStoreIO store $ appendToStream (StreamName ("dead-" <> T.pack (show n))) NoStream [makeEvent "E" (object [])]-    Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) (fromIntegral count)-    pure (V.toList events)+  forM_ [1 .. count] $ \n -> void $ runStoreIO store $ appendToStream (StreamName ("dead-" <> T.pack (show n))) NoStream [makeEvent "E" (object [])]+  Right events <- runStoreIO store $ readAllForward (GlobalPosition 0) (fromIntegral count)+  pure (V.toList events)
test/Test/SubscriptionDeadLettersMock.hs view
@@ -10,14 +10,14 @@  spec :: Spec spec = describe "SubscriptionDeadLetters mock interpreter" $ it "dispatches the public wrapper once with the entire query and returns the interpreter page" $ do-    calls <- newIORef (0 :: Int)-    let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "mock")){consumerGroupMember = Just 9, after = Just (SubscriptionDeadLetterCursor (GlobalPosition 21) 7)}-        expected = SubscriptionDeadLetterPage V.empty (Just (SubscriptionDeadLetterCursor (GlobalPosition 10) 3))-        runner = interpret_ $ \case-            ListSubscriptionDeadLetters actual -> do-                liftIO $ actual `shouldBe` query-                liftIO $ modifyIORef' calls (+ 1)-                pure expected-            _ -> error "unexpected Store operation"-    runEff (runner (subscriptionDeadLetters query)) `shouldReturn` expected-    readIORef calls `shouldReturn` 1+  calls <- newIORef (0 :: Int)+  let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "mock")) {consumerGroupMember = Just 9, after = Just (SubscriptionDeadLetterCursor (GlobalPosition 21) 7)}+      expected = SubscriptionDeadLetterPage V.empty (Just (SubscriptionDeadLetterCursor (GlobalPosition 10) 3))+      runner = interpret_ $ \case+        ListSubscriptionDeadLetters actual -> do+          liftIO $ actual `shouldBe` query+          liftIO $ modifyIORef' calls (+ 1)+          pure expected+        _ -> error "unexpected Store operation"+  runEff (runner (subscriptionDeadLetters query)) `shouldReturn` expected+  readIORef calls `shouldReturn` 1
+ test/Test/SubscriptionNamespace.hs view
@@ -0,0 +1,375 @@+module Test.SubscriptionNamespace (spec) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Concurrent.STM (atomically, newTVarIO, readTVarIO)+import Control.Exception (bracket, fromException)+import Control.Lens ((^.))+import Control.Monad (forM_, replicateM_, when)+import Data.Aeson qualified as Aeson+import Data.Generics.Labels ()+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)+import Data.Int (Int32, Int64)+import Data.List (sort)+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Data.Text qualified as T+import Data.Vector qualified as V+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Hasql.Statement (preparable)+import Hasql.Transaction qualified as Transaction+import Hasql.Transaction.Sessions qualified as Tx+import Kiroku.Store+import Kiroku.Store.Notification qualified as Notify+import Kiroku.Store.SQL qualified as SQL+import Kiroku.Store.Subscription.Fsm qualified as Fsm+import Kiroku.Store.Subscription.Worker (withNamespaceSafetyPollForTest, withNamespaceWindowHookForTest, withSaveCheckpointHookForTest)+import System.Timeout (timeout)+import Test.Helpers+import Test.Hspec++spec :: Spec+spec = describe "namespace subscriptions" $ do+  forM_ [1, 3] $ \width ->+    it ("catches up across empty windows, includes future categories, and restarts (W=" <> show width <> ")") $+      withTestStore $ \store -> do+        replicateM_ 9 (append store "noise-1")+        append store "pm-1"+        append store "pm:fulfillment-1"+        append store "pm:billing-1"+        append store "orders-1"+        seen <- newIORef []+        let config = (cfg "restart" "pm" (record seen)) {batchSize = batch width}+        withSubscription store config $ \handle -> do+          awaitLive handle+          positions seen `shouldReturn` [10, 11, 12]+          checkpoint store "restart" 0 `shouldReturn` Just 13+          append store "pm:new-1"+          await (positions seen) (== [10, 11, 12, 14])+          await (checkpoint store "restart" 0) (== Just 14)+        Map.null <$> readTVarIO (Notify.namespaceRegistry (store ^. #notifier)) `shouldReturn` True+        restarted <- subscribe store ((cfg "restart" "pm" (\event -> record seen event >> pure Stop)) {batchSize = batch width})+        append store "pm:newer-1"+        clean restarted+        positions seen `shouldReturn` [10, 11, 12, 14, 15]+        binding store "restart" `shouldReturn` ("category_namespace", Just "pm")++  forM_+    [ ("pm", ["pm", "pm:a", "pm:a:b"], ["pmm", "pmish", "orders"]),+      ("pm:a", ["pm:a", "pm:a:b"], ["pm", "pm:ab"]),+      ("%_\\,雪", ["%_\\,雪", "%_\\,雪:子"], ["xx雪", "%X\\,雪"]),+      ("", ["", ":", ":a"], ["pm", "orders"]),+      ("pm:", ["pm:", "pm::child"], ["pm", "pm:child"]),+      ("pm-x", [], ["pm", "pm:x", "pm-x"])+    ]+    $ \(namespace, included, excluded) ->+      it ("matches literal category boundaries for " <> show namespace) $+        withTestStore $ \store -> do+          forM_ (included <> excluded) $ \category -> append store (category <> "-1")+          seen <- newIORef []+          withSubscription store (cfg "literal" namespace (record seen)) $ \handle -> do+            awaitLive handle+            positions seen `shouldReturn` [1 .. fromIntegral (length included)]++  it "keeps Stop at the stopping event, then replays the rest of a prefetched window" $+    withTestStore $ \store -> do+      forM_ ["pm-1", "pm:a-1", "pm:b-1", "noise-1"] (append store)+      first <- subscribe store (cfg "stop" "pm" (\_ -> pure Stop))+      clean first+      checkpoint store "stop" 0 `shouldReturn` Just 1+      seen <- newIORef []+      withSubscription store (cfg "stop" "pm" (record seen)) $ \handle -> do+        awaitLive handle+        positions seen `shouldReturn` [2, 3]+        checkpoint store "stop" 0 `shouldReturn` Just 4++  it "does not publish speculative completion while a handler is blocked or cancelled" $+    withTestStore $ \store -> do+      append store "pm-1"+      append store "noise-1"+      entered <- newEmptyMVar+      blocked <- newEmptyMVar+      withSubscription store (cfg "cancel" "pm" (\_ -> putMVar entered () >> takeMVar blocked >> pure Continue)) $ \handle -> do+        bounded (takeMVar entered)+        checkpoint store "cancel" 0 `shouldReturn` Just 0+        currentState handle >>= \case+          Just (Fsm.CatchingUp (GlobalPosition 0) _) -> pure ()+          other -> expectationFailure (show other)+      seen <- newIORef []+      withSubscription store (cfg "cancel" "pm" (record seen)) $ \handle -> do+        awaitLive handle+        positions seen `shouldReturn` [1]+        checkpoint store "cancel" 0 `shouldReturn` Just 2++  it "resolves Retry, DeadLetter, filtering and completed trailing rows with one tail save" $+    withTestStore $ \store -> do+      append store "pm-1"+      append store "pm:a-1"+      append store "noise-1"+      attempts <- newIORef (0 :: Int)+      saves <- newIORef []+      let handler' event+            | event ^. #globalPosition == GlobalPosition 1 = do+                attempt <- atomicModifyIORef' attempts (\n -> (n + 1, n))+                pure (if attempt == 0 then Retry (RetryDelay 0) else Continue)+            | otherwise = pure (DeadLetter (DeadLetterPoison "test"))+      withSaveCheckpointHookForTest (\_ p -> atomicModifyIORef' saves (\ps -> (p : ps, ()))) $+        withSubscription store (cfg "dispositions" "pm" handler') awaitLive+      readIORef attempts `shouldReturn` 2+      readIORef saves `shouldReturn` [GlobalPosition 3]+      checkpoint store "dispositions" 0 `shouldReturn` Just 3+      binding store "dispositions" `shouldReturn` ("category_namespace", Just "pm")+      let filtered = (cfg "filter" "pm" (\_ -> expectationFailure "filtered handler ran" >> pure Continue)) {selector = Just (const False)}+      withSubscription store filtered awaitLive+      checkpoint store "filter" 0 `shouldReturn` Just 3++  it "coalesces empty-window saves at 32 windows and the head" $+    withTestStore $ \store -> do+      replicateM_ 70 (append store "noise-1")+      saves <- newIORef []+      withSaveCheckpointHookForTest (\_ p -> atomicModifyIORef' saves (\ps -> (ps <> [p], ()))) $+        withSubscription store ((cfg "coalesce" "pm" (\_ -> pure Continue)) {batchSize = batch 1}) awaitLive+      readIORef saves `shouldReturn` map GlobalPosition [32, 64, 70]++  it "does not mistake hard-deleted tail gaps or an empty window for pending delivery" $+    withTestStore $ \store -> do+      append store "pm-1"+      append store "gone-1"+      Right _ <- runStoreIO store (hardDeleteStream (StreamName "gone-1"))+      seen <- newIORef []+      withSubscription store ((cfg "gaps" "pm" (record seen)) {batchSize = batch 1}) awaitLive+      positions seen `shouldReturn` [1]+      checkpoint store "gaps" 0 `shouldReturn` Just 2++  it "partitions plain and grouped namespaces completely with per-member empty windows" $+    withTestStore $ \store -> do+      forM_ [1 .. 25 :: Int] $ \n -> do+        append store "noise-1"+        append store ("pm:" <> T.pack (show n) <> "-1")+      members <- mapM (const (newIORef [])) [0 .. 2 :: Int32]+      forM_ (zip [0 ..] members) $ \(index, seen) ->+        withSubscription store ((cfg "group" "pm" (record seen)) {consumerGroup = Just (validConsumerGroup index 3), batchSize = batch 1}) $ \handle -> do+          awaitLive handle+          checkpoint store "group" index `shouldReturn` Just 50+      delivered <- concat <$> mapM positions members+      sort delivered `shouldBe` [2, 4 .. 50]++  it "retries catch-up and live fetch failures without skipping matching rows" $+    withTestStore $ \store -> do+      append store "pm-1"+      remaining <- newIORef True+      seen <- newIORef []+      let inject _ _ _ = do+            failNow <- atomicModifyIORef' remaining (\b -> (False, b))+            pure (if failNow then Just (Left Pool.AcquisitionTimeoutUsageError) else Nothing)+      withNamespaceWindowHookForTest inject $+        withSubscription store (cfg "fetch-fault" "pm" (record seen)) $ \handle -> do+          awaitLive handle+          writeIORef remaining True+          append store "pm:new-1"+          await (positions seen) (== [1, 2])+          await (checkpoint store "fetch-fault" 0) (== Just 2)++  it "replays after ordinary save failure and never persists an unfinished atomic dead letter" $+    withTestStore $ \store -> do+      append store "pm-1"+      append store "noise-1"+      -- A database trigger injects actual ordinary/atomic SQL failures, rather+      -- than throwing from a seam before attempting a save.+      sql store "CREATE FUNCTION fail_namespace_save() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.last_seen > 0 THEN RAISE EXCEPTION 'injected save failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER fail_namespace_save BEFORE UPDATE ON subscriptions FOR EACH ROW EXECUTE FUNCTION fail_namespace_save()"+      withSubscription store (cfg "save-fault" "pm" (\_ -> pure Continue)) awaitLive+      checkpoint store "save-fault" 0 `shouldReturn` Just 0+      failed <- subscribe store (cfg "atomic-fault" "pm" (\_ -> pure (DeadLetter (DeadLetterPoison "fail"))))+      waitWithTimeout 5_000_000 failed >>= \case+        Right (Left _) -> pure ()+        other -> expectationFailure (show other)+      checkpoint store "atomic-fault" 0 `shouldReturn` Just 0+      sql store "DROP TRIGGER fail_namespace_save ON subscriptions"+      seen <- newIORef []+      withSubscription store (cfg "save-fault" "pm" (record seen)) awaitLive+      positions seen `shouldReturn` [1]+      checkpoint store "save-fault" 0 `shouldReturn` Just 2++  it "preserves namespace identity through rebind, resize, adoption and strict startup" $+    withTestStore $ \store -> do+      withSubscription store (cfg "binding" "pm" (\_ -> pure Continue)) awaitLive+      mismatch <- subscribe store (defaultSubscriptionConfig (SubscriptionName "binding") (Category (CategoryName "pm")) (\_ -> pure Stop))+      waitWithTimeout 5_000_000 mismatch >>= \case+        Right (Left e) -> (fromException e :: Maybe SubscriptionTargetMismatch) `shouldSatisfy` maybe False (const True)+        other -> expectationFailure (show other)+      let tx :: Transaction.Transaction a -> IO a+          tx action = Pool.use (store ^. #pool) (Tx.transaction Tx.ReadCommitted Tx.Write action) >>= either (fail . show) pure+      report <- tx (rebindSubscriptionTargetTx (SubscriptionName "binding") (CategoryNamespace "pm:a") (GlobalPosition 0))+      previousBindings report `shouldBe` V.singleton (Just (CategoryNamespace "pm"))+      _ <- tx (resizeConsumerGroupTx (SubscriptionName "binding") (either (error . show) Prelude.id (mkConsumerGroupSize 2)))+      withSubscription store ((cfg "binding" "pm:a" (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 1 2), targetBindingPolicy = RequireBound}) awaitLive+      _ <- tx (resizeConsumerGroupTx (SubscriptionName "adopt") (either (error . show) Prelude.id (mkConsumerGroupSize 2)))+      withSubscription store ((cfg "adopt" "pm" (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 0 2)}) awaitLive+      withSubscription store ((cfg "adopt" "pm" (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 1 2), targetBindingPolicy = RequireBound}) awaitLive++  it "routes only exact/ancestor namespace cells and releases shared registrations idempotently" $ do+    registry <- newTVarIO Map.empty+    (pm, release1) <- atomically (Notify.registerNamespace registry "pm")+    (same, release2) <- atomically (Notify.registerNamespace registry "pm")+    (nested, release3) <- atomically (Notify.registerNamespace registry "pm:a")+    (empty, release4) <- atomically (Notify.registerNamespace registry "")+    (colon, release5) <- atomically (Notify.registerNamespace registry "pm:")+    (pm == same) `shouldBe` True+    atomically (Notify.notifyNamespace registry "pm:a:b")+    mapM readTVarIO [pm, nested, empty, colon] `shouldReturn` [1, 1, 0, 0]+    atomically (Notify.notifyNamespace registry "pm::child" >> Notify.notifyNamespace registry ":child")+    mapM readTVarIO [pm, nested, empty, colon] `shouldReturn` [2, 1, 1, 1]+    atomically (release1 >> release1)+    Map.size <$> readTVarIO registry `shouldReturn` 4+    atomically (release2 >> release3 >> release4 >> release5)+    Map.null <$> readTVarIO registry `shouldReturn` True+    (replacement, release6) <- atomically (Notify.registerNamespace registry "pm")+    atomically (release2 >> Notify.notifyNamespace registry "pm")+    readTVarIO replacement `shouldReturn` 1+    atomically release6+    -- Empty registry path must not even evaluate a category.+    atomically (Notify.notifyNamespace registry (error "empty registry evaluated category"))++  it "suppresses unrelated live fetches and recovers a lost notification at the safety tick" $+    withNamespaceSafetyPollForTest 200_000 $+      withTestStore $ \store -> do+        fetches <- newIORef (0 :: Int)+        seen <- newIORef []+        withNamespaceWindowHookForTest (\_ _ _ -> atomicModifyIORef' fetches (\n -> (n + 1, Nothing))) $+          withSubscription store (cfg "idle" "pm" (record seen)) $ \handle -> do+            awaitLive handle+            append store "noise-1"+            -- Wait for the actual listener to process the unrelated notification.+            await (readTVarIO (Notify.categoryGenerations (store ^. #notifier))) (Map.member "noise")+            readIORef fetches `shouldReturn` 0+            await (readIORef fetches) (> 0)+            -- Temporarily remove the sole registry entry to simulate a lost wake.+            sql store "ALTER TABLE streams DISABLE TRIGGER stream_events_notify_insert; ALTER TABLE streams DISABLE TRIGGER stream_events_notify_update"+            append store "pm:new-1"+            sql store "ALTER TABLE streams ENABLE TRIGGER stream_events_notify_insert; ALTER TABLE streams ENABLE TRIGGER stream_events_notify_update"+            await (positions seen) (== [2])++  it "keeps a captured head finite and does not lose a notification during its drain" $+    withTestStore $ \store -> do+      append store "pm-1"+      captured <- newIORef False+      seen <- newIORef []+      let inject _ _ headPosition = do+            first <- atomicModifyIORef' captured (\done -> (True, not done))+            when first $ do+              headPosition `shouldBe` GlobalPosition 1+              append store "pm:new-1"+              await (readTVarIO (Notify.categoryGenerations (store ^. #notifier))) (Map.member "pm:new")+            pure Nothing+      withNamespaceWindowHookForTest inject $+        withSubscription store ((cfg "race" "pm" (record seen)) {batchSize = batch 1}) $ \handle -> do+          awaitLive handle+          await (positions seen) (== [1, 2])+          await (checkpoint store "race" 0) (== Just 2)++  it "never advances past an unhandled decode failure, and ignores unrelated decode failures" $ do+    let reject event = pure (Left (DecodeFailure (event ^. #eventId) "test refusal"))+        broken :: ConnectionSettings -> ConnectionSettings+        broken settings = settings {storeSettings = defaultStoreSettings {decodeHook = Just reject}}+    withTestStoreSettings broken $ \store -> do+      append store "noise-1"+      withSubscription store (cfg "unrelated-decode" "pm" (\_ -> expectationFailure "unrelated handler" >> pure Stop)) awaitLive+      checkpoint store "unrelated-decode" 0 `shouldReturn` Just 1+      append store "pm-1"+      append store "noise-1"+      handle <- subscribe store ((cfg "decode" "pm" (\_ -> expectationFailure "undecodable handler" >> pure Stop)) {retryPolicy = RetryPolicy 1})+      waitWithTimeout 5_000_000 handle >>= \case+        Right (Left err) -> (fromException err :: Maybe SubscriptionUndecodable) `shouldSatisfy` maybe False (const True)+        other -> expectationFailure (show other)+      checkpoint store "decode" 0 `shouldReturn` Just 0+      -- The alternate callback resolves only matching undecodable events.+      withSubscription store ((cfg "decode" "pm" (\_ -> pure Stop)) {undecodableHandler = Just (\_ _ -> pure Continue)}) awaitLive+      checkpoint store "decode" 0 `shouldReturn` Just 3++  it "shares live namespace wakes across grouped members and reconnects the listener" $+    withNamespaceSafetyPollForTest 100_000 $+      withTestStore $ \store -> do+        members <- mapM (const (newIORef [])) [0 .. 2 :: Int32]+        let configs = [(cfg "live-group" "pm" (record seen)) {consumerGroup = Just (validConsumerGroup index 3), batchSize = batch 1} | (index, seen) <- zip [0 ..] members]+        bracket (mapM (subscribe store) configs) (mapM_ cancel) $ \handles -> do+          mapM_ awaitLive handles+          forM_ [1 .. 6 :: Int] $ \n -> append store ("pm:new:" <> T.pack (show n) <> "-1")+          await (concat <$> mapM positions members) ((== [1 .. 6]) . sort)+          pid <- waitForListenerPid (store ^. #pool) 5_000_000+          terminateBackend (store ^. #pool) pid+          append store "pm:reconnect-1"+          await (concat <$> mapM positions members) ((== [1 .. 7]) . sort)+          _ <- waitForListenerPidNotEqual (store ^. #pool) pid 5_000_000+          pure ()+        Map.null <$> readTVarIO (Notify.namespaceRegistry (store ^. #notifier)) `shouldReturn` True++  it "retains a failed empty frontier and retries its flush without another append" $+    withNamespaceSafetyPollForTest 50_000 $+      withTestStore $ \store -> do+        append store "noise-1"+        sql store "CREATE FUNCTION fail_empty_save() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected flush failure'; END $$; CREATE TRIGGER fail_empty_save BEFORE UPDATE ON subscriptions FOR EACH ROW EXECUTE FUNCTION fail_empty_save()"+        withSubscription store (cfg "flush-fault" "pm" (\_ -> pure Continue)) $ \handle -> do+          awaitLive handle+          checkpoint store "flush-fault" 0 `shouldReturn` Just 0+          sql store "DROP TRIGGER fail_empty_save ON subscriptions"+          await (checkpoint store "flush-fault" 0) (== Just 1)++  it "releases namespace registrations when startup fails" $+    withTestStore $ \store -> do+      handle <- subscribe store ((cfg "missing" "pm" (\_ -> pure Continue)) {missingCheckpointPolicy = FailIfMissing})+      waitWithTimeout 5_000_000 handle >>= \case+        Right (Left _) -> pure ()+        other -> expectationFailure (show other)+      Map.null <$> readTVarIO (Notify.namespaceRegistry (store ^. #notifier)) `shouldReturn` True++cfg :: Text -> Text -> (RecordedEvent -> IO SubscriptionResult) -> SubscriptionConfig+cfg name namespace = defaultSubscriptionConfig (SubscriptionName name) (CategoryNamespace namespace)++batch :: Int32 -> BatchSize+batch = either (error . show) Prelude.id . mkBatchSize++record :: IORef [Int64] -> RecordedEvent -> IO SubscriptionResult+record ref event = do+  let GlobalPosition pos = event ^. #globalPosition+  atomicModifyIORef' ref (\xs -> (xs <> [pos], ()))+  pure Continue++positions :: IORef [Int64] -> IO [Int64]+positions = readIORef++append :: KirokuStore -> Text -> IO ()+append store stream = do+  Right _ <- runStoreIO store (appendToStream (StreamName stream) AnyVersion [makeEvent "E" (Aeson.object [])])+  pure ()++await :: IO a -> (a -> Bool) -> IO ()+await action predicate = bounded go+  where+    go = action >>= \value -> if predicate value then pure () else threadDelay 1000 >> go++bounded :: IO a -> IO a+bounded action = timeout 5_000_000 action >>= maybe (fail "namespace test timed out") pure++awaitLive :: SubscriptionHandle -> IO ()+awaitLive handle = await (currentState handle) (\case Just Fsm.Live {} -> True; _ -> False)++clean :: SubscriptionHandle -> IO ()+clean handle =+  waitWithTimeout 5_000_000 handle >>= \case+    Right (Right ()) -> pure ()+    other -> expectationFailure (show other)++checkpoint :: KirokuStore -> Text -> Int32 -> IO (Maybe Int64)+checkpoint store name index = session store (Session.statement (name, index) SQL.getCheckpointMemberStmt)++binding :: KirokuStore -> Text -> IO (Text, Maybe Text)+binding store name = session store (Session.statement name (preparable "SELECT target_kind,target_category FROM subscriptions WHERE subscription_name=$1 AND consumer_group_member=0" (E.param (E.nonNullable E.text)) (D.singleRow ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text)))))++sql :: KirokuStore -> Text -> IO ()+sql store = session store . Session.script++session :: KirokuStore -> Session.Session a -> IO a+session store action = Pool.use (store ^. #pool) action >>= either (fail . show) pure
test/Test/SubscriptionPauseResume.hs view
@@ -1,33 +1,32 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Regression tests for ExecPlan 41 M2 — recoverable backpressure.--Before M2, a slow AllStreams consumer that filled its bounded queue was-__terminal__: the publisher marked it @Overflowed@ and the worker threw-'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' and died (overflow-policy 'Kiroku.Store.Subscription.Types.DropSubscription'). M2 adds the-recoverable 'Kiroku.Store.Subscription.Types.PauseAndResume' policy (now the-default): when the queue fills the publisher /pauses/ delivery rather than-killing the subscriber, and the worker — once its slow handler catches up —-drains the stale queue and re-reads the events it missed directly from the-database from its checkpoint. No event is lost and the checkpoint advances-monotonically.--The first spec proves the recovery: a handler that blocks long enough to fill a-@queueCapacity = 1@ subscriber while five events are appended still delivers all-five in order, stops cleanly (not via 'SubscriptionOverflowed'), advances the-checkpoint to the last position, and emits a-'Kiroku.Store.Observability.KirokuEventSubscriptionPaused' followed by a-'Kiroku.Store.Observability.KirokuEventSubscriptionResumed'.--The second spec proves the fail-fast path is preserved: the identical scenario-under 'DropSubscription' still surfaces 'SubscriptionOverflowed' on @wait@.--To confirm the first spec actually pins the new behavior: temporarily set its-@overflowPolicy@ to 'DropSubscription' and it fails with-@Left (SubscriptionOverflowed ...)@ where it expected @Right ()@.--}+-- | Regression tests for ExecPlan 41 M2 — recoverable backpressure.+--+-- Before M2, a slow AllStreams consumer that filled its bounded queue was+-- __terminal__: the publisher marked it @Overflowed@ and the worker threw+-- 'Kiroku.Store.Subscription.Types.SubscriptionOverflowed' and died (overflow+-- policy 'Kiroku.Store.Subscription.Types.DropSubscription'). M2 adds the+-- recoverable 'Kiroku.Store.Subscription.Types.PauseAndResume' policy (now the+-- default): when the queue fills the publisher /pauses/ delivery rather than+-- killing the subscriber, and the worker — once its slow handler catches up —+-- drains the stale queue and re-reads the events it missed directly from the+-- database from its checkpoint. No event is lost and the checkpoint advances+-- monotonically.+--+-- The first spec proves the recovery: a handler that blocks long enough to fill a+-- @queueCapacity = 1@ subscriber while five events are appended still delivers all+-- five in order, stops cleanly (not via 'SubscriptionOverflowed'), advances the+-- checkpoint to the last position, and emits a+-- 'Kiroku.Store.Observability.KirokuEventSubscriptionPaused' followed by a+-- 'Kiroku.Store.Observability.KirokuEventSubscriptionResumed'.+--+-- The second spec proves the fail-fast path is preserved: the identical scenario+-- under 'DropSubscription' still surfaces 'SubscriptionOverflowed' on @wait@.+--+-- To confirm the first spec actually pins the new behavior: temporarily set its+-- @overflowPolicy@ to 'DropSubscription' and it fails with+-- @Left (SubscriptionOverflowed ...)@ where it expected @Right ()@. module Test.SubscriptionPauseResume (spec) where  import Control.Concurrent (threadDelay)@@ -53,148 +52,148 @@ -- subscription (member 0), or 'Nothing' if no row exists yet. readCheckpoint :: KirokuStore -> T.Text -> IO (Maybe Int) readCheckpoint store subName = do-    result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)-    case result of-        Left err -> error ("readCheckpoint failed: " <> show err)-        Right mPos -> pure (fmap fromIntegral mPos)+  result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)+  case result of+    Left err -> error ("readCheckpoint failed: " <> show err)+    Right mPos -> pure (fmap fromIntegral mPos)  spec :: Spec spec = describe "subscription FSM — recoverable backpressure (EP-41 M2)" $ do-    it "pauses a slow AllStreams consumer and resumes, delivering all events" $ do-        evtRef <- newIORef ([] :: [KirokuEvent])-        -- Publisher and worker callbacks can arrive concurrently.-        let evtHandler e = atomicModifyIORef' evtRef (\events -> (e : events, ()))-        withTestStoreSettings (& #eventHandler .~ Just evtHandler) $ \store -> do-            firstSeen <- newEmptyMVar-            release <- newEmptyMVar-            delivered <- newIORef ([] :: [GlobalPosition])-            seenCount <- newTVarIO (0 :: Int)-            let handler' evt = do-                    modifyIORef' delivered ((evt ^. #globalPosition) :)-                    n <- atomically $ do-                        c <- readTVar seenCount-                        writeTVar seenCount (c + 1)-                        pure (c + 1)-                    if n == 1-                        then putMVar firstSeen () >> takeMVar release-                        else pure ()-                    -- Stop once all five have been seen, so `wait` resolves Right ().-                    if n >= 5 then pure Stop else pure Continue-            let cfg =-                    SubscriptionConfig-                        { name = SubscriptionName "pause-resume-test"-                        , target = AllStreams-                        , handler = handler'-                        , batchSize = defaultBatchSize-                        , queueCapacity = 1-                        , overflowPolicy = PauseAndResume-                        , consumerGroup = Nothing-                        , consumerGroupGuard = False-                        , missingCheckpointPolicy = FromBeginning-                        , targetBindingPolicy = AdoptUnbound-                        , undecodableHandler = Nothing-                        , handlerStallWarnAfter = Nothing-                        , retryPolicy = defaultRetryPolicy-                        , eventTypeFilter = AllEventTypes-                        , selector = Nothing-                        }-            handle <- subscribe store cfg-            waitForLive handle-            -- First append: the worker reads it from the queue and the handler-            -- blocks inside it on `release`, so the worker stops draining.-            Right _ <- runStoreIO store $ appendToStream (StreamName "pr-1") NoStream [makeEvent "E1" (Aeson.object [])]-            takeMVar firstSeen-            -- With the worker stuck, append four more one at a time. Capacity is 1,-            -- so the second of these fills the queue and the publisher pauses the-            -- subscriber for the rest (skipping, not dropping).-            let appendOne i = do-                    let sn = StreamName ("pr-" <> T.pack (show (i :: Int)))-                    Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]-                    waitForPublisher store (GlobalPosition (fromIntegral i))-            appendOne 2-            appendOne 3-            appendOne 4-            appendOne 5-            -- Release the slow handler; the worker drains, resumes, re-catches-up-            -- from its checkpoint, and delivers the skipped events.-            putMVar release ()-            result <- waitWithTimeout 15_000_000 handle-            case result of-                Left timeout -> expectationFailure timeout-                Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)-                Right (Right ()) -> pure ()-            ds <- reverse <$> readIORef delivered-            ds `shouldBe` map GlobalPosition [1 .. 5]-            cp <- readCheckpoint store "pause-resume-test"-            cp `shouldBe` Just 5-            evts <- readIORef evtRef-            let isPaused (KirokuEventSubscriptionPaused (SubscriptionName "pause-resume-test") _ _) = True-                isPaused _ = False-                isResumed (KirokuEventSubscriptionResumed (SubscriptionName "pause-resume-test") _ _) = True-                isResumed _ = False-            any isPaused evts `shouldBe` True-            any isResumed evts `shouldBe` True+  it "pauses a slow AllStreams consumer and resumes, delivering all events" $ do+    evtRef <- newIORef ([] :: [KirokuEvent])+    -- Publisher and worker callbacks can arrive concurrently.+    let evtHandler e = atomicModifyIORef' evtRef (\events -> (e : events, ()))+    withTestStoreSettings (& #eventHandler .~ Just evtHandler) $ \store -> do+      firstSeen <- newEmptyMVar+      release <- newEmptyMVar+      delivered <- newIORef ([] :: [GlobalPosition])+      seenCount <- newTVarIO (0 :: Int)+      let handler' evt = do+            modifyIORef' delivered ((evt ^. #globalPosition) :)+            n <- atomically $ do+              c <- readTVar seenCount+              writeTVar seenCount (c + 1)+              pure (c + 1)+            if n == 1+              then putMVar firstSeen () >> takeMVar release+              else pure ()+            -- Stop once all five have been seen, so `wait` resolves Right ().+            if n >= 5 then pure Stop else pure Continue+      let cfg =+            SubscriptionConfig+              { name = SubscriptionName "pause-resume-test",+                target = AllStreams,+                handler = handler',+                batchSize = defaultBatchSize,+                queueCapacity = 1,+                overflowPolicy = PauseAndResume,+                consumerGroup = Nothing,+                consumerGroupGuard = False,+                missingCheckpointPolicy = FromBeginning,+                targetBindingPolicy = AdoptUnbound,+                undecodableHandler = Nothing,+                handlerStallWarnAfter = Nothing,+                retryPolicy = defaultRetryPolicy,+                eventTypeFilter = AllEventTypes,+                selector = Nothing+              }+      handle <- subscribe store cfg+      waitForLive handle+      -- First append: the worker reads it from the queue and the handler+      -- blocks inside it on `release`, so the worker stops draining.+      Right _ <- runStoreIO store $ appendToStream (StreamName "pr-1") NoStream [makeEvent "E1" (Aeson.object [])]+      takeMVar firstSeen+      -- With the worker stuck, append four more one at a time. Capacity is 1,+      -- so the second of these fills the queue and the publisher pauses the+      -- subscriber for the rest (skipping, not dropping).+      let appendOne i = do+            let sn = StreamName ("pr-" <> T.pack (show (i :: Int)))+            Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]+            waitForPublisher store (GlobalPosition (fromIntegral i))+      appendOne 2+      appendOne 3+      appendOne 4+      appendOne 5+      -- Release the slow handler; the worker drains, resumes, re-catches-up+      -- from its checkpoint, and delivers the skipped events.+      putMVar release ()+      result <- waitWithTimeout 15_000_000 handle+      case result of+        Left timeout -> expectationFailure timeout+        Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)+        Right (Right ()) -> pure ()+      ds <- reverse <$> readIORef delivered+      ds `shouldBe` map GlobalPosition [1 .. 5]+      cp <- readCheckpoint store "pause-resume-test"+      cp `shouldBe` Just 5+      evts <- readIORef evtRef+      let isPaused (KirokuEventSubscriptionPaused (SubscriptionName "pause-resume-test") _ _) = True+          isPaused _ = False+          isResumed (KirokuEventSubscriptionResumed (SubscriptionName "pause-resume-test") _ _) = True+          isResumed _ = False+      any isPaused evts `shouldBe` True+      any isResumed evts `shouldBe` True -    it "still fails fast under DropSubscription" $ do-        withTestStoreSettings Prelude.id $ \store -> do-            firstSeen <- newEmptyMVar-            release <- newEmptyMVar-            seenCount <- newTVarIO (0 :: Int)-            let handler' _evt = do-                    n <- atomically $ do-                        c <- readTVar seenCount-                        writeTVar seenCount (c + 1)-                        pure (c + 1)-                    if n == 1-                        then putMVar firstSeen () >> takeMVar release-                        else pure ()-                    pure Continue-            let cfg =-                    SubscriptionConfig-                        { name = SubscriptionName "dropsub-overflow-test"-                        , target = AllStreams-                        , handler = handler'-                        , batchSize = defaultBatchSize-                        , queueCapacity = 1-                        , overflowPolicy = DropSubscription-                        , consumerGroup = Nothing-                        , consumerGroupGuard = False-                        , missingCheckpointPolicy = FromBeginning-                        , targetBindingPolicy = AdoptUnbound-                        , undecodableHandler = Nothing-                        , handlerStallWarnAfter = Nothing-                        , retryPolicy = defaultRetryPolicy-                        , eventTypeFilter = AllEventTypes-                        , selector = Nothing-                        }-            handle <- subscribe store cfg-            waitForLive handle-            Right _ <- runStoreIO store $ appendToStream (StreamName "ds-1") NoStream [makeEvent "E1" (Aeson.object [])]-            takeMVar firstSeen-            let appendOne i = do-                    let sn = StreamName ("ds-" <> T.pack (show (i :: Int)))-                    Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]-                    waitForPublisher store (GlobalPosition (fromIntegral i))-            appendOne 2-            appendOne 3-            appendOne 4-            appendOne 5-            putMVar release ()-            result <- waitWithTimeout 15_000_000 handle-            case result of-                Left timeout -> expectationFailure timeout-                Right (Right ()) -> expectationFailure "expected SubscriptionOverflowed, got a clean stop"-                Right (Left e) ->-                    case Control.Exception.fromException e of-                        Just (SubscriptionOverflowed sn) -> sn `shouldBe` SubscriptionName "dropsub-overflow-test"-                        Nothing -> expectationFailure ("expected SubscriptionOverflowed, got: " <> show e)+  it "still fails fast under DropSubscription" $ do+    withTestStoreSettings Prelude.id $ \store -> do+      firstSeen <- newEmptyMVar+      release <- newEmptyMVar+      seenCount <- newTVarIO (0 :: Int)+      let handler' _evt = do+            n <- atomically $ do+              c <- readTVar seenCount+              writeTVar seenCount (c + 1)+              pure (c + 1)+            if n == 1+              then putMVar firstSeen () >> takeMVar release+              else pure ()+            pure Continue+      let cfg =+            SubscriptionConfig+              { name = SubscriptionName "dropsub-overflow-test",+                target = AllStreams,+                handler = handler',+                batchSize = defaultBatchSize,+                queueCapacity = 1,+                overflowPolicy = DropSubscription,+                consumerGroup = Nothing,+                consumerGroupGuard = False,+                missingCheckpointPolicy = FromBeginning,+                targetBindingPolicy = AdoptUnbound,+                undecodableHandler = Nothing,+                handlerStallWarnAfter = Nothing,+                retryPolicy = defaultRetryPolicy,+                eventTypeFilter = AllEventTypes,+                selector = Nothing+              }+      handle <- subscribe store cfg+      waitForLive handle+      Right _ <- runStoreIO store $ appendToStream (StreamName "ds-1") NoStream [makeEvent "E1" (Aeson.object [])]+      takeMVar firstSeen+      let appendOne i = do+            let sn = StreamName ("ds-" <> T.pack (show (i :: Int)))+            Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "Ex" (Aeson.object [])]+            waitForPublisher store (GlobalPosition (fromIntegral i))+      appendOne 2+      appendOne 3+      appendOne 4+      appendOne 5+      putMVar release ()+      result <- waitWithTimeout 15_000_000 handle+      case result of+        Left timeout -> expectationFailure timeout+        Right (Right ()) -> expectationFailure "expected SubscriptionOverflowed, got a clean stop"+        Right (Left e) ->+          case Control.Exception.fromException e of+            Just (SubscriptionOverflowed sn) -> sn `shouldBe` SubscriptionName "dropsub-overflow-test"+            Nothing -> expectationFailure ("expected SubscriptionOverflowed, got: " <> show e)  -- These fixtures must block live delivery, not the initial catch-up read. waitForLive :: SubscriptionHandle -> IO () waitForLive handle = do-    let go = do-            st <- currentState handle-            case st of-                Just Fsm.Live{} -> pure ()-                _ -> threadDelay 1_000 >> go-    Timeout.timeout 5_000_000 go >>= (`shouldBe` Just ())+  let go = do+        st <- currentState handle+        case st of+          Just Fsm.Live {} -> pure ()+          _ -> threadDelay 1_000 >> go+  Timeout.timeout 5_000_000 go >>= (`shouldBe` Just ())
test/Test/SubscriptionReconnect.hs view
@@ -1,29 +1,28 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Regression test for ExecPlan 41 M3 — worker-level live reconnect.--Before M3, a live database fetch error in the Category / consumer-group live-loops was retried in place, forever and invisibly. M3 lifts recovery into the-FSM: a live-mode 'Hasql.Pool.UsageError' drives the worker into the-'Kiroku.Store.Subscription.Fsm.Reconnecting' state — it emits-'Kiroku.Store.Observability.KirokuEventSubscriptionReconnecting', backs off, and-re-catches-up from its checkpoint — instead of silently spinning. The-subscription survives the fault and resumes from where it left off.--This spec uses a __Category__ subscription, not AllStreams: AllStreams live-delivery is fed by the shared publisher (which owns its own reconnect), so the-AllStreams worker performs no live-mode fetch and has nothing to reconnect. The-Category worker fetches in live mode, so 'withFetchBatchHookForTest' can inject-failures there. The hook fails the first @K@ fetches that occur __after__ the-subscription has caught up (a 'TVar' flag the @eventHandler@ sets on-'KirokuEventSubscriptionCaughtUp'), then falls through to the real database.--Asserted: at least one 'KirokuEventSubscriptionReconnecting' fired; @wait@ did-not surface the injected error (the worker recovered rather than died); every-appended event was delivered in increasing position; the checkpoint advanced to-the last position.--}+-- | Regression test for ExecPlan 41 M3 — worker-level live reconnect.+--+-- Before M3, a live database fetch error in the Category / consumer-group live+-- loops was retried in place, forever and invisibly. M3 lifts recovery into the+-- FSM: a live-mode 'Hasql.Pool.UsageError' drives the worker into the+-- 'Kiroku.Store.Subscription.Fsm.Reconnecting' state — it emits+-- 'Kiroku.Store.Observability.KirokuEventSubscriptionReconnecting', backs off, and+-- re-catches-up from its checkpoint — instead of silently spinning. The+-- subscription survives the fault and resumes from where it left off.+--+-- This spec uses a __Category__ subscription, not AllStreams: AllStreams live+-- delivery is fed by the shared publisher (which owns its own reconnect), so the+-- AllStreams worker performs no live-mode fetch and has nothing to reconnect. The+-- Category worker fetches in live mode, so 'withFetchBatchHookForTest' can inject+-- failures there. The hook fails the first @K@ fetches that occur __after__ the+-- subscription has caught up (a 'TVar' flag the @eventHandler@ sets on+-- 'KirokuEventSubscriptionCaughtUp'), then falls through to the real database.+--+-- Asserted: at least one 'KirokuEventSubscriptionReconnecting' fired; @wait@ did+-- not surface the injected error (the worker recovered rather than died); every+-- appended event was delivered in increasing position; the checkpoint advanced to+-- the last position. module Test.SubscriptionReconnect (spec) where  import Control.Concurrent.STM (atomically, check, newTVarIO, readTVar, readTVarIO, writeTVar)@@ -44,149 +43,149 @@  readCheckpoint :: KirokuStore -> T.Text -> IO (Maybe Int) readCheckpoint store subName = do-    result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)-    case result of-        Left err -> error ("readCheckpoint failed: " <> show err)-        Right mPos -> pure (fmap fromIntegral mPos)+  result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)+  case result of+    Left err -> error ("readCheckpoint failed: " <> show err)+    Right mPos -> pure (fmap fromIntegral mPos)  spec :: Spec spec = describe "subscription FSM — worker-level live reconnect (EP-41 M3)" $ do-    it "reconnects and resumes after transient live-mode database errors" $ do-        evtRef <- newIORef ([] :: [KirokuEvent])-        deliveredRef <- newIORef ([] :: [GlobalPosition])-        caughtUpFlag <- newTVarIO False-        failsLeft <- newTVarIO (2 :: Int)-        seenCount <- newTVarIO (0 :: Int)+  it "reconnects and resumes after transient live-mode database errors" $ do+    evtRef <- newIORef ([] :: [KirokuEvent])+    deliveredRef <- newIORef ([] :: [GlobalPosition])+    caughtUpFlag <- newTVarIO False+    failsLeft <- newTVarIO (2 :: Int)+    seenCount <- newTVarIO (0 :: Int) -        let subName = SubscriptionName "reconnect-test"-            observe evt = do-                modifyIORef' evtRef (evt :)-                case evt of-                    KirokuEventSubscriptionCaughtUp n _ _-                        | n == subName -> atomically (writeTVar caughtUpFlag True)-                    _ -> pure ()-            -- Fail the first K fetches that happen after catch-up, then fall-            -- through to the real database. Catch-up fetches (flag still False)-            -- are never failed, so the fault is purely a live-mode event.-            injectHook config _cursor-                | SubTypes.name config == subName = do-                    cu <- readTVarIO caughtUpFlag-                    if not cu-                        then pure Nothing-                        else atomically $ do-                            n <- readTVar failsLeft-                            if n > 0-                                then do-                                    writeTVar failsLeft (n - 1)-                                    pure (Just (Left Pool.AcquisitionTimeoutUsageError))-                                else pure Nothing-                | otherwise = pure Nothing-            handler' evt = do-                modifyIORef' deliveredRef ((evt ^. #globalPosition) :)-                n <- atomically $ do-                    c <- readTVar seenCount-                    writeTVar seenCount (c + 1)-                    pure (c + 1)-                if n >= 3 then pure Stop else pure Continue-            cfg =-                SubscriptionConfig-                    { name = subName-                    , target = Category (CategoryName "rc")-                    , handler = handler'-                    , batchSize = defaultBatchSize-                    , queueCapacity = 16-                    , overflowPolicy = PauseAndResume-                    , consumerGroup = Nothing-                    , consumerGroupGuard = False-                    , missingCheckpointPolicy = FromBeginning-                    , targetBindingPolicy = AdoptUnbound-                    , undecodableHandler = Nothing-                    , handlerStallWarnAfter = Nothing-                    , retryPolicy = defaultRetryPolicy-                    , eventTypeFilter = AllEventTypes-                    , selector = Nothing-                    }+    let subName = SubscriptionName "reconnect-test"+        observe evt = do+          modifyIORef' evtRef (evt :)+          case evt of+            KirokuEventSubscriptionCaughtUp n _ _+              | n == subName -> atomically (writeTVar caughtUpFlag True)+            _ -> pure ()+        -- Fail the first K fetches that happen after catch-up, then fall+        -- through to the real database. Catch-up fetches (flag still False)+        -- are never failed, so the fault is purely a live-mode event.+        injectHook config _cursor+          | SubTypes.name config == subName = do+              cu <- readTVarIO caughtUpFlag+              if not cu+                then pure Nothing+                else atomically $ do+                  n <- readTVar failsLeft+                  if n > 0+                    then do+                      writeTVar failsLeft (n - 1)+                      pure (Just (Left Pool.AcquisitionTimeoutUsageError))+                    else pure Nothing+          | otherwise = pure Nothing+        handler' evt = do+          modifyIORef' deliveredRef ((evt ^. #globalPosition) :)+          n <- atomically $ do+            c <- readTVar seenCount+            writeTVar seenCount (c + 1)+            pure (c + 1)+          if n >= 3 then pure Stop else pure Continue+        cfg =+          SubscriptionConfig+            { name = subName,+              target = Category (CategoryName "rc"),+              handler = handler',+              batchSize = defaultBatchSize,+              queueCapacity = 16,+              overflowPolicy = PauseAndResume,+              consumerGroup = Nothing,+              consumerGroupGuard = False,+              missingCheckpointPolicy = FromBeginning,+              targetBindingPolicy = AdoptUnbound,+              undecodableHandler = Nothing,+              handlerStallWarnAfter = Nothing,+              retryPolicy = defaultRetryPolicy,+              eventTypeFilter = AllEventTypes,+              selector = Nothing+            } -        withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do-            withFetchBatchHookForTest injectHook $ do-                -- Empty store: the worker catches up instantly (no fetch), sets the-                -- flag, then its first live-mode fetch is the one the hook fails —-                -- so the fault lands squarely in live mode.-                handle <- subscribe store cfg-                -- Wait until catch-up has completed and the reconnect failures have-                -- been consumed, so the appends below are served by the real DB.-                atomically $ do-                    cu <- readTVar caughtUpFlag-                    check cu-                    n <- readTVar failsLeft-                    check (n == 0)-                Right _ <- runStoreIO store $ appendToStream (StreamName "rc-1") NoStream [makeEvent "A" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "rc-2") NoStream [makeEvent "B" (Aeson.object [])]-                Right _ <- runStoreIO store $ appendToStream (StreamName "rc-3") NoStream [makeEvent "C" (Aeson.object [])]-                result <- waitWithTimeout 20_000_000 handle-                case result of-                    Left timeout -> expectationFailure timeout-                    Right (Left e) -> expectationFailure ("worker did not survive the fault: " <> show e)-                    Right (Right ()) -> pure ()+    withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do+      withFetchBatchHookForTest injectHook $ do+        -- Empty store: the worker catches up instantly (no fetch), sets the+        -- flag, then its first live-mode fetch is the one the hook fails —+        -- so the fault lands squarely in live mode.+        handle <- subscribe store cfg+        -- Wait until catch-up has completed and the reconnect failures have+        -- been consumed, so the appends below are served by the real DB.+        atomically $ do+          cu <- readTVar caughtUpFlag+          check cu+          n <- readTVar failsLeft+          check (n == 0)+        Right _ <- runStoreIO store $ appendToStream (StreamName "rc-1") NoStream [makeEvent "A" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "rc-2") NoStream [makeEvent "B" (Aeson.object [])]+        Right _ <- runStoreIO store $ appendToStream (StreamName "rc-3") NoStream [makeEvent "C" (Aeson.object [])]+        result <- waitWithTimeout 20_000_000 handle+        case result of+          Left timeout -> expectationFailure timeout+          Right (Left e) -> expectationFailure ("worker did not survive the fault: " <> show e)+          Right (Right ()) -> pure () -            delivered <- reverse <$> readIORef deliveredRef-            delivered `shouldBe` map GlobalPosition [1 .. 3]-            cp <- readCheckpoint store "reconnect-test"-            cp `shouldBe` Just 3-            evts <- readIORef evtRef-            let isReconnecting (KirokuEventSubscriptionReconnecting n _ _) = n == subName-                isReconnecting _ = False-            any isReconnecting evts `shouldBe` True+      delivered <- reverse <$> readIORef deliveredRef+      delivered `shouldBe` map GlobalPosition [1 .. 3]+      cp <- readCheckpoint store "reconnect-test"+      cp `shouldBe` Just 3+      evts <- readIORef evtRef+      let isReconnecting (KirokuEventSubscriptionReconnecting n _ _) = n == subName+          isReconnecting _ = False+      any isReconnecting evts `shouldBe` True -    it "resumes a database-driven live worker from processed progress" $ do-        delivered <- newIORef []-        caughtUp <- newTVarIO False-        processed <- newTVarIO False-        failed <- newTVarIO False-        recoveryCursor <- newIORef Nothing-        let subName = SubscriptionName "mid-live-progress"-            observe = \case-                KirokuEventSubscriptionCaughtUp n _ _ | n == subName -> atomically (writeTVar caughtUp True)-                _ -> pure ()-            inject config cursor-                | SubTypes.name config /= subName = pure Nothing-                | otherwise = do-                    live <- readTVarIO caughtUp-                    done <- readTVarIO processed-                    alreadyFailed <- readTVarIO failed-                    if live && done && not alreadyFailed-                        then do-                            cursor `shouldBe` GlobalPosition 1-                            atomically (writeTVar failed True)-                            pure (Just (Left Pool.AcquisitionTimeoutUsageError))-                        else do-                            if alreadyFailed-                                then do-                                    previous <- readIORef recoveryCursor-                                    case previous of-                                        Nothing -> modifyIORef' recoveryCursor (const (Just cursor))-                                        Just _ -> pure ()-                                else pure ()-                            pure Nothing-            handler event = do-                modifyIORef' delivered ((event ^. #globalPosition) :)-                if event ^. #globalPosition == GlobalPosition 1-                    then atomically (writeTVar processed True) >> pure Continue-                    else pure Stop-            cfg = defaultSubscriptionConfig subName (Category (CategoryName "midlive")) handler-        withTestStoreSettings (& #eventHandler .~ Just observe) $ \store ->-            withFetchBatchHookForTest inject $ do-                handle <- subscribe store cfg-                atomically (readTVar caughtUp >>= check)-                Right _ <- runStoreIO store $ appendToStream (StreamName "midlive-1") NoStream [makeEvent "A" (Aeson.object [])]-                atomically (readTVar processed >>= check)-                -- Wake the live fetch once more after its first successful batch.-                Right _ <- runStoreIO store $ appendToStream (StreamName "midlive-2") NoStream [makeEvent "B" (Aeson.object [])]-                result <- waitWithTimeout 10_000_000 handle-                case result of-                    Right (Right ()) -> pure ()-                    other -> expectationFailure (show other)-                readTVarIO failed `shouldReturn` True-                readIORef recoveryCursor `shouldReturn` Just (GlobalPosition 1)-                reverse <$> readIORef delivered `shouldReturn` [GlobalPosition 1, GlobalPosition 2]-                readCheckpoint store "mid-live-progress" `shouldReturn` Just 2+  it "resumes a database-driven live worker from processed progress" $ do+    delivered <- newIORef []+    caughtUp <- newTVarIO False+    processed <- newTVarIO False+    failed <- newTVarIO False+    recoveryCursor <- newIORef Nothing+    let subName = SubscriptionName "mid-live-progress"+        observe = \case+          KirokuEventSubscriptionCaughtUp n _ _ | n == subName -> atomically (writeTVar caughtUp True)+          _ -> pure ()+        inject config cursor+          | SubTypes.name config /= subName = pure Nothing+          | otherwise = do+              live <- readTVarIO caughtUp+              done <- readTVarIO processed+              alreadyFailed <- readTVarIO failed+              if live && done && not alreadyFailed+                then do+                  cursor `shouldBe` GlobalPosition 1+                  atomically (writeTVar failed True)+                  pure (Just (Left Pool.AcquisitionTimeoutUsageError))+                else do+                  if alreadyFailed+                    then do+                      previous <- readIORef recoveryCursor+                      case previous of+                        Nothing -> modifyIORef' recoveryCursor (const (Just cursor))+                        Just _ -> pure ()+                    else pure ()+                  pure Nothing+        handler event = do+          modifyIORef' delivered ((event ^. #globalPosition) :)+          if event ^. #globalPosition == GlobalPosition 1+            then atomically (writeTVar processed True) >> pure Continue+            else pure Stop+        cfg = defaultSubscriptionConfig subName (Category (CategoryName "midlive")) handler+    withTestStoreSettings (& #eventHandler .~ Just observe) $ \store ->+      withFetchBatchHookForTest inject $ do+        handle <- subscribe store cfg+        atomically (readTVar caughtUp >>= check)+        Right _ <- runStoreIO store $ appendToStream (StreamName "midlive-1") NoStream [makeEvent "A" (Aeson.object [])]+        atomically (readTVar processed >>= check)+        -- Wake the live fetch once more after its first successful batch.+        Right _ <- runStoreIO store $ appendToStream (StreamName "midlive-2") NoStream [makeEvent "B" (Aeson.object [])]+        result <- waitWithTimeout 10_000_000 handle+        case result of+          Right (Right ()) -> pure ()+          other -> expectationFailure (show other)+        readTVarIO failed `shouldReturn` True+        readIORef recoveryCursor `shouldReturn` Just (GlobalPosition 1)+        reverse <$> readIORef delivered `shouldReturn` [GlobalPosition 1, GlobalPosition 2]+        readCheckpoint store "mid-live-progress" `shouldReturn` Just 2
test/Test/SubscriptionRegistry.hs view
@@ -1,23 +1,22 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Tests for the central subscription-state registry (ExecPlan 45 / EP-1 of-MasterPlan 7).--These prove the registry's observable behaviour against a real migrated-PostgreSQL database:--  * Several live subscriptions (including consumer-group members) appear in the-    'Kiroku.Store.Subscription.subscriptionStates' snapshot with sensible state-    labels, matching keys, and a valid FSM cursor, and a live handle's-    'currentState' agrees with its snapshot entry (the single-source-of-truth-    reshape).-  * A subscription's key disappears from the snapshot when it stops cleanly, is-    cancelled, or crashes — and its held handle's 'currentState' then returns-    'Nothing'.-  * A stale duplicate-key worker's cleanup cannot delete a newer worker's-    replacement entry (the per-worker token defends the active entry).--}+-- | Tests for the central subscription-state registry (ExecPlan 45 / EP-1 of+-- MasterPlan 7).+--+-- These prove the registry's observable behaviour against a real migrated+-- PostgreSQL database:+--+--   * Several live subscriptions (including consumer-group members) appear in the+--     'Kiroku.Store.Subscription.subscriptionStates' snapshot with sensible state+--     labels, matching keys, and a valid FSM cursor, and a live handle's+--     'currentState' agrees with its snapshot entry (the single-source-of-truth+--     reshape).+--   * A subscription's key disappears from the snapshot when it stops cleanly, is+--     cancelled, or crashes — and its held handle's 'currentState' then returns+--     'Nothing'.+--   * A stale duplicate-key worker's cleanup cannot delete a newer worker's+--     replacement entry (the per-worker token defends the active entry). module Test.SubscriptionRegistry (spec) where  import Control.Concurrent (threadDelay)@@ -44,174 +43,174 @@ -- | A size-@n@ category-group config for member @m@ whose handler never stops. groupCont :: Text -> Text -> Int32 -> Int32 -> SubscriptionConfig groupCont nm cat m n =-    (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) (\_ -> pure Continue))-        { consumerGroup = Just (validConsumerGroup m n)-        }+  (defaultSubscriptionConfig (SubscriptionName nm) (Category (CategoryName cat)) (\_ -> pure Continue))+    { consumerGroup = Just (validConsumerGroup m n)+    }  -- | A size-@n@ @$all@ group config for member @m@ whose handler never stops. groupAllCont :: Text -> Int32 -> Int32 -> SubscriptionConfig groupAllCont nm m n =-    (defaultSubscriptionConfig (SubscriptionName nm) AllStreams (\_ -> pure Continue))-        { consumerGroup = Just (validConsumerGroup m n)-        }+  (defaultSubscriptionConfig (SubscriptionName nm) AllStreams (\_ -> pure Continue))+    { consumerGroup = Just (validConsumerGroup m n)+    }  publisherSubscriberCount :: KirokuStore -> IO Int publisherSubscriberCount store =-    IntMap.size <$> readTVarIO (Pub.subscribers (store ^. #publisher))+  IntMap.size <$> readTVarIO (Pub.subscribers (store ^. #publisher))  -- | Poll the snapshot until the keyed entry reaches the given 'statePhase' label. waitUntilPhase :: Int -> KirokuStore -> (SubscriptionName, Int32) -> Text -> IO Bool waitUntilPhase budget store key phase-    | budget <= 0 = pure False-    | otherwise = do-        m <- subscriptionStates store-        case Map.lookup key m of-            Just v | (v ^. #statePhase) == phase -> pure True-            _ -> threadDelay 20_000 >> waitUntilPhase (budget - 20_000) store key phase+  | budget <= 0 = pure False+  | otherwise = do+      m <- subscriptionStates store+      case Map.lookup key m of+        Just v | (v ^. #statePhase) == phase -> pure True+        _ -> threadDelay 20_000 >> waitUntilPhase (budget - 20_000) store key phase  -- | Poll the snapshot until the keyed entry is present. waitUntilPresent :: Int -> KirokuStore -> (SubscriptionName, Int32) -> IO Bool waitUntilPresent budget store key-    | budget <= 0 = pure False-    | otherwise = do-        m <- subscriptionStates store-        if Map.member key m-            then pure True-            else threadDelay 20_000 >> waitUntilPresent (budget - 20_000) store key+  | budget <= 0 = pure False+  | otherwise = do+      m <- subscriptionStates store+      if Map.member key m+        then pure True+        else threadDelay 20_000 >> waitUntilPresent (budget - 20_000) store key  -- | Poll the snapshot until the keyed entry is absent. waitUntilAbsent :: Int -> KirokuStore -> (SubscriptionName, Int32) -> IO Bool waitUntilAbsent budget store key-    | budget <= 0 = pure False-    | otherwise = do-        m <- subscriptionStates store-        if Map.member key m-            then threadDelay 20_000 >> waitUntilAbsent (budget - 20_000) store key-            else pure True+  | budget <= 0 = pure False+  | otherwise = do+      m <- subscriptionStates store+      if Map.member key m+        then threadDelay 20_000 >> waitUntilAbsent (budget - 20_000) store key+        else pure True  spec :: Spec spec = describe "subscription registry (EP-1 / plan 45)" $ do-    it "registers every live subscription with a sensible state and position" $-        withTestStore $ \store -> do-            let plainName = SubscriptionName "reg-plain"-                groupName = SubscriptionName "reg-group"-            Right _ <- runStoreIO store $ appendToStream (StreamName "reg-1") NoStream [makeEvent "A" (Aeson.object [])]-            Right _ <- runStoreIO store $ appendToStream (StreamName "reg-2") NoStream [makeEvent "B" (Aeson.object [])]-            waitForPublisher store (GlobalPosition 2)-            hPlain <- subscribe store (plainCont "reg-plain")-            hG0 <- subscribe store (groupCont "reg-group" "reg" 0 2)-            hG1 <- subscribe store (groupCont "reg-group" "reg" 1 2)-            okP <- waitUntilPhase 5_000_000 store (plainName, 0) "live"-            ok0 <- waitUntilPhase 5_000_000 store (groupName, 0) "live"-            ok1 <- waitUntilPhase 5_000_000 store (groupName, 1) "live"-            (okP, ok0, ok1) `shouldBe` (True, True, True)-            snap <- subscriptionStates store-            Map.keys snap `shouldMatchList` [(plainName, 0), (groupName, 0), (groupName, 1)]-            for_ (Map.toList snap) $ \((nm, mbr), v) -> do-                (v ^. #subscriptionName) `shouldBe` nm-                (v ^. #member) `shouldBe` mbr-                (v ^. #statePhase) `shouldSatisfy` (`elem` (["catching_up", "live"] :: [Text]))-                let GlobalPosition p = v ^. #cursor-                p `shouldSatisfy` (>= 0)-            -- currentState on a live handle agrees with its snapshot entry.-            Just s <- currentState hPlain-            case Map.lookup (plainName, 0) snap of-                Just v -> stateName s `shouldBe` (v ^. #statePhase)-                Nothing -> expectationFailure "plain subscription missing from snapshot"-            mapM_ cancel [hPlain, hG0, hG1]+  it "registers every live subscription with a sensible state and position" $+    withTestStore $ \store -> do+      let plainName = SubscriptionName "reg-plain"+          groupName = SubscriptionName "reg-group"+      Right _ <- runStoreIO store $ appendToStream (StreamName "reg-1") NoStream [makeEvent "A" (Aeson.object [])]+      Right _ <- runStoreIO store $ appendToStream (StreamName "reg-2") NoStream [makeEvent "B" (Aeson.object [])]+      waitForPublisher store (GlobalPosition 2)+      hPlain <- subscribe store (plainCont "reg-plain")+      hG0 <- subscribe store (groupCont "reg-group" "reg" 0 2)+      hG1 <- subscribe store (groupCont "reg-group" "reg" 1 2)+      okP <- waitUntilPhase 5_000_000 store (plainName, 0) "live"+      ok0 <- waitUntilPhase 5_000_000 store (groupName, 0) "live"+      ok1 <- waitUntilPhase 5_000_000 store (groupName, 1) "live"+      (okP, ok0, ok1) `shouldBe` (True, True, True)+      snap <- subscriptionStates store+      Map.keys snap `shouldMatchList` [(plainName, 0), (groupName, 0), (groupName, 1)]+      for_ (Map.toList snap) $ \((nm, mbr), v) -> do+        (v ^. #subscriptionName) `shouldBe` nm+        (v ^. #member) `shouldBe` mbr+        (v ^. #statePhase) `shouldSatisfy` (`elem` (["catching_up", "live"] :: [Text]))+        let GlobalPosition p = v ^. #cursor+        p `shouldSatisfy` (>= 0)+      -- currentState on a live handle agrees with its snapshot entry.+      Just s <- currentState hPlain+      case Map.lookup (plainName, 0) snap of+        Just v -> stateName s `shouldBe` (v ^. #statePhase)+        Nothing -> expectationFailure "plain subscription missing from snapshot"+      mapM_ cancel [hPlain, hG0, hG1] -    it "removes a subscription's entry when it stops cleanly" $-        withTestStore $ \store -> do-            let nm = SubscriptionName "reg-stop"-            Right _ <- runStoreIO store $ appendToStream (StreamName "stop-1") NoStream [makeEvent "A" (Aeson.object [])]-            waitForPublisher store (GlobalPosition 1)-            handle <- subscribe store (defaultSubscriptionConfig nm AllStreams (\_ -> pure Stop))-            _ <- wait handle-            gone <- waitUntilAbsent 5_000_000 store (nm, 0)-            gone `shouldBe` True-            cs <- currentState handle-            cs `shouldSatisfy` isNothing+  it "removes a subscription's entry when it stops cleanly" $+    withTestStore $ \store -> do+      let nm = SubscriptionName "reg-stop"+      Right _ <- runStoreIO store $ appendToStream (StreamName "stop-1") NoStream [makeEvent "A" (Aeson.object [])]+      waitForPublisher store (GlobalPosition 1)+      handle <- subscribe store (defaultSubscriptionConfig nm AllStreams (\_ -> pure Stop))+      _ <- wait handle+      gone <- waitUntilAbsent 5_000_000 store (nm, 0)+      gone `shouldBe` True+      cs <- currentState handle+      cs `shouldSatisfy` isNothing -    it "removes a subscription's entry when it is cancelled" $-        withTestStore $ \store -> do-            let nm = SubscriptionName "reg-cancel"-            Right _ <- runStoreIO store $ appendToStream (StreamName "cancel-1") NoStream [makeEvent "A" (Aeson.object [])]-            waitForPublisher store (GlobalPosition 1)-            handle <- subscribe store (plainCont "reg-cancel")-            live <- waitUntilPhase 5_000_000 store (nm, 0) "live"-            live `shouldBe` True-            cancel handle-            _ <- wait handle-            gone <- waitUntilAbsent 5_000_000 store (nm, 0)-            gone `shouldBe` True-            cs <- currentState handle-            cs `shouldSatisfy` isNothing+  it "removes a subscription's entry when it is cancelled" $+    withTestStore $ \store -> do+      let nm = SubscriptionName "reg-cancel"+      Right _ <- runStoreIO store $ appendToStream (StreamName "cancel-1") NoStream [makeEvent "A" (Aeson.object [])]+      waitForPublisher store (GlobalPosition 1)+      handle <- subscribe store (plainCont "reg-cancel")+      live <- waitUntilPhase 5_000_000 store (nm, 0) "live"+      live `shouldBe` True+      cancel handle+      _ <- wait handle+      gone <- waitUntilAbsent 5_000_000 store (nm, 0)+      gone `shouldBe` True+      cs <- currentState handle+      cs `shouldSatisfy` isNothing -    it "removes a subscription's entry when its handler crashes" $-        withTestStore $ \store -> do-            let nm = SubscriptionName "reg-crash"-            Right _ <- runStoreIO store $ appendToStream (StreamName "crash-1") NoStream [makeEvent "A" (Aeson.object [])]-            waitForPublisher store (GlobalPosition 1)-            handle <- subscribe store (defaultSubscriptionConfig nm AllStreams (\_ -> throwIO (userError "boom")))-            res <- wait handle-            case res of-                Left _ -> pure ()-                Right () -> expectationFailure "expected the crashing handler to fail the worker"-            gone <- waitUntilAbsent 5_000_000 store (nm, 0)-            gone `shouldBe` True-            cs <- currentState handle-            cs `shouldSatisfy` isNothing+  it "removes a subscription's entry when its handler crashes" $+    withTestStore $ \store -> do+      let nm = SubscriptionName "reg-crash"+      Right _ <- runStoreIO store $ appendToStream (StreamName "crash-1") NoStream [makeEvent "A" (Aeson.object [])]+      waitForPublisher store (GlobalPosition 1)+      handle <- subscribe store (defaultSubscriptionConfig nm AllStreams (\_ -> throwIO (userError "boom")))+      res <- wait handle+      case res of+        Left _ -> pure ()+        Right () -> expectationFailure "expected the crashing handler to fail the worker"+      gone <- waitUntilAbsent 5_000_000 store (nm, 0)+      gone `shouldBe` True+      cs <- currentState handle+      cs `shouldSatisfy` isNothing -    it "stale duplicate-key cleanup does not remove the replacement entry" $-        withTestStore $ \store -> do-            let nm = SubscriptionName "reg-dup"-            h1 <- subscribe store (plainCont "reg-dup")-            present1 <- waitUntilPresent 5_000_000 store (nm, 0)-            present1 `shouldBe` True-            -- A second subscribe with the same (name, member) supersedes h1's-            -- entry in the registry (insert is synchronous, before the fork).-            h2 <- subscribe store (plainCont "reg-dup")-            -- Stopping the first worker must not delete the second's live entry.-            cancel h1-            _ <- wait h1-            snap <- subscriptionStates store-            Map.member (nm, 0) snap `shouldBe` True-            cs1 <- currentState h1-            cs1 `shouldSatisfy` isNothing-            cs2 <- currentState h2-            cs2 `shouldSatisfy` isJust-            cancel h2+  it "stale duplicate-key cleanup does not remove the replacement entry" $+    withTestStore $ \store -> do+      let nm = SubscriptionName "reg-dup"+      h1 <- subscribe store (plainCont "reg-dup")+      present1 <- waitUntilPresent 5_000_000 store (nm, 0)+      present1 `shouldBe` True+      -- A second subscribe with the same (name, member) supersedes h1's+      -- entry in the registry (insert is synchronous, before the fork).+      h2 <- subscribe store (plainCont "reg-dup")+      -- Stopping the first worker must not delete the second's live entry.+      cancel h1+      _ <- wait h1+      snap <- subscriptionStates store+      Map.member (nm, 0) snap `shouldBe` True+      cs1 <- currentState h1+      cs1 `shouldSatisfy` isNothing+      cs2 <- currentState h2+      cs2 `shouldSatisfy` isJust+      cancel h2 -    describe "publisher queue registration" $ do-        it "registers publisher queues only for non-group AllStreams subscriptions" $-            withTestStore $ \store -> do-                initial <- publisherSubscriberCount store-                initial `shouldBe` 0+  describe "publisher queue registration" $ do+    it "registers publisher queues only for non-group AllStreams subscriptions" $+      withTestStore $ \store -> do+        initial <- publisherSubscriberCount store+        initial `shouldBe` 0 -                hCategory <- subscribe store (defaultSubscriptionConfig (SubscriptionName "reg-cat-no-queue") (Category (CategoryName "regcat")) (\_ -> pure Continue))-                publisherSubscriberCount store `shouldReturn` 0+        hCategory <- subscribe store (defaultSubscriptionConfig (SubscriptionName "reg-cat-no-queue") (Category (CategoryName "regcat")) (\_ -> pure Continue))+        publisherSubscriberCount store `shouldReturn` 0 -                hGroupCategory <- subscribe store (groupCont "reg-group-cat-no-queue" "regcat" 0 2)-                publisherSubscriberCount store `shouldReturn` 0+        hGroupCategory <- subscribe store (groupCont "reg-group-cat-no-queue" "regcat" 0 2)+        publisherSubscriberCount store `shouldReturn` 0 -                hGroupAll <- subscribe store (groupAllCont "reg-group-all-no-queue" 0 2)-                publisherSubscriberCount store `shouldReturn` 0+        hGroupAll <- subscribe store (groupAllCont "reg-group-all-no-queue" 0 2)+        publisherSubscriberCount store `shouldReturn` 0 -                hAll <- subscribe store (plainCont "reg-all-has-queue")-                publisherSubscriberCount store `shouldReturn` 1+        hAll <- subscribe store (plainCont "reg-all-has-queue")+        publisherSubscriberCount store `shouldReturn` 1 -                cancel hAll-                _ <- wait hAll-                publisherSubscriberCount store `shouldReturn` 0+        cancel hAll+        _ <- wait hAll+        publisherSubscriberCount store `shouldReturn` 0 -                cancel hCategory-                _ <- wait hCategory-                publisherSubscriberCount store `shouldReturn` 0+        cancel hCategory+        _ <- wait hCategory+        publisherSubscriberCount store `shouldReturn` 0 -                cancel hGroupCategory-                _ <- wait hGroupCategory-                publisherSubscriberCount store `shouldReturn` 0+        cancel hGroupCategory+        _ <- wait hGroupCategory+        publisherSubscriberCount store `shouldReturn` 0 -                cancel hGroupAll-                _ <- wait hGroupAll-                publisherSubscriberCount store `shouldReturn` 0+        cancel hGroupAll+        _ <- wait hGroupAll+        publisherSubscriberCount store `shouldReturn` 0
test/Test/SubscriptionRetryDeadLetter.hs view
@@ -1,25 +1,24 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Regression tests for EP-40 (MasterPlan 6, child plan 2) — per-event retry-and dead-letter dispositions in @kiroku-store@.--These pin the three behavioral acceptance criteria from the ExecPlan:--  * __dead-letter__: a handler returning 'DeadLetter' for one event records it in-    @kiroku.dead_letters@, advances the checkpoint past it atomically, and keeps-    delivering later events;-  * __bounded retry__: a handler returning 'Retry' for one event sees that same-    event redelivered (the checkpoint does not advance past it) until it-    succeeds;-  * __retry exhaustion__: once the configured 'retryMaxAttempts' is exceeded the-    event is dead-lettered with 'DeadLetterMaxAttempts' and later events-    continue.--All three use an 'AllStreams' subscription (the FSM 'DeliverBatch' delivery-path). Events are appended and the publisher is awaited before subscribing, so-delivery happens during catch-up.--}+-- | Regression tests for EP-40 (MasterPlan 6, child plan 2) — per-event retry+-- and dead-letter dispositions in @kiroku-store@.+--+-- These pin the three behavioral acceptance criteria from the ExecPlan:+--+--   * __dead-letter__: a handler returning 'DeadLetter' for one event records it in+--     @kiroku.dead_letters@, advances the checkpoint past it atomically, and keeps+--     delivering later events;+--   * __bounded retry__: a handler returning 'Retry' for one event sees that same+--     event redelivered (the checkpoint does not advance past it) until it+--     succeeds;+--   * __retry exhaustion__: once the configured 'retryMaxAttempts' is exceeded the+--     event is dead-lettered with 'DeadLetterMaxAttempts' and later events+--     continue.+--+-- All three use an 'AllStreams' subscription (the FSM 'DeliverBatch' delivery+-- path). Events are appended and the publisher is awaited before subscribing, so+-- delivery happens during catch-up. module Test.SubscriptionRetryDeadLetter (spec) where  import Control.Lens ((^.))@@ -42,125 +41,125 @@  readCheckpoint :: KirokuStore -> Text -> IO (Maybe Int64) readCheckpoint store subName = do-    result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)-    case result of-        Left err -> error ("readCheckpoint failed: " <> show err)-        Right mPos -> pure mPos+  result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)+  case result of+    Left err -> error ("readCheckpoint failed: " <> show err)+    Right mPos -> pure mPos  readDeadLetters :: KirokuStore -> Text -> IO [SQL.DeadLetterRecord] readDeadLetters store subName = do-    result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.readDeadLettersStmt)-    case result of-        Left err -> error ("readDeadLetters failed: " <> show err)-        Right v -> pure (V.toList v)+  result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.readDeadLettersStmt)+  case result of+    Left err -> error ("readDeadLetters failed: " <> show err)+    Right v -> pure (V.toList v)  -- Append three single-event streams so global positions are 1, 2, 3. appendThree :: KirokuStore -> IO () appendThree store =-    mapM_-        ( \i -> do-            let sn = StreamName ("rdl-" <> T.pack (show (i :: Int)))-            Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "E" (Aeson.object [])]-            pure ()-        )-        [1 .. 3]+  mapM_+    ( \i -> do+        let sn = StreamName ("rdl-" <> T.pack (show (i :: Int)))+        Right _ <- runStoreIO store $ appendToStream sn NoStream [makeEvent "E" (Aeson.object [])]+        pure ()+    )+    [1 .. 3]  spec :: Spec spec = describe "subscription dispositions — retry / dead-letter (EP-40)" $ do-    it "dead-letters one event, advances the checkpoint, and keeps delivering" $ do-        withTestStore $ \store -> do-            appendThree store-            waitForPublisher store (GlobalPosition 3)-            delivered <- newIORef ([] :: [Int64])-            let subName = SubscriptionName "rdl-deadletter"-                handler' evt = do-                    let p = posOf evt-                    modifyIORef' delivered (p :)-                    case p of-                        2 -> pure (DeadLetter (DeadLetterPoison "boom"))-                        3 -> pure Stop-                        _ -> pure Continue-                cfg = (defaultSubscriptionConfig subName AllStreams handler'){batchSize = defaultBatchSize}-            handle <- subscribe store cfg-            result <- waitWithTimeout 20_000_000 handle-            case result of-                Left timeout -> expectationFailure timeout-                Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)-                Right (Right ()) -> pure ()-            ds <- reverse <$> readIORef delivered-            -- Each event delivered once; event 2 was dead-lettered (not retried).-            ds `shouldBe` [1, 2, 3]-            dls <- readDeadLetters store "rdl-deadletter"-            map SQL.deadLetterGlobalPosition dls `shouldBe` [2]-            -- Checkpoint advanced to the last processed event (the Stop at 3).-            cp <- readCheckpoint store "rdl-deadletter"-            cp `shouldBe` Just 3+  it "dead-letters one event, advances the checkpoint, and keeps delivering" $ do+    withTestStore $ \store -> do+      appendThree store+      waitForPublisher store (GlobalPosition 3)+      delivered <- newIORef ([] :: [Int64])+      let subName = SubscriptionName "rdl-deadletter"+          handler' evt = do+            let p = posOf evt+            modifyIORef' delivered (p :)+            case p of+              2 -> pure (DeadLetter (DeadLetterPoison "boom"))+              3 -> pure Stop+              _ -> pure Continue+          cfg = (defaultSubscriptionConfig subName AllStreams handler') {batchSize = defaultBatchSize}+      handle <- subscribe store cfg+      result <- waitWithTimeout 20_000_000 handle+      case result of+        Left timeout -> expectationFailure timeout+        Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)+        Right (Right ()) -> pure ()+      ds <- reverse <$> readIORef delivered+      -- Each event delivered once; event 2 was dead-lettered (not retried).+      ds `shouldBe` [1, 2, 3]+      dls <- readDeadLetters store "rdl-deadletter"+      map SQL.deadLetterGlobalPosition dls `shouldBe` [2]+      -- Checkpoint advanced to the last processed event (the Stop at 3).+      cp <- readCheckpoint store "rdl-deadletter"+      cp `shouldBe` Just 3 -    it "retries one event until it succeeds without advancing past it" $ do-        withTestStore $ \store -> do-            appendThree store-            waitForPublisher store (GlobalPosition 3)-            delivered <- newIORef ([] :: [Int64])-            pos2Count <- newIORef (0 :: Int)-            let subName = SubscriptionName "rdl-retry"-                handler' evt = do-                    let p = posOf evt-                    modifyIORef' delivered (p :)-                    case p of-                        2 -> do-                            c <- atomicModifyIORef' pos2Count (\x -> (x + 1, x + 1))-                            -- Retry the first two deliveries, succeed on the third.-                            if c <= 2 then pure (Retry (RetryDelay 0)) else pure Continue-                        3 -> pure Stop-                        _ -> pure Continue-                cfg = (defaultSubscriptionConfig subName AllStreams handler'){batchSize = defaultBatchSize}-            handle <- subscribe store cfg-            result <- waitWithTimeout 20_000_000 handle-            case result of-                Left timeout -> expectationFailure timeout-                Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)-                Right (Right ()) -> pure ()-            ds <- reverse <$> readIORef delivered-            -- Event 2 delivered three times (two retries + success); 1 and 3 once each.-            length (filter (== 2) ds) `shouldBe` 3-            ds `shouldBe` [1, 2, 2, 2, 3]-            -- No dead letters; the retry succeeded.-            dls <- readDeadLetters store "rdl-retry"-            dls `shouldSatisfy` null-            cp <- readCheckpoint store "rdl-retry"-            cp `shouldBe` Just 3+  it "retries one event until it succeeds without advancing past it" $ do+    withTestStore $ \store -> do+      appendThree store+      waitForPublisher store (GlobalPosition 3)+      delivered <- newIORef ([] :: [Int64])+      pos2Count <- newIORef (0 :: Int)+      let subName = SubscriptionName "rdl-retry"+          handler' evt = do+            let p = posOf evt+            modifyIORef' delivered (p :)+            case p of+              2 -> do+                c <- atomicModifyIORef' pos2Count (\x -> (x + 1, x + 1))+                -- Retry the first two deliveries, succeed on the third.+                if c <= 2 then pure (Retry (RetryDelay 0)) else pure Continue+              3 -> pure Stop+              _ -> pure Continue+          cfg = (defaultSubscriptionConfig subName AllStreams handler') {batchSize = defaultBatchSize}+      handle <- subscribe store cfg+      result <- waitWithTimeout 20_000_000 handle+      case result of+        Left timeout -> expectationFailure timeout+        Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)+        Right (Right ()) -> pure ()+      ds <- reverse <$> readIORef delivered+      -- Event 2 delivered three times (two retries + success); 1 and 3 once each.+      length (filter (== 2) ds) `shouldBe` 3+      ds `shouldBe` [1, 2, 2, 2, 3]+      -- No dead letters; the retry succeeded.+      dls <- readDeadLetters store "rdl-retry"+      dls `shouldSatisfy` null+      cp <- readCheckpoint store "rdl-retry"+      cp `shouldBe` Just 3 -    it "dead-letters after exhausting the retry budget and continues" $ do-        withTestStore $ \store -> do-            appendThree store-            waitForPublisher store (GlobalPosition 3)-            delivered <- newIORef ([] :: [Int64])-            let subName = SubscriptionName "rdl-exhaust"-                handler' evt = do-                    let p = posOf evt-                    modifyIORef' delivered (p :)-                    case p of-                        2 -> pure (Retry (RetryDelay 0)) -- always retry → exhausts the budget-                        3 -> pure Stop-                        _ -> pure Continue-                cfg =-                    (defaultSubscriptionConfig subName AllStreams handler')-                        { batchSize = defaultBatchSize-                        , retryPolicy = RetryPolicy{retryMaxAttempts = 3}-                        }-            handle <- subscribe store cfg-            result <- waitWithTimeout 20_000_000 handle-            case result of-                Left timeout -> expectationFailure timeout-                Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)-                Right (Right ()) -> pure ()-            ds <- reverse <$> readIORef delivered-            -- Event 2 delivered exactly retryMaxAttempts (3) times, then dead-lettered.-            length (filter (== 2) ds) `shouldBe` 3-            ds `shouldBe` [1, 2, 2, 2, 3]-            dls <- readDeadLetters store "rdl-exhaust"-            map SQL.deadLetterGlobalPosition dls `shouldBe` [2]-            map SQL.deadLetterAttemptCount dls `shouldBe` [3]-            -- Later events still processed; checkpoint advanced to the end.-            cp <- readCheckpoint store "rdl-exhaust"-            cp `shouldBe` Just 3+  it "dead-letters after exhausting the retry budget and continues" $ do+    withTestStore $ \store -> do+      appendThree store+      waitForPublisher store (GlobalPosition 3)+      delivered <- newIORef ([] :: [Int64])+      let subName = SubscriptionName "rdl-exhaust"+          handler' evt = do+            let p = posOf evt+            modifyIORef' delivered (p :)+            case p of+              2 -> pure (Retry (RetryDelay 0)) -- always retry → exhausts the budget+              3 -> pure Stop+              _ -> pure Continue+          cfg =+            (defaultSubscriptionConfig subName AllStreams handler')+              { batchSize = defaultBatchSize,+                retryPolicy = RetryPolicy {retryMaxAttempts = 3}+              }+      handle <- subscribe store cfg+      result <- waitWithTimeout 20_000_000 handle+      case result of+        Left timeout -> expectationFailure timeout+        Right (Left e) -> expectationFailure ("expected a clean stop, got: " <> show e)+        Right (Right ()) -> pure ()+      ds <- reverse <$> readIORef delivered+      -- Event 2 delivered exactly retryMaxAttempts (3) times, then dead-lettered.+      length (filter (== 2) ds) `shouldBe` 3+      ds `shouldBe` [1, 2, 2, 2, 3]+      dls <- readDeadLetters store "rdl-exhaust"+      map SQL.deadLetterGlobalPosition dls `shouldBe` [2]+      map SQL.deadLetterAttemptCount dls `shouldBe` [3]+      -- Later events still processed; checkpoint advanced to the end.+      cp <- readCheckpoint store "rdl-exhaust"+      cp `shouldBe` Just 3
test/Test/SubscriptionState.hs view
@@ -1,20 +1,19 @@ {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} -{- | Regression tests for ExecPlan 41 M4 — observable FSM state and the-no-missed-events / monotonic-checkpoint invariants under the FSM driver.--The first spec reads the worker's current state through the new-'Kiroku.Store.Subscription.Types.currentState' handle accessor: while a handler-blocks on its first catch-up event the worker is observably @CatchingUp@, and-once it has caught up it is observably @Live@.--The second spec proves the FSM driver preserves the core delivery invariant: with-events appended both before and after the subscription starts, every event is-delivered exactly once in strictly increasing 'GlobalPosition' across the-catch-up → live boundary, and the durable checkpoint advances to the last-position (a monotonic, no-gap result).--}+-- | Regression tests for ExecPlan 41 M4 — observable FSM state and the+-- no-missed-events / monotonic-checkpoint invariants under the FSM driver.+--+-- The first spec reads the worker's current state through the new+-- 'Kiroku.Store.Subscription.Types.currentState' handle accessor: while a handler+-- blocks on its first catch-up event the worker is observably @CatchingUp@, and+-- once it has caught up it is observably @Live@.+--+-- The second spec proves the FSM driver preserves the core delivery invariant: with+-- events appended both before and after the subscription starts, every event is+-- delivered exactly once in strictly increasing 'GlobalPosition' across the+-- catch-up → live boundary, and the durable checkpoint advances to the last+-- position (a monotonic, no-gap result). module Test.SubscriptionState (spec) where  import Control.Concurrent (threadDelay)@@ -35,11 +34,11 @@ import Test.Hspec  isCatchingUp :: SubscriptionState -> Bool-isCatchingUp CatchingUp{} = True+isCatchingUp CatchingUp {} = True isCatchingUp _ = False  isLive :: SubscriptionState -> Bool-isLive Live{} = True+isLive Live {} = True isLive _ = False  -- Poll an FSM-state read until the predicate holds or the budget runs out.@@ -47,118 +46,118 @@ -- @Nothing@ (not currently live) is treated as "not yet in the wanted state". waitUntilState :: Int -> IO (Maybe SubscriptionState) -> (SubscriptionState -> Bool) -> IO Bool waitUntilState budget readSt p-    | budget <= 0 = pure False-    | otherwise = do-        s <- readSt-        if maybe False p s-            then pure True-            else threadDelay 20_000 >> waitUntilState (budget - 20_000) readSt p+  | budget <= 0 = pure False+  | otherwise = do+      s <- readSt+      if maybe False p s+        then pure True+        else threadDelay 20_000 >> waitUntilState (budget - 20_000) readSt p  readCheckpoint :: KirokuStore -> T.Text -> IO (Maybe Int) readCheckpoint store subName = do-    result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)-    case result of-        Left err -> error ("readCheckpoint failed: " <> show err)-        Right mPos -> pure (fmap fromIntegral mPos)+  result <- Pool.use (store ^. #pool) (Session.statement (subName, 0 :: Int32) SQL.getCheckpointMemberStmt)+  case result of+    Left err -> error ("readCheckpoint failed: " <> show err)+    Right mPos -> pure (fmap fromIntegral mPos)  spec :: Spec spec = describe "subscription FSM — observable state (EP-41 M4)" $ do-    it "reports CatchingUp while blocked in catch-up and Live once caught up" $ do-        caughtUp <- newEmptyMVar-        firstSeen <- newEmptyMVar-        release <- newEmptyMVar-        seen <- newTVarIO (0 :: Int)-        let subName = SubscriptionName "state-readout-test"-            observe = caughtUpEventHandler subName caughtUp Nothing-            handler' _evt = do-                n <- atomically $ do-                    c <- readTVar seen-                    writeTVar seen (c + 1)-                    pure (c + 1)-                if n == 1-                    then putMVar firstSeen () >> takeMVar release-                    else pure ()-                pure Continue-            cfg =-                SubscriptionConfig-                    { name = subName-                    , target = AllStreams-                    , handler = handler'-                    , batchSize = defaultBatchSize-                    , queueCapacity = 16-                    , overflowPolicy = PauseAndResume-                    , consumerGroup = Nothing-                    , consumerGroupGuard = False-                    , missingCheckpointPolicy = FromBeginning-                    , targetBindingPolicy = AdoptUnbound-                    , undecodableHandler = Nothing-                    , handlerStallWarnAfter = Nothing-                    , retryPolicy = defaultRetryPolicy-                    , eventTypeFilter = AllEventTypes-                    , selector = Nothing-                    }-        withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do-            Right _ <- runStoreIO store $ appendToStream (StreamName "st-1") NoStream [makeEvent "A" (Aeson.object [])]-            Right _ <- runStoreIO store $ appendToStream (StreamName "st-2") NoStream [makeEvent "B" (Aeson.object [])]-            Right _ <- runStoreIO store $ appendToStream (StreamName "st-3") NoStream [makeEvent "C" (Aeson.object [])]-            waitForPublisher store (GlobalPosition 3)-            handle <- subscribe store cfg-            -- Worker is now blocked inside the handler on the first catch-up event.-            takeMVar firstSeen-            Just st <- currentState handle-            st `shouldSatisfy` isCatchingUp-            -- Release: catch-up completes, the worker goes live.-            putMVar release ()-            waitForSubscriptionLive caughtUp-            becameLive <- waitUntilState 5_000_000 (currentState handle) isLive-            becameLive `shouldBe` True-            cancel handle+  it "reports CatchingUp while blocked in catch-up and Live once caught up" $ do+    caughtUp <- newEmptyMVar+    firstSeen <- newEmptyMVar+    release <- newEmptyMVar+    seen <- newTVarIO (0 :: Int)+    let subName = SubscriptionName "state-readout-test"+        observe = caughtUpEventHandler subName caughtUp Nothing+        handler' _evt = do+          n <- atomically $ do+            c <- readTVar seen+            writeTVar seen (c + 1)+            pure (c + 1)+          if n == 1+            then putMVar firstSeen () >> takeMVar release+            else pure ()+          pure Continue+        cfg =+          SubscriptionConfig+            { name = subName,+              target = AllStreams,+              handler = handler',+              batchSize = defaultBatchSize,+              queueCapacity = 16,+              overflowPolicy = PauseAndResume,+              consumerGroup = Nothing,+              consumerGroupGuard = False,+              missingCheckpointPolicy = FromBeginning,+              targetBindingPolicy = AdoptUnbound,+              undecodableHandler = Nothing,+              handlerStallWarnAfter = Nothing,+              retryPolicy = defaultRetryPolicy,+              eventTypeFilter = AllEventTypes,+              selector = Nothing+            }+    withTestStoreSettings (& #eventHandler .~ Just observe) $ \store -> do+      Right _ <- runStoreIO store $ appendToStream (StreamName "st-1") NoStream [makeEvent "A" (Aeson.object [])]+      Right _ <- runStoreIO store $ appendToStream (StreamName "st-2") NoStream [makeEvent "B" (Aeson.object [])]+      Right _ <- runStoreIO store $ appendToStream (StreamName "st-3") NoStream [makeEvent "C" (Aeson.object [])]+      waitForPublisher store (GlobalPosition 3)+      handle <- subscribe store cfg+      -- Worker is now blocked inside the handler on the first catch-up event.+      takeMVar firstSeen+      Just st <- currentState handle+      st `shouldSatisfy` isCatchingUp+      -- Release: catch-up completes, the worker goes live.+      putMVar release ()+      waitForSubscriptionLive caughtUp+      becameLive <- waitUntilState 5_000_000 (currentState handle) isLive+      becameLive `shouldBe` True+      cancel handle -    it "delivers every event exactly once in order across the catch-up to live boundary" $ do-        delivered <- newIORef ([] :: [GlobalPosition])-        seen <- newTVarIO (0 :: Int)-        let subName = SubscriptionName "no-missed-test"-            handler' evt = do-                modifyIORef' delivered ((evt ^. #globalPosition) :)-                n <- atomically $ do-                    c <- readTVar seen-                    writeTVar seen (c + 1)-                    pure (c + 1)-                if n >= 10 then pure Stop else pure Continue-            cfg =-                SubscriptionConfig-                    { name = subName-                    , target = AllStreams-                    , handler = handler'-                    , batchSize = defaultBatchSize-                    , queueCapacity = 16-                    , overflowPolicy = PauseAndResume-                    , consumerGroup = Nothing-                    , consumerGroupGuard = False-                    , missingCheckpointPolicy = FromBeginning-                    , targetBindingPolicy = AdoptUnbound-                    , undecodableHandler = Nothing-                    , handlerStallWarnAfter = Nothing-                    , retryPolicy = defaultRetryPolicy-                    , eventTypeFilter = AllEventTypes-                    , selector = Nothing-                    }-        withTestStoreSettings Prelude.id $ \store -> do-            -- Five events before subscribing (delivered during catch-up).-            let appendAt i =-                    runStoreIO store $-                        appendToStream (StreamName ("nm-" <> T.pack (show (i :: Int)))) NoStream [makeEvent "E" (Aeson.object [])]-            mapM_ (\i -> appendAt i >> pure ()) [1 .. 5]-            waitForPublisher store (GlobalPosition 5)-            handle <- subscribe store cfg-            -- Five more after (delivered live).-            mapM_ (\i -> appendAt i >> pure ()) [6 .. 10]-            result <- waitWithTimeout 20_000_000 handle-            case result of-                Left timeout -> expectationFailure timeout-                Right (Left e) -> expectationFailure ("subscription failed: " <> show e)-                Right (Right ()) -> pure ()-            ds <- reverse <$> readIORef delivered-            ds `shouldBe` map GlobalPosition [1 .. 10]-            cp <- readCheckpoint store "no-missed-test"-            cp `shouldBe` Just 10+  it "delivers every event exactly once in order across the catch-up to live boundary" $ do+    delivered <- newIORef ([] :: [GlobalPosition])+    seen <- newTVarIO (0 :: Int)+    let subName = SubscriptionName "no-missed-test"+        handler' evt = do+          modifyIORef' delivered ((evt ^. #globalPosition) :)+          n <- atomically $ do+            c <- readTVar seen+            writeTVar seen (c + 1)+            pure (c + 1)+          if n >= 10 then pure Stop else pure Continue+        cfg =+          SubscriptionConfig+            { name = subName,+              target = AllStreams,+              handler = handler',+              batchSize = defaultBatchSize,+              queueCapacity = 16,+              overflowPolicy = PauseAndResume,+              consumerGroup = Nothing,+              consumerGroupGuard = False,+              missingCheckpointPolicy = FromBeginning,+              targetBindingPolicy = AdoptUnbound,+              undecodableHandler = Nothing,+              handlerStallWarnAfter = Nothing,+              retryPolicy = defaultRetryPolicy,+              eventTypeFilter = AllEventTypes,+              selector = Nothing+            }+    withTestStoreSettings Prelude.id $ \store -> do+      -- Five events before subscribing (delivered during catch-up).+      let appendAt i =+            runStoreIO store $+              appendToStream (StreamName ("nm-" <> T.pack (show (i :: Int)))) NoStream [makeEvent "E" (Aeson.object [])]+      mapM_ (\i -> appendAt i >> pure ()) [1 .. 5]+      waitForPublisher store (GlobalPosition 5)+      handle <- subscribe store cfg+      -- Five more after (delivered live).+      mapM_ (\i -> appendAt i >> pure ()) [6 .. 10]+      result <- waitWithTimeout 20_000_000 handle+      case result of+        Left timeout -> expectationFailure timeout+        Right (Left e) -> expectationFailure ("subscription failed: " <> show e)+        Right (Right ()) -> pure ()+      ds <- reverse <$> readIORef delivered+      ds `shouldBe` map GlobalPosition [1 .. 10]+      cp <- readCheckpoint store "no-missed-test"+      cp `shouldBe` Just 10
test/Test/SubscriptionTarget.hs view
@@ -9,7 +9,7 @@ import Control.Monad (void) import Data.Aeson qualified as Aeson import Data.Generics.Labels ()-import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import Data.IORef (atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef) import Data.Int (Int32, Int64) import Data.Text (Text) import Data.Vector qualified as V@@ -28,160 +28,189 @@  spec :: Spec spec = do-    describe "subscription configuration" $ do-        it "validates batch and bridge buffer boundaries as values" $ do-            mkBatchSize 0 `shouldBe` Left (InvalidBatchSize 0)-            mkBatchSize (-1) `shouldBe` Left (InvalidBatchSize (-1))-            fmap batchSizeValue (mkBatchSize 1) `shouldBe` Right 1-            batchSizeValue defaultBatchSize `shouldBe` 100-            Buffer.mkStreamBufferSize 0 `shouldBe` Left (Buffer.InvalidStreamBufferSize 0)-            fmap Buffer.streamBufferSizeValue (Buffer.mkStreamBufferSize 1) `shouldBe` Right 1-        it "exposes every runtime startup refusal through the parent and concrete catches" $ do-            let name = SubscriptionName "hierarchy"-                key = SubscriptionCheckpointKey name 0-            assertHierarchy (SubscriptionCheckpointMissing key)-            assertHierarchy (ConsumerGroupGuardConflict name 0)-            assertHierarchy (ConsumerGroupSizeMismatch name 2 (V.singleton 1))-            assertHierarchy (SubscriptionTargetMismatch name AllStreams (V.singleton Nothing))-    describe "checkpoint target" $ do-        it "refuses accidental target reuse before delivery, then resumes after explicit rebind" $-            withTestStore $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "target-1") NoStream [makeEvent "E" (Aeson.object [])]-                first <- subscribe store (cfg "retarget" AllStreams (\_ -> pure Stop))-                clean first-                called <- newIORef False-                let newTarget = Category (CategoryName "target")-                refused <- subscribe store (cfg "retarget" newTarget (\_ -> writeIORef called True >> pure Stop))-                mismatch refused-                readIORef called `shouldReturn` False-                report <- rebind store "retarget" newTarget 0-                reboundMemberCount report `shouldBe` 1-                previousBindings report `shouldBe` V.singleton (Just AllStreams)-                accepted <- subscribe store (cfg "retarget" newTarget (\_ -> writeIORef called True >> pure Stop))-                clean accepted-                readIORef called `shouldReturn` True-                rows store "retarget" `shouldReturn` [(0, "category", Just "target", 1)]-        it "adopts every legacy member once and emits one bound event for the name" $ do-            events <- newIORef []-            let observe e = modifyIORef' events (e :)-            withTestStoreSettings (\s -> s{eventHandler = Just observe}) $ \store -> do-                void $ resize store "adopt" 2-                startLive store ((cfg "adopt" AllStreams (\_ -> pure Continue)){consumerGroup = Just (validConsumerGroup 0 2)})-                startLive store ((cfg "adopt" AllStreams (\_ -> pure Continue)){consumerGroup = Just (validConsumerGroup 1 2), targetBindingPolicy = RequireBound})-                rows store "adopt" `shouldReturn` [(0, "all", Nothing, 0), (1, "all", Nothing, 0)]-                seen <- readIORef events-                length [() | KirokuEventSubscriptionTargetBound (SubscriptionName "adopt") AllStreams _ <- seen] `shouldBe` 1-        it "refuses unbound rows under RequireBound without changing rows or invoking the handler" $-            withTestStore $ \store -> do-                void $ resize store "strict" 1-                called <- newIORef False-                handle <- subscribe store ((cfg "strict" AllStreams (\_ -> writeIORef called True >> pure Continue)){targetBindingPolicy = RequireBound})-                mismatch handle-                readIORef called `shouldReturn` False-                rows store "strict" `shouldReturn` [(0, "unbound", Nothing, 0)]-        it "refuses mixed bound/unbound siblings before inserting a missing member" $-            withTestStore $ \store -> do-                void $ resize store "mixed-target" 3-                runSession store (Session.script "UPDATE subscriptions SET target_kind = 'all' WHERE subscription_name = 'mixed-target' AND consumer_group_member = 0; DELETE FROM subscriptions WHERE subscription_name = 'mixed-target' AND consumer_group_member = 2")-                handle <- subscribe store ((cfg "mixed-target" AllStreams (\_ -> expectationFailure "handler ran" >> pure Stop)){consumerGroup = Just (validConsumerGroup 2 3)})-                mismatch handle-                rows store "mixed-target" `shouldReturn` [(0, "all", Nothing, 0), (1, "unbound", Nothing, 0)]-        it "does not adopt siblings when FailIfMissing refuses an absent member" $-            withTestStore $ \store -> do-                void $ resize store "missing-member" 2-                runSession store (Session.script "DELETE FROM subscriptions WHERE subscription_name = 'missing-member' AND consumer_group_member = 1")-                handle <- subscribe store ((cfg "missing-member" AllStreams (\_ -> pure Stop)){consumerGroup = Just (validConsumerGroup 1 2), missingCheckpointPolicy = FailIfMissing})-                waitWithTimeout 5_000_000 handle >>= \case-                    Right (Left e) -> (fromException e :: Maybe SubscriptionCheckpointMissing) `shouldSatisfy` maybe False (const True)-                    other -> expectationFailure (show other)-                rows store "missing-member" `shouldReturn` [(0, "unbound", Nothing, 0)]-        it "serializes conflicting targets on an initially absent name" $-            withTestStore $ \store -> do-                [a, b] <- Async.mapConcurrently (\target -> subscribe store (cfg "competing-targets" target (\_ -> pure Continue))) [AllStreams, Category (CategoryName "other")]-                outcome <- timeout 5_000_000 (Async.race (wait a) (wait b))-                case outcome of-                    Just (Left (Left e)) -> assertMismatch e-                    Just (Right (Left e)) -> assertMismatch e-                    other -> expectationFailure (show other)-                mapM_ cancel [a, b]-                length <$> rows store "competing-targets" `shouldReturn` 1-        it "preserves category binding on resize and initializes fresh names under RequireBound" $-            withTestStore $ \store -> do-                let target = Category (CategoryName "preserved")-                startLive store ((cfg "bound-resize" target (\_ -> pure Continue)){targetBindingPolicy = RequireBound})-                void $ resize store "bound-resize" 3-                rows store "bound-resize" `shouldReturn` [(m, "category", Just "preserved", 0) | m <- [0 .. 2]]-                startLive store ((cfg "bound-resize" target (\_ -> pure Continue)){consumerGroup = Just (validConsumerGroup 2 3), targetBindingPolicy = RequireBound})-        it "rebinds all members atomically, repeats idempotently, and rolls back with the caller" $-            withTestStore $ \store -> do-                void $ resize store "rebind-group" 3-                report <- rebind store "rebind-group" AllStreams 7-                reboundMemberCount report `shouldBe` 3-                previousBindings report `shouldBe` V.singleton Nothing-                rows store "rebind-group" `shouldReturn` [(m, "all", Nothing, 7) | m <- [0 .. 2]]-                again <- rebind store "rebind-group" AllStreams 7-                previousBindings again `shouldBe` V.singleton (Just AllStreams)-                void $ runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do-                    result <- rebindSubscriptionTargetTx (SubscriptionName "rebind-group") (Category (CategoryName "rollback")) (GlobalPosition 0)-                    Tx.condemn-                    pure result-                rows store "rebind-group" `shouldReturn` [(m, "all", Nothing, 7) | m <- [0 .. 2]]-                absent <- Pool.use (store ^. #pool) $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write (rebindSubscriptionTargetTx (SubscriptionName "absent") AllStreams (GlobalPosition 0))-                absent `shouldSatisfy` either (const True) (const False)-        it "persists target binding through an atomic dead-letter save" $-            withTestStore $ \store -> do-                Right _ <- runStoreIO store $ appendToStream (StreamName "poison-target-1") NoStream [makeEvent "E" (Aeson.object [])]-                withSubscription store (cfg "target-dead-letter" (Category (CategoryName "poison")) (\_ -> pure (DeadLetter (DeadLetterPoison "chosen by consumer")))) $ \_ -> do-                    let awaitDurable = do-                            persisted <- rows store "target-dead-letter"-                            if persisted == [(0, "category", Just "poison", 1)]-                                then pure ()-                                else threadDelay 1_000 >> awaitDurable-                    timeout 5_000_000 awaitDurable `shouldReturn` Just ()-                rows store "target-dead-letter" `shouldReturn` [(0, "category", Just "poison", 1)]+  describe "subscription configuration" $ do+    it "validates batch and bridge buffer boundaries as values" $ do+      mkBatchSize 0 `shouldBe` Left (InvalidBatchSize 0)+      mkBatchSize (-1) `shouldBe` Left (InvalidBatchSize (-1))+      fmap batchSizeValue (mkBatchSize 1) `shouldBe` Right 1+      batchSizeValue defaultBatchSize `shouldBe` 100+      Buffer.mkStreamBufferSize 0 `shouldBe` Left (Buffer.InvalidStreamBufferSize 0)+      fmap Buffer.streamBufferSizeValue (Buffer.mkStreamBufferSize 1) `shouldBe` Right 1+    it "exposes every runtime startup refusal through the parent and concrete catches" $ do+      let name = SubscriptionName "hierarchy"+          key = SubscriptionCheckpointKey name 0+      assertHierarchy (SubscriptionCheckpointMissing key)+      assertHierarchy (ConsumerGroupGuardConflict name 0)+      assertHierarchy (ConsumerGroupSizeMismatch name 2 (V.singleton 1))+      assertHierarchy (SubscriptionTargetMismatch name AllStreams (V.singleton Nothing))+  describe "checkpoint target" $ do+    it "refuses accidental target reuse before delivery, then resumes after explicit rebind" $+      withTestStore $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "target-1") NoStream [makeEvent "E" (Aeson.object [])]+        first <- subscribe store (cfg "retarget" AllStreams (\_ -> pure Stop))+        clean first+        called <- newIORef False+        let newTarget = Category (CategoryName "target")+        refused <- subscribe store (cfg "retarget" newTarget (\_ -> writeIORef called True >> pure Stop))+        mismatch refused+        readIORef called `shouldReturn` False+        report <- rebind store "retarget" newTarget 0+        reboundMemberCount report `shouldBe` 1+        previousBindings report `shouldBe` V.singleton (Just AllStreams)+        accepted <- subscribe store (cfg "retarget" newTarget (\_ -> writeIORef called True >> pure Stop))+        clean accepted+        readIORef called `shouldReturn` True+        rows store "retarget" `shouldReturn` [(0, "category", Just "target", 1)]+    it "emits a target mismatch followed by exactly one crashed stop before wait returns" $ do+      events <- newIORef []+      let observe e = atomicModifyIORef' events (\seen -> (e : seen, ()))+          subName = SubscriptionName "observe-target"+          config = (cfg "observe-target" AllStreams (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 1 2), targetBindingPolicy = RequireBound}+          refusal = SubscriptionTargetMismatch subName AllStreams (V.singleton Nothing)+      withTestStoreSettings (\s -> s {eventHandler = Just observe}) $ \store -> do+        void $ resize store "observe-target" 2+        handle <- subscribe store config+        mismatch handle+        seen <- reverse <$> readIORef events+        let lifecycle =+              [ e+              | e <- seen,+                case e of+                  KirokuEventSubscriptionTargetMismatch {} -> True+                  KirokuEventSubscriptionStopped {} -> True+                  _ -> False+              ]+        case lifecycle of+          [KirokuEventSubscriptionTargetMismatch actual grp, KirokuEventSubscriptionStopped n pos (StopWorkerCrashed err) stopGrp] -> do+            actual `shouldBe` refusal+            grp `shouldBe` GroupMember 1 2+            n `shouldBe` subName+            pos `shouldBe` GlobalPosition 0+            stopGrp `shouldBe` grp+            fromException err `shouldBe` Just refusal+          _ -> expectationFailure ("unexpected refusal lifecycle: " <> show lifecycle)+    it "adopts every legacy member once and emits one bound event for the name" $ do+      events <- newIORef []+      let observe e = modifyIORef' events (e :)+      withTestStoreSettings (\s -> s {eventHandler = Just observe}) $ \store -> do+        void $ resize store "adopt" 2+        startLive store ((cfg "adopt" AllStreams (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 0 2)})+        startLive store ((cfg "adopt" AllStreams (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 1 2), targetBindingPolicy = RequireBound})+        rows store "adopt" `shouldReturn` [(0, "all", Nothing, 0), (1, "all", Nothing, 0)]+        seen <- readIORef events+        length [() | KirokuEventSubscriptionTargetBound (SubscriptionName "adopt") AllStreams _ <- seen] `shouldBe` 1+    it "refuses unbound rows under RequireBound without changing rows or invoking the handler" $+      withTestStore $ \store -> do+        void $ resize store "strict" 1+        called <- newIORef False+        handle <- subscribe store ((cfg "strict" AllStreams (\_ -> writeIORef called True >> pure Continue)) {targetBindingPolicy = RequireBound})+        mismatch handle+        readIORef called `shouldReturn` False+        rows store "strict" `shouldReturn` [(0, "unbound", Nothing, 0)]+    it "refuses mixed bound/unbound siblings before inserting a missing member" $+      withTestStore $ \store -> do+        void $ resize store "mixed-target" 3+        runSession store (Session.script "UPDATE subscriptions SET target_kind = 'all' WHERE subscription_name = 'mixed-target' AND consumer_group_member = 0; DELETE FROM subscriptions WHERE subscription_name = 'mixed-target' AND consumer_group_member = 2")+        handle <- subscribe store ((cfg "mixed-target" AllStreams (\_ -> expectationFailure "handler ran" >> pure Stop)) {consumerGroup = Just (validConsumerGroup 2 3)})+        mismatch handle+        rows store "mixed-target" `shouldReturn` [(0, "all", Nothing, 0), (1, "unbound", Nothing, 0)]+    it "does not adopt siblings when FailIfMissing refuses an absent member" $+      withTestStore $ \store -> do+        void $ resize store "missing-member" 2+        runSession store (Session.script "DELETE FROM subscriptions WHERE subscription_name = 'missing-member' AND consumer_group_member = 1")+        handle <- subscribe store ((cfg "missing-member" AllStreams (\_ -> pure Stop)) {consumerGroup = Just (validConsumerGroup 1 2), missingCheckpointPolicy = FailIfMissing})+        waitWithTimeout 5_000_000 handle >>= \case+          Right (Left e) -> (fromException e :: Maybe SubscriptionCheckpointMissing) `shouldSatisfy` maybe False (const True)+          other -> expectationFailure (show other)+        rows store "missing-member" `shouldReturn` [(0, "unbound", Nothing, 0)]+    it "serializes conflicting targets on an initially absent name" $+      withTestStore $ \store -> do+        [a, b] <- Async.mapConcurrently (\target -> subscribe store (cfg "competing-targets" target (\_ -> pure Continue))) [AllStreams, Category (CategoryName "other")]+        outcome <- timeout 5_000_000 (Async.race (wait a) (wait b))+        case outcome of+          Just (Left (Left e)) -> assertMismatch e+          Just (Right (Left e)) -> assertMismatch e+          other -> expectationFailure (show other)+        mapM_ cancel [a, b]+        length <$> rows store "competing-targets" `shouldReturn` 1+    it "preserves category binding on resize and initializes fresh names under RequireBound" $+      withTestStore $ \store -> do+        let target = Category (CategoryName "preserved")+        startLive store ((cfg "bound-resize" target (\_ -> pure Continue)) {targetBindingPolicy = RequireBound})+        void $ resize store "bound-resize" 3+        rows store "bound-resize" `shouldReturn` [(m, "category", Just "preserved", 0) | m <- [0 .. 2]]+        startLive store ((cfg "bound-resize" target (\_ -> pure Continue)) {consumerGroup = Just (validConsumerGroup 2 3), targetBindingPolicy = RequireBound})+    it "rebinds all members atomically, repeats idempotently, and rolls back with the caller" $+      withTestStore $ \store -> do+        void $ resize store "rebind-group" 3+        report <- rebind store "rebind-group" AllStreams 7+        reboundMemberCount report `shouldBe` 3+        previousBindings report `shouldBe` V.singleton Nothing+        rows store "rebind-group" `shouldReturn` [(m, "all", Nothing, 7) | m <- [0 .. 2]]+        again <- rebind store "rebind-group" AllStreams 7+        previousBindings again `shouldBe` V.singleton (Just AllStreams)+        void $ runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write $ do+          result <- rebindSubscriptionTargetTx (SubscriptionName "rebind-group") (Category (CategoryName "rollback")) (GlobalPosition 0)+          Tx.condemn+          pure result+        rows store "rebind-group" `shouldReturn` [(m, "all", Nothing, 7) | m <- [0 .. 2]]+        absent <- Pool.use (store ^. #pool) $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write (rebindSubscriptionTargetTx (SubscriptionName "absent") AllStreams (GlobalPosition 0))+        absent `shouldSatisfy` either (const True) (const False)+    it "persists target binding through an atomic dead-letter save" $+      withTestStore $ \store -> do+        Right _ <- runStoreIO store $ appendToStream (StreamName "poison-target-1") NoStream [makeEvent "E" (Aeson.object [])]+        withSubscription store (cfg "target-dead-letter" (Category (CategoryName "poison")) (\_ -> pure (DeadLetter (DeadLetterPoison "chosen by consumer")))) $ \_ -> do+          let awaitDurable = do+                persisted <- rows store "target-dead-letter"+                if persisted == [(0, "category", Just "poison", 1)]+                  then pure ()+                  else threadDelay 1_000 >> awaitDurable+          timeout 5_000_000 awaitDurable `shouldReturn` Just ()+        rows store "target-dead-letter" `shouldReturn` [(0, "category", Just "poison", 1)]  assertHierarchy :: (Exception e, Eq e) => e -> IO () assertHierarchy refusal = do-    parent <- try (throwIO refusal) :: IO (Either SomeSubscriptionStartupFailure ())-    case parent of-        Left caught -> fromException (toException caught) `shouldBe` Just refusal-        Right () -> expectationFailure "parent catch did not match"+  parent <- try (throwIO refusal) :: IO (Either SomeSubscriptionStartupFailure ())+  case parent of+    Left caught -> fromException (toException caught) `shouldBe` Just refusal+    Right () -> expectationFailure "parent catch did not match"  cfg :: Text -> SubscriptionTarget -> EventHandler -> SubscriptionConfig cfg name = defaultSubscriptionConfig (SubscriptionName name)  clean :: SubscriptionHandle -> IO () clean handle =-    waitWithTimeout 5_000_000 handle >>= \case-        Right (Right ()) -> pure ()-        other -> expectationFailure (show other)+  waitWithTimeout 5_000_000 handle >>= \case+    Right (Right ()) -> pure ()+    other -> expectationFailure (show other)  assertMismatch :: SomeException -> IO () assertMismatch e = do-    (fromException e :: Maybe SubscriptionTargetMismatch) `shouldSatisfy` maybe False (const True)-    (fromException e :: Maybe SomeSubscriptionStartupFailure) `shouldSatisfy` maybe False (const True)+  (fromException e :: Maybe SubscriptionTargetMismatch) `shouldSatisfy` maybe False (const True)+  (fromException e :: Maybe SomeSubscriptionStartupFailure) `shouldSatisfy` maybe False (const True)  mismatch :: SubscriptionHandle -> IO () mismatch handle =-    waitWithTimeout 5_000_000 handle >>= \case-        Right (Left e) -> assertMismatch e-        other -> expectationFailure (show other)+  waitWithTimeout 5_000_000 handle >>= \case+    Right (Left e) -> assertMismatch e+    other -> expectationFailure (show other)  startLive :: KirokuStore -> SubscriptionConfig -> IO () startLive store config = withSubscription store config $ \handle -> do-    result <- timeout 5_000_000 (awaitLive handle)-    result `shouldBe` Just ()+  result <- timeout 5_000_000 (awaitLive handle)+  result `shouldBe` Just ()   where     awaitLive handle =-        currentState handle >>= \case-            Just state | stateName state == "live" -> pure ()-            _ -> threadDelay 1_000 >> awaitLive handle+      currentState handle >>= \case+        Just state | stateName state == "live" -> pure ()+        _ -> threadDelay 1_000 >> awaitLive handle  runSession :: KirokuStore -> Session.Session a -> IO a runSession store session = Pool.use (store ^. #pool) session >>= either (fail . show) pure  resize :: KirokuStore -> Text -> Int32 -> IO ConsumerGroupResizeReport resize store name n = let Right size = mkConsumerGroupSize n in runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write (resizeConsumerGroupTx (SubscriptionName name) size)+ rebind :: KirokuStore -> Text -> SubscriptionTarget -> Int64 -> IO SubscriptionTargetRebindReport rebind store name target position = runSession store $ TxSessions.transaction TxSessions.ReadCommitted TxSessions.Write (rebindSubscriptionTargetTx (SubscriptionName name) target (GlobalPosition position)) @@ -189,7 +218,7 @@ rows store name = V.toList <$> runSession store (Session.statement name stmt)   where     stmt =-        preparable-            "SELECT consumer_group_member, target_kind, target_category, last_seen FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"-            (E.param (E.nonNullable E.text))-            (D.rowVector ((,,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text) <*> D.column (D.nonNullable D.int8)))+      preparable+        "SELECT consumer_group_member, target_kind, target_category, last_seen FROM subscriptions WHERE subscription_name = $1 ORDER BY consumer_group_member"+        (E.param (E.nonNullable E.text))+        (D.rowVector ((,,,) <$> D.column (D.nonNullable D.int4) <*> D.column (D.nonNullable D.text) <*> D.column (D.nullable D.text) <*> D.column (D.nonNullable D.int8)))
test/Test/Transaction.hs view
@@ -1,8 +1,7 @@-{- | Tests for "Kiroku.Store.Transaction" — the transactional escape-hatch and append combinators that let callers compose store operations-with arbitrary 'Hasql.Transaction.Transaction' work in one ACID-transaction.--}+-- | Tests for "Kiroku.Store.Transaction" — the transactional escape+-- hatch and append combinators that let callers compose store operations+-- with arbitrary 'Hasql.Transaction.Transaction' work in one ACID+-- transaction. module Test.Transaction (spec) where  import Contravariant.Extras (contrazip2)@@ -28,400 +27,398 @@  spec :: Spec spec = around withTestStore $ do-    describe "runTransaction" $ do-        it "executes a no-op Tx.statement and returns its result" $ \store -> do-            result <--                runStoreIO store $-                    runTransaction $-                        Tx.statement () selectOneStmt-            case result of-                Right n -> n `shouldBe` (1 :: Int)-                Left err -> expectationFailure ("Expected Right 1, got: " <> show err)+  describe "runTransaction" $ do+    it "executes a no-op Tx.statement and returns its result" $ \store -> do+      result <-+        runStoreIO store $+          runTransaction $+            Tx.statement () selectOneStmt+      case result of+        Right n -> n `shouldBe` (1 :: Int)+        Left err -> expectationFailure ("Expected Right 1, got: " <> show err) -        it "preserves SQLSTATE and message for an opaque foreign-key failure" $ \store -> do-            createForeignKeyTables (store ^. #pool)-            result <--                runStoreIO store $-                    runTransaction $-                        Tx.statement () insertMissingForeignKeyStmt-            case result of-                Left (UnexpectedServerError code message) -> do-                    code `shouldBe` "23503"-                    T.toLower message `shouldSatisfy` T.isInfixOf "foreign key"-                Left (StreamNotFound (StreamName "<transaction>")) ->-                    expectationFailure "foreign-key failure was misclassified as a missing <transaction> stream"-                other ->-                    expectationFailure ("Expected UnexpectedServerError 23503, got: " <> show other)+    it "preserves SQLSTATE and message for an opaque foreign-key failure" $ \store -> do+      createForeignKeyTables (store ^. #pool)+      result <-+        runStoreIO store $+          runTransaction $+            Tx.statement () insertMissingForeignKeyStmt+      case result of+        Left (UnexpectedServerError code message) -> do+          code `shouldBe` "23503"+          T.toLower message `shouldSatisfy` T.isInfixOf "foreign key"+        Left (StreamNotFound (StreamName "<transaction>")) ->+          expectationFailure "foreign-key failure was misclassified as a missing <transaction> stream"+        other ->+          expectationFailure ("Expected UnexpectedServerError 23503, got: " <> show other) -    describe "appendToStreamTx (driven inside runTransaction)" $ do-        it "appends events and a side-table row in one atomic transaction" $ \store -> do-            createSideTable (store ^. #pool)-            let evt = makeEvent "Created" (Aeson.object [("x", Aeson.Number 1)])-            prepared <- prepareEventsIO [evt]-            now <- getCurrentTime-            result <- runStoreIO store $ runTransaction $ do-                outcome <- appendToStreamTx (StreamName "txn-success-1") NoStream prepared now-                case outcome of-                    Left c -> pure (Left c)-                    Right ar -> do-                        let StreamId sid = ar ^. #streamId-                        Tx.statement (sid, "hello") insertSideRowStmt-                        pure (Right ar)-            case result of-                Right (Right ar) -> do-                    (ar ^. #streamVersion) `shouldBe` StreamVersion 1-                    rows <- countSideTable (store ^. #pool)-                    rows `shouldBe` 1-                other ->-                    expectationFailure-                        ("Expected Right (Right AppendResult), got: " <> show other)+  describe "appendToStreamTx (driven inside runTransaction)" $ do+    it "appends events and a side-table row in one atomic transaction" $ \store -> do+      createSideTable (store ^. #pool)+      let evt = makeEvent "Created" (Aeson.object [("x", Aeson.Number 1)])+      prepared <- prepareEventsIO [evt]+      now <- getCurrentTime+      result <- runStoreIO store $ runTransaction $ do+        outcome <- appendToStreamTx (StreamName "txn-success-1") NoStream prepared now+        case outcome of+          Left c -> pure (Left c)+          Right ar -> do+            let StreamId sid = ar ^. #streamId+            Tx.statement (sid, "hello") insertSideRowStmt+            pure (Right ar)+      case result of+        Right (Right ar) -> do+          (ar ^. #streamVersion) `shouldBe` StreamVersion 1+          rows <- countSideTable (store ^. #pool)+          rows `shouldBe` 1+        other ->+          expectationFailure+            ("Expected Right (Right AppendResult), got: " <> show other) -        it "rolls back the append and the side row when the body condemns" $ \store -> do-            createSideTable (store ^. #pool)-            let evt = makeEvent "Created" (Aeson.object [])-            prepared <- prepareEventsIO [evt]-            now <- getCurrentTime-            result <- runStoreIO store $ runTransaction $ do-                outcome <- appendToStreamTx (StreamName "txn-condemn-1") NoStream prepared now-                case outcome of-                    Right ar -> do-                        let StreamId sid = ar ^. #streamId-                        Tx.statement (sid, "should-not-persist") insertSideRowStmt-                        Tx.condemn-                    Left _ -> Tx.condemn-            result `shouldBe` Right ()-            mInfo <- runStoreIO store $ getStream (StreamName "txn-condemn-1")-            mInfo `shouldBe` Right Nothing-            rows <- countSideTable (store ^. #pool)-            rows `shouldBe` 0+    it "rolls back the append and the side row when the body condemns" $ \store -> do+      createSideTable (store ^. #pool)+      let evt = makeEvent "Created" (Aeson.object [])+      prepared <- prepareEventsIO [evt]+      now <- getCurrentTime+      result <- runStoreIO store $ runTransaction $ do+        outcome <- appendToStreamTx (StreamName "txn-condemn-1") NoStream prepared now+        case outcome of+          Right ar -> do+            let StreamId sid = ar ^. #streamId+            Tx.statement (sid, "should-not-persist") insertSideRowStmt+            Tx.condemn+          Left _ -> Tx.condemn+      result `shouldBe` Right ()+      mInfo <- runStoreIO store $ getStream (StreamName "txn-condemn-1")+      mInfo `shouldBe` Right Nothing+      rows <- countSideTable (store ^. #pool)+      rows `shouldBe` 0 -        it "returns Left on version conflict; the caller's branch sees no AppendResult" $ \store -> do-            createSideTable (store ^. #pool)-            -- Pre-populate the stream so the ExactVersion check below mismatches.-            _ <--                runStoreIO store $-                    appendToStream-                        (StreamName "txn-conflict-1")-                        NoStream-                        [makeEvent "Created" (Aeson.object [])]-            prepared <- prepareEventsIO [makeEvent "Updated" (Aeson.object [])]-            now <- getCurrentTime-            result <- runStoreIO store $ runTransaction $ do-                outcome <--                    appendToStreamTx-                        (StreamName "txn-conflict-1")-                        (ExactVersion (StreamVersion 99))-                        prepared-                        now-                case outcome of-                    Right _ -> do-                        Tx.statement (0 :: Int64, "should-not-run") insertSideRowStmt-                        pure outcome-                    Left _ -> pure outcome-            case result of-                Right (Left (WrongExpectedVersionConflict _ _ _)) -> pure ()-                other ->-                    expectationFailure-                        ("Expected Right (Left WrongExpectedVersionConflict), got: " <> show other)-            rows <- countSideTable (store ^. #pool)-            rows `shouldBe` 0+    it "returns Left on version conflict; the caller's branch sees no AppendResult" $ \store -> do+      createSideTable (store ^. #pool)+      -- Pre-populate the stream so the ExactVersion check below mismatches.+      _ <-+        runStoreIO store $+          appendToStream+            (StreamName "txn-conflict-1")+            NoStream+            [makeEvent "Created" (Aeson.object [])]+      prepared <- prepareEventsIO [makeEvent "Updated" (Aeson.object [])]+      now <- getCurrentTime+      result <- runStoreIO store $ runTransaction $ do+        outcome <-+          appendToStreamTx+            (StreamName "txn-conflict-1")+            (ExactVersion (StreamVersion 99))+            prepared+            now+        case outcome of+          Right _ -> do+            Tx.statement (0 :: Int64, "should-not-run") insertSideRowStmt+            pure outcome+          Left _ -> pure outcome+      case result of+        Right (Left (WrongExpectedVersionConflict _ _ _)) -> pure ()+        other ->+          expectationFailure+            ("Expected Right (Left WrongExpectedVersionConflict), got: " <> show other)+      rows <- countSideTable (store ^. #pool)+      rows `shouldBe` 0 -        it "surfaces duplicate caller-supplied event ids as DuplicateEvent" $ \store -> do-            eid <- EventId <$> V7.genUUID-            let evt = eventWithId eid-            prepared <- prepareEventsIO [evt]-            now <- getCurrentTime-            first <--                runStoreIO store $-                    runTransaction $-                        appendToStreamTx (StreamName "txn-duplicate-source-1") NoStream prepared now-            first `shouldSatisfy` either (const False) isRight+    it "surfaces duplicate caller-supplied event ids as DuplicateEvent" $ \store -> do+      eid <- EventId <$> V7.genUUID+      let evt = eventWithId eid+      prepared <- prepareEventsIO [evt]+      now <- getCurrentTime+      first <-+        runStoreIO store $+          runTransaction $+            appendToStreamTx (StreamName "txn-duplicate-source-1") NoStream prepared now+      first `shouldSatisfy` either (const False) isRight -            duplicate <--                runStoreIO store $-                    runTransaction $-                        appendToStreamTx (StreamName "txn-duplicate-target-1") NoStream prepared now-            duplicate `shouldSatisfy` isDuplicateEvent+      duplicate <-+        runStoreIO store $+          runTransaction $+            appendToStreamTx (StreamName "txn-duplicate-target-1") NoStream prepared now+      duplicate `shouldSatisfy` isDuplicateEvent -    describe "runTransactionAppending" $ do-        it "commits 3 events and a side-table row in one ACID transaction" $ \store -> do-            createSideTable (store ^. #pool)-            let evts =-                    [ makeEvent "Created" (Aeson.object [("i", Aeson.Number 1)])-                    , makeEvent "Updated" (Aeson.object [("i", Aeson.Number 2)])-                    , makeEvent "Closed" (Aeson.object [("i", Aeson.Number 3)])-                    ]-            result <--                runStoreIO store $-                    runTransactionAppending-                        (StreamName "txn-wrapper-success-1")-                        NoStream-                        evts-                        ( \ar -> do-                            let StreamId sid = ar ^. #streamId-                            Tx.statement (sid, "projection") insertSideRowStmt-                            pure ar-                        )-            case result of-                Right (Right ar) ->-                    (ar ^. #streamVersion) `shouldBe` StreamVersion 3-                other ->-                    expectationFailure-                        ("Expected Right (Right AppendResult), got: " <> show other)-            -- Verify both the events and the side row landed.-            rows <- countSideTable (store ^. #pool)-            rows `shouldBe` 1-            mInfo <- runStoreIO store $ getStream (StreamName "txn-wrapper-success-1")-            case mInfo of-                Right (Just info) -> (info ^. #version) `shouldBe` StreamVersion 3-                other -> expectationFailure ("Expected stream with version 3, got: " <> show other)+  describe "runTransactionAppending" $ do+    it "commits 3 events and a side-table row in one ACID transaction" $ \store -> do+      createSideTable (store ^. #pool)+      let evts =+            [ makeEvent "Created" (Aeson.object [("i", Aeson.Number 1)]),+              makeEvent "Updated" (Aeson.object [("i", Aeson.Number 2)]),+              makeEvent "Closed" (Aeson.object [("i", Aeson.Number 3)])+            ]+      result <-+        runStoreIO store $+          runTransactionAppending+            (StreamName "txn-wrapper-success-1")+            NoStream+            evts+            ( \ar -> do+                let StreamId sid = ar ^. #streamId+                Tx.statement (sid, "projection") insertSideRowStmt+                pure ar+            )+      case result of+        Right (Right ar) ->+          (ar ^. #streamVersion) `shouldBe` StreamVersion 3+        other ->+          expectationFailure+            ("Expected Right (Right AppendResult), got: " <> show other)+      -- Verify both the events and the side row landed.+      rows <- countSideTable (store ^. #pool)+      rows `shouldBe` 1+      mInfo <- runStoreIO store $ getStream (StreamName "txn-wrapper-success-1")+      case mInfo of+        Right (Just info) -> (info ^. #version) `shouldBe` StreamVersion 3+        other -> expectationFailure ("Expected stream with version 3, got: " <> show other) -        it "rolls back the append and the side row when the callback condemns" $ \store -> do-            createSideTable (store ^. #pool)-            result <--                runStoreIO store $-                    runTransactionAppending-                        (StreamName "txn-wrapper-condemn-1")-                        NoStream-                        [makeEvent "Created" (Aeson.object [])]-                        ( \ar -> do-                            let StreamId sid = ar ^. #streamId-                            Tx.statement (sid, "should-not-persist") insertSideRowStmt-                            Tx.condemn-                            pure ar-                        )-            -- The callback ran and returned, so the wrapper sees Right; but the-            -- transaction was condemned, so neither write committed.-            case result of-                Right (Right _) -> pure ()-                other ->-                    expectationFailure-                        ("Expected Right (Right AppendResult), got: " <> show other)-            mInfo <- runStoreIO store $ getStream (StreamName "txn-wrapper-condemn-1")-            mInfo `shouldBe` Right Nothing-            rows <- countSideTable (store ^. #pool)-            rows `shouldBe` 0+    it "rolls back the append and the side row when the callback condemns" $ \store -> do+      createSideTable (store ^. #pool)+      result <-+        runStoreIO store $+          runTransactionAppending+            (StreamName "txn-wrapper-condemn-1")+            NoStream+            [makeEvent "Created" (Aeson.object [])]+            ( \ar -> do+                let StreamId sid = ar ^. #streamId+                Tx.statement (sid, "should-not-persist") insertSideRowStmt+                Tx.condemn+                pure ar+            )+      -- The callback ran and returned, so the wrapper sees Right; but the+      -- transaction was condemned, so neither write committed.+      case result of+        Right (Right _) -> pure ()+        other ->+          expectationFailure+            ("Expected Right (Right AppendResult), got: " <> show other)+      mInfo <- runStoreIO store $ getStream (StreamName "txn-wrapper-condemn-1")+      mInfo `shouldBe` Right Nothing+      rows <- countSideTable (store ^. #pool)+      rows `shouldBe` 0 -        it "skips the callback and surfaces the version conflict" $ \store -> do-            createSideTable (store ^. #pool)-            -- Pre-populate the stream so ExactVersion 99 will mismatch.-            _ <--                runStoreIO store $-                    appendToStream-                        (StreamName "txn-wrapper-conflict-1")-                        NoStream-                        [makeEvent "Created" (Aeson.object [])]-            calledRef <- newIORef (0 :: Int)-            result <--                runStoreIO store $-                    runTransactionAppending-                        (StreamName "txn-wrapper-conflict-1")-                        (ExactVersion (StreamVersion 99))-                        [makeEvent "Updated" (Aeson.object [])]-                        ( \ar -> do-                            -- Tx.Transaction has no MonadIO; the IORef bump can't go-                            -- here. Instead, write a side row whose absence asserts-                            -- non-invocation.-                            let StreamId sid = ar ^. #streamId-                            Tx.statement (sid, "should-not-run") insertSideRowStmt-                            pure ar-                        )-            -- Bump the ref outside the Tx; assertion below proves the guard-            -- works: if the callback HAD run, the side row would exist.-            modifyIORef' calledRef (+ 1)-            n <- readIORef calledRef-            n `shouldBe` 1-            case result of-                Right (Left (WrongExpectedVersion _ _ _)) -> pure ()-                other ->-                    expectationFailure-                        ("Expected Right (Left WrongExpectedVersion), got: " <> show other)-            rows <- countSideTable (store ^. #pool)-            rows `shouldBe` 0+    it "skips the callback and surfaces the version conflict" $ \store -> do+      createSideTable (store ^. #pool)+      -- Pre-populate the stream so ExactVersion 99 will mismatch.+      _ <-+        runStoreIO store $+          appendToStream+            (StreamName "txn-wrapper-conflict-1")+            NoStream+            [makeEvent "Created" (Aeson.object [])]+      calledRef <- newIORef (0 :: Int)+      result <-+        runStoreIO store $+          runTransactionAppending+            (StreamName "txn-wrapper-conflict-1")+            (ExactVersion (StreamVersion 99))+            [makeEvent "Updated" (Aeson.object [])]+            ( \ar -> do+                -- Tx.Transaction has no MonadIO; the IORef bump can't go+                -- here. Instead, write a side row whose absence asserts+                -- non-invocation.+                let StreamId sid = ar ^. #streamId+                Tx.statement (sid, "should-not-run") insertSideRowStmt+                pure ar+            )+      -- Bump the ref outside the Tx; assertion below proves the guard+      -- works: if the callback HAD run, the side row would exist.+      modifyIORef' calledRef (+ 1)+      n <- readIORef calledRef+      n `shouldBe` 1+      case result of+        Right (Left (WrongExpectedVersion _ _ _)) -> pure ()+        other ->+          expectationFailure+            ("Expected Right (Left WrongExpectedVersion), got: " <> show other)+      rows <- countSideTable (store ^. #pool)+      rows `shouldBe` 0 -        it "rejects $all before opening any transaction" $ \store -> do-            createSideTable (store ^. #pool)-            result <--                runStoreIO store $-                    runTransactionAppending-                        (StreamName "$all")-                        AnyVersion-                        [makeEvent "Should" (Aeson.object [])]-                        ( \ar -> do-                            let StreamId sid = ar ^. #streamId-                            Tx.statement (sid, "should-never-run") insertSideRowStmt-                            pure ar-                        )-            case result of-                Right (Left (ReservedStreamName (StreamName "$all"))) -> pure ()-                other ->-                    expectationFailure-                        ("Expected Right (Left (ReservedStreamName \"$all\")), got: " <> show other)-            rows <- countSideTable (store ^. #pool)-            rows `shouldBe` 0+    it "rejects $all before opening any transaction" $ \store -> do+      createSideTable (store ^. #pool)+      result <-+        runStoreIO store $+          runTransactionAppending+            (StreamName "$all")+            AnyVersion+            [makeEvent "Should" (Aeson.object [])]+            ( \ar -> do+                let StreamId sid = ar ^. #streamId+                Tx.statement (sid, "should-never-run") insertSideRowStmt+                pure ar+            )+      case result of+        Right (Left (ReservedStreamName (StreamName "$all"))) -> pure ()+        other ->+          expectationFailure+            ("Expected Right (Left (ReservedStreamName \"$all\")), got: " <> show other)+      rows <- countSideTable (store ^. #pool)+      rows `shouldBe` 0 -        it "rejects oversized stream names before opening any transaction" $ \store -> do-            createSideTable (store ^. #pool)-            let over = StreamName (T.replicate 513 "t")-            result <--                runStoreIO store $-                    runTransactionAppending-                        over-                        AnyVersion-                        [makeEvent "Should" (Aeson.object [])]-                        ( \ar -> do-                            let StreamId sid = ar ^. #streamId-                            Tx.statement (sid, "should-never-run") insertSideRowStmt-                            pure ar-                        )-            result `shouldBe` Right (Left (StreamNameTooLong over 513))-            runStoreIO store (getStream over) `shouldReturn` Right Nothing-            rows <- countSideTable (store ^. #pool)-            rows `shouldBe` 0+    it "rejects oversized stream names before opening any transaction" $ \store -> do+      createSideTable (store ^. #pool)+      let over = StreamName (T.replicate 513 "t")+      result <-+        runStoreIO store $+          runTransactionAppending+            over+            AnyVersion+            [makeEvent "Should" (Aeson.object [])]+            ( \ar -> do+                let StreamId sid = ar ^. #streamId+                Tx.statement (sid, "should-never-run") insertSideRowStmt+                pure ar+            )+      result `shouldBe` Right (Left (StreamNameTooLong over 513))+      runStoreIO store (getStream over) `shouldReturn` Right Nothing+      rows <- countSideTable (store ^. #pool)+      rows `shouldBe` 0 -        it "rejects empty event batches before opening a transaction" $ \store -> do-            createSideTable (store ^. #pool)-            result <--                runStoreIO store $-                    runTransactionAppending-                        (StreamName "txn-wrapper-empty-1")-                        NoStream-                        []-                        ( \ar -> do-                            let StreamId sid = ar ^. #streamId-                            Tx.statement (sid, "should-never-run") insertSideRowStmt-                            pure ar-                        )-            result `shouldBe` Right (Left (EmptyAppendBatch (StreamName "txn-wrapper-empty-1")))-            runStoreIO store (getStream (StreamName "txn-wrapper-empty-1")) `shouldReturn` Right Nothing-            rows <- countSideTable (store ^. #pool)-            rows `shouldBe` 0+    it "rejects empty event batches before opening a transaction" $ \store -> do+      createSideTable (store ^. #pool)+      result <-+        runStoreIO store $+          runTransactionAppending+            (StreamName "txn-wrapper-empty-1")+            NoStream+            []+            ( \ar -> do+                let StreamId sid = ar ^. #streamId+                Tx.statement (sid, "should-never-run") insertSideRowStmt+                pure ar+            )+      result `shouldBe` Right (Left (EmptyAppendBatch (StreamName "txn-wrapper-empty-1")))+      runStoreIO store (getStream (StreamName "txn-wrapper-empty-1")) `shouldReturn` Right Nothing+      rows <- countSideTable (store ^. #pool)+      rows `shouldBe` 0 -        it "surfaces duplicate caller-supplied event ids as DuplicateEvent" $ \store -> do-            eid <- EventId <$> V7.genUUID-            let evt = eventWithId eid-            first <--                runStoreIO store $-                    runTransactionAppending-                        (StreamName "txn-wrapper-duplicate-source-1")-                        NoStream-                        [evt]-                        pure-            first `shouldSatisfy` either (const False) isRight+    it "surfaces duplicate caller-supplied event ids as DuplicateEvent" $ \store -> do+      eid <- EventId <$> V7.genUUID+      let evt = eventWithId eid+      first <-+        runStoreIO store $+          runTransactionAppending+            (StreamName "txn-wrapper-duplicate-source-1")+            NoStream+            [evt]+            pure+      first `shouldSatisfy` either (const False) isRight -            duplicate <--                runStoreIO store $-                    runTransactionAppending-                        (StreamName "txn-wrapper-duplicate-target-1")-                        NoStream-                        [evt]-                        pure-            duplicate `shouldSatisfy` isDuplicateEvent+      duplicate <-+        runStoreIO store $+          runTransactionAppending+            (StreamName "txn-wrapper-duplicate-target-1")+            NoStream+            [evt]+            pure+      duplicate `shouldSatisfy` isDuplicateEvent  -- --------------------------------------------------------------------------- -- Local statements and pool-side helpers -- --------------------------------------------------------------------------- -{- | Trivial @SELECT 1@ used to verify 'runTransaction' wiring without-depending on any schema state.--}+-- | Trivial @SELECT 1@ used to verify 'runTransaction' wiring without+-- depending on any schema state. selectOneStmt :: Statement () Int selectOneStmt =-    preparable-        "SELECT 1::int4"-        E.noParams-        (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int4)))+  preparable+    "SELECT 1::int4"+    E.noParams+    (D.singleRow (fromIntegral <$> D.column (D.nonNullable D.int4)))  -- | DDL for the projection-row side table used by transaction tests. createSideTableStmt :: Statement () () createSideTableStmt =-    unpreparable-        "CREATE TABLE IF NOT EXISTS test_side_table \-        \(id BIGINT PRIMARY KEY, payload TEXT NOT NULL)"-        E.noParams-        D.noResult+  unpreparable+    "CREATE TABLE IF NOT EXISTS test_side_table \+    \(id BIGINT PRIMARY KEY, payload TEXT NOT NULL)"+    E.noParams+    D.noResult  createForeignKeyParentStmt :: Statement () () createForeignKeyParentStmt =-    unpreparable-        "CREATE TABLE IF NOT EXISTS test_tx_parent (id BIGINT PRIMARY KEY)"-        E.noParams-        D.noResult+  unpreparable+    "CREATE TABLE IF NOT EXISTS test_tx_parent (id BIGINT PRIMARY KEY)"+    E.noParams+    D.noResult  createForeignKeyChildStmt :: Statement () () createForeignKeyChildStmt =-    unpreparable-        "CREATE TABLE IF NOT EXISTS test_tx_child (parent_id BIGINT NOT NULL REFERENCES test_tx_parent(id))"-        E.noParams-        D.noResult+  unpreparable+    "CREATE TABLE IF NOT EXISTS test_tx_child (parent_id BIGINT NOT NULL REFERENCES test_tx_parent(id))"+    E.noParams+    D.noResult  insertMissingForeignKeyStmt :: Statement () () insertMissingForeignKeyStmt =-    preparable-        "INSERT INTO test_tx_child (parent_id) VALUES (999999)"-        E.noParams-        D.noResult+  preparable+    "INSERT INTO test_tx_child (parent_id) VALUES (999999)"+    E.noParams+    D.noResult -{- | Insert a row into the side table. The @id@ doubles as the foreign-key onto the stream the transaction appended to.--}+-- | Insert a row into the side table. The @id@ doubles as the foreign+-- key onto the stream the transaction appended to. insertSideRowStmt :: Statement (Int64, Text) () insertSideRowStmt =-    preparable-        "INSERT INTO test_side_table (id, payload) VALUES ($1, $2)"-        ( contrazip2-            (E.param (E.nonNullable E.int8))-            (E.param (E.nonNullable E.text))-        )-        D.noResult+  preparable+    "INSERT INTO test_side_table (id, payload) VALUES ($1, $2)"+    ( contrazip2+        (E.param (E.nonNullable E.int8))+        (E.param (E.nonNullable E.text))+    )+    D.noResult  -- | Count rows in the side table. countSideTableStmt :: Statement () Int64 countSideTableStmt =-    preparable-        "SELECT COUNT(*) FROM test_side_table"-        E.noParams-        (D.singleRow (D.column (D.nonNullable D.int8)))+  preparable+    "SELECT COUNT(*) FROM test_side_table"+    E.noParams+    (D.singleRow (D.column (D.nonNullable D.int8)))  createSideTable :: Pool -> IO () createSideTable pool = do-    r <- Pool.use pool (Session.statement () createSideTableStmt)-    case r of-        Left err -> error ("createSideTable failed: " <> show err)-        Right () -> pure ()+  r <- Pool.use pool (Session.statement () createSideTableStmt)+  case r of+    Left err -> error ("createSideTable failed: " <> show err)+    Right () -> pure ()  createForeignKeyTables :: Pool -> IO () createForeignKeyTables pool = do-    parent <- Pool.use pool (Session.statement () createForeignKeyParentStmt)-    child <- Pool.use pool (Session.statement () createForeignKeyChildStmt)-    case (parent, child) of-        (Right (), Right ()) -> pure ()-        other -> error ("createForeignKeyTables failed: " <> show other)+  parent <- Pool.use pool (Session.statement () createForeignKeyParentStmt)+  child <- Pool.use pool (Session.statement () createForeignKeyChildStmt)+  case (parent, child) of+    (Right (), Right ()) -> pure ()+    other -> error ("createForeignKeyTables failed: " <> show other)  countSideTable :: Pool -> IO Int64 countSideTable pool = do-    r <- Pool.use pool (Session.statement () countSideTableStmt)-    case r of-        Left err -> error ("countSideTable failed: " <> show err)-        Right n -> pure n+  r <- Pool.use pool (Session.statement () countSideTableStmt)+  case r of+    Left err -> error ("countSideTable failed: " <> show err)+    Right n -> pure n  isRight :: Either a b -> Bool isRight = \case-    Right _ -> True-    Left _ -> False+  Right _ -> True+  Left _ -> False  isDuplicateEvent :: Either StoreError a -> Bool isDuplicateEvent = \case-    Left (DuplicateEvent _) -> True-    _ -> False+  Left (DuplicateEvent _) -> True+  _ -> False  eventWithId :: EventId -> EventData eventWithId eid =-    EventData-        { eventId = Just eid-        , eventType = EventType "Created"-        , payload = Aeson.object []-        , metadata = Nothing-        , causationId = Nothing-        , correlationId = Nothing-        }+  EventData+    { eventId = Just eid,+      eventType = EventType "Created",+      payload = Aeson.object [],+      metadata = Nothing,+      causationId = Nothing,+      correlationId = Nothing+    }
test/Test/TruncateBefore.hs view
@@ -1,15 +1,14 @@-{- | Tests for the logical truncate-before marker (close-the-book-compaction, ExecPlan docs/plans/65).--The marker is a per-stream cursor that hides a prefix from the /ordered-per-stream/ reads ('readStreamForward' / 'readStreamBackward' and the paged-'readStreamForwardStream') while leaving the global @$all@ log, category-reads, and the physical row count untouched. The distinguishing property —-and the single most important assertion here — is that after a truncate the-@$all@ log and 'readCategory' still return the full history and-'countEvents' is unchanged: nothing is deleted, only hidden, and the-operation is fully reversible.--}+-- | Tests for the logical truncate-before marker (close-the-book+-- compaction, ExecPlan docs/plans/65).+--+-- The marker is a per-stream cursor that hides a prefix from the /ordered+-- per-stream/ reads ('readStreamForward' / 'readStreamBackward' and the paged+-- 'readStreamForwardStream') while leaving the global @$all@ log, category+-- reads, and the physical row count untouched. The distinguishing property —+-- and the single most important assertion here — is that after a truncate the+-- @$all@ log and 'readCategory' still return the full history and+-- 'countEvents' is unchanged: nothing is deleted, only hidden, and the+-- operation is fully reversible. module Test.TruncateBefore (spec) where  import Control.Lens ((^.))@@ -25,101 +24,101 @@ -- | Append @n@ events @E1 .. En@ to a fresh stream and return its name. seedStream :: KirokuStore -> StreamName -> Int -> IO () seedStream store name n = do-    let events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. n]]-    Right _ <- runStoreIO store $ appendToStream name NoStream events-    pure ()+  let events = [makeEvent (T.pack ("E" <> show i)) (Aeson.object []) | i <- [1 .. n]]+  Right _ <- runStoreIO store $ appendToStream name NoStream events+  pure ()  -- | The per-stream versions returned by a forward read, in order. forwardVersions :: KirokuStore -> StreamName -> IO [StreamVersion] forwardVersions store name = do-    Right v <- runStoreIO store $ readStreamForward name (StreamVersion 0) 100-    pure (map (^. #streamVersion) (V.toList v))+  Right v <- runStoreIO store $ readStreamForward name (StreamVersion 0) 100+  pure (map (^. #streamVersion) (V.toList v))  spec :: Spec spec = describe "TruncateBefore" $-    around withTestStore $ do-        it "bounds per-stream reads to the kept suffix" $ \store -> do-            let name = StreamName "preference-abc"-            seedStream store name 6 -- versions 1..6; treat v6 as the snapshot-            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6)+  around withTestStore $ do+    it "bounds per-stream reads to the kept suffix" $ \store -> do+      let name = StreamName "preference-abc"+      seedStream store name 6 -- versions 1..6; treat v6 as the snapshot+      Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6) -            forwardVersions store name `shouldReturn` [StreamVersion 6]+      forwardVersions store name `shouldReturn` [StreamVersion 6] -            Right back <- runStoreIO store $ readStreamBackward name (StreamVersion 0) 100-            map (^. #streamVersion) (V.toList back) `shouldBe` [StreamVersion 6]+      Right back <- runStoreIO store $ readStreamBackward name (StreamVersion 0) 100+      map (^. #streamVersion) (V.toList back) `shouldBe` [StreamVersion 6] -        it "leaves the $all global log and category reads intact" $ \store -> do-            let name = StreamName "preference-abc"-            seedStream store name 6-            countBefore <- countEvents store-            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6)+    it "leaves the $all global log and category reads intact" $ \store -> do+      let name = StreamName "preference-abc"+      seedStream store name 6+      countBefore <- countEvents store+      Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6) -            -- \$all global log still returns the full history.-            Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000-            length (V.toList allEvents) `shouldBe` 6+      -- \$all global log still returns the full history.+      Right allEvents <- runStoreIO store $ readAllForward (GlobalPosition 0) 1000+      length (V.toList allEvents) `shouldBe` 6 -            -- Category read still returns the full history.-            Right catEvents <- runStoreIO store $ readCategory (CategoryName "preference") (GlobalPosition 0) 1000-            length (V.toList catEvents) `shouldBe` 6+      -- Category read still returns the full history.+      Right catEvents <- runStoreIO store $ readCategory (CategoryName "preference") (GlobalPosition 0) 1000+      length (V.toList catEvents) `shouldBe` 6 -            -- Nothing was physically deleted.-            countAfter <- countEvents store-            countAfter `shouldBe` countBefore+      -- Nothing was physically deleted.+      countAfter <- countEvents store+      countAfter `shouldBe` countBefore -        it "is reversible via clearStreamTruncateBefore" $ \store -> do-            let name = StreamName "preference-abc"-            seedStream store name 6-            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6)-            forwardVersions store name `shouldReturn` [StreamVersion 6]+    it "is reversible via clearStreamTruncateBefore" $ \store -> do+      let name = StreamName "preference-abc"+      seedStream store name 6+      Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 6)+      forwardVersions store name `shouldReturn` [StreamVersion 6] -            Right (Just _) <- runStoreIO store $ clearStreamTruncateBefore name-            forwardVersions store name-                `shouldReturn` map StreamVersion [1 .. 6]+      Right (Just _) <- runStoreIO store $ clearStreamTruncateBefore name+      forwardVersions store name+        `shouldReturn` map StreamVersion [1 .. 6] -        it "is idempotent" $ \store -> do-            let name = StreamName "preference-abc"-            seedStream store name 6-            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 4)-            first <- forwardVersions store name-            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 4)-            second <- forwardVersions store name-            first `shouldBe` [StreamVersion 4, StreamVersion 5, StreamVersion 6]-            second `shouldBe` first+    it "is idempotent" $ \store -> do+      let name = StreamName "preference-abc"+      seedStream store name 6+      Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 4)+      first <- forwardVersions store name+      Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 4)+      second <- forwardVersions store name+      first `shouldBe` [StreamVersion 4, StreamVersion 5, StreamVersion 6]+      second `shouldBe` first -        it "applies across paged reads" $ \store -> do-            let name = StreamName "preference-abc"-            seedStream store name 6-            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 4)-            -- Page size 2 forces multiple pages across the kept suffix.-            Right streamed <--                runStoreIO store $-                    Stream.toList $-                        fmap (^. #streamVersion) (readStreamForwardStream name (StreamVersion 0) 2)-            streamed `shouldBe` [StreamVersion 4, StreamVersion 5, StreamVersion 6]+    it "applies across paged reads" $ \store -> do+      let name = StreamName "preference-abc"+      seedStream store name 6+      Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 4)+      -- Page size 2 forces multiple pages across the kept suffix.+      Right streamed <-+        runStoreIO store $+          Stream.toList $+            fmap (^. #streamVersion) (readStreamForwardStream name (StreamVersion 0) 2)+      streamed `shouldBe` [StreamVersion 4, StreamVersion 5, StreamVersion 6] -        it "reflects the marker in getStream" $ \store -> do-            let name = StreamName "preference-abc"-            seedStream store name 6-            Right (Just info0) <- runStoreIO store $ getStream name-            (info0 ^. #truncateBefore) `shouldBe` StreamVersion 0-            Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 3)-            Right (Just info1) <- runStoreIO store $ getStream name-            (info1 ^. #truncateBefore) `shouldBe` StreamVersion 3+    it "reflects the marker in getStream" $ \store -> do+      let name = StreamName "preference-abc"+      seedStream store name 6+      Right (Just info0) <- runStoreIO store $ getStream name+      (info0 ^. #truncateBefore) `shouldBe` StreamVersion 0+      Right (Just _) <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 3)+      Right (Just info1) <- runStoreIO store $ getStream name+      (info1 ^. #truncateBefore) `shouldBe` StreamVersion 3 -        it "returns Nothing for missing or soft-deleted streams" $ \store -> do-            Right missing <- runStoreIO store $ setStreamTruncateBefore (StreamName "does-not-exist") (StreamVersion 1)-            missing `shouldBe` Nothing+    it "returns Nothing for missing or soft-deleted streams" $ \store -> do+      Right missing <- runStoreIO store $ setStreamTruncateBefore (StreamName "does-not-exist") (StreamVersion 1)+      missing `shouldBe` Nothing -            let name = StreamName "preference-abc"-            seedStream store name 3-            Right (Just _) <- runStoreIO store $ softDeleteStream name-            Right deleted <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 1)-            deleted `shouldBe` Nothing+      let name = StreamName "preference-abc"+      seedStream store name 3+      Right (Just _) <- runStoreIO store $ softDeleteStream name+      Right deleted <- runStoreIO store $ setStreamTruncateBefore name (StreamVersion 1)+      deleted `shouldBe` Nothing -        it "rejects $all with ReservedStreamName" $ \store -> do-            result <- runStoreIO store $ setStreamTruncateBefore (StreamName "$all") (StreamVersion 1)-            case result of-                Left (ReservedStreamName (StreamName "$all")) -> pure ()-                other ->-                    expectationFailure-                        ("Expected Left (ReservedStreamName \"$all\"), got: " <> show other)+    it "rejects $all with ReservedStreamName" $ \store -> do+      result <- runStoreIO store $ setStreamTruncateBefore (StreamName "$all") (StreamVersion 1)+      case result of+        Left (ReservedStreamName (StreamName "$all")) -> pure ()+        other ->+          expectationFailure+            ("Expected Left (ReservedStreamName \"$all\"), got: " <> show other)
test/Test/UniqueViolationMapping.hs view
@@ -11,83 +11,83 @@  spec :: Spec spec = describe "unique violation mapping" $ do-    let eid = EventId (UUID.fromWords 0x01234567 0x89ab7def 0x80123456 0x7890abcd)-        scalar = "Key (event_id)=(01234567-89ab-7def-8012-34567890abcd) already exists."-        composite = "Key (event_id, stream_id)=(01234567-89ab-7def-8012-34567890abcd, 42) already exists."-        fallback = WrongExpectedVersion (StreamName "orders-1") AnyVersion (StreamVersion 0)-        mapAppend = mapUsageError "orders-1" AnyVersion-        cases =-            [ ("events_pkey", scalar, DuplicateEvent (Just eid))-            , ("stream_events_pkey", composite, DuplicateEvent (Just eid))-            , ("ix_streams_stream_name", "Key (stream_name)=(orders-1) already exists.", StreamAlreadyExists (StreamName "orders-1"))-            , ("ux_stream_events_stream_version", "Key (stream_id, stream_version)=(42, 1) already exists.", UnexpectedServerError "23505" "detail-only error")-            , ("unknown_constraint", scalar, fallback)-            ]-    forM_ cases $ \(name, detail, expected) -> do-        it ("classifies the exact message constraint " <> show name) $ do-            let message = constraintMessage name-                outcome = case expected of-                    UnexpectedServerError code _ -> UnexpectedServerError code message-                    other -> other-            mapAppend (serverUsage message (Just detail)) `shouldBe` outcome-        it ("classifies a detail-only constraint " <> show name) $-            mapAppend (serverUsage "detail-only error" (Just ("constraint: " <> name <> "; " <> detail)))-                `shouldBe` expected+  let eid = EventId (UUID.fromWords 0x01234567 0x89ab7def 0x80123456 0x7890abcd)+      scalar = "Key (event_id)=(01234567-89ab-7def-8012-34567890abcd) already exists."+      composite = "Key (event_id, stream_id)=(01234567-89ab-7def-8012-34567890abcd, 42) already exists."+      fallback = WrongExpectedVersion (StreamName "orders-1") AnyVersion (StreamVersion 0)+      mapAppend = mapUsageError "orders-1" AnyVersion+      cases =+        [ ("events_pkey", scalar, DuplicateEvent (Just eid)),+          ("stream_events_pkey", composite, DuplicateEvent (Just eid)),+          ("ix_streams_stream_name", "Key (stream_name)=(orders-1) already exists.", StreamAlreadyExists (StreamName "orders-1")),+          ("ux_stream_events_stream_version", "Key (stream_id, stream_version)=(42, 1) already exists.", UnexpectedServerError "23505" "detail-only error"),+          ("unknown_constraint", scalar, fallback)+        ]+  forM_ cases $ \(name, detail, expected) -> do+    it ("classifies the exact message constraint " <> show name) $ do+      let message = constraintMessage name+          outcome = case expected of+            UnexpectedServerError code _ -> UnexpectedServerError code message+            other -> other+      mapAppend (serverUsage message (Just detail)) `shouldBe` outcome+    it ("classifies a detail-only constraint " <> show name) $+      mapAppend (serverUsage "detail-only error" (Just ("constraint: " <> name <> "; " <> detail)))+        `shouldBe` expected -    it "does not match stream_events_pkey as events_pkey" $-        mapAppend (serverUsage (constraintMessage "stream_events_pkey") (Just composite))-            `shouldBe` DuplicateEvent (Just eid)-    forM_ ["events_pkey", "stream_events_pkey", "ix_streams_stream_name", "ux_stream_events_stream_version"] $ \name ->-        forM_ ["prefix_" <> name, name <> "_suffix", name <> "$suffix"] $ \other -> do-            it ("rejects the message lookalike " <> show other) $-                mapAppend (serverUsage (constraintMessage other) (Just scalar)) `shouldBe` fallback-            it ("rejects the detail lookalike " <> show other) $-                mapAppend (serverUsage "detail-only error" (Just (other <> "; " <> scalar))) `shouldBe` fallback+  it "does not match stream_events_pkey as events_pkey" $+    mapAppend (serverUsage (constraintMessage "stream_events_pkey") (Just composite))+      `shouldBe` DuplicateEvent (Just eid)+  forM_ ["events_pkey", "stream_events_pkey", "ix_streams_stream_name", "ux_stream_events_stream_version"] $ \name ->+    forM_ ["prefix_" <> name, name <> "_suffix", name <> "$suffix"] $ \other -> do+      it ("rejects the message lookalike " <> show other) $+        mapAppend (serverUsage (constraintMessage other) (Just scalar)) `shouldBe` fallback+      it ("rejects the detail lookalike " <> show other) $+        mapAppend (serverUsage "detail-only error" (Just (other <> "; " <> scalar))) `shouldBe` fallback -    it "uses the message constraint ahead of misleading detail tokens" $-        mapAppend (serverUsage (constraintMessage "ux_stream_events_stream_version") (Just ("events_pkey " <> scalar)))-            `shouldBe` UnexpectedServerError "23505" (constraintMessage "ux_stream_events_stream_version")-    it "retains an unknown message constraint even when detail names a known constraint" $-        mapAppend (serverUsage (constraintMessage "unknown_constraint") (Just ("events_pkey " <> scalar)))-            `shouldBe` fallback-    forM_ ["events_pkey", "stream_events_pkey"] $ \name ->-        forM_ [Nothing, Just "unparseable localized detail"] $ \detail ->-            it ("does not fabricate an event id for " <> show (name, detail)) $-                mapAppend (serverUsage (constraintMessage name) detail) `shouldBe` DuplicateEvent Nothing-    it "maps a composite duplicate inside a transaction with its event id" $-        mapTransactionUsageError (serverUsage (constraintMessage "stream_events_pkey") (Just composite))-            `shouldBe` DuplicateEvent (Just eid)-    it "preserves the link-specific duplicate classification" $-        mapLinkUsageError (StreamName "orders-1") (serverUsage (constraintMessage "stream_events_pkey") (Just composite))-            `shouldBe` EventAlreadyLinked (StreamName "orders-1") (Just eid)-    it "accepts a quoted constraint in legacy detail" $-        mapAppend (serverUsage "detail-only error" (Just (constraintMessage "stream_events_pkey" <> "; " <> composite)))-            `shouldBe` DuplicateEvent (Just eid)-    it "keeps an unnamed unique violation on the conservative fallback" $-        mapAppend (serverUsage "localized message" Nothing) `shouldBe` fallback-    it "does not classify a transaction constraint lookalike as a duplicate" $-        mapTransactionUsageError (serverUsage (constraintMessage "other_events_pkey") (Just scalar))-            `shouldBe` UnexpectedServerError "23505" (constraintMessage "other_events_pkey")-    it "does not classify a link constraint lookalike as already linked" $-        mapLinkUsageError (StreamName "orders-1") (serverUsage (constraintMessage "stream_events_pkey_backup") (Just composite))-            `shouldBe` UnexpectedServerError "23505" (constraintMessage "stream_events_pkey_backup")-    it "attributes a multi-stream name violation to the named operation" $-        attributeMultiStreamError-            [(StreamName "orders-1", AnyVersion), (StreamName "orders-2", NoStream)]-            (serverUsage (constraintMessage "ix_streams_stream_name") (Just "Key (stream_name)=(orders-2) already exists."))-            `shouldBe` StreamAlreadyExists (StreamName "orders-2")-    it "does not attribute a multi-stream constraint lookalike to a later operation" $-        attributeMultiStreamError-            [(StreamName "orders-1", AnyVersion), (StreamName "orders-2", NoStream)]-            (serverUsage (constraintMessage "ix_streams_stream_name_backup") (Just "Key (stream_name)=(orders-2) already exists."))-            `shouldBe` fallback+  it "uses the message constraint ahead of misleading detail tokens" $+    mapAppend (serverUsage (constraintMessage "ux_stream_events_stream_version") (Just ("events_pkey " <> scalar)))+      `shouldBe` UnexpectedServerError "23505" (constraintMessage "ux_stream_events_stream_version")+  it "retains an unknown message constraint even when detail names a known constraint" $+    mapAppend (serverUsage (constraintMessage "unknown_constraint") (Just ("events_pkey " <> scalar)))+      `shouldBe` fallback+  forM_ ["events_pkey", "stream_events_pkey"] $ \name ->+    forM_ [Nothing, Just "unparseable localized detail"] $ \detail ->+      it ("does not fabricate an event id for " <> show (name, detail)) $+        mapAppend (serverUsage (constraintMessage name) detail) `shouldBe` DuplicateEvent Nothing+  it "maps a composite duplicate inside a transaction with its event id" $+    mapTransactionUsageError (serverUsage (constraintMessage "stream_events_pkey") (Just composite))+      `shouldBe` DuplicateEvent (Just eid)+  it "preserves the link-specific duplicate classification" $+    mapLinkUsageError (StreamName "orders-1") (serverUsage (constraintMessage "stream_events_pkey") (Just composite))+      `shouldBe` EventAlreadyLinked (StreamName "orders-1") (Just eid)+  it "accepts a quoted constraint in legacy detail" $+    mapAppend (serverUsage "detail-only error" (Just (constraintMessage "stream_events_pkey" <> "; " <> composite)))+      `shouldBe` DuplicateEvent (Just eid)+  it "keeps an unnamed unique violation on the conservative fallback" $+    mapAppend (serverUsage "localized message" Nothing) `shouldBe` fallback+  it "does not classify a transaction constraint lookalike as a duplicate" $+    mapTransactionUsageError (serverUsage (constraintMessage "other_events_pkey") (Just scalar))+      `shouldBe` UnexpectedServerError "23505" (constraintMessage "other_events_pkey")+  it "does not classify a link constraint lookalike as already linked" $+    mapLinkUsageError (StreamName "orders-1") (serverUsage (constraintMessage "stream_events_pkey_backup") (Just composite))+      `shouldBe` UnexpectedServerError "23505" (constraintMessage "stream_events_pkey_backup")+  it "attributes a multi-stream name violation to the named operation" $+    attributeMultiStreamError+      [(StreamName "orders-1", AnyVersion), (StreamName "orders-2", NoStream)]+      (serverUsage (constraintMessage "ix_streams_stream_name") (Just "Key (stream_name)=(orders-2) already exists."))+      `shouldBe` StreamAlreadyExists (StreamName "orders-2")+  it "does not attribute a multi-stream constraint lookalike to a later operation" $+    attributeMultiStreamError+      [(StreamName "orders-1", AnyVersion), (StreamName "orders-2", NoStream)]+      (serverUsage (constraintMessage "ix_streams_stream_name_backup") (Just "Key (stream_name)=(orders-2) already exists."))+      `shouldBe` fallback  constraintMessage :: Text -> Text constraintMessage name = "duplicate key value violates unique constraint \"" <> name <> "\""  serverUsage :: Text -> Maybe Text -> UsageError serverUsage message detail =-    SessionUsageError $-        Errors.StatementSessionError 1 0 "" [] True $-            Errors.ServerStatementError $-                Errors.ServerError "23505" message detail Nothing Nothing+  SessionUsageError $+    Errors.StatementSessionError 1 0 "" [] True $+      Errors.ServerStatementError $+        Errors.ServerError "23505" message detail Nothing Nothing
test/Test/VisibleGlobalHeadPosition.hs view
@@ -14,92 +14,92 @@  spec :: Spec spec = describe "visible global head position" $ do-    it "returns zero for an empty migrated store through both public runners" $-        withTestStore $ \store -> do-            runStoreIO store visibleGlobalHeadPosition-                `shouldReturn` Right (GlobalPosition 0)+  it "returns zero for an empty migrated store through both public runners" $+    withTestStore $ \store -> do+      runStoreIO store visibleGlobalHeadPosition+        `shouldReturn` Right (GlobalPosition 0) -            resourceResult <--                runEff-                    . runErrorNoCallStack @StoreError-                    . runKirokuStoreWith store-                    . runStoreResource-                    $ visibleGlobalHeadPosition-            resourceResult `shouldBe` Right (GlobalPosition 0)+      resourceResult <-+        runEff+          . runErrorNoCallStack @StoreError+          . runKirokuStoreWith store+          . runStoreResource+          $ visibleGlobalHeadPosition+      resourceResult `shouldBe` Right (GlobalPosition 0) -    it "returns the greatest appended position" $-        withTestStore $ \store -> do-            appendEvents store (StreamName "visible-head-populated") 3-            runStoreIO store visibleGlobalHeadPosition-                `shouldReturn` Right (GlobalPosition 3)+  it "returns the greatest appended position" $+    withTestStore $ \store -> do+      appendEvents store (StreamName "visible-head-populated") 3+      runStoreIO store visibleGlobalHeadPosition+        `shouldReturn` Right (GlobalPosition 3) -    it "falls back across hard-deleted tails while the append frontier stays monotonic" $-        withTestStore $ \store -> do-            let first = StreamName "visible-head-first"-                middle = StreamName "visible-head-middle"-                lastStream = StreamName "visible-head-last"-            appendEvents store first 1-            appendEvents store middle 1-            appendEvents store lastStream 1+  it "falls back across hard-deleted tails while the append frontier stays monotonic" $+    withTestStore $ \store -> do+      let first = StreamName "visible-head-first"+          middle = StreamName "visible-head-middle"+          lastStream = StreamName "visible-head-last"+      appendEvents store first 1+      appendEvents store middle 1+      appendEvents store lastStream 1 -            Right (Just _) <- runStoreIO store $ hardDeleteStream middle-            assertHeadAndFrontier store 3 3+      Right (Just _) <- runStoreIO store $ hardDeleteStream middle+      assertHeadAndFrontier store 3 3 -            Right (Just _) <- runStoreIO store $ hardDeleteStream lastStream-            assertHeadAndFrontier store 1 3+      Right (Just _) <- runStoreIO store $ hardDeleteStream lastStream+      assertHeadAndFrontier store 1 3 -            Right (Just _) <- runStoreIO store $ hardDeleteStream first-            assertHeadAndFrontier store 0 3+      Right (Just _) <- runStoreIO store $ hardDeleteStream first+      assertHeadAndFrontier store 0 3 -    it "keeps logically truncated and soft-deleted events visible in $all" $-        withTestStore $ \store -> do-            let name = StreamName "visible-head-logical-lifecycle"-            appendEvents store name 3+  it "keeps logically truncated and soft-deleted events visible in $all" $+    withTestStore $ \store -> do+      let name = StreamName "visible-head-logical-lifecycle"+      appendEvents store name 3 -            Right (Just _) <--                runStoreIO store $-                    setStreamTruncateBefore name (StreamVersion 3)-            runStoreIO store visibleGlobalHeadPosition-                `shouldReturn` Right (GlobalPosition 3)+      Right (Just _) <-+        runStoreIO store $+          setStreamTruncateBefore name (StreamVersion 3)+      runStoreIO store visibleGlobalHeadPosition+        `shouldReturn` Right (GlobalPosition 3) -            Right (Just _) <- runStoreIO store $ softDeleteStream name-            runStoreIO store visibleGlobalHeadPosition-                `shouldReturn` Right (GlobalPosition 3)+      Right (Just _) <- runStoreIO store $ softDeleteStream name+      runStoreIO store visibleGlobalHeadPosition+        `shouldReturn` Right (GlobalPosition 3) -    it "does not invoke the event decode hook" $ do-        decodeCalls <- newIORef (0 :: Int)-        let failingHook event = do-                modifyIORef' decodeCalls (+ 1)-                ioError (userError ("unexpected decode of " <> show (event ^. #eventId)))-            tweak settings =-                settings-                    & #storeSettings-                        .~ defaultStoreSettings{decodeHook = Just failingHook}-        withTestStoreSettings tweak $ \store -> do-            appendEvents store (StreamName "visible-head-no-decode") 1+  it "does not invoke the event decode hook" $ do+    decodeCalls <- newIORef (0 :: Int)+    let failingHook event = do+          modifyIORef' decodeCalls (+ 1)+          ioError (userError ("unexpected decode of " <> show (event ^. #eventId)))+        tweak settings =+          settings+            & #storeSettings+              .~ defaultStoreSettings {decodeHook = Just failingHook}+    withTestStoreSettings tweak $ \store -> do+      appendEvents store (StreamName "visible-head-no-decode") 1 -            runStoreIO store visibleGlobalHeadPosition-                `shouldReturn` Right (GlobalPosition 1)-            readIORef decodeCalls `shouldReturn` 0+      runStoreIO store visibleGlobalHeadPosition+        `shouldReturn` Right (GlobalPosition 1)+      readIORef decodeCalls `shouldReturn` 0  appendEvents :: KirokuStore -> StreamName -> Int -> IO () appendEvents store name count = do-    let events =-            [ makeEvent "VisibleHeadEvent" (Aeson.object [("ordinal", Aeson.Number (fromIntegral ordinal))])-            | ordinal <- [1 .. count]-            ]-    result <- runStoreIO store $ appendToStream name NoStream events-    case result of-        Left err -> expectationFailure ("append failed: " <> show err)-        Right _ -> pure ()+  let events =+        [ makeEvent "VisibleHeadEvent" (Aeson.object [("ordinal", Aeson.Number (fromIntegral ordinal))])+        | ordinal <- [1 .. count]+        ]+  result <- runStoreIO store $ appendToStream name NoStream events+  case result of+    Left err -> expectationFailure ("append failed: " <> show err)+    Right _ -> pure ()  assertHeadAndFrontier :: KirokuStore -> Integer -> Integer -> IO () assertHeadAndFrontier store expectedHead expectedFrontier = do-    runStoreIO store visibleGlobalHeadPosition-        `shouldReturn` Right (GlobalPosition (fromIntegral expectedHead))-    result <- runStoreIO store subscriptionCheckpointInventory-    case result of-        Left err -> expectationFailure ("inventory read failed: " <> show err)-        Right inventory ->-            inventory ^. #storePosition-                `shouldBe` GlobalPosition (fromIntegral expectedFrontier)+  runStoreIO store visibleGlobalHeadPosition+    `shouldReturn` Right (GlobalPosition (fromIntegral expectedHead))+  result <- runStoreIO store subscriptionCheckpointInventory+  case result of+    Left err -> expectationFailure ("inventory read failed: " <> show err)+    Right inventory ->+      inventory ^. #storePosition+        `shouldBe` GlobalPosition (fromIntegral expectedFrontier)
test/Test/VisibleGlobalHeadPositionMock.hs view
@@ -11,21 +11,21 @@  spec :: Spec spec = describe "visible global head position mock" $-    it "returns the configured position through one Store effect call" $ do-        calls <- newIORef (0 :: Int)-        let expected = GlobalPosition 23-        actual <- runEff $ runVisibleHeadMock calls expected visibleGlobalHeadPosition-        actual `shouldBe` expected-        readIORef calls `shouldReturn` 1+  it "returns the configured position through one Store effect call" $ do+    calls <- newIORef (0 :: Int)+    let expected = GlobalPosition 23+    actual <- runEff $ runVisibleHeadMock calls expected visibleGlobalHeadPosition+    actual `shouldBe` expected+    readIORef calls `shouldReturn` 1  runVisibleHeadMock ::-    (IOE :> es) =>-    IORef Int ->-    GlobalPosition ->-    Eff (Store : es) a ->-    Eff es a+  (IOE :> es) =>+  IORef Int ->+  GlobalPosition ->+  Eff (Store : es) a ->+  Eff es a runVisibleHeadMock calls expected = interpret_ $ \case-    GetVisibleGlobalHeadPosition -> do-        liftIO $ modifyIORef' calls (+ 1)-        pure expected-    _ -> error "unexpected Store operation in visible-head mock"+  GetVisibleGlobalHeadPosition -> do+    liftIO $ modifyIORef' calls (+ 1)+    pure expected+  _ -> error "unexpected Store operation in visible-head mock"