← the tour · concept

concept iii

A feature, end to end

One folder, four files. The dependency graph stays acyclic because edges only cross through a slice's published contract.

Every feature in orion lives in a single folder. That folder contains exactly four files: a sql module that owns the schema and the queries, a view module that turns store state into HTML, a commands module that validates, mutates, and publishes, and a routes module that wires them into the HTTP resource. No models directory, no controllers directory, no services layer smeared across the tree. The feature is the unit — and the machine enforces it.

sql
schema · queries
view
store → Html
commands
validate · write
routes
the resource
↑  one folder — features/todos/ — the whole surface of a multiplayer todo list  ↑

The sql file: schema ownership

Every table belongs to exactly one feature's migration list, forever. That's not just convention — orion's store.migrate diffs sqlite_master around each entry, attributes every schema object to its root table, and registers ownership in an internal tracking table. A migration that touches another feature's table throws at boot. The feature's *.sql.ts is the data API: the only way another slice reads this data is through the exported query functions, never through raw SQL against the table directly.

// features/todos/todos.sql.ts — schema ownership + named queries
import type { Store } from "../../../belt/store.ts"

// Append-only, forward-only. Entry 0 already ran in production —
// schema changes are NEW entries, never edits to existing ones.
export const migrations = [
  `create table todos (
    id integer primary key,
    title text not null,
    done integer not null default 0,
    created_at text not null default (datetime('now'))
  )`,
  // Forward-only in practice: when RBAC landed, this was appended.
  `alter table todos add column created_by integer`,
]

export type Todo = { id: number; title: string; done: number }

export const all = (store: Store) =>
  store.query<Todo>("select id, title, done from todos order by id")

export const insert = (store: Store, title: string, createdBy: number | null) =>
  store.exec("insert into todos (title, created_by) values (?, ?)", title, createdBy)

export const remove = (store: Store, id: number) =>
  store.exec("delete from todos where id = ?", id)

No ORM, no query builder. The framework's only opinion here is where this lives (inside the feature) and what shape it takes (plain functions over Store). The five-method Store seam is synchronous by design — views call sql.all(store) during render with no await.

The view file: a pure function, run forever

The view module is the read side. Its exports are pure functions from state to HTML — no client state to drift, no optimistic updates to reconcile, no per-row patch bookkeeping. The live stream re-renders the whole region on every relevant event and the browser morphs the difference. The view just describes what the current store looks like.

// features/todos/todos.view.ts — the read side: state → Html
import { html, type Html } from "../../belt/html.ts"

export function TodoList(
  todos: Todo[],
  viewers: string[] = [],
  canDelete: (todo: Todo) => boolean = () => false,
): Html {
  const remaining = todos.filter((t) => !t.done).length
  return html`
    <section id="todos">
      ${presenceAvatars(viewers)}
      <p role="status">${remaining} of ${todos.length} remaining</p>
      <ul>${todos.map((todo) => TodoItem(todo, canDelete(todo)))}</ul>
    </section>`
}

/** Per-viewer affordances: the delete button only exists in the DOM
 *  of viewers holding the grant. The command re-checks regardless —
 *  the UI is a courtesy, authorization lives on the write side. */
export function TodoItem(todo: Todo, canDelete: boolean): Html {
  return html`
    <li id="todo-${todo.id}" class="${todo.done ? "done" : ""}">
      <input type="checkbox" ${todo.done ? "checked" : ""}
        data-on:change="$done = el.checked; @patch('/todos/${todo.id}')" />
      ${todo.title}
      ${canDelete && html`<button data-on:click="@get('/todos/${todo.id}/confirm-delete')">×</button>`}
    </li>`
}

Signals are used for interaction only — the add-form draft, the inline-edit draft, which editor is open. Shared state lives in the store. The view doesn't hold any of it.

A view is a function of the store, nothing else. There is no client state to drift, no optimistic update to reconcile — the server re-renders the complete region and the browser morphs the difference. Data lives in the feature, as named SQL.

The commands file: validate, mutate, publish

The commands module is the write side. Each command follows the same shape: parse and validate the incoming signals, mutate the store, publish a topic that triggers re-renders, and send only requester-scoped feedback back in the response — a form reset, a validation error, a toast. Shared state never comes back in a command response. It flows to everyone through their live streams.

// features/todos/todos.commands.ts — the write side
import { command } from "../../belt/http.ts"
import { shape, str, parse } from "../../belt/shape.ts"
import { formErrors } from "../../belt/ds.ts"
import { toast } from "../patterns/toast.ts"

const todoShape = shape({
  title: str().trim().min(1, "Title is required").max(200),
})

export const create = command(async (ctx, respond) => {
  const result = await parse(todoShape, ctx.signals)
  if (!result.ok) {
    // Requester-only feedback, into the spans formFor rendered.
    respond.patch(formErrors("todo", todoShape, result.issues))
    return
  }
  const user = currentUser(ctx)
  sql.insert(ctx.store, result.value.title, user?.id ?? null)
  ctx.bus.publish("todos", { type: "todos.created" }) // → every watcher re-renders
  respond.signals({ title: "" })                          // reset the requester's form
  toast(respond, "Todo added", { tone: "success" })
})

export const destroy = command(async (ctx, respond) => {
  const id = Number(paths.todos.item.params(ctx).id)
  const user = currentUser(ctx)
  if (!user || !authz.can(ctx.store, user.id, "delete", `todos:${id}`)) {
    toast(respond, "Only the creator or an admin can delete a todo.", { tone: "error" })
    closeModal(respond)
    return
  }
  sql.remove(ctx.store, id)
  ctx.bus.publish("todos", { type: "todos.deleted" })
  closeModal(respond)
})

Note the resource-level authorization: deleting todo 42 requires a grant matching ("delete", "todos:42"). The create command writes that grant when it inserts the row — through the auth slice's exported SQL, never by touching auth's tables directly. Cross-slice writes are choreography through the owner's API. The delete button appearing in the DOM is a courtesy; the command re-checks regardless.

The routes file: the public face

The routes module is the slice's manifest — everything the app needs to mount the feature is exported here. It wires the view and command exports into a typed HTTP resource and declares the live region: the model function that computes what each viewer sees, the topics that wake it, and the ephemeral stores it watches. The live region's model is per-viewer — the deletable ids list differs by who is looking — which is exactly what makes different viewers see different DOM without any client session state.

// features/todos/todos.routes.ts — the resource manifest
import { resource, view } from "../../belt/http.ts"
import { region } from "../../belt/region.ts"

export const todos = {
  name: "todos",
  migrations: sql.migrations,

  routes: [
    ...resource(paths.todos.base.pattern, {
      // GET /todos — server-rendered, works before JS loads
      index: view((ctx) =>
        TodoPage(sql.all(ctx.store), viewerNames(), currentUser(ctx), canDelete(ctx))
      ),

      // GET /todos/live — the long-lived SSE stream
      live: region(
        {
          topics: ["todos", "flags", "masquerade"],
          watch: [viewers, authz, masquerades],
          enter: joinViewers,        // register presence, return cleanup
          model: (ctx) => {
            const items = sql.all(ctx.store)
            const user = currentUser(ctx)
            return {
              items,
              viewers: viewerNames(),
              deletable: user
                ? items
                    .filter((t) => authz.can(ctx.store, user.id, "delete", `todos:${t.id}`))
                    .map((t) => t.id)
                : [],
            }
          },
        },
        (m) => TodoList(m.items, m.viewers, (t) => m.deletable.includes(t.id)),
      ),

      create:  commands.create,   // POST   /todos
      update:  commands.update,   // PATCH  /todos/:id
      destroy: commands.destroy,  // DELETE /todos/:id
    }),
  ],
}

Open it in two browser windows. Add a todo in one; it appears in the other in under a frame. That's the entire multiplayer story — no sockets to manage, no client store to reconcile. The region re-renders every time "todos" fires, and both windows have an open stream.

Slice discipline and the dependency graph

The import graph has a named shape, and it runs one direction only:

Cross-slice writes are choreography, not direct calls. When create inserts a todo, it calls authSql.addUserGrant(...) — the auth slice's exported function — and publishes an "authz" event. The auth slice reacts in its own init(). Slices hand off through messages and published contracts, never through each other's tables. The database enforces it: a migration touching another feature's table throws at boot, entry rolled back.

Cross-slice reads follow a ladder. Most composition is a layout problem — a page is several live regions, each owned by a different feature streaming its own fragment, wired by two data-init attributes. When one region genuinely needs to interleave data from two slices, the composition lives in model(): explicit, auditable, and the region dirty-checks the composed result. The join migrates to the owning feature as an exported function, never to the boundary.

migration ownership If a migration in the tags feature touches the todos table, orion throws at boot: "migration ownership violation: feature "tags" migration #0 touches table "todos", owned by feature "todos". A table belongs to exactly one feature's migration list — read it through the owner's exported queries instead." The machine checks this against sqlite_master, not against SQL parsing. No convention to forget; the oracle is ground truth.

Commands are protocol adapters

A command handler is deliberately a protocol adapter — not the operation itself. Its job is the hypermedia conversation: read signals from the request, run the operation, answer with requester-scoped feedback. While a command body is parse → owned SQL calls → publish → feedback, inline is correct. The moment an operation grows real invariants or a second caller appears — a job handler, a CLI script, another command — extract it as a plain exported function and let the handler call it. The signature is always the same: { store, bus, log } plus input, which is exactly what every handler already receives. No mediator needed; module imports of plain functions are the most boring possible dispatch.

What orion will not do: a single dispatch endpoint with a command name in the body. That erases the hypermedia map — every intent is a route, and orion routes prints your domain because it is. Per-route authorization, per-route logging, per-route typing all follow from that.

A feature is one folder, four files — sql / view / commands / routes. No logic smeared across a models directory, no cross-feature table writes, no ambient dependencies. The dependency graph stays acyclic because edges only cross through a slice's published contract. orion lint is the mechanical check.

orion ✦ a belt of stars · built on datastar