WI-012: The API
WI-012: The API
Problem
There is a database with 333 games and a search function, and nothing can reach either. The mobile scaffold renders five hard-coded samples because it has nowhere to ask.
In scope
apps/api— Hono on Cloudflare Workers (ADR-0001),nodejs_compat, compatibility date ≥ 2025-04-01.- Postgres through a Hyperdrive binding with postgres.js (ADR-0004). Never
@neondatabase/serverless. GET /health— reports database reachability and whether rate limiting and error reporting are actually on.GET /games/search— the WI-021 query layer, exposed.GET /games/:slug, validated with the shared contract rather than a local regex.- Rate limiting in the first commit.
- Sentry error reporting, hand-rolled.
Out of scope
- Authentication — WI-013.
- Writes of any kind. Everything here is a read; the first write arrives with auth.
- Deploying to production. The Hyperdrive resource has to be created by a human (WI-H04);
wrangler.tomlcarries a placeholder id and local development never touches it.
Rate limiting is in the first commit
Not because traffic is expected, but because the project runs on a free tier and an unmetered public endpoint is how a free tier stops being free. Adding it later means deliberately choosing a window in which to be unprotected.
It uses Cloudflare’s native binding rather than an in-memory counter. A Worker has many isolates; an in-memory counter limits each of them separately, which is close to not limiting at all — and it passes a single-threaded test perfectly.
The trap this item hit
The first draft cached the Postgres client at module scope, with a confident comment explaining that a client per request would exhaust connections. Both halves were wrong.
Workers forbids using an I/O object created during one request from another:
Cannot perform I/O on behalf of a different request. I/O objects ... created inthe context of one request handler cannot be accessed from a differentrequest's handler.So the cache threw on the second request and every one after. The first request was always fine, which is exactly how many people would test it.
A connection per request would be ruinous against Postgres directly. It is fine here because Hyperdrive holds the pool on Cloudflare’s side — that is what the binding is for, and doing this without it would be the mistake the comment described.
Hyperdrive is not optional, and CI proved it
The tests connect straight to Postgres — there is no Hyperdrive outside Workers. Locally that is Docker on localhost and a connection costs milliseconds. In CI it is a Neon branch across the network and a connection costs roughly a second, so a test making five sequential requests hit the 5s default timeout and failed.
Nothing is wrong with the API. That is the honest cost of a connection per request without a pooler, and it is exactly the argument for the binding: with Hyperdrive, Cloudflare holds the pool and the handshake does not recur. The test timeout is raised rather than the client cached, because caching would make the tests stop resembling the runtime.
Worth remembering when the Hyperdrive resource is finally created (WI-H04): deploying this Worker with the placeholder id would produce an API that works and is slow, which is harder to notice than one that fails.
A gap, recorded rather than glossed
../standards/testing.md asks for API tests on the real Workers runtime via @cloudflare/vitest-pool-workers. These tests use Hono’s app.request() against real Postgres instead.
That runs the genuine app and genuine SQL, but not the Workers runtime — so it cannot catch the class of bug above. It was found by running wrangler dev and calling the health route twice, not by the suite. The regression test added afterwards passes even with the bug reinstated at this layer.
This is a real gap. It is deferred rather than hidden, with a follow-up, because closing it needs Hyperdrive bindings inside the vitest pool and that is a larger piece of work than the API itself.
Definition of done
-
GET /healthreturns 200 with the database reachable, and 503 when it is not. - Health reports whether rate limiting and error reporting are actually enabled, not whether they are configured.
- Repeated calls succeed — the second-request trap has a regression test.
-
GET /games/searchfinds by designer, mechanic and theme, and survives a typo. - An empty or whitespace-only query is a 400, not a cheerful empty 200.
-
limitis validated and capped; a client cannot ask for the world. - Input that would break
to_tsqueryreturns 200, and the corpus survives. - Malformed slugs are rejected by
slugSchemafrompackages/shared. - No internal message ever reaches a client — asserted with an unreachable database.
- Rate limiting is present in this commit, using the platform binding.
- Gate green.
Verification
pnpm --filter @tabletop/db run db:uppnpm --filter @tabletop/db run seedpnpm --filter @tabletop/api dev # http://127.0.0.1:8787pnpm gate