verso docs prototype fullstack-gleam.md
22 kB · 244 lines
Markdown
at main

Fullstack Gleam: A Minimalist Architecture Spec #

Project Goals #

Build an end-to-end web application using Gleam as the only application language, targeting BEAM on the server and JavaScript on the client. Use HTML and CSS directly without a CSS framework or build pipeline beyond what Gleam itself requires. Be radically selective about dependencies, in the spirit of building from primitives rather than assembling pre-made layers. The architecture should be small enough to hold in one head, performant by virtue of doing less, and structured so that each piece can be replaced without rewriting the others.

This is a learning and exploration project. The constraints are deliberate.


High-Level Architecture #

The system is a monorepo of three Gleam packages that share types and logic across the server/client boundary, plus a static asset directory and a SQLite database for materialized state. The runtime model treats the application as a reduction over an event log: state at any moment is a fold of all events to date, the view is a pure function of that state, and the only thing that causes a render is an event being dispatched. There is no virtual DOM, no signal graph, no auto-tracking reactive system. There is one update mechanism, and it is explicit.

Package Layout #

project/
├── shared/          # Targets both Erlang and JavaScript
│   ├── src/
│   │   ├── shared/events.gleam        # Event type, decoders, encoders
│   │   ├── shared/state.gleam         # Application state types
│   │   ├── shared/reducer.gleam       # apply(state, event) -> state
│   │   └── shared/validation.gleam    # Pure validation functions
│   └── gleam.toml
├── server/          # Targets Erlang only
│   ├── src/
│   │   ├── server.gleam               # Entry point, mist + wisp setup
│   │   ├── server/router.gleam        # Top-level path dispatch
│   │   ├── server/handlers/           # Per-route request handlers
│   │   ├── server/views/              # nakai view functions
│   │   ├── server/db.gleam            # SQLite or Postgres access
│   │   └── server/sync.gleam          # Event log endpoints
│   ├── priv/static/                   # Served by wisp
│   └── gleam.toml
├── client/          # Targets JavaScript only
│   ├── src/
│   │   ├── client.gleam               # Entry point, mounts to DOM
│   │   ├── client/runtime.gleam       # Stable-key renderer
│   │   ├── client/dispatch.gleam      # Event dispatch + render scheduling
│   │   ├── client/sqlite.gleam        # Browser SQLite bindings
│   │   ├── client/views/              # Client-side nakai-style views
│   │   └── client/sync.gleam          # Event log sync to server
│   └── gleam.toml
└── static/                            # Hand-written CSS, copied to priv/

The shared package depends on nothing but gleam_stdlib and gleam_json so that it compiles to both targets cleanly. The server and client packages both depend on shared and add their target-specific dependencies. This division is the structural foundation everything else rests on.


The HTTP Layer #

The server is built on mist for the underlying HTTP/WebSocket transport and wisp for the request/response conventions on top of it. Wisp owns the request lifecycle, body reading, multipart parsing, cookie signing, CSRF token helpers, and static file serving from the priv/ directory. It does not own routing; the router is a single function that pattern-matches on wisp.path_segments(req) and req.method and delegates to handler functions. For sub-trees of the URL space, the top-level router delegates to nested routers that pattern-match on the remaining segments.

Middleware is composed using Gleam's use syntax. A typical handler begins with a small stack of use <- wisp.log_request(req), use <- wisp.rescue_crashes, use <- wisp.handle_head(req), and any application-level middleware (authentication, request ID generation) before the actual route logic. There is no registration step. The middleware pipeline is the call graph, readable top to bottom.

Body handling defers entirely to wisp. Forms come back as decoded structures. File uploads go to temp files. Size limits are enforced before allocation. Cookie reading and writing uses wisp's three-tier model (plaintext, signed, signed-and-encrypted) with the secret key base configured at server boot.


The View Layer #

Server-rendered HTML is produced by nakai. View functions are ordinary Gleam functions that take data and return nakai/html.Node values, composed freely with case expressions, list.map, and function calls. There is no template language, no component lifecycle, no props validation beyond Gleam's type system. Layouts are functions that wrap content in a shell; partials are functions returning fragments. The seam to wisp is view |> nakai.to_string_tree |> wisp.html_response(200).

All text content is HTML-escaped by default. Raw HTML insertion is available through an explicit UnsafeText constructor, making the dangerous path visible at the call site. Document-level concerns (head injection from deep components) are handled by nakai's Document type, which lets a chart component declare its own stylesheet dependency without the top-level layout knowing about it.

The same nakai code can be used on the client when the client package needs to produce HTML strings — for example, for hydrating an island or for producing the initial render that the client runtime walks. This is the practical payoff of the shared/server/client split: a single set of view functions runs in both environments.


The Event Log Model #

The application's canonical state is an append-only log of events. Events are typed Gleam values defined in shared/events.gleam with derived JSON encoders and decoders. The log is the source of truth; everything else is derived. State at any moment is the fold list.fold(events, initial_state, apply), where apply is the pure reducer in shared/reducer.gleam.

This model eliminates a class of bugs around "what is the current state?" because the answer is always "fold the log." It also makes server-client synchronization conceptually trivial: synchronization is the act of merging two logs and re-folding. Optimistic local updates are events appended locally before the server has confirmed them; server confirmation is another event in the log; rejection is a third. There is no separate code path for these cases at the view layer, because the view layer reads materialized state and does not care which events produced it.

Materialization #

Folding the entire log on every render is impractical past trivial sizes, so events are materialized into queryable state. On the client, this is a SQLite database running in the browser via sql.js or wa-sqlite, with bindings written through plinth. On the server, this is either the same SQLite file (via sqlight) or a Postgres database (via pog), depending on deployment shape.

Materialization runs each event through a function that translates it into SQL writes. For example, an ItemAdded(id, title) event becomes INSERT INTO items (id, title) VALUES (?, ?). The view layer queries SQLite directly and gets back ordinary Gleam values via gleam/dynamic/decode. There is no ORM and no query builder; SQL is written as strings.

The initial implementation invalidates all queries on every committed event, re-runs the view function in full, and lets the renderer's stable-key walk absorb the cost of producing identical output where nothing changed. This is dramatically simpler than tracking query dependencies and remains performant up to surprisingly large UIs because the renderer does the minimum work in the steady state. A dependency-tracking layer that maps SQL tables to dependent queries can be added later if profiling demands it; this is the LiveStore-style "reactive SQLite" optimization, and it is an optimization, not an architectural requirement.


The Renderer: Stable-Key Direct Construction #

The client renderer walks the live DOM in lockstep with the calls a view function makes, deciding at each step whether the existing node matches what the call wants. There is no virtual DOM and no diff between two trees. There is one tree — the live DOM — and a forward walk that touches it only where reality differs from the description.

Core Mechanism #

The runtime maintains during a render pass a cursor over the DOM (current parent, current child position within that parent), a side table keyed by stable path holding event handlers and any other transient per-node state, and a scratch list of nodes scheduled for removal at the end of the pass.

A primitive call like ui.div(key, attrs, children) performs the following. It examines the cursor's current position. If the node there is a div with the matching key, attribute deltas are applied in place. If the node does not match, a new div is inserted before the current node and the existing one is marked for removal. The runtime then descends: pushes the cursor, makes the new div the parent, resets child position to zero, runs the children block, pops back. At the end of the children block, any unconsumed siblings of the new parent are scheduled for removal.

Stable Keys #

Static structure (a header followed by a main followed by a footer) uses positional identity. The third call in a pass corresponds to the third node from the previous pass, because the call sequence does not change shape. Dynamic structure (a list of items where insertion in the middle is possible) requires explicit keys derived from the data, exactly as in any keyed reconciliation. Keys are consulted directly against the live DOM (typically via a data-key attribute or an internal map keyed by parent), not against a cached previous tree.

Event Handlers #

Event handlers cannot be compared for equality across passes, so the runtime always replaces them. Replacement is cheap because handlers are stored in the side table keyed by (path, event_name) and a single dispatcher is attached at mount time. The dispatcher resolves the handler by path when an event fires. Replacing a handler is a table write, not a removeEventListener/addEventListener pair.

API Sketch #

// Primitive elements take a key, attributes, and children.
// Children are produced by a closure so use-syntax composes naturally.
pub fn div(key: Key, attrs: List(Attr), children: fn() -> Nil) -> Nil
pub fn button(key: Key, attrs: List(Attr), children: fn() -> Nil) -> Nil
pub fn text(content: String) -> Nil

// Event handlers are attributes that close over a dispatch function.
pub fn on_click(handler: fn() -> Msg) -> Attr

// A view is a function that, given state, performs a sequence of calls.
pub type View(state) = fn(state) -> Nil

// Composition uses Gleam's use syntax for nesting.
fn item_card(item: Item) -> Nil {
  use <- div(Key(item.id), [class("card")])
  text(item.title)
  use <- button(Key("delete"), [on_click(fn() { Delete(item.id) })])
  text("Delete")
}

The closure-based children API is the Gleam-native way to express nesting, and it pairs cleanly with use. The renderer's job is to interpret these calls against the live DOM.

Performance Characteristics #

The steady-state cost of a render pass is one DOM node visit per node in the view, plus attribute equality checks. There is no allocation for matching subtrees and no garbage to collect. Mutations only happen where the description differs from reality. This is the same asymptotic profile as a fine-grained reactivity system, achieved through a different mechanism: instead of tracking dependencies to update only changed nodes, the renderer walks everything cheaply and updates only changed nodes.

The expected size of the renderer is roughly two hundred lines of Gleam plus a handful of plinth calls into the DOM API. This is small enough to read in one sitting and to debug by stepping through.


Dispatch and the Render Loop #

The single entry point for state change is dispatch(event). It performs four steps in order: appends the event to the local log, applies it through the reducer (running the SQL writes for materialization), schedules a render on the next animation frame if one is not already scheduled, and forwards the event to the sync engine for transmission to the server.

There is no other way to cause a render. This is the explicit-rendering model, and it is the architectural commitment that keeps the system comprehensible. If a re-render is needed, an event was dispatched. If no event was dispatched, no re-render happens. Performance behavior is predictable because the trigger graph is trivial.

The render scheduling uses requestAnimationFrame to coalesce multiple dispatches in the same tick into a single render pass. This handles the common case of a UI action producing multiple events (open a modal, focus an input, log an analytics event) without three render passes.


Server-Client Sync #

The sync engine treats events as the unit of replication. The client maintains a local event log and a "last synced sequence number." On dispatch, events are appended locally and immediately materialized for the optimistic view. A background task batches unsynced events and POSTs them to a sync endpoint on the server. The server validates each event against its authoritative state, appends accepted events to its log, and returns the canonical sequence numbers plus any rejection events.

Rejection is itself an event. The reducer in shared/reducer.gleam knows how to handle a Rejected(event_id, reason) event by reverting whatever the optimistic event did. This keeps the model uniform — there is one update mechanism, not separate ones for happy and sad paths.

The transport is a simple HTTP long-poll or WebSocket, depending on what feels appropriate. Both are available through mist. The sync protocol is intentionally not real-time-collaborative in the first pass; conflict resolution is "server wins, client reconciles." Operational transformation, CRDTs, and richer conflict models are deferred until the basic shape is working.


Data Flow Summary #

The system has one shape:

A user action produces an event. The event is dispatched, which appends it to the local log, materializes it into local SQLite, and schedules a render. The renderer runs the view function, which queries SQLite for whatever it needs, and walks the live DOM applying minimal mutations. The event is also sent to the server, which validates and persists it, and either confirms or rejects it. Confirmation or rejection arrives as another event, which goes through the same dispatch path.

Server-initiated changes (another user's actions, scheduled jobs, external systems) arrive via the sync channel as events into the local log and proceed identically. There is no special case at the view layer for "this came from elsewhere"; events are events.


Initial Page Load #

Initial page load is server-rendered through the same nakai view functions. The server materializes state for the requested page, runs the view function, and returns HTML. The client bundle loads, queries the server for the current event log sequence number, and adopts the rendered HTML as its initial DOM. The renderer's first pass is a no-op walk that confirms the live DOM matches what the view function produces from current state, after which subsequent passes update incrementally.

This is structurally similar to hydration in other frameworks, but conceptually simpler because the renderer's normal operation already handles "the DOM exists; reconcile against it." There is no separate hydration code path. The first pass is just a pass where every node happens to already be correct.

Resumability in the Qwik sense — deferring handler attachment until interaction — is a future optimization. It would require serializing dispatch information into the HTML and lazy-loading the relevant handler closures. The architecture does not preclude this, but it is not in scope for the initial build.


CSS #

CSS is hand-written in the static/ directory, copied to priv/static/ at build time, and served by wisp. The strategy uses cascade layers (@layer reset, base, components, utilities) to control specificity, container queries for component-local responsiveness, custom properties for design tokens, and @property registrations where animation of computed values is needed. There is no preprocessor, no PostCSS, no Tailwind. Modern CSS is sufficient.

Component-local styles can be co-located with their nakai view functions by emitting <style> tags via nakai's document head injection, though for a project of this size a single global stylesheet partitioned by cascade layers is likely simpler.


Dependency Inventory #

The expected direct dependencies, at the time of writing this spec:

The shared package depends on gleam_stdlib and gleam_json. The server package adds mist, wisp, nakai, and sqlight (or pog for Postgres deployments). The client package adds nakai, plinth, and the binding shims for browser SQLite. Indirect dependencies are minimal because most of these libraries are themselves thin.

This is roughly eight direct dependencies for a fullstack web application with local-first sync, server-rendered HTML, and a custom client runtime. The dependency surface is small enough to audit personally.


Open Questions and Research Targets #

Several pieces of the architecture warrant deeper investigation before or during implementation.

Browser SQLite binding strategy. The choice between sql.js (Emscripten-compiled SQLite, larger binary, simpler to deploy) and wa-sqlite (uses the Origin Private File System for persistence, more performant, more complex setup) needs evaluation against actual project size and persistence requirements. Both require Gleam-side bindings written against plinth.

Renderer ergonomics under load. The closure-based children API is elegant but allocates a closure per element per pass. For most UIs this is irrelevant; for very dense lists it may matter. Whether to provide a non-closure variant for hot paths is an empirical question.

Event log compaction. An append-only log grows without bound. The strategy for compaction (snapshotting state and discarding history before the snapshot, with care for events that other replicas have not yet seen) is non-trivial and worth designing before the log gets large enough to matter.

Reactive query subscriptions. If profiling shows that re-running queries on every event dominates render cost, the LiveStore-style approach of tracking which tables a query reads and invalidating selectively becomes valuable. The implementation is a few hundred lines but introduces real complexity in the query layer. Defer until needed.

Conflict resolution beyond server-wins. Multi-user collaborative editing requires more than rejection events. CRDTs, operational transforms, or domain-specific conflict resolution are all options, with very different complexity profiles. The architecture supports any of them; the choice depends on the application.

Static type checking of routes. Pattern-matching on path segments works but loses some of Gleam's type safety at the URL boundary. A small router DSL that produces typed route values is plausible and worth exploring once the route count grows.


Build Order #

A reasonable sequence for assembling the system, with each step yielding something runnable:

First, stand up the server with mist, wisp, and a single nakai-rendered page served from a hardcoded route. Confirm the static asset pipeline works by serving a CSS file from priv/static/. This validates the HTTP and view layers before any state machinery is involved.

Second, define the shared package with a minimal event type, a state type, and a reducer. Wire the server to read events from a SQLite database via sqlight, materialize them into a table, and render the materialized state into the page. This validates the event log and materialization model end to end on the server, with no client work yet.

Third, add a form that POSTs to a handler which appends a new event. The page re-renders on form submission via standard browser navigation. The application is now a working server-rendered event-sourced app with no JavaScript at all.

Fourth, build the client package: the renderer, the dispatch function, and a minimal browser SQLite binding. Wire the client to take over rendering after initial page load, with dispatch sending events to the server via fetch. The same view functions now run on both sides.

Fifth, build the sync engine: bidirectional event flow, sequence number tracking, reconnection logic, rejection handling. The application becomes local-first.

Each step produces a complete working system. The architecture supports incremental commitment.


Non-Goals #

To be explicit about what this architecture is not trying to be:

It is not a framework. There is no plugin system, no extension points, no general-purpose runtime. It is the bones of a specific application, written in a way that lets the bones be understood.

It is not optimized for team scale. The radical-minimalism approach assumes that the people working on the codebase can hold its shape in their heads. A larger team would justify more abstraction, more tooling, more conventions enforced by code rather than by reading.

It is not real-time collaborative. Multi-user editing of shared documents with conflict resolution is a separate problem with its own design space. The sync model here is single-user-per-device-with-server-authority.

It is not feature-complete. There is no authentication system specified, no authorization model, no observability stack. These are deliberate omissions; the spec is the runtime model, and other concerns layer on top using the same primitives.