packages feed

kiroku-store 0.5.0.0 → 0.6.0.0

raw patch · 9 files changed

+215/−1 lines, 9 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ Kiroku.Store.Effect: [GetVisibleGlobalHeadPosition] :: forall (a :: Type -> Type). Store a GlobalPosition
+ Kiroku.Store.Read: visibleGlobalHeadPosition :: forall (es :: [Effect]). (HasCallStack, Store :> es) => Eff es GlobalPosition
+ Kiroku.Store.SQL: visibleGlobalHeadPositionStmt :: Statement () GlobalPosition

Files

CHANGELOG.md view
@@ -1,5 +1,20 @@ # Changelog +## 0.6.0.0 — 2026-08-12++### Breaking Changes++* The exported `Store` effect gains `GetVisibleGlobalHeadPosition`. Exhaustive+  custom and mock interpreters must handle the new constructor.++### New Features++* `Kiroku.Store.Read.visibleGlobalHeadPosition` returns the greatest global+  position still visible in `$all`, or zero when no event remains, through a+  payload-free scalar query. Hard deletion of the visible tail can make the+  result regress while the authoritative append frontier remains monotonic; no+  event payload is read and the configured decode hook is not invoked.+ ## 0.5.0.0 — 2026-08-11  ### Breaking Changes
kiroku-store.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            kiroku-store-version:         0.5.0.0+version:         0.6.0.0 synopsis:        High-performance PostgreSQL event store description:   Kiroku is a PostgreSQL-backed event store for Haskell applications. It@@ -127,6 +127,8 @@     Test.SubscriptionState     Test.Transaction     Test.TruncateBefore+    Test.VisibleGlobalHeadPosition+    Test.VisibleGlobalHeadPositionMock    ghc-options:    -threaded -rtsopts -with-rtsopts=-N   build-depends:
src/Kiroku/Store/Effect.hs view
@@ -78,6 +78,14 @@     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)     {- | Resolve a 'StreamName' to its surrogate 'StreamId' without     materializing the full 'StreamInfo' row. Mirrors 'GetStream'\'s@@ -234,6 +242,9 @@             usePool (store ^. #pool) $                 Session.statement (cursor, limit) SQL.readAllBackwardStmt         liftIO $ decodeEvents (store ^. #storeSettings) evs+    GetVisibleGlobalHeadPosition ->+        usePool (store ^. #pool) $+            Session.statement () SQL.visibleGlobalHeadPositionStmt     GetStream (StreamName name) ->         usePool (store ^. #pool) $             Session.statement name SQL.getStreamStmt
src/Kiroku/Store/Read.hs view
@@ -4,6 +4,7 @@     readStreamBackward,     readAllForward,     readAllBackward,+    visibleGlobalHeadPosition,     readCategory,     getStream,     lookupStreamId,@@ -135,6 +136,25 @@     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.+-}+visibleGlobalHeadPosition ::+    (HasCallStack, Store :> es) =>+    Eff es GlobalPosition+visibleGlobalHeadPosition = send GetVisibleGlobalHeadPosition  {- | Read events whose source stream's category prefix matches the given 'CategoryName', in 'GlobalPosition' order.
src/Kiroku/Store/SQL.hs view
@@ -21,6 +21,7 @@     eventExistsInStreamStmt,     lookupStreamNamesStmt,     currentGlobalPositionStmt,+    visibleGlobalHeadPositionStmt,      -- * Consumer-group read statements     readCategoryForwardConsumerGroupStmt,@@ -456,6 +457,22 @@         "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)))  -- | Get stream metadata by name. getStreamStmt :: Statement Text (Maybe StreamInfo)
test/Main.hs view
@@ -63,6 +63,8 @@ import Test.SubscriptionState qualified as SubscriptionState import Test.Transaction qualified as Transaction import Test.TruncateBefore qualified as TruncateBefore+import Test.VisibleGlobalHeadPosition qualified as VisibleGlobalHeadPosition+import Test.VisibleGlobalHeadPositionMock qualified as VisibleGlobalHeadPositionMock  main :: IO () main = withSharedMigratedPostgres $ hspec $ do@@ -102,6 +104,8 @@     SubscriptionRegistry.spec     SubscriptionRetryDeadLetter.spec     EventTypeFilter.spec+    VisibleGlobalHeadPosition.spec+    VisibleGlobalHeadPositionMock.spec     around withTestStore $ do         describe "schema migrations" $ do             it "installs every Kiroku table under the kiroku schema" $ \store -> do
test/Test/PerformanceStructure.hs view
@@ -53,6 +53,15 @@ 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 "category high-cursor reads use ix_stream_events_all_by_origin" $ \store -> do                 plan <-                     explainProductionStatement
+ test/Test/VisibleGlobalHeadPosition.hs view
@@ -0,0 +1,105 @@+{-# LANGUAGE TypeApplications #-}++module Test.VisibleGlobalHeadPosition (spec) where++import Control.Lens ((&), (.~), (^.))+import Data.Aeson qualified as Aeson+import Data.Generics.Labels ()+import Data.IORef (modifyIORef', newIORef, readIORef)+import Effectful (runEff)+import Effectful.Error.Static (runErrorNoCallStack)+import Kiroku.Store+import Test.Helpers (makeEvent, withTestStore, withTestStoreSettings)+import Test.Hspec++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)++            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 "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 lastStream+            assertHeadAndFrontier store 1 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++            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)++    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++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 ()++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)
+ test/Test/VisibleGlobalHeadPositionMock.hs view
@@ -0,0 +1,31 @@+module Test.VisibleGlobalHeadPositionMock (spec) where++import Control.Monad.IO.Class (liftIO)+import Data.IORef (IORef, modifyIORef', newIORef, readIORef)+import Effectful (Eff, IOE, runEff, (:>))+import Effectful.Dispatch.Dynamic (interpret_)+import Kiroku.Store.Effect (Store (..))+import Kiroku.Store.Read (visibleGlobalHeadPosition)+import Kiroku.Store.Types (GlobalPosition (..))+import Test.Hspec++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++runVisibleHeadMock ::+    (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"