pg-migrate-test-support-1.2.0.0: src/Database/PostgreSQL/Migrate/Test.hs
-- | Test-only ephemeral PostgreSQL lifecycle. A validated plan is applied before a fresh
-- Hasql connection is bracketed around the caller's assertion callback.
module Database.PostgreSQL.Migrate.Test
( -- | Structured startup, migration, callback, and callback-plus-cleanup failures.
MigratedDatabaseError (..),
-- | Start a database, apply a plan with default runner options, and run a callback.
withMigratedDatabase,
-- | As 'withMigratedDatabase', with explicit runner options.
withMigratedDatabaseOptions,
-- | Fully configurable ephemeral-database variant.
withMigratedDatabaseConfig,
-- | Default ephemeral-database configuration with a stable per-user temporary root.
defaultEphemeralConfig,
)
where
import Control.Exception (SomeException)
import Control.Exception qualified as Exception
import Data.Monoid (Last (..))
import Database.PostgreSQL.Migrate
import EphemeralPg qualified
import EphemeralPg.Config qualified
import Hasql.Connection qualified as Connection
import Hasql.Connection.Settings qualified as Settings
import Hasql.Errors qualified as Errors
import System.Directory (createDirectoryIfMissing)
import System.Posix.User (getEffectiveUserID)
-- | Structured failures from the ephemeral database, migration, callback, or a callback
-- failure accompanied by a connection-release failure. A release failure after a
-- successful callback does not replace its value.
data MigratedDatabaseError
= MigratedDatabaseStartupFailed !EphemeralPg.StartError
| MigratedDatabaseMigrationFailed !MigrationError
| MigratedDatabaseCallbackAcquisitionFailed !Errors.ConnectionError
| MigratedDatabaseCallbackFailed !SomeException
| MigratedDatabaseCallbackAndCleanupFailed !SomeException !SomeException
deriving stock (Show)
-- | Start a database, migrate it with 'defaultRunOptions', and bracket a callback connection.
withMigratedDatabase ::
MigrationPlan ->
(Connection.Connection -> IO value) ->
IO (Either MigratedDatabaseError value)
withMigratedDatabase = withMigratedDatabaseOptions defaultRunOptions
-- | Start a database, migrate it with the supplied options, and bracket a callback connection.
withMigratedDatabaseOptions ::
RunOptions ->
MigrationPlan ->
(Connection.Connection -> IO value) ->
IO (Either MigratedDatabaseError value)
withMigratedDatabaseOptions options plan callback = do
config <- defaultEphemeralConfig
withMigratedDatabaseConfig config options plan callback
-- | 'EphemeralPg.defaultConfig' with @temporaryRoot@ pinned to
-- @\/tmp\/ephpg-pg-migrate-\<uid\>@, created if missing.
--
-- An unset root resolves to @$TMPDIR@, which @nix develop@, @nix-shell@, and many CI
-- runners allocate per session; ephemeral-pg's startup sweep then never sees clusters
-- abandoned by earlier killed runs. A root that is stable per effective uid keeps the
-- sweep effective while keeping build sandboxes running as another uid out of a
-- developer-owned @0700@ directory. Extend this rather than 'EphemeralPg.defaultConfig'
-- when calling 'withMigratedDatabaseConfig'.
defaultEphemeralConfig :: IO EphemeralPg.Config
defaultEphemeralConfig = do
uid <- getEffectiveUserID
let root = "/tmp/ephpg-pg-migrate-" <> show uid
createDirectoryIfMissing True root
pure EphemeralPg.defaultConfig {EphemeralPg.Config.temporaryRoot = Last (Just root)}
-- | Fully configurable variant accepting both ephemeral database and migration options.
-- Asynchronous callback exceptions are rethrown after releasing the callback connection.
withMigratedDatabaseConfig ::
EphemeralPg.Config ->
RunOptions ->
MigrationPlan ->
(Connection.Connection -> IO value) ->
IO (Either MigratedDatabaseError value)
withMigratedDatabaseConfig config options plan callback = do
started <-
EphemeralPg.withConfig config $ \database -> do
let settings = EphemeralPg.connectionSettings database
migrated <- runMigrationPlan options settings plan
case migrated of
Left migrationError -> pure (Left (MigratedDatabaseMigrationFailed migrationError))
Right _ -> runCallback settings callback
pure $ case started of
Left startError -> Left (MigratedDatabaseStartupFailed startError)
Right result -> result
runCallback ::
Settings.Settings ->
(Connection.Connection -> IO value) ->
IO (Either MigratedDatabaseError value)
runCallback settings callback =
Exception.mask $ \restore -> do
acquired <- Connection.acquire settings
case acquired of
Left connectionError -> pure (Left (MigratedDatabaseCallbackAcquisitionFailed connectionError))
Right connection -> do
callbackResult <- Exception.try @SomeException (restore (callback connection))
cleanupResult <- Exception.try @SomeException (Connection.release connection)
case callbackResult of
Left callbackError
| Just _ <- Exception.fromException @Exception.SomeAsyncException callbackError ->
Exception.throwIO callbackError
_ ->
pure $ case (callbackResult, cleanupResult) of
(Right value, _) -> Right value
(Left callbackError, Right ()) -> Left (MigratedDatabaseCallbackFailed callbackError)
(Left callbackError, Left cleanupError) ->
Left (MigratedDatabaseCallbackAndCleanupFailed callbackError cleanupError)