Queries and read models
A query reads. In Arc it is a function on a read model module, with typed parameters, optional paging and sorting, and, if you want it, live updates.
A read model module
Section titled “A read model module”use Arc.ReadModel, model: … names the struct the queries return, normally a Chronicle read model. Annotate functions with @query or @observe. Each takes (args, ctx) and returns {:ok, value} or {:error, results}.
defmodule Library.Authors.Listing.Authors do @moduledoc "Queries over authors."
# `types:` tells the proxy generator the id is a UUID; the struct default is only a string. use Arc.ReadModel, model: Library.Authors.Author, types: [id: :uuid]
alias Library.Authors.Author
@doc "All authors." @query many: true def all_authors(_args, ctx), do: Chronicle.all(Author, ctx.chronicle)
@doc "One author by id." @query params: [id: :uuid], many: false def author_by_id(%{id: id}, ctx), do: Chronicle.read_model(Author, id, ctx.chronicle)
@doc "All authors, kept current: re-runs whenever Chronicle changes an author." @observe many: true, watch: [Author] def live_authors(_args, ctx), do: Chronicle.all(Author, ctx.chronicle)endPass ctx.chronicle to Chronicle calls so the query uses the client Arc was configured with.
Options
Section titled “Options”| Option | Effect |
|---|---|
params: [name: type] |
Typed arguments. name: {type, optional: true} makes one optional |
many: bool |
The query returns a list (default true) |
authorize: / allow_anonymous: true |
Override the module-level authorize |
http_method: :get | :query |
Prefer one method in the generated client |
watch: [Model] |
(@observe) re-run when Chronicle changes these read models |
topics: [term] |
(@observe) re-run when Arc.Notifier.notify/2 is called with the topic |
@doc becomes the query’s summary. use Arc.ReadModel, types: [id: :uuid] overrides inferred field types for the generated TypeScript: Arc infers a type from each struct default, and "" or nil reads as a string.
Arguments arrive converted: %{id: "…"} has been cast to the declared type, and a missing required argument or a value of the wrong type is rejected as malformedRequest before your function runs.
Paging and sorting
Section titled “Paging and sorting”A list returned by a query is sorted and paged in memory by Arc when the client asks, using page and pageSize (zero-based) and sortBy and sortDirection. The result envelope reports paging with totalItems and totalPages.
curl 'localhost:4000/api/authors/listing/all-authors?page=0&pageSize=10&sortBy=name&sortDirection=desc'Those four parameters are reserved and matched case-insensitively; every other query-string parameter is an argument. QUERY requests carry the same in a JSON body:
{ "arguments": {}, "paging": { "page": 0, "pageSize": 10 }, "sorting": { "field": "name", "direction": "desc" } }For a large collection, page in the database instead and return an Arc.Page. Arc then reports its paging and does not touch the items.
{:ok, %Arc.Page{items: items, total: total, page: page, size: size}}Observable queries
Section titled “Observable queries”An @observe query is the same function, run again whenever its inputs change, with every result pushed to the client. A client can read it three ways from the same route:
- A plain
GETreturns the current result. Accept: text/event-streamopens Server-Sent Events, onedata:message per result.- A WebSocket upgrade, or the shared hub at
/.cratis/queries/ws, which carries many subscriptions on one connection. - The shared Server-Sent Events hub at
/.cratis/queries/sse: the same protocol and the same many subscriptions on one stream, for clients that cannot or do not want a WebSocket. See Hubs.
The generated React client chooses the WebSocket hub by default; queryTransportMethod selects the SSE one.
On the client the choice is two values, QueryTransportMethod and ObservableQueryTransferMode, which the showcase’s toolbar sets:
const [transport, setTransport] = useState<QueryTransportMethod>(QueryTransportMethod.WebSocket);const [transferMode, setTransferMode] = useState<ObservableQueryTransferMode>(ObservableQueryTransferMode.Delta);Each subscription is a process under your Arc application, started when the client subscribes and ended when its connection ends. It is restart: :temporary, so a failing subscription ends instead of looping; the client resubscribes. This needs MyApp.Arc in your supervision tree.
Both hubs speak the same protocol (Connected, Subscribe, Unsubscribe, QueryResult, Unauthorized, Error, Ping/Pong), with revisions so that a delayed subscribe cannot bring back a subscription that was cancelled. Each subscription goes through the ordinary query pipeline, so authorization, filters and the tenant apply as for a GET.
| WebSocket | Server-Sent Events | |
|---|---|---|
| Open | GET /.cratis/queries/ws upgrade |
GET /.cratis/queries/sse streams data: events; the first is Connected with the connection id |
| Subscribe | a Subscribe message |
POST /.cratis/queries/sse/subscribe with {connectionId, queryId, revision, request} |
| Unsubscribe | an Unsubscribe message |
POST /.cratis/queries/sse/unsubscribe with {connectionId, queryId, revision} |
| Needs | nothing special | an answer of 404 for an unknown connection or a different caller, 400 for a malformed body |
A browser WebSocket and EventSource cannot set request headers, so authenticate them with cookies, which an Arc authentication handler or a plug in front of Arc can read; the showcase sample shows one that turns cookies into the identity and tenant headers.
A subscription’s transferMode shapes what it receives: full sends a snapshot every time, delta sends a snapshot first and afterwards only the change set (added, replaced, removed), and without a preference every emission carries both. The change set is computed on the hub, so a source needs nothing extra to support it.
What triggers a re-run
Section titled “What triggers a re-run”watch: [Author] subscribes to Chronicle’s change stream for that read model. For anything else, declare topics and notify them:
@observe many: true, topics: ["authors"]def live_authors(_args, _ctx), do: {:ok, MyApp.Authors.list()}
# somewhere that changes authorsArc.Notifier.notify(MyApp.Arc, "authors")Arc.Notifier reaches subscriptions on the same node. To reach every node, broadcast with Phoenix.PubSub and call notify/2 in the receiver on each node.
Re-runs are debounced by 25 ms, so a burst of changes produces one result.
Run a query from code
Section titled “Run a query from code”result = Arc.query(MyApp.Arc, Library.Authors.Listing.Authors, :all_authors, %{}, page: 0, page_size: 10)result.dataThe result is an Arc.QueryResult; Arc.QueryResult.to_wire/1 is the HTTP envelope, documented field by field in Result envelopes.
Next steps
Section titled “Next steps”- LiveView keeps an assign current from an observable query.
- React and generated proxies turns each query into a typed hook.