If you know Rails or Sinatra
What carries over, what is spelled differently, and the four habits that will not work here.
melee’s surface was deliberately shaped like Sinatra, because that is the shape people and models already know. The differences are not stylistic — each one comes from the app being compiled ahead of time and each request being its own process.
The translation table
| Rails / Sinatra | melee |
|---|---|
get "/x" do ... end (Sinatra) | the same |
params[:id] | params[:id] (String or nil) or params.fetch(:id) (String, "" when absent) |
session[:user_id] = 1 | session[:user_id] = "1" — session values are Strings |
redirect_to "/x" | redirect "/x" (always 303) |
head :not_found / halt 404 | halt 404, or halt 400, "why" |
render :index, locals: {a: 1} | render :index, a: 1 |
render partial: "row", locals: {r: r} | partial :row, r: r |
before_action :require_admin | before "/admin" do ... end — a path prefix, not a callback name |
rescue_from | error do |e| ... end |
ActiveRecord::Base | db.query "SELECT ...", bind — SQL, no ORM |
db/migrate/*.rb | db/migrations/NNN_name.sql — SQL files, applied in name order |
app/views/x.html.erb | views/x.erb |
<%= form_authenticity_token %> | <%= csrf_field %> |
Rails.logger.info "x" | log.info "x", key: value — structured, one event per call |
ENV["SECRET"] | ENV["SECRET"], set with melee env SECRET value |
public/ | public/ |
bin/rails server | melee dev |
ActiveJob / sidekiq | a durable object with a timer — see Background jobs |
whenever / cron | the same timer |
Rails.cache | the database, or a durable object |
The four habits that will not work
1. Nothing survives the request in memory
# Rails: fine. melee: the next request gets a fresh process and recomputes it.
def settings
@settings ||= db.query("SELECT * FROM settings")
end
This is not an error — the memoisation works perfectly well within one request, and that is often what you wanted. What does not happen is the second request seeing it. A process-lifetime cache, a connection pool, a class-level registry filled at boot: all of these are per-request in melee.
Where you genuinely need something to persist, that is what a durable object is.
2. There is no background anything
No threads, no Thread.new, no job queue, no after_commit running later, no fork of your own. The
request child answers and exits. Work that must happen outside a request happens in a durable object’s
timer, and work that must happen because of a request either happens during it or gets recorded for a
timer to pick up. Background jobs and scheduled work covers the patterns.
3. No gems, and no metaprogramming
The whole program is compiled ahead of time, so anything that decides what to call at run time is out:
eval, method_missing, define_method with a computed name, send with a computed name, Class.new,
ObjectSpace. Dispatch with case. The available requires are a fixed list — json, base64, digest,
securerandom, uri, net/http, openssl, set, csv, strscan, optparse, pathname, tmpdir,
forwardable — plus require_relative for your own files.
There are a few smaller surprises in the same family: string literals are frozen, there is no Date class
and no Time.parse, and a rescue clause has to name its exception class in full rather than through a
constant alias. The Ruby that compiles is the complete list with a workaround for
each.
4. Compile-time checking is real but narrower than it looks
The build catches the shape of the program, which Rails would not: a template rendered with the wrong
locals, a migration whose SQL does not parse, a require of something that is not there, a forbidden
construct like eval, a call to a top-level helper you never defined.
It does not catch wrong arity, calling a method on nil, or any undefined method reached through an
explicit receiver — db.frist(...), "x".nope and Household.get("home").refrsh all compile and raise
NoMethodError when the line runs, however well the compiler knows the receiver’s type. So a green
melee check is not the same as a working app: click through it under melee dev before you push.
What is better than you expect
- The error page. Under
melee dev, a 500 shows the exception, the request, the params, the session and a backtrace. In production there is no backtrace at all (Spinel does not have them), but there is a structured log line — see When something breaks. - SQLite is enough. It is embedded in the binary, it is on the local disk, and a query is a function call rather than a network round trip. For the size of app melee is for, this removes most of what a database usually costs you.
- Deploys are atomic and instant. A new release is a new directory and a symlink swap. A failed build changes nothing.
- You can read the whole standard library. It is a closed, documented surface — the API reference is generated from the type signatures, so it is exactly what exists.
Next
- The tutorial.
- The Ruby that compiles, if you would rather start with the rules.