StatsHub Docs

Parity stack

Running the legacy Next.js API and the Go port side by side against one seeded database, to find out what the port changed.

The Go API is a port of the Next.js routes in apps/legacy/src/pages/api. The parity stack is how you find out whether it answers the same way: both servers running at once against one database that belongs to neither of them, with statshub-api-contract putting their responses side by side.

Three terminals:

bun run parity:up          # the seeded database and the Go API, in Docker
bun run parity:legacy      # the legacy server, on the host
bun run parity:compare     # both, side by side

parity:up takes about a minute the first time and seconds after that. The database does not build — it is pulled, already containing the data.

What runs where

PieceWherePort
dbContainer. Postgres 17, schema and seed already restored5433
apiContainer. apps/statshub-api, the Go port8090
legacyHost. apps/legacy, the implementation that defines the contract3000
the runnerHost. statshub-api-contract

db publishes on 5433 and api on 8090, so the stack can run alongside everything else this repo starts. 8081 is not far enough: docker-compose.yml claims 5432 and 8080, a host bun run dev:api takes 8080, and metro takes 8081.

Those collisions are not cosmetic. Docker publishes a port; if another process already holds it, requests reach that process and the container is never consulted. Nothing errors — compose still reports the container healthy, because its healthcheck runs inside the container. A comparison run that way returns a full set of plausible results about a server nobody meant to test.

So parity:up does not finish until it has proven the published port reaches the container: it issues a request that reads the database and checks the shared query log grew, which nothing else on the machine writes to. If the port is shadowed it fails and names what to stop, or which PARITY_API_PORT to use instead. Every script derives its URL from that same variable, so the runner cannot end up pointed where the stack is not.

Why legacy is not a container

Its production image does not build. Inside the workspace turbo prune hands to Docker, @types/react-dom@18.2.5 declares its own @types/react dependency as *; that resolves to 19.x, and createPortal then types against React 19 while the app's JSX is React 18. The build stops at PropsTable.tsx.

The same source typechecks on the host — tsc --noEmit exits 0 — because the unpruned workspace resolves @types/react@18.2.12 next to the app. So the host is where legacy runs until that resolution is pinned. Nothing about the comparison changes; the servers do not need to be colocated, only to share a database and a query log.

The shared query log

Both servers append to .parity/queries.ndjson, and the runner reads the window each request produced. That is what lets the suite assert how a route reached Postgres — how many statements, against which tables — and it is what catches a ported route returning correct JSON through forty queries where drizzle used one.

It is a bind mount rather than a named volume precisely because one of the two writers is not a container. parity:up creates .parity/ world-writable first: the api container writes as uid 10001, which has no account on the host.

If the database assertions come back empty, it is almost always that one of the three processes was given a different STATSHUB_QUERY_LOG than the other two.

Why the database is a prebuilt image

packages/statshub-api-contract/fixtures/seed.sql is 245MB of real rows, and it is not in the repository — it is gitignored, being both far past GitHub's 100MB blob limit and full of customer data. Handing it to anyone means moving 245MB, and restoring it into an empty volume takes minutes on every machine that does it.

scripts/dev-db/Dockerfile pays that cost once, at image build time. It runs initdb, loads the extensions, the schema and the seed, runs ANALYZE so the planner has statistics, and shuts down cleanly. What ships is the data directory. A container off this image accepts connections in about two seconds.

Two details in that Dockerfile are load-bearing and easy to undo by accident:

PGDATA is /var/lib/postgresql/baked, not /var/lib/postgresql/data. The base image declares the latter as a VOLUME, and Docker discards whatever a RUN layer writes into a declared volume. Point the bake at data and the build still succeeds — it just ships an empty database.

The image is published for linux/amd64 and linux/arm64 separately. A baked data directory is not portable across architectures: Postgres writes its files with the alignment of the machine that ran initdb, and an amd64 server rejects an arm64 directory rather than coping with it. Emulation does not help, because the data is what is wrong. scripts/dev-db/publish.sh builds both, and both have been built and booted: identical row counts across all 103 tables, 548 indexes, 286 constraints and 1,658 column definitions on each, accepting connections about two seconds after start.

The db service has no build: section, deliberately. With one, up on a machine that has never seen the image builds it instead of pulling — and the build needs the 245MB seed.sql that only the machine which extracted it has, so anyone else gets a COPY error about a file nobody gave them. pull_policy does not prevent this; compose builds anyway whenever a build section exists. With none, up can only pull, and an unauthenticated machine fails with error from registry: denied — which names the actual problem. bun run db:image:build invokes docker build directly.

Running the comparison

bun run parity:compare              # both targets, side by side, in one run
bun run parity:verify               # the Go API against the committed goldens
bun run parity:record               # re-capture the goldens from legacy

compare is the one to reach for when the question is "did I break anything". It runs every case against both servers in the same process on the same machine, which cancels most of the latency noise, and reports where the two disagree rather than where one differs from a file.

record is deliberately separate and never runs as a side effect of a failure. Regenerating goldens automatically is how a golden suite turns into a rubber stamp: the diff that should have been read gets committed instead.

Goldens are recorded against a specific dataset. Re-record whenever the seed is refreshed, and read the diff — a golden that moved because the data moved looks exactly like a golden that moved because the port changed.

Handing the stack to someone else

They need the repository, Docker, bun, and the ability to pull the database image.

echo <TOKEN> | docker login ghcr.io -u <THEIR-GITHUB-USERNAME> --password-stdin
bun install
bun run parity:up
bun run parity:legacy     # second terminal
bun run parity:compare    # third

The token is a GitHub personal access token with read:packages. On a classic token that scope covers every package the issuer owns, so a fine-grained token scoped to this package — or adding the person to the package's access list on GitHub — is the narrower option.

The image contains production rows. Whoever runs it has a copy of the customer data as it stood when the seed was extracted. Its credentials are baked in and identical on every machine (statshub / statshub on statshub), which is appropriate for a fixture on a laptop and for nothing else.

Refreshing the data

SEED_SOURCE_URL='postgres://...' bun run db:seed:extract   # re-dump the rows
bun run db:image:build                                     # rebake locally
bun run db:image:push                                      # both arches, to GHCR

db:image:push tags :latest and a dated tag. Pinning PARITY_DB_IMAGE to a dated tag is what keeps a comparison reproducible: a :latest that moved underneath a set of goldens produces failures that have nothing to do with the code.

CI

.github/workflows/ci.yml's contract job runs this same comparison on every push, using the same database image as a service container and building both servers natively on the runner. A failure there reproduces locally with bun run parity:compare and nothing else.

On this page