blog · 9 nov 2026
Building an append-only journal on Cloudflare Workers and D1
hlog is a journal formatted like a server log. You write short lines at levels like INFO, LEARN, WIN or ERROR; each one gets a timestamp from the server; nothing is public until you choose a level to share. And nothing can be edited. If you get something wrong, you write another line, the way you would in a real log.
It runs entirely on Cloudflare: one Worker (TypeScript, Hono, server-rendered JSX), one D1 database, a KV namespace for OAuth, and a small Rust CLI. These are the decisions that turned out to matter.
Append-only, enforced by the database
"You can't edit a line" is the product, so I didn't want it to depend on every code path remembering. SQLite triggers make it a property of the table:
CREATE TRIGGER lines_no_update BEFORE UPDATE ON lines
BEGIN
SELECT RAISE(ABORT, 'lines are append-only');
END;Any UPDATE, from any bug, fails. Deletion is still possible on purpose (account deletion, retention settings like "delete DEBUG lines after 7 days"), but changing history isn't. Timestamps come only from the server, so there's no backdating either.
A nice side effect: full-text search. hlog uses an FTS5 table kept in sync by triggers, and since rows never change, it only needs an insert trigger and a delete trigger. There is no update path to get wrong.
One function decides what's public
A line is public if its level is one you share and you haven't hidden that line, or if you shared that line individually. On top of that, lines removed by moderation and suspended accounts are never public. That rule shows up in a lot of places: your profile, the plain-text and RSS versions, the JSON API, the MCP server, the website widget, the GitHub README card, search, and other people's saved lines.
All of them call the same function, which returns one SQL fragment. Nothing is copied or cached as "public", so making a level private works retroactively and instantly. When I added the README card and saved lines, privacy came for free: they read through the same predicate, and the tests for each new surface check that a private line never appears.
Rate limits without writing on every request
D1 is SQLite, and writes are the expensive part. For appends, the limits (one line per two seconds, a hundred per day) need no counters at all: the newest line's timestamp answers the burst check, and a count of today's lines answers the daily cap. Both are reads on an index that exists anyway. Counters in a table are only used where there's nothing to read, like login attempts per email.
Magic links: GET shows a page, POST logs in
Login is a six-digit code or a link, both by email. Opening the link shows a page with a button; only the button's POST logs you in. That's because email security scanners open links before people do. If a GET consumed the token, the scanner would use it up and the person would see "expired".
MCP, and a CSP rule I didn't know
hlog is also a remote MCP server, so Claude and other assistants can append and search when you ask them to. Connecting uses OAuth: you land on hlog.dev, choose the scopes (read, append, follow), and press approve. The first time, approving did nothing.
The site's Content Security Policy had form-action 'self', and browsers apply form-action to the redirect that follows a form submission too, not just the form's own target. The consent form posts to hlog, and hlog redirects to the assistant's callback URL on another origin, which the browser blocked. The fix is narrow: only the consent page's CSP allows the registered redirect origin. Lines an assistant writes are marked via:claude (or the app's name), so it's always clear what you typed yourself.
Things production taught me
Crawlers follow everything. The dashboard showed a few thousand requests a day, far more than a handful of users could explain. A couple of minutes of wrangler tail --format json showed it was Googlebot and Google's AI crawler, fetching every public line and the "report" link under each one, which doubled the number of URLs. Report links are now nofollow, the report page sends noindex, and robots.txt keeps crawlers off it.
An invisible character broke every script. I wrote a regex that strips line breaks, including U+2028 (the Unicode line separator). It ended up in the file as the actual character instead of the \u2028 escape, and JavaScript treats that character as a line break inside a regex literal. The whole page script failed to parse. I caught it locally, and now I run node --check on the client script before committing.
The first limit isn't compute. At this size the Worker, D1 and KV cost close to nothing. The limit to watch is elsewhere: every login sends an email, and the email provider's daily cap is the first thing that would break on a busy day.
The log is the build log
hlog's own development is logged at @hlogdev, mistakes included: there's an ERROR line that should have been a WIN, followed by the line correcting it, because the first one can't be edited. That's the idea in miniature. If you want to try it, the command line is cargo install hlog, and there's a web version at hlog.dev.