packages feed

keiro-ops-0.12.0.0: src/Keiro/Ops/Stream.hs

-- | Operational adapters for Kiroku stream reads and lifecycle operations.
--
-- Every database action is a public @kiroku-store@ operation, preserving that
-- library's schema ownership under ADR 28.
module Keiro.Ops.Stream
  ( Command (..),
    TruncateCommand (..),
    commandParser,
    isMutation,
    runCommand,
  )
where

import Data.Aeson (Value, object, (.=))
import Data.Aeson qualified as Aeson
import Data.Int (Int64)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as Text.IO
import Data.Time (UTCTime, defaultTimeLocale, formatTime)
import Data.UUID (UUID)
import Data.UUID qualified as UUID
import Data.Vector qualified as Vector
import Effectful (Eff, IOE)
import Effectful.Error.Static (Error)
import Keiro.Ops.Env (OpsEnv (..), OutputMode (..))
import Keiro.Ops.Parse (nonNegativeIntReader, nonNegativeReader, positiveIntReader)
import Keiro.Ops.Render
import Keiro.Ops.Snapshot qualified as Snapshot
import Keiro.ReadModel (storeHeadPosition)
import Kiroku.Store.Causation (findCausationAncestors, findCausationDescendants)
import Kiroku.Store.Effect (Store, runStoreIO)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Lifecycle
import Kiroku.Store.Read (getStream, lookupStreamNames, readStreamForward)
import Kiroku.Store.Subscription
  ( SubscriptionCheckpoint (..),
    SubscriptionCheckpointInventory (..),
    SubscriptionName (..),
    subscriptionCheckpointInventory,
  )
import Kiroku.Store.Types
import Options.Applicative hiding (action, info, value)
import Options.Applicative qualified as Opt
import System.IO (hFlush, stdout)

data Command
  = Show !Text !StreamVersion !Int
  | SoftDelete !Text
  | Undelete !Text
  | HardDelete !Text
  | TruncateBefore !TruncateCommand
  | Causation !EventId
  | Subscriptions
  deriving stock (Eq, Show)

data TruncateCommand
  = SetTruncateBefore !Text !StreamVersion !(Maybe Snapshot.ExpectedDiscriminators) !Bool
  | ClearTruncateBefore !Text
  deriving stock (Eq, Show)

commandParser :: Parser Command
commandParser =
  hsubparser
    ( command "show" (Opt.info showParser (progDesc "Show stream metadata and ordered events"))
        <> command "soft-delete" (Opt.info (SoftDelete <$> streamArgument) (progDesc "Preview or soft-delete a stream"))
        <> command "undelete" (Opt.info (Undelete <$> streamArgument) (progDesc "Preview or restore a soft-deleted stream"))
        <> command "hard-delete" (Opt.info (HardDelete <$> streamArgument) (progDesc "Preview or permanently delete a stream"))
        <> command "truncate-before" (Opt.info (TruncateBefore <$> truncateParser) (progDesc "Operate the reversible stream visibility marker"))
        <> command "causation" (Opt.info (Causation <$> eventIdArgument) (progDesc "Show an event's causation ancestors and descendants"))
        <> command "subscriptions" (Opt.info (pure Subscriptions) (progDesc "List durable subscription checkpoints"))
    )
  where
    showParser =
      Show
        <$> streamArgument
        <*> (StreamVersion <$> option (nonNegativeReader "expected a non-negative stream version") (long "from" <> metavar "VERSION" <> Opt.value 0 <> showDefault <> help "Exclusive stream-version cursor"))
        <*> option positiveIntReader (long "limit" <> metavar "N" <> Opt.value 100 <> showDefault <> help "Maximum events")

truncateParser :: Parser TruncateCommand
truncateParser =
  hsubparser
    ( command "set" (Opt.info setParser (progDesc "Preview or set a truncate-before marker after snapshot preflight"))
        <> command "clear" (Opt.info (ClearTruncateBefore <$> streamArgument) (progDesc "Preview or clear a truncate-before marker"))
    )
  where
    setParser =
      SetTruncateBefore
        <$> streamArgument
        <*> (StreamVersion <$> argument (nonNegativeReader "expected a non-negative stream version") (metavar "VERSION"))
        <*> optional expectedParser
        <*> switch (long "skip-preflight" <> help "Bypass snapshot coverage checking (dangerous)")
    expectedParser =
      Snapshot.ExpectedDiscriminators
        <$> option nonNegativeIntReader (long "state-codec-version" <> metavar "N" <> help "Application's current state codec version")
        <*> textOption "regfile-shape-hash" "HASH" "Application's current register-layout hash"
        <*> textOption "state-shape-hash" "HASH" "Application's current control-state/fold hash"

streamArgument :: Parser Text
streamArgument = Text.pack <$> argument str (metavar "STREAM")

textOption :: String -> String -> String -> Parser Text
textOption name metavarText helpText = Text.pack <$> strOption (long name <> metavar metavarText <> help helpText)

eventIdArgument :: Parser EventId
eventIdArgument = EventId <$> argument uuidReader (metavar "EVENT_ID")

uuidReader :: ReadM UUID
uuidReader = eitherReader $ \raw -> maybe (Left "expected a UUID event id") Right (UUID.fromString raw)

isMutation :: Command -> Bool
isMutation = \case
  Show {} -> False
  Causation {} -> False
  Subscriptions -> False
  SoftDelete {} -> True
  Undelete {} -> True
  HardDelete {} -> True
  TruncateBefore {} -> True

runCommand :: OpsEnv -> Command -> IO OpsOutcome
runCommand env = \case
  Show name from limit -> runShow env name from limit
  SoftDelete name -> runLifecycle env "soft-delete" name (softDeleteStream (StreamName name))
  Undelete name -> runLifecycle env "undelete" name (undeleteStream (StreamName name))
  HardDelete name -> runHardDelete env name
  TruncateBefore truncateCommand -> runTruncate env truncateCommand
  Causation eventId -> runCausation env eventId
  Subscriptions -> runSubscriptions env

runSubscriptions :: OpsEnv -> IO OpsOutcome
runSubscriptions env =
  runAction env action $ \(visibleHead, inventory) ->
    Succeeded (subscriptionInventoryResult visibleHead inventory)
  where
    action = (,) <$> storeHeadPosition <*> subscriptionCheckpointInventory

subscriptionInventoryResult :: GlobalPosition -> SubscriptionCheckpointInventory -> OpsResult
subscriptionInventoryResult visibleHead inventory =
  OpsResult
    { headers = ["subscription", "member", "checkpoint_position", "checkpoint_updated_at", "store_position", "visible_store_head", "global_position_distance"],
      rows = map (checkpointRow captured visibleHead) durableCheckpoints,
      jsonValue =
        object
          [ "store_position" .= positionInt captured,
            "visible_store_head" .= positionInt visibleHead,
            "checkpoints" .= map (checkpointJson visibleHead) durableCheckpoints
          ]
    }
  where
    captured = storePosition inventory
    durableCheckpoints = Vector.toList (checkpoints inventory)

checkpointRow :: GlobalPosition -> GlobalPosition -> SubscriptionCheckpoint -> [Text]
checkpointRow captured visibleHead (SubscriptionCheckpoint (SubscriptionName name) member position updatedAt) =
  [ name,
    showText member,
    positionText position,
    utcText updatedAt,
    positionText captured,
    positionText visibleHead,
    showText (globalPositionDistance visibleHead position)
  ]

checkpointJson :: GlobalPosition -> SubscriptionCheckpoint -> Value
checkpointJson visibleHead (SubscriptionCheckpoint (SubscriptionName name) member position updatedAt) =
  object
    [ "subscription" .= name,
      "member" .= member,
      "checkpoint_position" .= positionInt position,
      "checkpoint_updated_at" .= updatedAt,
      "global_position_distance" .= globalPositionDistance visibleHead position
    ]

globalPositionDistance :: GlobalPosition -> GlobalPosition -> Int64
globalPositionDistance (GlobalPosition captured) (GlobalPosition checkpoint) = max 0 (captured - checkpoint)

utcText :: UTCTime -> Text
utcText = Text.pack . formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%S%QZ"

runShow :: OpsEnv -> Text -> StreamVersion -> Int -> IO OpsOutcome
runShow env name from limit =
  runAction env action $ \(info, events) -> Succeeded (streamResult name info events)
  where
    action = do
      info <- getStream (StreamName name)
      events <- readStreamForward (StreamName name) from (fromIntegral limit)
      pure (info, Vector.toList events)

runLifecycle :: OpsEnv -> Text -> Text -> Eff '[Store, Error StoreError, IOE] (Maybe StreamId) -> IO OpsOutcome
runLifecycle env operation name action
  | not env.force =
      runAction env (getStream (StreamName name)) $ \info ->
        PreviewRequired
          (lifecycleResult True operation name info Nothing)
          (forceInvocation env ["stream", operation, name])
  | otherwise =
      runAction env mutation $ \(before, outcome) ->
        Succeeded (lifecycleResult False operation name before outcome)
  where
    mutation = do
      before <- getStream (StreamName name)
      outcome <- action
      pure (before, outcome)

runHardDelete :: OpsEnv -> Text -> IO OpsOutcome
runHardDelete env name
  | not env.force = runLifecycle env "hard-delete" name (hardDeleteStream (StreamName name))
  | otherwise = do
      confirmed <- confirmDestructive env name
      if confirmed
        then runLifecycle env "hard-delete" name (hardDeleteStream (StreamName name))
        else pure (Failed "stream-name confirmation did not match; hard delete cancelled")

runTruncate :: OpsEnv -> TruncateCommand -> IO OpsOutcome
runTruncate env = \case
  ClearTruncateBefore name -> runClearTruncate env name
  SetTruncateBefore name before expected skipPreflight -> runSetTruncate env name before expected skipPreflight

runClearTruncate :: OpsEnv -> Text -> IO OpsOutcome
runClearTruncate env name
  | not env.force =
      runAction env (getStream (StreamName name)) $ \streamInfo ->
        PreviewRequired
          (lifecycleResult True "truncate-before clear" name streamInfo Nothing)
          (forceInvocation env ["stream", "truncate-before", "clear", name])
  | otherwise =
      runAction env action $ \(streamInfo, outcome) ->
        Succeeded (lifecycleResult False "truncate-before clear" name streamInfo outcome)
  where
    action = do
      streamInfo <- getStream (StreamName name)
      outcome <- clearStreamTruncateBefore (StreamName name)
      pure (streamInfo, outcome)

runSetTruncate :: OpsEnv -> Text -> StreamVersion -> Maybe Snapshot.ExpectedDiscriminators -> Bool -> IO OpsOutcome
runSetTruncate env name before expected skipPreflight = do
  checked <-
    if skipPreflight
      then pure Nothing
      else do
        outcome <- runStoreIO env.store (Snapshot.preflightFor name before expected)
        case outcome of
          Left storeError -> pure (Just (Left (Text.pack (show storeError))))
          Right evidence -> pure (Just (Right evidence))
  case checked of
    Just (Left message) -> pure (Failed message)
    Just (Right evidence)
      | not evidence.passed ->
          pure (Failed ("snapshot preflight failed: " <> evidence.reason))
    _
      | not env.force ->
          runAction env (getStream (StreamName name)) $ \info ->
            PreviewRequired
              (truncateResult True name before info checked)
              (forceInvocation env (setArguments name before expected skipPreflight))
      | otherwise -> do
          confirmed <- confirmDestructive env name
          if not confirmed
            then pure (Failed "stream-name confirmation did not match; truncate-before cancelled")
            else runAction env action $ \(info, outcome) ->
              Succeeded (truncateMutationResult name before info outcome checked)
  where
    action = do
      info <- getStream (StreamName name)
      outcome <- setStreamTruncateBefore (StreamName name) before
      pure (info, outcome)

setArguments :: Text -> StreamVersion -> Maybe Snapshot.ExpectedDiscriminators -> Bool -> [Text]
setArguments name before expected skipPreflight =
  ["stream", "truncate-before", "set", name, versionText before]
    <> maybe [] discriminatorArguments expected
    <> ["--skip-preflight" | skipPreflight]

discriminatorArguments :: Snapshot.ExpectedDiscriminators -> [Text]
discriminatorArguments expected =
  [ "--state-codec-version",
    showText expected.stateCodecVersion,
    "--regfile-shape-hash",
    expected.regfileShapeHash,
    "--state-shape-hash",
    expected.stateShapeHash
  ]

confirmDestructive :: OpsEnv -> Text -> IO Bool
confirmDestructive env name
  | env.outputMode == Json = pure True
  | otherwise = do
      Text.IO.putStr ("type the stream name to confirm: " <> name <> "\n> ")
      hFlush stdout
      entered <- Text.IO.getLine
      pure (entered == name)

runCausation :: OpsEnv -> EventId -> IO OpsOutcome
runCausation env eventId =
  runAction env action $ \(ancestors, descendants, names) ->
    Succeeded (causationResult eventId ancestors descendants names)
  where
    action = do
      ancestors <- Vector.toList <$> findCausationAncestors eventId
      descendants <- Vector.toList <$> findCausationDescendants eventId
      names <- lookupStreamNames (map (.originalStreamId) (ancestors <> descendants))
      pure (ancestors, descendants, names)

runAction :: OpsEnv -> Eff '[Store, Error StoreError, IOE] a -> (a -> OpsOutcome) -> IO OpsOutcome
runAction env action onSuccess = do
  result <- runStoreIO env.store action
  pure $ either (Failed . Text.pack . show) onSuccess result

streamResult :: Text -> Maybe StreamInfo -> [RecordedEvent] -> OpsResult
streamResult name info events =
  OpsResult
    { headers = ["stream", "version", "event_id", "event_type", "global_position", "created_at", "payload"],
      rows = map eventRow events,
      jsonValue = object ["stream" .= streamInfoJson name info, "events" .= map (eventJson Nothing) events]
    }
  where
    eventRow event =
      [ name,
        versionText event.streamVersion,
        eventIdText event.eventId,
        eventTypeText event.eventType,
        positionText event.globalPosition,
        showText event.createdAt,
        truncateCell 120 (jsonText event.payload)
      ]

streamInfoJson :: Text -> Maybe StreamInfo -> Value
streamInfoJson name = \case
  Nothing -> object ["name" .= name, "exists" .= False]
  Just info ->
    object
      [ "name" .= name,
        "exists" .= True,
        "stream_id" .= streamIdInt info.id,
        "version" .= versionInt info.version,
        "created_at" .= info.createdAt,
        "deleted_at" .= info.deletedAt,
        "truncate_before" .= versionInt info.truncateBefore
      ]

eventJson :: Maybe Text -> RecordedEvent -> Value
eventJson direction event =
  object
    [ "direction" .= direction,
      "event_id" .= eventIdText event.eventId,
      "event_type" .= eventTypeText event.eventType,
      "stream_version" .= versionInt event.streamVersion,
      "global_position" .= positionInt event.globalPosition,
      "original_stream_id" .= streamIdInt event.originalStreamId,
      "original_version" .= versionInt event.originalVersion,
      "payload" .= event.payload,
      "metadata" .= event.metadata,
      "causation_id" .= fmap UUID.toText event.causationId,
      "correlation_id" .= fmap UUID.toText event.correlationId,
      "created_at" .= event.createdAt
    ]

lifecycleResult :: Bool -> Text -> Text -> Maybe StreamInfo -> Maybe StreamId -> OpsResult
lifecycleResult preview operation name info outcome =
  OpsResult
    { headers = ["operation", "stream", "current_state", "disposition"],
      rows = [[operation, name, maybe "not_found" streamState info, disposition]],
      jsonValue = object ["preview" .= preview, "operation" .= operation, "stream" .= streamInfoJson name info, "affected_stream_id" .= fmap streamIdInt outcome, "disposition" .= disposition]
    }
  where
    disposition
      | preview = maybe "not_found" (const ("would_" <> Text.replace " " "_" operation)) info
      | otherwise = maybe "not_transitioned" (const "transitioned") outcome

streamState :: StreamInfo -> Text
streamState info
  | info.deletedAt == Nothing = "live"
  | otherwise = "soft_deleted"

truncateResult :: Bool -> Text -> StreamVersion -> Maybe StreamInfo -> Maybe (Either Text Snapshot.PreflightEvidence) -> OpsResult
truncateResult preview name before info checked =
  OpsResult
    { headers = ["stream", "current_marker", "new_marker", "preflight", "disposition"],
      rows = [[name, maybe "not_found" (versionText . (.truncateBefore)) info, versionText before, preflightText checked, if preview then "would_set" else "set"]],
      jsonValue = object ["preview" .= preview, "stream" .= streamInfoJson name info, "new_marker" .= versionInt before, "preflight" .= preflightJson checked]
    }

truncateMutationResult :: Text -> StreamVersion -> Maybe StreamInfo -> Maybe StreamId -> Maybe (Either Text Snapshot.PreflightEvidence) -> OpsResult
truncateMutationResult name before info outcome checked =
  (truncateResult False name before info checked)
    { rows = [[name, maybe "not_found" (versionText . (.truncateBefore)) info, versionText before, preflightText checked, maybe "not_transitioned" (const "set") outcome]],
      jsonValue = object ["preview" .= False, "stream" .= streamInfoJson name info, "new_marker" .= versionInt before, "preflight" .= preflightJson checked, "affected_stream_id" .= fmap streamIdInt outcome]
    }

preflightText :: Maybe (Either Text Snapshot.PreflightEvidence) -> Text
preflightText Nothing = "skipped"
preflightText (Just (Left message)) = "error: " <> message
preflightText (Just (Right evidence)) = if evidence.passed then "passed" else "failed: " <> evidence.reason

preflightJson :: Maybe (Either Text Snapshot.PreflightEvidence) -> Value
preflightJson Nothing = object ["skipped" .= True]
preflightJson (Just (Left message)) = object ["error" .= message]
preflightJson (Just (Right evidence)) =
  object
    [ "passed" .= evidence.passed,
      "reason" .= evidence.reason,
      "required_snapshot_version" .= versionInt evidence.requiredSnapshotVersion,
      "version_covered" .= evidence.versionCovered,
      "discriminators_match" .= evidence.discriminatorsMatch
    ]

causationResult :: EventId -> [RecordedEvent] -> [RecordedEvent] -> Map StreamId StreamName -> OpsResult
causationResult seed ancestors descendants names =
  OpsResult
    { headers = ["direction", "event_id", "stream", "type", "global_position", "causation_id"],
      rows = map (uncurry row) directed,
      jsonValue = Aeson.toJSON [eventJson (Just direction) event | (direction, event) <- directed]
    }
  where
    seedRows = take 1 (filter ((== seed) . (.eventId)) (ancestors <> descendants))
    directed =
      map ("seed",) seedRows
        <> map ("ancestor",) (filter ((/= seed) . (.eventId)) ancestors)
        <> map ("descendant",) (filter ((/= seed) . (.eventId)) descendants)
    row direction event =
      [ direction,
        eventIdText event.eventId,
        maybe ("#" <> showText (streamIdInt event.originalStreamId)) streamNameText (Map.lookup event.originalStreamId names),
        eventTypeText event.eventType,
        positionText event.globalPosition,
        maybe "" UUID.toText event.causationId
      ]

streamNameText :: StreamName -> Text
streamNameText (StreamName value) = value

eventIdText :: EventId -> Text
eventIdText (EventId value) = UUID.toText value

eventTypeText :: EventType -> Text
eventTypeText (EventType value) = value

streamIdInt :: StreamId -> Int64
streamIdInt (StreamId value) = value

versionInt :: StreamVersion -> Int64
versionInt (StreamVersion value) = value

positionInt :: GlobalPosition -> Int64
positionInt (GlobalPosition value) = value

versionText :: StreamVersion -> Text
versionText = showText . versionInt

positionText :: GlobalPosition -> Text
positionText = showText . positionInt

showText :: (Show a) => a -> Text
showText = Text.pack . show

forceInvocation :: OpsEnv -> [Text] -> Text
forceInvocation env arguments = Text.unwords (map shellQuote ("keiro-ops" : arguments <> globalFlags <> ["--force"]))
  where
    globalFlags = ["--json" | env.outputMode == Json] <> ["--allow-schema-drift" | env.allowSchemaDrift]

shellQuote :: Text -> Text
shellQuote value = "'" <> Text.replace "'" "'\"'\"'" value <> "'"