Architecture
cuento is one Go binary serving server-rendered HTML from an embedded filesystem, backed by a single SQLite database file. Everything below follows from that choice.
The stack
- One binary, one file. The application ships as a single Go executable. It
uses the pure-Go
modernc.org/sqlitedriver, so the release build is CGO-free and statically linkable, and cross-compiles trivially.internal/db.Openis the only place database pragmas are configured. - SQLite as the database. All state lives in one file. A single-writer SQLite file suits a small nonprofit’s transaction volume and is what makes the one-VM deployment (with off-host streaming backups) honest, rather than scale-to-zero on an ephemeral filesystem.
- Server-rendered HTML + htmx. Pages are rendered with
html/template. Interactivity is a pinned, vendored copy of htmx plus small hand-written ES modules. There is no SPA framework, no bundler, and no CDN.
Request lifecycle
A browser request enters a middleware chain and, if authorized, reaches a handler that reads through the store and renders a template:
- Security headers are applied as the outermost middleware, so every response – 200, 302, 403, 404, JSON health check, or a static asset – carries them.
- Cross-origin protection rejects non-safe cross-site browser requests.
- Session resolution loads the session and re-reads the full user identity each request, so a permission or lockout change takes effect without a re-login.
- Authorization checks the route’s declared permission against the resolved user via a single pure decision function.
- Language resolution picks the locale (user setting, then cookie, then English) so every rendered string comes from the right catalog.
- The handler runs: it reads through
internal/store(or, for reports, the read-only report toolkit) and renders anhtml/template. Mutations go through the write funnel; nothing else opens a transaction.
htmx requests target a fragment of the page and swap it in place; the handlers return the same partials the full-page render composes, so there is one source of truth per region.
Layer boundaries
The internal packages have deliberately narrow responsibilities:
| Package | Responsibility |
|---|---|
internal/db |
Open() (the only place pragmas are set), the embedded goose migrations, and the sqlc-generated query code. |
internal/store |
The only writer. Holds the actor context, the write funnel, entity operations, and the balance/register queries. |
internal/money |
Amount (int64 minor units), currency math, and the date/number format enums – the only date/number formatting and parsing entry points. |
internal/i18n |
The embedded en.toml / es.toml catalogs, T(), and the template function. |
internal/ledger |
The named integrity checks (error and warning severities) and Check(). |
internal/reports |
The report registry, the read-only toolkit, and one file per report (goldens in testdata/). Rendering is the shared report template in internal/web. |
internal/web |
The route registry (routes.go), middleware, handlers, and the embedded templates and static assets. |
Dependencies point inward: web and reports read through store; store
owns the database; money and i18n are leaf utilities used everywhere.
Migrations and sqlc
- Migrations are the source of truth for the schema. They are embedded
goose migrations, forward-only and
numbered; the runner backs up the database file before applying pending
migrations, and there are no down migrations. An applied migration is never
edited.
cuento serveauto-migrates on start;cuento migrateruns it standalone. - Reads go through sqlc. All SQL lives in
.sqlfiles compiled by sqlc into typed Go; there is no string-concatenated SQL. The one reviewed exception is the ledger’s named integrity checks, which are staticconstSQL strings reviewed as a set.
The write funnel
Every database mutation goes through internal/store’s
write(ctx, kind, note, fn) helper. It opens one transaction, records exactly
one changes row naming the acting user (carried on the context), and runs the
supplied function – which performs the entity change and appends the matching
full-snapshot *_versions rows in the same transaction. Handlers, reports, and
the CLI never open a transaction or write directly. This is what makes the audit
trail complete by construction: there is no write path that skips versioning.
The reports registry
A report is a small piece of data (an id, a title key, a group, and a parameter
spec) plus a pure Run(ctx, toolkit, params) that returns a typed Table. The
report never opens a transaction; it computes through a read-only toolkit over
the store’s balance and activity queries. The web layer auto-mounts a route, a
CSV export, and a drill-down endpoint per report – each gated by the report’s
permission group – and the /reports index and the permission matrix pick the
report up with no additional wiring. Adding a report is therefore a code-only
addition: register it, add its i18n keys to both catalogs, and add a golden.
The frontend and CSP model
The frontend is boring on purpose. It is html/template output, a pinned
vendored copy of htmx, and small hand-written ES modules – no framework, no
bundler, no CDN, and no inline event handlers. It is served under a strict
Content-Security-Policy whose script-src and style-src are 'self' with no
'unsafe-inline', so an injected <script>, an inline onclick, or a
style="..." attribute is refused by the browser. In production, static assets
are served content-addressed (a hash of the content in the filename) with
immutable cache headers, so a changed file gets a new URL and can never be served
stale; under -dev they are served unhashed and never immutable-cached. Dates
are always entered through text inputs with a hand-written calendar popover, never
a native input[type=date], so formatting follows each user’s settings.
Testing
- Go table tests by default, with property tests where the plan names them. Handler tests hit the real mounted router against a real migrated temporary database – the store is never mocked at the handler layer.
- Report goldens live next to each report and are regenerated only with a reviewed diff.
- Every store mutation test asserts versioning through a shared
AssertVersionedhelper, and every ledger invariant has at least one negative test proving it rejects. - JavaScript units run under
node --testfor the hand-written ES modules. - End-to-end tests use Playwright (a test-only Node suite) that launches a
real
cuento serve -devand drives a browser. Playwright is a dev/test dependency only; it is never imported by the shipped binary or the frontend runtime, and the hermeticrake testdoes not run it.