ACTIAS

object

Class declarations, state, hooks, and calling instances.

Declare a class

local Auction = object "Auction" {
    migrations = "migrations/Auction",
    publishes = { "bid", events = "self" },
    receives = { ["Account:flagged"] = function(state, event) end },
    expire = "30d",
    admit = function(name) return #name > 3 end,
    directory = {
        from = function(state) return state.store:get("lot") end,
        fields = { status = f.string },
    },
    hooks = {
        init = function(state) end,
        alarm = function(state) end,
        follow = function(state, topic, follower) return true end,
    },

    bid = function(state, user, amount) end,   -- a method
}

Seven reserved keys; every other key is a method.

KeyMeaning
migrationsdirectory of .sql files that build each instance's schema
publishestopics this class emits; name = "self" or = "public" sets a policy instead of using the hook
receives["Class:topic"] = handler for streams it consumes; the class must be declared in the project
expirea duration string; an instance untouched that long is deleted. Touch renews it, a pending alarm postpones it
admitfunction(name), decides whether a NEW instance may exist; truthy admits. A refused name leaves nothing behind
directory{ from, fields }: one queryable row per instance; see Directory
hooksinit, alarm, follow

A class name starting with __ is reserved for the platform and refused. Referencing a class declared elsewhere:

local Auction = objects "Auction"

Instances

Auction("lot-42"):bid("ada", 25)     -- callable form
Auction:get("lot-42"):bid("ada", 25) -- same thing

Arguments and return values must be JSON-representable. A name is yours: any non-empty string, and it says nothing about where the object lives, which is the platform's business.

Class verbs

Auction:list { where = { status = "open" }, order = { closes_at = "asc" }, limit = 50 }
Auction:find { status = "open" }
Auction:visit { where = { status = "open" }, limit = 50 }

All three read the class's directory and wake nothing. A class without a directory declaration has no rows to read.

state

FieldMeaning
state.sqlthis instance's private database
state.storekey-value face on the same storage
state.nameinstance name
state.now()platform clock, milliseconds
state:set_alarm(duration)schedule the alarm hook
state:destroy()ends this instance after the current call replies: storage, snapshot and edges reclaimed, the name recreatable fresh
state:publish(topic, event)emit on a declared topic; an undeclared topic raises
state:follow(handle, topic, filter?)follow another object; returns nothing, raises follow refused when the publisher declines
state:unfollow(handle, topic)drop that edge
state:followers(topic?)current followers, every topic when none is named
state:drop_followers(handle)drop every edge an identity holds
state:method(...)a sibling method of this class, run directly in this call
any other keyin-memory scratch, lost when the object sleeps

state.sql takes the same exec / query / query_one / batch as a database, minus the read variants.

state.store

The key-value face on the same file, for state too small to deserve a table. No migrations key, no declaration at all:

local Counter = object "Counter" {
    hit = function(state)
        local n = (state.store:get("count") or 0) + 1
        state.store:set("count", n)
        return n
    end,
}

The verbs are kv's, page shape included:

MethodReturns
get(key)the value, or nil
set(key, value)nothing; a nil value is a delete
set_batch(table)nothing; writes every pair, deletes the nil ones
delete(key)nothing; one key, where kv takes several
list(options?)one page: { entries, cursor }
  • One transaction. A call that touches state.store and state.sql commits once; the two faces are one file and can never disagree.
  • Values are JSON-representable and at most 65,536 bytes each as stored text. Past that, the write is refused naming the key, and the state wants a table in state.sql.
  • list takes { prefix?, limit?, cursor? }; entries are { key, value } in ascending key order, limit defaults to 100 and a value outside 1 to 1000 is refused, and a page without a cursor is the last.
  • The pairs live in a reserved table that SQL cannot see, from methods and the console alike. Listing them is what list is for.

Hooks

hooks = {
    init = function(state) end,
    alarm = function(state) end,
    follow = function(state, topic, follower) return true end,
}
HookRuns
initonce per instance ever, before its first call proceeds
alarmwhen a set alarm comes due
followto decide whether follower may follow topic; truthy accepts

Hooks are called by the platform. Calling one through a handle is refused: 'init' is a platform hook; only the platform calls it.

With migrations declared, the files apply before init, so init only ever seeds rows. Without it, init is where the tables get created.

follower

The identity a follow hook receives:

FieldMeaning
follower.nameinstance name it speaks as
follower.id"Class/name"
follower.transport"object" or "connection"
follower:is(Class)class check against a class value

Rows from state:followers carry the same fields plus topic.

Durations

set_alarm and workflow timeouts take "500ms", "30s", "10m", "24h", "7d", or a bare number of seconds. expire takes the string form only, and wants at least a second.

Gotchas

  • A method cannot be named init, alarm, follow, receive, receives, hooks, admit or directory, and a name starting with _ is not callable through a handle either.
  • hooks.receive is not a hook. Stream handlers live under receives, keyed by "Class:topic"; the old spelling is refused with a message saying so.
  • Scratch keys on state look durable and are not. Anything needed after the object sleeps goes in state.store or state.sql.
  • state.sql has no read variants. An object's storage has no replica to read from.
  • Objects: serialization, alarms, placement.
  • Directory: the directory key and the class verbs.
  • Streams: what publishes and receives do.