lily/store

The Store holds your application state and update logic, shared across client and server. The mental model is very simple, each store holds a basic model type and an update function fn(model, message) -> model. The store is agnostic to how your frontend actually displays these components, with the view function or rendering completely detached (unlike Lustre and closer to Redux).

Here’s a minimal example of a counter:

pub type Model {
  Model(count: Int)
}

pub type Message {
  Increment
  Decrement
}

pub fn update(model: Model, message: Message) -> Model {
  case message {
    Increment -> Model(count: model.count + 1)
    Decrement -> Model(count: model.count - 1)
  }
}

// this should be in your client/frontend code
let store = store.new(Model(count: 0), with: update)

You wrap it with store.new on the client and pipe the result into client.start.

The server is then set up separately via server.new, where Wiring comes into play. Syncing your model with the server splits your model into a per-connection session slice and then shared topic slices (see the topic module for more info). For each message, the wiring allows us to figure out which message belongs where (through extract), how the slice should update (update), and how to read and write the slice on the outer model (field_get/field_set). It also merges server snapshots back into the right slice. server snapshots back into the right slice.

Build one Wiring in your shared package and hand the same value to both the client (passed to client.start) and the server (passed to server.new):

import lily/store

pub fn wiring() -> store.Wiring(Model, Message) {
  store.wiring()
  |> store.session(
    extract: fn(message) {
      case message {
        Session(inner) -> Ok(inner)
        _ -> Error(Nil)
      }
    },
    update: session_update,
    field_get: fn(model: Model) { model.session },
    field_set: fn(model, session) { Model(..model, session:) },
  )
  |> store.topic(
    id: "chat",
    extract: fn(message) {
      case message {
        Chat(inner) -> Ok(inner)
        _ -> Error(Nil)
      }
    },
    update: chat_update,
    field_get: fn(model: Model) { model.chat },
    field_set: fn(model, chat) { Model(..model, chat:) },
  )
  // you can chain more topics by determining a different topic id
}

Model fields that should not be synced to the server can be wrapped in Local. The server holds Local fields at their initial values and the client runtime preserves them when the server sends a snapshot on reconnect. Pair with client.session_field to persist them across page navigations.

The same store runs on the client via client.start and the server via server.start, meaning your update function works identically on both sides.

Types

Model fields that are client-only and not synced to the server should be wrapped using Local(_). The server holds Local fields at their initial values and the client runtime preserves them when applying a server snapshot on reconnect.

pub type Model {
  Model(count: Int, theme: store.Local(String))
}
pub type Local(a) {
  Local(a)
}

Constructors

  • Local(a)

The store with your application state and update logic. The same store runs on both the client (via client.start) and the server (via server.start). Construct via new; fields are not exposed to keep the internal layout free to evolve.

pub opaque type Store(model, message)

Wiring configuration for multi-store Lily apps. A Wiring(model, message) value tells the client how to dispatch messages to the session store or to a topic store, and how to merge incoming snapshots back into the outer model. Build with wiring, then pipe through session and topic.

pub opaque type Wiring(model, message)

Values

pub fn new(
  initial_model model: model,
  with update: fn(model, message) -> model,
) -> Store(model, message)

Create a new store, seeded with an initial_model (similar to Lustre’s init) and an update function that transforms the model based on a given message.

let app_store =
  Model(count: 0, user: "Guest")
  |> store.new(with: update)
pub fn session(
  wiring: Wiring(model, message),
  extract extract: fn(message) -> Result(session_message, Nil),
  update update: fn(session_model, session_message) -> session_model,
  field_get field_get: fn(model) -> session_model,
  field_set field_set: fn(model, session_model) -> model,
) -> Wiring(model, message)

Register the session store entry in the wiring. The extract function identifies session messages; update applies them to the session sub-model; field_get and field_set map between the outer model and the session sub-model.

store.wiring()
|> store.session(
  extract: fn(message) {
    case message {
      Session(inner) -> Ok(inner)
      _ -> Error(Nil)
    }
  },
  update: session_update,
  field_get: fn(model: Model) { model.session },
  field_set: fn(model, session) { Model(..model, session:) },
)
pub fn topic(
  wiring: Wiring(model, message),
  id id: String,
  extract extract: fn(message) -> Result(topic_message, Nil),
  update update: fn(topic_model, topic_message) -> topic_model,
  field_get field_get: fn(model) -> topic_model,
  field_set field_set: fn(model, topic_model) -> model,
) -> Wiring(model, message)

Register a topic store entry in the wiring. The id is the topic identifier used in client.subscribe; the other parameters are the same as for session.

store.wiring()
|> store.topic(
  id: "chat",
  extract: fn(message) {
    case message {
      Chat(inner) -> Ok(inner)
      _ -> Error(Nil)
    }
  },
  update: chat_update,
  field_get: fn(model: Model) { model.chat },
  field_set: fn(model, chat) { Model(..model, chat:) },
)
pub fn topic_kind(
  wiring: Wiring(model, message),
  prefix prefix: String,
  extract extract: fn(message) -> Result(
    #(String, kind_message),
    Nil,
  ),
  update update: fn(kind_model, kind_message) -> kind_model,
  field_get field_get: fn(model, String) -> kind_model,
  field_set field_set: fn(model, String, kind_model) -> model,
) -> Wiring(model, message)

Register a parametric topic family in the wiring. Where topic binds one id to one model slice, topic_kind binds a whole family of dynamic ids sharing prefix ("room:1", "room:2", and so on) to a keyed slice, so a client can be subscribed to many instances at once.

extract returns the instance key together with the sub-message; the full topic id on the wire is prefix <> key. field_get and field_set read and write one instance’s sub-model by key, so back them with a keyed collection such as Dict(String, _) on your model. This one entry is what lets an outgoing message route to the right instance, an incoming update apply to the right key, and a snapshot merge into the right key.

store.wiring()
|> store.topic_kind(
  prefix: "room:",
  extract: fn(message) {
    case message {
      Room(id, inner) -> Ok(#(int.to_string(id), inner))
      _ -> Error(Nil)
    }
  },
  update: room_update,
  field_get: fn(model: Model, key) {
    dict.get(model.rooms, key) |> result.unwrap(new_room())
  },
  field_set: fn(model, key, room) {
    Model(..model, rooms: dict.insert(model.rooms, key, room))
  },
)
pub fn unwrap_local(local: Local(a)) -> a

Unwrap a Local field to get the inner value.

pub fn wiring() -> Wiring(model, message)

Create an empty wiring configuration. Pipe through session, topic, and topic_kind to register stores. Pass the result to client.start and server.new.

store.wiring()
|> store.session(extract:, update:, field_get:, field_set:)
|> store.topic(id: "chat", extract:, update:, field_get:, field_set:)
Search Document