An Elixir client for the ash_typescript RPC protocol. A backend publishes
a versioned Ash.Info.Manifest JSON artifact describing its RPC-exposed
resources, types, actions, and filter/sort capabilities. mix ash_remote.gen
consumes that manifest and generates standalone Ash resources — data parts
mirrored, actions as stubs — backed by AshRemote.DataLayer, which speaks
/rpc/run and /rpc/validate. Generated resources depend only on ash +
ash_remote and compile in any Ash app.
backend (ash + Ash.Info.Manifest) client (ash + ash_remote)
AshRemote.Server.manifest_json/1 ──► manifest.json ──► mix ash_remote.gen ──► generated resources
(served at GET /manifest.json) │
/rpc/run, /rpc/validate ◄──────────────── AshRemote.DataLayer ◄───────────────┘
The manifest comes from AshRemote.Server.manifest_json/1 (served by
AshRemote.Server.Router at GET /manifest.json), which enriches Ash core's
serializer output with the action names, relationship attributes, and
validations the generator needs — the raw mix ash.manifest.dump output alone
is not sufficient.
AshRemote.Gen translates that manifest into AshCloner.Definitions.
AshCloner then creates or updates the client modules, including output paths,
missing entities, and drift handling. All cloning and merge behavior lives in
the in-repo packages/ash_cloner package.
- The client's
Ash.Query/changeset is encoded into an RPC body (AshRemote.Encode.{Fields,Filter,Sort,Pagination}), sent viaAshRemote.Transport(defaultReq), and the response decoded back into resource structs. Calculation and aggregate loads fold into the field selection; relationship loads currently use Ash's batched follow-up reads (one request per relationship). - The server is authoritative: generated action stubs carry no changes/validations; the backend does the real casting, authorization, and persistence.
Generate resources from a manifest:
mix ash_remote.gen --manifest path/to/manifest.json --namespace MyApp.RemoteConfigure the base URL (resolved lazily at call time, so one codebase works per env):
# config/runtime.exs
config :ash_remote, base_url: System.fetch_env!("REMOTE_BASE_URL")Then call the generated resources like any Ash resource:
MyApp.Remote.Todo
|> Ash.Query.filter(completed == false)
|> Ash.Query.load([:comment_count, :is_overdue, user: [:name]])
|> Ash.read!()Validations declared on backend resources are mirrored onto the generated client
resources when they're expressible as data (builtin validations with literal
options, including where-guarded ones) — so forms and changesets fail fast
client-side, while the server re-validates every write. Changes and lifecycle
hooks are never mirrored; they run only on the server.
Debug the wire traffic by logging every RPC request (URL, resource/action, outcome, duration, request/response bodies):
# config/dev.exs
config :ash_remote, debug_requests: true[debug] ash_remote: POST http://127.0.0.1:4010/rpc/run TodoServer.Todo.read → ok (4.2ms)
request: %{"action" => "read", ...}
response: %{"data" => [...], "success" => true}
Beyond request/response, ash_remote can replicate server-side Ash
notifications to connected clients over Phoenix Channels: when a mutation
happens on the server, each subscribed client re-emits an equivalent local
%Ash.Notifier.Notification{} on its proxy resource — so client resources can
declare ordinary notifiers (Ash.Notifier.PubSub → LiveView, …) and they fire
as if the mutation were local.
Model: notifications are invalidation hints; reads are truth. Delivery is
at-most-once with no replay — a reconnect gap loses events, and the
:resubscribed lifecycle event is the documented "refetch now" signal.
# resource: attach the notifier
use Ash.Resource, notifiers: [AshRemote.Server.Notifier]
# domain rpc block: name the broadcaster and (optionally) gate publications
rpc do
pub_sub MyAppWeb.Endpoint # anything exporting broadcast/3
resource MyApp.Todo do
expose :create # exposing publishes by default
publish :internal_touch # opt an unexposed action in
no_publish :create # opt out — always wins
end
endPublished set = (exposed ∪ publish) ∖ no_publish (mutation actions only).
Mount AshRemote.Server.Socket in your endpoint
(socket "/ash_remote/socket", …); channel join defaults to deny —
implement authorize_subscription/4.
# generated resource carries `realtime? true`; add it to your supervision tree:
{AshRemote.Realtime, otp_app: :my_app}AshRemote.Realtime discovers realtime? resources, opens one Slipstream
socket per base_url, auto-joins a topic each, and re-emits every pushed change
locally (echoes of the client's own writes are suppressed by default). Requires
the optional :phoenix/:phoenix_pubsub (server) and :slipstream (client)
deps.
Two layers, both enforced:
- RPC and socket connect resolve an actor the same way you already do in
Ash — an upstream auth plug sets it via
Ash.PlugHelpers(RPC), or the host socket'sconnect/3assigns:ash_remote_actor(realtime). Both integrate directly withash_authentication. - Per-record: the actor's read-policy filter is computed once at join and every broadcast is checked against it in-memory before it is pushed (with a single authorized re-read by pk as fallback), so a subscription never reveals a row the actor could not have read (resources without policies skip the check).
To authenticate, pass the actor — a token in the actor's metadata
(Ash.read!(RemoteTodo, actor: signed_in_user)) is auto-forwarded as a Bearer
header, or set explicit context: %{ash_remote: %{headers: %{…}}}. See
example/todo_server + example/todo_client for a full ash_authentication
demo (owner-only vs public todos, live).
AshRemote.DataLayer supports pk-based upsert, making it a valid asynchronous
replication target for ash_multi_datalayer's LocalOutbox strategy (local-first
writes, background flush). AshRemote.MultiDatalayer ships the glue for the
inbound direction: ChangeNotifier forwards realtime pushes into the layered
resource's orchestrator, and LifecycleGuard turns socket lifecycle events
(resubscribe, join denial) into refresh/reconcile signals. Requires the optional
:ash_multi_datalayer dep; see the AshRemote.MultiDatalayer moduledoc and
example/README.md for a full offline-first demo.
- Bulk writes default
notify?: false→ not replicated unless you opt in. - Generic (
:action) actions and previous-values topics are not published. - A notifier's
load/2on a client resource triggers a remote read.
| Path | What |
|---|---|
lib/ash_remote/transport/ |
AshRemote.Transport behaviour + Req implementation |
lib/ash_remote/realtime.ex, realtime/ |
client realtime supervisor, Slipstream connection, inbound replication |
lib/ash_remote/server/{notifier,socket,channel}.ex |
server broadcast notifier + Phoenix socket/channel (join + per-record auth) |
lib/ash_remote/topics.ex, decoder.ex |
shared topic naming + wire-record decoding |
lib/ash_remote/multi_datalayer.ex, multi_datalayer/ |
optional glue for ash_multi_datalayer (inbound change notifier + lifecycle guard) |
lib/ash_remote/protocol.ex |
pure request-body build / response parse |
lib/ash_remote/error.ex |
wire errors → Ash.Error.* |
lib/ash_remote/encode/ |
Ash.Query → wire (fields, filter, sort, pagination) |
lib/ash_remote/data_layer.ex |
AshRemote.DataLayer (implements Ash.DataLayer and folds in the remote do ... end section) |
lib/ash_remote/resource/section.ex |
shared remote do ... end Spark DSL section |
lib/ash_remote/manifest/ |
manifest loader + normalized structs |
lib/ash_remote/gen/ |
manifest → resource source |
lib/ash_remote/server.ex, server/ |
server-side RPC core + AshRemote.Server.Router plug (ported from ash_typescript; mount it in a backend) |
lib/mix/tasks/ash_remote.gen.ex |
the Igniter generator task |
test/support/backend/ |
a reference backend that mounts AshRemote.Server.Router |
mix testThe suite includes the full end-to-end path (test/ash_remote/e2e_test.exs):
dump a manifest from the reference backend → generate client resources → compile
them → drive CRUD + relationship/calculation/aggregate loads over real HTTP
against the running backend. See DECISIONS.md for design decisions.