Streams
Objects publish events on named topics; followers the publisher approved receive them.
Publishing
An object declares its topics and publishes events on them.
local Auction = object "Auction" {
migrations = "migrations/Auction",
publishes = { "bid", "closed" },
bid = function(state, user, amount)
state.sql:exec("INSERT INTO bids VALUES (?, ?, ?)",
{ state.now(), user, amount })
state:publish("bid", { user = user, amount = amount })
end,
}
state:publish appends the event to this object's log in the same
transaction as the write above it. An event therefore never describes a
write that did not happen.
Delivery runs after the call commits, along whatever edges exist. Publishing an undeclared topic is an error.
flowchart LR
log[(publisher's log<br/>and edge table)] --> lo[local object edge:<br/>routed receive call, cursor,<br/>retried 8 times]
log --> lc[local connection edge:<br/>pushed into the 256-item inbox,<br/>at-most-once]
log --> ro[remote object edge:<br/>one delivery per edge<br/>to its home node]
log --> rc[remote connection edges:<br/>the events once per node,<br/>sliced there per edge]
Who may follow
An edge exists only if the publisher accepted it.
publishes = {
"bid", -- the follow hook decides
events = "self", -- only this instance's own identity
closed = "public", -- any identity
}
| Spelling | Who gets in |
|---|---|
| bare name | whoever the follow hook returns true for |
= "self" | only an identity equal to this instance |
= "public" | any identity |
| neither | nobody. A missing hook fails closed |
Use the hook when the answer depends on state:
local Guild = object "Guild" {
migrations = "migrations/Guild",
publishes = { "activity" },
hooks = {
follow = function(state, topic, follower)
return follower:is(Account) and state.sql:query_one(
"SELECT 1 FROM members WHERE user = ?", { follower.name }) ~= nil
end,
},
}
The follower argument is data, not a live handle: follower.name,
follower.id, follower.transport ("object" or "connection"),
and follower:is(SomeClass) for its class.
When to use "self"
"self" is the personal stream: a user's own notifications, reaching
whichever of their devices is connected. A websocket's identity is
minted server-side after authentication, so a connection following
Account("ada"):events genuinely is Account/ada; anyone speaking as
someone else is refused.
Following and receiving
local Account = object "Account" {
migrations = "migrations/Account",
receives = {
["Auction:closed"] = function(state, event)
state.sql:exec("INSERT INTO notifications VALUES (?, ?)",
{ state.now(), json.stringify(event.data) })
end,
},
watch = function(state, auction_id)
state:follow(Auction(auction_id), "closed")
end,
}
receives keys are "Class:topic". publishes and receives at the
top of a class show both directions of its data flow.
| Verb | Does |
|---|---|
state:follow(handle, topic, filter?) | creates the edge; returns nothing, and raises follow refused when the publisher declines |
state:unfollow(handle, topic) | removes it |
state:drop_followers(Account(name)) | removes every edge that identity holds |
state:followers(topic?) | lists current followers, every topic when none is named; rows carry name, id, topic and transport |
The optional filter matches equality against top-level fields of the event data, so one filtered edge replaces many topic-specific ones.
Delivery to objects
Events reach a sleeping object. The publisher's log keeps them, the edge tracks how far the follower has read, and delivery wakes the follower. An account with nothing running receives every event for what it follows, in order.
A new edge starts at the log's head: history is the publisher's state, reached by an ordinary method, never replayed down a fresh edge. Re-following an existing edge keeps its cursor.
The follower records the event's position in the same transaction as
its handler's writes, so a redelivered event is skipped, not re-run.
An event whose "Class:topic" has no receives entry is consumed and
discarded without error.
Delivery to browsers
Websocket connections are also followers, with weaker promises: at-most-once, no retry, and the edge dies with the tab. See sockets.
A connection that declares an event handler shapes each delivered
event into its own frame. One that declares event = "forward" sends
the platform envelope, { type = "event", topic, from, data },
without running any code.
Keep anything that must survive on an object edge, one hop back from the connection. A device may miss a frame; the object does not miss the event.
Gotchas
- Delivery to objects is at-least-once, with the follower's own writes protected by the cursor. Effects that leave the follower's transaction, a call to a third object or a kv write, can repeat after a crash mid-handler; make those safe to run twice.
- Order holds per publisher, not across publishers. If order matters across sources, put a sequence number in the data.
- An edge that keeps failing is dropped after 8 attempts, 500 ms doubling to 32 s. It is not retried forever and it does not resume by itself.
- A delivery that runs past 10 seconds counts as a failed attempt.
Related
- Objects: the entities that publish.
- Sockets: connection edges and their weaker promises.
- Guarantees: every promise and limit in one table.