# 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 ```gleam // 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 `