packages feed

shomei-postgres-0.2.0.0: src/Shomei/Persistence/Database/Postgres.hs

-- | The @Database@ effect: a thin @effectful@ wrapper over a @hasql@ connection pool.
-- Interpreters run a 'Session' (or a 'Transaction') and surface a @Left UsageError@ for
-- the caller to translate with 'postgresUnavailable'.
module Shomei.Persistence.Database.Postgres
  ( Database (..),
    runSession,
    runTransaction,
    postgresUnavailable,
    uniqueViolation,
    postgresWriteError,
    runDatabasePool,
  )
where

import Data.Text (Text)
import Data.Text qualified as Text
import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, liftIO, (:>))
import Effectful.Dispatch.Dynamic (interpret_, send)
import Hasql.Errors qualified as Hasql
import Hasql.Pool (Pool, UsageError)
import Hasql.Pool qualified as Pool
import Hasql.Session (Session)
import Hasql.Transaction (Transaction)
import Hasql.Transaction.Sessions qualified as Tx
import Shomei.Error (AuthDependency (PostgreSQL), AuthError (DependencyUnavailable))

data Database :: Effect where
  RunSession :: Session a -> Database m (Either UsageError a)
  RunTransaction :: Transaction a -> Database m (Either UsageError a)

type instance DispatchOf Database = Dynamic

runSession :: (Database :> es) => Session a -> Eff es (Either UsageError a)
runSession = send . RunSession

runTransaction :: (Database :> es) => Transaction a -> Eff es (Either UsageError a)
runTransaction = send . RunTransaction

-- | Collapse all Hasql execution details to the one typed dependency failure.
-- Driver messages and SQL must never cross the persistence boundary.
postgresUnavailable :: UsageError -> AuthError
postgresUnavailable _ = DependencyUnavailable PostgreSQL

-- | The constraint name of a PostgreSQL unique violation (SQLSTATE 23505), when the server
-- supplied one in its primary message. Other failures deliberately remain opaque.
uniqueViolation :: UsageError -> Maybe Text
uniqueViolation = \case
  Pool.SessionUsageError
    ( Hasql.StatementSessionError
        _
        _
        _
        _
        _
        (Hasql.ServerStatementError (Hasql.ServerError "23505" message _ _ _))
      ) -> constraintName message
  _ -> Nothing

-- | Preserve a domain conflict for recognized unique indexes; otherwise keep the existing
-- fail-closed dependency error and never expose SQL or driver text.
postgresWriteError :: (Text -> Maybe AuthError) -> UsageError -> AuthError
postgresWriteError classify err =
  maybe (postgresUnavailable err) id (uniqueViolation err >>= classify)

constraintName :: Text -> Maybe Text
constraintName message =
  case Text.breakOn marker message of
    (_, rest)
      | Text.null rest -> Nothing
      | otherwise ->
          case Text.breakOn "\"" (Text.drop (Text.length marker) rest) of
            (name, closing)
              | not (Text.null name) && not (Text.null closing) -> Just name
            _ -> Nothing
  where
    marker = "unique constraint \""

-- | Interpret @Database@ against a concrete @hasql@ 'Pool'. Transactions run
-- read-committed, read-write (with @hasql-transaction@'s automatic retry on
-- serialization failures).
runDatabasePool :: (IOE :> es) => Pool -> Eff (Database : es) a -> Eff es a
runDatabasePool pool = interpret_ \case
  RunSession sess -> liftIO (Pool.use pool sess)
  RunTransaction t -> liftIO (Pool.use pool (Tx.transaction Tx.ReadCommitted Tx.Write t))