Current section
Files
Jump to
Current section
Files
usage-rules.md
# arcadic usage rules
_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_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: 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), 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.
- **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 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.Import`** — `database/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.Export`** — `database/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`).
- **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 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 `PULL`s 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+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](https://github.com/ArcadeData/arcadedb/issues/5106).
- **`Arcadic.Transport`** — the transport behaviour seam; `Arcadic.Transport.HTTP`
(Req/Finch) is the default, `Arcadic.Transport.Bolt` is the optional Bolt one.
- **`Arcadic.Error` / `Arcadic.TransportError`** — the typed error taxonomy.
- **`Arcadic.Telemetry`** — value-free `:telemetry.span/3` spans.
- **`Arcadic.Identifier`** — allowlist identifier validation.
- **`Arcadic.Param`** — `int8/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.FullText`** — `FULL_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.Bulk`** — `ingest/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.Geo`** — `GEOSPATIAL` 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.Function`** — `DEFINE 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.Trigger`** — `CREATE 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.MaterializedView`** — `CREATE 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`:
```elixir
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:
```elixir
{: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:
```elixir
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.
```elixir
: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).
```elixir
: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.
```elixir
{: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.
```elixir
: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**.
```elixir
# 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/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 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.