api
The Go HTTP API — a route-for-route port of legacy's pages/api, on port 8080, with a described /api/v2 surface beside it.
apps/statshub-api serves the StatsHub HTTP API in Go. It is a port of the 218 Next.js
route files in apps/legacy/src/pages/api, reading the same Postgres through
the same schema and serving the same paths, query parameters and JSON shapes.
bun run dev:api # http://localhost:8080
cd apps/statshub-api && make dev # equivalent, from the appWhat it does
Fixtures, teams, players, referees
The entity reads every client starts from — one match, one squad, one player's log, one official's record.
The models
valuebets, shotmodel and odds are packages, not endpoints: the value
board, the shot model and the odds comparison that the boards on
web render.
Stats and screeners
stats and bookmakers serve the hit rates, trends and per-book prices the
prop screener filters on.
Bots and gateways
telegram posts the alerts, aigateway fronts the model calls, stripe
handles subscriptions, websearch and notion fetch what the rest needs.
Sitemaps
sitemap enumerates the entity URLs worth crawling — filtered to visible
tournaments, and to finished fixtures from the last 60 days. It is also
what web prerenders from.
A described surface
/api/v2 is the same 298 operations behind typed structs, one error model,
and an OpenAPI document you can generate a client from. The
reference is that document, rendered.
How it is built
The OpenAPI document is read out of the code
cmd/openapigen walks the router and handlers with go/types and writes
internal/api/v2_gen.go. The description is recovered from the
implementation rather than maintained beside it, so the two cannot drift —
and openapi:check fails a build where someone added a route and did not
regenerate.
Writes to production are blocked in-process
internal/database/readonly.go classifies every statement on its way out
and refuses anything that is not a read — at the driver, because a Bun
BeforeQuery hook returns a context and not an error. It fails closed: a
statement it cannot classify counts as a write.
Two surfaces, one implementation
/api is byte-compatible with the Next.js routes it was ported from —
untyped, with four different error envelopes. /api/v2 is the same handlers
behind typed responses. Neither is a reimplementation of the other.
Faithfulness is tested, not asserted
Golden responses recorded from legacy and replayed against Go, and a
conformance test that checks the declared v2 types against those same
recordings — so the document cannot claim a field the handler stopped
writing.
82 tables, twice
internal/schema is the Drizzle schema legacy uses, restated as Bun
models. It is a second copy of one definition and stays that way until
legacy is gone, which is the cost of running both against one database.
The cache is bounded on purpose
internal/cache is an LRU with a per-entry TTL, ported from
bounded-cache.ts. The Node original replaced module-level new Map()
caches that grew until the heap cap was hit; the entry cap is what stops
that happening again.
GET /api/health answers as soon as the process is up; everything else needs
DATABASE_URL. scripts/dev.sh loads it from the root .env, then
apps/statshub-api/.env, then .env.local, because Go has no equivalent of the
automatic .env loading the Next.js apps get.
Which database you are attached to
Three databases can back the API, and the third one is production. Which one
you get is decided by the command you run, not by whichever file last set
DATABASE_URL.
| Command | Database | Writes |
|---|---|---|
make dev | Local Postgres, from .env / .env.local | Allowed |
bun run parity:up | The seeded fixture container | Allowed |
make dev-remote | The real one online, from .env.remote | Blocked |
cp apps/statshub-api/.env.remote.example apps/statshub-api/.env.remote # paste the URI, then
cd apps/statshub-api && make dev-remoteSupabase hands you the string under Project Settings → Database → Connection
string → URI. Take the transaction pooler one on port 6543: the pool limits
and the simple query protocol in internal/database are built for it.
.env.remote is read by nothing else and is gitignored. That separation is the
point — make dev cannot pick up production because a file was edited weeks
ago and forgotten.
Writes are blocked, and that is enforced in the process
Attaching to production opens the connection read-only. Every statement passes
internal/database/readonly.go on its way out, and anything that is not a read
is refused before it reaches Postgres:
{"level":"WARN","msg":"attached to a REMOTE database","host":"…pooler.supabase.com","writes":"blocked (read-only)"}The check sits at the driver rather than in a bun query hook because a hook
cannot fail a query — BeforeQuery returns a context, not an error. It sits in
the application rather than relying on SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY because a transaction pooler does not carry session
state across transactions, so the server-side setting is not dependable through
Supabase's pooler.
The classifier fails closed. A statement it cannot classify counts as a write,
WITH … AS (DELETE … RETURNING) SELECT is caught rather than passing as a
SELECT, and comments and string literals are stripped first so a row whose
text happens to say delete does not block a read.
To write — a migration, or reproducing a write path — say so on the command line. There is no config file for it, so it cannot be left on by accident:
STATSHUB_DB_WRITES=allow make dev-remoteSTATSHUB_DB_MODE=local|remote forces the mode when a tunnel makes production
look like localhost. Anything the process cannot prove is local is treated as
remote, which is the safe direction: the cost is a rejected write rather than a
row in production.
Two surfaces, one implementation
| Prefix | Contract |
|---|---|
/api | Byte-compatible with the Next.js routes it was ported from. Unchanged, untyped, four different error envelopes. |
/api/v2 | The same 298 operations through typed Go structs, one application/problem+json error model, and an OpenAPI document generated from those structs. |
The endpoint reference is generated from that document, and OpenAPI and /api/v2 explains how the types are recovered and what the generator deliberately leaves open.
cd apps/statshub-api
bun run openapi # regenerate internal/api/v2_gen.go and docs/openapi.json
bun run openapi:check # the same, then fail if either changedopenapi:check is what belongs in CI: a route added without regenerating shows
up as a diff.
The port is in progress
apps/statshub-api/docs/go-port.md lists every legacy route and whether it has moved
across. It is generated — edit apps/statshub-api/docs/.ported and run:
cd apps/statshub-api && make porting-statusThat ledger is a data file, not a doc
packages/statshub-api-contract parses its ported-routes table to learn which routes
claim to be ported, and update_manifest.py rewrites the whole file. Do not
hand-edit the tables, and do not move the file without updating
packages/statshub-api-contract/src/cli.ts, which pins the path.
Layout
| Package | What it owns |
|---|---|
cmd/api | Bootstrap and graceful shutdown |
cmd/openapigen | Reads the router and handlers with go/types, writes internal/api/v2_gen.go |
cmd/openapispec | Serialises the document to docs/openapi.json |
internal/config | Environment parsing; PORT defaults to 8080 |
internal/database | pgx pool + Bun, pooled to match legacy |
internal/schema | The 82 Drizzle tables as Bun models |
internal/httpx | Request parsing and the legacy response conventions |
internal/auth | Supabase session resolution |
internal/router | chi routing |
internal/api | The generated v2 surface and its conformance test |
Domain packages sit alongside those — valuebets, odds, shotmodel, stats,
bookmakers, telegram, stripe, sitemap, aigateway and the rest.
Faithfulness is tested, not assumed
Two gates, and they check different things:
statshub-api-contractrecords golden responses fromlegacyand replays them against Go. This is what proves the bytes match.internal/api/v2_conformance_test.gochecks every declared v2 response type against those same recordings, so the OpenAPI document cannot claim a field the handler stopped writing.
Read both before changing a serialisation. A shape change that passes Go's own tests can still break every client.