# bolty Native Haskell driver for [Neo4j](https://neo4j.com/) using the BOLT protocol (versions 4.4 through 5.4). ## Features - **Connection pooling** with configurable idle timeout, health checks, and validation strategies - **Cluster routing** — automatic server discovery and read/write splitting for Neo4j causal clusters - **Sessions** with bookmark-based causal consistency across transactions - **Transactions** with automatic retry on transient errors (exponential backoff) - **Type-safe record decoding** — composable `Decode` / `RowDecoder` combinators, or derive via `FromBolt` - **All Neo4j types** — nodes, relationships, paths, temporal (date, time, datetime, duration), spatial (point2D, point3D) - **Query metadata** — server timing, query statistics, notifications, EXPLAIN/PROFILE plans - **TLS support** via `crypton-connection` - **Multi-auth** — basic, bearer token, Kerberos, custom schemes, plus LOGON/LOGOFF for Bolt 5.1+ ## Quick start ```haskell import qualified Database.Bolty as Bolt import Data.Default (def) main :: IO () main = do let cfg = def{ Bolt.scheme = Bolt.Basic "neo4j" "password", Bolt.use_tls = False } case Bolt.validateConfig cfg of Failure errs -> mapM_ putStrLn errs Success vc -> do conn <- Bolt.connect vc result <- Bolt.runBolt conn $ Bolt.queryWith (Bolt.field "greeting" Bolt.text) "RETURN 'hello' AS greeting" mempty print result -- Right (Vector ["hello"]) Bolt.close conn ``` ## Configuration Use `Data.Default.def` for sensible defaults and override what you need. The config must be validated before use: ```haskell import Data.Default (def) cfg = def { host = "db.example.com" , port = 7687 , scheme = Basic "neo4j" "s3cret" , use_tls = True , timeout = 5000 -- ms } case validateConfig cfg of Failure errs -> error (show errs) Success vc -> connect vc >>= ... ``` Default config: `127.0.0.1:7687`, no auth, TLS on, 10s timeout, BOLT 5.4 down to 4.4. ### Authentication schemes ```haskell None -- no auth Basic "user" "pass" -- username/password Bearer "jwt-token" -- SSO / JWT token Kerberos "base64-ticket" -- Kerberos Custom "scheme" credentials -- custom auth provider ``` ## The BoltM monad Queries run in `BoltM`, a thin `ReaderT Connection IO` wrapper: ```haskell runBolt :: Connection -> BoltM a -> IO a ``` ### Running queries ```haskell -- Auto-decode via FromBolt (returns Either DecodeError (Vector a)) query :: FromBolt a => Text -> HashMap Text Ps -> BoltM (Either DecodeError (Vector a)) -- Explicit decoder queryWith :: RowDecoder a -> Text -> HashMap Text Ps -> BoltM (Either DecodeError (Vector a)) -- Raw result set (field names + records) queryResult :: Text -> HashMap Text Ps -> BoltM ResultSet -- Side-effects only, discard results execute :: Text -> HashMap Text Ps -> BoltM () ``` Pass `mempty` for no parameters. ### Parameters ```haskell import Data.PackStream.Ps (Ps(..)) import qualified Data.HashMap.Lazy as H queryWith decoder "MATCH (p:Person) WHERE p.age > $minAge RETURN p.name AS name" (H.singleton "minAge" (PsInteger 21)) ``` ## Record decoding bolty provides composable, type-safe decoders. A `Decode a` extracts a single value from a `Bolt` cell; a `RowDecoder a` maps column names to decoders for a full row. ### Primitive decoders ```haskell bool :: Decode Bool int :: Decode Int -- may lose precision from Int64 int64 :: Decode Int64 float :: Decode Double text :: Decode Text bytes :: Decode ByteString ``` ### Combining with RowDecoder ```haskell data Person = Person { pName :: Text, pAge :: Int64 } personDecoder :: RowDecoder Person personDecoder = Person <$> field "name" text <*> field "age" int64 result <- runBolt conn $ queryWith personDecoder "MATCH (p:Person) RETURN p.name AS name, p.age AS age" mempty ``` ### Node property decoders When a query returns full nodes, decode properties from within: ```haskell data Person = Person { name :: Text, age :: Int64 } personDecoder :: RowDecoder Person personDecoder = do n <- field "p" node pure $ Person <$> nodeProperty "name" text n <*> nodeProperty "age" int64 n ``` ### Other decoders ```haskell nullable :: Decode a -> Decode (Maybe a) -- NULL-safe list :: Decode a -> Decode (Vector a) -- list values dict :: Decode (HashMap Text Bolt) -- raw dictionary node :: Decode Node -- graph node relationship :: Decode Relationship -- graph relationship path :: Decode Path -- graph path uuid :: Decode UUID -- UUID from string utcTime :: Decode UTCTime -- DateTime → UTCTime day :: Decode Day -- Date → Day timeOfDay :: Decode TimeOfDay -- LocalTime → TimeOfDay aesonValue :: Decode Aeson.Value -- Bolt → aeson Value ``` ### Result sets For multi-pass decoding (e.g. denormalized `OPTIONAL MATCH` results): ```haskell rs <- runBolt conn $ queryResult "MATCH (p:Person) RETURN p.name AS name, p.age AS age" mempty -- Decode all rows people <- either throwIO pure $ decodeResultSet personDecoder rs -- Decode just the first row first <- either throwIO pure $ decodeHead personDecoder rs -- Group by a key field (consecutive grouping) groups <- either throwIO pure $ groupByField (field "dept" (nullable text)) rs ``` ## Transactions ### Basic transactions ```haskell withTransaction conn $ \txConn -> do runBolt txConn $ execute "CREATE (p:Person {name: 'Alice'})" mempty runBolt txConn $ execute "CREATE (p:Person {name: 'Bob'})" mempty -- auto-commits on success, rolls back on exception ``` ### Retry on transient errors ```haskell withRetryTransaction defaultRetryConfig conn $ \txConn -> runBolt txConn $ execute "CREATE (p:Person {name: 'Alice'})" mempty -- retries up to 5 times with exponential backoff on transient Neo4j errors ``` `RetryConfig` controls `maxRetries` (default 5), `initialDelay` (200ms), and `maxDelay` (5s). ## Connection pooling ```haskell pool <- createPool validatedConfig defaultPoolConfig -- defaultPoolConfig: 10 max connections, 60s idle timeout, PingIfIdle 30s withConnection pool $ \conn -> runBolt conn $ query @Person "MATCH (p:Person) RETURN p" mempty -- Convenience: pool + retry transaction in one call withTransaction' pool $ \conn -> runBolt conn $ execute "CREATE (n:Test)" mempty destroyPool pool ``` ### Validation strategies Control how connections are health-checked on checkout: ```haskell AlwaysPing -- send RESET before every use (safest) PingIfIdle 30 -- only ping if idle > 30 seconds (default, good balance) NeverPing -- skip health check (fastest, use in trusted environments) ``` ## Cluster routing For Neo4j causal clusters with multiple servers: ```haskell let cfg = def{ scheme = Basic "neo4j" "pass", routing = EnableRouting Nothing } routingPool <- createRoutingPool validatedConfig defaultRoutingPoolConfig -- Writes go to a writer server, reads to a reader withRoutingTransaction routingPool WriteAccess $ \conn -> runBolt conn $ execute "CREATE (n:Test)" mempty withRoutingTransaction routingPool ReadAccess $ \conn -> runBolt conn $ queryWith decoder "MATCH (n) RETURN n" mempty destroyRoutingPool routingPool ``` The routing pool automatically discovers servers via the ROUTE message, caches routing tables with TTL, and retries on different servers when a routing error occurs. ## Sessions Sessions track bookmarks for causal consistency across transactions: ```haskell session <- createSession pool defaultSessionConfig -- Each transaction's bookmark is automatically passed to the next writeTransaction session $ \conn -> runBolt conn $ execute "CREATE (p:Person {name: 'Alice'})" mempty readTransaction session $ \conn -> runBolt conn $ queryWith decoder "MATCH (p:Person) RETURN p" mempty -- ↑ guaranteed to see Alice because of bookmark chaining bookmarks <- getLastBookmarks session ``` ## Query metadata ```haskell (result, meta) <- runBolt conn $ queryMetaWith decoder cypher params -- meta :: QueryMeta contains: -- parsedNotifications :: [Notification] -- warnings, deprecations -- parsedStats :: Maybe QueryStats -- nodes/rels created/deleted -- parsedPlan :: Maybe PlanNode -- EXPLAIN plan -- parsedProfile :: Maybe ProfileNode -- PROFILE with execution stats -- bookmark, db, tFirst, tLast -- timing and metadata -- EXPLAIN a query without executing it plan <- runBolt conn $ queryExplain "MATCH (n) RETURN n" mempty -- PROFILE a query with actual execution statistics (rows, profile) <- runBolt conn $ queryProfile "MATCH (n) RETURN n" mempty ``` ## Query logging ```haskell let cfg = def{ queryLogger = Just $ \ql meta -> do putStrLn $ "Query: " <> show (qlCypher ql) putStrLn $ "Rows: " <> show (qlRowCount ql) putStrLn $ "Time: " <> show (qlClientTime ql) <> "ns" } ``` ## Error handling ```haskell -- Check if an error is transient (safe to retry) isTransient :: Error -> Bool -- Check if an error is a routing error (server unreachable, etc.) isRoutingError :: Error -> Bool ``` ## Bolt value types Every cell in a query result is a `Bolt` value: | Neo4j type | Bolt constructor | Haskell type inside | |---|---|---| | null | `BoltNull` | — | | boolean | `BoltBoolean` | `Bool` | | integer | `BoltInteger` | `PSInteger` | | float | `BoltFloat` | `Double` | | bytes | `BoltBytes` | `ByteString` | | string | `BoltString` | `Text` | | list | `BoltList` | `Vector Bolt` | | map | `BoltDictionary` | `HashMap Text Bolt` | | node | `BoltNode` | `Node` (id, labels, properties) | | relationship | `BoltRelationship` | `Relationship` (id, start, end, type, properties) | | path | `BoltPath` | `Path` (nodes, rels, indices) | | date | `BoltDate` | `Date` (days since epoch) | | time | `BoltTime` | `Time` (nanos, tz offset) | | local time | `BoltLocalTime` | `LocalTime` (nanos since midnight) | | datetime | `BoltDateTime` | `DateTime` (seconds, nanos) | | datetime (zoned) | `BoltDateTimeZoneId` | `DateTimeZoneId` (seconds, nanos, tz name) | | local datetime | `BoltLocalDateTime` | `LocalDateTime` (seconds, nanos) | | duration | `BoltDuration` | `Duration` (months, days, seconds, nanos) | | point (2d) | `BoltPoint2D` | `Point2D` (srid, x, y) | | point (3d) | `BoltPoint3D` | `Point3D` (srid, x, y, z) | ## Module structure **Public API — import these:** | Module | Purpose | |---|---| | `Database.Bolty` | Main entry point, re-exports everything | | `Database.Bolty.Decode` | Record decoders (`Decode`, `RowDecoder`, `FromBolt`) | | `Database.Bolty.Pool` | Connection pooling | | `Database.Bolty.Routing` | Cluster routing | | `Database.Bolty.Session` | Sessions with bookmark management | | `Database.Bolty.ResultSet` | Multi-pass result set decoding | | `Database.Bolty.Logging` | Query log types | | `Database.Bolty.Notification` | Server notifications | | `Database.Bolty.Plan` | EXPLAIN/PROFILE plan types | | `Database.Bolty.Stats` | Query statistics types | | `Database.Bolty.Record` | Record type alias | All other modules under `Database.Bolty.Connection.*`, `Database.Bolty.Message.*`, and `Database.Bolty.Value.*` are **internal** — exposed for `bolty-streamly` but not part of the stable API. ## Supported GHC versions 9.6.7, 9.8.4, 9.10.3, 9.12.3 ## License Apache-2.0