packages feed

keiro-0.12.0.0: src/Keiro/ReadModel/Rebuild/Group.hs

{-# OPTIONS_HADDOCK hide #-}

-- | Internal implementation shared by the public rebuild lifecycle and the
-- catalog-derived live-writer paths. The public facade is
-- "Keiro.ReadModel.Rebuild".
module Keiro.ReadModel.Rebuild.Group
  ( preCanonicalRunSliceSentinel,
    canonicalSlicePrefix,
    RebuildRunId,
    mkRebuildRunId,
    rebuildRunIdText,
    RebuildRequest (..),
    RebuildFailure (..),
    GroupLifecycleStatus (..),
    GroupRebuildMetadata (..),
    CatalogRegistrationError (..),
    GroupAdoptionClass (..),
    RegistrationAdoptionAction (..),
    RegistrationAdoption (..),
    OrphanedRegistration (..),
    CatalogAdoptionPlan (..),
    CatalogAdoptionResult (..),
    CatalogAdoptionError (..),
    RebuildStartError (..),
    GroupTransitionError (..),
    ProjectionWriteBinding (..),
    ProjectionWriteFence (..),
    ProjectionRepairFence (..),
    GroupPreparation (..),
    groupPreparationFor,
    GroupRebuildHandle,
    groupRebuildHandleGroup,
    groupRebuildHandleRun,
    groupRebuildHandleSliceFingerprint,
    groupRebuildHandlePreparation,
    groupRebuildHandleResetCheckpointKeys,
    GroupCompletionToken,
    completionTokenForHandle,
    groupRebuildHandleFor,
    registerProjectionCatalog,
    previewCatalogAdoption,
    adoptCatalogGroups,
    lookupProjectionRebuildGroup,
    beginGroupRebuild,
    resetDeclaredSubscriptions,
    insertProjectionDedupBatchStmt,
    finishGroupRebuild,
    finishGroupRebuildTx,
    abandonGroupRebuild,
    abandonPreCanonicalGroupRebuild,
    lockProjectionGroupsTx,
    lockProjectionGroupForRepairTx,
  )
where

import Contravariant.Extras (contrazip2, contrazip3, contrazip4, contrazip5)
import Data.Functor (($>))
import Data.List qualified as List
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map.Strict qualified as Map
import Data.Maybe (mapMaybe, maybeToList)
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TE
import Data.UUID (UUID)
import Data.Vector qualified as Vector
import Effectful (Eff, (:>))
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Statement (Statement, preparable)
import Keiro.Connection (qualifyTable)
import Keiro.Prelude
import Keiro.Projection.Catalog
  ( CatalogRegistration (..),
    GroupSliceFingerprint,
    PhysicalTargets,
    ProjectionRevisionId,
    QualifiedTable (..),
    RebuildGroupId,
    TargetResetPolicy (..),
    ValidatedProjectionCatalog,
    asyncProjectionRegistrations,
    catalogInventory,
    catalogRegistrations,
    groupSliceFingerprint,
    groupSliceFingerprintText,
    mkPhysicalTargets,
    mkRebuildGroupId,
    mkTargetId,
    projectionRevisionIdText,
    rebuildGroupIdText,
    replayAdapterMetadata,
  )
import Keiro.Projection.Catalog qualified as Catalog
import Keiro.ReadModel.External qualified as External
import Kiroku.Store.Effect (Store)
import Kiroku.Store.Subscription.Checkpoint
  ( SubscriptionCheckpointResetReport (..),
    resetSubscriptionCheckpointsTx,
  )
import Kiroku.Store.Subscription.Types
  ( SubscriptionCheckpointKey,
    SubscriptionName (..),
  )
import Kiroku.Store.Transaction (runTransaction)
import Kiroku.Store.Types (GlobalPosition (..))
import "hasql-transaction" Hasql.Transaction qualified as Tx
import Prelude (not, null, (&&), (||))
import Prelude qualified

-- | Sentinel that migration 0024 stamps into
-- @keiro_projection_rebuild_runs.group_slice_fingerprint@ for runs begun
-- before canonical slice identity existed. Handled only by the recovery
-- paths: always abandonable, never resumable, never a valid identity.
preCanonicalRunSliceSentinel :: Text
preCanonicalRunSliceSentinel = "$pre-canonical"

-- | Prefix of the current canonical group-slice format (ADR-32).
canonicalSlicePrefix :: Text
canonicalSlicePrefix = "slice-v6:"

-- | Stable operator-supplied identity for one rebuild attempt.
newtype RebuildRunId = RebuildRunId Text
  deriving stock (Eq, Ord, Show, Generic)

mkRebuildRunId :: Text -> Either Text RebuildRunId
mkRebuildRunId value
  | Text.null value = Left "rebuild run id must not be empty"
  | Text.strip value /= value = Left "rebuild run id must not have surrounding whitespace"
  | otherwise = Right (RebuildRunId value)

rebuildRunIdText :: RebuildRunId -> Text
rebuildRunIdText (RebuildRunId value) = value

data RebuildRequest = RebuildRequest
  { rebuildRunId :: !RebuildRunId,
    requestedBy :: !Text,
    requestReason :: !Text,
    replayFrom :: !GlobalPosition
  }
  deriving stock (Eq, Show, Generic)

data RebuildFailure = RebuildFailure
  { failureCode :: !Text,
    failureDetail :: !Text
  }
  deriving stock (Eq, Show, Generic)

data GroupLifecycleStatus
  = GroupLive
  | GroupRebuilding
  | GroupFailed
  | UnknownGroupStatus !Text
  deriving stock (Eq, Ord, Show, Generic)

data GroupRebuildMetadata = GroupRebuildMetadata
  { rebuildGroupId :: !RebuildGroupId,
    sliceFingerprint :: !Text,
    status :: !GroupLifecycleStatus,
    activeRunId :: !(Maybe RebuildRunId),
    requestedBy :: !(Maybe Text),
    requestReason :: !(Maybe Text),
    startedAt :: !(Maybe UTCTime),
    completedAt :: !(Maybe UTCTime),
    failedAt :: !(Maybe UTCTime),
    failureCode :: !(Maybe Text),
    failureDetail :: !(Maybe Text)
  }
  deriving stock (Eq, Show, Generic)

data CatalogRegistrationError
  = RegisteredGroupSliceDrift !RebuildGroupId !Text !Text
  | RegisteredGroupStaleFingerprint !RebuildGroupId !Text
  | RegisteredQueryModelDrift !Text !Text
  | RegisteredQueryModelNotLive !Text
  | RegisteredExternalReadContract !External.ExternalReadReconciliationError
  deriving stock (Eq, Show, Generic)

data GroupAdoptionClass
  = AdoptionNew
  | AdoptionUnchanged
  | AdoptionSliceChanged !Text !Text
  | AdoptionStaleFormat !Text
  deriving stock (Eq, Show, Generic)

-- | What adoption did, or in a preview will do, for one catalog registration.
data RegistrationAdoptionAction
  = RegistrationUpdate
  | RegistrationInsert
  deriving stock (Eq, Show, Generic)

data RegistrationAdoption = RegistrationAdoption
  { registryName :: !Text,
    rebuildGroupId :: !RebuildGroupId,
    action :: !RegistrationAdoptionAction
  }
  deriving stock (Eq, Show, Generic)

-- | A registry row bound to a catalog group whose name no registration in the
-- complete validated catalog claims.
data OrphanedRegistration = OrphanedRegistration
  { registryName :: !Text,
    boundGroupId :: !RebuildGroupId
  }
  deriving stock (Eq, Show, Generic)

data CatalogAdoptionPlan = CatalogAdoptionPlan
  { groupStates :: ![(RebuildGroupId, GroupAdoptionClass)],
    removedGroups :: ![RebuildGroupId],
    registrations :: ![RegistrationAdoption],
    orphanedRegistrations :: ![OrphanedRegistration]
  }
  deriving stock (Eq, Show, Generic)

data CatalogAdoptionResult = CatalogAdoptionResult
  { adoptedGroups :: ![GroupRebuildMetadata],
    registrationOutcomes :: ![RegistrationAdoption],
    removedOrphans :: ![OrphanedRegistration]
  }
  deriving stock (Eq, Show, Generic)

data CatalogAdoptionError
  = AdoptGroupNotInCatalog !RebuildGroupId
  | AdoptGroupUnregistered !RebuildGroupId
  | AdoptGroupNotLive !RebuildGroupId !GroupLifecycleStatus !(Maybe RebuildRunId)
  | AdoptExternalReadContract !External.ExternalReadReconciliationError
  deriving stock (Eq, Show, Generic)

data RebuildStartError
  = RebuildGroupNotInCatalog !RebuildGroupId
  | RebuildGroupUnregistered !RebuildGroupId
  | RebuildGroupSliceDrift !RebuildGroupId !Text !Text
  | RebuildGroupNotLive !RebuildGroupId !GroupLifecycleStatus !(Maybe RebuildRunId)
  | -- | At least one catalog-declared subscription had no persisted member to reset.
    RebuildSubscriptionCheckpointsMissing !RebuildGroupId ![SubscriptionName]
  deriving stock (Eq, Show, Generic)

data GroupTransitionError
  = RebuildHandleNoLongerActive !RebuildGroupId !RebuildRunId
  | RebuildCompletionTokenMismatch !RebuildGroupId !RebuildRunId
  deriving stock (Eq, Show, Generic)

-- | Revision and closed-world physical target binding selected while the
-- corresponding group row remains locked. Legacy groups have no revision but
-- still carry their catalog-declared physical targets.
data ProjectionWriteBinding = ProjectionWriteBinding
  { writeGroupId :: !RebuildGroupId,
    writeRevisionId :: !(Maybe ProjectionRevisionId),
    writePhysicalTargets :: !PhysicalTargets
  }
  deriving stock (Eq, Show, Generic)

data ProjectionWriteFence
  = ProjectionWritesAllowed ![ProjectionWriteBinding]
  | ProjectionWriteFenced !RebuildGroupId !RebuildRunId
  | ProjectionWriteGroupUnregistered !RebuildGroupId
  | ProjectionServingRevisionUnavailable !RebuildGroupId !ProjectionRevisionId
  | ProjectionServingBindingInvalid !RebuildGroupId !ProjectionRevisionId !Text
  deriving stock (Eq, Show, Generic)

-- | Exclusive group lock and exact serving binding used by one targeted
-- stream repair. Unlike the ordinary writer fence this requires both reads
-- and writes to be available and refuses every active rebuild.
data ProjectionRepairFence
  = ProjectionRepairAllowed !ProjectionWriteBinding
  | ProjectionRepairGroupUnregistered !RebuildGroupId
  | ProjectionRepairActiveRebuild !RebuildGroupId !RebuildRunId
  | ProjectionRepairGroupUnavailable !RebuildGroupId !Text !Bool !Bool
  | ProjectionRepairSliceDrift !RebuildGroupId !Text !Text
  | ProjectionRepairServingRevisionUnavailable !RebuildGroupId !ProjectionRevisionId
  | ProjectionRepairServingBindingInvalid !RebuildGroupId !ProjectionRevisionId !Text
  deriving stock (Eq, Show, Generic)

data GroupPreparation = GroupPreparation
  { clearTargets :: ![QualifiedTable],
    preservedTargets :: ![QualifiedTable],
    resetDedupNames :: ![Text],
    resetSubscriptionNames :: ![Text]
  }
  deriving stock (Eq, Show, Generic)

-- | Resolve the closed-world target, deduplication, and subscription preparation
-- declared for one group. Versioned promotion reuses this catalog authority
-- without constructing an offline rebuild handle.
groupPreparationFor :: ValidatedProjectionCatalog -> RebuildGroupId -> Maybe GroupPreparation
groupPreparationFor = preparationFor

data GroupRebuildHandle = GroupRebuildHandle
  { handleGroup :: !RebuildGroupId,
    handleRun :: !RebuildRunId,
    handleSliceFingerprint :: !GroupSliceFingerprint,
    handlePreparation :: !GroupPreparation,
    handleResetCheckpointKeys :: ![SubscriptionCheckpointKey]
  }
  deriving stock (Eq, Show, Generic)

groupRebuildHandleGroup :: GroupRebuildHandle -> RebuildGroupId
groupRebuildHandleGroup = handleGroup

groupRebuildHandleRun :: GroupRebuildHandle -> RebuildRunId
groupRebuildHandleRun = handleRun

groupRebuildHandleSliceFingerprint :: GroupRebuildHandle -> GroupSliceFingerprint
groupRebuildHandleSliceFingerprint = handleSliceFingerprint

groupRebuildHandlePreparation :: GroupRebuildHandle -> GroupPreparation
groupRebuildHandlePreparation = handlePreparation

-- | Exact persisted subscription-member rows reset during preparation.
groupRebuildHandleResetCheckpointKeys :: GroupRebuildHandle -> [SubscriptionCheckpointKey]
groupRebuildHandleResetCheckpointKeys = handleResetCheckpointKeys

-- | Opaque proof that completion verification was recorded for this exact
-- group, run, and catalog. Plan 211 constructs it only after durable completion
-- accounting; ordinary callers cannot fabricate one through the public facade.
data GroupCompletionToken = GroupCompletionToken
  { completionGroup :: !RebuildGroupId,
    completionRun :: !RebuildRunId,
    completionSliceFingerprint :: !GroupSliceFingerprint
  }

completionTokenForHandle :: GroupRebuildHandle -> GroupCompletionToken
completionTokenForHandle handle =
  GroupCompletionToken
    { completionGroup = handleGroup handle,
      completionRun = handleRun handle,
      completionSliceFingerprint = handleSliceFingerprint handle
    }

-- | Reconstruct the opaque authorization for a persisted run. The replay
-- runner must still prove the active group/run/fingerprint in the same
-- transaction as every use of this value.
groupRebuildHandleFor ::
  ValidatedProjectionCatalog ->
  RebuildGroupId ->
  RebuildRunId ->
  Maybe GroupRebuildHandle
groupRebuildHandleFor catalog groupId runId = do
  preparation <- preparationFor catalog groupId
  slice <- groupSliceFingerprint catalog groupId
  pure
    GroupRebuildHandle
      { handleGroup = groupId,
        handleRun = runId,
        handleSliceFingerprint = slice,
        handlePreparation = preparation,
        handleResetCheckpointKeys = []
      }

registerProjectionCatalog ::
  (Store :> es) =>
  ValidatedProjectionCatalog ->
  Eff es (Either CatalogRegistrationError [GroupRebuildMetadata])
registerProjectionCatalog catalog =
  runTransaction $ registerProjectionCatalogTx catalog

registerProjectionCatalogTx ::
  ValidatedProjectionCatalog ->
  Tx.Transaction (Either CatalogRegistrationError [GroupRebuildMetadata])
registerProjectionCatalogTx catalog = do
  groupsResult <- registerGroups [] groupIds
  case groupsResult of
    Left err -> Tx.condemn $> Left err
    Right groups -> do
      revisionsResult <- registerRevisions revisionRegistrations
      case revisionsResult of
        Left err -> Tx.condemn $> Left err
        Right () -> do
          reconcileCursorAuthorities groupIds
          queriesResult <- registerQueries queryRegistrations
          case queriesResult of
            Left err -> Tx.condemn $> Left err
            Right () -> do
              externalReads <- External.reconcileExternalReadContractsTx catalog
              case externalReads of
                Left err -> Tx.condemn $> Left (RegisteredExternalReadContract err)
                Right () -> do
                  Tx.statement () deleteOrphanLegacyGroupsStmt
                  pure (Right (List.sortOn (rebuildGroupIdText . (^. #rebuildGroupId)) groups))
  where
    groupIds = (^. #rebuildGroupId) <$> (catalogInventory catalog ^. #inventoryGroups)
    revisionRegistrations =
      [ ( revision ^. #rebuildGroupId,
          revision ^. #revisionId,
          sliceTextFor catalog (revision ^. #rebuildGroupId)
        )
      | revision <- catalogInventory catalog ^. #inventoryProjectionRevisions
      ]
    queryRegistrations = catalogRegistrations catalog

    reconcileCursorAuthorities =
      traverse_ $ \groupId ->
        Tx.statement
          (cursorAuthorityParams catalog groupId)
          upsertGroupCursorAuthorityStmt

    registerGroups accumulated = \case
      [] -> pure (Right (Prelude.reverse accumulated))
      groupId : rest -> do
        let currentSlice = sliceFor groupId
            currentText = groupSliceFingerprintText currentSlice
        metadata <-
          Tx.statement
            (rebuildGroupIdText groupId, currentText)
            registerGroupStmt
        let stored = metadata ^. #sliceFingerprint
        if stored == currentText
          then registerGroups (metadata : accumulated) rest
          else
            if canonicalSlicePrefix `Text.isPrefixOf` stored
              then pure (Left (RegisteredGroupSliceDrift groupId stored currentText))
              else pure (Left (RegisteredGroupStaleFingerprint groupId stored))

    sliceFor groupId =
      fromMaybe
        (error "registerProjectionCatalogTx: inventory group has no slice")
        (groupSliceFingerprint catalog groupId)

    registerRevisions = \case
      [] -> pure (Right ())
      (groupId, revisionId, slice) : rest -> do
        stored <-
          Tx.statement
            (rebuildGroupIdText groupId, projectionRevisionIdText revisionId, slice)
            registerProjectionRevisionStmt
        if stored == slice
          then registerRevisions rest
          else pure (Left (RegisteredGroupSliceDrift groupId stored slice))

    registerQueries = \case
      [] -> pure (Right ())
      registration : rest -> do
        existing <- Tx.statement (registration ^. #registryName) lookupQueryRegistrationStmt
        result <-
          case existing of
            Nothing -> do
              Tx.statement (queryRegistrationParams registration) insertQueryRegistrationStmt
              pure (Right ())
            Just row -> reconcileQueryRegistration registration row
        case result of
          Left err -> pure (Left err)
          Right () -> registerQueries rest

    reconcileQueryRegistration registration row
      | rowVersion row /= registration ^. #version =
          pure (Left (queryDrift registration row "version differs"))
      | rowShapeHash row /= registration ^. #shapeHash =
          pure (Left (queryDrift registration row "shape hash differs"))
      | rowGroupId row == rebuildGroupIdText (registration ^. #rebuildGroupId) =
          pure (Right ())
      | rowStatus row == "live" && "$legacy-read-model:" `Text.isPrefixOf` rowGroupId row = do
          Tx.statement
            (registration ^. #registryName, rebuildGroupIdText (registration ^. #rebuildGroupId))
            adoptLegacyQueryRegistrationStmt
          pure (Right ())
      | rowStatus row /= "live" =
          pure (Left (RegisteredQueryModelNotLive (registration ^. #registryName)))
      | otherwise = pure (Left (queryDrift registration row "rebuild group differs"))

    queryDrift registration row reason =
      RegisteredQueryModelDrift
        (registration ^. #registryName)
        ( reason
            <> "; registered group="
            <> rowGroupId row
            <> ", catalog group="
            <> rebuildGroupIdText (registration ^. #rebuildGroupId)
        )

previewCatalogAdoption ::
  (Store :> es) =>
  ValidatedProjectionCatalog ->
  Eff es CatalogAdoptionPlan
previewCatalogAdoption catalog =
  runTransaction $ do
    registered <- Tx.statement () listGroupsStmt
    registryRows <- Tx.statement () listQueryRegistrationBindingsStmt
    let registeredById = Map.fromList [(metadata ^. #rebuildGroupId, metadata) | metadata <- registered]
        catalogGroups =
          List.sortOn
            rebuildGroupIdText
            ((^. #rebuildGroupId) <$> (catalogInventory catalog ^. #inventoryGroups))
        catalogGroupSet = Set.fromList catalogGroups
        classify groupId =
          ( groupId,
            case Map.lookup groupId registeredById of
              Nothing -> AdoptionNew
              Just metadata -> adoptionClass groupId (metadata ^. #sliceFingerprint)
          )
        removed =
          List.sortOn
            rebuildGroupIdText
            [ metadata ^. #rebuildGroupId
            | metadata <- registered,
              metadata ^. #sliceFingerprint /= "$legacy-unmanaged",
              (metadata ^. #rebuildGroupId) `Set.notMember` catalogGroupSet
            ]
        catalogRegistrationRows = List.sortOn (^. #registryName) (catalogRegistrations catalog)
        catalogRegistrationNames = Set.fromList ((^. #registryName) <$> catalogRegistrationRows)
        registeredNames = Set.fromList (Prelude.fst <$> registryRows)
        registrationPlans =
          [ RegistrationAdoption
              { registryName = registration ^. #registryName,
                rebuildGroupId = registration ^. #rebuildGroupId,
                action =
                  if (registration ^. #registryName) `Set.member` registeredNames
                    then RegistrationUpdate
                    else RegistrationInsert
              }
          | registration <- catalogRegistrationRows
          ]
        orphaned =
          List.sortOn
            (^. #registryName)
            [ OrphanedRegistration name groupId
            | (name, storedGroupId) <- registryRows,
              name `Set.notMember` catalogRegistrationNames,
              groupId <- maybeToList (either (Prelude.const Nothing) Just (mkRebuildGroupId storedGroupId)),
              groupId `Set.member` catalogGroupSet
            ]
    pure
      CatalogAdoptionPlan
        { groupStates = classify <$> catalogGroups,
          removedGroups = removed,
          registrations = registrationPlans,
          orphanedRegistrations = orphaned
        }
  where
    adoptionClass groupId stored
      | stored == current = AdoptionUnchanged
      | canonicalSlicePrefix `Text.isPrefixOf` stored = AdoptionSliceChanged stored current
      | otherwise = AdoptionStaleFormat stored
      where
        current = sliceTextFor catalog groupId

adoptCatalogGroups ::
  (Store :> es) =>
  ValidatedProjectionCatalog ->
  NonEmpty.NonEmpty RebuildGroupId ->
  Eff es (Either CatalogAdoptionError CatalogAdoptionResult)
adoptCatalogGroups catalog requested =
  case List.find (`Set.notMember` catalogGroupSet) groupIds of
    Just groupId -> pure (Left (AdoptGroupNotInCatalog groupId))
    Nothing -> runTransaction adoptTx
  where
    groupIds = List.sort . Set.toList . Set.fromList $ NonEmpty.toList requested
    catalogGroupSet =
      Set.fromList ((^. #rebuildGroupId) <$> (catalogInventory catalog ^. #inventoryGroups))
    namedGroupSet = Set.fromList groupIds
    registrations =
      List.sortOn
        (^. #registryName)
        [ registration
        | registration <- catalogRegistrations catalog,
          (registration ^. #rebuildGroupId) `Set.member` namedGroupSet
        ]
    allRegistrationNames = Set.fromList ((^. #registryName) <$> catalogRegistrations catalog)
    revisionRegistrations =
      [ ( revision ^. #rebuildGroupId,
          revision ^. #revisionId,
          sliceTextFor catalog (revision ^. #rebuildGroupId)
        )
      | revision <- catalogInventory catalog ^. #inventoryProjectionRevisions,
        (revision ^. #rebuildGroupId) `Set.member` namedGroupSet
      ]

    adoptTx = do
      locked <- lockAll [] groupIds
      case locked of
        Left err -> Tx.condemn $> Left err
        Right lockedGroups -> do
          for_ groupIds $ \groupId ->
            Tx.statement
              (rebuildGroupIdText groupId, sliceTextFor catalog groupId)
              adoptGroupSliceStmt
          for_ revisionRegistrations $ \(groupId, revisionId, slice) ->
            void
              ( Tx.statement
                  (rebuildGroupIdText groupId, projectionRevisionIdText revisionId, slice)
                  adoptProjectionRevisionStmt
              )
          for_ groupIds $ \groupId ->
            Tx.statement
              (cursorAuthorityParams catalog groupId)
              upsertGroupCursorAuthorityStmt
          registrationResults <- traverse (reconcileRegistration lockedGroups) registrations
          boundRows <- Tx.statement (rebuildGroupIdText <$> groupIds) lockGroupRegistrationsStmt
          let groupByText = Map.fromList [(rebuildGroupIdText groupId, groupId) | groupId <- groupIds]
              orphaned =
                List.sortOn
                  (^. #registryName)
                  [ OrphanedRegistration name groupId
                  | (name, storedGroupId) <- boundRows,
                    name `Set.notMember` allRegistrationNames,
                    groupId <- maybeToList (Map.lookup storedGroupId groupByText)
                  ]
          unless (null orphaned)
            $ Tx.statement ((^. #registryName) <$> orphaned) deleteQueryRegistrationsStmt
          externalReads <-
            External.reconcileExternalReadContractsForGroupsTx
              catalog
              (Just namedGroupSet)
          case externalReads of
            Left err -> Tx.condemn $> Left (AdoptExternalReadContract err)
            Right () -> do
              updated <- traverse (\groupId -> Tx.statement (rebuildGroupIdText groupId) lookupGroupStmt) groupIds
              pure
                ( Right
                    CatalogAdoptionResult
                      { adoptedGroups = [metadata | Just metadata <- updated],
                        registrationOutcomes = registrationResults,
                        removedOrphans = orphaned
                      }
                )

    reconcileRegistration lockedGroups registration = do
      existing <- Tx.statement (registration ^. #registryName) lookupQueryRegistrationStmt
      action <-
        case existing of
          Just _ -> do
            affected <- Tx.statement (queryRegistrationParams registration) adoptQueryRegistrationStmt
            when (affected /= 1)
              $ error "adoptTx: locked registration row vanished"
            pure RegistrationUpdate
          Nothing -> do
            Tx.statement
              (adoptionQueryRegistrationParams lockedGroups registration)
              insertAdoptedQueryRegistrationStmt
            pure RegistrationInsert
      pure
        RegistrationAdoption
          { registryName = registration ^. #registryName,
            rebuildGroupId = registration ^. #rebuildGroupId,
            action
          }

    adoptionQueryRegistrationParams lockedGroups registration =
      let (name, version, shape, groupId) = queryRegistrationParams registration
          status =
            case List.find ((== registration ^. #rebuildGroupId) . (^. #rebuildGroupId)) lockedGroups of
              Just metadata -> case metadata ^. #status of
                GroupLive -> "live"
                GroupFailed -> "abandoned"
                _ -> error "adoptTx: non-adoptable group reached registration insert"
              Nothing -> error "adoptTx: registration group was not locked"
       in (name, version, shape, groupId, status)

    lockAll accumulated = \case
      [] -> pure (Right (Prelude.reverse accumulated))
      groupId : rest -> do
        row <- Tx.statement (rebuildGroupIdText groupId) lockGroupForUpdateStmt
        case row of
          Nothing -> pure (Left (AdoptGroupUnregistered groupId))
          Just metadata
            | not (adoptable metadata) ->
                pure
                  ( Left
                      ( AdoptGroupNotLive
                          groupId
                          (metadata ^. #status)
                          (metadata ^. #activeRunId)
                      )
                  )
            | otherwise -> lockAll (metadata : accumulated) rest

    adoptable metadata =
      metadata
        ^. #status
        == GroupLive
        || ( metadata ^. #status == GroupFailed
               && not
                 ( canonicalSlicePrefix
                     `Text.isPrefixOf` (metadata ^. #sliceFingerprint)
                 )
           )

sliceTextFor :: ValidatedProjectionCatalog -> RebuildGroupId -> Text
sliceTextFor catalog groupId =
  maybe
    (error "sliceTextFor: catalog inventory group has no slice")
    groupSliceFingerprintText
    (groupSliceFingerprint catalog groupId)

cursorAuthorityParams ::
  ValidatedProjectionCatalog ->
  RebuildGroupId ->
  (Text, Text, [Text])
cursorAuthorityParams catalog groupId =
  ( rebuildGroupIdText groupId,
    if null subscriptionNames then "append" else "checkpoint",
    subscriptionNames
  )
  where
    inventory = catalogInventory catalog
    subscriptionNamesById =
      Map.fromList
        [ (subscription ^. #subscriptionId, subscription ^. #subscriptionName)
        | subscription <- inventory ^. #inventorySubscriptions
        ]
    subscriptionIds =
      Set.fromList
        [ subscriptionId
        | projection <- inventory ^. #inventoryProjections,
          projection ^. #rebuildGroupId == groupId,
          Catalog.InventoryAsyncHandler _ subscriptionId _ <- projection ^. #handlers
        ]
    subscriptionNames =
      List.sort
        [ fromMaybe
            (error "cursorAuthorityParams: validated handler has no subscription")
            (Map.lookup subscriptionId subscriptionNamesById)
        | subscriptionId <- Set.toList subscriptionIds
        ]

lookupProjectionRebuildGroup ::
  (Store :> es) =>
  RebuildGroupId ->
  Eff es (Maybe GroupRebuildMetadata)
lookupProjectionRebuildGroup groupId =
  runTransaction
    $ Tx.statement
      (rebuildGroupIdText groupId)
      lookupGroupStmt

beginGroupRebuild ::
  (Store :> es) =>
  ValidatedProjectionCatalog ->
  RebuildGroupId ->
  RebuildRequest ->
  Eff es (Either RebuildStartError GroupRebuildHandle)
beginGroupRebuild catalog groupId request =
  case preparationFor catalog groupId of
    Nothing -> pure (Left (RebuildGroupNotInCatalog groupId))
    Just preparation ->
      runTransaction $ do
        registered <-
          Tx.statement
            (rebuildGroupIdText groupId)
            lockGroupForUpdateStmt
        case registered of
          Nothing -> Tx.condemn $> Left (RebuildGroupUnregistered groupId)
          Just metadata
            | metadata ^. #sliceFingerprint /= expectedSliceText ->
                Tx.condemn
                  $> Left
                    ( RebuildGroupSliceDrift
                        groupId
                        (metadata ^. #sliceFingerprint)
                        expectedSliceText
                    )
            | metadata ^. #status /= GroupLive
                && metadata ^. #status /= GroupFailed ->
                Tx.condemn
                  $> Left
                    ( RebuildGroupNotLive
                        groupId
                        (metadata ^. #status)
                        (metadata ^. #activeRunId)
                    )
            | otherwise -> do
                Tx.statement
                  ( rebuildGroupIdText groupId,
                    rebuildRunIdText (request ^. #rebuildRunId),
                    request ^. #requestedBy,
                    request ^. #requestReason
                  )
                  beginGroupStmt
                Tx.statement (rebuildGroupIdText groupId) markGroupQueriesRebuildingStmt
                truncateTargets (preparation ^. #clearTargets)
                unless (null (preparation ^. #resetDedupNames))
                  $ Tx.statement (preparation ^. #resetDedupNames) deleteProjectionDedupStmt
                resetReport <- resetDeclaredSubscriptions preparation (request ^. #replayFrom)
                let missingNames = Vector.toList (resetReport ^. #missingSubscriptionNames)
                if null missingNames
                  then
                    pure
                      ( Right
                          GroupRebuildHandle
                            { handleGroup = groupId,
                              handleRun = request ^. #rebuildRunId,
                              handleSliceFingerprint = expectedSlice,
                              handlePreparation = preparation,
                              handleResetCheckpointKeys = Vector.toList (resetReport ^. #resetCheckpointKeys)
                            }
                      )
                  else
                    Tx.condemn
                      $> Left (RebuildSubscriptionCheckpointsMissing groupId missingNames)
  where
    expectedSlice =
      fromMaybe
        (error "beginGroupRebuild: prepared group has no catalog slice")
        (groupSliceFingerprint catalog groupId)
    expectedSliceText = groupSliceFingerprintText expectedSlice

-- | Reset every declared subscription member to one exact position.
-- Preparation uses this to rewind to @replayFrom@; promotion uses it to
-- advance to the captured head. Both transitions remain inside the group
-- lifecycle transaction.
resetDeclaredSubscriptions :: GroupPreparation -> GlobalPosition -> Tx.Transaction SubscriptionCheckpointResetReport
resetDeclaredSubscriptions preparation replayFrom =
  case NonEmpty.nonEmpty (SubscriptionName <$> preparation ^. #resetSubscriptionNames) of
    Nothing ->
      pure
        SubscriptionCheckpointResetReport
          { resetCheckpointKeys = Vector.empty,
            missingSubscriptionNames = Vector.empty
          }
    Just subscriptionNames -> resetSubscriptionCheckpointsTx subscriptionNames replayFrom

finishGroupRebuild ::
  (Store :> es) =>
  GroupRebuildHandle ->
  GroupCompletionToken ->
  Eff es (Either GroupTransitionError GroupRebuildMetadata)
finishGroupRebuild handle token
  | not (tokenMatchesHandle handle token) =
      pure (Left (RebuildCompletionTokenMismatch (handleGroup handle) (handleRun handle)))
  | otherwise = runTransaction (finishGroupRebuildTx handle token)

finishGroupRebuildTx ::
  GroupRebuildHandle ->
  GroupCompletionToken ->
  Tx.Transaction (Either GroupTransitionError GroupRebuildMetadata)
finishGroupRebuildTx handle token
  | not (tokenMatchesHandle handle token) =
      pure (Left (RebuildCompletionTokenMismatch (handleGroup handle) (handleRun handle)))
  | otherwise = do
      promoted <-
        Tx.statement
          ( rebuildGroupIdText (handleGroup handle),
            rebuildRunIdText (handleRun handle),
            groupSliceFingerprintText (handleSliceFingerprint handle)
          )
          finishGroupStmt
      case promoted of
        Nothing ->
          Tx.condemn
            $> Left (RebuildHandleNoLongerActive (handleGroup handle) (handleRun handle))
        Just metadata -> do
          Tx.statement (rebuildGroupIdText (handleGroup handle)) markGroupQueriesLiveStmt
          pure (Right metadata)

abandonGroupRebuild ::
  (Store :> es) =>
  GroupRebuildHandle ->
  RebuildFailure ->
  Eff es (Either GroupTransitionError GroupRebuildMetadata)
abandonGroupRebuild handle failure =
  runTransaction $ do
    abandoned <-
      Tx.statement
        ( rebuildGroupIdText (handleGroup handle),
          rebuildRunIdText (handleRun handle),
          groupSliceFingerprintText (handleSliceFingerprint handle),
          failure ^. #failureCode,
          failure ^. #failureDetail
        )
        abandonGroupStmt
    case abandoned of
      Nothing ->
        Tx.condemn
          $> Left (RebuildHandleNoLongerActive (handleGroup handle) (handleRun handle))
      Just metadata -> do
        Tx.statement (rebuildGroupIdText (handleGroup handle)) markGroupQueriesAbandonedStmt
        pure (Right metadata)

-- | Abandon a run stamped by migration 0024 before canonical slice identity
-- existed. This recovery transition deliberately does not compare a catalog
-- slice: the sentinel is evidence that no meaningful slice was persisted.
-- Failed groups are returned unchanged so retries preserve the first failure
-- evidence.
abandonPreCanonicalGroupRebuild ::
  (Store :> es) =>
  RebuildGroupId ->
  RebuildRunId ->
  RebuildFailure ->
  Eff es (Either GroupTransitionError GroupRebuildMetadata)
abandonPreCanonicalGroupRebuild groupId runId failure =
  runTransaction $ do
    locked <- Tx.statement (rebuildGroupIdText groupId) lockGroupForUpdateStmt
    case locked of
      Just metadata
        | metadata ^. #activeRunId == Just runId ->
            case metadata ^. #status of
              GroupFailed -> pure (Right metadata)
              GroupRebuilding -> do
                abandoned <-
                  Tx.statement
                    ( rebuildGroupIdText groupId,
                      rebuildRunIdText runId,
                      failure ^. #failureCode,
                      failure ^. #failureDetail
                    )
                    abandonPreCanonicalGroupStmt
                case abandoned of
                  Nothing -> inactive
                  Just updated -> do
                    Tx.statement (rebuildGroupIdText groupId) markGroupQueriesAbandonedStmt
                    pure (Right updated)
              _ -> inactive
      _ -> inactive
  where
    inactive =
      Tx.condemn
        $> Left (RebuildHandleNoLongerActive groupId runId)

lockProjectionGroupsTx ::
  ValidatedProjectionCatalog ->
  [RebuildGroupId] ->
  Tx.Transaction ProjectionWriteFence
lockProjectionGroupsTx catalog = go [] . List.sort . Set.toList . Set.fromList
  where
    go bindings = \case
      [] -> pure (ProjectionWritesAllowed (Prelude.reverse bindings))
      groupId : rest -> do
        row <-
          Tx.statement
            (rebuildGroupIdText groupId)
            lockGroupWithServingBindingsStmt
        case row of
          Nothing -> pure (ProjectionWriteGroupUnregistered groupId)
          Just (_, activeRunId, _, False, _) ->
            case activeRunId of
              Just runId -> pure (ProjectionWriteFenced groupId (RebuildRunId runId))
              Nothing -> pure (ProjectionWriteGroupUnregistered groupId)
          Just ("live", Nothing, Nothing, True, _) ->
            case declaredPhysicalTargets catalog groupId of
              Nothing -> pure (ProjectionWriteGroupUnregistered groupId)
              Just targets ->
                go
                  (ProjectionWriteBinding groupId Nothing targets : bindings)
                  rest
          Just (status, _, Just revisionIdText, True, rows)
            | status `Prelude.elem` ["serving-versioned", "rebuilding-versioned"] ->
                case findRevision revisionIdText of
                  Nothing ->
                    case Catalog.mkProjectionRevisionId revisionIdText of
                      Left _ -> pure (ProjectionWriteGroupUnregistered groupId)
                      Right revisionId ->
                        pure (ProjectionServingRevisionUnavailable groupId revisionId)
                  Just revision ->
                    case servingPhysicalTargets revision rows of
                      Left detail ->
                        pure
                          ( ProjectionServingBindingInvalid
                              groupId
                              (revision ^. #revisionId)
                              detail
                          )
                      Right targets ->
                        go
                          ( ProjectionWriteBinding
                              groupId
                              (Just (revision ^. #revisionId))
                              targets
                              : bindings
                          )
                          rest
          _ -> pure (ProjectionWriteGroupUnregistered groupId)

    findRevision revisionIdText =
      case Catalog.mkProjectionRevisionId revisionIdText of
        Left _ -> Nothing
        Right revisionId -> Catalog.catalogProjectionRevision catalog revisionId

-- | Take the group's writer-conflicting lock and resolve the exact persisted
-- serving revision/physical generations without changing lifecycle state.
-- Sanctioned external readers take the compatible shared lock and therefore
-- wait for this transaction to finish.
lockProjectionGroupForRepairTx ::
  ValidatedProjectionCatalog ->
  RebuildGroupId ->
  Tx.Transaction ProjectionRepairFence
lockProjectionGroupForRepairTx catalog groupId = do
  row <-
    Tx.statement
      (rebuildGroupIdText groupId)
      lockGroupForRepairStmt
  case row of
    Nothing -> pure (ProjectionRepairGroupUnregistered groupId)
    Just (_, Just activeRunId, _, _, _, _) ->
      pure (ProjectionRepairActiveRebuild groupId (RebuildRunId activeRunId))
    Just (status, Nothing, maybeRevisionId, readsAllowed, writesAllowed, storedSlice) ->
      case groupSliceFingerprint catalog groupId of
        Nothing -> pure (ProjectionRepairGroupUnregistered groupId)
        Just currentSlice
          | storedSlice /= groupSliceFingerprintText currentSlice ->
              pure
                ( ProjectionRepairSliceDrift
                    groupId
                    (groupSliceFingerprintText currentSlice)
                    storedSlice
                )
          | not readsAllowed || not writesAllowed || status /= "serving-versioned" ->
              pure (ProjectionRepairGroupUnavailable groupId status readsAllowed writesAllowed)
          | otherwise ->
              case maybeRevisionId >>= findRevision of
                Nothing ->
                  case maybeRevisionId >>= eitherToMaybe . Catalog.mkProjectionRevisionId of
                    Just revisionId -> pure (ProjectionRepairServingRevisionUnavailable groupId revisionId)
                    Nothing -> pure (ProjectionRepairGroupUnavailable groupId status readsAllowed writesAllowed)
                Just revision -> do
                  rows <-
                    Tx.statement
                      (rebuildGroupIdText groupId)
                      servingTargetBindingsStmt
                  case servingPhysicalTargets revision rows of
                    Left detail ->
                      pure
                        ( ProjectionRepairServingBindingInvalid
                            groupId
                            (revision ^. #revisionId)
                            detail
                        )
                    Right targets ->
                      pure
                        ( ProjectionRepairAllowed
                            ( ProjectionWriteBinding
                                groupId
                                (Just (revision ^. #revisionId))
                                targets
                            )
                        )
  where
    findRevision revisionIdText =
      case Catalog.mkProjectionRevisionId revisionIdText of
        Left _ -> Nothing
        Right revisionId -> Catalog.catalogProjectionRevision catalog revisionId

    eitherToMaybe = \case
      Left _ -> Nothing
      Right value -> Just value

declaredPhysicalTargets ::
  ValidatedProjectionCatalog ->
  RebuildGroupId ->
  Maybe PhysicalTargets
declaredPhysicalTargets catalog groupId = do
  group <-
    List.find
      ((== groupId) . (^. #rebuildGroupId))
      (inventory ^. #inventoryGroups)
  targetRows <- traverse targetFor (group ^. #orderedTargets)
  either (Prelude.const Nothing) Just
    $ mkPhysicalTargets
      (group ^. #orderedTargets)
      ( Map.fromList
          [ (target ^. #targetId, target ^. #qualifiedTable)
          | target <- targetRows
          ]
      )
  where
    inventory = catalogInventory catalog
    targetFor targetId =
      List.find ((== targetId) . (^. #targetId)) (inventory ^. #inventoryTargets)

servingPhysicalTargets ::
  Catalog.ProjectionRevision ->
  [(Text, Text, Text, Text)] ->
  Either Text PhysicalTargets
servingPhysicalTargets revision rows = do
  parsedRows <- traverse parseRow rows
  let expected = Map.keys (revision ^. #targetProvisioners)
      supplied = Map.fromList [(targetId, table) | (targetId, table) <- parsedRows]
  case mkPhysicalTargets expected supplied of
    Left errors -> Left (Text.pack (show errors))
    Right targets -> Right targets
  where
    parseRow (targetIdTextValue, revisionIdTextValue, schemaName, tableName)
      | revisionIdTextValue /= projectionRevisionIdText (revision ^. #revisionId) =
          Left
            ( "serving target "
                <> targetIdTextValue
                <> " belongs to revision "
                <> revisionIdTextValue
            )
      | otherwise = do
          targetId <-
            case mkTargetId targetIdTextValue of
              Left err -> Left (Text.pack (show err))
              Right value -> Right value
          pure (targetId, QualifiedTable schemaName tableName)

data RegisteredQueryRow = RegisteredQueryRow
  { rowVersion :: !Int,
    rowShapeHash :: !Text,
    rowGroupId :: !Text,
    rowStatus :: !Text
  }

queryRegistrationParams :: CatalogRegistration -> (Text, Int64, Text, Text)
queryRegistrationParams registration =
  ( registration ^. #registryName,
    Prelude.fromIntegral (registration ^. #version),
    registration ^. #shapeHash,
    rebuildGroupIdText (registration ^. #rebuildGroupId)
  )

preparationFor :: ValidatedProjectionCatalog -> RebuildGroupId -> Maybe GroupPreparation
preparationFor catalog groupId = do
  group <- List.find ((== groupId) . (^. #rebuildGroupId)) (inventory ^. #inventoryGroups)
  let orderedTargetRows = mapMaybe targetFor (group ^. #orderedTargets)
      replayableProjectionIds =
        Set.fromList
          [ entry ^. #projectionId
          | entry <- replayAdapterMetadata catalog,
            entry ^. #rebuildGroupId == groupId,
            entry ^. #replayable
          ]
      asyncRows =
        [ entry
        | entry <- asyncProjectionRegistrations catalog,
          (entry ^. #projectionId) `Set.member` replayableProjectionIds
        ]
  pure
    GroupPreparation
      { clearTargets =
          [ target ^. #qualifiedTable
          | target <- orderedTargetRows,
            target ^. #resetPolicy == ClearBeforeReplay
          ],
        preservedTargets =
          [ target ^. #qualifiedTable
          | target <- orderedTargetRows,
            target ^. #resetPolicy == PreserveAndReconcile
          ],
        resetDedupNames = List.nub (List.sort ((^. #dedupName) <$> asyncRows)),
        resetSubscriptionNames = List.nub (List.sort ((^. #subscriptionName) <$> asyncRows))
      }
  where
    inventory = catalogInventory catalog
    targetFor targetId =
      List.find ((== targetId) . (^. #targetId)) (inventory ^. #inventoryTargets)

truncateTargets :: [QualifiedTable] -> Tx.Transaction ()
truncateTargets = \case
  [] -> pure ()
  targets ->
    Tx.sql
      ( TE.encodeUtf8
          ( "TRUNCATE TABLE "
              <> Text.intercalate
                ", "
                [ qualifyTable (target ^. #schemaName) (target ^. #tableName)
                | target <- targets
                ]
          )
      )

tokenMatchesHandle :: GroupRebuildHandle -> GroupCompletionToken -> Bool
tokenMatchesHandle handle token =
  completionGroup token == handleGroup handle
    && completionRun token == handleRun handle
    && completionSliceFingerprint token == handleSliceFingerprint handle

registerGroupStmt :: Statement (Text, Text) GroupRebuildMetadata
registerGroupStmt =
  preparable
    """
    INSERT INTO keiro.keiro_projection_rebuild_groups
      (group_id, slice_fingerprint, status)
    VALUES ($1, $2, 'live')
    ON CONFLICT (group_id) DO UPDATE
      SET group_id = EXCLUDED.group_id
    RETURNING group_id, slice_fingerprint, status, active_run_id,
              requested_by, request_reason, started_at, completed_at, failed_at,
              failure_code, failure_detail
    """
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    groupMetadataSingle

registerProjectionRevisionStmt :: Statement (Text, Text, Text) Text
registerProjectionRevisionStmt =
  preparable
    """
    INSERT INTO keiro.keiro_projection_revisions
      (group_id, revision_id, group_slice_fingerprint)
    VALUES ($1, $2, $3)
    ON CONFLICT (group_id, revision_id) DO UPDATE
      SET revision_id = EXCLUDED.revision_id
    RETURNING group_slice_fingerprint
    """
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    (D.singleRow (D.column (D.nonNullable D.text)))

upsertGroupCursorAuthorityStmt :: Statement (Text, Text, [Text]) ()
upsertGroupCursorAuthorityStmt =
  preparable
    """
    INSERT INTO keiro.keiro_projection_group_cursors
      (group_id, position_basis, subscription_names)
    VALUES ($1, $2, $3)
    ON CONFLICT (group_id) DO UPDATE
      SET position_basis = EXCLUDED.position_basis,
          subscription_names = EXCLUDED.subscription_names,
          updated_at = now()
    """
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))
    )
    D.noResult

adoptProjectionRevisionStmt :: Statement (Text, Text, Text) ()
adoptProjectionRevisionStmt =
  preparable
    """
    INSERT INTO keiro.keiro_projection_revisions
      (group_id, revision_id, group_slice_fingerprint)
    VALUES ($1, $2, $3)
    ON CONFLICT (group_id, revision_id) DO UPDATE
      SET group_slice_fingerprint = EXCLUDED.group_slice_fingerprint,
          updated_at = now()
    """
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    D.noResult

lookupGroupStmt :: Statement Text (Maybe GroupRebuildMetadata)
lookupGroupStmt =
  preparable
    """
    SELECT group_id, slice_fingerprint, status, active_run_id,
           requested_by, request_reason, started_at, completed_at, failed_at,
           failure_code, failure_detail
    FROM keiro.keiro_projection_rebuild_groups
    WHERE group_id = $1
    """
    (E.param (E.nonNullable E.text))
    (D.rowMaybe groupMetadataDecoder)

listGroupsStmt :: Statement () [GroupRebuildMetadata]
listGroupsStmt =
  preparable
    """
    SELECT group_id, slice_fingerprint, status, active_run_id,
           requested_by, request_reason, started_at, completed_at, failed_at,
           failure_code, failure_detail
    FROM keiro.keiro_projection_rebuild_groups
    ORDER BY group_id
    """
    E.noParams
    (D.rowList groupMetadataDecoder)

lockGroupForUpdateStmt :: Statement Text (Maybe GroupRebuildMetadata)
lockGroupForUpdateStmt =
  preparable
    """
    SELECT group_id, slice_fingerprint, status, active_run_id,
           requested_by, request_reason, started_at, completed_at, failed_at,
           failure_code, failure_detail
    FROM keiro.keiro_projection_rebuild_groups
    WHERE group_id = $1
    FOR UPDATE
    """
    (E.param (E.nonNullable E.text))
    (D.rowMaybe groupMetadataDecoder)

beginGroupStmt :: Statement (Text, Text, Text, Text) ()
beginGroupStmt =
  preparable
    """
    UPDATE keiro.keiro_projection_rebuild_groups
    SET status = 'rebuilding',
        reads_allowed = FALSE,
        writes_allowed = FALSE,
        active_run_id = $2,
        requested_by = $3,
        request_reason = $4,
        started_at = now(),
        completed_at = NULL,
        failed_at = NULL,
        failure_code = NULL,
        failure_detail = NULL,
        updated_at = now()
    WHERE group_id = $1
    """
    ( contrazip4
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    D.noResult

finishGroupStmt :: Statement (Text, Text, Text) (Maybe GroupRebuildMetadata)
finishGroupStmt =
  preparable
    """
    UPDATE keiro.keiro_projection_rebuild_groups
    SET status = 'live',
        reads_allowed = TRUE,
        writes_allowed = TRUE,
        active_run_id = NULL,
        completed_at = now(),
        failed_at = NULL,
        failure_code = NULL,
        failure_detail = NULL,
        updated_at = now()
    WHERE group_id = $1
      AND active_run_id = $2
      AND slice_fingerprint = $3
      AND status = 'rebuilding'
    RETURNING group_id, slice_fingerprint, status, active_run_id,
              requested_by, request_reason, started_at, completed_at, failed_at,
              failure_code, failure_detail
    """
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    (D.rowMaybe groupMetadataDecoder)

abandonGroupStmt :: Statement (Text, Text, Text, Text, Text) (Maybe GroupRebuildMetadata)
abandonGroupStmt =
  preparable
    """
    UPDATE keiro.keiro_projection_rebuild_groups
    SET status = 'failed',
        reads_allowed = FALSE,
        writes_allowed = FALSE,
        failed_at = now(),
        failure_code = $4,
        failure_detail = $5,
        updated_at = now()
    WHERE group_id = $1
      AND active_run_id = $2
      AND slice_fingerprint = $3
      AND status = 'rebuilding'
    RETURNING group_id, slice_fingerprint, status, active_run_id,
              requested_by, request_reason, started_at, completed_at, failed_at,
              failure_code, failure_detail
    """
    ( contrazip5
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    (D.rowMaybe groupMetadataDecoder)

abandonPreCanonicalGroupStmt :: Statement (Text, Text, Text, Text) (Maybe GroupRebuildMetadata)
abandonPreCanonicalGroupStmt =
  preparable
    """
    UPDATE keiro.keiro_projection_rebuild_groups
    SET status = 'failed',
        reads_allowed = FALSE,
        writes_allowed = FALSE,
        failed_at = now(),
        failure_code = $3,
        failure_detail = $4,
        updated_at = now()
    WHERE group_id = $1
      AND active_run_id = $2
      AND status = 'rebuilding'
    RETURNING group_id, slice_fingerprint, status, active_run_id,
              requested_by, request_reason, started_at, completed_at, failed_at,
              failure_code, failure_detail
    """
    ( contrazip4
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    (D.rowMaybe groupMetadataDecoder)

lockGroupWithServingBindingsStmt :: Statement Text (Maybe (Text, Maybe Text, Maybe Text, Bool, [(Text, Text, Text, Text)]))
lockGroupWithServingBindingsStmt =
  preparable
    """
    SELECT locked_group.status,
           locked_group.active_run_id,
           locked_group.serving_revision_id,
           locked_group.writes_allowed,
           COALESCE(array_agg(generations.target_id ORDER BY generations.target_id)
             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[]),
           COALESCE(array_agg(generations.revision_id ORDER BY generations.target_id)
             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[]),
           COALESCE(array_agg(generations.schema_name ORDER BY generations.target_id)
             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[]),
           COALESCE(array_agg(generations.relation_name ORDER BY generations.target_id)
             FILTER (WHERE generations.target_id IS NOT NULL), ARRAY[]::text[])
    FROM (
      SELECT status, active_run_id, serving_revision_id, writes_allowed
      FROM keiro.keiro_projection_rebuild_groups
      WHERE group_id = $1
      FOR SHARE
    ) AS locked_group
    LEFT JOIN LATERAL (
      SELECT target_id, revision_id, schema_name, relation_name
      FROM keiro.keiro_projection_target_generations
      WHERE group_id = $1 AND lifecycle = 'serving'
    ) AS generations ON TRUE
    GROUP BY locked_group.status,
             locked_group.active_run_id,
             locked_group.serving_revision_id,
             locked_group.writes_allowed
    """
    (E.param (E.nonNullable E.text))
    ( D.rowMaybe
        ( (,,,,)
            <$> D.column (D.nonNullable D.text)
            <*> D.column (D.nullable D.text)
            <*> D.column (D.nullable D.text)
            <*> D.column (D.nonNullable D.bool)
            <*> ( List.zip4
                    <$> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))
                    <*> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))
                    <*> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))
                    <*> D.column (D.nonNullable (D.listArray (D.nonNullable D.text)))
                )
        )
    )

lockGroupForRepairStmt :: Statement Text (Maybe (Text, Maybe Text, Maybe Text, Bool, Bool, Text))
lockGroupForRepairStmt =
  preparable
    """
    SELECT status, active_run_id, serving_revision_id,
           reads_allowed, writes_allowed, slice_fingerprint
    FROM keiro.keiro_projection_rebuild_groups
    WHERE group_id = $1
    FOR UPDATE
    """
    (E.param (E.nonNullable E.text))
    ( D.rowMaybe
        ( (,,,,,)
            <$> D.column (D.nonNullable D.text)
            <*> D.column (D.nullable D.text)
            <*> D.column (D.nullable D.text)
            <*> D.column (D.nonNullable D.bool)
            <*> D.column (D.nonNullable D.bool)
            <*> D.column (D.nonNullable D.text)
        )
    )

servingTargetBindingsStmt :: Statement Text [(Text, Text, Text, Text)]
servingTargetBindingsStmt =
  preparable
    """
    SELECT target_id, revision_id, schema_name, relation_name
    FROM keiro.keiro_projection_target_generations
    WHERE group_id = $1 AND lifecycle = 'serving'
    ORDER BY target_id
    """
    (E.param (E.nonNullable E.text))
    ( D.rowList
        ( (,,,)
            <$> D.column (D.nonNullable D.text)
            <*> D.column (D.nonNullable D.text)
            <*> D.column (D.nonNullable D.text)
            <*> D.column (D.nonNullable D.text)
        )
    )

insertQueryRegistrationStmt :: Statement (Text, Int64, Text, Text) ()
insertQueryRegistrationStmt =
  preparable
    """
    INSERT INTO keiro.keiro_read_models
      (name, version, shape_hash, rebuild_group_id, status, last_built_at)
    VALUES ($1, $2, $3, $4, 'live', now())
    """
    ( contrazip4
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.int8))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    D.noResult

insertAdoptedQueryRegistrationStmt :: Statement (Text, Int64, Text, Text, Text) ()
insertAdoptedQueryRegistrationStmt =
  preparable
    """
    INSERT INTO keiro.keiro_read_models
      (name, version, shape_hash, rebuild_group_id, status, last_built_at)
    VALUES ($1, $2, $3, $4, $5,
            CASE WHEN $5 = 'live' THEN now() ELSE NULL END)
    """
    ( contrazip5
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.int8))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    D.noResult

lookupQueryRegistrationStmt :: Statement Text (Maybe RegisteredQueryRow)
lookupQueryRegistrationStmt =
  preparable
    """
    SELECT version, shape_hash, rebuild_group_id, status
    FROM keiro.keiro_read_models
    WHERE name = $1
    FOR UPDATE
    """
    (E.param (E.nonNullable E.text))
    ( D.rowMaybe
        ( RegisteredQueryRow
            <$> (Prelude.fromIntegral <$> D.column (D.nonNullable D.int8))
            <*> D.column (D.nonNullable D.text)
            <*> D.column (D.nonNullable D.text)
            <*> D.column (D.nonNullable D.text)
        )
    )

adoptLegacyQueryRegistrationStmt :: Statement (Text, Text) ()
adoptLegacyQueryRegistrationStmt =
  preparable
    """
    UPDATE keiro.keiro_read_models
    SET rebuild_group_id = $2,
        updated_at = now()
    WHERE name = $1
    """
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    D.noResult

adoptGroupSliceStmt :: Statement (Text, Text) ()
adoptGroupSliceStmt =
  preparable
    """
    UPDATE keiro.keiro_projection_rebuild_groups
    SET slice_fingerprint = $2,
        updated_at = now()
    WHERE group_id = $1
    """
    ( contrazip2
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    D.noResult

adoptQueryRegistrationStmt :: Statement (Text, Int64, Text, Text) Int64
adoptQueryRegistrationStmt =
  preparable
    """
    UPDATE keiro.keiro_read_models
    SET version = $2,
        shape_hash = $3,
        rebuild_group_id = $4,
        updated_at = now()
    WHERE name = $1
    """
    ( contrazip4
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.int8))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
    )
    D.rowsAffected

listQueryRegistrationBindingsStmt :: Statement () [(Text, Text)]
listQueryRegistrationBindingsStmt =
  preparable
    """
    SELECT name, rebuild_group_id
    FROM keiro.keiro_read_models
    ORDER BY name
    """
    E.noParams
    (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))

lockGroupRegistrationsStmt :: Statement [Text] [(Text, Text)]
lockGroupRegistrationsStmt =
  preparable
    """
    SELECT name, rebuild_group_id
    FROM keiro.keiro_read_models
    WHERE rebuild_group_id = ANY($1)
    ORDER BY name
    FOR UPDATE
    """
    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))
    (D.rowList ((,) <$> D.column (D.nonNullable D.text) <*> D.column (D.nonNullable D.text)))

deleteQueryRegistrationsStmt :: Statement [Text] ()
deleteQueryRegistrationsStmt =
  preparable
    """
    DELETE FROM keiro.keiro_read_models
    WHERE name = ANY($1)
    """
    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))
    D.noResult

deleteOrphanLegacyGroupsStmt :: Statement () ()
deleteOrphanLegacyGroupsStmt =
  preparable
    """
    DELETE FROM keiro.keiro_projection_rebuild_groups AS groups
    WHERE groups.slice_fingerprint = '$legacy-unmanaged'
      AND groups.status = 'live'
      AND NOT EXISTS (
        SELECT 1
        FROM keiro.keiro_read_models AS models
        WHERE models.rebuild_group_id = groups.group_id
      )
    """
    E.noParams
    D.noResult

markGroupQueriesRebuildingStmt :: Statement Text ()
markGroupQueriesRebuildingStmt =
  queryStatusStatement "rebuilding" False

markGroupQueriesLiveStmt :: Statement Text ()
markGroupQueriesLiveStmt =
  queryStatusStatement "live" True

markGroupQueriesAbandonedStmt :: Statement Text ()
markGroupQueriesAbandonedStmt =
  queryStatusStatement "abandoned" False

queryStatusStatement :: Text -> Bool -> Statement Text ()
queryStatusStatement newStatus stampBuilt =
  preparable
    ( "UPDATE keiro.keiro_read_models SET status = '"
        <> newStatus
        <> "', last_built_at = "
        <> (if stampBuilt then "now()" else "last_built_at")
        <> ", updated_at = now() WHERE rebuild_group_id = $1"
    )
    (E.param (E.nonNullable E.text))
    D.noResult

deleteProjectionDedupStmt :: Statement [Text] ()
deleteProjectionDedupStmt =
  preparable
    """
    DELETE FROM keiro.keiro_projection_dedup
    WHERE projection_name = ANY($1)
    """
    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))
    D.noResult

-- | Insert a bounded batch of async-projection dedup identities. Existing
-- identities are expected during promotion retries and are left unchanged.
insertProjectionDedupBatchStmt :: Statement ([Text], [UUID]) Int64
insertProjectionDedupBatchStmt =
  preparable
    """
    INSERT INTO keiro.keiro_projection_dedup (projection_name, event_id)
    SELECT pair.name, pair.event
    FROM unnest($1::text[], $2::uuid[]) AS pair (name, event)
    ON CONFLICT (projection_name, event_id) DO NOTHING
    """
    ( contrazip2
        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))
        (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))
    )
    D.rowsAffected

groupMetadataSingle :: D.Result GroupRebuildMetadata
groupMetadataSingle = D.singleRow groupMetadataDecoder

groupMetadataDecoder :: D.Row GroupRebuildMetadata
groupMetadataDecoder =
  GroupRebuildMetadata
    <$> (decodeGroupId <$> D.column (D.nonNullable D.text))
    <*> D.column (D.nonNullable D.text)
    <*> (groupStatusFromText <$> D.column (D.nonNullable D.text))
    <*> (fmap RebuildRunId <$> D.column (D.nullable D.text))
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nullable D.timestamptz)
    <*> D.column (D.nullable D.timestamptz)
    <*> D.column (D.nullable D.timestamptz)
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nullable D.text)
  where
    decodeGroupId raw =
      case mkRebuildGroupId raw of
        Right value -> value
        Left _ -> error "stored rebuild group id violates catalog identity invariants"

groupStatusFromText :: Text -> GroupLifecycleStatus
groupStatusFromText = \case
  "live" -> GroupLive
  "rebuilding" -> GroupRebuilding
  "failed" -> GroupFailed
  raw -> UnknownGroupStatus raw