I think that Hologram as it stands today already does a world-class job separating client-side and server-side concerns. Extending that principle into managing offline data is how I would approach this problem.
Let me explain the DX I wish for with some code examples. (Caveat: I will include Ash-like snippets in my examples, but that’s primarily because I work in Ash every day. I am much more comfortable expressing myself in it)
Imagine a simple Hologram page. Right now, opening and closing a ticket works this way:
defmodule MyApp.TicketListPage do
use Hologram.Page
# ... route, layout, init, template omitted for brevity ...
def action(:open_ticket, _params, component) do
subject = component.state.new_subject
component
|> put_state(:new_subject, "")
|> put_command(:create_ticket, %{subject: subject})
end
def action(:close_ticket, params, component) do
component
|> put_command(:update_ticket, %{id: params.id, status: :closed})
end
def command(:create_ticket, params, server) do
case do_open_ticket(params.subject) do
{:ok, ticket} -> put_action(server, :ticket_created, %{ticket: ticket})
{:error, _} -> put_action(server, :create_failed, %{})
end
end
def command(:update_ticket, params, server) do
case do_close_ticket(params.id) do
{:ok, ticket} -> put_action(server, :ticket_updated, %{ticket: ticket})
{:error, _} -> put_action(server, :update_failed, %{})
end
end
defp do_open_ticket(subject) do
# Does something... Starts an Oban job, calls an external API, sends an email, etc
MyApp.Support.Ticket.open(subject)
...
end
defp do_close_ticket(id) do
# Also does some very important server-side things
MyApp.Support.Ticket.close(id)
...
end
end
With a ticket resource that looks like this:
defmodule MyApp.Support.Ticket do
use Ash.Resource,
domain: MyApp.Support,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "tickets"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
attribute :subject, :string, allow_nil?: false, public?: true
attribute :status, :atom do
constraints one_of: [:open, :closed]
default :open
allow_nil? false
end
end
relationships do
belongs_to :representative, MyApp.Support.Representative
end
policies do
policy action_type(:read) do
authorize_if expr(representative_id == ^actor(:id))
end
policy action_type(:create) do
authorize_if always()
end
policy action(:close) do
authorize_if expr(representative_id == ^actor(:id))
end
end
actions do
defaults [:read]
create :open do
accept [:subject]
end
update :close do
accept []
argument :id, :uuid, allow_nil?: false
validate attribute_does_not_equal(:status, :closed) do
message "Ticket is already closed"
end
change set_attribute(:status, :closed)
end
end
code_interface do
define :open, args: [:subject]
define :close, args: [:id]
end
end
In my dream DX, upgrading what we have today to a local first app will only need these changes:
Configure an extension provided by Hologram:
defmodule MyApp.Support.Ticket do
use Ash.Resource,
domain: MyApp.Support,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [Hologram.Extension.IndexedDb] # add the local datastore you want to use
# ... everything else stays the same ...
indexed_db do
store "tickets"
scope :authorized # sync everything the actor is authorized to read
resolution_strategy :last_write_wins # can be the simple strategy for a start. future versions can even allow anonymous functions for custom strategies
end
end
On the page, actions now write to the local store first and then dispatch commands:
defmodule MyApp.TicketListPage do
use Hologram.Page
# ... route, layout, template omitted for brevity ...
def init(_params, component, _server) do
# Reads come from the local store -- no server round-trip, no spinner.
tickets = MyApp.Support.Ticket.read!()
put_state(component, %{tickets: tickets, new_subject: ""})
end
def action(:open_ticket, _params, component) do
subject = component.state.new_subject
# 1. Write to local store immediately (optimistic update)
{:ok, ticket} = MyApp.Support.Ticket.open(subject)
tickets = MyApp.Support.Ticket.read!()
component
|> put_state(%{tickets: tickets, new_subject: ""})
|> put_command(:create_ticket, %{subject: subject})
# 2. Command syncs to server in the background
end
def action(:close_ticket, params, component) do
# Write to local store immediately
{:ok, _ticket} = MyApp.Support.Ticket.close(params.id)
tickets = MyApp.Support.Ticket.read!()
component
|> put_state(:tickets, tickets)
|> put_command(:update_ticket, %{id: params.id})
end
# Commands stay exactly the same -- they still do the important
# server-side work (Oban jobs, external APIs, etc.)
def command(:create_ticket, params, server) do
case do_open_ticket(params.subject) do
{:ok, ticket} -> put_action(server, :ticket_created, %{ticket: ticket})
{:error, _} -> put_action(server, :create_failed, %{})
end
end
def command(:update_ticket, params, server) do
case do_close_ticket(params.id) do
{:ok, ticket} -> put_action(server, :ticket_updated, %{ticket: ticket})
{:error, _} -> put_action(server, :update_failed, %{})
end
end
defp do_open_ticket(subject) do
MyApp.Support.Ticket.open(subject)
...
end
defp do_close_ticket(id) do
MyApp.Support.Ticket.close(id)
...
end
end
This approach is beautiful to me because it’s declarative and explicit, and it preserves the mental model that the developer already works with (client-side concerns happen in actions and server-side concerns in commands, no magic). Calling MyApp.Support.Ticket.open in both the action and the command might initially seem redundant. But to me, it is explicit. It’s not redundant.
In addition, this approach suddenly unlocks several new possibilities! Local-first, local-only, local-never (server-only), @woylie‘s vision of 3-way-sync, or any other permutation of interest, all by configuring resource extensions and choosing where to call a write function:
- Local-only: Action writes to local store. No command dispatched. Data never leaves the client. Use case: drafts, preferences, UI state.
- Local-first: Action writes to local store AND dispatches a command. UI updates instantly, server syncs in the background. Use case: most app data. This is what the example above does.
- Server-only: Action dispatches a command without writing locally. Waits for server response. Use case: payments, sensitive operations.
- Three-way sync: Same resource configured with multiple extensions (e.g., IndexedDB in the browser, SQLite on the filesystem, Postgres on the server). Each syncs via the same command queue. Use case: @woylie’s vision of PWA + desktop + self-hosted server.
The wiring to achieve this is non-trivial. Perhaps Hologram has a command queue already for handling commands when disconnected, but that’s a key piece. When a command dispatch fails, it queues locally and retries with backoff. On reconnection, queued commands replay through the same server-side business logic (@jam’s proposal). Also simplifies the retry logic.
So, in summary, mapping back to @bartblast’s original questions:
1. Declarative sync
Configure an extension on the resource. Authorization rules determine what syncs. Actions and commands control write locality. Define the model once, declare where it lives, write normal Elixir.
2. Conflict resolution
Declared on the resource via the extension’s DSL (resolution_strategy :last_write_wins). Queued commands replay through business logic for natural conflict detection. Future versions can support per-field strategies and custom resolver functions.
3. Offline experience
Transparent. Actions work against the local store regardless of connectivity. Commands queue when unreachable and replay on reconnection.
4. Inspiration
The biggest inspiration is Ash’s data-layer agnosticism – same resource definition, multiple storage backends. @jam referenced Meteor’s “it just works” DX as the bar. I think the action/command model gets us there.
Really excited about this discussion!