packages feed

ephemeral-pg-0.1.0.0: 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 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.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
    snapshotPath :: FilePath,
    -- | Whether to delete the snapshot when done
    snapshotTemporary :: 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 = do
  -- Stop postgres gracefully to ensure data is flushed
  stopResult <- stopPostgres (dbProcess db) ShutdownGraceful 30
  case stopResult of
    Just err -> do
      -- Restart postgres and return error
      _ <- restartPostgres db
      pure $ Left $ "Failed to stop postgres for snapshot: " <> T.pack (show err)
    Nothing -> do
      -- Create snapshot directory
      tmpDir <- getCanonicalTemporaryDirectory
      snapshotDir <- createTempDirectory tmpDir "ephpg-snap-"

      -- Detect CoW capability
      cowCapability <- detectCowCapability snapshotDir

      -- Copy data directory to snapshot
      copyResult <- copyDirectory cowCapability (dbDataDirectory db) snapshotDir

      case copyResult of
        Left err -> do
          removeDirectoryIfExists snapshotDir
          _ <- restartPostgres db
          pure $ Left $ "Failed to copy data directory: " <> err
        Right () -> do
          -- Clean up runtime files from the snapshot
          cleanupRuntimeFiles snapshotDir

          -- Restart postgres
          restartResult <- restartPostgres db
          case restartResult of
            Left err -> do
              removeDirectoryIfExists snapshotDir
              pure $ Left err
            Right _newProcess ->
              -- Note: We don't update the Database record with the new process
              -- since Database is immutable. The caller should handle this.
              pure $
                Right $
                  Snapshot
                    { snapshotPath = snapshotDir,
                      snapshotTemporary = 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 = do
  -- Verify snapshot exists
  exists <- doesDirectoryExist (snapshotPath snapshot)
  if not exists
    then pure $ Left $ "Snapshot not found: " <> T.pack (snapshotPath snapshot)
    else do
      -- Stop postgres
      stopResult <- stopPostgres (dbProcess db) ShutdownGraceful 30
      case stopResult of
        Just err -> do
          _ <- restartPostgres db
          pure $ Left $ "Failed to stop postgres for restore: " <> T.pack (show err)
        Nothing -> do
          -- Remove current data directory
          removeDirectoryIfExists (dbDataDirectory db)

          -- Detect CoW capability
          cowCapability <- detectCowCapability (snapshotPath snapshot)

          -- Copy snapshot to data directory
          copyResult <- copyDirectory cowCapability (snapshotPath snapshot) (dbDataDirectory db)

          case copyResult of
            Left err -> do
              -- This is bad - data directory is in inconsistent state
              pure $ Left $ "Failed to restore snapshot, database may be corrupted: " <> err
            Right () -> do
              -- Restart postgres
              restartResult <- restartPostgres db
              case restartResult of
                Left err -> pure $ Left err
                Right _newProcess -> pure $ Right ()

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

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