Architecture
Architecture
How SchellingBoard is built, in one place to start from. The details live in the chapters it links to: The server and the API, The web app and Architecture rules. What was designed but not built is in not-built.md; the decisions behind the shape are in decisions.md; the words are in glossary.md.
The design on one screen
| Topic | What the code does | Decision |
|---|---|---|
| Shape | One Node process (Hono) serves an OpenAPI-described HTTP API at /api/v1, an SSE change feed, uploads and a static React single-page app. No server-side rendering. |
D1 |
| Boundaries | Workspace packages domain → contracts → api-client; apps/web imports only those. server/ is split into modules, guarded by dependency-cruiser. |
D5 |
| Backbone | Every state change is committed together with a change in an append-only log. The log feeds the live UI, notifications and the jobs loop. | D8 |
| Client data | The web app keeps a replica of one event: a snapshot plus the change feed, persisted in IndexedDB so the schedule opens offline. | D3 |
| Rules | Phase gating, RSVP, vote and booking rules are pure functions in packages/domain. The server decides with them and the client predicts with them. |
Rules |
| Identity | Guests with an assurance level per login session (claimed or verified), an identity mode per event, personal join links and a site-admin role. | D2 |
| Persistence | SQLite in WAL mode, repositories behind ports in db/. One process, one file; several replicas are a non-goal. |
D6 |
| Live updates | Server-Sent Events, resumable by sequence number. | D7 |
| Background | One leased loop in the same process runs reactions to changes, deliveries (email, push), reminders and pruning. | D10 |
The containers
┌──────────────────────────┐ HTTPS/JSON (OpenAPI) ┌────────────────────────────────────┐
│ Web app (static SPA) │ ───────────────────────► │ Server (one Node process) │
│ React · Vite · PWA │ SSE change feed │ HTTP API · change feed · jobs │
│ replica of one event │ ◄─────────────────────── │ 10 modules · kernel │
└──────────────────────────┘ └──────┬─────────────────┬───────────┘
│ SQL via ports │ files
┌──────▼──────┐ ┌──────▼──────┐
│ SQLite (WAL)│ │ uploads dir │
│ state + │ └─────────────┘
│ change log │
└─────────────┘
external: SMTP · browser push services · a reverse proxy for TLS
The LikeC4 views index and containers in diagrams/ draw the same picture.
- Web app (
apps/web): static files built by Vite. It loads a snapshot, follows the feed and renders from the replica. Mutations are commands: predict, call the API, reconcile. The web app. - Server (
server/, entryserver/main.ts): the HTTP gates and routes, the feed, the modules and the kernel they share. The server and the API. - Database and files (
db/, the uploads directory): SQLite holds state, thechangestable and the job tables; uploads are re-encoded and served from/media.
Packages and modules
packages/domain pure TypeScript: entities, rules, change types, time helpers. No I/O.
packages/contracts zod schemas for requests, responses, snapshot and feed; openapi.json is generated from them.
packages/api-client generated from openapi.json. Typed fetch, nothing else.
apps/web the SPA. Imports those three packages and nothing from the server.
server/, db/, utils/ the process, the database layer and shared server helpers, at the repository root.
The server has ten modules under server/modules/: sessions (sessions, RSVPs, room
unavailability, attendee counts), proposals (proposals and votes), comments, meetings,
people (guests, profiles, sign-in and join links), notifications, events (events and days),
venue (locations), settings and updates. A module is imported only through its module.ts;
its use cases depend on ports, and adapters are wired in server/composition.ts. The rules the
build enforces are in Architecture rules.
How a request flows
- The gates (
server/http/web.ts,gates.ts) check the site password and the origin of a mutation;actorMiddlewareresolves the actor from the login session or guest cookie, then the rate limit applies. - The route validates the body against its contract and calls one use case.
- The use case loads what it needs through ports, applies the rules from
packages/domain, and writes inuow.run: one transaction holding the new state and the changes it records. - After commit the kernel hands each change, in
seqorder, to its subscribers: the feed hub (fan out to connected viewers) and the jobs loop (a nudge). - The response returns. The change carries the caller’s
Idempotency-Key, so the client recognises its own change on the feed even if the feed message arrives before the response.
Reads are the snapshot plus the feed (everything in the viewer’s audience for one event) or explicit queries for paged or role-restricted data: the guest directory, a host’s vote breakdown, notification history, admin lists.
Why the change log is the spine
Four things are all “something happened, and someone else needs to know”: the live UI, in-app
notifications, email and push delivery, and the reactions the jobs loop runs. One append-only
changes table, written in the same transaction as the state it describes, serves all of them. It
is not event sourcing: state lives in ordinary tables and the log is derived from operations. It is
the transactional outbox pattern with the outbox as a queryable table. Each change stores its
audience at write time (audienceOf in packages/domain/src/change.ts), so a later change to a
visibility rule cannot expose old changes. Changes older than seven days that the reactions have
handled are deleted; a client that resumes the feed from before that gets a reset message and
loads a new snapshot. ADR 0011,
ADR 0014.
The web app
Every screen reads the replica and writes through commands/. A command predicts with the same
domain rule the server will run (rsvpRefusal, voteRefusal), applies the predicted change under
the request’s idempotency key, calls the API, and drops the prediction when the confirmed change
arrives on the feed or the request fails. apply in packages/contracts/src/feed-apply.ts is the
pure reducer. Admin screens are not in the replica: they load their own queries/. The service
worker (public/sw.js) caches the shell, never API responses.
Rules
Rules sit in packages/domain as pure functions that take the clock as an argument: the event phase
(phase.ts), rsvp-rule.ts, vote-rule.ts, session-booking.ts, session-overlap.ts, meeting
slots and clashes. A rule returns a reason code, never a sentence; the SPA and the emails word it.
An ESLint rule bans new Date() outside the request boundary, so the dev fake clock
(ADR 0004) reaches every rule.
Time
Instants are stored and sent as ISO-8601 UTC. Every event has an IANA time zone and every day is a local date in it; dates are formatted in the event’s zone, never the process’s or the browser’s.
Security in one paragraph
A visitor needs the site password; the cookie is httpOnly, SameSite=Lax. A guest acts through a
server-side login session row with an assurance level; an event’s identity mode and a guest’s own
protection set which level an action needs. Mutations from another origin are refused. The admin API
checks the site-admin role in its use cases. Responses go through their contracts, so a repository
row never reaches the wire. Uploads are re-encoded with sharp and served from /media with
nosniff. ADR 0017 has the identity decisions.