packages feed

ephemeral-pg-0.2.0.0: src/EphemeralPg.hs

-- | Temporary PostgreSQL databases for testing.
--
-- This module provides functions for creating isolated, temporary PostgreSQL
-- instances for testing purposes. Databases are automatically cleaned up
-- when they go out of scope.
--
-- = Quick Start
--
-- @
-- import EphemeralPg qualified as Pg
-- import Hasql.Connection qualified as Connection
--
-- main :: IO ()
-- main = do
--   result <- Pg.'with' $ \\db -> do
--     Right conn <- Connection.acquire (Pg.'connectionSettings' db)
--     -- Use the connection...
--     Connection.release conn
--   case result of
--     Left err -> putStrLn $ "Error: " <> show err
--     Right () -> putStrLn "Success!"
-- @
--
-- = Custom Configuration
--
-- @
-- import EphemeralPg qualified as Pg
--
-- main :: IO ()
-- main = do
--   let config = Pg.'defaultConfig' { Pg.databaseName = "testdb" }
--   Pg.'withConfig' config $ \\db -> do
--     -- Use the database...
--     pure ()
-- @
module EphemeralPg
  ( -- * Database Handle
    Database (..),
    connectionSettings,
    connectionString,

    -- * Lifecycle Management
    with,
    withConfig,
    withCached,
    start,
    startCached,
    stop,
    restart,

    -- * Configuration
    Config (..),
    DirectoryConfig (..),
    ShutdownMode (..),
    defaultConfig,
    verboseConfig,
    autoExplainConfig,

    -- * Cache Management
    CacheConfig (..),
    defaultCacheConfig,
    clearCache,
    clearAllCaches,

    -- * Errors
    StartError (..),
    StopError (..),
    renderStartError,
    renderStopError,
  )
where

import Control.Exception (mask, onException)
import Control.Monad (when)
import Control.Monad.IO.Class (liftIO)
import Data.Monoid (Last (..))
import Data.Text (Text)
import Data.Word (Word16)
import EphemeralPg.Config
  ( Config (..),
    DirectoryConfig (..),
    ShutdownMode (..),
    autoExplainConfig,
    defaultConfig,
    defaultShutdownTimeoutSeconds,
    verboseConfig,
  )
import EphemeralPg.Database
  ( Database (..),
    connectionSettings,
    connectionString,
  )
import EphemeralPg.Error
  ( StartError (..),
    StopError (..),
    renderStartError,
    renderStopError,
  )
import EphemeralPg.Internal.Cache
  ( CacheConfig (..),
    CacheKey,
    cleanupRuntimeFiles,
    clearAllCaches,
    clearCache,
    createCache,
    defaultCacheConfig,
    getCacheKey,
    isCached,
    restoreFromCache,
  )
import EphemeralPg.Internal.Directory
  ( createTempDataDirectory,
    createTempSocketDirectory,
    removeDirectoryIfExists,
    resolveDirectory,
    retryRemoveDirectory,
  )
import EphemeralPg.Internal.Except (liftE, onError, runStartup)
import EphemeralPg.Internal.Port (findFreePort)
import EphemeralPg.Process (getCurrentUser)
import EphemeralPg.Process.CreateDb (runCreateDb)
import EphemeralPg.Process.InitDb (runInitDb, writePostgresConf)
import EphemeralPg.Process.Postgres (startPostgres, stopPostgres)

-- | Create a temporary database with default configuration, run an action, then clean up.
--
-- This is the recommended way to use ephemeral-pg. The database is
-- guaranteed to be stopped and cleaned up even if an exception is thrown.
--
-- @
-- result <- 'with' $ \\db -> do
--   conn <- Connection.acquire ('connectionSettings' db)
--   -- Use the connection...
-- @
with :: (Database -> IO a) -> IO (Either StartError a)
with = withConfig defaultConfig

-- | Like 'with' but with custom configuration.
--
-- @
-- let config = 'defaultConfig' { databaseName = "testdb" }
-- 'withConfig' config $ \\db -> do
--   -- Use the database...
-- @
withConfig :: Config -> (Database -> IO a) -> IO (Either StartError a)
withConfig config action = mask $ \restore -> do
  result <- start config
  case result of
    Left err -> pure (Left err)
    Right db -> do
      a <- restore (action db) `onException` stop db
      stop db
      pure (Right a)

-- | Start a temporary database.
--
-- You are responsible for calling 'stop' when done. Prefer 'with' or
-- 'withConfig' when possible.
--
-- @
-- db <- 'start' 'defaultConfig'
-- case db of
--   Right database -> do
--     -- Use database...
--     'stop' database
--   Left err -> handleError err
-- @
start :: Config -> IO (Either StartError Database)
start config = runStartup $ do
  let mTempRoot = getLast config.temporaryRoot

  -- Create data directory
  (dataDir, dataDirIsTemp) <-
    liftE $
      resolveDirectory
        config.dataDirectory
        mTempRoot
        "data"
        createTempDataDirectory

  -- Create socket directory
  (socketDir, socketDirIsTemp) <-
    liftE
      ( resolveDirectory
          config.socketDirectory
          mTempRoot
          "socket"
          createTempSocketDirectory
      )
      `onError` when dataDirIsTemp (removeDirectoryIfExists dataDir)

  -- Get port
  p <-
    liftE (getPort config)
      `onError` cleanup dataDirIsTemp dataDir socketDirIsTemp socketDir

  -- Get username
  username <- liftIO $ getUsername config

  -- Run initdb
  () <-
    liftE (runInitDb config dataDir)
      `onError` cleanup dataDirIsTemp dataDir socketDirIsTemp socketDir

  -- Start postgres
  pgProcess <-
    liftE (startPostgres config dataDir socketDir p username)
      `onError` cleanup dataDirIsTemp dataDir socketDirIsTemp socketDir

  -- Create database
  let dbName = config.databaseName
  () <-
    liftE (runCreateDb config socketDir p username dbName)
      `onError` do
        _ <- stopPostgres pgProcess ShutdownImmediate 5
        cleanup dataDirIsTemp dataDir socketDirIsTemp socketDir

  -- Build cleanup action
  let cleanupAction = do
        when dataDirIsTemp $ do
          -- Use retry to handle pg_stat race
          _ <- retryRemoveDirectory dataDir 5 100000
          pure ()
        when socketDirIsTemp $
          removeDirectoryIfExists socketDir

  pure $
    Database
      { dataDirectory = dataDir,
        socketDirectory = socketDir,
        port = p,
        databaseName = dbName,
        user = username,
        password = config.password,
        process = pgProcess,
        cleanup = cleanupAction,
        dataDirIsTemp = dataDirIsTemp,
        socketDirIsTemp = socketDirIsTemp
      }
  where
    cleanup :: Bool -> FilePath -> Bool -> FilePath -> IO ()
    cleanup dataDirIsTemp dataDir socketDirIsTemp socketDir = do
      when dataDirIsTemp $ removeDirectoryIfExists dataDir
      when socketDirIsTemp $ removeDirectoryIfExists socketDir

-- | Get port from config or find a free one.
getPort :: Config -> IO (Either StartError Word16)
getPort config = case getLast config.port of
  Just p -> pure $ Right p
  Nothing -> findFreePort

-- | Get username from config or current user.
getUsername :: Config -> IO Text
getUsername config = case config.user of
  "" -> getCurrentUser
  u -> pure u

-- | Stop a database and clean up resources.
--
-- This sends SIGTERM to postgres and waits for graceful shutdown,
-- then removes temporary directories.
--
-- Safe to call multiple times (subsequent calls are no-ops).
stop :: Database -> IO ()
stop db = do
  let mode = ShutdownGraceful
  let timeoutSecs = defaultShutdownTimeoutSeconds

  -- Stop postgres
  _ <- stopPostgres db.process mode timeoutSecs

  -- Run cleanup (removes temp directories)
  db.cleanup

-- | Restart a database.
--
-- This stops the postgres server and starts it again, returning a new
-- 'Database' handle with the updated process information.
--
-- The data directory and all database contents are preserved.
-- This is useful for testing scenarios that require a server restart,
-- such as configuration changes that require a restart to take effect.
--
-- @
-- db <- 'start' 'defaultConfig'
-- case db of
--   Right database -> do
--     -- Use database...
--     newDb <- 'restart' database
--     case newDb of
--       Right database' -> -- Use restarted database...
--       Left err -> handleError err
--   Left err -> handleError err
-- @
restart :: Database -> IO (Either StartError Database)
restart db = runStartup $ do
  -- Stop postgres gracefully
  liftIO $ do
    _ <- stopPostgres db.process ShutdownGraceful defaultShutdownTimeoutSeconds
    pure ()

  -- Start postgres again with the same configuration
  newProcess <-
    liftE $
      startPostgres
        defaultConfig
        db.dataDirectory
        db.socketDirectory
        db.port
        db.user

  pure $ db {process = newProcess}

-- | Like 'with' but uses initdb caching for faster startup.
--
-- The first invocation runs initdb and caches the result.
-- Subsequent invocations copy from the cache (using CoW if available).
--
-- @
-- result <- 'withCached' $ \\db -> do
--   conn <- Connection.acquire ('connectionSettings' db)
--   -- Use the connection...
-- @
withCached :: (Database -> IO a) -> IO (Either StartError a)
withCached = withCachedConfig defaultConfig defaultCacheConfig

-- | Like 'withCached' but with custom configuration.
withCachedConfig :: Config -> CacheConfig -> (Database -> IO a) -> IO (Either StartError a)
withCachedConfig config cacheConfig action = mask $ \restore -> do
  result <- startCached config cacheConfig
  case result of
    Left err -> pure (Left err)
    Right db -> do
      a <- restore (action db) `onException` stop db
      stop db
      pure (Right a)

-- | Start a temporary database using initdb caching.
--
-- If caching is enabled and a cache exists, the data directory is copied
-- from the cache. Otherwise, initdb is run and the result is cached.
startCached :: Config -> CacheConfig -> IO (Either StartError Database)
startCached config cacheConfig
  | not cacheConfig.enabled = start config
  | otherwise = do
      -- Get cache key
      keyResult <- getCacheKey config
      case keyResult of
        Left _err ->
          -- Can't determine cache key, fall back to non-cached start
          start config
        Right cacheKey -> do
          -- Check if cache exists
          cached <- isCached cacheKey cacheConfig.root
          if cached
            then startFromCache config cacheConfig cacheKey
            else startAndCache config cacheConfig cacheKey

-- | Start from an existing cache.
startFromCache :: Config -> CacheConfig -> CacheKey -> IO (Either StartError Database)
startFromCache config cacheConfig cacheKey = do
  let mTempRoot = getLast config.temporaryRoot

  -- Create temporary data directory (to get the path)
  dataResult <- createTempDataDirectory mTempRoot
  case dataResult of
    Left err -> pure $ Left err
    Right (dataDir, dataDirIsTemp) -> do
      -- Remove the directory so cp can create it fresh
      -- (otherwise cp -cR creates nested directories on macOS)
      removeDirectoryIfExists dataDir

      -- Restore from cache
      restoreResult <- restoreFromCache cacheKey dataDir cacheConfig.root
      case restoreResult of
        Left _err -> do
          -- Cache restore failed, fall back to non-cached start
          removeDirectoryIfExists dataDir
          start config
        Right () -> do
          -- Clean up any runtime files from the cache (postmaster.pid, etc.)
          cleanupRuntimeFiles dataDir

          -- Write postgresql.conf (cache doesn't include our custom settings)
          writePostgresConf config dataDir

          -- Continue with normal startup from the restored data directory
          continueStartup config dataDir dataDirIsTemp

-- | Start normally and cache the result.
-- Cache is created after initdb but before postgres starts.
startAndCache :: Config -> CacheConfig -> CacheKey -> IO (Either StartError Database)
startAndCache config cacheConfig cacheKey = do
  let mTempRoot = getLast config.temporaryRoot

  -- Create data directory
  dataResult <- createTempDataDirectory mTempRoot
  case dataResult of
    Left err -> pure $ Left err
    Right (dataDir, dataDirIsTemp) -> do
      -- Run initdb
      initResult <- runInitDb config dataDir
      case initResult of
        Left err -> do
          when dataDirIsTemp $ removeDirectoryIfExists dataDir
          pure $ Left err
        Right () -> do
          -- Cache the data directory NOW (before postgres starts)
          -- This ensures the cache contains only clean initdb output
          when dataDirIsTemp $ do
            _ <- createCache cacheKey dataDir cacheConfig.root
            pure ()

          -- Continue with normal startup from the initialized data directory
          continueStartup config dataDir dataDirIsTemp

-- | Continue startup from an existing data directory.
continueStartup :: Config -> FilePath -> Bool -> IO (Either StartError Database)
continueStartup config dataDir dataDirIsTemp = runStartup $ do
  let mTempRoot = getLast config.temporaryRoot

  -- Create socket directory
  (socketDir, socketDirIsTemp) <-
    liftE
      ( resolveDirectory
          config.socketDirectory
          mTempRoot
          "socket"
          createTempSocketDirectory
      )
      `onError` when dataDirIsTemp (removeDirectoryIfExists dataDir)

  -- Get port
  p <-
    liftE (getPort config)
      `onError` cleanupDirs dataDirIsTemp dataDir socketDirIsTemp socketDir

  -- Get username
  username <- liftIO $ getUsername config

  -- Start postgres (initdb already done)
  pgProcess <-
    liftE (startPostgres config dataDir socketDir p username)
      `onError` cleanupDirs dataDirIsTemp dataDir socketDirIsTemp socketDir

  -- Create database
  let dbName = config.databaseName
  () <-
    liftE (runCreateDb config socketDir p username dbName)
      `onError` do
        _ <- stopPostgres pgProcess ShutdownImmediate 5
        cleanupDirs dataDirIsTemp dataDir socketDirIsTemp socketDir

  -- Build cleanup action
  let cleanupAction = do
        when dataDirIsTemp $ do
          _ <- retryRemoveDirectory dataDir 5 100000
          pure ()
        when socketDirIsTemp $
          removeDirectoryIfExists socketDir

  pure $
    Database
      { dataDirectory = dataDir,
        socketDirectory = socketDir,
        port = p,
        databaseName = dbName,
        user = username,
        password = config.password,
        process = pgProcess,
        cleanup = cleanupAction,
        dataDirIsTemp = dataDirIsTemp,
        socketDirIsTemp = socketDirIsTemp
      }
  where
    cleanupDirs :: Bool -> FilePath -> Bool -> FilePath -> IO ()
    cleanupDirs dataDirIsTemp' dataDir' socketDirIsTemp' socketDir' = do
      when dataDirIsTemp' $ removeDirectoryIfExists dataDir'
      when socketDirIsTemp' $ removeDirectoryIfExists socketDir'