- Library is
ash_remote(client),AshRemote.*namespace.
- The rich structural manifest comes from ash core
Ash.Info.Manifest(generate/1,JsonSerializer.to_map/1,schema_version"1.0.0"). - It ships in released Ash (>= 3.29), so we use a normal hex dep
{:ash, "~> 3.29"}— no path dep.
- We speak the
ash_typescriptRPC wire protocol (/rpc/run,/rpc/validate). - No
ash_typescriptdependency. The server-side RPC core lives inash_remoteitself —AshRemote.Server+AshRemote.Server.Router(ause-able Plug router) — ported from ash_typescript and written so it can later be extracted into a shared package bothash_typescriptandash_remotedepend on. A backend needs no custom RPC code:use AshRemote.Server.Router, otp_app: :my_app. - Exposure is declared with the
AshRemote.Rpcdomain extension (rpc do resource X do expose :action end end), the ash_typescript-style counterpart.
- Decoupled / manifest-driven: generated resources depend only on
ash+ash_remote. - A custom
AshRemote.DataLayer(implementsAsh.DataLayer) translates queries/changesets into RPC calls. Actions on generated resources are stubs (server is authoritative). - Capabilities (
can?/2, filter/sort pushdown) are derived from the manifest, not a hand-written matrix (per-fieldfilter_operators/sortable?). - Igniter is used for non-destructive (re)generation.
- Actions are addressed by
{resource, action}(both in the manifest), not an opaque RPC name — the manifest doesn't serialize one. filteruses Ash'sfilter_inputmap form;sortuses thesort_inputstring form; pagination is{limit, offset}(offset: 0is omitted as a no-op).- Wire field names are snake_case by default (
AshRemote.Formatterstrategy:none); a:camelstrategy is available for camelCasing backends.
- Ash's
JsonSerializer(through at least 3.29.3) omits the actionnamefrom serialized entrypoints, so a JSON-only client couldn't tell which action to call. The%Manifest{}struct does carry it, soAshRemote.Server.manifest_json/1builds the JSON fromto_map/1and injects the actionnameinto each entrypoint. No ash fork needed; a candidate upstream fix toserialize_action/1.
- The serializer also omits
source_attribute/destination_attributefrom relationships (andAsh.Info.Manifest.Relationshipdoesn't carry them at all), so the generator had to guess FKs by naming convention — which breaks forbelongs_to :list(FKlist_id, nottodo_list_id) and self-referentialhas_many :subtasks.manifest_json/1injects both attributes into each serialized relationship from the live resource, the loader parses them tolerantly (older manifests still load), and the generator emits them explicitly on every generated relationship. Candidate upstream fix to the manifestRelationshipstruct + serializer.
- The generator does not persist per-resource
managed_*bookkeeping in theremoteblock. Ownership is defined by manifest membership, recomputed at regen time: entities the manifest declares are generator-owned; anything else in the file is user-added and ignored. mix ash_remote.genimplements this with the stock Igniter composition, not a bespoke diff engine: a module that doesn't exist is created whole; an existing module gains only the manifest entities it's missing, viaAsh.Resource.Igniter.add_new_attribute/ add_new_relationship/add_new_action(+Ash.Domain.Igniter.add_resource_referencefor the domain). User edits to generated entities and user-added code are never touched, and regen with an unchanged manifest is a no-op (covered bytest/mix/ash_remote_gen_test.exs).- Drift is detected but never auto-resolved: an entity that differs from the
manifest (user edit, or the server changed it) and an entity absent from the
manifest (user-added, or the server removed it) are indistinguishable cases,
so the task surfaces each as a warning by default;
--interactiveprompts per entity — keep the current version (the default answer) or take the manifest's (replace / remove). The same applies to staleresourcereferences in the client domain. - Upstream note:
Ash.Resource.Igniter.defines_calculation/3(≤ 3.29.3) only matches arity-3calculatecalls, missing thecalculate ... do ... endform — the task carries a corrected arity-3-or-4 check; candidate upstream fix.
- Ash core's manifest serializes no validations, so
AshRemote.Server.manifest_json/1publishes them itself (same augmentation channel as action names and relationship attributes): each resource gets a"validations"list of{module, opts, on, where, message, only_when_valid}entries. - Mirrorable = builtin data-check module (allowlist in
server.ex) + opts that round-trip as safe literals (AshRemote.Literal: inspect → parse → literal-only AST, with{Spark.Regex, :cache, [...]}as the single allowed call shape — how~r//is stored).whereconditions are the same{module, opts}shape and mirror under the same recursive test — awheredoes NOT disqualify a validation. Function validations, custom modules, and non-literal opts are skipped: the server stays authoritative. - Opts travel as Elixir source strings (JSON-safe, exact fidelity for atoms/keywords); the generator re-verifies module namespace + literal safety before emitting anything — a crafted manifest can't inject code into generated resources.
- Generated validations are rendered as the
Builtinssugar the backend author wrote (validate string_length(:title, min: 3), defaulton:omitted) whenever calling the builtin reproduces the manifest opts exactly (AshRemote.Gen.Validations.sugar/2— lossy rendering is impossible by construction); otherwise the{Module, opts}tuple form. Regen/drift compare by canonical identity (identity/1): sugar vs tuple form, option order, and~r//vs Spark's lazy regex tuple are all equivalent. - Changes/lifecycle hooks remain server-only by construction (action stubs carry none); mirrored validations run on both sides — client for fast feedback, server for truth.
- Regen identity for validations is node equivalence (they have no name): a
matching
validatealready exists → skip; an edited one is flagged as drift and the manifest version re-added.
- The client encodes query
limit/offsetas the wirepage— butAsh.get/2reads with an internallimit: 2, and a backend read action withoutpaginationenabled rejects page options. The server (apply_page/3) applies a plain limit/offsetpageasAsh.Query.limit/offsetwhen the action has no pagination — the same read minus the page envelope; real pagination still goes throughAsh.Query.page/2.
- Captured at the notifier level (
AshRemote.Server.Notifier), not at RPC dispatch, so server-local writes replicate too and Ash's transaction/bulk deferral come free. The notifier broadcasts wire payloads viapub_sub.broadcast/3— the same contract asAsh.Notifier.PubSub'smodule, so no compile-time Phoenix dependency. - The client reconstructs a full
%Ash.Notifier.Notification{}including a synthetic changeset (plain struct, neverfor_*) — required becauseAsh.Notifier.PubSubdereferenceschangeset.resource/.to_tenantfor:_pkey/:_tenanttopics. - Two-layer auth. Join is a topic gate (default deny). Every broadcast is
then checked per subscriber in the channel's
handle_out— broadcasts fan out to all topic subscribers, so row-level read policies must be enforced there, not at broadcast time. Resources without authorizers skip it. (Superseded mechanism, 2026-07-05: the original per-broadcastAsh.can?({record, :read}, actor)call was replaced with the ash_graphql approach — the actor's read-policy filter is computed once at join (Ash.can(query, actor, run_queries?: false, alter_source?: true)) and each notification is matched in-memory viaAsh.Expr.eval/2, with a single authorized pk re-read as fallback, skipped for destroys.) - Auth threading mirrors ash_typescript. RPC resolves the actor from the
conn via
Ash.PlugHelpers(host plugsash_authentication); the socket resolves it in the host'sconnect/3. Both run every action with that actor. The client forwards a token from the actor's metadata (auto Bearer header, propagates to relationship loads) or explicit context headers. - Broadcast is best-effort (
try/rescue): a realtime transport failure must never fail the originating write. Notifications are hints; reads are truth (at-most-once, no replay;:resubscribedis the refetch signal). - Optional deps:
:phoenix/:phoenix_pubsub(server),:slipstream(client), each guarded byCode.ensure_loaded?/1so a client-only app compiles without Phoenix.
- PK-upsert as the LocalOutbox replication target.
AshRemote.DataLayeranswerscan?(:upsert)for pk-based upserts soash_multi_datalayer's LocalOutbox strategy can flush local-first writes to the backend idempotently under retries. - ash_remote_cache folded in. Its lib dissolved into
AshRemote.MultiDatalayer.{ChangeNotifier, LifecycleGuard}(optionalash_multi_datalayerdep); its example becameexample/here. The glue is strategy-agnostic: inbound realtime pushes route through the layered resource's orchestrator, lifecycle events become refresh/reconcile signals. - Source-map fan-out.
Realtimegroups subscriptions by backend source/topic, so multiple client mirrors of one backend resource each react to a push (previously last-writer-wins in the source map dropped all but one).
- Tenant travels on the wire, not just in the conn.
Protocol.build_run/build_validategained an optional"tenant"key (absent = old wire shape, backward compatible); the client threadsquery.tenant/changeset.to_tenantthroughrun_query/2,write/5(create/update),destroy/2, andfetch_remote_calculations/4. Server resolves wire-first, falling back to the conn's tenant. Explicitly documented as input to Ash multitenancy, not an auth claim — policies must still scope actors to tenants server-side.validate_action/2became/3(opts: actor/tenant) in the same change as threading the actor into it — the router's/rpc/validatecall site changed in the same commit, since the two were independently broken in the same way (neither actor nor tenant reached the validate path at all). - No
Module.concat/String.to_atomon wire input, anywhere.AshRemote.Server.ResourceResolverprecomputes a string→module map per{otp_app, site}(RPC exposure vs. realtime publications are different sets — separate cache keys,:persistent_term), used by bothServer.resolve_resource/2andServer.Channel.resolve_resource/2.AshMultiDatalayer.Orchestrator.LocalOutbox.HostResolver(sibling repo) applies the same pattern for outbox entries.Manifest.Loader.atom/2moved fromString.to_atom/1toString.to_existing_atom/1against an explicit, compile-time-primed vocabulary list (so load order elsewhere in the app never matters), naming the offending manifest key on failure. - Realtime field-policy stripping is server-computed, not per-subscriber.
Server.Notifierexcludes policy-target fields (the fields afield_policyapplies TO, not fields merely referenced by a policy condition) from bothpayload/4's"data"andchanged/2's"changed"— computed once per notification fromAsh.Policy.Info.field_policies_for_field/2. Chosen over per-subscriber evaluation for cost; a field-policied attribute never travels over realtime — load it via an authorized RPC read instead. - Transport errors are a typed Ash error.
AshRemote.Error.Transport(Ash.Error.Unknown-class) wraps{:transport_error, _}/{:http_error, _, _}in all fourrequest/4call sites. Its:unknownclass classifies:transientinash_multi_datalayer'sFlush.classify/1(retry, then park) — the one cross-repo coupling in this fix round, landed together with that classifier's own:authclass forForbidden. Also extendedManifest.Error.to_exception/1to map the wire type"invalid_changes"(what a server-side identity/uniqueness pre-check violation actually serializes as) toAsh.Error.Changes.InvalidChanges(class: :invalid) instead of falling through to the:unknowncatch-all — needed soupsert/3's collision detection (below) can key offclass: :invalidthe way the rest of the error-handling code already does. upsert/3's create-collision resolves to an update, once. The read-then-write deciding create-vs-update is not atomic — two concurrent upserts for the same PK can both readniland both attemptcreate; the loser now re-reads on a:invalid-class create failure and retries as an update instead of surfacing the collision. Does not close the window entirely (a third concurrent write between the retry's read and its update could still race) — a true fix needs a server-side identity upsert, filed as a follow-up against the protocol.- A durably-denied realtime topic is terminal for the socket process.
Connectiontracks denied topics in process state (handle_topic_close's{:failed_to_join, _}clause) andhandle_connect/1excludes them from every subsequent rejoin;:join_deniedfires once, not once per reconnect. The state resets only with the socket process itself —connect_paramsis evaluated once ininit/1and reused for the process's lifetime (the previous "evaluated per connect" comment was wrong), so a durable denial cannot be un-denied without a fresh connection process (e.g. a supervisor restart with a new token). safe_message/1keys off Splode's.class, not a module allowlist. Mirrorsash_json_api'sAshJsonApi.Error.to_json_api_errors/4fallback branch:error.class in [:invalid, :forbidden]is safe to show verbatim (coversAsh.Error.Invalid,Forbidden, and every NotFound variant, allclass: :invalid); everything else — critically:unknown, what a raised non-Ash exception becomes viaAsh.Error.to_error_class/1— logs server-side with a correlation id and returns a generic message. A hardcoded module-name allowlist was tried first and missedAsh.Error.Changes.InvalidChanges(not literallyAsh.Error.Invalid).ClientId.register/1is idempotent. Keeps the first id ever registered for a base_url instead of overwriting on every call —:persistent_term.put/2on an EXISTING key triggers a full VM-wide GC pass, so an unconditional overwrite on every supervisor restart was needless global cost, and would have changed the echo-correlation identity out from under in-flight requests. OneAshRemote.Realtimesupervisor per base_url remains the supported topology; a second registration for the same base_url logs once and shares the existing identity by design.Encode.Filter's:applicablegate deleted, not wired up.remote_config/1never actually populated it (applicable: nilunconditionally), so the gate was permanently a no-op — the smaller, honest fix is deletion. The manifest data it would have gated on survives in the loader's normalizedfilter_operators/filter_functionsfor a future reintroduction, which would also need to extend the generator (it doesn't currently emit per-field operator info at all).
- A replicated write's accepted-keys filter excludes every
writable?: falseattribute, not just the primary key. H2's original fix only excluded the PK from a replicated write's wire input (the field is addressed via the protocol's separateprimary_keyfield, not as ordinary input). Found live while exercising the offline-first demo end-to-end: a LocalOutbox flush's hydrated snapshot carries every known attribute, including auto-managedinserted_at/updated_at— sent as wire input, the remote correctly rejects them ("is currentlywritable?: false"). Fixed by excluding every non-writable attribute the target resource declares, not only the PK. AshRemote.DataLayer.accepted_keys/1's replicated-write clause readschangeset.resource's own attribute metadata, not the manifest's. This is correct for a dedicatedAshRemote.DataLayer-backed resource (its attributes mirror the remote's writability via codegen), but a multi-datalayer HOST resource (e.g.ash_multi_datalayer'sTodoClient.Local.Todo) has its own, independently-declared attribute writability — it must accurately mark hydration-only/display fieldswritable?: falseitself for this filter to work. This is a real authoring responsibility for any multi-datalayer host resource wrappingAshRemote.DataLayeras a target layer, not something the library can infer on its own from the manifest.
- Ash
~> 3.29(hex, resolved 3.29.3). Elixir 1.18 / OTP 27. - Realtime optional deps:
phoenix ~> 1.7,phoenix_pubsub ~> 2.1,slipstream ~> 1.1. - Composition optional deps:
ash_multi_datalayer(path dep on the sibling repo),plug ~> 1.16.