packages feed

keiro-0.17.0.0: src/Keiro/Inbox/Delegated.hs

-- | Safe adapters for handlers used with delegated-idempotence inbox intake.
--
-- The wrappers in "Keiro.Inbox" deliberately accept any effectful handler, so
-- they cannot prove that its complete operation is idempotent. This module
-- supplies narrower adapters for one aggregate command or one already-resolved
-- process-manager command result. Both require a durable event receipt and
-- refuse to acknowledge failures or successful commands that appended no event.
module Keiro.Inbox.Delegated
  ( delegatedEventId,
    DelegatedCommandError (..),
    delegatedCommand,
    delegatedFromPMCommand,
  )
where

import Data.ByteString qualified as ByteString
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text.Encoding
import Data.UUID.V5 qualified as UUID.V5
import Effectful (Eff, (:>))
import Keiro.Command (CommandError, CommandResult, RunCommandOptions)
import Keiro.DeterministicId (identitySeedBytes)
import Keiro.Inbox.Types (DelegatedOutcome (..))
import Keiro.Prelude
import Keiro.ProcessManager (PMCommandResult (..), dispatchDeduplicatedCommand)
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Types (EventId (..), StreamName (..))

-- | Derive the permanent first-event receipt for one delegated command.
--
-- The identity contains a version tag followed by consumer, integration source,
-- inbox dedupe key, resolved target stream, and stable operation name. Every
-- field is prefixed with its UTF-8 byte length, making boundaries unambiguous
-- even for empty, Unicode, or delimiter-containing values. These inputs and the
-- version-1 recipe are replay identity and must remain stable for the full
-- redelivery horizon.
delegatedEventId :: Text -> Text -> Text -> StreamName -> Text -> EventId
delegatedEventId consumer source dedupe (StreamName target) operation =
  EventId
    ( UUID.V5.generateNamed
        UUID.V5.namespaceURL
        ( identitySeedBytes
            ( Text.concat
                ( encodeField
                    <$> [ "keiro/inbox-delegated/1",
                          consumer,
                          source,
                          dedupe,
                          target,
                          operation
                        ]
                )
            )
        )
    )
  where
    encodeField field =
      Text.pack (show (ByteString.length (Text.Encoding.encodeUtf8 field)))
        <> ":"
        <> field

-- | Why a command cannot serve as a delegated-idempotence receipt.
data DelegatedCommandError
  = -- | The command failed; the target name is retained for retry and diagnostics.
    DelegatedCommandFailed !StreamName !CommandError
  | -- | The command succeeded without appending an event, so it left no durable
    -- receipt for this intake identity.
    DelegatedCommandWithoutReceipt !StreamName
  deriving stock (Generic, Eq, Show)

-- | Protect one atomic command append with a deterministic first-event receipt.
--
-- The adapter probes @markerId@ in @targetStream@ before invoking the callback,
-- preventing hydration or dispatch on a confirmed replay. It replaces the
-- options' event-id list with the singleton marker, then passes those prepared
-- options to the callback. The callback must use the supplied options and target,
-- perform exactly one atomic append, and include all protected SQL, projection,
-- and outbox work in that append transaction.
--
-- A positive append is fresh; a preflight hit or a concurrently confirmed append
-- is duplicate. A zero-event success and every unconfirmed command error remain
-- typed failures. In particular, callers must inspect 'Left' and apply their
-- retry or dead-letter policy; wrapping this whole result in 'DelegatedFresh'
-- would acknowledge a failed operation.
delegatedCommand ::
  forall target es.
  (Store :> es) =>
  RunCommandOptions ->
  StreamName ->
  EventId ->
  (RunCommandOptions -> Eff es (Either CommandError (CommandResult target))) ->
  Eff es (Either DelegatedCommandError (DelegatedOutcome (CommandResult target)))
delegatedCommand baseOptions targetStream markerId dispatch =
  dispatchDeduplicatedCommand
    preparedOptions
    targetStream
    (markerId :| [])
    (const (Right DelegatedDuplicate))
    (Left . DelegatedCommandFailed targetStream)
    classifySuccess
    (dispatch preparedOptions)
  where
    preparedOptions = baseOptions & #eventIds .~ [markerId]
    classifySuccess result
      | result ^. #eventsAppended > 0 = Right (DelegatedFresh result)
      | otherwise = Left (DelegatedCommandWithoutReceipt targetStream)

-- | Adapt one process-manager command result after its deterministic dispatch.
--
-- This is valid only for a single dispatch whose command identity already
-- absorbs the intake identity. It does not prove that an arbitrary multi-command
-- process-manager reaction completed.
delegatedFromPMCommand ::
  StreamName ->
  PMCommandResult target ->
  Either DelegatedCommandError (DelegatedOutcome (CommandResult target))
delegatedFromPMCommand resolvedTarget = \case
  PMCommandDuplicate _ -> Right DelegatedDuplicate
  PMCommandAppended result
    | result ^. #eventsAppended > 0 -> Right (DelegatedFresh result)
    | otherwise -> Left (DelegatedCommandWithoutReceipt resolvedTarget)
  PMCommandFailed failedTarget err -> Left (DelegatedCommandFailed failedTarget err)