this is the first thing i've written about the forums i'm building (shh), so i'll start where the project actually started, which wasn't with code. it started with a decision i've already made twice and didn't want to make a third time: do i roll my own appview, or lean on someone else's? and i really really really hoped it was not the first one.
for linkring and pollen.place i rolled my own. wrote the firehose consumer, owned every index, ran the appview as my own service. it works and i learned a lot doing it, but it's a pile of undifferentiated plumbing you have to build and keep alive before you write one line of the product you actually care about. for the forums i wanted to know if i could skip the pile. i also just hate writing workers.
happyview is the candidate this time! it's an appview engine: you hand it lexicons, it stands up the xrpc endpoints and indexes the matching records off the firehose, so you don't write and babysit your own appview to read your own data back. if it holds up, happyview owns the atproto plumbing and the forum product just speaks xrpc to it. that's a lot less code that's mine to break 🙏.
so before betting the project on it, i built a fake forum to try to break it instead. 12 boards, 30k threads, 300k replies, 500 authors, seeded zipf-skewed because real forums have one busy board and eleven completely empty ghost towns. then i spent a day trying to make happyview fall over 👹. it didn't, mostly, and where it did the fix was obvious. numbers below, plus the three bugs i'm filing upstream.
the setup
local stack is docker-compose, postgres 17, happyview built from source at git head (forums-happyview:head, vendor/happyview at 0a09486, dated 2026-07-06). i built from source because i had to, which is its own finding, more on that in the bugs section.
bootstrap is a little hands-on. the first oauth login promotes you to super user, which is fine for humans and useless for a headless dev loop, so spike/bootstrap.sh inserts a super user plus an hv_ api key straight into postgres. one thing i was glad to confirm early: anonymous xrpc reads are allowed by default. client keys gate rate limits and oauth identity, not read access. that's the right shape for public forum pages you want to render server-side without making every anonymous reader authenticate first.
the fake forum
i uploaded 8 lexicons through the admin api: 3 record types, 3 queries bound via target_collection, 2 create-procedures. the generated xrpc endpoints went live immediately, and the jetstream collection filter reconnected with the new nsids within seconds. no restart.
the part that did more work than i expected: every at:// string inside any record gets reverse-indexed automatically, into happyview_record_refs, a (target_uri, collection) btree. thread-to-board and reply-to-thread both fall out of that for free. i didn't write a join table or a single line of indexing code for either relationship.
scale was 12 boards, 30k threads, 300k replies, 500 authors, zipf-skewed on purpose so the busiest board holds 4.8k threads on its own. a forum where every board has the same 2.5k threads is not a forum anyone has ever seen.
the number that mattered
here's the benchmark, n=50 per query, m-series laptop:
| query | 33k records, read-time | 330k, read-time | 330k, materialized |
|---|---|---|---|
| getBoardIndex | p50 22.5ms | p50 329ms | p50 8.3ms |
| getBoardThreads (busiest board) | p50 56ms | p50 942ms / max 2048ms | p50 1.7ms |
| getThreadPage | p50 13ms | p50 3.8ms | p50 3.2ms |
read-time aggregation over the one big happyview_records table is fine at 33k and comes apart at 330k. getBoardThreads on the busiest board went to a 942ms p50 with a 2-second tail. that's the exact wall phpbb hit thirty years ago, and it's the exact reason phpbb denormalizes into *_last_post_* columns instead of counting rows on every page view.
so i did the same thing, inside the engine. spike_thread_stats holds thread_uri, board_uri, reply_count, last_activity, with a (board_uri, last_activity desc) index. backfill it once, then keep it current with record.create trigger scripts. the busiest board's listing dropped from 942ms to 1.7ms. every listing surface in the real build gets a table like this, maintained by triggers. they're phpbb's last-post columns, reborn 🐦🔥.
i had to write lua, lol. the trigger scripts run in a sandboxed lua vm, and i had not touched lua since some WoW modding phase i'd mostly forgotten about. the hook that keeps a thread's counter fresh is this:
-- bump reply_count / last_activity on the parent thread's stats row.
function handle()
if record and record.thread and record.thread.uri then
db.raw([[
UPDATE spike_thread_stats
SET reply_count = reply_count + 1,
last_activity = GREATEST(last_activity, $2)
WHERE thread_uri = $1
]], { record.thread.uri, record.createdAt or "" })
end
return record
enda reply lands in someone's repo, jetstream delivers it, this fires, the parent thread's counter goes up. sql, in a lua string, in a heredoc, in a rust engine, is a genuinely strange place to be lol, and it also solved the thing i went in most worried about. i'd expected the lua fuel limit to bite: happyview caps each script at a million instructions. but db.raw runs real parameterized postgres outside that budget, so the 942ms aggregate never got counted. fuel only counts lua-side row shuffling, and my scripts never touched more than 100 rows. the sandbox constrains the loop, not the query.
the honest caveat: the spike only exercised creates. deletes and edits will need to decrement or recompute those counters, and i haven't written or tested that path yet. i know how it goes wrong (it's a race and a bookkeeping problem), i just haven't done it.
watching a write travel the whole loop
reads were the easy half. i wanted to watch one write go all the way from my real account to a live stats bump.
i registered an api client with granular scopes, one repo:<nsid> per collection, instead of the old transition:generic blanket grant. authorized it on bsky.social with my actual account, and the consent screen listed the per-collection permissions it was asking for, not a single scary "this app can touch everything." the procedure writes then landed against my real pds.
the bit i actually cared about: the pds enforces those scopes. i took a pre-upgrade session still holding the old broad grant and the write came back ScopeMissingError. that's the protocol doing the thing, not a screenshot of the protocol claiming to.
then a thread and a reply, both created through procedures, produced a stats row that appeared and bumped within about 5 seconds of the pds write. that's record.create:<nsid> firing off a real jetstream event, not a fixture i poked in by hand. two of those spike records are still sitting in my repo (…forumspike.thread/3mpyrhm4ue22n and one reply). i kept them on purpose. they're the project's first real posts, and i can delete them any time since the scope's already granted.
poking the thing that merged four days ago
private boards were the open question i was most nervous about, because the mechanism i'd need only merged three days before i started. the permissioned spaces proposal landed july 3. happyview already has an implementation behind feature.spaces_enabled, where a space type is declared as a lexicon (type: "space" plus a list of allowed collections). com.atproto.simplespace.createSpace hands back an ats:// uri, and records get written into the space from there.
i tested the access control by hand:
anonymous request: 401.
authenticated but not a member: rejected.
read-level member passing an explicit
repo=: can list any single member's records. drop the param and you get your own repo; aread_selfmember is pinned to their own repo no matter what they pass.owner: write access.
membership add and list behaved. the caveat i want kept in view: listing a whole private board at once, aggregated across every member's repo rather than one repo at a time, needs space credentials. that's implemented upstream and i haven't tested it. so private boards ship flagged "beta," which was the plan before the spike anyway, except now the beta label is backed by a test i ran instead of a hope i had.
three bugs i hit
none of these are fatal.
stale docker images. the ghcr.io latest tag is from may 8, roughly v2.0-era, and the version tags stop at v1.11. its schema is incompatible with head: unprefixed tables, a per-lexicon script column, no /admin/scripts api. anyone self-hosting today has to build from source, and nothing tells them that until the schema mismatch does.
loopback redirect_uri. loopback api clients register with redirect_uris: none (src/auth/client_registry.rs), unlike the primary client in main.rs, which sets its callback explicitly. so the auth server redirects to a bare http://127.0.0.1/ and the flow dead-ends. i worked around it with a simple callback-forwarder.ts bound to port 80.
hardcoded primary client scopes. the dashboard's built-in client has atproto identity:* hardcoded in main.rs, so getting record scopes at all means registering your own api client. that's fine for me, since the forum product layer is an api client either way, but it's undocumented and it cost me time to work out.
go
the go/no-go criteria, and where each landed:
hot queries correct at realistic volume: verified against direct db counts.
fast enough (bar was comfortably under 100ms): 1.7 to 8.3ms p50 with stats tables.
inside the lua fuel budget: sql runs uncounted, lua loops stayed under 100 rows.
writes and auth end to end: live, with granular-scope oauth, procedure, pds, jetstream, and trigger all real.
private boards viable: hands-on spaces test, member-gated reads enforced.
no dx showstopper: the admin api is scriptable start to finish. the one real caveat is image staleness, above.
all met. it's a go ✅
happyview owns the atproto plumbing, and the forum product sits on top and only ever speaks xrpc to it. 🙏
if you're testing happyview or spaces for anything of your own, i'd like to compare notes. find me on bluesky.