A small, read-only Go service that serves the Bitcoin Calendar event databases over HTTP, in English and Russian.
It is read-only in a structural sense, not as a convention. The databases are an artifact
authored and validated in a separate repository and shipped here whole; this service opens
them with mode=ro, never migrates them, and never creates the indexes, triggers or
full-text tables they already carry. On the server the files are mode 0444 in a 0555
directory, and systemd's ReadOnlyPaths makes the kernel refuse a write even if a future
code change tried one.
That arrangement exists because the project previously had eighteen divergent copies of the same database and no way to tell which one any given consumer was reading.
make build # CGO_ENABLED=1 go build -tags fts5 …
make test # black-box: builds the binary, serves a fixture, drives it over HTTP
DB_PATH_EN=/path/events_en.db \
DB_PATH_RU=/path/events_ru.db \
API_KEYS=some-secret \
./bitcal-api
curl localhost:3000/health | jq .
curl -H "X-API-KEY: some-secret" 'localhost:3000/api/events?month=8&day=9&lang=ru' | jq .Two things about the build are not optional:
-tags fts5. The SQLite driver does not compile FTS5 in by default, and without it every query touchingevents_ftsfails at runtime while the rest of the API looks fine. A build without the tag is refused outright — seefts5_required.go.- Build on Ubuntu/glibc, or in a matching container (
make build-ubuntu). CGO ties the binary to the C library it was built against; one built on macOS or Alpine will not start on the server at all.
Everything under /api requires an X-API-KEY header. /health does not.
| Endpoint | Purpose |
|---|---|
GET /health |
Which artifact this process has open, and whether it is fully indexed. Unauthenticated. |
GET /api/events |
Events, paginated. Filter with year, month, day. |
GET /api/events/:id |
One event. |
GET /api/events/tags/:tag |
Events carrying a tag. |
GET /api/tags |
Every tag with the number of events carrying it. |
GET /api/search?q= |
Full-text search over title, description and tags. |
All of them take lang=en (default) or lang=ru. Full detail, including every field and
every error, is in docs/APIDocumentation.md.
These are the parts that are not guessable from the endpoint list. Each was a real bug.
dateis a string,"2013-08-09"— not a timestamp. The range starts at 1881-09-29, before the Unix epoch, and there is no time-of-day component to report. The column's declared type isdate, which makes the SQLite driver convert it to atime.Timebefore GORM sees it, sodatabase.gocarries a scanner that converts it back. Without that the API emits"1881-09-29T00:00:00Z"— an invented time and timezone.mediaandreferencesare JSON arrays encoded as strings, andnullwhen absent — never""and never"[]". Decode them a second time.nullmeans "no media", which is not the same claim as "an empty list of media".created_atandupdated_atare frequentlynull. Many rows genuinely have no timestamp. Rendering those as"0001-01-01T00:00:00Z"would be inventing data.url_path(/2013-08-09/hal-finneys-last-post/) is the cross-language join key and the website's page URL. It is present on every row.categoryis nottags[0]. Every event has exactly onecategory, and it is what the website colours and filters by. Consumers used to derive it from the first tag; tag order carries no meaning now and that inference is wrong.bitcoinis the sharp edge: it is a category on 132 RU and 66 EN events and no longer a tag on any, so/api/events/tags/bitcoinreturns an empty list. Filter with/api/events?category=…and discover the values with/api/categories. The set is closed but owned by the data and liable to grow —securityappeared a day after the column did — so accept unrecognised values rather than hardcoding the list. The service derives the accepted values from the artifact at startup for that reason; an unknown category is a400, not an empty list. It is a filter on/api/eventsonly — sending it to/api/searchor/api/events/tags/:tagis a400too, rather than a200full of unfiltered results.eventsis always an array,[]when nothing matches, on every endpoint that returns a list — and so isdataon/api/tagsand/api/categories. Nevernull.- An unknown
langsilently serves English.lang=xxis not an error. Do not rely on a typo being caught. /api/tagsreturns its list underdata, nottags.- A malformed
month,dayoryearis a 400, deliberately. An unparseable filter used to return an empty list, which is indistinguishable from a day that has no events — a client would post nothing and report success forever. - A malformed search query is a 400, not a 500. Bare
AND/OR/NOT, unbalanced parentheses or quotes, and a leading*are all invalid FTS5. Prefix search (биткоин*) andOR/NEARdo work. - Quoting
qis a phrase search.q="bitcoin price"wants those words adjacent, in that order — 6 hits against the English artifact. Unquoted,q=bitcoin priceis an implicitANDand finds 39. Before 2026-08-09 the two were the same: the handler doubled every"on its way to FTS5, so quotes were silently discarded and a stray"answered 200 rather than 400. limitis capped at 1000 andpageat 1000000, and both are a 400 when out of range or unparseable rather than being clamped.limit=abcused to be page 1 of 20 with a 200. The ceiling sits above the corpus on purpose —limit=1000returns every event for a language — so it refuses only values that cannot be meant seriously.- Lists are newest first, ties broken by
iddescending. 19 dates carry more than one event in English, and without the tiebreaker paging across one could show an event twice and skip another. - Rate limiting is 100/min per API key. Give each consumer its own key: they all reach the service over loopback, so anything keyed per-IP would be one shared budget.
{
"status": "ok",
"version": "0.1.0-dd9c9dd",
"databases": {
"ru": {
"path": "/srv/bitcal/data/releases/20260810T191227Z/events_ru.db",
"sha256": "12a5f040…",
"rows": 581,
"fts": { "indexed": 581, "consistent": true },
"categories": { "present": true, "count": 15 }
}
}
}path is symlink-resolved and sha256 is computed once at startup, so this describes the
inode the process actually has open rather than whatever current points at when you ask.
Those two differing is the failure the endpoint exists to catch.
fts.consistent is indexed == rows: every event is reachable by search. When it is false,
status becomes degraded — the service is up and search is silently incomplete.
categories is what the service read out of the artifact at startup, and it is what
?category= validates against. count: 0 means every category filter is rejected — either
the artifact predates the column (present: false, which is what a rollback looks like) or
no row carries a value (present: true, which should never have been published).
publish-db.sh refuses to leave a release in the second state. It does not affect status:
a rollback target is not a degraded service.
The service refuses to start if a full-text index is missing, empty or unreadable. That
is deliberate: a broken index makes /api/search return an empty result set, which is
indistinguishable from a query that matched nothing, so nothing downstream could ever report
it. Startup is the only place it can be made loud.
A native systemd service, not Docker. The unit is deploy/bitcal-api.service; it binds
loopback only and is not proxied by nginx.
There are two release paths and they do not overlap:
deploy/publish-db.shships database artifacts. It validates the source, stages it, validates the staged copy again, checks that this API can actually emit the staged schema, flips thecurrentsymlink, restarts the service — mandatory, since SQLite holds an open file descriptor and the symlink alone changes nothing — then verifies/health.deploy/publish-api.shships the binary. It builds on the box (CGO ties the binary to the target's glibc), runs the suite there, backs the running binary aside, installs, restarts, and asserts/healthshows a new version and unchanged data.
Both roll back automatically if anything after the irreversible step fails. Because they are
independent, the binary and the data drift; check both halves of /health when diagnosing.
See docs/Deployment.md.
The suite lives in tests/ and is black-box: it builds this binary, stages a database fixture
exactly as a release is staged (0444 in a 0555 directory), starts the service against it
and drives it over HTTP. So make test also exercises the build tag, the read-only open, the
boot probe and the JSON contract above — none of which a unit test calling InitDB directly
would prove.
- docs/APIDocumentation.md — every endpoint, field and error
- docs/DatabaseDocumentation.md — schema and the artifact contract
- docs/Deployment.md — building, installing, releasing, troubleshooting
| Variable | |
|---|---|
API_KEYS |
Required. Comma-separated. The service exits if unset. |
DB_PATH_EN |
Required. No default — the service exits naming the variable. |
DB_PATH_RU |
Required. Same. |
LISTEN_ADDR |
Defaults to 127.0.0.1:3000. |
CORS_ALLOWED_ORIGINS |
Comma-separated. Defaults to http://localhost:3000. |
The API will be publicly available in Q3 2026. To test it before then, DM @Tony on Nostr and I'll share a key.