ACTIAS

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
}
SpellingWho gets in
bare namewhoever the follow hook returns true for
= "self"only an identity equal to this instance
= "public"any identity
neithernobody. 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.

VerbDoes
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.
  • Objects: the entities that publish.
  • Sockets: connection edges and their weaker promises.
  • Guarantees: every promise and limit in one table.