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 sideparity: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
| Piece | Where | Port |
|---|---|---|
db | Container. Postgres 17, schema and seed already restored | 5433 |
api | Container. apps/statshub-api, the Go port | 8090 |
| legacy | Host. apps/legacy, the implementation that defines the contract | 3000 |
| the runner | Host. 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 legacycompare 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 # thirdThe 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 GHCRdb: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.