packages feed

ephemeral-pg-0.2.0.1: src/EphemeralPg/Snapshot.hs

-- | Database snapshot functionality for test isolation.
--
-- This module provides snapshot and restore capabilities for ephemeral
-- PostgreSQL databases. Snapshots capture the entire database state
-- and can be restored to reset the database between tests.
--
-- = Example Usage
--
-- @
-- result <- Pg.'with' $ \\db -> do
--   -- Initial setup
--   runMigrations db
--
--   -- Take a snapshot after setup
--   snapshot <- Pg.'createSnapshot' db
--
--   -- Run test 1
--   insertTestData db
--   runTest1 db
--
--   -- Restore to clean state
--   Pg.'restoreSnapshot' snapshot db
--
--   -- Run test 2 with clean state
--   insertTestData db
--   runTest2 db
-- @
module EphemeralPg.Snapshot
  ( -- * Snapshot Types
    Snapshot (..),

    -- * Snapshot Operations
    createSnapshot,
    restoreSnapshot,
    deleteSnapshot,
  )
where

import Control.Monad (unless, void)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (ExceptT (..), runExceptT, throwE, withExceptT)
import Data.Text (Text)
import Data.Text qualified as T
import EphemeralPg.Config (ShutdownMode (..), defaultConfig)
import EphemeralPg.Database (Database (..), PostgresProcess (..))
import EphemeralPg.Internal.Cache (cleanupRuntimeFiles)
import EphemeralPg.Internal.CopyOnWrite
  ( copyDirectory,
    detectCowCapability,
  )
import EphemeralPg.Internal.Directory (removeDirectoryIfExists)
import EphemeralPg.Internal.Except (onError)
import EphemeralPg.Process.Postgres (startPostgres, stopPostgres)
import System.Directory (doesDirectoryExist)
import System.IO.Temp (createTempDirectory, getCanonicalTemporaryDirectory)

-- | A snapshot of database state.
data Snapshot = Snapshot
  { -- | Path to the snapshot data directory
    path :: FilePath,
    -- | Whether to delete the snapshot when done
    temporary :: Bool
  }
  deriving stock (Eq, Show)

-- | Create a snapshot of the current database state.
--
-- This stops postgres, copies the data directory using copy-on-write
-- if available, then restarts postgres.
--
-- Note: This is a relatively expensive operation and should be used
-- sparingly (e.g., once after setup, not between every test).
createSnapshot :: Database -> IO (Either Text Snapshot)
createSnapshot db = runExceptT $ do
  stopPostgresE "snapshot" db
    `onError` void (restartPostgres db)
  snapshotDir <- liftIO $ do
    tmpDir <- getCanonicalTemporaryDirectory
    createTempDirectory tmpDir "ephpg-snap-"
  cowCapability <- liftIO $ detectCowCapability snapshotDir
  withExceptT
    ("Failed to copy data directory: " <>)
    (ExceptT $ copyDirectory cowCapability db.dataDirectory snapshotDir)
    `onError` do
      removeDirectoryIfExists snapshotDir
      void $ restartPostgres db
  liftIO $ cleanupRuntimeFiles snapshotDir
  -- Note: We don't update the Database record with the new process
  -- since Database is immutable. The caller should handle this.
  _ <-
    ExceptT (restartPostgres db)
      `onError` removeDirectoryIfExists snapshotDir
  pure
    Snapshot
      { path = snapshotDir,
        temporary = True
      }

-- | Restore a database from a snapshot.
--
-- This stops postgres, replaces the data directory with the snapshot,
-- then restarts postgres. The original database content is lost.
restoreSnapshot :: Snapshot -> Database -> IO (Either Text ())
restoreSnapshot snapshot db = runExceptT $ do
  exists <- liftIO $ doesDirectoryExist snapshot.path
  unless exists $
    throwE $
      "Snapshot not found: " <> T.pack snapshot.path
  stopPostgresE "restore" db
    `onError` void (restartPostgres db)
  liftIO $ removeDirectoryIfExists db.dataDirectory
  cowCapability <- liftIO $ detectCowCapability snapshot.path
  withExceptT
    ("Failed to restore snapshot, database may be corrupted: " <>)
    (ExceptT $ copyDirectory cowCapability snapshot.path db.dataDirectory)
  _ <- ExceptT $ restartPostgres db
  pure ()

-- | Delete a snapshot.
--
-- Only deletes temporary snapshots. Permanent snapshots are left alone.
deleteSnapshot :: Snapshot -> IO ()
deleteSnapshot snapshot =
  if snapshot.temporary
    then removeDirectoryIfExists snapshot.path
    else pure ()

-- | Stop postgres, failing with a descriptive error.
stopPostgresE :: Text -> Database -> ExceptT Text IO ()
stopPostgresE purpose db = do
  stopResult <- liftIO $ stopPostgres db.process ShutdownGraceful 30
  case stopResult of
    Just err ->
      throwE $ "Failed to stop postgres for " <> purpose <> ": " <> T.pack (show err)
    Nothing -> pure ()

-- | Restart postgres after a snapshot/restore operation.
restartPostgres :: Database -> IO (Either Text PostgresProcess)
restartPostgres db = do
  result <-
    startPostgres
      defaultConfig
      db.dataDirectory
      db.socketDirectory
      db.port
      db.user
  case result of
    Left err -> pure $ Left $ "Failed to restart postgres: " <> T.pack (show err)
    Right newProcess -> pure $ Right newProcess