packages feed

keiro-core-0.2.0.0: src/Keiro/Stream.hs

{- | A phantom-typed handle to a single event stream.

'Stream' wraps a 'StreamName' but carries a phantom type parameter @a@
identifying /which/ aggregate or event stream the name belongs to. This
lets the rest of the framework demand, say, a @Stream Order@ rather than a
bare 'StreamName', so a name for one aggregate cannot be passed where
another is expected. The wrapper is otherwise transparent — use 'stream'
to construct one and 'streamName' to recover the underlying name.
-}
module Keiro.Stream (
    Stream (..),
    stream,
    streamName,
    mapStreamName,

    -- * Safe, category-based construction
    StreamCategory,
    categoryText,
    CategoryError (..),
    category,
    categoryUnsafe,
    categoryName,
    StreamIdSegment (..),
    entityStream,
    entityStreamId,
)
where

import Data.Char qualified as Char
import Data.Text qualified as Text
import GHC.Stack (HasCallStack)
import Keiro.Prelude
import Kiroku.Store.Types (CategoryName (..), StreamName (..))
import Kiroku.Store.Types qualified as Store

-- | A 'StreamName' tagged with the phantom type @a@ of the stream it names.
newtype Stream a = Stream
    { name :: StreamName
    }
    deriving stock (Generic, Eq, Ord, Show)

-- | Build a 'Stream' handle from a raw stream-name 'Text'.
stream :: Text -> Stream a
stream name = Stream{name = StreamName name}

-- | Recover the underlying 'StreamName' from a 'Stream' handle.
streamName :: Stream a -> StreamName
streamName value = value ^. #name

{- | Transform the underlying 'StreamName' while preserving the phantom
type. Handy for namespacing or prefixing a stream name without losing the
compile-time tag.
-}
mapStreamName :: (StreamName -> StreamName) -> Stream a -> Stream a
mapStreamName f value = value & #name %~ f

{- | A validated stream /category/: the prefix that precedes the first @-@ in
every stream name belonging to this family. Kiroku defines a stream's category
as the substring before its first @-@ (see 'Kiroku.Store.Types.categoryName'),
so a category must itself contain no @-@. Carries the same phantom type @a@ as
the 'Stream' handles it produces. Write a compound category in camelCase (e.g.
@"hospitalSurge"@ for a saga over hospital surges); @:@ is reserved for the
workflow stream family (@wf:\<name\>@).

Named 'StreamCategory' (not @Category@) to avoid clashing with the
'Kiroku.Store.Subscription.Types.Category' subscription-target constructor,
which consumer code commonly imports alongside this module.
-}
newtype StreamCategory a = StreamCategory {categoryTextOf :: Text}
    deriving stock (Generic, Eq, Ord, Show)

-- | Why a 'Text' is not a valid 'StreamCategory'.
data CategoryError
    = -- | the empty string
      CategoryEmpty
    | -- | contains the reserved @-@ category/id boundary
      CategoryContainsSeparator !Text
    | -- | equals a store-reserved name (@$all@)
      CategoryReserved !Text
    | -- | contains whitespace or a control character
      CategoryContainsIllegalChar !Char !Text
    deriving stock (Eq, Show, Generic)

{- | Validate a 'Text' as a 'Category'. Rejects the empty string, any text
containing @-@ (Kiroku's category/id boundary, which would make 'categoryName'
ambiguous), whitespace/control characters, and the reserved name @$all@.
-}
category :: Text -> Either CategoryError (StreamCategory a)
category t
    | Text.null t = Left CategoryEmpty
    | t == "$all" = Left (CategoryReserved t)
    | Text.isInfixOf "-" t = Left (CategoryContainsSeparator t)
    | Just illegal <- Text.find (\c -> Char.isSpace c || Char.isControl c) t =
        Left (CategoryContainsIllegalChar illegal t)
    | otherwise = Right (StreamCategory t)

-- | Recover the validated category text.
categoryText :: StreamCategory a -> Text
categoryText (StreamCategory t) = t

{- | Partial constructor for static, known-good category literals at definition
sites. Calls 'error' on an invalid category; never pass user input. Intended for
top-level @fooCategory = categoryUnsafe "foo"@.
-}
categoryUnsafe :: (HasCallStack) => Text -> StreamCategory a
categoryUnsafe t =
    case category t of
        Right value -> value
        Left err -> error ("Keiro.Stream.categoryUnsafe: invalid category " <> show t <> ": " <> show err)

{- | The 'CategoryName' for category-scoped reads
('Kiroku.Store.Read.readCategory') and category subscription targets. For any
@cat@ and id segment, @categoryName cat@ equals
@Kiroku.Store.Types.categoryName (streamName (entityStream cat id))@ — the
category rule is single-sourced in kiroku.
-}
categoryName :: StreamCategory a -> CategoryName
categoryName (StreamCategory t) = CategoryName t

{- | Render a value as the /id segment/ of a stream name (the part after the
first @-@). The id may itself contain @-@ without corrupting the leading
category, but must be non-blank to keep the stream name distinct from a bare
category read.
-}
class StreamIdSegment i where
    renderIdSegment :: i -> Text

instance StreamIdSegment Text where
    renderIdSegment = id

instance StreamIdSegment String where
    renderIdSegment = Text.pack

{- | Build the per-entity 'Stream' handle for an aggregate instance, rendering
@\<category\>-\<id\>@. The phantom type is carried from the 'StreamCategory', so
the result is correctly tagged. The actual name mechanics are delegated to
kiroku's 'Store.streamNameInCategory', keeping the category rule single-sourced
in the store.
-}
entityStream :: (HasCallStack) => StreamCategory a -> Text -> Stream a
entityStream (StreamCategory c) idSeg =
    if Text.null (Text.strip idSeg)
        then error ("Keiro.Stream.entityStream: blank id segment for category " <> show c)
        else Stream{name = Store.streamNameInCategory (CategoryName c) idSeg}

{- | 'entityStream' for a typed id with a 'StreamIdSegment' instance. Domain id
types typically add @instance StreamIdSegment FooId where renderIdSegment =
Text.pack . show@.
-}
entityStreamId :: (HasCallStack, StreamIdSegment i) => StreamCategory a -> i -> Stream a
entityStreamId c = entityStream c . renderIdSegment