← 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.

orion ✦ a belt of stars · built on datastar