packages feed

keiro-ops-0.22.0.0: src/Keiro/Ops/Aggregate.hs

-- | Read-only adapters over the application's aggregate catalog.
module Keiro.Ops.Aggregate (Command (..), ShowOptions (..), ListOptions (..), commandParser, validateCommand, isMutation, runCommand) where

import Data.Aeson (object, (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as Text
import Effectful (Eff, IOE)
import Effectful.Error.Static (Error)
import Keiro.Inspection.Aggregate qualified as Aggregate
import Keiro.Inspection.Cursor (InspectionCursor, decodeCursor)
import Keiro.Ops.Env (OpsEnv (..))
import Keiro.Ops.Parse (cursorReader, positiveIntReader)
import Keiro.Ops.Render
import Kiroku.Store.Effect (Store, runStoreIO)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Types (StreamName (..), StreamVersion (..))
import Options.Applicative hiding (action, command, info, value)
import Options.Applicative qualified as Opt

data ShowOptions = ShowOptions {name :: !Text, aggregateId :: !Text}
  deriving stock (Eq, Show)

data ListOptions = ListOptions {name :: !Text, limit :: !Int, after :: !(Maybe InspectionCursor)}
  deriving stock (Eq, Show)

data Command = Types | Show !ShowOptions | Snapshot !ShowOptions | List !ListOptions
  deriving stock (Eq, Show)

commandParser :: Parser Command
commandParser =
  hsubparser
    ( Opt.command "types" (Opt.info (pure Types) (progDesc "List mounted aggregate kinds"))
        <> Opt.command "show" (Opt.info (Show <$> showOptions) (progDesc "Inspect folded aggregate state"))
        <> Opt.command "snapshot" (Opt.info (Snapshot <$> showOptions) (progDesc "Inspect aggregate snapshot health"))
        <> Opt.command "list" (Opt.info (List <$> listOptions) (progDesc "Read one aggregate page"))
    )
  where
    textOption key label = Text.pack <$> strOption (long key <> metavar label)
    nameOption = textOption "name" "NAME"
    showOptions = ShowOptions <$> nameOption <*> textOption "id" "ID"
    listOptions = ListOptions <$> nameOption <*> option positiveIntReader (long "limit" <> Opt.value 50 <> metavar "N") <*> optional (option cursorReader (long "after" <> metavar "CURSOR"))

isMutation :: Command -> Bool
isMutation = const False

-- | Reject invalid input before a transport borrows a schema-check connection.
-- The owning page read additionally checks the catalog's exact category.
validateCommand :: Command -> Either (Text, Text) ()
validateCommand = \case
  Types -> Right ()
  Show options -> validateId options
  Snapshot options -> validateId options
  List options
    | options.limit < 1 || options.limit > Aggregate.maxAggregatePageSize -> Left ("invalid_page_size", "page size must be 1..500")
    | otherwise -> case options.after of
        Nothing -> Right ()
        Just cursor -> case decodeCursor Aggregate.aggregateListCursorKind cursor of
          Right (name, category, after :: Text)
            | name == options.name && Text.isPrefixOf (category <> "-") after -> Right ()
          _ -> Left ("invalid_cursor", "invalid cursor or cursor belongs to another aggregate inspector")
  where
    validateId options
      | Text.null (Text.strip options.aggregateId) = Left ("invalid_aggregate_id", "aggregate id must not be blank")
      | otherwise = Right ()

runCommand :: OpsEnv -> Aggregate.AggregateInspectors -> Command -> IO OpsOutcome
runCommand env catalog command = case command of
  Types ->
    pure
      ( Succeeded
          ( OpsResult
              ["name", "category", "snapshots_configured", "snapshot_policy"]
              [[summary.name, summary.category, if summary.snapshotsConfigured then "true" else "false", summary.snapshotPolicy] | summary <- Aggregate.listAggregateTypes catalog]
              (object ["items" .= map Aggregate.aggregateTypeJson (Aggregate.listAggregateTypes catalog)])
          )
      )
  Show options -> case Map.lookup options.name catalog of
    Nothing -> pure (Failed ("unknown aggregate kind: " <> options.name))
    Just (Aggregate.SomeAggregateInspector inspector) ->
      runAction env (Aggregate.inspectAggregate inspector options.aggregateId) $ \view ->
        OpsResult
          ["name", "aggregate_id", "stream_version", "state"]
          [[view.name, view.aggregateId, versionText view.streamVersion, maybe "" id view.stateLabel]]
          (Aggregate.aggregateViewJson view)
  Snapshot options -> case Map.lookup options.name catalog of
    Nothing -> pure (Failed ("unknown aggregate kind: " <> options.name))
    Just (Aggregate.SomeAggregateInspector inspector) ->
      runAction env (Aggregate.snapshotStatusFor inspector options.aggregateId) $ \(StreamName streamName, StreamVersion version, status) ->
        let value = case Aggregate.snapshotStatusJson status of
              Aeson.Object fields -> Aeson.Object (fields <> KeyMap.fromList [("name", Aeson.toJSON options.name), ("aggregate_id", Aeson.toJSON options.aggregateId), ("stream_name", Aeson.toJSON streamName), ("stream_version", Aeson.toJSON version)])
              other -> other
         in OpsResult ["name", "aggregate_id"] [[options.name, options.aggregateId]] value
  List options -> case Map.lookup options.name catalog of
    Nothing -> pure (Failed ("unknown aggregate kind: " <> options.name))
    Just (Aggregate.SomeAggregateInspector inspector) ->
      runAction env (Aggregate.listAggregates inspector (Aggregate.AggregateListRequest options.limit options.after)) $ \page ->
        OpsResult
          ["aggregate_id", "stream_name", "stream_version"]
          [[item.aggregateId, nameText item.streamName, versionText item.streamVersion] | item <- page.items]
          (Aggregate.aggregateListPageJson page)

runAction :: OpsEnv -> Eff '[Store, Error StoreError, IOE] (Either Aggregate.AggregateInspectionError a) -> (a -> OpsResult) -> IO OpsOutcome
runAction env action render = do
  result <- runStoreIO env.store action
  pure $ case result of
    Left err -> Failed (Text.pack (show err))
    Right (Left err) -> case err of
      Aggregate.InvalidPageSize _ -> InspectionFailed "invalid_page_size" (Aggregate.inspectionErrorText err)
      Aggregate.InvalidCursor _ -> InspectionFailed "invalid_cursor" (Aggregate.inspectionErrorText err)
      Aggregate.ResultTooLarge -> InspectionFailed "inspection_result_too_large" (Aggregate.inspectionErrorText err)
      _ -> Failed (Aggregate.inspectionErrorText err)
    Right (Right value) -> Succeeded (render value)

versionText :: StreamVersion -> Text
versionText (StreamVersion version) = Text.pack (show version)

nameText :: StreamName -> Text
nameText (StreamName name) = name