intertwingly

It’s just data

Round Trip


Yesterday I closed with the bet that "within a few iterations the round trip works end to end." Iteration one closed faster than that rhetoric implied.

The lowered shape fixtures/spinel-blog was demonstrating by hand is now what roundhouse produces from fixtures/real-blog. The produced thing runs.

The demo

Two commands from a fresh clone:

git clone https://github.com/rubys/roundhouse
cd roundhouse
make real-blog            # generate the Rails fixture (~60s)
make spinel-dev           # transpile + assets + serve on :3000 (~3-5min cold)

make real-blog runs rails new + the standard scaffold + bin/rails db:prepare, populating fixtures/real-blog/storage/development.sqlite3 with three articles and seven comments via db/seeds.rb. make spinel-dev builds the roundhouse Rust crate, transpiles the Rails source through the lower-and-emit pipeline, overlays the result onto a verbatim copy of the spinel-blog scaffold (runtime, dev server, Gemfile, Makefile), and starts the dev server.

Open http://localhost:3000 in a browser. Three Rails-seeded articles render, served by the Spinel-subset Ruby that roundhouse produced. Create a new article — the form posts, the redirect lands on the article page, the flash notice appears and clears on the next render. Open a second tab; create another article in the first; the second tab's index updates in real time without a page refresh, via Turbo Stream broadcasts forwarded over WebSocket. Destroy a comment — the data-turbo-confirm dialog appears; confirming dispatches a _method=delete POST and the row vanishes from the DOM and the SQLite file behind it.

Two things worth being explicit about:

What it took

A sampling of what landed in iteration one. Each is a small focused lowerer pass — mechanical once the framework is in place. One example, the most striking:

Real-blog's Comment model has this:

after_create_commit  { article.broadcast_replace_to("articles") rescue nil }
after_destroy_commit { article.broadcast_replace_to("articles") rescue nil }

broadcast_replace_to is a Rails-API method on associations. Spinel's runtime doesn't implement it; the rescue nil swallows the failure silently. So before this iteration, when a comment was created the parent article's index card never re-rendered. The hand-written fixtures/spinel-blog rewrites the same intent into:

parent = article
return if parent.nil?
Broadcasts.replace(
  stream: "articles",
  target: "article_#{parent.id}",
  html: Views::Articles.article(parent),
)

Same semantics, different API surface. The lowerer now does this rewrite automatically: detect <assoc>.broadcast_replace_to(<stream>) where <assoc> names a belongs_to on the model, replace with the Spinel-shape sequence, drop the rescue nil modifier (the explicit nil-check supersedes it). broadcast_append_to and broadcast_remove_to get the same treatment.

Other rewrites in the same vein — less photogenic, each closing a specific gap:

Some of these gaps surfaced on the roundhouse side. One surfaced on the other.

The flywheel

matz/spinel#49Default-argument values not inserted at omitting call sites — was filed Monday morning as a six-line repro extracted from the spinel-blog fixture. By the time I'd written the rest of that day's commits, Matz had closed it.

I'd hesitate to read too much into one issue. But it's a concrete instance of the loop both projects gain from: roundhouse's emit forces specific Spinel coverage decisions ("can it compile this?"), and Spinel maturing enlarges what the lowerer can confidently emit. The default-arg fix means roundhouse can keep emitting def initialize(attrs = {}) — the natural Ruby shape — without contorting around it. The contract is bidirectional, and the channel works.

The honest gap

What's still incomplete, in order of decreasing visibility:

Spinel hasn't compiled this output yet. Today's demo runs on CRuby. Four of the five inference gaps from the previous post remain. The path is open — issue #49 is the proof — but the compile-half of the round trip stays unproven until those close.

Coverage is one app. Real-blog is idiomatic Rails 8 MVC: two models, two controllers, four resourceful routes, broadcasts, validations, basic flash, no concerns, no jobs, no mailers, no file uploads. Other Rails apps will surface DSL patterns and callback shapes the lowerer doesn't yet recognize. Each is a small focused pass; the work scales linearly per feature — but it scales, and one app is one app.

Some integration parts are placeholders. sqlite3 linked through the CRuby gem instead of through Spinel FFI; broadcasts via a watched tmp-directory instead of shared memory or unix sockets. Both work for the demo. Neither is the right answer for production. Both are deployment-substrate concerns, separable from the architectural shape.

What this changes

The bet yesterday was that the architectural shape was right and the maturity gaps would close on both sides. Iteration one's empirical answer:

What was unproven before is now demonstrated; what's unproven now is the Spinel half. That's a different thing to be unsure about than "does the architecture work" — it's a finite list of compiler issues with a responsive author on the other end.

The reason any of this matters — requests per second per gigabyte of resident memory, the line item the cloud bill is actually charging for — is what CRuby's fork-per-worker shape leaves on the table. Compiled Ruby that stays inside the Rails ecosystem closes that gap without rewriting into another language. WHY.md is the full case.

Roundhouse generates the Spinel-shape Ruby; Spinel compiles it. The interface holds, and the feedback runs in both directions. From here, the work of growing both halves is something the two projects are well-positioned to do together.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.