packages feed

ephemeral-pg-0.2.2.0: README.md

# ephemeral-pg

A modern Haskell library for creating temporary PostgreSQL databases for testing.

## Features

- Native hasql integration
- initdb caching for fast startup
- Copy-on-write support (macOS/Linux)
- Filesystem snapshots
- No shell injection vulnerabilities
- Type-safe configuration

## Requirements

- GHC 9.6+
- PostgreSQL 14+

## Installation

Add to your `cabal` file:

```cabal
build-depends:
  ephemeral-pg
```

Or with Stack, add to your `package.yaml`:

```yaml
dependencies:
  - ephemeral-pg
```

## Quick Start

```haskell
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: " <> Pg.renderStartError err
    Right () -> putStrLn "Success!"
```

## Usage Examples

### Basic Usage

The simplest way to use ephemeral-pg is with the `with` function, which creates
a temporary database, runs your action, and cleans up automatically:

```haskell
import EphemeralPg qualified as Pg
import Hasql.Connection qualified as Connection
import Hasql.Session qualified as Session
import Hasql.Statement qualified as Statement

testQuery :: IO ()
testQuery = do
  result <- Pg.with \db -> do
    Right conn <- Connection.acquire (Pg.connectionSettings db)

    -- Run a simple query
    result <- Session.run (Session.statement () selectOne) conn

    Connection.release conn
    pure result

  case result of
    Left err -> putStrLn $ "Startup error: " <> Pg.renderStartError err
    Right (Left sessionErr) -> putStrLn $ "Query error: " <> show sessionErr
    Right (Right value) -> putStrLn $ "Result: " <> show value
  where
    selectOne = Statement.Statement "SELECT 1" mempty decoder True
    decoder = Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.int4))
```

### Custom Configuration

You can customize the database configuration:

```haskell
import EphemeralPg qualified as Pg
import EphemeralPg.Config qualified as Config

customTest :: IO ()
customTest = do
  let config = Pg.defaultConfig
        { Config.databaseName = "mytest"
        , Config.postgresSettings =
            [ ("log_statement", "'all'")
            , ("log_min_duration_statement", "0")
            ]
        }

  result <- Pg.withConfig config \db -> do
    -- Database name is "mytest"
    -- All statements are logged
    pure ()

  case result of
    Left err -> putStrLn $ Pg.renderStartError err
    Right () -> putStrLn "Done!"
```

### Using Verbose Configuration

For debugging, use the pre-configured verbose settings:

```haskell
import EphemeralPg qualified as Pg

debugTest :: IO ()
debugTest = do
  result <- Pg.withConfig Pg.verboseConfig \db -> do
    -- All statements logged with duration
    pure ()
  pure ()
```

### Using auto_explain

For query plan analysis:

```haskell
import EphemeralPg qualified as Pg

analyzeQueries :: IO ()
analyzeQueries = do
  result <- Pg.withConfig Pg.autoExplainConfig \db -> do
    -- Query plans automatically logged for slow queries
    pure ()
  pure ()
```

### Caching for Fast Startup

For faster test suite execution, use caching. The first run initializes the
cache, and subsequent runs copy from it (using CoW if available):

```haskell
import EphemeralPg qualified as Pg

cachedTest :: IO ()
cachedTest = do
  -- First call: ~2s (runs initdb and caches result)
  -- Subsequent calls: ~200ms (copies from cache)
  result <- Pg.withCached \db -> do
    -- Use the database...
    pure ()
  pure ()
```

### Suite-level fixtures with template databases

For larger integration suites where many examples need the same migrated
schema, consider starting one cached PostgreSQL server for the whole suite,
migrating a template database once, and cloning clean per-example databases
with PostgreSQL's `CREATE DATABASE ... TEMPLATE ...`.

See [Suite-level template databases](docs/suite-template-databases.md) for the
full pattern, tradeoffs, and a complete fixture implementation.

### Manual Lifecycle Management

For more control, use `start` and `stop` directly:

```haskell
import EphemeralPg qualified as Pg
import Control.Exception (bracket)

manualLifecycle :: IO ()
manualLifecycle = do
  result <- Pg.start Pg.defaultConfig
  case result of
    Left err -> putStrLn $ Pg.renderStartError err
    Right db -> do
      -- Use the database...
      putStrLn $ "Database running on port: " <> show db.port

      -- Clean up when done
      Pg.stop db
```

Or with bracket for exception safety:

```haskell
import EphemeralPg qualified as Pg
import Control.Exception (bracket)

bracketExample :: IO ()
bracketExample = do
  result <- Pg.start Pg.defaultConfig
  case result of
    Left err -> putStrLn $ Pg.renderStartError err
    Right db ->
      bracket (pure db) Pg.stop \db' -> do
        -- Use the database...
        pure ()
```

### Restarting the Database

You can restart the database to apply configuration changes or test recovery:

```haskell
import EphemeralPg qualified as Pg

restartTest :: IO ()
restartTest = do
  result <- Pg.with \db -> do
    let port1 = db.port

    -- Restart the server
    restartResult <- Pg.restart db
    case restartResult of
      Left err -> fail $ "Restart failed: " <> show err
      Right db' -> do
        -- Server restarted, data preserved
        let port2 = db'.port
        -- Port remains the same
        pure (port1 == port2)

  case result of
    Left err -> putStrLn $ Pg.renderStartError err
    Right same -> putStrLn $ "Ports same: " <> show same
```

### Snapshots

Create filesystem snapshots for fast test isolation:

```haskell
import EphemeralPg qualified as Pg
import EphemeralPg.Snapshot qualified as Snapshot

snapshotTest :: IO ()
snapshotTest = do
  result <- Pg.with \db -> do
    -- Set up initial state
    -- ... create tables, insert data ...

    -- Create a snapshot
    Right snapshot <- Snapshot.createSnapshot db

    -- Run a destructive test
    -- ... delete everything ...

    -- Restore to the snapshot
    Right () <- Snapshot.restoreSnapshot snapshot db

    -- Data is restored

    -- Clean up snapshot when done
    Snapshot.deleteSnapshot snapshot

    pure ()

  pure ()
```

### Dump and Restore

Export and import database contents:

```haskell
import EphemeralPg qualified as Pg
import EphemeralPg.Dump qualified as Dump

dumpTest :: IO ()
dumpTest = do
  result <- Pg.with \db -> do
    -- Set up data
    -- ...

    -- Dump to file
    Right () <- Dump.dump db "/tmp/backup.sql"

    pure ()

  -- Later, restore to a new database
  result2 <- Pg.with \db2 -> do
    Right () <- Dump.restore db2 "/tmp/backup.sql"
    -- Data is now in db2
    pure ()

  pure ()
```

## Configuration Reference

### Config Fields

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `databaseName` | `Text` | `"postgres"` | Name of the database to create |
| `user` | `Text` | Current user | PostgreSQL username |
| `password` | `Maybe Text` | `Nothing` | PostgreSQL password (`Nothing` = trust auth) |
| `port` | `Last Word16` | Auto-assigned | Port number (finds free port if not specified) |
| `dataDirectory` | `DirectoryConfig` | Temporary | Data directory location |
| `socketDirectory` | `DirectoryConfig` | Temporary | Unix socket directory |
| `temporaryRoot` | `Last FilePath` | System temp | Root for temporary directories |
| `postgresSettings` | `[(Text, Text)]` | Optimized defaults | postgresql.conf settings |
| `initDbArgs` | `[Text]` | `[]` | Additional initdb arguments |

### Pre-configured Configs

- `defaultConfig` - Basic configuration with optimized defaults
- `verboseConfig` - All statements logged with duration
- `autoExplainConfig` - Query plans logged for slow queries

### PostgreSQL Settings Defaults

The default configuration includes these optimized settings for testing:

```
shared_buffers = 12MB
fsync = off
synchronous_commit = off
full_page_writes = off
log_min_duration_statement = 0
log_connections = on
log_disconnections = on
random_page_cost = 1.0
```

## API Reference

### Core Functions

```haskell
-- Bracket-style (recommended)
with :: (Database -> IO a) -> IO (Either StartError a)
withConfig :: Config -> (Database -> IO a) -> IO (Either StartError a)

-- With caching
withCached :: (Database -> IO a) -> IO (Either StartError a)

-- Manual lifecycle
start :: Config -> IO (Either StartError Database)
stop :: Database -> IO ()
restart :: Database -> IO (Either StartError Database)
```

### Database Handle

Access database fields using dot syntax (requires `OverloadedRecordDot`):

```haskell
db.port             :: Word16       -- Port number
db.databaseName     :: Text         -- Database name
db.user             :: Text         -- Username
db.dataDirectory    :: FilePath     -- Data directory path
db.socketDirectory  :: FilePath     -- Socket directory path
```

Computed connection helpers:

```haskell
connectionSettings :: Database -> Settings  -- hasql Settings
connectionString :: Database -> Text         -- libpq connection string
```

### Cache Management

```haskell
clearCache :: IO ()      -- Clear current user's cache
clearAllCaches :: IO ()  -- Clear all cached data
```

## Benchmarks

Measured with [tasty-bench](https://hackage.haskell.org/package/tasty-bench) in wall-clock mode on Apple Silicon.

| Benchmark | Time | vs baseline |
|---|---|---|
| **Lifecycle** | | |
| `defaultConfig` | 796 ms | -- |
| `defaultConfig <> verboseConfig` | 812 ms | 1.02x |
| `defaultConfig <> autoExplainConfig 100` | 769 ms | 0.97x |
| **Caching** | | |
| `withConfig` (uncached) | 755 ms | -- |
| `withCached` | 434 ms | 0.58x |
| **Snapshot** | | |
| lifecycle only | 761 ms | -- |
| lifecycle + `createSnapshot` | 1.13 s | 1.48x |
| lifecycle + `createSnapshot` + `restoreSnapshot` | 1.39 s | 1.83x |
| **Connection** | | |
| hasql `acquire` + `release` | 1.64 ms | -- |

Highlights:
- Cached startup is ~42% faster than uncached (CoW copy vs full initdb)
- `verboseConfig` and `autoExplainConfig` add negligible overhead
- Snapshot create adds ~365 ms, restore adds ~265 ms
- Connection acquire/release is ~1.6 ms once the database is running

## Comparison with tmp-postgres

ephemeral-pg is a modern replacement for tmp-postgres, addressing several issues:

| Feature | tmp-postgres | ephemeral-pg |
|---------|-------------|--------------|
| Shell injection | Vulnerable | Safe (typed-process) |
| Windows support | Partial | Unix-only (explicit) |
| Socket path length | Can fail | Validated |
| GHC version | 8.0+ | 9.6+ |
| hasql integration | Via options | Native |
| initdb caching | Yes | Yes (improved) |
| Copy-on-write | Yes | Yes |
| PostgreSQL version | 9.3+ | 14+ |

## Troubleshooting

### "initdb not found"

Ensure PostgreSQL binaries are in your PATH:

```bash
# macOS (Homebrew)
export PATH="/opt/homebrew/opt/postgresql@14/bin:$PATH"

# Linux (Debian/Ubuntu)
export PATH="/usr/lib/postgresql/14/bin:$PATH"
```

### Socket path too long

Unix sockets have a path length limit (~104 bytes). ephemeral-pg uses short
paths to avoid this, but if you specify a custom socket directory, ensure
the full path is under 90 characters.

### Permission denied

Ensure you have write access to the temporary directory. By default, the
system temp directory is used.

## Tracing

OpenTelemetry tracing is available through the optional companion
package `ephemeral-pg-opentelemetry`, which lives next to this library
in the same repository. It mirrors the `EphemeralPg` lifecycle API and
emits one span per public operation:

| Wrapper                      | Span name                       |
| ---------------------------- | ------------------------------- |
| `withTraced`                 | `ephemeralpg.with`              |
| `withCachedTraced`           | `ephemeralpg.with_cached`       |
| `startTraced`                | `ephemeralpg.start`             |
| `stopTraced`                 | `ephemeralpg.stop`              |
| `restartTraced`              | `ephemeralpg.restart`           |
| `createSnapshotTraced`       | `ephemeralpg.snapshot.create`   |
| `restoreSnapshotTraced`      | `ephemeralpg.snapshot.restore`  |
| `deleteSnapshotTraced`       | `ephemeralpg.snapshot.delete`   |
| `dumpTraced`                 | `ephemeralpg.dump`              |
| `restoreTraced`              | `ephemeralpg.restore`           |

Each span carries the standard database attributes
(`db.system.name`/`db.system`, `db.namespace`/`db.name`) plus
library-specific ones (`ephemeralpg.port`,
`ephemeralpg.shutdown.mode`). Attribute name selection obeys
`OTEL_SEMCONV_STABILITY_OPT_IN` exactly the way upstream HTTP
instrumentation does — set it to `http` for stable names, `http/dup`
for both stable and legacy. Errors from `EphemeralPg.start` are
recorded uniformly with `error.type` (constructor name), span status
`Error`, and a `recordException` event.

The wrappers are designed to nest under a parent test span. Combined
with `hs-opentelemetry-instrumentation-hspec`, a test that calls
`withTraced` produces a tree of the form:

    Run tests
      └─ ephemeral-pg under OpenTelemetry
         └─ emits a span tree under withTraced
            └─ ephemeralpg.with
               ├─ ephemeralpg.start
               ├─ ephemeralpg.with.body
               └─ ephemeralpg.stop

Add the dependency to your test-suite (the package is not yet on
Hackage; reference it via the local `cabal.project`):

```cabal
build-depends:
  ephemeral-pg-opentelemetry,
  hs-opentelemetry-api,
  hs-opentelemetry-sdk,
  hs-opentelemetry-instrumentation-hspec,
```

See `ephemeral-pg-opentelemetry/test/Demo.hs` for a runnable example.

## License

BSD-3-Clause