packages feed

atelier-db (empty) → 0.1.0.0

raw patch · 7 files changed

+411/−0 lines, 7 filesdep +aesondep +atelier-coredep +atelier-prelude

Dependencies added: aeson, atelier-core, atelier-prelude, base, bytestring, containers, data-default, effectful, effectful-core, effectful-plugin, effectful-th, hasql, hasql-pool, hasql-transaction, rel8, text, time

Files

+ CHANGELOG.md view
@@ -0,0 +1,13 @@+# Changelog++All notable changes to `atelier-db` will be documented in this file.++The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),+and this project adheres to the [PVP](https://pvp.haskell.org/).++## [Unreleased]++### Added++- Initial release: a relational database effect (Hasql/Rel8) for the atelier+  toolkit.
+ LICENSE view
@@ -0,0 +1,21 @@+MIT License++Copyright (c) 2025 Christian Georgii++Permission is hereby granted, free of charge, to any person obtaining a copy+of this software and associated documentation files (the "Software"), to deal+in the Software without restriction, including without limitation the rights+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+copies of the Software, and to permit persons to whom the Software is+furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE+SOFTWARE.
+ README.md view
@@ -0,0 +1,24 @@+# atelier-db++Relational database access via [Hasql](https://github.com/nikita-volkov/hasql) and [Rel8](https://github.com/circuithub/rel8), exposed as an [Effectful](https://github.com/haskell-effectful/effectful) effect. Part of the **atelier** toolkit.++## Overview++`atelier-db` exposes database access as a first-class effect so queries can be interpreted, mocked, and composed alongside the rest of your application's effects.++| Module | Purpose |+|---|---|+| `Atelier.Effects.DB` | The `DB` effect and its interpreters |+| `Atelier.Effects.DB.Config` | Connection configuration |+| `Atelier.Effects.DB.Rel8` | Rel8 query helpers over the `DB` effect |++## Part of atelier++- [`atelier-prelude`](https://github.com/atelier-hub/tricorder/tree/main/atelier-prelude) — relude-based prelude with Effectful conventions+- [`atelier-core`](https://github.com/atelier-hub/tricorder/tree/main/atelier-core) — foundational effects and utilities+- [`atelier-db`](https://github.com/atelier-hub/tricorder/tree/main/atelier-db) — this package+- [`atelier-testing`](https://github.com/atelier-hub/tricorder/tree/main/atelier-testing) — database-backed test utilities++## License++MIT — see [LICENSE](LICENSE).
+ atelier-db.cabal view
@@ -0,0 +1,76 @@+cabal-version: 2.0++-- This file has been generated from package.yaml by hpack version 0.38.2.+--+-- see: https://github.com/sol/hpack++name:           atelier-db+version:        0.1.0.0+synopsis:       Relational database effect for atelier (Hasql/Rel8)+description:    Relational database access via Hasql and Rel8, exposed as an Effectful effect — part of the atelier toolkit.+category:       Database+homepage:       https://github.com/atelier-hub/tricorder#readme+bug-reports:    https://github.com/atelier-hub/tricorder/issues+author:         Christian Georgii+maintainer:     christian.georgii@tweag.io+license:        MIT+license-file:   LICENSE+build-type:     Simple+extra-doc-files:+    CHANGELOG.md+    README.md++source-repository head+  type: git+  location: https://github.com/atelier-hub/tricorder++library+  exposed-modules:+      Atelier.Effects.DB+      Atelier.Effects.DB.Config+      Atelier.Effects.DB.Rel8+  other-modules:+      Paths_atelier_db+  autogen-modules:+      Paths_atelier_db+  hs-source-dirs:+      src+  default-extensions:+      BlockArguments+      DataKinds+      DeriveAnyClass+      DerivingStrategies+      DerivingVia+      DuplicateRecordFields+      FlexibleContexts+      GADTs+      LambdaCase+      MultiWayIf+      OverloadedLabels+      OverloadedRecordDot+      OverloadedStrings+      StrictData+      TemplateHaskell+      TypeFamilies+  ghc-options: -Weverything -Wno-unsafe -Wno-missing-safe-haskell-mode -Wno-monomorphism-restriction -Wno-missing-kind-signatures -Wno-missing-poly-kind-signatures -Wno-missing-role-annotations -Wno-missing-local-signatures -Wno-missing-import-lists -Wno-implicit-prelude -Wno-unticked-promoted-constructors -Wno-unused-packages -Wno-all-missed-specialisations -Wno-missed-specialisations -fplugin=Effectful.Plugin -threaded+  build-depends:+      aeson ==2.2.*+    , atelier-core ==0.1.*+    , atelier-prelude ==0.1.*+    , base ==4.20.*+    , bytestring ==0.12.*+    , containers ==0.7.*+    , data-default ==0.8.*+    , effectful ==2.6.*+    , effectful-core ==2.6.*+    , effectful-plugin ==2.0.*+    , effectful-th ==1.0.*+    , hasql ==1.9.*+    , hasql-pool ==1.3.*+    , hasql-transaction ==1.2.*+    , rel8 ==1.7.*+    , text ==2.1.*+    , time ==1.12.*+  mixins:+      base hiding (Prelude)+  default-language: GHC2021
+ src/Atelier/Effects/DB.hs view
@@ -0,0 +1,116 @@+-- | Database access effects for Hasql connection pools.+--+-- 'DBRead' runs read-only statements against a reader pool; 'DBWrite' runs+-- transactions against a writer pool. Both record OpenTelemetry spans, and+-- 'DBRead' additionally emits Prometheus metrics (named via+-- 'DBReadMetricNames'). Failures surface through the @Error Text@ effect.+-- 'runDB' installs both effects over a shared 'DBPools'.+module Atelier.Effects.DB+    ( DBRead+    , DBWrite+    , runQuery+    , runTransaction+    , runDBRead+    , runDBWrite+    , runDB+    , DBReadMetricNames (..)+    )+where++import Atelier.Effects.Monitoring.Metrics (Metrics, counterInc, withHistogramTiming)+import Atelier.Effects.Monitoring.Tracing (SpanStatus (..), Tracing, addAttribute, setStatus, withSpan)+import Effectful (Effect, IOE)+import Effectful.Dispatch.Dynamic (interpretWith_)+import Effectful.Error.Static (Error, throwError)+import Effectful.Reader.Static (Reader, asks)+import Effectful.TH+import Hasql.Statement (Statement)+import Hasql.Transaction.Sessions (IsolationLevel (ReadCommitted), Mode (Write), transaction)++import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Hasql.Transaction qualified as Transaction++import Atelier.Effects.DB.Config (DBPools)++import Atelier.Effects.DB.Config qualified as DB+++-- | Names of the Prometheus metrics recorded for database read operations.+data DBReadMetricNames = DBReadMetricNames+    { queries :: Text+    -- ^ Counter incremented once per query.+    , errors :: Text+    -- ^ Counter incremented when a query fails.+    , duration :: Text+    -- ^ Histogram recording query duration.+    }+++-- | Effect for read-only database queries.+data DBRead :: Effect where+    -- | Run a named read-only statement and return its result.+    RunQuery :: Text -> Statement () b -> DBRead m b+++-- | Effect for write database transactions.+data DBWrite :: Effect where+    -- | Run a named transaction and return its result.+    RunTransaction :: Text -> Transaction.Transaction a -> DBWrite m a+++makeEffect ''DBRead+makeEffect ''DBWrite+++-- | Interpret 'DBRead' against the reader pool, tracing each query and+-- recording the given metrics.+runDBRead+    :: (Error Text :> es, IOE :> es, Metrics :> es, Reader DBPools :> es, Tracing :> es)+    => DBReadMetricNames+    -> Eff (DBRead : es) a+    -> Eff es a+runDBRead metricNames eff = do+    pool <- asks DB.readerPool+    interpretWith_ eff \case+        RunQuery queryName stmt -> withSpan queryName do+            addAttribute @Text "db.operation" "read"+            counterInc metricNames.queries+            withHistogramTiming metricNames.duration $ do+                result <- liftIO $ Pool.use pool (Session.statement () stmt)+                case result of+                    Left err -> do+                        counterInc metricNames.errors+                        setStatus $ Error $ "Query failed: " <> show err+                        throwError $ "Query failed: " <> queryName <> " - " <> show err+                    Right value -> do+                        setStatus Ok+                        pure value+++-- | Interpret 'DBWrite' against the writer pool, tracing each transaction.+runDBWrite+    :: (Error Text :> es, IOE :> es, Reader DBPools :> es, Tracing :> es)+    => Eff (DBWrite : es) a+    -> Eff es a+runDBWrite eff = do+    pool <- asks DB.writerPool+    interpretWith_ eff \case+        RunTransaction txName tx -> withSpan txName do+            result <- liftIO $ Pool.use pool (transaction ReadCommitted Write tx)+            case result of+                Left err -> do+                    setStatus $ Error $ "Transaction failed: " <> show err+                    throwError $ "Transaction failed: " <> txName <> " - " <> show err+                Right value -> do+                    setStatus Ok+                    pure value+++-- | Run both DBRead and DBWrite effects with a shared connection pool+runDB+    :: (Error Text :> es, IOE :> es, Metrics :> es, Reader DBPools :> es, Tracing :> es)+    => DBReadMetricNames+    -> Eff (DBRead : DBWrite : es) a+    -> Eff es a+runDB metricNames = runDBWrite . runDBRead metricNames
+ src/Atelier/Effects/DB/Config.hs view
@@ -0,0 +1,101 @@+-- | Database connection configuration and pool acquisition.+--+-- 'DBConfig' describes how to reach a database as one user, and 'PoolConfig'+-- tunes its connection pool. 'acquireDatabasePools' builds separate reader and+-- writer pools ('DBPools'), typically using distinct least-privilege roles.+module Atelier.Effects.DB.Config+    ( DBConfig (..)+    , DBPools (..)+    , PoolConfig (..)+    , acquireDatabasePool+    , acquireDatabasePools+    )+where++import Atelier.Types.QuietSnake (QuietSnake (..))+import Data.Aeson (FromJSON)++import Hasql.Connection.Setting qualified as Setting+import Hasql.Connection.Setting.Connection qualified as Connection+import Hasql.Connection.Setting.Connection.Param qualified as Param+import Hasql.Pool qualified as Pool+import Hasql.Pool.Config qualified as Pool+++-- | Connection pool configuration.+data PoolConfig = PoolConfig+    { size :: Int+    -- ^ Maximum number of connections in the pool.+    , acquisitionTimeoutSeconds :: Int+    -- ^ How long to wait for a free connection before failing.+    , agingTimeoutSeconds :: Int+    -- ^ Maximum lifetime of a connection before it is retired.+    , idlenessTimeoutSeconds :: Int+    -- ^ How long an idle connection is kept before being closed.+    }+    deriving stock (Eq, Generic, Show)+    deriving (FromJSON) via QuietSnake PoolConfig+++-- | Connection details for reaching a database as a single user.+data DBConfig = DBConfig+    { host :: Text+    -- ^ Database server host.+    , port :: Word16+    -- ^ Database server port.+    , user :: Text+    -- ^ Role to connect as.+    , password :: Text+    -- ^ Password for the role.+    , databaseName :: Text+    -- ^ Name of the database to connect to.+    , pool :: PoolConfig+    -- ^ Connection pool settings.+    }+    deriving stock (Eq, Show)+++-- | Separate connection pools for read and write operations.+data DBPools = DBPools+    { readerPool :: Pool.Pool+    -- ^ Pool for read-only queries.+    , writerPool :: Pool.Pool+    -- ^ Pool for write transactions.+    }+++-- | Acquire a single database connection pool+acquireDatabasePool :: DBConfig -> IO Pool.Pool+acquireDatabasePool config = do+    let settings =+            [ Pool.staticConnectionSettings+                [ Setting.connection+                    $ Connection.params+                        [ Param.host config.host+                        , Param.port config.port+                        , Param.user config.user+                        , Param.password config.password+                        , Param.dbname config.databaseName+                        ]+                ]+            , Pool.size config.pool.size+            , Pool.acquisitionTimeout (fromIntegral config.pool.acquisitionTimeoutSeconds)+            , Pool.agingTimeout (fromIntegral config.pool.agingTimeoutSeconds)+            , Pool.idlenessTimeout (fromIntegral config.pool.idlenessTimeoutSeconds)+            ]++    Pool.acquire $ Pool.settings settings+++-- | Acquire both read and write connection pools+-- Uses different database users for read-only and read-write operations+acquireDatabasePools+    :: DBConfig+    -- ^ Read-only user config+    -> DBConfig+    -- ^ Read-write user config+    -> IO DBPools+acquireDatabasePools readerConfig writerConfig = do+    readerPool <- acquireDatabasePool readerConfig+    writerPool <- acquireDatabasePool writerConfig+    pure $ DBPools {readerPool, writerPool}
+ src/Atelier/Effects/DB/Rel8.hs view
@@ -0,0 +1,60 @@+-- | Rel8 query helpers layered over the 'DBRead' and 'DBWrite' effects.+--+-- Thin wrappers that build Hasql @Statement@s from Rel8 queries and run them+-- through the database effects: 'select' and 'select1' for reads, and 'transact'+-- with 'insert_', 'update_', 'delete_' and 'selectTx' for writes. The Hasql+-- 'Transaction' type is re-exported for building transaction bodies.+module Atelier.Effects.DB.Rel8+    ( Transaction+    , select+    , select1+    , transact+    , insert_+    , update_+    , delete_+    , selectTx+    )+where++import Hasql.Transaction (Transaction)+import Rel8 (Delete, Insert, Query, Serializable, Update)++import Hasql.Transaction qualified as TX+import Rel8 qualified++import Atelier.Effects.DB (DBRead, DBWrite, runQuery, runTransaction)+++-- | Run a Rel8 SELECT query via 'DBRead'.+select :: (DBRead :> es, Serializable exprs results) => Text -> Query exprs -> Eff es [results]+select name query = runQuery name (Rel8.run $ Rel8.select query)+++-- | Run a Rel8 SELECT expecting exactly one result via 'DBRead'.+select1 :: (DBRead :> es, Serializable exprs results) => Text -> Query exprs -> Eff es results+select1 name query = runQuery name (Rel8.run1 $ Rel8.select query)+++-- | Run a Hasql transaction via 'DBWrite'.+transact :: (DBWrite :> es) => Text -> Transaction a -> Eff es a+transact = runTransaction+++-- | Run a Rel8 INSERT inside a 'Transaction'.+insert_ :: Insert a -> Transaction ()+insert_ = TX.statement () . Rel8.run_ . Rel8.insert+++-- | Run a Rel8 UPDATE inside a 'Transaction'.+update_ :: Update a -> Transaction ()+update_ = TX.statement () . Rel8.run_ . Rel8.update+++-- | Run a Rel8 DELETE inside a 'Transaction'.+delete_ :: Delete a -> Transaction ()+delete_ = TX.statement () . Rel8.run_ . Rel8.delete+++-- | Run a Rel8 SELECT inside a 'Transaction'.+selectTx :: (Serializable exprs results) => Query exprs -> Transaction [results]+selectTx = TX.statement () . Rel8.run . Rel8.select