bolty-0.1.0.0: readme.md
# 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