arcadic usage rules

Copy Markdown View Source

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

  • Arcadicconnect/3, with_database/2; query/4 + query!/4 (idempotent read endpoint), command/4 + command!/4 (write endpoint — accepts an :auto_commit boolean opt, forwarded as-is to ArcadeDB's autoCommit body param, not arcadic-interpreted; auto_commit: false outside transaction/3 means ArcadeDB itself does not auto-commit the write), command_async/4 (fire-and-forget, returns :ok on 202); explain/4 + explain!/4 (execution plan, does NOT run the statement) and profile/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()]}} (plan the portable human string, plan_tree the raw transport-defined structure, rows empty for explain/4); transaction/3 and rollback/2 for session transactions (transaction/3 accepts 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 into sql/gremlin/graphql/mongo/ sqlscript per call. query/4/command/4/query_stream/4 on a statement already carrying an EXPLAIN/PROFILE prefix return {:error, %Arcadic.Error{reason: :use_explain}} — call explain/4/profile/4 instead.
  • Arcadic.Conn — a pure-data connection handle (no process). Its Inspect redacts auth and session id. with_database/2 derives a same-pool handle on another database (clears the session); with_bearer/2 derives a Bearer-authenticated handle from a Basic one (typically fed Arcadic.Security.login/1's token) — HTTP-only, raises ArgumentError on a Bolt conn (Bolt authenticates from transport_options, never conn.auth). with_consistency/2 derives a handle at a given read-consistency level, and connect(hosts: [...]) adds multi-host failover targets, both HTTP-only; see Reliability below.
  • Arcadic.Server — server/database admin, HTTP-only, not delegated from the Arcadic facade: 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: trueCHECK 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), and profiler/2 (action:results | :start | :stop | :reset). shutdown/1 halts 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/1 mints a session token (POST /api/v1/login) — feed it to Arcadic.Conn.with_bearer/2 for subsequent Bearer-authenticated calls; logout/1 revokes the current session; sessions/1, users/1, groups/1, api_tokens/1 list the corresponding 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 in an error, log, or telemetry line (an unencodable spec is rejected value-free as {:error, :invalid_user_spec}); drop_user/2 removes a user by name.
  • Arcadic.Backup — backup/restore, HTTP-only: backup/2 (BACKUP DATABASE on conn.database, optional :to target URL), list/1 (backups for conn.database), restore/3 (restore database <name> <url>). A :to target and a restore/3 URL are both Arcadic.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.
  • MigrationsArcadic.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 ArcadeDB LSM_VECTOR / LSM_SPARSE_VECTOR: create_dense_index/5, drop_dense_index/3, neighbors/6, fuse/3, index_ref/2, plus create_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_distance bind as params, index refs are identifier-validated, and metadata / query / fusion option inputs are allowlisted and validated value-free. Shared opts on neighbors / sparse_neighbors / fuse: filter (non-empty #bucket:pos RID candidate set), group_by (Identifier-shape-guarded), group_size — all param-bound. distance scale is similarity-dependent; fuse/3 and sparse_neighbors/8 rank by score (sparse rows carry no distance). 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 :type filter), buckets/1, database/1 (the engine config, schema:database), stats/1 (schema:stats per-database operation counters, a single map), dictionary/1 (schema:dictionary, a single map), and materialized_views/1 (schema:materializedviews, a list) (all + !). SQL-only SELECT FROM schema:*; a caller type name binds as a SQL :name param (never $name — see Parameter binding below) and is Identifier-shape-guarded; ArcadeDB's @props serializer noise is deep-stripped at every depth. indexes/2 returns both logical and physical per-bucket rows (filter on fileId absence for logical-only).
  • Arcadic.Importdatabase/3 (+ !): IMPORT DATABASE bulk 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-parens WITH k = v grammar. A private/loopback host trips ArcadeDB's SSRF guard (:unauthorized / java.lang.SecurityException, distinct from an auth failure via error.exception); file:// is server-local.
  • Arcadic.Exportdatabase/3 (+ !): EXPORT DATABASE file://<name> server-side, symmetric to Arcadic.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).
  • StreamingArcadic.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 own ORDER 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_size must be a positive integer. A WHERE-less SQL statement pages by an O(n) arcadic-owned @rid keyset cursor (WHERE @rid > <cursor> ORDER BY @rid LIMIT); a statement with its own WHERE falls back to ORDER BY @rid SKIP/LIMIT offset (O(n²) — arcadic cannot inject a keyset predicate without parsing). 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: 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_id set) — in-tx streaming is Bolt-only, over the transaction's own connection (so it sees the transaction's own uncommitted writes), guarded so a command/query on that same conn cannot interleave an open cursor on the shared socket. Consume an in-tx stream INSIDE the transaction/3 body — 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 Bolt query_stream/4 consumer that pauses between PULLs longer than that can have its cursor abandoned mid-stream, so keep pulling.
  • Bolt TLSArcadic.Transport.Bolt.setup(scheme: "bolt+s", ssl_opts: [...]) runs Bolt over TLS. bolt+s is secure by default: it verifies the server certificate against the OS trust store (verify_peer) unless the caller passes ssl_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 :scheme stays on the plaintext bolt scheme. 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 clean unknown_ca alert 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, with two optional transports: Arcadic.Transport.Bolt (Bolt v4 via boltx) and Arcadic.Transport.Grpc (via :grpc/:protobuf).
  • Arcadic.Error / Arcadic.TransportError — the typed error taxonomy.
  • Arcadic.Telemetry — value-free :telemetry.span/3 spans.
  • Arcadic.Identifier — allowlist identifier validation.
  • Arcadic.Paramint8/1 / bytes/1 typed param-value wrappers (%{"$int8" => [...]} / %{"$bytes" => base64}), decoded server-side to a Java byte[] before the query runs. HTTP-only, requires ArcadeDB ≥ 26.5.1.
  • Arcadic.FullTextFULL_TEXT (Lucene) index DDL (create_index/4 + drop_index/3) and SEARCH_INDEX/SEARCH_FIELDS query builders (search/5, search_fields/5), parallel to Arcadic.Vector. HTTP-only SQL; a FULL_TEXT index retro-indexes rows that already exist. :with_score (BM25 $score) applies to search/5 (SEARCH_INDEX) only — SEARCH_FIELDS has no relevance score, so search_fields/5 with :with_score projects a constant 0.0.
  • Arcadic.Bulkingest/3 (+ !): bulk-creates vertices and edges over ArcadeDB's POST /api/v1/batch/<db> NDJSON endpoint, the heavy-ingest sibling of Arcadic.Import.database. Create-only, atomic by default, HTTP-only.
  • Arcadic.Vector.fuse/3 now 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 (see Arcadic.Vector above and Bulk loading below).
  • Arcadic.GeoGEOSPATIAL index DDL: create_index/4 (type, property, idempotent IF NOT EXISTS unless if_not_exists: false) and drop_index/3 (IF EXISTS), both + !. The index sits on a string property holding WKT (ArcadeDB has no native POINT schema type) — see Geospatial indexing & functions below for the querying side.
  • Arcadic.FunctionDEFINE FUNCTION / DELETE FUNCTION DDL: define/4 (name a dotted library.fn, validated per segment; body; a single trailing opts keyword list — :params an atom/string list, :language :js default | :sql | :cypher) and delete/2, both + !. There is no call wrapper (a query template, a charter non-goal) — invoke a defined function inside an ordinary query/4/command/4 via the backtick idiom: SELECT `lib.fn`(:a, :b) (SQL) — the name is interpolated behind the per-segment allowlist, arguments ride params. 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.TriggerCREATE TRIGGER / DROP TRIGGER DDL: create/4 (name, type, opts all required: :timing :before/:after, :event :create/:delete/:update/:read, :execute a {lang, code} tuple with lang :sql/:javascript/:java) and drop/2 (no IF EXISTS — dropping a missing trigger is a server error), both + !. Shares Arcadic.Function's single-line/single-quoted body substrate limit — same reject-not-escape guard.
  • Arcadic.MaterializedViewCREATE MATERIALIZED VIEW / DROP MATERIALIZED VIEW DDL: create/3 (name, a raw select_sql string emitted verbatim — unlike Function/Trigger, this is trailing SQL, not a quoted DDL literal, so an internal single-quoted string in a WHERE clause is legitimate and passes through) and drop/2 (no IF EXISTS), both + !.
  • Arcadic.Changes — a caller-supervised GenServer client for ArcadeDB's live /ws change-events feed: start_link/1 (:conn, :name, :max_buffer, default 1000) and subscribe/3 / unsubscribe/2 (:type filter, :change_types subset of [:create, :update, :delete], :subscriber pid — 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: TIMESERIES DDL (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/ts route). DDL and continuous-aggregate statements ride Arcadic.command/4 SQL-only (like Arcadic.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 EDGE loop: 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 an IMPORT 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-local file://. 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 transaction/3), then create the index — a LSM_TREE/dense LSM_VECTOR index retro-indexes existing rows, but a LSM_SPARSE_VECTOR index must be created BEFORE the load (see Arcadic.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 each command/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's id_mapping maps 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 $rows idiom — for a bulk upsert (as opposed to create-only), unwind a list-of-maps param through MERGE:
      Arcadic.command(conn, "UNWIND $rows AS r MERGE (n:T {id: r.id}) SET n += r.props", %{"rows" => rows})
      Safe to replay — MERGE matches existing rows instead of duplicating them, unlike Arcadic.Bulk.ingest/3.

Reliability: retry, consistency & multi-host

  • Managed retry. transaction/3 accepts retry: 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:
    {: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
      )
    A 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) or Arcadic.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 raises ArgumentError. Pair :read_your_writes with the bookmarked calls (query_bookmarked/4 / command_bookmarked/4, same opts as query/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"})
    conn2 carries the monotonically-advancing bookmark - thread it forward, don't discard it. The :read_your_writes level is REQUIRED for the guarantee: on a plain :eventual conn the bookmark is still captured into conn2 but never SENT (the X-ArcadeDB-Read-After header rides only a :read_your_writes conn), so a lagging replica can still serve a stale read - bookmarking is inert without the level. On a single-server deployment :read_your_writes is 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); point base_url at a load balancer, or use non-bookmarked query/4/command/4 when 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
end

The 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) and point({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/rectangle build 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 (unlike point/ linestring/polygon/circle/rectangle/distance, which are registered under both a geo.-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 windowed query/3 count 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/3 guarantees syntactic validity by construction but cannot know server type existence (tenant-blind, no schema cache) — a typo'd type: in a mixed batch is silent; verify with query/3 or Arcadic.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/3 raises a value-free ArgumentError client-side instead — including on a DateTime whose 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 params map and is referenced by a placeholder in the statement — $name for Cypher, :name for SQL (see Parameter binding below; never interpolate a value into a Cypher/SQL string — that is a query-injection defect). This holds for query/4, command/4, command_async/4, query_stream/4, explain/4, profile/4, query_bookmarked/4, command_bookmarked/4, and inside transaction/3.
  • Redact at the boundary. Errors and logs carry structure only. Arcadic.Error exposes a typed reason, http_status, and exception class; its detail field is quarantined (absent from message/1 and inspect/1). Arcadic.TransportError carries 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/1 first (a failure carries the invalid-shape fact only, never the offending string). Values are never identifiers — they ride params.

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

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

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/4 and command_async/4 (the latter's metadata carries :async? true).

  • [:arcadic, :explain, :start | :stop | :exception]explain/4 (:mode :read) and profile/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/3 events, not a span — no :exception variant); :stop carries reason: :ok | :halted plus a :row_count measurement.
  • [:arcadic, :transaction, :start | :stop | :exception]transaction/3 (metadata carries :isolation).

  • [:arcadic, :transaction, :retry] - one per managed-retry attempt on transaction/3 (retry: opt); :attempt measurement, :reason metadata (the retriable error reason that triggered the attempt).
  • [:arcadic, :vector, :sparse_index_preexisting] — see Arcadic.Vector above.
  • [:arcadic, :admin, :start | :stop | :exception] — every Arcadic.Server / Arcadic.Security / Arcadic.Backup call (metadata carries :operation, the atom naming the call, e.g. :login, :set_database_setting, :restore, plus :reason on :stop).

  • [:arcadic, :bulk, :start | :stop | :exception]Arcadic.Bulk.ingest/3 (:stop carries :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 tooArcadic.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.

gRPC transport (optional)

Arcadic.Transport.Grpc runs over ArcadeDB's gRPC plugin (GrpcServerPlugin), behind the optional {:grpc, "~> 0.11"} + {:protobuf, "~> 0.17"} deps (the transport and its vendored protobuf stubs are compile-guarded — HTTP/Bolt-only consumers who don't add them are unaffected). Select it with a grpc://host:port URL (or grpcs:// for TLS) and transport: Arcadic.Transport.Grpc; credentials come from Conn.auth ({user, pass} — a bearer conn is rejected, as with Bolt).

conn = Arcadic.connect("grpc://localhost:50051", "mydb",
         transport: Arcadic.Transport.Grpc, auth: {"root", pw})

Its reason to exist is query_stream/4 — a real server cursor (StreamQuery CURSOR: O(n), server-paced, language-agnostic), where HTTP streaming offset-pages (O(n²) general case) and Bolt streams Cypher only. It implements the full surface it can: execute/query_stream, transactions (begin/commit/rollback + tx-scoped reads/writes), graph bulk ingest (Arcadic.Bulk.ingest via GraphBatchLoad), document ingest (Arcadic.Ingest via BulkInsert/InsertStream), single-record CRUD (Arcadic.Record), admin (Arcadic.Server list_databases/database_exists?/create_database/ drop_database/info/database_info + Arcadic.explain/profile).

  • Value-free — errors carry an atom reason, never the gRPC wire message; per-row ingest errors surface only %{row_index, code} (a categorical code), never the value.
  • Auth planes — data-plane RPCs authenticate by x-arcade-* metadata; admin-plane RPCs by the body credentials field. TLS is verify_peer when enabled (secure scheme or transport_options: [tls: true]); plaintext otherwise, so prefer a secure scheme in production (credentials travel on the wire).
  • Transactions fail closed for bulk ingestBulk.ingest/Ingest.insert inside transaction/3 return {:error, %Arcadic.Error{reason: :transaction_unsupported}} (BulkInsert/GraphBatchLoad do not honor an outer session tx), rather than silently auto-commit outside it; single-record CRUD DOES join the tx. Use an UNWIND $rows/INSERT statement for transactional bulk writes.
  • Channel pooling — add {Arcadic.Transport.Grpc.ChannelPool, []} to your supervision tree to reuse one long-lived HTTP/2-multiplexed channel per endpoint; absent it, each call opens a fresh channel.
  • HTTP-only — server settings, user management (unimplemented server-side over gRPC), token login/logout, time-series, and HA read-consistency return :not_supported; use an HTTP conn for those.

See AGENTS.md for the full working rules and the verified ArcadeDB HTTP contract.