a belt of stars

orion

Real-time, multiplayer web apps without the machinery.

An opinionated, zero-dependency toolbelt for building hypermedia apps on Datastar. Batteries-included like Rails — but it's pieces you wire yourself, not a runtime that owns your process. One small idea, followed all the way down, is what makes live collaboration the default behavior instead of a feature you bolt on — and what makes the whole thing small enough for one developer to run a business on. See it running: the live multi-tenant demo deploys the whole stack to Cloudflare — switch persona, probe a foreign tenant, and watch the gate decide.

read the code try the live demo

Status of each piece is marked: built works in the example app today · planned designed, behind the seam, not yet wired

iThe one loop

Everything in orion serves a single round-trip. A user acts; a command validates and writes to the store, then publishes a topic that says only “something here changed.” Every live view subscribed to that topic re-reads the store and re-renders its region. The server streams the fresh HTML over SSE and the browser morphs it in place.

act
POST /todos
command
validate · write
bus
publish("todos")
live
store → Html
fat morph
SSE · brotli
↑  the store — SQLite in WAL mode — is the only source of truth  ↑

The load-bearing trick: events carry no state. They just say “re-read the store.” Every render is the complete current truth — so dropped, coalesced, or out-of-order renders are harmless, and every viewer is consistent by construction.

Want to see what this actually looks like as code? Before the concepts, there's a short guided read of a whole orion app — the project tree, main.ts, routing, a feature slice, and a seam — in the order you'd explore the repo.

read the code read more on the loop

iiWhy multiplayer is free

Because shared state always flows back through live, single-player and multiplayer are the same code. A command publishes to a topic; every subscribed stream re-renders. With one viewer that's a normal app. With fifty, it's multiplayer. Nothing in the feature changes.

read · long-lived

live(store → Html)

One SSE stream per page region. A pure function of the store, re-run on every relevant event. No diffing, no client state to drift.

write · short-lived

command(ctx)

Validate signals, mutate the store, publish. Answers only with requester-scoped feedback — form resets, validation errors. Shared truth goes out through live.

The seam that scales it out is the Bus — two methods, publish and subscribe. In-process Map today; Redis, NATS, or a Durable Object when you outgrow one machine. The feature code that publishes and subscribes never changes a line.

read more

iiiA feature, end to end built

A feature is one folder, four files: sql (data), view (read), commands (write), routes (the resource). Here is the whole multiplayer surface of a todo list.

// todos.view.ts — the read side. A pure function of the store.
import { region } from "../../belt/region.ts"
import { html } from "../../belt/html.ts"

export const list = region(
  { topics: ["todos"], model: (ctx) => ({ todos: todos.all(ctx.store) }) },
  ({ todos }) => html`
    <ul id="todos">
      ${todos.map(t => html`<li>${t.title}</li>`)}
    </ul>`,
)                                          // model first, view second

// todos.commands.ts — the write side. Validate, mutate, publish.
import { command } from "../../belt/http.ts"
import { parse, shape, str } from "../../belt/shape.ts"
import { formErrors } from "../../belt/ds.ts"

const todoShape = shape({ title: str().min(1) })

export const add = command(async (ctx, respond) => {
  const r = await parse(todoShape, ctx.signals)
  if (!r.ok) return respond.patch(formErrors("todo", todoShape, r.issues))
  todos.insert(ctx.store, r.value.title)
  ctx.bus.publish("todos")            // → every watcher re-renders
})

Open it in two browser windows. Add a todo in one; it appears in the other. That's the entire multiplayer story — no sockets to manage, no client store to reconcile.

Presence, inline edit, and per-viewer affordances

The same loop carries richer collaboration. Presence is an ephemeral store the live view watches — connection state that lives on the server but isn't in the database; a permission check decides what each viewer's HTML even contains.

import { region } from "../../belt/region.ts"
import { presenceAvatars as avatars } from "../patterns/presence.ts"

export const board = region(
  {
    topics: ["todos"],
    watch: [presence, authz],                // repaint on join/leave & grant change
    model: (ctx) => ({
      todos:   todos.all(ctx.store),
      viewers: presence.here(ctx),           // who's looking, right now
      canEdit: authz.can(ctx.store, ctx.user.id, "edit", "todos"),
    }),
  },
  ({ todos, viewers, canEdit }) => html`
    <header>${avatars(viewers)}</header>
    <ul id="todos">${todos.map(t => row(t, canEdit))}</ul>`,
)

Grant a viewer the edit role mid-session and their edit controls appearauthz is watchable, so the permission change repaints every affected region with no refresh. The same mechanism powers live feature flags and billing entitlements.

read more

ivRuns inside any HTTP server built

The HTTP seam is fetch-standard. The router is (Request) => Promise<Response> and handlers are (ctx) => Response — no framework coupling. The bundled node:http host is just one optional battery; the same router.handle drops into anything that speaks fetch. The fetch-style refactor touched zero lines of the example feature.

// the blessed default — belt/serve.ts wraps node:http
import { serve } from "./belt/serve.ts"

serve(router.handle, { port: 3000 })

// global transforms are plain function composition — no middleware stack
serve(withSecurityHeaders(router.handle))
// mount the same router inside an existing Hono app
import { Hono } from "hono"

const app = new Hono()
app.use("/admin/*", someHonoAuth)
app.mount("/", router.handle)   // orion owns the rest
// Bun's native server speaks fetch directly
Bun.serve({
  port: 3000,
  fetch: router.handle,
})
// Deno runs the WHOLE loop unchanged — same node:sqlite, no build
Deno.serve(router.handle)

// the only Node-ism was the host; node:async_hooks + node:crypto port as-is
// a thin Worker routes to a per-tenant Durable Object…
export default {
  fetch(req, env) {
    return env.APP.getByName(tenant).fetch(req)
  },
}
// …and inside the DO, the SAME router.handle runs,
// Store bound to the DO's synchronous ctx.storage.sql

One repo can be deploy-heterogeneous: the main loop on a Node or Deno box, individual slices shipped to Cloudflare as Workers where their primitives earn it. The boundary between them is message-based, never shared memory.

vThe store: synchronous SQLite, single writer built

The render path is synchronous — views call todos.all(store) during render with no await. That one constraint is what keeps “why didn't it update?” down to a single suspect (your render), and it's why SQLite is blessed: it's the database that's already in-process and synchronous.

SQLite in WAL mode 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. The Store seam is five methods over plain SQL; the default adapter is node:sqlite, which ships with Node. No ORM, no query builder, no migrations framework — queries are named functions living inside the feature that owns them.

And under write bursts there's an opt-in write queue. In single-process Node the writer is already serialized — one connection, one thread — so the lever isn't serialization but the fsync per commit. { writeQueue: true } turns on group commit: a burst of writes coalesces into one transaction and one fsync (a comment storm becomes one write), while each tx() stays atomic via savepoints and exec() still returns real results synchronously. It's invisible at the seam — feature code doesn't change.

Much of orion's SQLite stance — WAL, group commit, the read-heavy single-writer shape — stands on Anders Murphy's work: the unreasonable effectiveness of SQLite and improving performance with pre-sort.

// off by default; opt in once fsync cost bites. Migrations never batch.
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

The trade is a small durability window — a write returns before its batch commits, so an app/OS crash in that sub-tick gap loses the un-flushed batch (rolled back cleanly, never corrupted), which is why it's opt-in. flush(), close(), and graceful shutdown all force a durable commit on demand.

read more

viBackground work & durable workflows

Background jobs ride the same store. enqueue() is a plain INSERT, so inside a command's transaction the job commits or rolls back with the domain write — a transactional outbox, for free, with no second system. built

// a command enqueues inside its OWN transaction — outbox for free
export const requestReport = command({
  run: ({ store, jobs, input }) => store.tx(() => {
    const id = reports.insert(store, input)
    jobs.enqueue("report.build", { id })   // commits with the insert, or not at all
  }),
})

// the handler runs in-process via the feature's start() slot.
// progress publishes bare events → every watcher's <progress> bar morphs.
jobs.handle("report.build", async ({ store, bus, payload }) => {
  for (const step of buildReport(payload.id)) {
    reports.advance(store, payload.id, step)
    bus.publish("jobs")                    // live progress, no new plumbing
  }
})

The zero-dep default claims jobs with one atomic UPDATE…RETURNING, so two consumers — even in two processes sharing the db file — never double-run a job. At-least-once, exponential backoff, dead-letter after max attempts.

True durable workflows — same seam, upgraded executor

v1 jobs is deliberately enqueue / progress / retry. Durable workflows — steps, sleep, await-event, surviving restarts and deploys — live behind the same seam, backed by honker (a SQLite extension), Absurd on Postgres, or Cloudflare Workflows. A feature that codes against the seam doesn't change a line when you swap executors. planned

// honker upgrade: durable steps & sleep, still inside your SQLite file
workflow("onboard", async (step, { account }) => {
  await step.do("provision",  () => provisionTenant(account))
  await step.sleep("grace", "3 days")            // durable — survives deploys
  await step.do("welcome",    () => sendWelcomeEmail(account))
})
TierZero-dep defaultSelf-host upgradeCloudflarePostgres
Jobs_orion_jobs + pollhonker (NOTIFY/LISTEN)QueuesAbsurd
WorkflowsSQLite sagahonker steps/sleepWorkflowsAbsurd
Notify / cronsetTimeout pollhonker + cronQueues + CronLISTEN/NOTIFY

read more

viiThe growth ladder — and where Postgres fits

Scale is a ladder you climb rung by rung, never a rewrite — because feature code is written against seams, not against a deployment. The blessed baseline is the boring one: a single node — SQLite + systemd. One process, one db file, a unit file, journald for logs. This is a proven shape, not a toy: a real bug tracker, dbugs.yagni.club, runs this exact baseline in production, and a single SQLite node handles far more than most teams expect — see Anders Murphy's 100,000 TPS over a billion rows.

RungWhat you runWhat it buys
0 · baselineOne Node/Deno process, one SQLite file, systemdThe whole app. Most products never leave here.
1 · durability+ Litestream → object storageContinuous backup, then read replicas.
2 · multi-node+ a Bus adapter (Redis / NATS / honker)Fan-out across machines. Live repaints cross the network.
3 · per-slice edgeSlices shipped to Cloudflare (Queues, R2, DO SQLite)Edge primitives where they earn it, from one repo.

So how do I use Postgres?

Two honest answers, because Postgres meets orion in two different places:

  • For background work — cleanly, today's design. The Jobs/Workflow seam takes a Postgres executor (Absurd) with no change to feature code. If you already run Postgres, that's where it slots in first. planned
  • For the render-path store — possible, but a bigger swap. The Store seam is just five methods, so a Postgres adapter is writable — but Postgres is async, and the render path is deliberately synchronous. So it isn't a drop-in rung; it's a deeper change, and the more honest move when you outgrow one SQLite node is usually a Bus adapter (rung 2) or, on Cloudflare, db-per-tenant (below). Postgres on the hot path is a documented redesign, not a flag.

The point of the ladder isn't that orion scales infinitely — it's that the decision is reversible and local, because the slice never knew which rung it was on.

read more

viiiMulti-tenancy, by construction built

The single most common SaaS leak is a forgotten WHERE tenant_id = ?. orion designs the leak out of existence. To the belt, a tenant is just an opaque key and a store bound to it — what a tenant is (org, workspace, team) stays your policy. Two isolation models, the same feature code:

db-per-tenant

Physical isolation

One SQLite file (or one Durable Object) per tenant. ctx.store is already this tenant's database — cross-tenant access is impossible to express. Feature code is unchanged.

shared-db

Row-level scope

One file, a tenant_id column. The predicate is baked into every generated statement — you can't omit it — and a dev oracle throws on any raw query that touches a tenant table unscoped.

// db-per-tenant — feature code is UNCHANGED, isolation is physical
todos.all(ctx.store)         // cannot return another tenant's rows, period

// shared-db — the predicate is generated, never remembered
const t = tenantScope(ctx.store, key, { column: "workspace_id" })
t.all("todos")               // select * from todos where workspace_id = ?
t.insert("todos", { title })  // workspace_id injected — you can't forget it

No ambient “current tenant” to leak across requests, jobs, or tests — the key is baked into ctx.store, built fresh per request. On Cloudflare, db-per-tenant is DO-per-tenant: getByName(tenant) routes to a Durable Object whose SQLite no other tenant can address. Backup, export, and delete for one customer become a single file operation. (Prototype: 237 tests, both modes, Node + Cloudflare.)

read more

ixOn Cloudflare: two planes, one gate built

The blessed deploy is a single Node node — one process, one db file. Cloudflare is offered for convenience, and it keeps the loop intact by mapping orion's coordination boundary onto Durable Objects: one node = one DO. A Directory DO is the control plane — apex, sessions, tenant-scoped RBAC, and the gate; each tenant is its own Workspace DO (the data plane), so no query can reach another tenant's rows — DB-per-tenant by construction.

Worker edge router Directory DO control plane · the gate Workspace DO · acme Workspace DO · globex Workspace DO · … verdict first →
Every data request is gated at the Directory DO before the Worker forwards it; a non-member is refused 403 at the gate, before any Workspace DO is contacted.

Bus & SSE stay in-process inside the DO — the single-threaded DO holds every open stream for its tenant, so the live loop is unchanged (the in-process bus never needs a Cloudflare pub/sub). The trade-offs are real and documented: DO SQLite is ~10 GB per tenant; and the store stays synchronous because DO SQL is synchronous (D1, being async-only, would force a different framework).

The one cost wrinkle is SSE: an HTTP stream isn't hibernatable, so a DO that holds the SSE stays resident and billed while it's open, even idle. The fix is a broker: let the Worker hold the long-lived SSE to the browser — Workers aren't billed for idle wall-time the way a resident DO is — and have the Worker talk to the DO over a WebSocket, which can hibernate. The DO wakes on an event, renders, pushes the fragment up the socket, and goes back to sleep; the open connection to the user costs Worker time, not DO time. So you keep SSE end-to-end at the browser (Datastar unchanged) and still let the DO idle to zero. planned — the deployed demo keeps it simple (the DO holds the SSE), which is exactly why an idle workspace DO stays resident; the broker is the production cost-optimization.

The orion architecture console on Cloudflare — two planes, x-rayed
The live architecture console: switch persona, probe a foreign tenant, and read the SQL landing across Durable Objects in real time — the two-plane model, made visible.

read more

xCapabilities & circuit breaking built

First, who are you? — sessions and a hashed-token security model, shipped as belt mechanism plus a users/sessions slice you own. That's authentication, and it's where orion's mechanism-vs-policy split was first drawn. Then comes may you?

Four seams answer that second question — may I do X, here, now? — and they all share a shape: a cheap, subject-scoped, watchable boolean. Because they're watchable, the answer changing repaints every gated region in every open tab. A customer upgrades and the feature appears, no refresh.

SeamDriven byThe check
authzidentity / grantscan(ctx, action, resource)
flagsan admin toggle / % rolloutenabled(name, context)
breakerdependency healthavailable()
entitlementsbilling (Stripe → capabilities)entitled(account, capability)

The circuit breaker is the failure-driven member of the family. It wraps a flaky dependency — a payment API, an LLM, a webhook target — and trips after repeated failures (open → half-open → closed, with cooloff), so an outage fails fast instead of piling up timeouts. And because it's watchable, a tripped breaker can repaint a “degraded” banner into every open page automatically.

const stripe = breaker({ failures: 5, cooloffMs: 30_000 })

// in a command — fail fast, tell the requester, don't hammer a dead API
if (!stripe.available()) return toast(respond, "Payments briefly unavailable")
await stripe.run(() => chargeCustomer(account, amount))

// a live region watches it → a degraded banner appears/clears on its own
region({ watch: [stripe], view: ({}) => stripe.available() ? html`` : degradedBanner() })

Gate on the capability, never on the source: if (entitled(account, "advanced-reports")), not if (plan === "pro"). Re-tiering a plan then touches one mapping, not every call site — the insight that makes billing a side-business path, not a rewrite.

authentication & the mechanism/policy split authorization & capabilities

xiThe workflow & the belt

The runtime is a toolbelt; the CLI carries the Rails energy. You scaffold slices and proven patterns shadcn-style — code copied into your project, owned and editable, never hidden behind an import.

orion new my-app             # main.ts, features/, an example slice
orion gen feature board       # the four-file slice, ready to fill in
orion gen feature auth        # sessions, passwords, RBAC — policy you own
orion gen feature tenancy     # multi-tenant control plane, the noun is yours
orion gen pattern combobox    # SQL-backed, keyboard-navigable, vendored in
orion gen recipe billing-stripe   # checkout + webhook → entitlements
orion routes                  # print your domain as a hypermedia map
orion dev                      # watch + live reload + pretty logs

main.ts is yours — about twenty lines, no DI container, everything greppable. It grows by array entries, not paragraphs: construct the store, bus, and log; migrate each feature; mount routes; wire subscriptions; listen. The boot order is fixed because mis-sequencing — not missing pieces — is the real failure mode of ad-hoc startup.

resource()
Start from nouns. Declares a page and its commands as one hypermedia resource.
live() · region()
Model-bound live views. Free dirty-checking and shared renders across viewers.
command()
Validate, mutate, publish. A protocol adapter — the operation stays a plain function.
fragment()
On-demand reads: render once, patch, close. Modal bodies, combobox options.
Store · Bus
Five-method SQL store; two-method pub/sub. The two seams everything else rides.
jobs() · workflow()
Transactional outbox jobs; durable steps/sleep behind the same seam.
authz() · flags() · breaker()
Watchable capabilities — grant, flip, or trip and gated regions repaint live.
tenant()
Opaque-key tenancy, db-per-tenant or shared-db, no cross-tenant leak by construction.
auth()
Session/password/cookie/CSRF primitives; the policy slice is generated, owned by you.
trace() · health() · stalls()
OTel-shaped tracing, event-loop health, and stall attribution — the admin dashboard.
shape()
Zero-dep signal validation, Standard-Schema compatible — zod/valibot/Effect drop in.

All of it is roughly 600 lines, zero dependencies, zero build. The belt implements the Datastar SSE protocol itself; templating is tagged literals; brotli is node:zlib; SQLite is node:sqlite. The only external code is the Datastar client script in the browser.

xiiVendored, not imported — the injection model

orion isn't an npm package you depend on; it's code that lands in your repo. Three tiers, one move (shadcn's): the belt is the zero-dep core you own outright; patterns are vendored view-functions and their CSS, generated into patterns/ for you to edit; recipes are small glue files that wire an outside dependency into a seam — and that dependency lands in your package.json, never the belt's.

the belt zero-dep core · yours package.json: {} patterns vendored HTML + CSS edit freely recipes glue → a seam the dep is yours
Generators copy code in; the belt never imports a recipe's dependency. "Zero-dep" stays literal.

Why a backend dev should care — three reasons that compound:

  • Blast radius. Fewer dependencies, and the few you keep are glue you reviewed. A bad npm release or a supply-chain compromise can't reach through a dependency you don't have — the belt's package.json is literally {}.
  • You can fix it. A bug or a slow path in vendored code is a one-line edit in your tree — not a fork, an upstream issue, and a wait. Performance tuning and bug fixes happen where the code lives: yours.
  • Agent-legible. In an agentic codebase, the mechanism your assistant must reason about is in the repo, greppable — not hidden behind a module boundary in node_modules. Agents read what's there; orion keeps the core there.

The seam test keeps it honest: a recipe must wire into the loop. "Here's a good library" with no integration is documentation, not a recipe — and the belt still never imports it.

read more

xiiiWhy it stays resilient

  • One source of truth. SQLite holds the state; events are just nudges to re-read it. There is no client cache to invalidate and no second copy to drift.
  • Idempotent renders. Every fat-morph patch is the complete current region, so a dropped or coalesced update can never leave the screen wrong — the next one heals it.
  • Works before JS. Every page is server-rendered and functional on first paint; the live stream is an enhancement, and a reconnect is just a fresh, complete render.
  • Transactional handoffs. Jobs enqueue inside the command's own transaction — a real outbox — so background work can't be orphaned by a crash between write and queue.
  • Fail fast, not flat. Circuit breakers cut off a sick dependency before it drags the app down, and surface the degradation live instead of silently timing out.
  • A boot liturgy and graceful shutdown. Migrations run before any read, subscriptions wire before traffic, and teardown unwinds in reverse: stop intake, drain streams, close the store.

xivThe dev loop catches your mistakes built

Dev mode is a set of guards that turn whole classes of bug into a loud, located error instead of a silent wrong render:

  • The region oracle. A live region must be a pure function of its model. If a view reads data that isn't in the model, the dirty-check can't see it change — so dev throws "the model is unchanged but the view produced different HTML" and points at the fix. The bug that would otherwise ship as a stale screen fails at the source.
  • Error overlay. A throw in a handler renders — in dev — as an SSE overlay patched onto the live page (for Datastar actions) or a full error document (for page loads), with the route and the stack. Prod gets an opaque 500 and a log line; never a leak.
  • Stall attribution. The event-loop monitor warns on sustained lag, names the likely culprit, and in dev broadcasts a dismissible banner onto open streams — the synchronous loop's early-warning system.
  • Read-only masquerade lockout. A support agent viewing a customer's account read-only is blocked from writes at the top of every mutating command — a visible, greppable guard, not buried middleware.
  • Live reload. /_orion/dev reconnects and re-renders on change — the same idempotent full-region render that heals a dropped SSE frame.
The dev error overlay patched over the running app, showing the error, route, and stack trace
A throw in a handler, in dev: the error overlay is patched over the live page via SSE — message, route, and stack — with a dismiss button. In prod the same throw is an opaque 500 and a log line.

read more

xvA debug panel you can toggle built

The next rung of the dev loop, and now live: a toggleable in-page debug panel. Its headline feature is a live-region highlighter — as SSE pushes land and fat-morph the DOM, it briefly outlines exactly which elements a push just changed, and ticks a counter. You see the loop work — every command's ripple, every viewer's repaint — turning the invisible morph into something you can watch and debug.

A dev-only client snippet (belt/dev.ts), injected next to the live-reload script. It's a MutationObserver watching content changes (not attribute toggles), so only what the stream changed flashes — never a client-side data-show flip. Off in prod.

The todos app with the debug panel, presence avatars, and an inline-edit validation error
Three dev surfaces at once: the debug panel (lower right, counting highlighted SSE updates), presence avatars from the live region, and inline-edit validation — native HTML validity surfaced as a signal-backed error, gated server-side before the rename commits.

xviIntegrate without footguns built

The web is at-least-once. Stripe re-delivers a webhook until it gets a 200; a user double-clicks Pay; a flaky network retries a POST. "Do this exactly once even though the request arrived three times" is the kind of race that's dangerous to hand-roll — so the belt does it once, correctly, and every caller shares it. idempotency is a single-flight primitive over durable state: the claim is one atomic insert … on conflict do nothing (no check-then-insert window), in-process callers share the winner's promise, and a second node racing the same delivery gets a 409 — which an at-least-once sender simply turns into a clean replay.

// run the effect at most once per key; repeats replay the stored result
const result = await idem.once(event.id, () => chargeTheCard(amount))

An inbound webhook is then just verify + dedupe + hand off. The belt owns the shape; a recipe owns each provider's signature algorithm (Stripe's HMAC, GitHub's X-Hub-Signature). Webhooks mount as plain routes — not Datastar commands — read the raw bytes the signature is computed over, and ack fast, leaving heavy work to a bus event or a job.

router.add("POST", "/webhooks/stripe", webhook({
  verify: stripeVerify(WEBHOOK_SECRET),       // recipe: the HMAC, node:crypto, no SDK
  handle: ({ event, ctx }) => ctx.bus.publish("billing", event), // runs at most once
}))

One idempotency kernel sits behind every integration — a Stripe webhook, a Resend bounce, a payment retry, a customer's own retrying client — so each new provider reuses the same single-flight primitive and its race tests instead of hand-rolling exactly-once again. The example app mounts a live inbound webhook route and an outbound delivery runner over it.

xviiKnow what it's doing — as a product planned

The admin dashboard (§ix) tells you the system is healthy. A different question — did this human activate, retain, convert — needs an append-only stream of behavioral events you can slice into funnels and cohorts. That's product analytics, and the honest answer to "which analytics tool" is: a table and some group by. It's the same SQLite you already run, so there's no Posthog bill, no second pipeline, and nothing about your users leaving the box.

The split mirrors billing exactly. The belt owns the sink — an append-only _orion_events table and a buffered track() that flushes one multi-row insert per tick, so capture never touches the synchronous render loop. Your feature owns the questions — funnels and retention as plain SQL you can read, not a query DSL you can't.

// capture — server-side by default: a command that succeeds IS the conversion
analytics.track("subscription_started", { plan }, { subject: user.id })

// ask — a funnel is a left join of "did the event happen, per subject"
const funnel = activationFunnel(store, 30)  // signup → activated → paid

subject is your opaque user id — never an IP, never a fingerprint; props are a JSON blob you choose. First-party by construction. A live region over those funnels repaints the activation board as events land — your dashboard, built from a group by, costing one more SQLite read.

read more

xviiiThe data ladder: state, journal, events planned

Just as deployment is a ladder (§vii), so is how a slice models truth. Most of an app never leaves the bottom rung; a rare slice climbs — and it climbs alone, because the table-ownership fence lets one feature be event-sourced while everything around it stays boring CRUD.

RungModelWhat's the truth
0 — statetables onlycurrent state; no history
1 — journaltables + an append-only fact log, written in the command's transactiontables; events ride alongside as durable audit (the outbox move)
2 — analyticstables + a best-effort behavioral sink, off the looptables; events are a lossy aggregate stream
N — event sourcingan event log; tables are a projection you fold and replaythe events; state is the shadow

Read the rightmost column. Rungs 0–2 keep tables as the truth and events as a shadow; full event sourcing inverts it — events become the truth, state a derived read model. That inversion is the whole decision, and it isn't free here: it pushes the source of truth off the synchronous read path — the exact property the one loop (render reads SQLite with no await) is built on.

So the heuristic. Climb a slice to full event sourcing only when one of these holds:

  • History is the asset. Ledgers, balances, accounting — "what was true on date X and why" must be reconstructable exactly, and is contested. No destructive updates allowed.
  • Time-travel is a feature. As-of reads, replay, whole-aggregate undo.
  • Many read models over one write stream. The same events project into several shapes and you refuse to dual-write them.
  • The domain is transition-shaped. Collaborative editing, workflow state machines — the transitions matter more than the snapshot.

Otherwise stay down-ladder. Need current state plus an audit trail? — the journal, and outbox semantics come free. Need aggregate product insight? — the analytics sink. The tax you'd otherwise pay — versioned events, projections, replay infra, eventual-consistency reads — buys nothing the bottom rungs don't already give the other 90% of CRUD.

You don't adopt event sourcing — a slice does. And the journal is the on-ramp: the day a slice starts reconstructing state by folding its own fact rows, it has earned the next rung. Same shape, scaled elsewhere — the analytics read engine is its own ladder: SQLite group by + a rollup table, then an embedded DuckDB that queries the SQLite file directly, then DuckDB over day-partitioned Parquet — swapped behind the funnel functions, never rewritten.

xixThe canonical log line built

Most teams log naively — a scatter of narrow lines with no shared context, so "what happened to tenant X's request" means grepping six of them and joining by hand. The fix is the canonical log line (Stripe's move, what evlog productizes): accumulate one wide event per request and emit it once. orion is already set up for it — the router opens a root span per request, and a canonical line is that span's attribute bag as a log event. canon({ rows_scanned }) appends from anywhere in the async chain, no ctx threading, the same way currentSpan() works.

// one line lands on the way out — sliceable by tenant, plan, route, flag
{ "route":"POST /todos", "tenant_id":"acme", "plan":"pro", "user_id":"u_91",
  "status":200, "duration_ms":12.4, "flags.welcome":true, "trace_id":"…" }

The split is the usual one. Mechanism — the belt threads the lifecycle and the structural fields it always knows (route, status, duration, outcome, trace_id), and never auto-captures signal values. Policy — the app supplies enrichers that map its seams to fields (tenant, plan, user, the flags actually evaluated this request), plus redaction and sampling. Because tenancy, entitlements, and flags each go through one seam, that context attaches itself — which is exactly what naive logging can't do.

On cardinality: wide events love it — you store one row per request and query it, so per-entity fields (user_id, tenant_id, trace_id) cost storage, not the combinatorial time-series explosion that kills a metrics label. So add() takes any cardinality; a separate tag() carries the low-cardinality, metric-safe contract and dev-lints a uuid/email that wanders into it. And cardinality isn't volume — you control cost by sampling (keep every error and slow request, sample the boring 200s), never by dropping dimensions.

Because the log lines land in a Watchable ring (the same process-local buffer stance as the trace ring), the admin dashboard surfaces them live. Health, Traces, and Logs sit under one Observability section, grouped by a server-driven tabs pattern — and the tabs are themselves a small lesson in the Tao. The active tab is not a client signal; it's admin session state (so it survives a reload), held server-side and switched by a command that patches that one admin's container back. Only the open panel is ever in the DOM, and selecting a tab swaps it — aborting the previous panel's stream and opening the new one. Which tab you're looking at is backend truth, like everything else.

This is orion's third wide-event stream — journal (durable, transactional, by-subject), analytics (best-effort, by-name), canon (per-request, by-trace) — same shape, different contracts. And the export is the path that's already here: a canonical line is the trace's attributes, so the onTrace→OTLP recipe ships it to evlog or Honeycomb unchanged. The belt is the good-enough embedded approximation; the seam to the real thing is wired.

The admin Observability section, Logs tab — the canonical log line ring
The admin dashboard's Logs tab: the canonical log line ring, one wide event per request (method, status, duration, trace_id), surfaced live next to Health and Traces — no second system.

xxEnough to run a business on, solo

That's the whole ethos. A single developer should be able to ship and operate a real product without assembling a platform first. orion's batteries are the things a SaaS actually needs, each a blessed default behind a small seam:

  • Accounts & accesssessions and passwords (pilcrow's hashed-token model), RBAC, and session masquerading for support, all as code you own.
  • Multiple customers — multi-tenancy with leak-proof isolation and trivial per-customer backup, export, and delete.
  • Getting paid — Stripe checkout and webhooks mapped to entitlements you gate on by capability, not by plan name.
  • Keeping it up — background jobs, durable workflows, circuit breakers, feature flags, and a single-node deploy you can actually reason about.
  • Knowing what it's doing — a live admin dashboard with tracing, event-loop health, stall attribution, and a query log — built as ordinary live views over the seams themselves; plus first-party product analytics (funnels, retention) as group by over the same SQLite.
  • Talking to the outside — inbound webhooks with signature verification and exactly-once handling, on one idempotency kernel shared by payments, retries, and any third party that delivers at-least-once.

No Kubernetes, no message broker, no five managed services on day one. One process, one database file, and a toolbelt that already knows the shape of the problem.

Would you build a product on something like this?

orion is a working sketch, not a shipped product — a thesis about how small the gap between “a web page” and “a live, multiplayer, billable SaaS” can be for one person. I'm sharing it to find out whether the shape resonates before taking it further.

Reactions, doubts, and “I'd use this for ___” all welcome.

Built on Datastar, and in the spirit of Anders Murphy's Hyperlith — the server-rendered, SSE-morph, one-SQLite-node shape, carried into a Node toolbelt. Not a port; a kindred build.

orion ✦ a belt of stars · built on datastar