Durable objects
Named, long-lived objects with their own SQLite database and one timer, hosted by the app’s worker.
A class that inherits from Durable is a named, long-lived object with its own SQLite database and one
timer. It lives in the app’s worker process, not in a request child, so it survives between requests and can
wake itself up when nothing is happening.
# app.rb, or lib/household.rb
class Household < Durable
def setup = timer(after: 0) # once, when the object is first created
def on_timer # when the timer fires
refresh
timer after: 600
end
def refresh
storage.query("SELECT * FROM feeds").each { |f| refresh_feed(f) }
storage.put("last_refresh", Time.now.to_i)
end
def events_for(day) = storage.query("SELECT * FROM events WHERE day = ? ORDER BY starts_at", day.to_s)
def stale? = Time.now.to_i - storage.get("last_refresh").to_i > 3600
end
get "/d/:token" do
home = Household.get("home")
render :display, today: home.events_for(today), stale: home.stale?
end
class Household < Durable must be at the top level of app.rb or a file under lib/, not nested in a
module or another class. The build step reads it, so a durable class inside a def or an if is never seen.
Getting a handle
| Call | Returns |
|---|---|
Household.get(id) | a handle to the Household with this id. The id must be a non-empty String; anything else raises. The object exists from the first call — there is no separate “create”. |
Household.list | every id in this class, oldest first |
Household.count | how many there are |
There is no default instance and no way to list across classes. Household.get("home") always returns a
handle, whether or not "home" exists yet; the object is created, and setup runs, on that handle’s first
call.
Every other method comes from the class itself: home.refresh, home.events_for(today). The build step
reads your class and generates one forwarding method per public method it finds, so the set of callable
methods is fixed and readable in .melee/objects.rb.
It does not follow that a misspelling is caught at build time. home.refrsh compiles and raises
NoMethodError when the line runs, the same as any other undefined method with an explicit receiver
(dialect.md, “Errors that only appear at runtime”). Wrong arity raises ArgumentError at run time too.
Measured 2026-09-13 against the pinned Spinel a9f1d9ae.
Inside the object
id is the object’s own id, a String. log is a request-shaped logger (log.info "msg", key: value),
useful here because no request is current in the worker to attach one to.
storage is the object’s own SQLite database — a separate file per object, not the app’s db. It has the
same query/first/run/exec/transaction/last_id as db (see database.md), plus a key/value
shortcut:
storage.put("last_refresh", Time.now.to_i) # -> the value
storage.get("last_refresh") # -> the value, or nil
storage.delete("last_refresh") # -> true if a row was removed
put/get/delete share one table, created on first use. A put value must be JSON-shaped — see below —
so it survives being written and read back exactly.
Plain instance variables are a cache: they persist between calls while the object is in memory, and vanish
whenever the worker decides to unload it (an idle object, a deploy, a restart). Anything that must survive
goes through storage, never through an ivar.
Timers
timer after: 600 # ten minutes from now
timer at: Time.now + 3600 # a specific time, or Unix seconds
cancel_timer # clear the pending timer, if any
One pending timer per object; setting it replaces whatever was there. When it fires, the worker calls the
object’s on_timer method — define it to do anything on a schedule, and call timer again inside it to keep
firing (there is no repeating “every N seconds” yet; re-arming on_timer covers it). timer and
cancel_timer return once the pending timer is recorded, which survives a crash between calls: nothing is
lost if the worker dies right after.
(in: would read better than after:, but Ruby cannot name a keyword argument in.)
setup and destroy
def setup = timer(after: 0)
setup runs exactly once, inside the call that first created the object, before that call’s own method runs.
Use it to schedule the first timer or seed storage.
def close_out
destroy
nil
end
destroy deletes the object’s database file and its row in the registry. Anything still held for this id is
dead afterwards — a further storage, timer or cancel_timer call in the same method raises
Durable::Destroyed. There is no undo and nothing is collected automatically otherwise: an object you never
destroy lives forever.
Migrations
An object class’s own schema lives in db/objects/<class_name>/NNN.sql, where <class_name> is the class
name underscored (Household -> household). Each file is plain SQL, checked at build time the same way the
app’s own migrations are, and applied once per object’s database, in filename order, the first time that
object is opened:
-- db/objects/household/001_feeds.sql
CREATE TABLE feeds (id INTEGER PRIMARY KEY, name TEXT NOT NULL, colour TEXT NOT NULL, url TEXT NOT NULL,
fetched_at INTEGER, error TEXT);
Every Household gets its own copy of this table, in its own file. Adding 002_....sql later migrates every
object the next time each one is opened; nothing re-runs 001.
The rules
- Positional parameters only. A durable method may take required and optional positional parameters
(literal defaults, like
def add(a, b = 1)); no keyword arguments, no splat, no block, no destructuring. The build step rejects anything else with the file and line. - JSON-shaped in, JSON-shaped out. Arguments and return values may only be
String,Integer,Float,true,false,nil, and Arrays and String-keyed Hashes of those — the same shape adb.queryrow already has. ATimeor aSymbolis a runtimeArgumentErroron bothmelee devand in production, not a build error: narrow with.to_i/.to_sthe way route code narrowsparams. - Reserved and forbidden names.
setup,on_timer,timer,cancel_timer,storage,log,id,destroy,get,list,count(and a few internals:live!,release,initialize) are the object’s own vocabulary and cannot be redefined as callable methods. A further list —to_s,inspect,class,send,call,each,close, and the rest of whatObjectandIOalready mean — is a build error if you define it, because a Handle would collide with what Ruby itself expects there. - One call at a time, per app. The worker that hosts every Durable object in an app is single-threaded: a slow method on one object delays calls to every other object in that app, in this milestone. Keep durable methods quick.
- Ivars are a cache, as above — they do not survive an unload. Only
storagedoes.
Under melee dev
There is no separate worker process in development: melee dev handles one HTTP request at a time already,
so it runs the same dispatch in-process and keeps the objects alive between requests in the dev server
itself. Its accept loop checks for due timers roughly once a second — including while no request is
happening — and fires them, printing a line (timer Household/home -> on_timer) or the exception if one
raised. Same code path as production, no socket in between.
Routing straight to an object
A route can name an object method instead of taking a block, and declare what to do with what it returns. The method still runs in the worker, exactly as it does through a handle; the difference is that the route says what the answer is for, so you do not write the lines that unpack it.
get "/c/:course/l/:lesson", to: "Lesson#show", view: :lesson, id: [:course, :lesson]
post "/c/:course/l/:lesson/done", to: "Lesson#complete", id: [:course, :lesson], redirect: "/c/:course/l/:id"
get "/board", to: "Board#items", view: :board, id: "board", create: true
to: "Lesson#show" | the class and the method, as one string |
view: :lesson | render views/lesson.erb with the returned Hash as its locals |
layout: false | as on render; the default is true |
redirect: "/c/:course" | redirect to this path, with each :name filled in |
id: | which path param is the object’s id, when it is not the only one |
create: true | make the object if this id has none yet; without it an unknown id is a 404 |
A route has a block or a to:, never both, and a to: route has exactly one of view: and redirect:.
Everything else about the route is unchanged: before filters, CSRF and not_found all still run first, in
the request child, so a filter can turn a request away before the object is called at all.
The route reaches an object that already exists. A handle creates its object on the first call — that is
what Household.get("home") does — so a route whose id comes from the URL would make one for every path
anybody typed, filling the registry with objects nobody asked for. An object route therefore looks the id up
first and answers with your not_found block when there is none. Say create: true where first visit really
should create it: a per-URL cache object, or a fixed id: you wrote yourself.
The method returns a Hash, which is what a durable method already had to return — String keys, JSON-shaped
values, the same shape as a db.query row. For a view: route those keys are the template’s locals, and the
method must return every local the template declares; forgetting one is an error naming the method, the key
and the template rather than a blank space in the page. Returning nil means the method had nothing to
render — the object exists, its storage is empty or the thing asked for is not in it — and your not_found
block answers.
class Lesson < Durable
def show(params)
row = storage.first("SELECT title, body FROM lesson")
return nil if row.nil?
{ "title" => row["title"].to_s, "body" => row["body"].to_s }
end
def complete = { "id" => "done" }
end
The method takes no arguments, or one, which receives the request’s merged params (path, then query, then
form) as a Hash{String => String} — the same values params gives a block route. A JSON request body is not
in it.
The object’s id comes from the path. With one :param in the pattern that is the id; otherwise name it
with id: :handle. id: [:course, :lesson] makes a composite id from several params, URL-encoded and joined,
so two courses can each have a lesson intro without colliding. id: "home" is a fixed id for an app with
one of something. Inside the method the individual parts are still readable from params.
For a redirect: route each :name in the path is filled from the returned Hash first and the request’s
params second, so a create can redirect to the thing it just made ({ "id" => storage.last_id } fills
:id). Here nil is simply no values to fill in, since there is no page to render: a method that only does
work needs to return nothing in particular.
What you give up is branching: one route, one method, one view or one redirect, with no way to choose a different template, set a status or read the session on the way through. When a page needs any of that, write an ordinary block route and call the same method through a handle — the two forms reach the same objects, and nothing stops an app using both.
Errors a route can see
A call that reaches a live worker and raises inside the object surfaces as Durable::RemoteError in the
route, with remote_class set to the raising exception’s class name ("ArgumentError", and so on):
begin
Household.get("home").refresh
rescue Durable::RemoteError => e
log.error "refresh failed", remote_class: e.remote_class, message: e.message
halt 502
end
The same exception class also covers a worker that could not be reached at all, with remote_class set to
"Melee::Objects::WorkerError" — there is no way to tell the two apart except by that string. The request’s
own timeout still bounds the whole thing, so a durable method stuck forever turns into a 504 rather than
hanging the route indefinitely.
From the command line
melee objects # every durable object, across every class
melee objects Household # just this class
melee objects Household home --destroy
Lists class, id, created, last call and the next pending timer (- when none), oldest first. --destroy
needs both a class and an id, and deletes the object exactly as calling destroy from inside it would.