← the tour · concept
concept v
The store: synchronous SQLite
The render path calls the store with no await. That one constraint picks the database, shapes the write strategy, and keeps "why didn't it update?" down to a single suspect.
orion's render path is synchronous. A view is a function of the store, and the
store is called with no await. That isn't an accident or a limitation — it's the
property that makes the loop debuggable: when something looks stale, there is exactly one place
to look. An async store introduces a second class of suspects (did the promise resolve before the
render ran?) and orion declines to add it.
The database that's already in-process and synchronous is SQLite. node:sqlite
ships with Node — no install, no daemon, no config. The Store seam is five methods
over plain SQL; the default adapter wraps it directly. No ORM, no query builder. Queries are named
functions living inside the feature that owns them:
// features/todos/todos.sql.ts — data stays with the feature that owns it
import type { Store } from "../../belt/store.ts"
export const all = (store: Store) =>
store.query<Todo>("select id, title, done from todos order by id")
export const insert = (store: Store, title: string) =>
store.exec("insert into todos (title) values (?)", title)
A view calls todos.all(store) during render — no await, no callback, no promise.
The result is there. The render is a pure function of the store, nothing else.
The Store seam: five methods
The complete interface is deliberately small. Outgrowing SQLite means writing a Postgres adapter for five methods; feature code doesn't change (modulo dialect differences in your own SQL — that's on you, by design):
interface Store {
query<T>(sql: string, ...params: unknown[]): T[]
one<T>(sql: string, ...params: unknown[]): T | undefined
exec(sql: string, ...params: unknown[]): { changes: number; lastId: number | bigint }
tx<T>(fn: () => T): T
migrate(feature: string, migrations: string[]): void
}
query returns rows. one returns a row or nothing.
exec writes and returns affected count and last insert id. tx wraps a
function in a transaction. migrate runs a feature's schema entries. That's the whole
seam.
WAL mode and the read-heavy shape
The belt opens every SQLite database in WAL mode. WAL is single-writer by nature — one writer at a time, many concurrent readers — which is exactly the shape the read-heavy fat-morph pattern wants. Thirty viewers watching a board each hold a long-lived SSE stream, each re-rendering on every relevant event. That's thirty concurrent readers, and in WAL mode none of them block each other or the writer. The pattern is read-heavy by construction; WAL is its native terrain.
The pragmas sqliteStore sets at open:
// belt/store.ts — applied to every database, unconditionally
db.exec("pragma journal_mode = wal")
db.exec("pragma synchronous = normal") // fsync at checkpoint, not per commit
db.exec("pragma busy_timeout = 5000") // two processes sharing the file wait it out
db.exec("pragma foreign_keys = on")
synchronous = normal pairs with WAL: fsync happens at checkpoint, not on every
commit — the standard server setting. An app crash loses nothing; only an OS or power crash can
drop the last few transactions, and it never corrupts. synchronous = full is
available as an option for stricter durability requirements.
Anders Murphy's 100,000 TPS over a billion rows is the empirical foundation for orion's SQLite stance: WAL, the read-heavy single-writer shape, and the throughput ceiling of a well-tuned single node. The short version: most products never need to climb off that node.
Queries live next to the feature
orion never generates SQL and never hides it. Every query is a named function in the feature that owns its table. The ownership is enforced by the migration system — a migration touching a table another feature registered throws at boot, rolled back cleanly, with an error that names exactly who owns it.
Cross-feature data access goes through the owner's exported query functions, never through direct SQL against a foreign table. The fence is machine-checked, not a convention you remember:
// NOT OK — the migration system blocks this at boot
store.query("select * from orders") // called from the "todos" feature
// OK — read through the owner's exported function
orders.forUser(store, userId)
The migration list itself is an append-only array of plain SQL strings. Applied entries are
tracked per feature in _orion_migrations; only new entries run, each in its own
transaction. Forward-only — no down migrations. Production history only moves one direction;
orion declines to pretend otherwise.
// features/todos/todos.sql.ts — migrations live with the queries
export const migrations = [
`create table todos (
id integer primary key,
title text not null,
done integer not null default 0
)`,
// entry 0 already ran in production — append, never edit:
`alter table todos add column created_at text
not null default (datetime('now'))`,
]
The write queue: group commit
In single-process Node the writer is already serialized — one connection, one thread, no two
exec()s ever overlap. So the throughput lever isn't serialization (you have it
free). It's the fsync per commit. Under a burst — a comment storm, a fan-in of
commands — each write committing on its own pays one fsync each. That adds up.
{ writeQueue: true } turns on group commit: the first write opens
one shared transaction and runs immediately, so exec() still returns real
changes/lastId synchronously and same-connection reads see the pending
rows (read-your-writes is preserved). Subsequent writes in the same event-loop turn join that
open transaction. One COMMIT — one fsync — flushes the whole batch at the end of the turn. A
burst of writes becomes one write:
// off by default; opt in once fsync cost bites. Migrations never batch.
import { sqliteStore } from "../../belt/store.ts"
const store = sqliteStore("app.db", { writeQueue: true })
store.tx(() => orders.insert(store, o)) // returns now; commits with the batch…
// …one fsync flushes the whole turn's writes at the end of the tick
Each tx(fn) becomes a savepoint inside the batch, so per-command
atomicity is exactly preserved: a throwing tx rolls back to its savepoint and the
rest of the batch is untouched. The seam is invisible to feature code — whether the queue is on
or off, tx() behaves correctly and exec() returns real results
synchronously.
The trade is a small durability window: a write returns
before its batch commits, so an app or OS crash in that sub-tick gap loses the un-flushed batch.
SQLite rolls the open transaction back cleanly on next open — no corruption, ever. That window is
why it's opt-in. flush(), close(), and graceful shutdown all force a
durable commit on demand. Migrations are never batched; they commit durably, one at a time.
Tuning the batch window
The default batch window is one event-loop turn (setImmediate) — it coalesces
writes already queued in the current turn, which is the natural burst window for most
applications. Two knobs are available if the default isn't right:
import { sqliteStore } from "../../belt/store.ts"
const store = sqliteStore("app.db", {
writeQueue: {
maxBatch: 256, // flush when this many writes accumulate, even mid-tick
maxDelayMs: 2, // widen the window by 2ms to catch adjacent-turn writes
},
})
maxBatch bounds the open-transaction window and memory — once that many writes
accumulate, a flush happens regardless of the timer. maxDelayMs widens the coalesce
window beyond one turn; the cost is that much added latency-to-durable and a longer-held write
lock. The default (0, one turn) is right for almost everything.
Anders Murphy's follow-up on improving performance with pre-sort covers further techniques for read-heavy workloads at scale — pre-sorting before write, index design for the morph pattern, and when the single-node ceiling actually bites. Worth reading before reaching for a write queue tuned beyond defaults.
Observability: what SQL ran and how long
instrumentStore wraps any Store to emit a QueryEvent per read or
write — the source for the dev query panel. Param values are never recorded (they carry secrets);
only the parameterized SQL text, the param count, rows returned or changed, and the
duration:
// composition root — zero overhead in prod when sample is 0
import { sqliteStore, instrumentStore } from "../../belt/store.ts"
const store = config.querySample > 0
? instrumentStore(sqliteStore(path), { onQuery: queryLog.record, sample: config.querySample })
: sqliteStore(path)
At sample: 0 the composition root skips the wrapper entirely — the raw store, zero
overhead. At sample: 1 every query is recorded (useful in development and demos). A
small fraction like 0.01 gives bounded prod sampling. tx and
migrate pass through; queries inside a transaction are still visible because callers
hit the wrapped store.