A lean, framework-agnostic Elixir client for ArcadeDB over the HTTP Cypher command API, with an optional Bolt transport for the query hot path.
Arcadic is the "postgrex of ArcadeDB" — it ships Cypher/SQL to ArcadeDB and
manages connections, sessions, and transactions, and nothing more. It is
deliberately tenant-blind and framework-agnostic: no Ash, no multitenancy,
no data classification. Those belong one layer up, in
ash_arcadic (the "ash_postgres of
ArcadeDB").
Highlights
- Cypher-first, multi-language — the default language is
"cypher"; opt intosql,gremlin,graphql,mongo, orsqlscriptper call. - Parameters only — every dynamic value reaches ArcadeDB as a bound parameter
(
$name), never string interpolation, so the injection surface stays closed. - Typed errors with boundary redaction —
Arcadic.Errorcarries a typedreason, HTTP status, and error class; raw parameter values and response rows never enter an error message, log line, orinspect/1output. - Session transactions —
transaction/3opens an ArcadeDB session and commits on normal return, rolls back and reraises on exception (postgrex semantics). - Pluggable transport — HTTP (Req/Finch) by default, with an optional Bolt v4 transport for the query hot path and lazy result streaming.
- Vector search — dense (
LSM_VECTOR) and sparse (LSM_SPARSE_VECTOR) index DDL plus nearest-neighbour, sparse, and hybrid-fusion query builders (Arcadic.Vector) with a candidate-setfilterandgroup_by/group_sizeshaping, all params-only and value-free. - Schema, import & export — read-only schema introspection (
Arcadic.Schema— types / properties / indexes / buckets / database engine-config,@props-stripped), server-side bulk load (Arcadic.Import.database/3,IMPORT DATABASE) behind a positive-allowlist URL validator that closes the interpolated-URL injection surface, and symmetric server-side export (Arcadic.Export.database/3,EXPORT DATABASE) with a path-safe name. - Batteries included — server admin, a migration runner, vector search, schema introspection, bulk import, allowlist-validated identifiers, and value-free telemetry spans.
Quickstart
conn = Arcadic.connect("http://localhost:2480", "mydb", auth: {"root", pass})
{:ok, rows} = Arcadic.query(conn, "MATCH (n:User) RETURN n LIMIT $lim", %{"lim" => 10})
{:ok, [user]} =
Arcadic.command(conn, "CREATE (u:User {name:$n}) RETURN u", %{"n" => "Jo"})
{:ok, result} =
Arcadic.transaction(conn, fn tx ->
Arcadic.command!(tx, "MERGE (u:User {id:$id})", %{"id" => "u1"})
end)Every dynamic value reaches ArcadeDB only as a bound parameter ($name).
query/4 hits the idempotent read endpoint; command/4 hits the write endpoint.
Both return {:ok, rows} or {:error, %Arcadic.Error{} | %Arcadic.TransportError{}};
query!/4 and command!/4 return the rows or raise. command_async/4 submits a
fire-and-forget write, returning :ok once ArcadeDB accepts it for processing
(HTTP 202). The default language is "cypher"; pass language: "sql" (or
gremlin/graphql/mongo/sqlscript) to switch.
Arcadic.transaction/3 opens an ArcadeDB session, runs the fun with a
session-scoped conn, and commits on normal return. An exception rolls back and
reraises; Arcadic.rollback/2 aborts intentionally and yields {:error, reason}.
Production pool
The HTTP transport runs on Req/Finch. In production, give Arcadic a dedicated Finch pool in your supervision tree rather than the default shared one:
# lib/my_app/application.ex
children = [
{Finch, name: MyApp.ArcadicFinch},
# ...
]
# then point connections at it
conn =
Arcadic.connect("http://localhost:2480", "mydb",
auth: {"root", pass},
transport_options: [finch: MyApp.ArcadicFinch]
)Server admin
Arcadic.Server covers server-level operations: create_database/2 (+ !),
drop_database/2 (+ !), database_exists?/2, list_databases/1, and
ready?/1. Every database identifier is allowlist-validated before it reaches
the wire.
Migrations
Arcadic.Migrator runs Arcadic.Migrations in order and tracks applied
versions in the _arcadic_migrations type. Declare a migration
(version/0, up/1, down/1), register the ordered list with
use Arcadic.MigrationRegistry + migrations [...], then run
Arcadic.Migrator.migrate/2 / status/2 / rollback/3 / reset/2.
defmodule MyApp.Migrations.V1 do
@behaviour Arcadic.Migration
@impl true
def version, do: 1
@impl true
def up(conn), do: Arcadic.command!(conn, "CREATE VERTEX TYPE User", %{}, language: "sql") && :ok
@impl true
def down(conn), do: Arcadic.command!(conn, "DROP TYPE User IF EXISTS", %{}, language: "sql") && :ok
end
defmodule MyApp.Migrations do
use Arcadic.MigrationRegistry
migrations [MyApp.Migrations.V1]
end
{:ok, _count} = Arcadic.Migrator.migrate(conn, MyApp.Migrations)Vector search
Arcadic.Vector builds ArcadeDB dense and sparse vector index DDL plus
nearest-neighbour, sparse, and hybrid-fusion queries. Create an LSM_VECTOR index
(idempotent — IF NOT EXISTS), then search. The query vector, k, and options bind
as parameters; the index reference is identifier-validated before it reaches the
statement.
:ok =
Arcadic.Vector.create_dense_index(conn, "Doc", "embedding", 1536, similarity: :cosine)
{:ok, rows} =
Arcadic.Vector.neighbors(conn, "Doc", "embedding", query_vector, 10, max_distance: 0.3)
# hybrid fusion over multiple dense subqueries
{:ok, fused} =
Arcadic.Vector.fuse(
conn,
[{"Doc", "embedding", dense_a, 20}, {"Doc", "embedding", dense_b, 20}],
fusion: :rrf
)Sparse retrieval (learned-sparse / BM25-style) runs over an LSM_SPARSE_VECTOR index
on a (tokens, weights) property pair, ranked by a top-level score. Create the
index before loading rows — a sparse index does not cover pre-existing rows (a
[:arcadic, :vector, :sparse_index_preexisting] telemetry event fires if you create
it over existing data).
:ok = Arcadic.Vector.create_sparse_index(conn, "Doc", "tokens", "weights", modifier: :idf)
{:ok, hits} =
Arcadic.Vector.sparse_neighbors(conn, "Doc", "tokens", "weights", tokens, weights, 10)All three builders (neighbors/6, sparse_neighbors/8, fuse/3) also accept a
candidate-set filter (a non-empty list of #bucket:pos RID strings) and
group_by / group_size result shaping — all param-bound.
neighbors/6 rows carry a distance whose scale depends on the index similarity
(COSINE 0..1 ascending, so smaller is nearer; DOT_PRODUCT is negative, so a small
positive max_distance filters nothing — choose thresholds per similarity). fuse/3
rows are ranked by score (higher is better); sparse_neighbors/8 rows carry score
and no distance. The Ash-native data-layer surface remains a non-goal (owned by the
sibling ash_arcadic).
Schema introspection & bulk import
Arcadic.Schema reflects the live schema, tenant-blind and @props-stripped. Every
query is arcadic's own fixed SELECT FROM schema:* literal; a caller type name binds as
a $param (never interpolated) and is identifier-shape-guarded.
{:ok, types} = Arcadic.Schema.types(conn)
{:ok, props} = Arcadic.Schema.properties(conn, "User")
{:ok, indexes} = Arcadic.Schema.indexes(conn, type: "User")
{:ok, buckets} = Arcadic.Schema.buckets(conn)
{:ok, cfg} = Arcadic.Schema.database(conn) # engine config (schema:database), @props-strippedindexes/2 returns both logical and physical per-bucket rows (filter on the absence of
fileId for logical-only).
Arcadic.Import.database/3 bulk-loads a database export server-side via IMPORT DATABASE.
The source URL cannot be a bound parameter (ArcadeDB rejects it), so it is interpolated
behind a positive character allowlist (RFC 3986 minus the single quote and backslash —
which closes the SQL-literal injection surface, since ArcadeDB honours backslash-escapes)
plus a scheme allowlist (http / https / file). Rejections are value-free.
{:ok, _} = Arcadic.Import.database(conn, "https://host/export.jsonl.tgz")
{:ok, _} = Arcadic.Import.database(conn, "file:///srv/exports/dump", with: [commitEvery: 10_000])The server must be able to reach the URL — ArcadeDB blocks private/loopback hosts by
default (surfaced as %Arcadic.Error{reason: :unauthorized, exception: "java.lang.SecurityException"},
distinct from an auth failure), so use a public URL or a server-local file:// path. with: settings
accept number, boolean, and charset-allowlisted string values (e.g. mapping: "map.json"), emitted as
ArcadeDB's no-parens WITH k = v grammar.
Arcadic.Export.database/3 is the symmetric server-side export — EXPORT DATABASE file://<name> to
ArcadeDB's exports directory. The bare name is path-traversal-guarded (value-free); with: settings
reuse Arcadic.Import's grammar.
{:ok, _} = Arcadic.Export.database(conn, "nightly_backup", with: [format: "jsonl", overwrite: true])Streaming & secure transport
Arcadic.query_stream/4 lazily streams a large read as raw row maps — over the default HTTP
transport or over Bolt.
{:ok, stream} =
Arcadic.query_stream(conn, "SELECT FROM User", %{}, language: "sql", chunk_size: 500)
stream |> Stream.each(&IO.inspect/1) |> Stream.run()Over HTTP, arcadic pages the statement itself behind the scenes — the statement must NOT carry
its own ORDER BY/SKIP/LIMIT, or a comment (--//* for SQL, // for Cypher, which would
neutralize the appended suffix), each rejected value-free. A WHERE-less SQL statement pages
by an O(n) arcadic-owned @rid keyset cursor; a statement with its own WHERE falls back to
ORDER BY @rid SKIP/LIMIT offset (O(n²) server-side — prefer a Bolt cursor for very large
exports in that case). Cypher streams via a caller-supplied order_key: "id(v)" (restricted
to id(<identifier>), the only total, unique order), offset-paged with Cypher $name
placeholders:
Arcadic.query_stream(conn, "MATCH (v:Person) RETURN v", %{}, language: "cypher", order_key: "id(v)")Either way, paging is a stable order, not a snapshot — a concurrent delete can skip a row across
pages. HTTP streaming refuses inside a transaction; in-transaction streaming is Bolt-only,
running over the transaction's own connection (so it sees the transaction's own uncommitted
writes) and guarded against interleaving a command/query on the same socket while a cursor is
open. Consume an in-transaction stream inside the transaction/3 body — it is bound to the
transaction's connection.
Bolt can also run over TLS: Arcadic.Transport.Bolt.setup(scheme: "bolt+s", ...) is secure by
default (verifies the server certificate against the OS trust store); pass
ssl_opts: [verify: :verify_none] to opt out (documents the MITM exposure — only for a trusted
network path, e.g. local dev).
Operator note — upstream ArcadeDB Bolt-TLS hazard. With ArcadeDB's Bolt-TLS listener enabled, a single untrusted-cert TLS handshake failure can wedge the server's shared Bolt listener: it pins a core at ~100% CPU and stops answering (no ServerHello) for every subsequent client until ArcadeDB is restarted. This is an ArcadeDB server defect, not an arcadic one — arcadic's client-side TLS (secure-by-default
verify_peer, fail-closed on an untrusted cert) is unaffected and live-verified. Tracked upstream at ArcadeData/arcadedb#5106.
Bolt transport (optional)
The query hot path can run over Bolt via the optional
boltx dependency. Add {:boltx, "~> 0.0.6"},
start a Bolt connection with Arcadic.Transport.Bolt.start_link/1 (it pins Bolt
v4 — versions: [4.4, 4.3, 4.2, 4.1] — and the non-TLS bolt scheme, which
ArcadeDB uses, and takes username/password), then pass the connection
reference. Server admin runs over HTTP; use an HTTP conn for it even when queries
go over Bolt.
{:ok, bolt} =
Arcadic.Transport.Bolt.start_link(
hostname: "localhost", port: 7687, username: "root", password: pass
)
conn =
Arcadic.connect("http://localhost:2480", "mydb",
auth: {"root", pass},
transport: Arcadic.Transport.Bolt,
transport_options: [bolt: bolt]
)For paging large result sets, Arcadic.query_stream/4 returns a lazy Stream.t()
of rows over Bolt, chunked via PULL.
Bolt raises if a BOLT_USER, BOLT_PWD, BOLT_HOST, or BOLT_TCP_PORT
environment variable is set — at pool setup (start_link/1/setup/1) and on every
connect/reconnect: boltx reads those with precedence over arcadic's explicit config and
re-reads them at connect time, so a var set after startup would otherwise silently
override the connection or its credentials; the connect-time reject closes that window.
Unset the variable and pass :scheme/:hostname/:port/:username/:password
explicitly.
Vector search is HTTP-only — Arcadic.Vector (LSM_VECTOR / LSM_SPARSE_VECTOR)
runs SQL, and Bolt is Cypher-only (a SELECT over Bolt is a syntax error; the Bolt
RUN carries no SQL-language selector), so run vector queries over the HTTP transport.
Layering
Ash core (multitenancy DSL, policies, the tenant concept)
│ passes tenant / builds queries
ash_arcadic (Ash.DataLayer — set_tenant/3, sensitive-attr verifiers, traversal)
│ calls
Arcadic ← this lib (HTTP Cypher transport, sessions/transactions — tenant-blind)
│ POST /api/v1/command/<db> {"language":"cypher", ...}
ArcadeDB (native OpenCypher engine)Installation
Add arcadic from Hex:
def deps do
[
{:arcadic, "~> 0.2"},
# optional, for the Bolt transport:
{:boltx, "~> 0.0.6"}
]
endArcadic is co-developed with its Ash data layer
ash_arcadic; to hack on both together, point at
a local checkout instead — {:arcadic, path: "../arcadic"}.
Development
mix deps.get
mix test
mix quality # format --check-formatted + credo --strict + dialyzer
To explore the full surface interactively against a local ArcadeDB, open the getting-started notebook (the Run in Livebook badge at the top launches it directly).
Contributor and agent working rules — including the params-only, redaction, and
tenant-blind invariants — live in
AGENTS.md.
Benchmarks
A load/traversal benchmark harness for ArcadeDB lives under
bench/ (not shipped in the package),
driven by Benchee against a throwaway database:
ARCADIC_BENCH_URL=<url> ARCADIC_BENCH_PASSWORD=<pw> mix run bench/run.exs
It profiles ingest throughput, k-hop traversal latency by depth, point lookups, and
throughput-under-concurrency. Methodology, knobs, and a full 100k-node result set are in
bench/README.md and
bench/RESULTS.md. Headline
figures — 100k Person / 1M KNOWS, single 2 GB-capped node, ArcadeDB 26.8.1, localhost, single
client (a profile, not a neo4j comparison):
| metric | figure |
|---|---|
| k-hop traversal p50 | 0.7 ms (1-hop) → 49 ms (4-hop, ~10k nodes reached) |
| point lookup p50 | ~0.6 ms (@rid and indexed uid within ~6% once settled) |
| client-write ingest | ~4.2k edges/s (RID-addressed; ~2.3× the naive uid-subquery path) |
Bulk loading
The ingest figure above is per-statement client writes. To load a large dataset, use
ArcadeDB's bulk facilities — far faster than any INSERT/CREATE EDGE loop:
Arcadic.Import.database/3—Arcadic.Import.database(conn, "https://…/data.csv.tgz")imports CSV / JSON / GraphML / Neo4j / OrientDB / ArcadeDB exports server-side. The URL is validated (positive character + scheme allowlist, value-free) rather than hand-interpolated, and must be reachable by the server (private/loopback hosts are blocked; use a public URL or afile://path). Optionalwith:number/boolean/string settings tune the load (e.g.with: [commitEvery: 10_000]).- for an index-deferred incremental load, order it yourself — create the type, bulk-load the
rows (a
command/4loop or oneArcadic.transaction/3), then create the index. ALSM_TREEor denseLSM_VECTORindex retro-indexes existing rows, but anLSM_SPARSE_VECTORindex must be created before the load (see Vector search); the correct ordering is index-type-specific, so arcadic ships no generic index-deferral helper. - for batched incremental writes, wrap them in
Arcadic.transaction/3(one commit for many statements) instead of auto-committing each.
Credits
- ArcadeDB — the multi-model database Arcadic speaks to.
- arcadex — prior-art ArcadeDB client that served as a reference for the HTTP command-API request/response shapes.
- boltx — the Bolt protocol driver behind the optional Bolt transport.
- Req / Finch — the HTTP client and pool behind the default transport.
- DBConnection — connection pooling for the Bolt transport.
The postgrex/ash_postgres split that inspired Arcadic and ash_arcadic is the
work of the Elixir Ecto and Ash communities.
License
MIT — see LICENSE.