web: Writes, reports and notifications go through grain-serve
MergedThis pull moves the web's writes, reports and notifications onto grain-serve's social.grain.* methods. It adds createRecord, putRecord, deleteRecord, applyWrites and uploadBlob in $lib/utils/records. It adds REPORT_REASONS in $lib/labels and a $lib/pds module that finds the PDS from the DID document.
I read only part of the diff. It is about 5,100 lines, and I read records.ts, pds.ts and the REPORT_REASONS map in labels.ts. I did not read the route, component and test changes. In what I read I found no bugs. The write wrappers map one-to-one onto their methods and drop repo. applyWrites does nothing for an empty list. pdsOf handles both did:plc and did:web, and it drops a failed lookup from its cache so the next call retries. Every grain label has a report reason.
All four checks (check, test, rust, harness) were still queued when I looked, so nothing has verified this yet. Please read their results before merging. The pull also says it was not tried in a browser.
Two things the pull already states, which I'm repeating because they affect users:
- Bluesky cross-posting from create, edit and stories will fail until grain-serve accepts
app.bsky.feed.post. - Mute, unmute, delete gallery and delete account answer 501 until grain-serve serves them.
Both are acceptable on a feature branch that doesn't deploy. Keep them on the list to close before this reaches main.
The only change since my last round is in preferenceSync.ts. readPreferences and keepPreferences now keep the preference set only in the browser. This fixes a real hazard: on the server, one module serves every request, so a module-level current could carry one user's preferences into another's request. The new test covers it. After a server-side read, the next write re-fetches getPreferences instead of reusing the kept set.
My earlier findings still stand: I found no bugs, and the caveats the pull states are acceptable on a feature branch. The four checks on the new commit (check, test, rust, harness) were all still queued when I looked, so please read their results before merging. The pull also says it was not tried in a browser.
- web: Writes, reports and notifications go through grain-serve
- main

What changes
Stacked on
web/reads(onweb/client); merge those first.Writes.
$lib/utils/recordshascreateRecord,putRecord,deleteRecord,applyWritesanduploadBlob, each calling itssocial.grain.repo.*method with the generated schema, taking what the old ones took (norepo: the cookie's account). Favorites, follows, blocks, comments, stories, galleries (create, edit,applyWriteswithsocial.grain.repo.applyWrites#create|update|delete), group submissions, items and declines, the profile editor and the Bluesky cross-post all write through them. Muting issocial.grain.graph.muteActor/unmuteActor, deleting a gallerysocial.grain.gallery.deleteGallery, deleting the accountsocial.grain.server.deleteAccount.Reports are
com.atproto.moderation.createReport. The modal still offers grain's labels; each is sent as thereasonTypegrain-serve files under that label ($lib/labels'sREPORT_REASONS, the inverse ofgrain::labels::label_for): atproto's for spam, misleading, sexual, rude and other, Ozone'sreasonViolenceGraphicContentandreasonChildSafetyOtherfor gore and child safety,social.grain.moderation.defs#reasonCopyrightand#reasonNudityfor the two neither has.Notifications are
social.grain.notification.listNotifications; the badge isgetUnreadCount'scountwhere it wasunseenCount; marking seen isupdateSeen(in the first pull). Each item now also carriesindexedAtandisReadand the pageseenAt, which the page does not show yet; the list is ordered byindexedAt.The profile editor reads its record's existing avatar blob ref with
com.atproto.repo.getRecordat the account's own PDS ($lib/pds, the PDS found from its DID document at plc.directory or/.well-known/did.json), since grain-serve's views give the avatar as a CDN URL and no longer servegetRecord.Why
The last slice of moving the web onto grain-serve. After it, the only
$hatk/clientimports left inweb/are the spaces calls below, for the spaces port.How it was checked
New
test/web/writes.test.ts: each write's method and body,applyWrites' write types and its no-op for none, an upload's bytes, every label having a report reason, the PDS found from a did:plc and a did:web document and the record read there.npm run checkandvp testpass locally; the runner's verdict is below. Not tried in a browser against grain-serve.Shortcomings
createRecordtakes only collections it indexes and answersUnknownCollectionforapp.bsky.feed.post, so cross-posting from create, edit and stories fails until grain-serve writes posts it does not index (or the web stops offering it).graph.muteActor,graph.unmuteActor,gallery.deleteGallery,server.deleteAccount(and, from the last pull,getBlocks,getMutes,story.getArchive,story.markViewed,server.getSitemap). They answer 501 until it does.$hatk/client, for the spaces port: private galleries (createPrivateGallery,getPrivateGallery,listPrivateGalleries,listSharedGalleries,getSpaceMembers,addSpaceMember,getSpaceSupport) and groups and pools (createGroup,getGroup,getGroupHost,listGroups,getGroupSubmissions,joinGroup,leaveGroup,listPoolGalleries,listPoolFeed,getPoolGallery,createPoolGallery,deletePoolGallery,createPoolComment,deletePoolComment,putPoolFavorite), inlib/queries.ts,lib/mutations.ts,FavoriteButton,CommentSheet,routes/private/*androutes/group/[did]/create. With them go the$hatkaliases insvelte.config.jsand the old plugin invitest.config.ts(withserver/).Serving in production
As proposed in the first pull: the node adapter as a
webservice besidegrain-servebehind Caddy on one host,/xrpc/*,/oauth/*,/oauth-client-metadata.json,/og/*and/.well-known/*to grain-serve, the rest towebwithGRAIN_SERVE_URL=http://grain-serve:2600.🤖 Generated with Claude Code