kiroku-store-0.12.0.0: src/Kiroku/Store/Settings.hs
-- | 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 (..),
DecodedEvent (..),
DecodedBatch (..),
decodeEvents,
decodeEvent,
decodedEventRecorded,
decodedBatchLength,
decodedBatchLastEvent,
filterDecodedBatch,
)
where
import Data.Text (Text)
import Data.Vector (Vector)
import Data.Vector qualified as V
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.
data DecodeFailure = DecodeFailure
{ 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)
-- | 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)
decodedEventRecorded :: DecodedEvent -> RecordedEvent
decodedEventRecorded = \case
Decoded event -> event
Undecodable event _ -> event
decodedBatchLength :: DecodedBatch -> Int
decodedBatchLength = \case
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)
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.
--
-- 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
{ -- | 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
}
-- | 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
-- | 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
-- | 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
applyDecode :: (RecordedEvent -> IO (Either DecodeFailure RecordedEvent)) -> RecordedEvent -> IO DecodedEvent
applyDecode f event = either (Undecodable event) Decoded <$> f event