0
2
grain-serve lists notifications and counts the unread, as Bluesky does
Merged Approved
This pull adds listNotifications and getUnreadCount. Both are derived at read time from a nine-arm UNION ALL in read/notification.rs. It also adds a trigger-maintained mention table (V6) and the arising and unread_count seams for push.
I read read/notification.rs and the V6 migration in full. I did not read the rest of the diff, the tests or the lexicon changes: the 67k-character diff was saved to a file that my tools could not open. I did not check the CI results.
- Arms: each record comes out as at most one notification. The mention arms exclude the cases the reply and comment arms already cover. Preferences, moderation and the viewer's own activity are all filtered in SQL, so a page can't come back short while a cursor is still offered.
- Read state:
seen_atis stored in UTC with milliseconds (set_seen, V5).sort_atis built with the samestrftimeformat, so the text comparisons inisReadand the count line up. A never-seen viewer gets'', so every item counts as unread, as the pull describes. - V6 triggers: insert, update and delete are all covered, and the backfill uses the same extraction as the triggers. Only real
#mentionfacets with a textdidcount.
I found nothing that has to change. The pull's own shortcoming about reading all of an account's notifications per page is a fair one to revisit if it gets slow.
Merged as 1345063
Part of a stack of 2
- grain-serve lists notifications and counts the unread, as Bluesky does
- main
Stacked on grain-serve keeps when an account last saw its notifications (
notifications/state). This pull's own change is its last commit. Merge that one first.What changes
social.grain.notification.listNotificationsandgetUnreadCount(read/notification.rs), for the caller's service token only (401 without one). Notifications are not stored. They are found when asked for, as aUNION ALLof nine arms, one for each of hatk's sources:Each arm leaves out the viewer's own activity. It also leaves out what the read side leaves out (
moderation.rs): authors that are taken down or gone, authors who block the viewer or whom the viewer blocks, accounts the viewer muted, and records with a hiding label from grain or from their author. Each arm applies the viewer'snotification_prefin SQL: a kind turned off, or a kind limited to accounts the viewer follows. Reasons, hydration (authorprofileView, gallery title and cover, story thumb, comment and reply text) and the comment-favorite-through-to-its-gallery lookup are hatk's.Bluesky's read model. The list says
seenAt, and each item saysindexedAtandisRead.indexedAtis when the index first held the record, or the record's owncreatedAt(in UTC) if that is earlier. Items are sorted by it, and it is what is compared withseenAt: an item is read whenindexedAt <= seenAt.getUnreadCountcounts items withindexedAt > seenAt, or all of them if the viewer has never calledupdateSeen. The cursor isindexedAt::uri, as the other lists' are. The lexicon changes accordingly:unseenCountis gone from the list,seenAtis added, andindexedAtandisReadare added tonotificationItemand required there. The embedded copies inhatk.generated.tsare updated to match, andlexicons/README.mdnotes how this differs from hatk.mention(migration V6): one row for each account a comment or gallery mentions, byapp.bsky.richtext.facet#mentionfeatures. Triggers oncommentandgallerykeep it up to date through inserts, edits and deletes, and the migration fills it from what the index already holds. It is derived, so a rebuild rebuilds it.The push seam.
notification::arising(db, uri)runs the same arms the other way: for one record, it returns whom that record notifies and why. It applies thepushpreference instead ofin_app.unread_count(db, did)is the badge number. The push step calls these for each record the stream creates. Neither push norregisterPushis built here.Why
The second half of the notifications step, after
updateSeenand the state tables (#1 of this stack). The user chose Bluesky's model:seenAtandisReadon the list, and the count as a method of its own.Why derive at read time rather than keep a
notificationtable. hatk derived them, and every arm reads by index from the recipient (gallery_didthenfavorite_subject,follow_subject,comment_didthencomment_reply_to,mention's key, and so on). Deriving means nothing to backfill, and nothing goes stale when a record is deleted or an account taken down. A table would mean writing a row for every favorite and keeping it in step with deletes, takedowns, blocks and preferences. The plan check covers all three queries in both directions.Why a
mentiontable. hatk found mentions withfacets LIKE '%' || did || '%'over every comment and every gallery, which reads both tables whole on every call. The plan check refuses that.Where this differs from hatk, and why:
LIMIT, so a page could come back short or empty while a cursor was still offered. Its count ignored preferences, so the badge counted notifications the list hid.story-commentandgallery-comment-mention).LIKEmatched the DID anywhere in the facets, such as inside a link.indexedAt, as Bluesky's do. hatk sorted on the rawcreatedAtstring, so a record dated in the future stayed at the top and stayed unread. A timezone offset also compared wrongly against the seen time.indexedAt::uri. hatk'screated_at < cursorskipped notifications that shared a timestamp.How it was checked
tests/notifications.rsbuilds hatk'snotificationSourcesfixture, plus the cases above, against the reference PDS's account as viewer. Every answer goes throughcommonforanswers.mjs. The tests cover:updateSeen, andisReadat, before and afterseenAt;createdAt;inAppoff,pushoff (still listed), andfrom: followsbefore and after following;limitout of bounds or a malformed cursor refused;arising: a reply that is also a mention notifies two accounts, and the push preference applies where the in-app one does not;mentionfollowing an edit that drops a mention, and a delete.read::tests::the_reads_read_by_indexgains the list, count andarisingqueries and the hydration queries. I ran it,cargo checkand clippy here. CI runs the integration tests, the coverage gate and the answers check.Shortcomings
notificationtable filled as records arrive, which the push step would want anyway.galleryUri/storyUri, as hatk's did.arisingis not called yet: push is a later step.getNotifications. Moving it is the web step's.Review
The design. Start at
ARMSandunioninread/notification.rs, then V6 inindex/schema.rs.🤖 Generated with Claude Code