Skip to content

Repository files navigation

ash_remote

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.

How it works

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.

  • The client's Ash.Query/changeset is encoded into an RPC body (AshRemote.Encode.{Fields,Filter,Sort,Pagination}), sent via AshRemote.Transport (default Req), 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.

Usage

Generate resources from a manifest:

mix ash_remote.gen --manifest path/to/manifest.json --namespace MyApp.Remote

Configure 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}

Realtime

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.

Server

# 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
end

Published 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.

Client

# 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.

Authorization

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's connect/3 assigns :ash_remote_actor (realtime). Both integrate directly with ash_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).

Composing with ash_multi_datalayer

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.

Limitations (v1)

  • 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/2 on a client resource triggers a remote read.

Layout

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)
lib/ash_remote/resource.ex AshRemote.Resource extension (remote do ... end)
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

Tests

mix test

The 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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages