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 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:)