Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

CallReturns
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.listevery id in this class, oldest first
Household.counthow 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 a db.query row already has. A Time or a Symbol is a runtime ArgumentError on both melee dev and in production, not a build error: narrow with .to_i/.to_s the way route code narrows params.
  • 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 what Object and IO already 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 storage does.

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: :lessonrender views/lesson.erb with the returned Hash as its locals
layout: falseas 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: truemake 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.