DEV Community

arcker
arcker

Posted on Originally published at arcker.org

Lithair learns SQL — and lists everything it refuses to promise

Lithair is a memory-first web framework I build in Rust — state lives in memory, an event log on disk lets it be rebuilt, and everything ships as one binary. Its tagline is In Memory We Trust. This post stands on its own.

This week it gained SQL storage.

Put like that, it sounds like a betrayal. It isn't, and the reason has less to do with what the new adapter can do than with the list of things it refuses to promise.

What shipped

A new crate, lithair-turso, versioned separately and marked experimental. Add it to the application, put one annotation on the model, and register that model like any other:

#[derive(Clone, Serialize, Deserialize, DeclarativeModel)]
#[storage(turso)]
struct Archive {
    #[db(primary_key)]
    id: String,
    #[http(validate = "non_empty")]
    title: String,
}

LithairServer::new()
    .with_model::<LiveTask>("./data/tasks", "/api/tasks")        // native, the default
    .with_model::<Archive>("./data/archives", "/api/archives")   // SQL
    .serve().await?;
Enter fullscreen mode Exit fullscreen mode

At startup Lithair opens ./data/archives/model.db — an embedded, SQLite-compatible Turso file — and generates the CRUD routes: validation, atomic PATCH, session authentication, model permissions. No repository to write, no HTTP handler, no database opening.

And the point that matters to everyone who doesn't want it: lithair-core does not depend on the Turso driver. A native application doesn't compile it, doesn't download it, doesn't know it exists.

The choice: one authority per model

The RFC framing the work starts by separating three things that are easy to conflate:

  1. native models      what exists: events, retention, replication
  2. SQL models         SQL owns the current state; reads query SQL
  3. SQL projections    derived state, rebuilt from the native log
                        ← later, and it is a different product
Enter fullscreen mode Exit fullscreen mode

This release ships (2) next to (1). A model has one authority: either the memory-first path or SQL. Never both, never one as a cache in front of the other. An application can mix models of both kinds; a model cannot.

The consequence is written down plainly: there is no distributed transaction between a native model and a SQL model. A workflow touching both needs a saga or a compensation, designed separately. The framework doesn't pretend to do that for you.

What it refuses — and where

A native Lithair model can do a lot: history, memory/disk retention, SSE streams, Raft replication, lifecycle auditing. A SQL model knows none of that. The temptation, in this kind of addition, is to accept the annotations and quietly ignore them.

Here it's the opposite:

  not provided on a SQL model
  ─────────────────────────────────────────────────────────────
  secondary uniqueness, foreign keys, native migration,
  history, retention, immutability, SSE, Raft, joins,
  transparent cache coherence

  → the macro REJECTS a declaration asking for them     (at compile time)
  → startup REJECTS native clustering and data-admin    (at launch)
    on a server that has SQL models
Enter fullscreen mode Exit fullscreen mode

The second refusal is the instructive one. Lithair's data-admin can export and back up native models. On a mixed server that export would succeed — and leave out everything living in SQL. A backup that succeeds while omitting half the data is exactly the kind of success I spent the summer getting rid of. So the server refuses to start in that configuration. It's less convenient. It's true.

Same logic for directories: selecting Turso in a folder holding native events, or the reverse, doesn't start. Changing engines is an explicit migration, not a side effect of an annotation.

Five releases in two days

v1.11.0 went out, and four more followed within two days. None adds glitter; each fixes what a first real use showed.

  • v1.12.0 — migrations. The first cut said: to change the schema, migrate by hand or pick a new collection. That didn't survive a day. You now declare version = 2, migrations(note_v2): ordered Rust transformations, applied at startup, validated before serving, rolled back as a whole if one fails or panics. Downgrades are rejected.
  • v1.12.1 — pagination. SQL lists returned 50 results and nothing saying there were more. They now carry has_more and next_offset. With an honest caveat in the note: the count happens before the permission filter, so an authorized page can be empty and still have a next one.
  • v1.12.2 — parity. A SQL DELETE answered 200 {"deleted": true}; a native DELETE answers 204 with no body. Two engines, two contracts for the same route: fixed, and the note tells clients to stop parsing a body.

And then there's v1.11.1, which isn't about Turso at all.

The unrelated bug — that matters most

The same day, an application built on Lithair reported this: create a record, delete it (204), restart the server — and the GET on the deleted ID answers 200.

The log on disk was correct: the Deleted event was there, in the right place, after the creation. The replay was wrong. It deserialized every envelope as a model and inserted it, without looking at the event type. And a deletion envelope carries the deleted record. So replay inserted it again. A deleted record came back to life on every restart, and the same defect hit replicated deletions of records evicted from memory.

It had been there since at least 1.10, and Turso models were unaffected. It's the most serious bug of the week, it lives in the original engine, and no test found it: someone using it did. Fixed in v1.11.1 the same day — replay now interprets operations in append order, including delete-then-recreate of the same ID — without rewriting the log or touching the hash chain.

There's an irony I won't paper over: the week the framework gains a second engine while promising never to pretend, it's the first engine that gets caught pretending. A 204 followed by a resurrection is precisely a success reported for work that doesn't hold.

What we learned

That you can add SQL storage to a memory-first framework without betraying it, on one condition: don't make it carry the other engine's promises. Native stays the default, SQL is a per-model choice, the crate has its own 0.x version series, and everything it can't do is a compile error or a refusal to start — not a line in a FAQ.

And the limit, named: it's experimental. No PostgreSQL, no projections, no SSE on SQL models, string equality as the only filter, and pagination capped at 100 candidates. The RFC ends with a "next decision" section, not a roadmap.

The tagline doesn't change. In Memory We Trust — and when it isn't in memory, you're told at compile time.

French original on arcker.org.

Top comments (0)