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.fuse/3fuses heterogeneous dense, sparse, and full-text neighbor specs into one ranked result set. - Full-text search —
Arcadic.FullTextbuildsFULL_TEXT(Lucene) index DDL andSEARCH_INDEX/SEARCH_FIELDSquery builders (BM25$scoreon request); aFULL_TEXTindex retro-indexes rows that already exist. - Bulk ingest & typed params —
Arcadic.Bulk.ingest/3bulk-creates vertices and edges in one atomic NDJSON POST to ArcadeDB's/batchendpoint (edges wire by a structural"@id"temp key, resolved to real RIDs in the response);Arcadic.Param.int8/1/bytes/1wrap efficient$int8/$bytestyped param values for compact embedding/byte-array ingest. - 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. - Change events & server programmability —
Arcadic.Changesstreams ArcadeDB's live/wschange feed to a caller-supervised process (best-effort at-most-once, with in-band reconnect/overflow gap markers);Arcadic.Function/Arcadic.Triggerdefine server-side functions and triggers (DEFINE FUNCTION/CREATE TRIGGER);Arcadic.MaterializedViewcreates/drops materialized views from a rawSELECT;Arcadic.GeoaddsGEOSPATIALindex DDL over WKT-string properties. - Time-series —
Arcadic.TimeSerieswraps ArcadeDB's Influx-line-protocol writes, JSON query/latest reads, and PromQL read family (instant, range, labels, series) plusTIMESERIESDDL, downsampling policies, and continuous aggregates. Requires ArcadeDB ≥ 26.7.2. - Batteries included — server, security, and backup admin (
Arcadic.Server,Arcadic.Security,Arcadic.Backup), 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 here is
Cypher; SQL uses :name — see Parameter binding by language).
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}.
Parameter binding by language
Every dynamic value is a bound parameter, never string interpolation — but the
placeholder syntax is language-specific: SQL binds :name; Cypher binds $name.
The two fail differently if swapped: a $name placeholder in a language: "sql"
statement binds to null (ArcadeDB does not error — a silent mis-bind), while a
:name placeholder in Cypher (or any default-language call) is a parse error.
# SQL — :name
{:ok, rows} =
Arcadic.query(conn, "SELECT FROM User WHERE name = :name", %{"name" => "Jo"}, language: "sql")
# Cypher (the default language) — $name
{:ok, rows} =
Arcadic.query(conn, "MATCH (u:User {name: $name}) RETURN u", %{"name" => "Jo"})EXPLAIN & PROFILE
Arcadic.explain/4 returns a statement's execution plan without running it —
plan-only and side-effect-free, so it is safe to call on a write statement.
Arcadic.profile/4 executes the statement — a write mutates — and annotates the
plan with real runtime metrics. Both prepend EXPLAIN/PROFILE to the statement and
return {:ok, %{plan: String.t(), plan_tree: map(), rows: [map()]}}: plan is the
portable, human-readable plan string; plan_tree is the raw, transport-defined plan
structure (its shape differs between HTTP and Bolt); rows is [] for explain/4 and
the executed rows for profile/4. Both accept only :language (default "cypher") and
:timeout — :retries/:limit/:serializer are rejected value-free (a plan is not a
retried, paged, or serialized row set). Bolt support is Cypher-only, same as everywhere
else in arcadic.
{:ok, plan} = Arcadic.explain(conn, "MATCH (u:User) RETURN u")
plan.rows #=> []
{:ok, profiled} =
Arcadic.profile(conn, "CREATE (u:User {name: $name}) RETURN u", %{"name" => "Jo"})
profiled.rows #=> [%{"u" => %{...}}] # the write ranCalling query/4, command/4, or query_stream/4 on a statement that already carries
an EXPLAIN/PROFILE prefix now returns {:error, %Arcadic.Error{reason: :use_explain}}
(previously a silent {:ok, []}) — use explain/4/profile/4 instead.
Reliability: retry, read consistency & multi-host
Arcadic.transaction/3 accepts an opt-in :retry option. Off by default (unchanged
behavior); retry: true uses the defaults (max_attempts: 3, base_backoff_ms: 50, max_backoff_ms: 1000), or pass a keyword to override any of those. On a transient
server fault (:concurrent_modification, :not_leader, and a pre-commit :timeout)
it retries with jittered exponential backoff. The retried function must be
idempotent - it can run more than once before it succeeds or the attempts are
exhausted:
{:ok, _} =
Arcadic.transaction(
conn,
fn tx -> Arcadic.command!(tx, "MERGE (u:User {id: $id}) SET u.seen = $ts", %{"id" => "u1", "ts" => ts}) end,
retry: true
)Arcadic.Conn.with_consistency/2 (or connect(consistency: ...)) sets the
read-consistency level for subsequent reads: :eventual (default, sends no extra
header), :read_your_writes, or :linearizable. HTTP-only; a non-default level on a
Bolt conn raises. Pair :read_your_writes with query_bookmarked/4 /
command_bookmarked/4 (+ !, same opts as query/4/command/4) to guarantee a read
observes your own prior write. These return {:ok, rows, conn'} where conn'
carries a monotonically-advancing bookmark:
rw = Arcadic.Conn.with_consistency(conn, :read_your_writes)
{:ok, _rows, conn2} = Arcadic.command_bookmarked(rw, "CREATE (u:User {id: $id})", %{"id" => "u1"})
{:ok, rows} = Arcadic.query(conn2, "MATCH (u:User {id: $id}) RETURN u", %{"id" => "u1"})Thread conn2 forward - don't discard it. On a single-server deployment
:read_your_writes is a harmless no-op.
connect(hosts: [url2, url3, ...]) adds multi-host availability failover, not
load balancing (front a cluster with a load-balancer VIP for request distribution).
Reads fail over to the next host on any connection error; writes fail over only on a
pre-send connect error, never on an ambiguous post-send close. A session
(transaction/3) pins to whichever host answers first. Bookmarked calls target the
primary host and do not participate in failover (the bookmark is host-relative).
Change events & server programmability
Arcadic.Changes is arcadic's one caller-supervised process — start it under your
own supervision tree, then subscribe a database. It delivers each change as
{:arcadic_change, %Arcadic.Changes.Event{}} to a single subscriber pid.
Best-effort at-most-once. ArcadeDB's /ws feed has no replay or checkpoint: on
reconnect the process delivers a change_type: :reconnected marker (events during
the gap are gone), and on subscriber-side buffer overflow it delivers one
change_type: :overflow marker after dropping the oldest buffered events. Treat
either marker as an obligation to reconcile the affected database — the feed is a
change hint, not a durable log. A terminal 401 on the (re)handshake (auth expiry or
credential rotation) arrives as a distinct {:arcadic_change_error, :unauthorized}
message and then stops the process — terminal, not reconnected; the caller
re-establishes with fresh credentials.
children = [
{Arcadic.Changes, conn: conn, name: MyApp.Changes}
]
:ok = Arcadic.Changes.subscribe(MyApp.Changes, "mydb", change_types: [:create, :update])
receive do
{:arcadic_change, %Arcadic.Changes.Event{change_type: :reconnected}} -> reconcile()
{:arcadic_change, %Arcadic.Changes.Event{change_type: :overflow}} -> reconcile()
{:arcadic_change, %Arcadic.Changes.Event{change_type: :create, record: record}} -> handle(record)
{:arcadic_change, %Arcadic.Changes.Event{change_type: :update, record: record}} -> handle(record)
# terminal — the process has stopped; re-establish with fresh credentials
{:arcadic_change_error, :unauthorized} -> reauthenticate()
endArcadic.Function and Arcadic.Trigger define server-side JS/SQL logic
(DEFINE FUNCTION, CREATE TRIGGER); Arcadic.MaterializedView and Arcadic.Geo
round out the DDL surface. See usage-rules.md for the full
contract, including the single-line/single-quoted body limit shared by
Function/Trigger.
Time-series
Arcadic.TimeSeries is a client for ArcadeDB's time-series wire family (Influx
line-protocol write, JSON query/latest, PromQL reads) plus the TIMESERIES DDL,
downsampling policies, and continuous aggregates. Requires ArcadeDB ≥ 26.7.2
— an older server answers every /api/v1/ts route with a plain 404
(%Arcadic.Error{http_status: 404}).
now_ms = System.system_time(:millisecond) # write/query take epoch-MILLISECONDS
# +1: div/2 floors, and a Prometheus instant query excludes a sample at or after the eval instant
now_s = div(now_ms, 1000) + 1 # the PromQL family takes epoch-SECONDS
:ok =
Arcadic.TimeSeries.create_type!(conn, "sensor", "ts",
fields: [temperature: "DOUBLE", humidity: "DOUBLE"],
tags: [sensor_id: "STRING", location: "STRING"],
precision: :millisecond,
retention: {90, :days}
)
:ok =
Arcadic.TimeSeries.write(
conn,
[
%{
type: "sensor",
tags: %{"sensor_id" => "s1", "location" => "lab-a"},
fields: %{"temperature" => 21.5, "humidity" => 45.0},
timestamp: now_ms
}
],
precision: :ms
)
{:ok, %{columns: columns, rows: rows, count: count}} =
Arcadic.TimeSeries.query(conn, "sensor", from: now_ms - 3_600_000, to: now_ms + 3_600_000)
{:ok, %{columns: latest_columns, latest: latest}} =
Arcadic.TimeSeries.latest(conn, "sensor", tag: {"sensor_id", "s1"})
{:ok, %{"resultType" => "vector", "result" => result}} =
Arcadic.TimeSeries.prom_query(conn, ~S(sensor{sensor_id="s1"}), time: now_s)DDL and continuous aggregates (create_aggregate/3, refresh_aggregate/2,
drop_aggregate/2) ride Arcadic.command/4 SQL-only, like Arcadic.Schema;
write/write_lines, query/latest, and the PromQL family ride four
optional transport callbacks implemented by the HTTP transport only — Bolt
returns {:error, %Arcadic.Error{reason: :not_supported}}. Operational
contract (read before relying on retries or mixed-type batches): writes are
append-only and non-idempotent (a lost response followed by a naive retry
duplicates every point); a mixed-type batch silently drops any line naming
an unknown type (204, no error) as long as at least one line's type exists; an
unknown field name inserts a zero-filled row; an unknown tag key fails open
(ignored, not rejected) on query/3/latest/3. See
usage-rules.md for the full contract, the two distinct
precision grammars (DDL vs wire), and the server's known fields-projection
defect. The notebooks/timeseries.livemd
notebook walks the full surface, including pointing Prometheus/Grafana at
ArcadeDB.
Options reference
Which options each function accepts (an unknown key is rejected value-free):
| opt | query/4 | command/4 / command_async/4 | query_stream/4 | explain/4 / profile/4 |
|---|---|---|---|---|
:language | yes | yes | yes | yes |
:limit | yes | yes | no | no |
:serializer | yes | yes | no | no |
:retries | no | yes | no | no |
:auto_commit | no | yes | no | no |
:timeout | yes | yes | yes | yes |
:chunk_size | no | no | yes | no |
:order_key | no | no | yes (Cypher only) | no |
Errors
Non-bang calls return {:ok, rows} or {:error, %Arcadic.Error{} | %Arcadic.TransportError{}}.
Arcadic.Error.reason is one of:
:not_idempotent— a write submitted to the read endpoint (query/4):parse_error— a statement syntax error:unauthorized— an ArcadeDBSecurityException(an auth failure, or a blocked private/loopback import URL — see Schema introspection and bulk import):database_not_found:transaction_error— a server transaction fault, or a client-side session misuse (nesting, or a commit/rollback with no active session):concurrent_modification— an optimistic-concurrency or retry-needed conflict:duplicate_key:timeout— a server-side statement timeout (distinct from%Arcadic.TransportError{reason: :timeout}, the client-side connection timeout):not_leader- the target node is not the cluster leader and could not forward the write; a managed-retrytransaction/3and multi-host failover both treat it as retriable (the write was rejected, nothing applied):invalid_begin_body— an invalid:isolationvalue ontransaction/3:server_error— the generic fallback for an unmatched or absent ArcadeDB exception:use_explain—query/4/command/4/query_stream/4called on a statement that already carries anEXPLAIN/PROFILEprefix; callexplain/4/profile/4instead:not_supported— the active transport doesn't implement the called capability (e.g.explain/4on a transport without it, HTTP streaming inside a transaction, Bolt database admin) or the statement/opts fail a streaming-eligibility check
Arcadic.TransportError.reason is a connection-level failure with no HTTP response —
the underlying transport's own atom, not a fixed enum: for HTTP, whatever Mint/Finch
reports (e.g. :timeout, :closed, :econnrefused); for Bolt, :timeout (a RUN/PULL
receive timeout), :bolt_protocol_error, :transaction_error, :cursor_open /
:cursor_already_open (the stream-interleaving guard), a boltx error code, or
:unknown as a last-resort fallback.
A separate, non-Arcadic.Error convention: an invalid identifier (e.g. a bad type name
to Arcadic.Schema.properties/2) returns the bare {:error, :invalid_identifier}, never
echoing the offending string. The admin surface follows the same convention:
Arcadic.Server.set_server_setting/3 / set_database_setting/3 return
{:error, :invalid_setting_key} / {:error, :invalid_setting_value},
Arcadic.Backup.backup/2 / restore/3 return {:error, :invalid_url}, and
Arcadic.Security.create_user/2 returns {:error, :invalid_user_spec} for an unencodable
user spec (e.g. a non-UTF-8 password) — all value-free. Arcadic.Function.define/4 and
Arcadic.Trigger.create/4 return {:error, :unencodable_body} for a body ArcadeDB's
quoted DDL literal can't hold (a literal ", a backslash, or a newline); Arcadic.Changes
returns {:error, :mint_web_socket_not_available} from start_link/1 when the optional
mint_web_socket dependency is absent, and {:error, :subscriber_conflict} from
subscribe/3 for a second subscriber pid on an already-bound process.
Telemetry
arcadic emits value-free :telemetry.span/3 spans — metadata is validated against a
fixed allowlist (Arcadic.Telemetry.allowed_meta_keys/0): :language, :mode,
:http_status, :reason, :row_count, :in_transaction?, :isolation, :async?,
:operation. No statement, params, values, or database name ever rides telemetry.
[:arcadic, :query, :start | :stop | :exception]—query/4. Metadata::language,:mode(:read), plus:http_status/:reason/:row_counton:stop.[:arcadic, :command, :start | :stop | :exception]— bothcommand/4andcommand_async/4. Shared metadata::language,:mode(:write), plus:reasonon:stop.command/4also carries:in_transaction?(start) and:http_status/:row_count(on its success case);command_async/4instead carries:async? trueand, being fire-and-forget, has no rows to count.[:arcadic, :explain, :start | :stop | :exception]— bothexplain/4(:mode:read) andprofile/4(:mode:write, carries:in_transaction?, since PROFILE executes). Otherwise mirrors:query/:command;:row_countis0for a bare EXPLAIN.[:arcadic, :query_stream, :start]/[:arcadic, :query_stream, :stop]— every HTTP and Bolt stream path (manual:telemetry.execute/3events, not a span — no:exceptionvariant).:startmetadata is%{mode: :read};:stopcarries%{mode: :read, reason: :ok | :halted}(:okdrained,:haltedstopped early) plus a:row_countmeasurement.
arcadic also emits [:arcadic, :transaction, :start | :stop | :exception]
(transaction/3, metadata carrying :isolation), [:arcadic, :transaction, :retry]
(one per managed-retry attempt: :attempt measurement, :reason metadata) and the
standalone [:arcadic, :vector, :sparse_index_preexisting] event (see
Vector search) under the same allowlist.
[:arcadic, :admin, :start | :stop | :exception]— everyArcadic.Server,Arcadic.Security, andArcadic.Backupcall. Metadata::operation(the atom naming the call, e.g.:set_server_setting,:login,:backup) on:start, plus:reason(:ok, or the error'sreason/tag) on:stop. Value-free — no database name, setting key/value, URL, or credential ever rides this span.
:start measurements are :telemetry.span/3's standard :system_time/:monotonic_time;
:stop and :exception carry :duration/:monotonic_time. An :exception event's
metadata is the span's start metadata (not the :stop-only additions like :reason)
plus :kind/:reason/:stacktrace, added by :telemetry itself.
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]
)Administration & operations
Three modules cover ArcadeDB's server admin surface — all HTTP-only, tenant-blind,
and (like the rest of arcadic) never delegated from the Arcadic facade, so
destructive/privileged calls stay namespaced under Arcadic.Server,
Arcadic.Security, and Arcadic.Backup. Every identifier, setting key/value, and
URL they take is allowlist-validated before it reaches the wire.
Arcadic.Server
Database lifecycle: create_database/2 (+ !), drop_database/2 (+ !),
database_exists?/2, list_databases/1, open_database/2, close_database/2,
ready?/1. Server introspection and control: info/2 (opts mode: :basic | :default | :cluster — :default/:cluster add metrics/settings),
metrics/1 (the "metrics" map out of info(conn, mode: :default)),
health?/1 (liveness probe), events/1 (the server event log),
set_server_setting/3 / set_database_setting/3 (key/value both validated
value-free — a bad key or value returns {:error, :invalid_setting_key | :invalid_setting_value}, never echoing the offending string),
check_database/2 (fix: true runs CHECK DATABASE FIX; returns the
integrity report map), and profiler/2 (action ∈ :results | :start | :stop | :reset).
{:ok, info} = Arcadic.Server.info(conn, mode: :default)
{:ok, metrics} = Arcadic.Server.metrics(conn)
:ok = Arcadic.Server.set_database_setting(conn, "arcadedb.dateFormat", "yyyy-MM-dd")
{:ok, report} = Arcadic.Server.check_database(conn, fix: true)align_database/2 re-syncs a database across a cluster — cluster-only: on a
single-server node it returns a server error
({:error, %Arcadic.Error{reason: :server_error}}), since ArcadeDB has nothing
to align against. shutdown/1 halts the server; because the server stops
responding mid-request, a successful shutdown typically surfaces as
{:error, %Arcadic.TransportError{reason: :closed}} rather than :ok — treat a
transport-closed error from shutdown/1 as success, not a fault to retry.
Arcadic.Security
Session and identity admin. login/1 mints a session token from the conn's
credentials (POST /api/v1/login); pair it with Arcadic.Conn.with_bearer/2 to
derive a Bearer-authenticated conn for subsequent calls:
{:ok, token} = Arcadic.Security.login(conn)
bearer_conn = Arcadic.Conn.with_bearer(conn, token)
:ok = Arcadic.Security.logout(bearer_conn)with_bearer/2 raises ArgumentError on a Bolt conn — Bearer auth is
HTTP-only (Bolt authenticates from transport_options, never conn.auth).
sessions/1, users/1, groups/1, and api_tokens/1 list the corresponding
server admin resources. create_user/2 takes %{name:, password:, databases: %{db => [roles]}} (databases optional); the password is JSON-encoded into
the server command and never echoed back in an error, log, or telemetry
line — an unencodable spec (e.g. a non-UTF-8 password) is rejected value-free
as {:error, :invalid_user_spec} before any wire call. drop_user/2 removes a
user by name.
Arcadic.Backup
backup/2 runs BACKUP DATABASE on conn.database, with an optional :to
target URL overriding the server's default backup directory; list/1 lists
backups for conn.database; restore/3 runs restore database <name> <url>.
Both a :to target and a restore/3 URL are validated with
Arcadic.Identifier.validate_url/1 before interpolation (a URL cannot be a
bound parameter in these commands) — a bad one returns {:error, :invalid_url}
value-free.
{:ok, _} = Arcadic.Backup.backup(conn, to: "file:///backups/mydb.zip")
{:ok, ls} = Arcadic.Backup.list(conn)
{:ok, _} = Arcadic.Backup.restore(conn, "mydb_restored", "file:///backups/mydb.zip")SSRF note: whether the server itself blocks a private/loopback restore
source is server-config-dependent — restore/3's URL is trusted operator
input, not attacker-controlled data; do not pass it caller-supplied values.
:auto_commit
Arcadic.command/4 (and command_async/4) accept an :auto_commit boolean
opt, forwarded as-is to ArcadeDB's autoCommit request field — a faithful
passthrough, not arcadic-interpreted. auto_commit: false outside an explicit
transaction/3 means the write is not auto-committed (ArcadeDB's own
semantic).
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).
GraphRAG quickstart
Arcadic.Bulk, Arcadic.FullText, and Vector.fuse/3's heterogeneous specs combine into a
small graphRAG pipeline — bulk-load a graph, index it for full-text, then hybrid-retrieve
across a dense embedding arm and a full-text arm in one fused, ranked result set:
# 0. Declare the schema first — the batch endpoint needs the vertex/edge TYPEs to exist,
# and a FULL_TEXT index needs a declared property (a bulk-inserted value is schemaless).
Arcadic.command!(conn, "CREATE VERTEX TYPE Doc IF NOT EXISTS", %{}, language: "sql")
Arcadic.command!(conn, "CREATE EDGE TYPE RELATED IF NOT EXISTS", %{}, language: "sql")
Arcadic.command!(conn, "CREATE PROPERTY Doc.title IF NOT EXISTS STRING", %{}, language: "sql")
# 1. Bulk-create vertices + edges in one atomic POST — vertices carry a temporary "@id"
# that edges reference via "@from"/"@to"; the response maps each "@id" to its real RID.
{:ok, counts} =
Arcadic.Bulk.ingest(conn, [
%{"@type" => "vertex", "@class" => "Doc", "@id" => "d1", "title" => "Graph databases 101"},
%{"@type" => "vertex", "@class" => "Doc", "@id" => "d2", "title" => "Vector search primer"},
%{"@type" => "edge", "@class" => "RELATED", "@from" => "d1", "@to" => "d2"}
])
counts.id_mapping #=> %{"d1" => "#12:0", "d2" => "#12:1"}
# 2. Full-text index (retro-indexes the rows just created) + BM25-ranked search
:ok = Arcadic.FullText.create_index(conn, "Doc", "title")
{:ok, hits} = Arcadic.FullText.search(conn, "Doc", "title", "graph", with_score: true)
# 3. Hybrid fusion — a dense vector arm plus a full-text arm, fused by reciprocal-rank fusion.
# Prerequisites (see the vector docs above): a dense index on `Doc.embedding` via
# `Vector.create_dense_index!/5`, embeddings loaded on the docs, and `query_vector` = your
# query embedding (a list of floats). SEARCH_INDEX contributes the BM25-ranked full-text arm.
{:ok, fused} =
Arcadic.Vector.fuse(conn, [
{"Doc", "embedding", query_vector, 10},
{:fulltext, "Doc", "title", "graph", 10}
])The notebooks/graphrag.livemd notebook walks the full
surface end to end — bulk graph ingest, idempotent UNWIND upsert, dense + sparse vector
indexes, full-text search, hybrid fusion, INT8-quantized vectors (Arcadic.Param.int8/1),
and a Cypher multi-hop traversal.
Schema introspection and bulk import
Arcadic.Schema reflects the live schema, tenant-blind and @props-stripped. Every
query is arcadic's own fixed SELECT FROM schema:* literal, SQL-only (see
Parameter binding by language); a caller type name
binds as a SQL :name parameter (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-stripped
{:ok, stats} = Arcadic.Schema.stats(conn) # per-database operation counters (schema:stats)
{:ok, dict} = Arcadic.Schema.dictionary(conn) # the record dictionary (schema:dictionary)
{:ok, views} = Arcadic.Schema.materialized_views(conn) # schema:materializedviewsindexes/2 returns both logical and physical per-bucket rows (filter on the absence of
fileId for logical-only). stats/1 and dictionary/1 return a single map (not a row list);
materialized_views/1 returns a list (empty on a database with none).
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.
ArcadeDB
parallelScanAbandonedTimeoutcaveat. ArcadeDB aborts a server-side scan cursor left idle for roughly 10 minutes. A Boltquery_stream/4consumer that pauses betweenPULLs longer than that (slow per-row processing, backpressure) can have its cursor abandoned server-side mid-stream — keep pulling, or move per-row processing off the enumeration path so the stream itself isn't the bottleneck.
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 (fixed upstream 2026-07-08; ships in 26.7.2). On ArcadeDB builds predating the fix, the Bolt-TLS listener performed every TLS handshake on its single shared accept thread, so one bad handshake could deny Bolt to all clients: an early-closed connection sent the accept thread into a tight loop (~100% CPU), and a stalled or untrusted-cert handshake blocked every subsequent client (no ServerHello) until ArcadeDB was 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. Root-caused and fixed upstream onmain2026-07-08 (per-connection handshake threads + read timeout) — see ArcadeData/arcadedb#5106. If your server build predates the fix (including26.8.1-SNAPSHOTimages built before 2026-07-08), treat the hazard as present and upgrade: the wedge is condition-dependent (early-close/stalled handshakes trigger it; a cleanly-deliveredunknown_caalert does not), so a clean probe on a pre-fix build proves nothing.
Bolt transport (optional)
The query hot path can run over Bolt via the optional
boltx dependency. Add {:boltx, "~> 0.0.6"}, then
build the connection with Arcadic.Transport.Bolt.setup/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). setup/1 starts the pool AND returns the
transport_options for Arcadic.connect/3 in one call, carrying both :bolt (the pool,
used by execute/transaction/ready?) and :bolt_opts (the resolved per-stream
connect opts, used by query_stream/4) — pass its whole return value straight through
as transport_options. A bare transport_options: [bolt: pool] (skipping setup/1)
omits :bolt_opts and makes query_stream/4 return {:error, %Arcadic.Error{reason: :not_supported}}.
{:ok, transport_options} =
Arcadic.Transport.Bolt.setup(
hostname: "localhost", port: 7687, username: "root", password: pass
)
conn =
Arcadic.connect("http://localhost:2480", "mydb",
auth: {"root", pass},
transport: Arcadic.Transport.Bolt,
transport_options: transport_options
)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.7"},
# 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.