packages feed

relocant-1.0.0: src/Relocant/App.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# OPTIONS_HADDOCK hide #-}
module Relocant.App
  ( run
  ) where

import Control.Monad (guard, unless)
import Data.Aeson ((.=))
import Data.Aeson qualified as Aeson
import Data.ByteString.Char8 qualified as ByteString (putStr)
import Data.ByteString.Lazy.Char8 qualified as ByteString.Lazy (putStrLn)
import Data.Foldable (for_, traverse_)
import Data.Text.IO qualified as Text
import Database.PostgreSQL.Simple qualified as DB (Connection, withTransaction)
import GHC.Records (HasField)
import Prelude hiding (id, log)
import System.Exit (exitFailure)

import Relocant qualified
import Relocant.App.Env qualified as Env
import Relocant.App.Log (Log)
import Relocant.App.Log qualified as Log
import Relocant.App.Opts qualified as Opts
import Relocant.App.Opts.Fmt (Fmt)
import Relocant.App.Opts.Fmt qualified as Fmt
import Relocant.App.ToText (ToText(..))
import Relocant.Applied qualified as Applied
import Relocant.DB qualified as DB (ConnectionString, Table, connect, dumpSchema, withLock, withTryLock)
import Relocant.Script (readScript)
import Relocant.Script qualified as Script
import Relocant.Merge qualified as Merge


run :: IO ()
run = do
  env <- Env.parse
  (cfg, cmd) <- Opts.parse env
  runCmd (Log.make cfg.minSeverity) cmd

runCmd :: Log -> Opts.Cmd -> IO ()
runCmd log = \case
  Opts.ListUnapplied opts -> do
    Log.debug log
      [ "action" .= ("list-unapplied" :: String)
      , "opts" .= opts
      ]
    runListUnapplied log opts
  Opts.ListApplied opts -> do
    Log.debug log
      [ "action" .= ("list-applied" :: String)
      , "opts" .= opts
      ]
    runListApplied log opts
  Opts.ShowApplied opts -> do
    Log.debug log
      [ "action" .= ("show-applied" :: String)
      , "opts" .= opts
      ]
    runShowApplied log opts
  Opts.Verify opts -> do
    Log.debug log
      [ "action" .= ("verify" :: String)
      , "opts" .= opts
      ]
    runVerify log opts
  Opts.Apply opts -> do
    Log.debug log
      [ "action" .= ("apply" :: String)
      , "opts" .= opts
      ]
    runApply log opts
  Opts.Version version ->
    putStrLn version
  Opts.Internal internalCmd ->
    runInternalCmd log internalCmd

runInternalCmd :: Log -> Opts.InternalCmd -> IO ()
runInternalCmd log = \case
  Opts.DumpSchema opts -> do
    Log.debug log
      [ "action" .= ("dump-schema" :: String)
      , "opts" .= opts
      ]
    runDumpSchema opts
  Opts.MarkApplied opts -> do
    Log.debug log
      [ "action" .= ("mark-applied" :: String)
      , "opts" .= opts
      ]
    runMarkApplied log opts
  Opts.Delete opts -> do
    Log.debug log
      [ "action" .= ("delete" :: String)
      , "opts" .= opts
      ]
    runDelete log opts
  Opts.DeleteAll opts -> do
    Log.debug log
      [ "action" .= ("delete-all" :: String)
      , "opts" .= opts
      ]
    runDeleteAll log opts

runListUnapplied :: Log -> Opts.ListUnapplied -> IO ()
runListUnapplied log opts = do
  withTryLock log opts $ \conn -> do
    migrations <- Relocant.mergeAll opts.scripts opts.table conn
    traverse_ (println opts.format) migrations.unapplied

runListApplied :: Log -> Opts.ListApplied -> IO ()
runListApplied log opts = do
  withTryLock log opts $ \conn -> do
    migrations <- Applied.getApplied opts.table conn
    traverse_ (println opts.format) migrations

runShowApplied :: Log -> Opts.ShowApplied -> IO ()
runShowApplied log opts = do
  withTryLock log opts $ \conn -> do
    applied <- Applied.getAppliedByID opts.id opts.table conn
    maybe exitFailure (\a -> ByteString.putStr a.bytes) applied

runVerify :: Log -> Opts.Verify -> IO ()
runVerify log opts = do
  withTryLock log opts $ \conn -> do
    verify log opts.scripts (opts.format <$ guard (not opts.quiet)) opts.table conn

runApply :: Log -> Opts.Apply -> IO ()
runApply log opts = do
  withLock log opts $ \conn -> do
    apply log opts.scripts opts.format opts.table conn
    verify log opts.scripts Nothing opts.table conn

runDumpSchema :: Opts.DumpSchema -> IO ()
runDumpSchema opts =
  DB.dumpSchema opts.table

runMarkApplied :: Log -> Opts.MarkApplied -> IO ()
runMarkApplied log opts = do
  withTryLock log opts $ \conn -> do
    script <- readScript opts.script
    DB.withTransaction conn $ do
      Log.debug log
        [ "action" .= ("mark-applied" :: String)
        , "delete" .= script.id
        ]
      _deleted <- Applied.deleteByID script.id opts.table conn
      Log.debug log
        [ "action" .= ("mark-applied" :: String)
        , "record" .= script.id
        ]
      markApplied <- Script.markApplied script
      Applied.record markApplied opts.table conn

runDelete :: Log -> Opts.Delete -> IO ()
runDelete log opts = do
  withTryLock log opts $ \conn -> do
    deleted <- Applied.deleteByID opts.id opts.table conn
    unless deleted $ do
      Log.notice log
        [ "action" .= ("delete" :: String)
        , "result" .= ("couldn't delete the recorded migration, typo in the ID?" :: String)
        ]
      exitFailure

runDeleteAll :: Log -> Opts.DeleteAll -> IO ()
runDeleteAll log opts = do
  withTryLock log opts $ \conn -> do
    Applied.deleteAll opts.table conn

verify :: Log -> FilePath -> Maybe Fmt -> DB.Table -> DB.Connection -> IO ()
verify log dir formatQ table conn = do
  migrations <- Relocant.mergeAll dir table conn
  unless (Merge.converged migrations) $ do
    Log.debug log
      [ "action" .= ("apply" :: String)
      , "unrecorded" .= map (.id) migrations.unrecorded
      , "script-missing" .= map (.id) migrations.scriptMissing
      , "content-mismatch" .= map (\cm -> (cm.expected.id, cm.butGot.id)) migrations.contentMismatch
      , "unapplied" .= map (.id) migrations.unapplied
      ]
    for_ formatQ $ \format -> do
      println format migrations
    exitFailure

apply :: Log -> FilePath -> Fmt -> DB.Table -> DB.Connection -> IO ()
apply log scripts format table conn = do
  merged <- Relocant.mergeAll scripts table conn
  unless (Merge.canApply merged) $ do
    Log.error log
      [ "action" .= ("apply" :: String)
      , "result" .= ("not ready to apply the scripts, run `verify' first" :: String)
      ]
    exitFailure
  Log.debug log
    [ "action" .= ("apply" :: String)
    , "migrations" .= map (.id) merged.unapplied
    ]
  for_ merged.unapplied $ \script -> do
    println format script
    DB.withTransaction conn $ do
      Log.debug log
        [ "action" .= ("apply" :: String)
        , "apply" .= script.id
        ]
      applied <- Script.apply script conn
      Log.debug log
        [ "action" .= ("apply" :: String)
        , "record" .= script.id
        , "duration" .= applied.durationS
        ]
      Applied.record applied table conn
      println format applied

withLock
  :: (HasField "table" cfg DB.Table, HasField "connString" cfg DB.ConnectionString)
  => Log
  -> cfg
  -> (DB.Connection -> IO b)
  -> IO b
withLock log cfg m = do
  conn <- DB.connect cfg.connString cfg.table
  Log.debug log
    [ "action" .= ("acquire-lock" :: String)
    , "table" .= cfg.table
    ]
  DB.withLock cfg.table conn $ do
    Log.debug log
      [ "action" .= ("try-to-acquire-lock" :: String)
      , "table" .= cfg.table
      , "result" .= ("acquired" :: String)
      ]
    m conn

withTryLock
  :: (HasField "table" cfg DB.Table, HasField "connString" cfg DB.ConnectionString)
  => Log
  -> cfg
  -> (DB.Connection -> IO b) -> IO b
withTryLock log cfg m = do
  conn <- DB.connect cfg.connString cfg.table
  Log.debug log
    [ "action" .= ("try-to-acquire-lock" :: String)
    , "table" .= cfg.table
    ]
  DB.withTryLock cfg.table conn $ \locked -> do
    unless locked $ do
      Log.error log
        [ "action" .= ("try-to-acquire-lock" :: String)
        , "table" .= cfg.table
        , "result" .= ("couldn't lock the database, migration in progress?" :: String)
        ]
      exitFailure
    Log.debug log
      [ "action" .= ("try-to-acquire-lock" :: String)
      , "table" .= cfg.table
      , "result" .= ("acquired" :: String)
      ]
    m conn

println :: (Aeson.ToJSON a, ToText a) => Fmt -> a -> IO ()
println = \case
  Fmt.Json ->
    ByteString.Lazy.putStrLn . Aeson.encode
  Fmt.Text ->
    Text.putStrLn . toText