packages feed

clickhouse-migrate-0.1.0.0: src/ClickHouseMigrate/Plan.hs

-- | Order-preserving validated plans and immutable context-bound views.
module ClickHouseMigrate.Plan
  ( MigrationPlan,
    migrationPlan,
    migrationPlanFromList,
    scheduledMigrationPlan,
    scheduledMigrationPlanFromList,
    inspectSchedule,
    inspectPlan,
    ComponentView,
    componentViewName,
    componentDependencies,
    componentMigrations,
    MigrationView,
    migrationId,
    migrationDescription,
    migrationSteps,
    migrationChecksum,
    StepView,
    stepViewName,
    stepSql,
    stepChecksum,
    StepChecksum,
    MigrationChecksum,
    ChecksumFormat (ChecksumV1),
    checksumFormatName,
    parseChecksumFormat,
    stepChecksumFormat,
    migrationChecksumFormat,
    stepChecksumHex,
    migrationChecksumHex,
    completionPolicy,
  )
where

import ClickHouseMigrate.Definition
import ClickHouseMigrate.Error
import ClickHouseMigrate.Internal.Checksum qualified as Checksum
import ClickHouseMigrate.Internal.Types
import ClickHouseMigrate.Prelude
import Control.Monad (foldM)
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set

migrationPlan :: NonEmpty MigrationComponent -> Either PlanError MigrationPlan
migrationPlan components = do
  let ordered = NE.toList components
      names = [name | MigrationComponent name _ _ <- ordered]
      allNames = Set.fromList names
      graph = Map.fromList [(name, deps) | MigrationComponent name deps _ <- ordered]
      bad c xs = Left (PlanError c (map componentNameText xs))
  if Set.size allNames /= length names then bad DuplicateComponent names else pure ()
  mapM_
    ( \(MigrationComponent name deps _) ->
        if Set.member name deps
          then bad SelfDependency [name]
          else case Set.toAscList (Set.difference deps allNames) of
            [] -> Right ()
            missing -> bad MissingDependency (name : missing)
    )
    ordered
  case cyclicRemainder graph of
    [] -> pure ()
    remaining -> bad DependencyCycle remaining
  let positions = Map.fromList (zip names [0 :: Int ..])
  mapM_
    ( \(MigrationComponent name deps _) ->
        case [dep | dep <- Set.toAscList deps, Map.lookup dep positions >= Map.lookup name positions] of
          [] -> Right ()
          late -> bad DependencyAfterDependent (name : late)
    )
    ordered
  let catalog = fmap bindComponent components
  pure (MigrationPlan catalog (catalog >>= componentMigrations))

-- | Parsing boundary for application formats which represent empty lists.
migrationPlanFromList :: [MigrationComponent] -> Either PlanError MigrationPlan
migrationPlanFromList = maybe (Left (PlanError EmptyPlan [])) migrationPlan . NE.nonEmpty

-- | Complete application-owned order. Dependencies constrain first activation;
-- later upgrades follow the explicit schedule, retaining each library's order.
scheduledMigrationPlan :: NonEmpty MigrationComponent -> NonEmpty MigrationId -> Either PlanError MigrationPlan
scheduledMigrationPlan components schedule = do
  ordinary <- migrationPlan components
  let catalog = inspectPlan ordinary
      authored = NE.toList (inspectSchedule ordinary)
      byId = Map.fromList [(migrationId view, view) | view <- authored]
      ids = NE.toList schedule
      named identity = [componentNameText (migrationIdComponent identity), migrationNameText (migrationIdName identity)]
      reject kind identity = Left (PlanError kind (named identity))
  resolved <- traverse (\identity -> maybe (reject UnknownScheduledMigration identity) Right (Map.lookup identity byId)) schedule
  _ <- foldM (\seen identity -> if Set.member identity seen then reject DuplicateScheduledMigration identity else Right (Set.insert identity seen)) Set.empty ids
  mapM_ (\view -> unless (migrationId view `elem` ids) (reject MissingScheduledMigration (migrationId view))) authored
  mapM_
    ( \component -> do
        let name = componentViewName component
            local = filter ((== name) . migrationIdComponent) ids
        unless (local == map migrationId (NE.toList (componentMigrations component))) (Left (PlanError MigrationOrderChanged [componentNameText name]))
    )
    catalog
  let dependencies = Map.fromList [(componentViewName c, componentDependencies c) | c <- NE.toList catalog]
  _ <-
    foldM
      ( \active identity -> do
          let name = migrationIdComponent identity
              missing = Map.findWithDefault Set.empty name dependencies `Set.difference` active
          unless (Set.member name active || Set.null missing) (Left (PlanError DependencyNotActivated (map componentNameText (name : Set.toAscList missing))))
          Right (Set.insert name active)
      )
      Set.empty
      ids
  pure (MigrationPlan catalog resolved)

scheduledMigrationPlanFromList :: [MigrationComponent] -> [MigrationId] -> Either PlanError MigrationPlan
scheduledMigrationPlanFromList components schedule = do
  catalog <- maybe (Left (PlanError EmptyPlan [])) Right (NE.nonEmpty components)
  order <- maybe (Left (PlanError EmptySchedule [])) Right (NE.nonEmpty schedule)
  scheduledMigrationPlan catalog order

inspectSchedule :: MigrationPlan -> NonEmpty MigrationView
inspectSchedule (MigrationPlan _ schedule) = schedule

cyclicRemainder :: Map.Map ComponentName (Set ComponentName) -> [ComponentName]
cyclicRemainder graph
  | Map.null graph = []
  | Map.null ready = Map.keys graph
  | otherwise = cyclicRemainder (Map.map (`Set.difference` Map.keysSet ready) waiting)
  where
    (ready, waiting) = Map.partition Set.null graph

bindComponent :: MigrationComponent -> ComponentView
bindComponent (MigrationComponent component deps migrations) = ComponentView component deps (fmap bindMigration migrations)
  where
    bindMigration (Migration name description steps) =
      let encodings = fmap (encodeStep name) steps
          boundSteps = NE.zipWith (\(SqlStep step sql) encoded -> StepView step sql (StepChecksum (Checksum.digest encoded))) steps encodings
          checksum = MigrationChecksum (Checksum.digest (Checksum.encodeMigrationV1 (componentNameText component) (migrationNameText name) (NE.toList encodings)))
       in MigrationView (MigrationId component name) description boundSteps checksum
    encodeStep migration (SqlStep step sql) = Checksum.encodeStepV1 (componentNameText component) (migrationNameText migration) (stepNameText step) sql

inspectPlan :: MigrationPlan -> NonEmpty ComponentView
inspectPlan (MigrationPlan components _) = components

componentViewName :: ComponentView -> ComponentName
componentViewName (ComponentView name _ _) = name

componentDependencies :: ComponentView -> Set ComponentName
componentDependencies (ComponentView _ deps _) = deps

componentMigrations :: ComponentView -> NonEmpty MigrationView
componentMigrations (ComponentView _ _ migrations) = migrations

migrationId :: MigrationView -> MigrationId
migrationId (MigrationView identity _ _ _) = identity

migrationDescription :: MigrationView -> Maybe Text
migrationDescription (MigrationView _ description _ _) = description

migrationSteps :: MigrationView -> NonEmpty StepView
migrationSteps (MigrationView _ _ steps _) = steps

migrationChecksum :: MigrationView -> MigrationChecksum
migrationChecksum (MigrationView _ _ _ checksum) = checksum

stepViewName :: StepView -> StepName
stepViewName (StepView name _ _) = name

stepSql :: StepView -> ByteString
stepSql (StepView _ sql _) = sql

stepChecksum :: StepView -> StepChecksum
stepChecksum (StepView _ _ checksum) = checksum

checksumFormatName :: ChecksumFormat -> Text
checksumFormatName ChecksumV1 = "sha256-v1"

parseChecksumFormat :: Text -> Either DefinitionError ChecksumFormat
parseChecksumFormat "sha256-v1" = Right ChecksumV1
parseChecksumFormat _ = Left (DefinitionError UnknownChecksumFormat ["checksum", "format"] Nothing)

stepChecksumFormat :: StepChecksum -> ChecksumFormat
stepChecksumFormat _ = ChecksumV1

migrationChecksumFormat :: MigrationChecksum -> ChecksumFormat
migrationChecksumFormat _ = ChecksumV1

stepChecksumHex :: StepChecksum -> Text
stepChecksumHex (StepChecksum bytes) = Checksum.digestHex bytes

migrationChecksumHex :: MigrationChecksum -> Text
migrationChecksumHex (MigrationChecksum bytes) = Checksum.digestHex bytes

completionPolicy :: ByteString
completionPolicy = Checksum.completionPolicyV1