Live runtime — Caravela.Live.*

Copy Markdown View Source

The default mix caravela.gen.live output is intentionally vanilla Phoenix — no Caravela imports, no macros, nothing to learn beyond handle_event. That keeps CRUD pages easy to read, easy to patch by hand, and easy to eject.

When you're hand-writing a complex stateful LiveView, that plainness starts to cost. Editors with async validation, wizards with branching steps, dashboards with a dozen event types — all benefit from giving state transitions a name and composing them like functions. That's what Caravela.Live.* is for.

Three modules, each small and independent:

You can use one without the others. Updater works in any LiveView callback. Domain works without Template if you want to drive it yourself. Template needs a Domain, but nothing else.

When to reach for it

Use Caravela.Live.* when the LiveView has:

  • More than ~5 event types
  • Multiple sources of "loading" or "saving" state that need to clear together
  • Async work (Task.async, PubSub subscribes) whose completion updates the UI
  • Nested child state (a parent LiveView composing child-domain updaters with embed/2)

Don't reach for it when the LiveView is:

  • A plain CRUD index/show/form (the generator's default output is already correct)
  • One event type and less than a few lines of state
  • Glue code with no meaningful state transitions

The generated CRUD output is the proof of the second case — four events, six assigns, no domain needed.

Caravela.Live.Updater

An updater is a pure function (assigns -> assigns) or (assigns, arg -> assigns). Caravela.Live.Updater.run/2,3 applies one to a socket, assigning the result. compose/2 chains them; embed/2 narrows one to operate on a nested key; the ~> macro is sugar for compose.

import Caravela.Live.Updater

mark_saving = fn s -> %{s | saving: true} end
clear_flash = fn s -> %{s | flash_message: nil} end

# horizontal composition
pipeline = mark_saving ~> clear_flash
socket = run(socket, pipeline)

# vertical composition — apply `increment` only to :child_state
scoped = embed(&ChildDomain.increment/1, :child_state)
socket = run(socket, scoped)

Caravela.Live.Updater.apply/2,3 is an undocumented backwards-compat alias for run/2,3. Prefer runapply shadows Kernel.apply/2,3 and reads worse in pipelines.

Caravela.Live.Domain

Declares a set of named state transitions as a DSL:

defmodule MyApp.BookEditorDomain do
  use Caravela.Live.Domain

  state do
    field :book, :map, default: %{}
    field :saving, :boolean, default: false
    field :flash_message, :string, default: nil
  end

  updater :mark_saving, fn s -> %{s | saving: true} end
  updater :mark_saved,  fn s -> %{s | saving: false, flash_message: "Saved"} end
  updater :set_book,    fn s, book -> %{s | book: book} end

  # `apply_updater` resolves against this module — @caravela_live_domain
  # is set to __MODULE__ inside `use Caravela.Live.Domain`.
  on_event "save", fn socket ->
    apply_updater(socket, :mark_saving)
  end

  # Async responses handled symmetrically
  on_info {:saved, book}, fn socket ->
    socket
    |> apply_updater(:set_book, book)
    |> apply_updater(:mark_saved)
  end
end

The compiled module exposes four lookup functions consumed by Caravela.Live.Template:

  • __caravela_live_state__/0 — the default assigns map
  • __caravela_live_updater__/1 — function lookup by name (or nil)
  • __caravela_live_event__/3 — dispatch an event name to its handler
  • __caravela_live_info__/2 — dispatch an async message pattern

At compile time the DSL rejects updaters with arities outside [1, 2] and events with non-string names.

Caravela.Live.Template

defmodule MyAppWeb.BookEditorLive do
  use MyAppWeb, :live_view
  use Caravela.Live.Template, domain: MyApp.BookEditorDomain

  def render(assigns) do
    ~H"""
    <LiveSvelte.render
      name="library/BookEditor"
      props={%{book: @book, saving: @saving, flash_message: @flash_message}}
    />
    """
  end
end

The use Template macro injects:

  • mount/3 — assigns the domain's default state
  • handle_event/3 — dispatches to the domain's on_event handlers
  • handle_info/2 — dispatches to the domain's on_info handlers
  • apply_updater/2,3 — sugar for resolving updater names against the bound domain

Everything is defoverridable, so you can override any callback for I/O-heavy logic while keeping the updater sugar.

Unknown events log a warning and leave the socket unchanged (rather than crashing), so typos in Svelte pushEvent names are loud but not fatal.

Compose with embed/2 for nested child domains

A parent LiveView that mounts several child domains can compose child updaters without knowing their internals:

import Caravela.Live.Updater

parent_updater =
  embed(&MyApp.EditorDomain.__caravela_live_updater__(:mark_saving).(&1), :editor) ~>
  embed(&MyApp.SidebarDomain.__caravela_live_updater__(:collapse).(&1), :sidebar)

Caravela.Live.Updater.run(socket, parent_updater)

Onramp: mix caravela.gen.live --with-domain

Running the live generator with --with-domain emits a <Entity>Live.FormDomain module next to each form.ex, and regenerates form.ex to use Caravela.Live.Template. It's the shortest path to a working example of the pattern without writing a domain from scratch.

Caravela.Live.Form — visibility predicates + async validation

When a form needs conditional fields or server-round-trip validation, reach for Caravela.Live.Form. It's a thin DSL layered on top of Caravela.Live.Domain: everything from the Domain DSL still works, plus two new macros:

defmodule MyApp.BookFormDomain do
  use Caravela.Live.Form,
    entity: MyApp.Library.V1.Book,
    context_fields: [:current_user]

  state do
    field :attrs, :map, default: %{}
    field :current_user, :map, default: nil
  end

  # Compile-time visibility predicate. Evaluated server-side on every
  # assigns change. The result is sent to the Svelte component as
  # `field_visibility.published`.
  visible :published, fn assigns ->
    Map.get(assigns.attrs, :advanced_mode) == true
  end

  visible :price, fn assigns ->
    Map.get(assigns.current_user || %{}, :role) in [:admin, :editor]
  end

  # Async field validator. The optional `:debounce` option (ms) is
  # emitted as metadata so the generated Svelte form can debounce the
  # input before pushing `"validate_async"` back to the LiveView.
  validate_async :isbn, [debounce: 500], fn value, _assigns ->
    MyApp.ISBNService.validate(value)
  end
end

The compiled module exposes:

  • __caravela_form__/0 — metadata (entity, context fields, visible fields, async fields, debounce map)
  • __caravela_form_visibility__/1 — compute the field_visibility map by running every predicate against an assigns map
  • __caravela_form_visible__/2 — per-field visibility predicate (fallback returns true)
  • __caravela_form_validate_async__/3 — dispatches a field's async validator

Authorization-sensitive visibility stays on the server — the client only receives the boolean result. Svelte's {#if ...} blocks never see fields the current user shouldn't have.

Dynamic Svelte form — Caravela.Gen.SvelteForm

Caravela.Gen.SvelteForm.render(form_module, domain) generates a <Entity>FormDynamic.svelte sibling to the plain <Entity>Form.svelte the CRUD generator emits. The dynamic form:

  • Declares field_visibility, async_errors, and pushEvent props
  • Wraps every field that has a visible predicate in {#if field_visibility.<name>}
  • Installs a client-side setTimeout per validate_async field, firing pushEvent("validate_async", { field, value }) after the declared debounce
  • Renders async errors in a separate .error.async span so they don't conflict with synchronous changeset errors

Both form variants coexist: plain CRUD keeps the static form, forms that need conditional/async behaviour switch to the dynamic one.