AshArcadic
An Ash FrameworkDataLayer for
ArcadeDB — native OpenCypher over the HTTP command API.
AshArcadic is the "ash_postgres of ArcadeDB": define Ash resources backed by an
ArcadeDB graph store, with multitenancy, data-classification, and graph traversal
enforced Ash-natively. It executes through the
arcadic client (the transport — the
"postgrex of ArcadeDB").
Status: feature-complete for 0.1.0, pre-publish. The full remaining-capabilities roadmap has shipped (11 slices, 692 tests incl. live-ArcadeDB integration, dialyzer clean). Working rules for contributors are in
AGENTS.md; consumer usage rules (the fine print per feature) are inusage-rules.md.
Try it in 5 minutes:livebooks/tour.livemd — a
runnable Livebook tour of the whole surface (CRUD → queries → keyset streaming →
concurrent bulk → multitenancy → vector search → graph traversal) against a
throwaway database. Every cell is verified against a live ArcadeDB.
What it supports
| Area | Capabilities |
|---|---|
| CRUD & upserts | create/read/update/destroy; idempotent MERGE upsert (incl. composite PKs); atomic SET on create/update/upsert ({:atomic, …}) |
| Bulk writes | bulk_create; multi-row bulk upsert; heterogeneous update_many; query-scoped update_query/destroy_query push-down (one Cypher statement); MVCC conflict retry at two levels — concurrent Ash.bulk_* with transaction: false converges deterministically |
| Query push-down | filters (==/!=/>/</>=/<=/in/is_nil, boolean logic, string-match, arithmetic/concat/if expressions), sort (orderable-storage allowlist), limit/offset, distinct/distinct_sort, combinations (union/union_all/intersect/except) |
| Pagination | offset + keyset (page: [after:/before: …], Ash.stream!, count: true) — cursor-correct across duplicate sort values, per stored type incl. microsecond temporals |
| Temporal | datetime/time comparisons compare temporal-to-temporal (ArcadeDB coerces stored ISO8601 → native; params are wrapped in datetime()/localtime()) |
| Aggregates | query aggregates (count/sum/avg/min/max/first/list/exists) + relationship aggregates over graph traversals (post-authz Elixir fold) |
| Calculations | expression calculations — loaded (Elixir eval) and in filters/sorts (Cypher push-down), fail-closed on sensitive/non-stored refs |
| Relationships | standard attribute-FK rels (belongs_to/has_many/has_one/many_to_many); graph traversal as a manual relationship (Traverse: direction, depth ranges, per-source limit/offset, per-hop tenant scoping + per-hop authorization); first-class edge writes (create_edge/destroy_edge changes, edge properties) |
| Vector search | dense kNN (vector_index), sparse/learned-sparse (sparse_vector_index), hybrid fusion (dense + sparse + full-text arms) — all fail-closed tenant-scoped via a self-injected candidate set |
| Multitenancy | :attribute (discriminator column) and :context (physical DB per tenant), fail-closed everywhere — reads, writes, bulk, traversal, vector, keyset cursors |
| Data classification | sensitive attributes (encrypted-binary contract, compile-verified); value-free redacted errors — no value, tenant, or byte ever reaches an error/log |
| Transactions | session transactions (Ash.DataLayer.transaction), lazy session open, rollback-safe |
| Concurrency | :async_engine — Ash runs independent loads/aggregates concurrently (pool-proven safe) |
| Observability | :telemetry spans for reads/writes/traversals/vector with value-free metadata |
Deliberate non-goals: {:lateral_join} (Ash bypasses it for manual traversal rels),
window-function inline aggregates, transport concerns (pooling, gRPC — those live in
arcadic). Per-feature limitations are documented in
usage-rules.md.
Layering
Ash core multitenancy DSL, policies, the tenant concept
│
AshArcadic ← HERE Ash.DataLayer: tenancy, classification, traversal, query compile
│
arcadic HTTP Cypher transport, sessions/transactions — tenant-blind
│
ArcadeDB native OpenCypher engine (97.8% TCK)
Multitenancy lives here, not in arcadic — exactly as ash_postgres (not
postgrex) owns schema-based tenancy.
Installation
Not yet on Hex (0.1.0 imminent). Until then, a path/git dependency:
# mix.exs
def deps do
[
{:ash, "~> 3.0"},
{:ash_arcadic, path: "../ash_arcadic"} # or git: ...
]
end
Connecting to ArcadeDB
AshArcadic asks the host app for a connection handle through one small behaviour —
the repo analog. The host owns the URL, credentials, and database name; the data
layer asks only for the handle (called per operation; Arcadic.Conn is pure data,
so this is cheap):
defmodule MyApp.ArcadicClient do
@behaviour AshArcadic.Client
@impl true
def conn do
Arcadic.connect(
System.fetch_env!("ARCADEDB_URL"), # e.g. "http://localhost:2480"
"my_database",
auth: {"root", System.fetch_env!("ARCADEDB_PASSWORD")}
)
end
end
Everything Arcadic.connect/3 supports rides along (multi-host failover, read
consistency levels, Bolt transport…) — see arcadic's docs. For :context
multitenancy the database given here is the base database; tenant databases are
resolved per call.
Quick start
defmodule MyApp.Doc do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshArcadic.DataLayer
arcade do
client MyApp.ArcadicClient
label :Doc # vertex label (defaults to module short name)
# sensitive [:ssn_cipher] # classified attrs (must be encrypted binaries)
# vector_index :embedding, dimensions: 1536, similarity: :cosine
end
attributes do
attribute :id, :string, primary_key?: true, allow_nil?: false, public?: true
attribute :org_id, :string, public?: true
attribute :title, :string, public?: true
attribute :score, :integer, public?: true
end
multitenancy do
strategy :attribute # or :context for a physical DB per tenant
attribute :org_id
end
actions do
default_accept [:id, :org_id, :title, :score]
defaults [:create, :update, :destroy]
read :read do
primary? true
pagination keyset?: true, offset?: true, countable: true, required?: false
end
end
end
# Everything is plain Ash:
Ash.create!(MyApp.Doc, %{id: "d1", title: "Hello", score: 10}, tenant: "org1")
MyApp.Doc
|> Ash.Query.filter(score > 5)
|> Ash.Query.sort(score: :desc)
|> Ash.read!(tenant: "org1", page: [limit: 20, count: true])
# Stream a large set with bounded memory (keyset pagination under the hood):
MyApp.Doc |> Ash.Query.sort(score: :asc) |> Ash.stream!(tenant: "org1") |> Enum.take(1_000)
# Concurrent bulk load that converges even under write contention:
Ash.bulk_create!(rows, MyApp.Doc, :create, transaction: false, max_concurrency: 8, tenant: "org1")
Relationship to ash_age
ash_age (Ash data layer for Apache AGE) is the design reference: its
multitenancy, sensitive-attribute verifiers, and traversal-as-manual-
relationship patterns port here. One key divergence:ash_age bans MERGE
(an Apache AGE performance bug); AshArcadic usesMERGE for idempotent
upsert — ArcadeDB's native OpenCypher MERGE is verified correct.
Development
mix deps.get
mix test # unit suite (no server needed)
ARCADIC_TEST_URL=http://localhost:2480 mix test # + live ArcadeDB integration
mix quality # format --check-formatted + credo --strict + dialyzer
The integration suite provisions throwaway databases per test module and drops them on exit; it never touches existing data.
License
MIT — see LICENSE.