Rowflare is an open-source (AGPL-3.0) Airtable alternative. The whole app is one Cloudflare Worker plus one Durable Object class: every base gets its own Durable Object, and that object owns a private SQLite database with all of the base's tables, fields, views and rows.
browser --HTTP--> Worker (auth, routing, REST API, assets) --RPC--> Base Durable Object (one per base)
^ | | SQLite: tables, fields, views, rows
+------ WebSocket --------+-----------------------------------------+ alarm: batch jobs
D1: users, workspaces, members, tokens R2: attachments
The Worker handles sessions, personal access tokens, workspaces and roles (in D1), the REST API, uploads to R2 and the static web app. The Durable Object applies every write, recomputes dependent fields, pushes changes to connected browsers over WebSocket Hibernation, and runs long jobs from its alarm.
Most of the interesting decisions were about how to lay out user data inside that one SQLite database. Several of them went the opposite way from what we first assumed, and only load tests settled them.
1. One physical table per user table
The obvious design is a single rows table with a table_id column. In load tests it made the SQLite query planner pick unrelated indexes, and filters got 2 to 4 times slower. So each user table is its own physical table (t_1, t_2, ...).
2. Rows are JSON documents keyed by field id
Spreadsheet users add, rename and delete columns all the time. Storing each row as a JSON document keyed by field id means those operations only touch metadata: no ALTER TABLE, no locking, no column limit. Deleted fields leave keys behind that are ignored on read.
3. No expression indexes on the row JSON
Expression indexes look like the natural way to make JSON fields filterable. They are rewritten on every write, though, and in our tests they tripled the rows written and made full-column recomputes 3 times slower. We dropped them, and the aggregation engine folds rows in 1,000-row batches inside the Durable Object instead of running GROUP BY over JSON.
4. Links get their own pair tables
A link field is stored twice: in a pair table per link field (l_1(a, b), indexed both ways) and mirrored into both cells as [{ id, t }], where t is the cached title the grid displays. Reverse lookups ("which tasks point at this project?") use the index instead of scanning JSON.
Keeping computed fields correct
Lookups, rollups and formulas are materialized into the row, so reads are free and writes only update what depends on the change. Every write goes through one transaction:
- Coerce incoming values to each field's stored form.
- For link cells, diff old and new ids, update the pair table and patch the inverse cell on the other side.
- Recompute local computed fields in dependency order, then follow incoming links to rows elsewhere whose lookups, rollups or link titles depend on what changed. Formula cycles are rejected when the formula is saved.
- Flush changed rows and broadcast them to clients (or a "reload" hint when more than 500 rows changed).
Not blocking edits behind big recomputes
Durable Objects are single-threaded. A new formula on a 100,000-row table can take seconds to compute, and nobody else can edit while it runs. Column-wide work therefore goes into a _jobs table. Tables up to 2,000 rows are processed inside the request; larger tables are processed 500 rows per step from the Durable Object alarm, 4 steps per alarm, so other users' edits slip in between batches. That batching is what keeps a 125,000-row base responsive.
Some numbers
Measured against the real engine with 50,000 rows, 15 fields, 2,000 linked projects, a lookup, two rollups and a formula (local wrangler dev, so indicative only):
| Action | ms |
|---|---|
| Open a view, first page | 0.46 |
| Edit a cell + rollup update | 0.3 |
| Filter by any field | 58 |
| Kanban group counts | 81 |
| One 500-row recompute batch | 3 |
| Paste 1,000 rows and delete them | 24 |
The full table, including the numbers that are still unmeasured on production, is in the architecture docs.
Running it outside Cloudflare
The same Worker code also runs in a single Docker container: a small Node entry point starts it in workerd through Miniflare, with Durable Objects, D1 and R2 stored as local files in one volume. It needs no external database.
docker run -d -p 8787:8787 -v rowflare-data:/data rowflare/rowflare:0.1.0
Links
- Source: https://github.com/rowflare/rowflare
- Architecture write-up: https://rowflare.com/docs/architecture/
- Site: https://rowflare.com
Disclosure: I'm part of the Rowflare project. Questions about the storage layout or the Durable Object batching are welcome in the comments.
Top comments (1)
How do you handle a formula being edited again halfway through a column recompute? The architecture doc describes sequence numbers for AI cells; is there a similar generation check for
_jobs, or does the next batch pick up the current formula and restart from the beginning? Curious what happens to the rows already processed under the old formula.