A framework-agnostic Elixir client for ArcadeDB over the HTTP Cypher command API.
What arcadic is (and is not)
- Is: a thin transport. Sends Cypher/SQL to ArcadeDB's HTTP command API, manages connections and session transactions, normalizes responses.
- Is not: Ash-aware, tenant-aware, or classification-aware. Never put
multitenancy or sensitive-data logic here — that is
ash_arcadic's job.
Public surface
Arcadic—connect/3,with_database/2;query/4+query!/4(idempotent read endpoint),command/4+command!/4(write endpoint — accepts an:auto_commitboolean opt, forwarded as-is to ArcadeDB'sautoCommitbody param, not arcadic-interpreted;auto_commit: falseoutsidetransaction/3means ArcadeDB itself does not auto-commit the write),command_async/4(fire-and-forget, returns:okon 202);explain/4+explain!/4(execution plan, does NOT run the statement) andprofile/4+profile!/4(EXECUTES the statement — a write mutates — plan annotated with runtime metrics), both returning{:ok, %{plan: String.t(), plan_tree: map(), rows: [map()]}}(planthe portable human string,plan_treethe raw transport-defined structure,rowsempty forexplain/4);transaction/3androllback/2for session transactions (transaction/3accepts an opt-in:retry, see Reliability below);query_bookmarked/4/command_bookmarked/4(+!) for read-your-writes bookmark threading. Non-bang calls return{:ok, rows}or{:error, %Arcadic.Error{} | %Arcadic.TransportError{}}. Default language is"cypher"; opt intosql/gremlin/graphql/mongo/sqlscriptper call.query/4/command/4/query_stream/4on a statement already carrying anEXPLAIN/PROFILEprefix return{:error, %Arcadic.Error{reason: :use_explain}}— callexplain/4/profile/4instead.Arcadic.Conn— a pure-data connection handle (no process). ItsInspectredacts auth and session id.with_database/2derives a same-pool handle on another database (clears the session);with_bearer/2derives a Bearer-authenticated handle from a Basic one (typically fedArcadic.Security.login/1's token) — HTTP-only, raisesArgumentErroron a Bolt conn (Bolt authenticates fromtransport_options, neverconn.auth).with_consistency/2derives a handle at a given read-consistency level, andconnect(hosts: [...])adds multi-host failover targets, both HTTP-only; see Reliability below.Arcadic.Server— server/database admin, HTTP-only, not delegated from theArcadicfacade:create_database/2(+!),drop_database/2(+!),database_exists?/2,list_databases/1,ready?/1,open_database/2,close_database/2,align_database/2(cluster-only — a single-server node returns{:error, %Arcadic.Error{reason: :server_error}}),check_database/2(fix: true→CHECK DATABASE FIX, returns the integrity map),info/2(mode: :basic | :default | :cluster),metrics/1,health?/1,events/1,set_server_setting/3/set_database_setting/3(key + value both validated value-free — see Errors below), andprofiler/2(action∈:results | :start | :stop | :reset).shutdown/1halts the server; a successful shutdown typically surfaces as{:error, %Arcadic.TransportError{reason: :closed}}(the server stops responding mid-request) rather than:ok— treat that as success, not a retryable fault.Arcadic.Security— session/identity admin, HTTP-only:login/1mints a session token (POST /api/v1/login) — feed it toArcadic.Conn.with_bearer/2for subsequent Bearer-authenticated calls;logout/1revokes the current session;sessions/1,users/1,groups/1,api_tokens/1list the corresponding admin resources;create_user/2takes%{name:, password:, databases: %{db => [roles]}}(databasesoptional) — the password is JSON-encoded into the server command and never echoed in an error, log, or telemetry line (an unencodable spec is rejected value-free as{:error, :invalid_user_spec});drop_user/2removes a user by name.Arcadic.Backup— backup/restore, HTTP-only:backup/2(BACKUP DATABASEonconn.database, optional:totarget URL),list/1(backups forconn.database),restore/3(restore database <name> <url>). A:totarget and arestore/3URL are bothArcadic.Identifier.validate_url/1- validated before interpolation (neither command can bind a URL param) — a bad one returns{:error, :invalid_url}. SSRF note: whether the server blocks a private/loopback restore source is server-config-dependent —restore/3's URL is trusted operator input, never pass it caller-supplied values.- Migrations —
Arcadic.Migration(behaviour:version/0,up/1,down/1),Arcadic.MigrationRegistry(use+migrations [...]),Arcadic.Migrator(migrate/2,status/2,rollback/3,reset/2,pending_migrations/2), tracking applied versions in_arcadic_migrations. Arcadic.Vector— dense + sparse vector search over ArcadeDBLSM_VECTOR/LSM_SPARSE_VECTOR:create_dense_index/5,drop_dense_index/3,neighbors/6,fuse/3,index_ref/2, pluscreate_sparse_index/5,sparse_index_ref/3,drop_sparse_index/4,sparse_neighbors/8(all +!). Tenant-blind; query vector / tokens / weights /k/ef_search/max_distancebind as params, index refs are identifier-validated, and metadata / query / fusion option inputs are allowlisted and validated value-free. Shared opts onneighbors/sparse_neighbors/fuse:filter(non-empty#bucket:posRID candidate set),group_by(Identifier-shape-guarded),group_size— all param-bound.distancescale is similarity-dependent;fuse/3andsparse_neighbors/8rank byscore(sparse rows carry nodistance). Create sparse indexes before loading rows — they do not retro-index existing data (a[:arcadic, :vector, :sparse_index_preexisting]telemetry event fires if you do).Arcadic.Schema— read-only schema introspection:types/1,properties/2,indexes/2(with a:typefilter),buckets/1,database/1(the engine config,schema:database),stats/1(schema:statsper-database operation counters, a single map),dictionary/1(schema:dictionary, a single map), andmaterialized_views/1(schema:materializedviews, a list) (all +!). SQL-onlySELECT FROM schema:*; a caller type name binds as a SQL:nameparam (never$name— see Parameter binding below) and isIdentifier-shape-guarded; ArcadeDB's@propsserializer noise is deep-stripped at every depth.indexes/2returns both logical and physical per-bucket rows (filter onfileIdabsence for logical-only).Arcadic.Import—database/3(+!):IMPORT DATABASEbulk load. The source URL is interpolated (ArcadeDB rejects a bound:url) behind a positive character + scheme (http/https/file) allowlist that closes the SQL-literal injection surface, value-free on rejection;with:takes number, boolean, and charset-allowlisted string settings (injection-inert), emitted as ArcadeDB's no-parensWITH k = vgrammar. A private/loopback host trips ArcadeDB's SSRF guard (:unauthorized/java.lang.SecurityException, distinct from an auth failure viaerror.exception);file://is server-local.Arcadic.Export—database/3(+!):EXPORT DATABASE file://<name>server-side, symmetric toArcadic.Import. The bare export name is guarded by a positive allowlist (no path / traversal / quote, value-free);with:settings reuse the import grammar (e.g.format: "jsonl",overwrite: true).- Streaming —
Arcadic.query_stream(conn, sql, params, language: "sql", chunk_size: 500)lazily streams a large read as raw row maps over the default HTTP transport. A streamable statement must NOT carry its ownORDER BY/SKIP/LIMIT, or a comment (--//*for SQL,//for Cypher, which would neutralize arcadic's appended suffix) — each rejected value-free (reason: :not_supported), as is a param named__arcadic_skip/__arcadic_limit(reserved).chunk_sizemust be a positive integer. A WHERE-less SQL statement pages by an O(n) arcadic-owned@ridkeyset cursor (WHERE @rid > <cursor> ORDER BY @rid LIMIT); a statement with its ownWHEREfalls back toORDER BY @rid SKIP/LIMIToffset (O(n²) — arcadic cannot inject a keyset predicate without parsing). Cypher streams via a caller-suppliedorder_key: "id(v)"(restricted toid(<identifier>), the only total, unique order), offset-paged with Cypher$nameplaceholders: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: each page is an independent stateless request, so a concurrent delete can skip a row — use a Bolt in-tx cursor for snapshot consistency. HTTP streaming refuses inside a transaction (session_idset) — in-tx streaming is Bolt-only, over the transaction's own connection (so it sees the transaction's own uncommitted writes), guarded so acommand/queryon that same conn cannot interleave an open cursor on the shared socket. Consume an in-tx stream INSIDE thetransaction/3body — it is bound to the transaction's connection and cannot be enumerated after the transaction returns. ArcadeDB aborts a server-side scan cursor idle for ~10 minutes (parallelScanAbandonedTimeout) — a Boltquery_stream/4consumer that pauses betweenPULLs longer than that can have its cursor abandoned mid-stream, so keep pulling. - Bolt TLS —
Arcadic.Transport.Bolt.setup(scheme: "bolt+s", ssl_opts: [...])runs Bolt over TLS.bolt+sis secure by default: it verifies the server certificate against the OS trust store (verify_peer) unless the caller passesssl_opts: [verify: :verify_none]— an explicit opt-in that accepts any certificate (documents the MITM exposure; only use it against a trusted network path, e.g. local dev). Omitting:schemestays on the plaintextboltscheme. Operator note (upstream, fixed 2026-07-08 → ships in 26.7.2): on ArcadeDB builds predating the fix, the Bolt-TLS listener ran every TLS handshake on its single shared accept thread — one early-closed connection pinned it in a tight loop (~100% CPU), and a stalled or untrusted-cert handshake blocked every other client (no ServerHello) until restart — an ArcadeDB server defect, not arcadic's (client-side TLS is unaffected). Fixed upstream (per-connection handshake threads + read timeout). If your server build predates the fix, treat the hazard as present — it is condition-dependent (early-close/stall trigger it; a cleanunknown_caalert exchange does not), so a clean probe proves nothing — and upgrade. Tracked at ArcadeData/arcadedb#5106. Arcadic.Transport— the transport behaviour seam;Arcadic.Transport.HTTP(Req/Finch) is the default,Arcadic.Transport.Boltis the optional Bolt one.Arcadic.Error/Arcadic.TransportError— the typed error taxonomy.Arcadic.Telemetry— value-free:telemetry.span/3spans.Arcadic.Identifier— allowlist identifier validation.Arcadic.Param—int8/1/bytes/1typed param-value wrappers (%{"$int8" => [...]}/%{"$bytes" => base64}), decoded server-side to a Javabyte[]before the query runs. HTTP-only, requires ArcadeDB ≥ 26.5.1.Arcadic.FullText—FULL_TEXT(Lucene) index DDL (create_index/4+drop_index/3) andSEARCH_INDEX/SEARCH_FIELDSquery builders (search/5,search_fields/5), parallel toArcadic.Vector. HTTP-only SQL; aFULL_TEXTindex retro-indexes rows that already exist.:with_score(BM25$score) applies tosearch/5(SEARCH_INDEX) only —SEARCH_FIELDShas no relevance score, sosearch_fields/5with:with_scoreprojects a constant0.0.Arcadic.Bulk—ingest/3(+!): bulk-creates vertices and edges over ArcadeDB'sPOST /api/v1/batch/<db>NDJSON endpoint, the heavy-ingest sibling ofArcadic.Import.database. Create-only, atomic by default, HTTP-only.Arcadic.Vector.fuse/3now accepts heterogeneous neighbor specs — a bare{type, property, query_vector, k}dense arm, a{:sparse, type, tokens_property, weights_property, tokens, weights, k}arm, and/or a{:fulltext, type, property, query, k}arm — fused in one hybrid-ranked result set (seeArcadic.Vectorabove and Bulk loading below).Arcadic.Geo—GEOSPATIALindex DDL:create_index/4(type,property, idempotentIF NOT EXISTSunlessif_not_exists: false) anddrop_index/3(IF EXISTS), both +!. The index sits on a string property holding WKT (ArcadeDB has no nativePOINTschema type) — see Geospatial indexing & functions below for the querying side.Arcadic.Function—DEFINE FUNCTION/DELETE FUNCTIONDDL:define/4(namea dottedlibrary.fn, validated per segment;body; a single trailingoptskeyword list —:paramsan atom/string list,:language:jsdefault |:sql|:cypher) anddelete/2, both +!. There is no call wrapper (a query template, a charter non-goal) — invoke a defined function inside an ordinaryquery/4/command/4via the backtick idiom:SELECT `lib.fn`(:a, :b)(SQL) — the name is interpolated behind the per-segment allowlist, arguments rideparams. Body is single-line and single-quoted — a substrate limit, not an arcadic narrowing: ArcadeDB's"..."body literal has no escape (a literal", a backslash, or a newline all parse-error server-side), so a body needing any of those is rejected value-free before any wire call.Arcadic.Trigger—CREATE TRIGGER/DROP TRIGGERDDL:create/4(name,type,optsall required::timing:before/:after,:event:create/:delete/:update/:read,:executea{lang, code}tuple withlang:sql/:javascript/:java) anddrop/2(noIF EXISTS— dropping a missing trigger is a server error), both +!. SharesArcadic.Function's single-line/single-quoted body substrate limit — same reject-not-escape guard.Arcadic.MaterializedView—CREATE MATERIALIZED VIEW/DROP MATERIALIZED VIEWDDL:create/3(name, a rawselect_sqlstring emitted verbatim — unlikeFunction/Trigger, this is trailing SQL, not a quoted DDL literal, so an internal single-quoted string in aWHEREclause is legitimate and passes through) anddrop/2(noIF EXISTS), both +!.Arcadic.Changes— a caller-supervisedGenServerclient for ArcadeDB's live/wschange-events feed:start_link/1(:conn,:name,:max_buffer, default 1000) andsubscribe/3/unsubscribe/2(:typefilter,:change_typessubset of[:create, :update, :delete],:subscriberpid — one subscriber per process, a conflicting second subscriber gets{:error, :subscriber_conflict}). Delivers{:arcadic_change, %Arcadic.Changes.Event{}}to the subscriber pid. See Change events below for the reliability contract.Arcadic.TimeSeries— ArcadeDB time-series client:TIMESERIESDDL (create_type/4,drop_type/2,add_downsampling/3,drop_downsampling/2), continuous aggregates (create_aggregate/3,refresh_aggregate/2,drop_aggregate/2), Influx line-protocol writes (write/3,write_lines/3), reads (query/3,latest/3), and the PromQL family (prom_query/3,prom_query_range/6,prom_labels/2,prom_label_values/3,prom_series/3) — all +!. Requires ArcadeDB ≥ 26.7.2 (an older server 404s every/api/v1/tsroute). DDL and continuous-aggregate statements rideArcadic.command/4SQL-only (likeArcadic.Schema); the write/query/PromQL wire family rides 4 optional transport callbacks, HTTP-only. See Time-series below for the full operational contract.
Bulk loading
- For a large initial load, prefer ArcadeDB's server-side import over an
INSERT/CREATE EDGEloop:Arcadic.Import.database(conn, "https://host/export.jsonl.tgz")imports CSV / JSON / GraphML / Neo4j / OrientDB / ArcadeDB exports. The source URL is validated (positive character + scheme allowlist, value-free) rather than hand-interpolated — do NOT hand-build anIMPORT DATABASE '<url>'string, which reopens the injection surface. The URL must be reachable by the SERVER; ArcadeDB blocks private/loopback hosts by default, so use a public URL or a server-localfile://. 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 onetransaction/3), then create the index — aLSM_TREE/denseLSM_VECTORindex retro-indexes existing rows, but aLSM_SPARSE_VECTORindex must be created BEFORE the load (seeArcadic.Vector). arcadic ships no generic index-deferral helper because the correct ordering is index-type-specific. - For batched incremental writes, wrap them in
transaction/3(one commit for many statements) instead of auto-committing eachcommand/4. - Choosing a bulk-write path. Three options, in order of what they optimize for:
Arcadic.Bulk.ingest/3(POST /api/v1/batch) — records held client-side, one atomic NDJSON POST. Vertices carry a structural"@id"temp key that edges reference via"@from"/"@to"; the response'sid_mappingmaps each temp"@id"to its assigned real RID. Create-only (no dedup) — a retry after a lost response duplicates every record. Best for a graph you're building in one shot from in-memory data.Arcadic.Import.database/3— server-side fetch of a CSV/JSON/GraphML/ Neo4j/OrientDB/ArcadeDB export. Best for large or already-serialized loads (the server streams it, not the client).- The idempotent
UNWIND $rowsidiom — for a bulk upsert (as opposed to create-only), unwind a list-of-maps param throughMERGE:
Safe to replay —Arcadic.command(conn, "UNWIND $rows AS r MERGE (n:T {id: r.id}) SET n += r.props", %{"rows" => rows})MERGEmatches existing rows instead of duplicating them, unlikeArcadic.Bulk.ingest/3.
Reliability: retry, consistency & multi-host
- Managed retry.
transaction/3acceptsretry: true(defaults:max_attempts: 3, base_backoff_ms: 50, max_backoff_ms: 1000) or a keyword overriding any of those. Off by default (unchanged behavior). 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:
A{: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 )MERGE-based upsert body is retry-safe; a body with a side effect outside the transaction (e.g. a non-idempotent external call) is not. - Read consistency & bookmarks.
Arcadic.connect(..., consistency: level)orArcadic.Conn.with_consistency(conn, level)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 raisesArgumentError. Pair:read_your_writeswith the bookmarked calls (query_bookmarked/4/command_bookmarked/4, same opts asquery/4/command/4) to guarantee a read observes your own prior write: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"})conn2carries the monotonically-advancing bookmark - thread it forward, don't discard it. The:read_your_writeslevel is REQUIRED for the guarantee: on a plain:eventualconn the bookmark is still captured intoconn2but never SENT (theX-ArcadeDB-Read-Afterheader rides only a:read_your_writesconn), so a lagging replica can still serve a stale read - bookmarking is inert without the level. On a single-server deployment:read_your_writesis a harmless no-op (there is no replica lag to guard against). - Multi-host availability failover.
connect(hosts: [url2, url3, ...])adds failover targets. 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, so a write is never blindly resent to a second host after it may already have landed on the first). A session (transaction/3) pins to whichever host answers first. This is availability failover, not load balancing; front a cluster with a load-balancer VIP if you want request distribution across hosts. Bookmarked calls (query_bookmarked/4/command_bookmarked/4) target the primary host and do not participate in failover (the bookmark is host-relative); pointbase_urlat a load balancer, or use non-bookmarkedquery/4/command/4when you need failover.
Server-side programmability: functions, triggers & materialized views
Arcadic.Function (DEFINE FUNCTION/DELETE FUNCTION) and Arcadic.Trigger
(CREATE TRIGGER/DROP TRIGGER) both embed a caller body as a "..." DDL
string literal. That literal has no escape — ArcadeDB parse-errors on a
literal double-quote, a backslash, or a newline inside it — so the body must be
a single line, single-quoted (i.e. use '...' for any string literal
inside the body, never "..."). This is a substrate limit, not something
arcadic could lift: ArcadeDB's own end-to-end tests use only single-line,
single-quoted-JS bodies. A body that needs a double quote, a backslash, or a
newline is rejected value-free as {:error, :unencodable_body} before any wire
call — restructure it (e.g. drop the newline, single-quote your JS strings)
rather than trying to escape it.
:ok = Arcadic.Function.define(conn, "math.sum", "return a + b;", params: [:a, :b])
:ok =
Arcadic.Trigger.create(conn, "logCreate", "User",
timing: :after,
event: :create,
execute: {:javascript, "print('user created: ' + record.name);"}
)Arcadic.MaterializedView.create/3 is different: its select_sql is raw
trailing SQL, not a quoted DDL literal, so a 'single-quoted string' inside a
WHERE clause is ordinary SQL and passes through unmodified — injection safety
instead rests on ArcadeDB's single-statement backstop (a ;-separated second
statement is a parse error).
:ok = Arcadic.MaterializedView.create(conn, "activeUsers", "SELECT FROM User WHERE active = true")Arcadic.Trigger.drop/2 and Arcadic.MaterializedView.drop/2 take no IF EXISTS (probe-confirmed) — dropping a name that doesn't exist is a server
error, unlike Arcadic.Function.delete/2 (idempotent server-side) or
Arcadic.Geo's index drop.
Change events (Arcadic.Changes)
Arcadic.Changes is arcadic's one caller-supervised process — start it
under your own supervision tree (start_link/1, opts :conn/:name/
:max_buffer), then subscribe/3 a database. It presents conn.auth on the
/ws handshake and pushes each change to a single subscriber pid as
{:arcadic_change, %Arcadic.Changes.Event{}}.
Reliability contract: best-effort at-most-once, stated plainly. ArcadeDB's
/ws feed has no replay and no checkpoint. On every reconnect (dropped socket,
re-established) the process delivers a change_type: :reconnected marker
before re-subscribing — any events that occurred during the gap are gone.
On buffer overflow (:max_buffer, default 1000 — a slow subscriber) it drops
the oldest buffered events and delivers one change_type: :overflow
marker (database: nil, since the drop can span the whole subscription).
Receiving either marker is not optional to handle — it obligates the
subscriber to reconcile the affected database against current state, because
the feed is a change hint, not a durable log. A terminal 401/403 on the
(re)handshake (auth expiry, credential rotation, or a forbidden principal) is
delivered as a distinct {:arcadic_change_error, :unauthorized} message and then
stops the process — it is terminal, not reconnected (the caller must
re-establish with fresh credentials). A server-side rejection of a
subscribe/unsubscribe (an error frame) arrives as a non-terminal
{:arcadic_change_error, :subscribe_rejected} (the socket stays open; the
server's error text is never forwarded). A subscribe with a different
:subscriber than the one already bound is rejected
{:error, :subscriber_conflict}; the bound subscriber's exit stops the process.
start_link/1 also rejects a malformed :conn value-free
(:invalid_auth / :invalid_url_scheme / :invalid_max_buffer) — notably an
unrecognized URL scheme is refused rather than silently downgraded to plaintext.
The buffer bounds arcadic's own memory (a slow subscriber never wedges the
server); it does not bound the subscriber's mailbox — a persistently slow
consumer must reconcile on the markers and shed load itself.
{:ok, pid} = Arcadic.Changes.start_link(conn: conn)
:ok = Arcadic.Changes.subscribe(pid, "mydb", change_types: [:create, :update])
receive do
{:arcadic_change, %Arcadic.Changes.Event{change_type: :reconnected}} ->
# reconcile — events during the gap are lost, not replayed
:ok
{:arcadic_change, %Arcadic.Changes.Event{change_type: :overflow}} ->
# reconcile — this subscriber fell behind and the oldest events were dropped
:ok
{:arcadic_change, %Arcadic.Changes.Event{change_type: :create, record: record}} ->
handle_create(record)
{:arcadic_change, %Arcadic.Changes.Event{change_type: :update, record: record}} ->
handle_update(record)
{:arcadic_change_error, :unauthorized} ->
# terminal — the process has stopped; re-establish with fresh credentials
:stopped
endThe WebSocket client rides the optional mint_web_socket dependency —
start_link/1 returns {:error, :mint_web_socket_not_available} at runtime
if it isn't in your deps (the module itself always compiles).
Geospatial indexing & functions
Arcadic.Geo.create_index/4 creates a GEOSPATIAL index over a string
property holding WKT ("POINT (x y)", "LINESTRING (...)", etc.) — this
ArcadeDB build has no native POINT schema type, so geospatial data is stored
as WKT text and indexed as such. Querying rides ordinary query/4/command/4;
Arcadic.Geo has no query builder (a query template is a charter non-goal).
Function names verified against the ArcadeDB engine source
(engine/.../function/{geo,sql/geo}/) and, for the constructor/distance set,
live-confirmed callable:
- Live-confirmed callable (bare names):
point,linestring,polygon,rectangle,circle,distance. In Cypher (arcadic's default language),point(x, y)andpoint({longitude:, latitude:})/point({x:, y:})build a point map;distance(p1, p2)returns great-circle metres for WGS-84 points (Haversine) or Euclidean distance for Cartesian ones.linestring/polygon/circle/rectanglebuild WKT strings. - Source-registered, not independently live-tested this slice — the
geo.*-namespaced predicate family:geo.contains,geo.crosses,geo.disjoint,geo.dWithin,geo.equals,geo.intersects,geo.overlaps,geo.touches,geo.within. These have no bare alias (unlikepoint/linestring/polygon/circle/rectangle/distance, which are registered under both ageo.-namespaced primary name and a bare backward-compatibility alias) — verify live before depending on one in production.
:ok = Arcadic.Geo.create_index(conn, "Place", "wkt")
Arcadic.command!(conn, "CREATE (p:Place {wkt: $wkt})", %{"wkt" => "POINT (-122.4 37.8)"})
# distance(p1, p2) rides ordinary query/4 — no arcadic query builder
{:ok, [%{"d" => meters}]} =
Arcadic.query(conn, "RETURN distance(point(-122.4, 37.8), point(-122.0, 37.3)) AS d")Time-series
The write/read/PromQL wire family rides four optional transport callbacks
(ts_write/3, ts_query/3, ts_latest/3, ts_prom_get/4) implemented by
the HTTP transport only — Bolt returns
{:error, %Arcadic.Error{reason: :not_supported}} for all four — and a
pre-26.7.2 server's missing /api/v1/ts routes land as a plain
%Arcadic.Error{http_status: 404} (the function surface and version floor
are on the Arcadic.TimeSeries bullet in Public surface above).
Two distinct precision grammars — do not conflate them. create_type/4's
:precision opt takes DDL tokens (:second | :millisecond | :microsecond | :nanosecond, omit → server default nanosecond); write/3/write_lines/3's
:precision opt takes wire tokens (:ns default | :us | :ms | :s) declaring
the unit of the timestamps in the body — each rejects the other's tokens.
query/3's :from/:to are always epoch-milliseconds (integer or
DateTime, converted), regardless of the type's declared DDL :precision —
the raw wire 400s an ISO-8601 string (through query/3 a string raises a
client-side ArgumentError before any request), and an epoch value in the
wrong unit (e.g. nanoseconds) silently returns an empty result, no error.
prom_query/3's
:time and prom_query_range/6's from/to/step are epoch-seconds
(PromQL convention) — do not reuse query/3's millisecond values there. A
PromQL instant query's eval-time floor also matters: Prometheus excludes a
sample written at or after the eval instant, so a sample written at epoch-ms
t0 needs time: div(t0, 1000) + 1, not div(t0, 1000), to be visible.
latest/3 takes at most one tag. The server applies only the first
tag=k:v query parameter and silently ignores the rest (order-dependent —
probed both orders return different rows), so a multi-entry tags map would be
a nondeterministic filter; latest/3's :tag opt is a single {key, value}
pair, rejected value-free if given more than one. The tag value may contain
colons — the server splits the wire key:value on the first colon and
matches the remainder exactly (probed 2026-07-11); an empty value is still
rejected value-free (it would match nothing deterministically-uselessly).
Operational contract — write path (every clause live-probed on 26.7.2):
- Append-only, non-idempotent. No dedup, no upsert, no server-assigned id:
the identical point written twice is TWO rows. A lost response followed by a
naive retry duplicates every point in the body (the same
non-confirmability class as
Arcadic.Bulk.ingest/3). Verify with a windowedquery/3count before retrying an unconfirmed write. - Mixed-body partial swallow. When at least one line's type exists, lines
naming an UNKNOWN type are silently dropped (HTTP 204, no error). The
loud 400
Unknown timeseries type(s)fires only when every line's type is unknown.write/3guarantees syntactic validity by construction but cannot know server type existence (tenant-blind, no schema cache) — a typo'dtype:in a mixed batch is silent; verify withquery/3orArcadic.Schema.types/1. - Unknown FIELD zero-fill. A line whose field name is not on the type inserts a zero-filled row (204, no error).
- int64 bound. An integer field value or timestamp outside signed int64
(±9223372036854775807/8) is a 204 + silent line drop server-side (probed
both signs). As of the S13 closeout,
write/3raises a value-freeArgumentErrorclient-side instead — including on aDateTimewhose converted timestamp overflows (e.g. year 2263+ at:ns); previously the out-of-range line was silently dropped server-side. - Unknown tag KEY fails open on
query/3/latest/3— the filter is ignored server-side rather than rejected. write_lines/3(raw passthrough) additionally inherits the malformed-line silent-skip: a syntactically bad line is dropped, not rejected.
Known upstream defect (26.7.2) — fields projection. query/3's
:fields projection returns a columns list carrying the right names but row
values misaligned under a wrong-width header (pending an upstream fix).
Until fixed, treat a :fields-projected
query's row values as unverified — omit :fields (the
default, full-width columns) when the values matter.
Non-negotiable rules
- Parameters only. Every dynamic value goes into the request
paramsmap and is referenced by a placeholder in the statement —$namefor Cypher,:namefor SQL (see Parameter binding below; never interpolate a value into a Cypher/SQL string — that is a query-injection defect). This holds forquery/4,command/4,command_async/4,query_stream/4,explain/4,profile/4,query_bookmarked/4,command_bookmarked/4, and insidetransaction/3. - Redact at the boundary. Errors and logs carry structure only.
Arcadic.Errorexposes a typedreason,http_status, andexceptionclass; itsdetailfield is quarantined (absent frommessage/1andinspect/1).Arcadic.TransportErrorcarries only the value-free reason atom. Never surface raw parameter values or response rows. - Validate identifiers. Database names and other identifiers reaching a URL
path or statement go through
Arcadic.Identifier.validate/1first (a failure carries the invalid-shape fact only, never the offending string). Values are never identifiers — they rideparams.
Parameter binding
SQL binds :name; Cypher binds $name. A $name placeholder in a
language: "sql" statement binds to null (ArcadeDB does not error — a silent
mis-bind); a :name placeholder in Cypher (or any default-language call) is a
parse error.
# SQL
Arcadic.query(conn, "SELECT FROM User WHERE name = :name", %{"name" => n}, language: "sql")
# Cypher (default language)
Arcadic.query(conn, "MATCH (u:User {name: $name}) RETURN u", %{"name" => n})Typed param-value wrappers (Arcadic.Param). A param value that is a single-key
%{"$int8" => list} or %{"$bytes" => base64} map is decoded server-side to a byte[]
before the query runs — Arcadic.Param.int8/1 / bytes/1 build these. The statement
still references the parameter by the normal placeholder (:name/$name). HTTP-only
(inert over Bolt) and requires ArcadeDB ≥ 26.5.1. Ambient single-key-collision caveat:
ArcadeDB decodes any single-key {"$int8" => …} / {"$bytes" => …} value it finds in
params, whether or not it came from Arcadic.Param — a legitimate caller value that
happens to be exactly a single-key map with one of those keys is reinterpreted as a
byte[]; add a second key to a map you want left untouched.
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
Arcadic.Error.reason: :not_idempotent (write via query/4), :parse_error,
:unauthorized (auth failure, or a blocked private/loopback import URL),
:database_not_found, :transaction_error (server fault, or client-side session
misuse), :concurrent_modification, :duplicate_key, :timeout (server-side
statement timeout — distinct from the client-side TransportError below),
: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, since nothing was applied), :invalid_begin_body (bad
:isolation on transaction/3), :server_error
(generic fallback), :use_explain (call explain/4/profile/4 instead), and
:not_supported (the transport lacks the called capability, e.g. explain/4
without a transport impl, HTTP streaming in 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.
A separate, non-Arcadic.Error convention: value-free bare-atom validation
failures, never echoing the offending value. {:error, :invalid_identifier}
(Arcadic.Identifier.validate/1 — e.g. a bad type name to
Arcadic.Schema.properties/2, or a bad database/user name on the admin
surface); {:error, :invalid_setting_key} / {:error, :invalid_setting_value}
(Arcadic.Server.set_server_setting/3 / set_database_setting/3);
{:error, :invalid_url} (Arcadic.Backup.backup/2's :to target and
restore/3's source URL); {:error, :invalid_user_spec}
(Arcadic.Security.create_user/2 — an unencodable user spec, e.g. a non-UTF-8
password); and, from Arcadic.Bulk.ingest/3, {:error, :invalid_record} (a
record that fails to encode), {:error, :not_supported} (the transport has no
batch endpoint, e.g. Bolt), and {:error, :unexpected_response} (a non-map 2xx
body — off-contract). Arcadic.Function.define/4 / Arcadic.Trigger.create/4
return {:error, :unencodable_body} for a body ArcadeDB's "..." DDL literal
cannot hold (a literal ", a backslash, or a newline). Arcadic.Changes
returns {:error, :mint_web_socket_not_available} from start_link/1 (the
optional mint_web_socket dependency is absent) and {:error, :subscriber_conflict} from subscribe/3 (a second subscriber pid on an
already-bound process).
Telemetry
Value-free :telemetry.span/3 spans; metadata is validated against the fixed
allowlist in 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.[:arcadic, :command, :start | :stop | :exception]—command/4andcommand_async/4(the latter's metadata carries:async? true).[:arcadic, :explain, :start | :stop | :exception]—explain/4(:mode:read) andprofile/4(:mode:write, carries:in_transaction?, since PROFILE executes).[:arcadic, :query_stream, :start]/[:arcadic, :query_stream, :stop]— every HTTP and Bolt stream path (manual:telemetry.execute/3events, not a span — no:exceptionvariant);:stopcarriesreason: :ok | :haltedplus a:row_countmeasurement.[:arcadic, :transaction, :start | :stop | :exception]—transaction/3(metadata carries:isolation).[:arcadic, :transaction, :retry]- one per managed-retry attempt ontransaction/3(retry:opt);:attemptmeasurement,:reasonmetadata (the retriable error reason that triggered the attempt).[:arcadic, :vector, :sparse_index_preexisting]— seeArcadic.Vectorabove.[:arcadic, :admin, :start | :stop | :exception]— everyArcadic.Server/Arcadic.Security/Arcadic.Backupcall (metadata carries:operation, the atom naming the call, e.g.:login,:set_database_setting,:restore, plus:reasonon:stop).[:arcadic, :bulk, :start | :stop | :exception]—Arcadic.Bulk.ingest/3(:stopcarries:row_count, the sum of vertices + edges created).
:start measurements are :telemetry.span/3's standard :system_time/
:monotonic_time; :stop/:exception carry :duration/:monotonic_time.
Bolt transport (optional)
The Arcadic.Transport.Bolt adapter (optional boltx dependency) runs the query
hot path over Bolt. Build it with Arcadic.Transport.Bolt.setup/1, which pins Bolt
to v4 (versions: [4.4, 4.3, 4.2, 4.1] — ArcadeDB speaks v4; boltx defaults to
v5), uses the non-TLS bolt scheme (ArcadeDB Bolt is TLS-disabled by default),
and takes username/password. setup/1 starts the pool AND returns the
transport_options for Arcadic.connect/3 in one call — [bolt: pool, bolt_opts: resolved] — carrying both the pool (:bolt, for execute/transaction/ready?)
and the resolved per-stream connect opts (:bolt_opts, for query_stream/4); pass
its return value straight through as transport_options. Do NOT hand-build
transport_options: [bolt: pool] alone (start_link/1's bare return) — it omits
:bolt_opts and makes query_stream/4 return {:error, %Arcadic.Error{reason: :not_supported}}.
Admin (Arcadic.Server, Arcadic.Security, Arcadic.Backup) is HTTP-only —
use an HTTP conn for admin even when queries run over Bolt (with_bearer/2 also
raises on a Bolt conn). Vector search is HTTP-only too —
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 keep vector queries on the HTTP transport.
BOLT_* env vars are rejected. arcadic raises if BOLT_USER, BOLT_PWD,
BOLT_HOST, or BOLT_TCP_PORT is set in the environment — 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 var and pass
:scheme/:hostname/:port/:username/:password explicitly.
See AGENTS.md for the full working rules and the verified ArcadeDB HTTP contract.