packages feed

keiro-core-0.6.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