Hex.pm Hexdocs Run in Livebook

A lean, framework-agnostic Elixir client for ArcadeDB over the HTTP Cypher command API, with optional Bolt and gRPC transports for the query hot path, streaming, and bulk ingest.

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 into sql, gremlin, graphql, mongo, or sqlscript per 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 redactionArcadic.Error carries a typed reason, HTTP status, and error class; raw parameter values and response rows never enter an error message, log line, or inspect/1 output.
  • Session transactionstransaction/3 opens an ArcadeDB session and commits on normal return, rolls back and reraises on exception (postgrex semantics).
  • Pluggable transport — HTTP (Req/Finch) by default, plus two optional transports: Bolt v4 (boltx) for the query hot path and lazy streaming, and gRPC (Arcadic.Transport.Grpc, behind optional :grpc/:protobuf deps) — a real server-cursor query_stream, transactions, graph + document bulk ingest (Arcadic.Bulk/Arcadic.Ingest), single-record CRUD (Arcadic.Record), admin, and an opt-in shared-channel pool. Select with transport: Arcadic.Transport.Grpc and a grpc:// URL; HTTP-only consumers are unaffected (the transport is compile-guarded).
  • 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-set filter and group_by/group_size shaping, all params-only and value-free. fuse/3 fuses heterogeneous dense, sparse, and full-text neighbor specs into one ranked result set.
  • Full-text searchArcadic.FullText builds FULL_TEXT (Lucene) index DDL and SEARCH_INDEX/SEARCH_FIELDS query builders (BM25 $score on request); a FULL_TEXT index retro-indexes rows that already exist.
  • Bulk ingest & typed paramsArcadic.Bulk.ingest/3 bulk-creates vertices and edges in one atomic NDJSON POST to ArcadeDB's /batch endpoint (edges wire by a structural "@id" temp key, resolved to real RIDs in the response); Arcadic.Param.int8/1 / bytes/1 wrap efficient $int8/$bytes typed 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 programmabilityArcadic.Changes streams ArcadeDB's live /ws change feed to a caller-supervised process (best-effort at-most-once, with in-band reconnect/overflow gap markers); Arcadic.Function / Arcadic.Trigger define server-side functions and triggers (DEFINE FUNCTION / CREATE TRIGGER); Arcadic.MaterializedView creates/drops materialized views from a raw SELECT; Arcadic.Geo adds GEOSPATIAL index DDL over WKT-string properties.
  • Time-seriesArcadic.TimeSeries wraps ArcadeDB's Influx-line-protocol writes, JSON query/latest reads, and PromQL read family (instant, range, labels, series) plus TIMESERIES DDL, 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 ran

Calling 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()
end

Arcadic.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 and gRPC return {: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):

optquery/4command/4 / command_async/4query_stream/4explain/4 / profile/4
:languageyesyesyesyes
:limityesyesnono
:serializeryesyesnono
:retriesnoyesnono
:auto_commitnoyesnono
:timeoutyesyesyesyes
:chunk_sizenonoyesno
:order_keynonoyes (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 ArcadeDB SecurityException (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-retry transaction/3 and multi-host failover both treat it as retriable (the write was rejected, nothing applied)
  • :invalid_begin_body — an invalid :isolation value on transaction/3
  • :server_error — the generic fallback for an unmatched or absent ArcadeDB exception
  • :use_explainquery/4/command/4/query_stream/4 called on a statement that already carries an EXPLAIN/PROFILE prefix; call explain/4/profile/4 instead
  • :not_supported — the active transport doesn't implement the called capability (e.g. explain/4 on 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_count on :stop.

  • [:arcadic, :command, :start | :stop | :exception] — both command/4 and command_async/4. Shared metadata: :language, :mode (:write), plus :reason on :stop. command/4 also carries :in_transaction? (start) and :http_status/:row_count (on its success case); command_async/4 instead carries :async? true and, being fire-and-forget, has no rows to count.

  • [:arcadic, :explain, :start | :stop | :exception] — both explain/4 (:mode :read) and profile/4 (:mode :write, carries :in_transaction?, since PROFILE executes). Otherwise mirrors :query/:command; :row_count is 0 for a bare EXPLAIN.

  • [:arcadic, :query_stream, :start] / [:arcadic, :query_stream, :stop] — every HTTP and Bolt stream path (manual :telemetry.execute/3 events, not a span — no :exception variant). :start metadata is %{mode: :read}; :stop carries %{mode: :read, reason: :ok | :halted} (:ok drained, :halted stopped early) plus a :row_count measurement.

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] — every Arcadic.Server, Arcadic.Security, and Arcadic.Backup call. Metadata: :operation (the atom naming the call, e.g. :set_server_setting, :login, :backup) on :start, plus :reason (:ok, or the error's reason/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)

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:materializedviews

indexes/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 parallelScanAbandonedTimeout caveat. ArcadeDB aborts a server-side scan cursor left idle for roughly 10 minutes. A Bolt query_stream/4 consumer that pauses between PULLs 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 on main 2026-07-08 (per-connection handshake threads + read timeout) — see ArcadeData/arcadedb#5106. If your server build predates the fix (including 26.8.1-SNAPSHOT images 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-delivered unknown_ca alert 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-onlyArcadic.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"}
  ]
end

Arcadic 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):

metricfigure
k-hop traversal p500.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/3Arcadic.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 a file:// path). Optional with: 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/4 loop or one Arcadic.transaction/3), then create the index. A LSM_TREE or dense LSM_VECTOR index retro-indexes existing rows, but an LSM_SPARSE_VECTOR index 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.