README.md

# BoltexNif

Elixir driver for [Neo4j](https://neo4j.com), implemented as a
[Rustler](https://github.com/rusterlium/rustler)-powered NIF around the
official Rust driver [`neo4rs`](https://github.com/neo4j-labs/neo4rs).

- Full **Bolt v5** protocol (via `neo4rs`'s `unstable-v1` feature bundle).
- **No Rust toolchain required** — precompiled NIFs ship for macOS, Linux
  (glibc), and Windows via
  [`rustler_precompiled`](https://hex.pm/packages/rustler_precompiled).
- **Async on the inside, sync on the outside**: every NIF call returns a
  `ref` immediately and the work runs on a shared Tokio runtime; the Elixir
  API you see is plain synchronous — `{:ok, ...}` / `{:error, ...}` with
  proper timeouts.
- **First-class types**: nodes, relationships, paths, points, temporals,
  durations, bytes, nested maps/lists — all marshalled to idiomatic Elixir
  structs.
- **Production essentials**: connection pool, explicit transactions, lazy
  row streaming, result summary (counters + notifications + bookmarks),
  TLS, user impersonation, and structured `Neo4jError` with retryable
  classification.

## Table of contents

- [Installation](#installation)
- [Quick start](#quick-start)
- [Connecting](#connecting)
- [Running queries](#running-queries)
- [Transactions](#transactions)
- [Streaming](#streaming)
- [Type mapping](#type-mapping)
- [Error handling](#error-handling)
- [Concurrency & the connection pool](#concurrency--the-connection-pool)
- [Using it from Phoenix](#using-it-from-phoenix)
- [Local development](#local-development)
- [Testing](#testing)
- [Roadmap](#roadmap)
- [Releasing new versions (maintainers)](#releasing-new-versions-maintainers)
- [License](#license)

## Installation

Add to `mix.exs` and run `mix deps.get` — that's it:

```elixir
def deps do
  [
    {:boltex_nif, "~> 0.1"}
  ]
end
```

**No Rust toolchain required.** `boltex_nif` ships precompiled NIFs for:

| OS            | Architectures                           |
|---------------|-----------------------------------------|
| macOS         | aarch64 (Apple silicon), x86_64 (Intel) |
| Linux (glibc) | x86_64, aarch64                         |
| Windows       | x86_64 (MSVC)                           |

For unsupported targets (musl/Alpine, 32-bit ARM, RISC-V, …) or to force a
local build, set `FORCE_BOLTEX_BUILD=1` and add `{:rustler, "~> 0.37"}` to
your own deps:

```sh
FORCE_BOLTEX_BUILD=1 mix deps.compile boltex_nif
```

Building from source needs Rust ≥ 1.81.

### Runtime requirements

- Elixir 1.19 / OTP 28 (the regression-tested matrix). NIF 2.16 binaries
  are published too, so older Elixir/OTP combos with NIF ≥ 2.16 should
  also work, they just aren't part of the test matrix.
- A reachable Neo4j 5.x instance (Community or Enterprise). The repo ships
  `docker-compose.yml` (local dev) and `docker-compose.production.yml`
  (Coolify-ready) — see [Local development](#local-development).

## Quick start

```elixir
{:ok, graph} =
  BoltexNif.connect(
    uri: "bolt://localhost:7687",
    user: "neo4j",
    password: "boltex_nif_pass"
  )

:ok = BoltexNif.run(graph, "MERGE (:Greeter {name:$n})", %{"n" => "Ada"})

{:ok, rows} =
  BoltexNif.execute(graph, "MATCH (g:Greeter) RETURN g.name AS name")

Enum.map(rows, & &1["name"])
#=> ["Ada"]
```

## Connecting

`BoltexNif.connect/1` takes a keyword list or a map:

```elixir
{:ok, graph} =
  BoltexNif.connect(
    uri: "bolt://localhost:7687",     # required — bolt:// or neo4j:// (routing)
    user: "neo4j",                    # required
    password: "secret",               # required
    db: "neo4j",                      # optional — default database
    fetch_size: 500,                  # optional — rows per pull (driver default 200)
    max_connections: 16,              # optional — pool size (driver default 16)
    impersonate_user: "alice",        # optional — Bolt v5 impersonation
    tls: :skip_validation,            # optional — see below
    timeout: 15_000                   # optional — connect handshake timeout (ms)
  )
```

### TLS

```elixir
tls: nil                                  # default — honors scheme (neo4j+s://, bolt+ssc://)
tls: {:ca, "/etc/ssl/neo4j-ca.pem"}       # validate server cert against the CA
tls: {:mutual, ca: "/etc/ssl/ca.pem", cert: "/etc/ssl/client.pem", key: "/etc/ssl/client.key"}
tls: :skip_validation                     # accept anything — DO NOT use in prod
```

The returned `graph` is an opaque `reference()` you pass to every query
function. It can be safely shared across processes — internal connection
pooling is handled by the Rust layer.

## Running queries

Three flavors, increasing in what they return:

### `run/4` — fire-and-forget

```elixir
:ok = BoltexNif.run(graph, "CREATE (:Foo {i:$i})", %{"i" => 1})
# or with an options keyword:
:ok = BoltexNif.run(graph, "CREATE (:Foo)", nil, timeout: 30_000)
```

### `run_with_summary/4` — write stats without rows

```elixir
{:ok, %BoltexNif.Summary{stats: stats, query_type: type}} =
  BoltexNif.run_with_summary(graph, "CREATE (:Foo {i:1}), (:Foo {i:2})")

stats.nodes_created       #=> 2
stats.properties_set      #=> 2
type                      #=> "write" (or "read" / "read_write" / "schema_write")
```

Full fields on `%BoltexNif.Summary{}`:

- `bookmark` — `String.t() | nil` — Bolt v5 bookmark for `start_txn_as`.
- `available_after_ms`, `consumed_after_ms` — server-side timings.
- `query_type` — `"read" | "write" | "read_write" | "schema_write"`.
- `db` — database the query ran against.
- `stats` — `%BoltexNif.Summary.Counters{}` with `nodes_created`,
  `relationships_created`, `properties_set`, `labels_added`,
  `indexes_added`, `constraints_added`, their `*_deleted`/`*_removed`
  counterparts, and `system_updates`.
- `notifications` — `[%BoltexNif.Notification{}]` (code, title, severity,
  category, source `InputPosition`).

### `execute/4` — collect all rows

```elixir
{:ok, rows} =
  BoltexNif.execute(
    graph,
    "MATCH (p:Person {age: $age}) RETURN p.name AS name, p.age AS age ORDER BY name",
    %{"age" => 30},
    timeout: 60_000
  )

rows
#=> [%{"name" => "Ada", "age" => 30}, %{"name" => "Grace", "age" => 30}]
```

Rows are plain maps keyed by the `AS` alias you declare in the Cypher.

## Transactions

### Imperative — full control over commit/rollback

```elixir
{:ok, txn} = BoltexNif.begin_transaction(graph)

{:ok, _summary} = BoltexNif.txn_run(txn, "CREATE (:T {x:1})")
{:ok, rows}    = BoltexNif.txn_execute(txn, "MATCH (t:T) RETURN t")

# Either:
:ok                        = BoltexNif.rollback(txn)
# or (Bolt v5 returns the bookmark, otherwise just :ok):
{:ok, bookmark_or_nil}     = BoltexNif.commit(txn)
```

### Declarative — `transaction/3`

Commits on `{:ok, value}`, rolls back on `{:error, _}`, re-raises on
exceptions (rollback first):

```elixir
{:ok, count} =
  BoltexNif.transaction(graph, fn txn ->
    {:ok, _} = BoltexNif.txn_run(txn, "CREATE (:T {x:1})")
    {:ok, rows} = BoltexNif.txn_execute(txn, "MATCH (t:T) RETURN count(t) AS c")
    {:ok, rows |> hd() |> Map.get("c")}
  end)
```

## Streaming

For result sets bigger than memory, stream row by row:

```elixir
{:ok, stream} =
  BoltexNif.stream_start(graph, "MATCH (n) RETURN n LIMIT 100_000")

Stream.repeatedly(fn -> BoltexNif.stream_next(stream) end)
|> Enum.take_while(&(&1 != :done))
|> Enum.each(fn {:ok, row} -> process(row) end)
```

- `{:ok, row}` — one row returned.
- `:done` — stream exhausted (connection is returned to the pool).
- `{:error, :closed}` — `stream_next` called after `:done` or `stream_close/1`.
- `BoltexNif.stream_close(stream)` — drop early without draining; `:ok`
  even if already closed.

## Type mapping

### Parameters (Elixir → Bolt)

| Elixir value                                                   | Becomes (Bolt)   |
|----------------------------------------------------------------|------------------|
| `nil`                                                          | `Null`           |
| `true` / `false`                                               | `Boolean`        |
| `integer()`                                                    | `Integer` (i64)  |
| `float()`                                                      | `Float` (f64)    |
| `binary()` (UTF-8)                                             | `String`         |
| `{:bytes, binary()}`                                           | `Bytes`          |
| `list()`                                                       | `List`           |
| `map()` — keys **must be** strings or atoms                    | `Map`            |
| `%Date{}`                                                      | `Date`           |
| `%Time{}`                                                      | `LocalTime`      |
| `%NaiveDateTime{}`                                             | `LocalDateTime`  |
| `%DateTime{}`                                                  | `DateTime`       |
| `%BoltexNif.Time{time: %Time{}, offset_seconds}`               | `Time`           |
| `%BoltexNif.DateTime{naive: %NaiveDateTime{}, offset_seconds}` | `DateTime`       |
| `%BoltexNif.DateTimeZoneId{naive, tz_id}`                      | `DateTimeZoneId` |
| `%BoltexNif.Duration{months, days, seconds, nanoseconds}`      | `Duration`       |
| `%BoltexNif.Point{srid, x, y, z \\ nil}`                       | `Point2D`/`3D`   |
| `%BoltexNif.Node{id, labels, properties}`                      | `Node`           |
| `%BoltexNif.Relationship{...}`                                 | `Relationship`   |
| `%BoltexNif.UnboundRelationship{id, type, properties}`         | `UnboundRel`     |

### Results (Bolt → Elixir)

Symmetric to the param table. Highlights:

- `Bolt String` → UTF-8 `binary()`.
- `Bolt Bytes` → `{:bytes, binary()}`.
- `Bolt Date`/`LocalTime`/`LocalDateTime` → stdlib `%Date{}` / `%Time{}` /
  `%NaiveDateTime{}`.
- `Bolt DateTime` (FixedOffset) → `%BoltexNif.DateTime{}` (keeps offset
  without needing a TZ database).
- `Bolt DateTimeZoneId` → `%BoltexNif.DateTimeZoneId{}`.
- `Bolt Time` (with offset) → `%BoltexNif.Time{}`.
- `Bolt Duration` → `%BoltexNif.Duration{}` (Bolt keeps months/days/
  seconds/nanos separately; we never collapse them).
- `Bolt Point2D` → `%BoltexNif.Point{z: nil}`; `Point3D` → `z: float()`.
- `Bolt Node` → `%BoltexNif.Node{id, labels, properties}`.
- `Bolt Relationship` → `%BoltexNif.Relationship{id, start_node_id,
  end_node_id, type, properties}`.
- `Bolt Path` → `%BoltexNif.Path{nodes, relationships, indices}` where
  `relationships` is a list of `%BoltexNif.UnboundRelationship{}`.

## Error handling

Every `{:error, _}` response is one of:

```elixir
{:error, {:neo4j, %BoltexNif.Neo4jError{code: code, message: msg, kind: kind}}}
{:error, {:invalid_config, msg}}
{:error, {:io, msg}}
{:error, {:deserialization, msg}}
{:error, {:unexpected_type, msg}}
{:error, {:unexpected, msg}}
{:error, {:argument, msg}}
{:error, :timeout}          # NIF didn't answer within the caller's timeout
```

`kind` on a Neo4j error classifies the failure for retry decisions. One of:

`:authentication`, `:authorization_expired`, `:token_expired`,
`:other_security`, `:session_expired`, `:fatal_discovery`,
`:transaction_terminated`, `:protocol_violation`, `:client_other`,
`:client_unknown`, `:transient`, `:database`, `:unknown`.

```elixir
case BoltexNif.execute(graph, cypher, params) do
  {:ok, rows} -> rows
  {:error, {:neo4j, err}} ->
    if BoltexNif.Neo4jError.retryable?(err), do: retry(), else: raise("boom: #{err.message}")
  {:error, :timeout} -> retry()
end
```

## Concurrency & the connection pool

- The underlying `neo4rs` pool is bounded by `:max_connections` (default
  16). All BoltexNif functions are **safe to call concurrently** from any
  number of processes — they queue up on the Rust-side pool transparently.
- Each call returns a fresh `ref` and waits on a single Erlang message
  (`{ref, result}`), so it obeys `:timeout` cleanly even while queued.
- A request that times out on the Elixir side only stops **waiting** for
  the result; the Rust task finishes anyway and its reply is dropped. Keep
  `:timeout` generous when you know you might be behind a deep queue.
- Measured throughput against a remote Coolify Neo4j (≈ 430 ms RTT): ~9
  q/s per connection, 50× pool over-subscription handled without losses.
  Against a local `docker compose up -d` Neo4j: order of magnitude higher.

See `phoenix_neo4j/test/phoenix_neo4j/neo4j_stress_test.exs` for the
`:stress` suite (parallel reads/writes, transaction interleaving,
streaming concurrency, pool saturation).

## Using it from Phoenix

The repo includes **`phoenix_neo4j/`** — a minimal Phoenix 1.8 app that
wires `boltex_nif` into a supervision tree, exposes the pool, and serves a
demo `/neo4j` page (list/create/delete `:Greeter` nodes).

Core pattern in `phoenix_neo4j/lib/phoenix_neo4j/neo4j.ex`:

```elixir
defmodule MyApp.Neo4j do
  use GenServer
  @key {__MODULE__, :graph}

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)

  def graph, do: :persistent_term.get(@key)

  def run(cypher, params \\ nil, opts \\ []),
    do: BoltexNif.run(graph(), cypher, params, opts)

  def execute(cypher, params \\ nil, opts \\ []),
    do: BoltexNif.execute(graph(), cypher, params, opts)

  @impl true
  def init(opts) do
    {:ok, graph} = BoltexNif.connect(opts)
    :persistent_term.put(@key, graph)
    {:ok, %{graph: graph}}
  end
end
```

Add to your Application:

```elixir
children = [
  # …,
  {MyApp.Neo4j, uri: System.fetch_env!("NEO4J_URI"),
                user: System.fetch_env!("NEO4J_USER"),
                password: System.fetch_env!("NEO4J_PASSWORD"),
                max_connections: 16}
]
```

Callers just use `MyApp.Neo4j.execute/2` — no GenServer bottleneck, pool
is handled in Rust.

To run the demo:

```sh
docker compose up -d
cd phoenix_neo4j
mix deps.get
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=boltex_nif_pass \
  mix phx.server
# visit http://localhost:4000/neo4j
```

## Local development

Two compose files ship with the repo:

### `docker-compose.yml` — local dev

Neo4j 5.26 Community, bound to `localhost:7687`:

```sh
docker compose up -d      # boot
docker compose down -v    # tear down and drop volumes
```

Default creds: `neo4j` / `boltex_nif_pass`.

### `docker-compose.production.yml` — Coolify-ready

Production-oriented: APOC auto-install, query logging, healthcheck via
`cypher-shell`, memory & ulimit tuning, opt-in backup sidecar using APOC
export. Drop into Coolify's "Docker Compose" resource and pass the env
vars from `.env.production.example`.

Automatic `SERVICE_FQDN_NEO4J` substitution means you only need to set
`CFG_NEO4J_PASSWORD` — the rest has sensible defaults. See comments at
the top of the file for the TLS-for-Bolt block and the `:port` gotchas
around Cloudflare (Bolt TCP needs DNS-only, not proxied).

## Testing

The test suite is live-by-default — it will refuse to run the DB-touching
tests unless `NEO4J_URI` is set. Two tag tiers:

- `:live` — touches Neo4j. Excluded when `NEO4J_URI` isn't set.
- `:stress` — opt-in, long-running concurrency tests. Always excluded by
  default.

```sh
# Library tests (14 cases):
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=boltex_nif_pass \
  mix test --include live

# Phoenix demo tests (48 cases):
cd phoenix_neo4j
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=boltex_nif_pass \
  mix test --include live

# Full concurrency/stress suite (add --only stress to isolate):
cd phoenix_neo4j
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=boltex_nif_pass \
  STRESS_SCALE=1.0 \
  mix test --include live --include stress
```

### One-shot smoke

`scripts/smoke.sh` runs an end-to-end check: Phoenix HTTP endpoints (if a
server is up at `$PHX_URL`), the BoltexNif type / transaction / streaming
probes in `scripts/smoke.exs`, and the full `mix test --include live`.

```sh
# Requires Neo4j reachable at $NEO4J_URI (defaults baked in for Coolify).
./scripts/smoke.sh
# or isolate phases:
SKIP_PHX=1 ./scripts/smoke.sh        # only library
SKIP_MIX_TEST=1 ./scripts/smoke.sh   # library checks + phoenix HTTP
```

## Roadmap

- **Phase 1** (done): scaffolding, primitives + graph/temporal/spatial
  types, auto-commit `run`/`execute`.
- **Phase 2** (done): transactions, streaming, `ResultSummary`.
- **Phase 3** (done): Bolt v5 bookmarks, TLS, impersonation, structured
  Neo4j errors.
- **Phase 4** (done): precompiled NIFs via `rustler_precompiled`, Hex
  metadata, GitHub Actions release pipeline.
- **Future**:
  - `element_id` on Nodes/Relationships (requires decoding via the Bolt v5
    serde path, not the classic `types/*` tree).
  - Optional `neo4rs` features: `json` (transparent `serde_json::Value`),
    `uuid` (`Ecto.UUID` ↔ `String`).
  - Per-stream `fetch_size` override, streaming within a transaction.
  - `:telemetry` hooks (query start/stop, pool checkout, tx commit).
  - musl (Alpine) precompiled targets once a working `Cross.toml` is in
    place.

## Releasing new versions (maintainers)

`.github/workflows/release.yml` runs only on tag pushes (`v*`). It builds
a matrix of 5 targets × 2 NIF versions (10 artifacts) and uploads them to
a draft GitHub Release. After publishing the draft, run:

```sh
mix rustler_precompiled.download BoltexNif.Native --all --ignore-unavailable --print
git add checksum-boltex_nif-X.Y.Z.exs
git commit -m "chore(release): checksum for vX.Y.Z"
git push
mix hex.publish
```

Full step-by-step in [`RELEASING.md`](RELEASING.md).

## License

MIT — see [`LICENSE`](LICENSE).