chore: adding docs
This commit is contained in:
parent
23816d00f7
commit
81c5112409
39 changed files with 7257 additions and 2 deletions
354
data/docs/go-bananas-architecture.md
Normal file
354
data/docs/go-bananas-architecture.md
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
<!-- go-doc:generated:begin source=go-bananas:docs/architecture.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
|
||||
Imported from docs/architecture.md in the go-bananas repository.
|
||||
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
How go-bananas is put together, and why each decision was made. The reasoning
|
||||
behind the load-bearing choices lives in [the ADRs](adr/); this document is the map.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- [The shape of the system](#the-shape-of-the-system)
|
||||
- [Packages](#packages)
|
||||
- [The dependency rule](#the-dependency-rule)
|
||||
- [Request lifecycle](#request-lifecycle)
|
||||
- [The token engine](#the-token-engine)
|
||||
- [The render cache](#the-render-cache)
|
||||
- [Publishing](#publishing)
|
||||
- [The database](#the-database)
|
||||
- [The stack](#the-stack)
|
||||
- [Cross-cutting concerns](#cross-cutting-concerns)
|
||||
- [Performance](#performance)
|
||||
- [What is deliberately absent](#what-is-deliberately-absent)
|
||||
|
||||
---
|
||||
|
||||
## The shape of the system
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
browser ──────────────▶│ /rt/v1 runtime public, cached │
|
||||
<link>, fetch, import │ /api/v1 admin authenticated │
|
||||
│ /auth/* OIDC login flow │
|
||||
│ /metrics Prometheus │
|
||||
│ /healthz /readyz /livez │
|
||||
│ /* admin SPA (static) │
|
||||
└───────────────────┬────────────────────────┘
|
||||
│
|
||||
chi router (v5)
|
||||
│
|
||||
┌─────────────────────┼─────────────────────┐
|
||||
│ │ │
|
||||
┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||
│ middleware │ │ router │ │ handlers │
|
||||
│ 13 layers │ │ route tree │ │ no logic │
|
||||
└─────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
┌─────▼──────┐ │ ┌──────▼──────┐
|
||||
│ auth │ │ │ tokenengine │
|
||||
│ OIDC·keys │ │ │ colour·CSS │
|
||||
│ ·RBAC │ │ │ ·validate │
|
||||
└─────┬──────┘ │ └──────┬──────┘
|
||||
│ │ │
|
||||
└──────────┬──────────┴─────────────────────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ domain │ entities, invariants
|
||||
└─────────┬──────────┘ errors, validation rules
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ store │ the only package with SQL
|
||||
│ SQLite (WAL) │
|
||||
└─────────<E29480><E29480>──────────┘
|
||||
│
|
||||
SQLite file on disk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Responsibility | May import |
|
||||
| --- | --- | --- |
|
||||
| `cmd/go-bananas` | `main()`. Signal handling and an exit code. Nothing else. | `app` |
|
||||
| `app` | Dependency injection, the process lifecycle, static file serving. | everything except `cmd` |
|
||||
| `config` | Environment parsing, validation, defaults. | stdlib |
|
||||
| `domain` | Entities, invariants, sentinel errors, slug rules. | stdlib |
|
||||
| `tokenengine` | Colour maths, validation, CSS and JSON rendering, palettes. | `domain` |
|
||||
| `store` | The only package that writes SQL. Migrations, repositories. | `domain` |
|
||||
| `auth` | OIDC, session cookies, role resolution. | `config`, `domain` |
|
||||
| `httpapi` | Routing, middleware, request and response encoding. | `domain`, `store`, `tokenengine`, `auth`, `config` |
|
||||
| `logging` | slog configuration and the `Logger` interface. | stdlib |
|
||||
| `seed` | The four built-in themes, and their reconciliation rules. | `domain`, `tokenengine` |
|
||||
| `observability` | Reserved for OpenTelemetry wiring. | — |
|
||||
|
||||
### The dependency rule
|
||||
|
||||
```
|
||||
cmd → app → httpapi → auth → config
|
||||
↓ ↓
|
||||
tokenengine store
|
||||
↓ ↓
|
||||
domain ←──┘
|
||||
```
|
||||
|
||||
Enforced by review, not by tooling, because a linter for this is more trouble than
|
||||
it is worth. The rule that matters most: **`store` is the only package that contains
|
||||
SQL**. A handler never writes a query, which is what makes replacing the database a
|
||||
change to one package.
|
||||
|
||||
---
|
||||
|
||||
## Request lifecycle
|
||||
|
||||
A runtime stylesheet request, which is the overwhelming majority of traffic:
|
||||
|
||||
```
|
||||
1. RequestIDMiddleware assign or propagate a correlation id
|
||||
2. RecoverMiddleware a panic becomes a 500, not a dead process
|
||||
3. LoggingMiddleware one structured line, on completion
|
||||
4. TrustedProxyMiddleware resolve the client address, believing X-Forwarded-For
|
||||
only from a configured proxy
|
||||
5. CORSMiddleware preflight, for the admin API
|
||||
6. SecurityHeaders CSP, nosniff, frame-deny, HSTS when TLS
|
||||
7. BodyLimitMiddleware 2 MiB cap
|
||||
8. AuthMiddleware resolve the caller; never rejects
|
||||
9. MetricsMiddleware record route pattern, status and latency
|
||||
10. RateLimitMiddleware fixed window, keyed by route class and client
|
||||
11. Handler
|
||||
store.Get(slug) ← the only database access, on a cache miss
|
||||
cache.CSS(...) ← one map lookup on a cache hit
|
||||
renderDeclarations(...) ← only on a miss
|
||||
writeCacheable(...) ETag, Cache-Control, 304 when unchanged
|
||||
```
|
||||
|
||||
Layers 1–3 are outermost so that everything after them is covered by the panic
|
||||
recovery. Layer 8 authenticates without rejecting, so a public endpoint can serve
|
||||
while a protected one responds 401 with a proper JSON body rather than a bare
|
||||
`net/http` rejection.
|
||||
|
||||
The steady-state cost is one map lookup, one string write, and no database access.
|
||||
|
||||
---
|
||||
|
||||
## The token engine
|
||||
|
||||
Pure functions over a token document. No I/O, no global state, safe for concurrent
|
||||
use — which is what lets the HTTP layer cache rendered themes aggressively and
|
||||
pre-warm them at start-up.
|
||||
|
||||
### Validation
|
||||
|
||||
`Validate(doc) → ValidationResult` returns every issue, ordered by severity then
|
||||
path so the output is deterministic. Three tiers:
|
||||
|
||||
- **Errors** block publication: an unknown group, a value of the wrong type, a
|
||||
malformed colour, a reserved key used as a group.
|
||||
- **Warnings** do not block: a missing recommended token, no dark scheme declared.
|
||||
- **Info** is advisory.
|
||||
|
||||
### Rendering
|
||||
|
||||
`Render(theme, options) → string` produces the stylesheet:
|
||||
|
||||
1. Resolve the base document.
|
||||
2. Deep-merge the requested appearance's overrides on top.
|
||||
3. Flatten to leaves, in sorted path order.
|
||||
4. Append derived tokens for values the author did not define.
|
||||
5. Emit scoped selectors: the theme's default appearance, the alternate, a `.dark`
|
||||
ancestor, and optionally `prefers-color-scheme`.
|
||||
6. Emit the component layer inside `@layer bananas`.
|
||||
|
||||
**Sorted path order** is not cosmetic. It makes the output byte-stable, so the ETag
|
||||
is stable, so a re-render of an unchanged theme produces a cache hit at the CDN rather
|
||||
than a 200 with a fresh body.
|
||||
|
||||
### Colour
|
||||
|
||||
Everything the engine needs is computed from WCAG 2.1 relative luminance:
|
||||
`ContrastRatio`, `BestForeground` (black or white, whichever wins), `Mix` with
|
||||
straight-alpha compositing, `HSLHex`, and `GenerateRamp` — the palette builder behind
|
||||
the editor's one-click ramp and the reason the built-in themes are tonally consistent.
|
||||
|
||||
Non-`#rrggbb` syntaxes (`oklch()`, `color()`, CSS keywords) parse, render faithfully,
|
||||
and are excluded from contrast computation rather than guessed at.
|
||||
|
||||
---
|
||||
|
||||
## The render cache
|
||||
|
||||
The single most important performance component.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Key | `slug \| appearance \| variant` |
|
||||
| Value | the rendered string, plus a content-hash ETag |
|
||||
| Invalidated | on write, for exactly one theme's entries |
|
||||
| Eviction | none: themes are bounded by human authorship |
|
||||
| Pre-warmed | both appearances × all three variants, at start-up |
|
||||
|
||||
Rendering a theme traverses its whole token document. Serving the cached string is one
|
||||
map lookup. Because the key includes the version, publishing invalidates precisely
|
||||
that theme and nothing else, and a re-render after a restart produces byte-identical
|
||||
output, so a CDN's existing entry stays valid.
|
||||
|
||||
The ETag is a content hash rather than a version counter. Two themes with different
|
||||
slugs can produce byte-identical CSS, and a content hash lets a shared CDN
|
||||
deduplicate them. It is also self-correcting: a validator can never disagree with the
|
||||
body it describes.
|
||||
|
||||
---
|
||||
|
||||
## Publishing
|
||||
|
||||
```
|
||||
POST /api/v1/themes/{slug}/publish
|
||||
│
|
||||
├─ 1. load the theme
|
||||
├─ 2. Validate(tokens) ← errors block; warnings do not
|
||||
├─ 3. transaction:
|
||||
│ UPDATE themes SET status='published', version=version+1, …
|
||||
│ INSERT INTO theme_versions … ← immutable snapshot
|
||||
├─ 4. cache.Invalidate(slug) ← exactly this theme
|
||||
└─ 5. audit append ← actor, action, target, IP
|
||||
```
|
||||
|
||||
Every mutation bumps `version`, because the version is the cache key for the rendered
|
||||
CSS and must change whenever the rendered output could.
|
||||
|
||||
History is **append-only**. A rollback restores an old snapshot as a *new* version
|
||||
rather than rewinding, so an application already deployed against version 7 can never
|
||||
have version 7 silently taken away.
|
||||
|
||||
---
|
||||
|
||||
## The database
|
||||
|
||||
See [ADR 0001](adr/0001-embedded-sqlite.md) for the full argument. In brief: a single
|
||||
file, WAL journalling, `synchronous=NORMAL`, a 16 MiB page cache, mmap reads, and a
|
||||
deliberately small connection pool.
|
||||
|
||||
Migrations are embedded with `go:embed`, applied at start-up, and tracked in a
|
||||
`schema_migrations` table. They are idempotent by construction.
|
||||
|
||||
Timestamps are TEXT in RFC 3339, which sorts lexicographically, so `ORDER BY` and range
|
||||
filters need no date parsing and no timezone ambiguity.
|
||||
|
||||
Token documents are JSON columns. They are always read and written whole, never
|
||||
queried into — which is exactly the case where a JSON column is the right shape and a
|
||||
join table would be ceremony with no benefit.
|
||||
|
||||
---
|
||||
|
||||
## The stack
|
||||
|
||||
| Layer | Choice | Why |
|
||||
| --- | --- | --- |
|
||||
| Language | Go 1.27 | Static binaries, trivial cross-compilation, a good standard library |
|
||||
| Router | chi v5 | Small, `net/http`-compatible, middleware composition, no magic |
|
||||
| Database | `modernc.org/sqlite` | Pure Go: no cgo, static binary, no libc |
|
||||
| OIDC | `coreos/go-oidc` v3 | Discovery-driven: provider-agnostic from one issuer URL |
|
||||
| Session | HMAC-signed cookie | Replica-agnostic, no shared state |
|
||||
| Logging | `log/slog` | Standard library; no vendor in the graph |
|
||||
| Metrics | hand-written collector | ~200 lines, no dependency in a security-sensitive service |
|
||||
| Frontend | React 19 | The most common consumer, and the admin UI's own choice |
|
||||
| Build | Vite 8 | Fast, and the SDK is served rather than published |
|
||||
| Styling | Tailwind 4 | The admin UI consumes runtime variables through Tailwind's namespaces |
|
||||
| Data | TanStack Query 5 | Server-state management without inventing a cache |
|
||||
| Validation | Zod 4 | Runtime parsing at the network boundary |
|
||||
| Routing | React Router 7 | Data router; `lazy` route splitting |
|
||||
| Language | TypeScript 6.0.3 | Newest release with the compiler API the linters need — [ADR 0004](adr/0004-typescript-version.md) |
|
||||
|
||||
Deliberately absent: no ORM (the queries are simple and an ORM would hide them), no
|
||||
DI framework (constructor injection is enough), no GraphQL (a stylesheet is a GET), no
|
||||
Redis (the cache is process-local), no component library in the admin UI (it would
|
||||
fight the runtime variables).
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting concerns
|
||||
|
||||
### Authentication
|
||||
|
||||
Two mechanisms: an OIDC session cookie, and a development bypass that refuses to
|
||||
initialise outside development.
|
||||
|
||||
The session cookie is the only credential. There is no bearer-token path, so a
|
||||
request carrying an `Authorization` header is treated exactly like one without: a
|
||||
stale or forged token cannot grant anything. Machine credentials were removed
|
||||
because there is no machine caller left — the runtime API is public and the
|
||||
administration API is for people.
|
||||
|
||||
Roles come from provider groups. The mapping fails **closed**: a user in no
|
||||
configured group is a viewer. A mapping mistake must grant nothing, not everything.
|
||||
|
||||
### Authorisation
|
||||
|
||||
Enforced in the server on every admin route. The UI hides what the caller cannot do,
|
||||
as a convenience — never as the control.
|
||||
|
||||
### Audit
|
||||
|
||||
Every mutation records actor, action, target, timestamp and IP. The actor comes from
|
||||
the authenticated identity, never from the request body. A write failure is logged,
|
||||
not propagated: losing an audit row is bad, failing a publish because of it is worse.
|
||||
|
||||
### Observability
|
||||
|
||||
Structured JSON logs with a correlation id on every line. Prometheus metrics with
|
||||
request counts by route pattern (never by concrete path — a thousand theme slugs must
|
||||
not become a thousand time series), a latency histogram, cache hit rate, and Go
|
||||
runtime gauges. Three probes: `/livez` for liveness, `/healthz` for liveness with
|
||||
build information, `/readyz` for readiness, which is the only one that touches the
|
||||
database.
|
||||
|
||||
### Errors
|
||||
|
||||
One vocabulary. The domain returns sentinel errors; the store translates driver
|
||||
errors once; `writeProblem` maps sentinels to status codes once; the frontend client
|
||||
turns a non-2xx into an `ApiError` carrying the code and the per-field messages. A
|
||||
handler never chooses a status code itself, and no repository matches an error string.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
What the runtime path actually costs, with a warm cache:
|
||||
|
||||
| Operation | Cost |
|
||||
| --- | --- |
|
||||
| Stylesheet hit | one map lookup + one string write |
|
||||
| Stylesheet miss | token document traversal, ~100 µs for 200 tokens |
|
||||
| Manifest | one query, small result, cached by the CDN for 30 s |
|
||||
| Admin list | one indexed query |
|
||||
| Save | one transaction, two writes, one cache invalidation |
|
||||
|
||||
Measured on the built-in themes, a cold stylesheet render is sub-millisecond, which is
|
||||
why the render cache is an optimisation rather than a requirement. It exists to remove
|
||||
the work entirely under load, and to make the first request after a deploy fast.
|
||||
|
||||
The design consequence: **the hot path is not the database**. That is what makes an
|
||||
embedded database viable here, and it is why the connection pool is small on purpose.
|
||||
|
||||
---
|
||||
|
||||
## What is deliberately absent
|
||||
|
||||
| Not used | Why |
|
||||
| --- | --- |
|
||||
| An ORM | The queries are ten lines each; an ORM would hide the SQL that matters for the hot path |
|
||||
| Redis | The render cache is process-local and loses nothing on restart. A second service to operate for a cache is a bad trade — until multi-node, where it becomes a good one |
|
||||
| DI framework | Constructor injection in `app.New` is clearer than a container |
|
||||
| GraphQL | A stylesheet is a GET. A schema, a server and a client library to deliver a JSON document is a poor trade |
|
||||
| A component library in the admin UI | It would fight the runtime variables, which are the point |
|
||||
| Server-side rendering of tokens per request | Every consumer would implement its own fetcher, cache, fallback and revalidation, and they would all get it subtly wrong |
|
||||
| A message queue | The write path is a human clicking publish |
|
||||
| Feature flags | The chart covers configuration; a flag system for four booleans is a second way to be wrong |
|
||||
|
||||
Each of these would be the right answer under different constraints, and the ADR
|
||||
process is how one of them becomes the answer here.
|
||||
<!-- go-doc:generated:end -->
|
||||
Loading…
Add table
Add a link
Reference in a new issue