Knowledge Base

Phase 5: Sixteen Endpoints, One Sitting

Phase 5 was the big one. Every core-CRUD endpoint from the Python app, ported to Go in a single session: register, login, me; documents (list, get, put, versions, version-by-id); the sync endpoint; share links, including password-optional public access to shared docs; and publish-to-vault, including slug collision handling that appends -2, -3, and so on. Sixteen endpoints total.

The structure is an API struct holding the database repository and config, with each handler as a method on it. That's my Go answer to FastAPI's per-parameter Depends(...) dependency injection. Go has no runtime reflection over function signatures, so you don't get free injection — you wire the dependencies into a struct and call methods on it.

Last-write-wins, and getting it right

The centerpiece is the sync endpoint's last-write-wins logic. When a client pushes a document, folder, or comment, the server only accepts the write if the client's updated_at is strictly newer than what the server already has. Equal timestamps are rejected too — not just older ones.

Get that comparison wrong and a client that's been offline a while, or a stale retried request, can silently clobber newer edits made elsewhere. I wrote explicit multi-step tests for this: a stale write is rejected and the server's version survives; an equal-timestamp write is also rejected; a genuinely newer write is accepted. All three passed.

I also tested that documents must be created before comments that reference them in the same sync request. Foreign-key ordering matters here — the database has foreign_keys=ON, so getting the order wrong is a hard SQL error, not a silent one. And a version snapshot is only taken when content actually changed on an existing document, capturing the pre-update state. Not create-then-immediately-snapshot.

The rate limiter gotcha

The interesting bug this phase wasn't in the CRUD at all. It was in the rate limiter, ported from the Python app's ratelimit.py — an in-memory per-IP sliding window.

The Python version's own comment says it's "fine for a single process." And it's right, but only because FastAPI's single asyncio event loop never actually runs two requests' worth of that function body concurrently. There's no await inside the critical section, so Python's cooperative scheduling gives the shared dict exclusive access for free, without anyone writing a lock.

Go's http.Server hands every request its own goroutine, genuinely running in parallel across OS threads. A direct line-for-line translation — a shared map with no synchronization — would have compiled fine, passed every single-request test, and then corrupted itself or panicked under real concurrent traffic.

I wrapped the shared map in a mutex. Not a stylistic add-on. An actual correctness requirement Go's concurrency model imposes that Python's happened to sidestep for reasons specific to its own runtime — not because in-memory rate limiters are inherently safe.

Where protection lives

Route protection in the Go router (chi) is a property of where a route is mounted in the router tree — inside or outside a group with an auth middleware applied — not a property of the handler function's own signature.

FastAPI's user: User = Depends(get_current_user) makes protection visible right in the function you're reading. The Go version is invisible at the handler level; you have to go check the router file to know whether a given handler requires a token.

Traded one kind of clarity (local, per-function) for another (one file shows the entire route tree's protection at a glance). Arguably neither is strictly better. But the Go one fails silently if a route is mounted in the wrong group, where FastAPI's fails loudly — forget the Depends and the handler literally can't reach the user object.

Real HTTP, not just go test

I verified everything end-to-end for real, not just with go test. Built the actual binary, ran it against a fresh real SQLite database (not an in-memory test database), and drove register → put document → get document → list documents through real HTTP with curl.

The JSON response shape matches the Python API's field names exactly. That's the check that actually proves the existing shipped React frontend could talk to this backend unmodified — which no amount of Go-side unit testing alone can prove.

What's next

Phase 6 is config completion, CORS middleware, and consolidated panic recovery. Phase 7 is Docker and deploy to a side-by-side subdomain. Phase 8 is benchmarks. The core surface is done; now it needs to be shippable.