packages feed

keiro-0.12.0.0: src/Keiro/Projection.hs

-- | Projections: turning a stream's events into read-side state.
--
-- Two flavors, trading consistency against coupling:
--
-- * An 'InlineProjection' runs in the /same/ transaction as the command that
--   produced the events, so the read model is updated atomically with the
--   append — never stale, but tied to the writer's transaction and latency.
--   'runCommandWithProjections' runs a command and applies a list of inline
--   projections to whatever it emits.
-- * An 'AsyncProjection' runs later from a subscription draining the event
--   log. It carries a 'subscriptionName' for checkpointing and an
--   'idempotencyKey' so redelivery is safe; 'applyAsyncProjection' performs
--   one application. This decouples the read model from the writer at the
--   cost of eventual consistency.
--
-- Both ultimately fold events into a SQL read model via a
-- 'Hasql.Transaction.Transaction'; the difference is only /when/ that
-- transaction runs.
module Keiro.Projection
  ( -- * Inline projections
    InlineProjection (..),
    runCommandWithProjections,
    runDomainCommandWithProjections,
    ProjectionCommandOutcome (..),
    runCommandWithCatalogProjections,
    DomainProjectionCommandOutcome (..),
    runDomainCommandWithCatalogProjections,

    -- * Asynchronous projections
    AsyncProjection (..),
    AsyncApplyOutcome (..),
    CatalogAsyncApplyOutcome (..),
    applyAsyncProjection,
    applyAsyncProjectionFromCatalog,
    applyAsyncProjectionUnfenced,
    pruneAsyncProjectionDedupBefore,
    countAsyncProjectionDedupForBefore,
    pruneAsyncProjectionDedupForBefore,
    recordProjectionGlobalPositionDistance,
    recordProjectionLag,
  )
where

import Contravariant.Extras (contrazip2)
import Data.List qualified as List
import Data.List.NonEmpty qualified as NonEmpty
import Data.UUID (UUID)
import Effectful (Eff, IOE, (:>))
import Effectful.Error.Static (Error)
import GHC.Stack (HasCallStack)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Keiki.Core (BoolAlg, RegFile)
import Keiro.Command
  ( CommandError,
    CommandResult,
    DomainCommandHandler,
    DomainCommandOutcome,
    DomainSqlCommandOutcome (..),
    RunCommandOptions,
    SqlCommandOutcome (..),
    SqlTransactionDecision (..),
    runCommandWithSqlEvents,
    runCommandWithSqlEventsControlled,
    runDomainCommandWithSqlEvents,
    runDomainCommandWithSqlEventsControlled,
  )
import Keiro.EventStream (EventStream)
import Keiro.EventStream.Validate (ValidatedEventStream)
import Keiro.Prelude
import Keiro.Projection.Catalog
  ( PhysicalTargets,
    ProjectionHandler (..),
    ProjectionId,
    ProjectionRevisionId,
    ProjectionSet,
    RebuildGroupId,
    RevisionLiveDelivery (..),
    RevisionLiveHandler (..),
    SourceId,
    ValidatedProjectionCatalog,
    asyncProjectionRebuildGroup,
    asyncProjectionRegistrations,
    catalogRevisionLiveHandler,
    typedInlineProjectionsForGroup,
    typedProjectionRebuildGroups,
  )
import Keiro.Projection.Types
import Keiro.ReadModel (storeHeadPosition, subscriptionPositionFromInventory)
import Keiro.ReadModel.Rebuild.Group
  ( ProjectionWriteFence (..),
    RebuildRunId,
    lockProjectionGroupsTx,
  )
import Keiro.Stream (Stream)
import Keiro.Telemetry (KeiroMetrics)
import Keiro.Telemetry qualified as Telemetry
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Effect.Resource (KirokuStoreResource)
import Kiroku.Store.Error (StoreError)
import Kiroku.Store.Subscription
  ( SubscriptionName (..),
    subscriptionCheckpointInventory,
  )
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (EventId (..), GlobalPosition (..), RecordedEvent)
import "hasql-transaction" Hasql.Transaction qualified as Tx
import Prelude qualified

-- | The database-visible result of one asynchronous projection attempt.
data AsyncApplyOutcome
  = AsyncApplied
  | AsyncDuplicate
  | AsyncFenced
  deriving stock (Generic, Eq, Show)

-- | Result of a catalog-fenced inline command. A fenced result proves the
-- append transaction was rolled back, so neither its events nor any projection
-- SQL committed.
data ProjectionCommandOutcome target
  = ProjectionCommandApplied !(CommandResult target)
  | ProjectionCommandFenced !RebuildGroupId !RebuildRunId
  | ProjectionCommandGroupUnregistered !RebuildGroupId
  | ProjectionCommandServingRevisionUnavailable !RebuildGroupId !ProjectionRevisionId
  | ProjectionCommandServingBindingInvalid !RebuildGroupId !ProjectionRevisionId !Text
  | ProjectionCommandCatalogMismatch !SourceId
  deriving stock (Generic, Eq, Show)

-- | Outcome-aware counterpart to 'ProjectionCommandOutcome'. Successful
-- selected silent decisions are applied outcomes even though no projection
-- handler ran. Fence outcomes prove an accepted append was rolled back.
data DomainProjectionCommandOutcome target co rejection noOp
  = DomainProjectionCommandApplied !(DomainCommandOutcome target co rejection noOp)
  | DomainProjectionCommandFenced !RebuildGroupId !RebuildRunId
  | DomainProjectionCommandGroupUnregistered !RebuildGroupId
  | DomainProjectionCommandServingRevisionUnavailable !RebuildGroupId !ProjectionRevisionId
  | DomainProjectionCommandServingBindingInvalid !RebuildGroupId !ProjectionRevisionId !Text
  | DomainProjectionCommandCatalogMismatch !SourceId
  deriving stock (Generic, Eq, Show)

-- | Catalog-aware result of one asynchronous projection application.
data CatalogAsyncApplyOutcome
  = CatalogAsyncApplied
  | CatalogAsyncDuplicate
  | CatalogAsyncFenced !RebuildGroupId !RebuildRunId
  | CatalogAsyncGroupUnregistered !RebuildGroupId
  | CatalogAsyncServingRevisionUnavailable !RebuildGroupId !ProjectionRevisionId
  | CatalogAsyncServingBindingInvalid !RebuildGroupId !ProjectionRevisionId !Text
  | CatalogAsyncProjectionUnknown !ProjectionId
  deriving stock (Generic, Eq, Show)

-- | Run a command and apply every supplied 'InlineProjection' to the events
-- it emits, all inside the command's append transaction. A projection failure
-- aborts the whole transaction, so the events and the read-model update commit
-- together or not at all.
--
-- This compatibility runner does not consult catalog rebuild-group fences. New
-- managed callers should use 'runCommandWithCatalogProjections'.
runCommandWithProjections ::
  forall phi rs s ci co es.
  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>
  RunCommandOptions ->
  ValidatedEventStream phi rs s ci co ->
  Stream (EventStream phi rs s ci co) ->
  ci ->
  [InlineProjection co] ->
  Eff es (Either CommandError (CommandResult (EventStream phi rs s ci co)))
runCommandWithProjections options eventStream targetStream command projections = do
  result <-
    runCommandWithSqlEvents
      options
      eventStream
      targetStream
      command
      ( \pairs _appendResult ->
          traverse_
            ( \projection ->
                traverse_
                  (\(event, recorded) -> (projection ^. #apply) event recorded)
                  pairs
            )
            projections
      )
  pure (fmap Prelude.fst result)

-- | Run a domain command and apply every supplied inline projection to an
-- accepted event batch in the append transaction. Typed rejection and no-op
-- decisions return directly without invoking any projection.
runDomainCommandWithProjections ::
  forall phi rs s ci co rejection noOp es.
  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>
  RunCommandOptions ->
  DomainCommandHandler phi rs s ci co rejection noOp ->
  Stream (EventStream phi rs s ci co) ->
  ci ->
  [InlineProjection co] ->
  Eff es (Either CommandError (DomainCommandOutcome (EventStream phi rs s ci co) co rejection noOp))
runDomainCommandWithProjections options handler targetStream command projections = do
  outcome <-
    runDomainCommandWithSqlEvents
      options
      handler
      targetStream
      command
      ( \pairs _appendResult ->
          traverse_
            ( \projection ->
                traverse_
                  (\(event, recorded) -> (projection ^. #apply) event recorded)
                  pairs
            )
            projections
      )
  pure (fmap Prelude.fst outcome)

-- | Run a command through the typed source view derived from one validated
-- catalog. Every distinct rebuild group is locked in stable ID order inside the
-- append transaction before any projection handler runs. A rebuilding or failed
-- group condemns that transaction and returns a typed fence outcome.
runCommandWithCatalogProjections ::
  forall phi rs s ci co es.
  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>
  RunCommandOptions ->
  ValidatedEventStream phi rs s ci co ->
  Stream (EventStream phi rs s ci co) ->
  ci ->
  ValidatedProjectionCatalog ->
  ProjectionSet co ->
  Eff es (Either CommandError (ProjectionCommandOutcome (EventStream phi rs s ci co)))
runCommandWithCatalogProjections options eventStream targetStream command catalog projectionSet = do
  if Prelude.null groups
    then pure (Right (ProjectionCommandCatalogMismatch (projectionSet ^. #projectionSource)))
    else do
      outcome <-
        runCommandWithSqlEventsControlled
          options
          eventStream
          targetStream
          command
          (\pairs _appendResult -> applyCatalogProjectionsTx catalog projectionSet groups pairs)
      pure (fmap toProjectionOutcome outcome)
  where
    groups = typedProjectionRebuildGroups catalog projectionSet

    toProjectionOutcome = \case
      SqlCommandNoOp result -> ProjectionCommandApplied result
      SqlCommandCommitted result _ -> ProjectionCommandApplied result
      SqlCommandRolledBack fence -> fenceOutcome fence

    fenceOutcome = \case
      ProjectionWritesAllowed _ ->
        error "runCommandWithCatalogProjections: rolled back with writes allowed"
      ProjectionWriteFenced groupId runId -> ProjectionCommandFenced groupId runId
      ProjectionWriteGroupUnregistered groupId -> ProjectionCommandGroupUnregistered groupId
      ProjectionServingRevisionUnavailable groupId revisionId ->
        ProjectionCommandServingRevisionUnavailable groupId revisionId
      ProjectionServingBindingInvalid groupId revisionId detail ->
        ProjectionCommandServingBindingInvalid groupId revisionId detail

-- | Domain-aware catalog projection runner. Accepted commands retain existing
-- catalog fence semantics. Typed rejection/no-op decisions invoke neither the
-- fence transaction nor projection handlers and carry no fabricated projection
-- result.
runDomainCommandWithCatalogProjections ::
  forall phi rs s ci co rejection noOp es.
  (HasCallStack, IOE :> es, Store :> es, Error StoreError :> es, KirokuStoreResource :> es, BoolAlg phi (RegFile rs, ci), Eq co) =>
  RunCommandOptions ->
  DomainCommandHandler phi rs s ci co rejection noOp ->
  Stream (EventStream phi rs s ci co) ->
  ci ->
  ValidatedProjectionCatalog ->
  ProjectionSet co ->
  Eff es (Either CommandError (DomainProjectionCommandOutcome (EventStream phi rs s ci co) co rejection noOp))
runDomainCommandWithCatalogProjections options handler targetStream command catalog projectionSet = do
  if Prelude.null groups
    then pure (Right (DomainProjectionCommandCatalogMismatch (projectionSet ^. #projectionSource)))
    else do
      outcome <-
        runDomainCommandWithSqlEventsControlled
          options
          handler
          targetStream
          command
          (\pairs _appendResult -> applyCatalogProjectionsTx catalog projectionSet groups pairs)
      pure (fmap toProjectionOutcome outcome)
  where
    groups = typedProjectionRebuildGroups catalog projectionSet

    toProjectionOutcome = \case
      DomainSqlCommandSilent outcome -> DomainProjectionCommandApplied outcome
      DomainSqlCommandCommitted outcome _ -> DomainProjectionCommandApplied outcome
      DomainSqlCommandRolledBack fence -> fenceOutcome fence

    fenceOutcome = \case
      ProjectionWritesAllowed _ ->
        error "runDomainCommandWithCatalogProjections: rolled back with writes allowed"
      ProjectionWriteFenced groupId runId -> DomainProjectionCommandFenced groupId runId
      ProjectionWriteGroupUnregistered groupId -> DomainProjectionCommandGroupUnregistered groupId
      ProjectionServingRevisionUnavailable groupId revisionId ->
        DomainProjectionCommandServingRevisionUnavailable groupId revisionId
      ProjectionServingBindingInvalid groupId revisionId detail ->
        DomainProjectionCommandServingBindingInvalid groupId revisionId detail

applyCatalogProjectionsTx ::
  ValidatedProjectionCatalog ->
  ProjectionSet co ->
  [RebuildGroupId] ->
  [(co, RecordedEvent)] ->
  Tx.Transaction (SqlTransactionDecision ProjectionWriteFence)
applyCatalogProjectionsTx catalog projectionSet groups pairs = do
  fence <- lockProjectionGroupsTx catalog groups
  case fence of
    ProjectionWritesAllowed bindings -> do
      traverse_ applyBinding bindings
      pure (CommitSqlTransaction fence)
    _ -> pure (RollbackSqlTransaction fence)
  where
    applyBinding binding =
      case binding ^. #writeRevisionId of
        Nothing ->
          traverse_
            ( \projection ->
                traverse_
                  (\(event, recorded) -> (projection ^. #apply) event recorded)
                  pairs
            )
            (typedInlineProjectionsForGroup catalog projectionSet (binding ^. #writeGroupId))
        Just revisionId ->
          traverse_
            ( \wantedDelivery ->
                case catalogRevisionLiveHandler catalog revisionId wantedDelivery of
                  Nothing -> error "applyCatalogProjectionsTx: validated revision delivery disappeared"
                  Just handler ->
                    traverse_
                      (\(_, recorded) -> (handler ^. #runRevisionLive) (binding ^. #writePhysicalTargets) recorded)
                      pairs
            )
            [ RevisionInlineDelivery (definition ^. #projectionId) (projection ^. #name)
            | definition <- NonEmpty.toList (projectionSet ^. #projectionDefinitions),
              definition ^. #rebuildGroup == binding ^. #writeGroupId,
              handler <- NonEmpty.toList (definition ^. #handlers),
              InlineHandler projection _ <- [handler]
            ]

-- | Apply one event to a live 'AsyncProjection', returning a distinct outcome
-- for a successful application, a retained dedup key, or a rebuild fence.
--
-- The registry row is read with @FOR SHARE@ inside the same transaction as the
-- dedup insert and application. A missing row or any status other than @live@
-- returns 'AsyncFenced' without touching either table. A worker that receives
-- 'AsyncFenced' must not checkpoint past the event: fail or park the delivery and
-- retry after promotion. Ack-coupled Kiroku delivery preserves the checkpoint
-- when its handler does not acknowledge success.
--
-- This compatibility path consults only the legacy single-read-model registry.
-- Catalog-managed workers should use 'applyAsyncProjectionFromCatalog'.
--
-- The projection's 'idempotencyKey' is inserted into @keiro_projection_dedup@
-- inside the same transaction as 'applyRecorded'. When that insert conflicts,
-- the event was already applied within the retained dedup window and the update
-- is skipped. Use 'pruneAsyncProjectionDedupBefore' only for events older than
-- the subscription system can redeliver; pruning intentionally re-opens those
-- events for application if they are replayed later.
applyAsyncProjection :: AsyncProjection -> RecordedEvent -> Tx.Transaction AsyncApplyOutcome
applyAsyncProjection projection recorded = do
  status <-
    Tx.statement
      (projection ^. #readModelName)
      lockReadModelStatusStmt
  case status of
    Just "live" -> applyAsyncProjectionUnfenced projection recorded
    _ -> pure AsyncFenced

-- | Apply one async handler through its validated catalog identity and the same
-- rebuild-group row lock used by inline commands and rebuild preparation.
-- Fenced outcomes perform no dedup insert or target write; an ack-coupled worker
-- must therefore leave its subscription checkpoint unchanged.
applyAsyncProjectionFromCatalog ::
  ValidatedProjectionCatalog ->
  ProjectionId ->
  AsyncProjection ->
  RecordedEvent ->
  Tx.Transaction CatalogAsyncApplyOutcome
applyAsyncProjectionFromCatalog catalog projectionId projection recorded =
  case List.find matchesRegistration (asyncProjectionRegistrations catalog) of
    Nothing -> pure (CatalogAsyncProjectionUnknown projectionId)
    Just registration -> do
      let groupId =
            fromMaybe
              (error "applyAsyncProjectionFromCatalog: validated async registration has no projection group")
              (asyncProjectionRebuildGroup catalog projectionId (projection ^. #name))
      fence <- lockProjectionGroupsTx catalog [groupId]
      case fence of
        ProjectionWritesAllowed [binding] -> do
          outcome <-
            case binding ^. #writeRevisionId of
              Nothing -> applyAsyncProjectionUnfenced projection recorded
              Just revisionId ->
                case catalogRevisionLiveHandler
                  catalog
                  revisionId
                  ( RevisionSubscriptionDelivery
                      projectionId
                      (registration ^. #subscriptionId)
                      (registration ^. #dedupKeyId)
                  ) of
                  Nothing ->
                    error "applyAsyncProjectionFromCatalog: validated revision delivery disappeared"
                  Just handler ->
                    applyRevisionAsyncProjectionUnfenced
                      projection
                      (binding ^. #writePhysicalTargets)
                      handler
                      recorded
          pure $ case outcome of
            AsyncApplied -> CatalogAsyncApplied
            AsyncDuplicate -> CatalogAsyncDuplicate
            AsyncFenced ->
              error "applyAsyncProjectionUnfenced returned a fenced outcome"
        ProjectionWritesAllowed _ ->
          error "applyAsyncProjectionFromCatalog: one group lock returned an unexpected binding set"
        ProjectionWriteFenced fencedGroup runId ->
          pure (CatalogAsyncFenced fencedGroup runId)
        ProjectionWriteGroupUnregistered missingGroup ->
          pure (CatalogAsyncGroupUnregistered missingGroup)
        ProjectionServingRevisionUnavailable missingGroup revisionId ->
          pure (CatalogAsyncServingRevisionUnavailable missingGroup revisionId)
        ProjectionServingBindingInvalid invalidGroup revisionId detail ->
          pure (CatalogAsyncServingBindingInvalid invalidGroup revisionId detail)
  where
    matchesRegistration registration =
      registration ^. #projectionId == projectionId
        Prelude.&& registration ^. #projectionName == projection ^. #name

applyRevisionAsyncProjectionUnfenced ::
  AsyncProjection ->
  PhysicalTargets ->
  RevisionLiveHandler ->
  RecordedEvent ->
  Tx.Transaction AsyncApplyOutcome
applyRevisionAsyncProjectionUnfenced projection physicalTargets handler recorded = do
  inserted <-
    Tx.statement
      (projection ^. #name, eventIdToUuid ((projection ^. #idempotencyKey) recorded))
      insertProjectionDedupStmt
  if inserted
    then do
      (handler ^. #runRevisionLive) physicalTargets recorded
      pure AsyncApplied
    else pure AsyncDuplicate

-- | Apply one event without consulting the read-model registry fence.
--
-- This is exclusively the rebuild replay entry point: it retains normal dedup
-- semantics while permitting the designated rebuilder to write while the model is
-- @rebuilding@. Live workers must use 'applyAsyncProjection'.
applyAsyncProjectionUnfenced :: AsyncProjection -> RecordedEvent -> Tx.Transaction AsyncApplyOutcome
applyAsyncProjectionUnfenced projection recorded = do
  inserted <-
    Tx.statement
      (projection ^. #name, eventIdToUuid ((projection ^. #idempotencyKey) recorded))
      insertProjectionDedupStmt
  if inserted
    then do
      (projection ^. #applyRecorded) recorded
      pure AsyncApplied
    else pure AsyncDuplicate

-- | Age out async-projection dedup rows older than the supplied timestamp.
--
-- Use this only beyond the subscription system's redelivery window; pruning
-- re-opens those events for application. It is not a rebuild reset. Supported
-- rebuilds use 'Keiro.ReadModel.Rebuild.startRebuild', which atomically deletes
-- only the named projections' keys while fencing writers and resetting the model.
-- Returns the number of rows pruned.
pruneAsyncProjectionDedupBefore :: (Store :> es) => UTCTime -> Eff es Int64
pruneAsyncProjectionDedupBefore cutoff =
  runTransaction
    $ Tx.statement cutoff pruneProjectionDedupBeforeStmt

-- | Count one projection's dedup rows older than a timestamp. This is the
-- read-only operator preview for 'pruneAsyncProjectionDedupForBefore'.
countAsyncProjectionDedupForBefore ::
  (Store :> es) =>
  Text ->
  UTCTime ->
  Eff es Int64
countAsyncProjectionDedupForBefore projectionName cutoff =
  runTransaction
    $ Tx.statement (projectionName, cutoff) countProjectionDedupForBeforeStmt

-- | Age out one named projection's dedup rows older than the supplied
-- timestamp. Scoping the mutation keeps unrelated projection redelivery
-- windows independent.
pruneAsyncProjectionDedupForBefore ::
  (Store :> es) =>
  Text ->
  UTCTime ->
  Eff es Int64
pruneAsyncProjectionDedupForBefore projectionName cutoff =
  runTransaction
    $ Tx.statement (projectionName, cutoff) pruneProjectionDedupForBeforeStmt

-- | Record the non-negative global position distance between the newest visible
-- event and the slowest durable member checkpoint for one async projection. A
-- global position is an opaque cursor, so this is not an exact count of relevant
-- events for filtered, category, or sharded consumers. Tail hard-deletion leaves
-- a caught-up projection at distance zero because an authoritative append
-- counter that includes deleted events is not actionable backlog.
--
-- There is no in-library polling drain loop today (the application drives
-- 'applyAsyncProjection' per event), so this is the entry point an application
-- calls once per drain pass after applying a batch. The preferred and legacy
-- gauges record the same value during the 0.11 compatibility interval.
recordProjectionGlobalPositionDistance ::
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  AsyncProjection ->
  Eff es ()
recordProjectionGlobalPositionDistance metrics projection = do
  inventory <- subscriptionCheckpointInventory
  visibleHead <- storeHeadPosition
  let checkpoint =
        fromMaybe (GlobalPosition 0)
          $ subscriptionPositionFromInventory
            (SubscriptionName (projection ^. #subscriptionName))
            inventory
      distance = globalPositionDistance visibleHead checkpoint
  Telemetry.recordProjectionGlobalPositionDistance metrics distance
  Telemetry.recordProjectionLag metrics distance

-- | Deprecated compatibility name for 'recordProjectionGlobalPositionDistance'.
{-# DEPRECATED recordProjectionLag "Use recordProjectionGlobalPositionDistance; the value is a global position distance, not an event count." #-}
recordProjectionLag ::
  (IOE :> es, Store :> es) =>
  Maybe KeiroMetrics ->
  AsyncProjection ->
  Eff es ()
recordProjectionLag = recordProjectionGlobalPositionDistance

-- | The non-negative distance between two opaque global positions.
globalPositionDistance :: GlobalPosition -> GlobalPosition -> Int64
globalPositionDistance (GlobalPosition headP) (GlobalPosition checkP) = max 0 (headP Prelude.- checkP)

insertProjectionDedupStmt :: Statement (Text, UUID) Bool
insertProjectionDedupStmt =
  preparable
    """
    INSERT INTO keiro.keiro_projection_dedup (projection_name, event_id)
    VALUES ($1, $2)
    ON CONFLICT (projection_name, event_id) DO NOTHING
    """
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.uuid))
    )
    ((> 0) <$> D.rowsAffected)

lockReadModelStatusStmt :: Statement Text (Maybe Text)
lockReadModelStatusStmt =
  preparable
    """
    SELECT status
    FROM keiro.keiro_read_models
    WHERE name = $1
    FOR SHARE
    """
    (E.param (E.nonNullable E.text))
    (D.rowMaybe (D.column (D.nonNullable D.text)))

pruneProjectionDedupBeforeStmt :: Statement UTCTime Int64
pruneProjectionDedupBeforeStmt =
  preparable
    """
    DELETE FROM keiro.keiro_projection_dedup
    WHERE applied_at < $1
    """
    (E.param (E.nonNullable E.timestamptz))
    D.rowsAffected

countProjectionDedupForBeforeStmt :: Statement (Text, UTCTime) Int64
countProjectionDedupForBeforeStmt =
  preparable
    """
    SELECT count(*)::bigint
    FROM keiro.keiro_projection_dedup
    WHERE projection_name = $1
      AND applied_at < $2
    """
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.timestamptz))
    )
    (D.singleRow (D.column (D.nonNullable D.int8)))

pruneProjectionDedupForBeforeStmt :: Statement (Text, UTCTime) Int64
pruneProjectionDedupForBeforeStmt =
  preparable
    """
    DELETE FROM keiro.keiro_projection_dedup
    WHERE projection_name = $1
      AND applied_at < $2
    """
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.timestamptz))
    )
    D.rowsAffected

eventIdToUuid :: EventId -> UUID
eventIdToUuid (EventId value) = value