Push Notifications Design
Overview
Add push notification support across three repos: hatk gains an on-commit hook primitive and push delivery infrastructure, grain wires notification events to push, and grain-native registers for and handles APNs.
hatk: on-commit Hook Primitive
A new hook trigger type that fires after the firehose indexer writes a record to the database. Follows the existing defineHook(event, handler) pattern with an options object for declarative collection filtering.
// server/hooks/on-commit-favorite.ts
export default defineHook(
"on-commit",
{ collections: ["social.grain.favorite"] },
async ({ action, collection, record, repo, uri, db, lookup, push }) => {
// action: "create" | "delete"
// collection: NSID that matched
// record: the record value
// repo: DID of the committing actor
// uri: AT URI of the record
// db: { query, run } — raw SQL access
// lookup: typed record lookup (same as BaseContext)
// push: push delivery interface
},
);
The firehose indexer already processes commits in flushBuffer(). After insertRecord / deleteRecord, it invokes matching on-commit hooks filtered by collections. Multiple hooks can match the same commit. Hooks run async and non-blocking (same pattern as runLabelRules).
hatk: Push Token Registration & Storage
Database
CREATE TABLE _push_tokens (
did TEXT NOT NULL,
token TEXT NOT NULL,
platform TEXT NOT NULL, -- "apns" for now, "fcm"/"web" later
createdAt TEXT NOT NULL,
PRIMARY KEY (did, token)
);
Built-in XRPC Endpoints
social.hatk.push.registerToken— accepts{ token, platform }, associates with authenticated viewer's DID. Handles duplicates via upsert.social.hatk.push.unregisterToken— accepts{ token }, removes it.
Configuration
// hatk.config.ts
push: {
apns: {
keyFile: "./certs/AuthKey_XXXX.p8",
keyId: "XXXX",
teamId: "YYYY",
bundleId: "com.grain.app",
}
}
Push features are disabled entirely if no push config is present.
hatk: push.send() Delivery
The push object is injected into hook handler context:
await push.send({
did: string, // recipient's DID
title: string, // notification title
body: string, // notification body
data?: Record<string, string>, // custom payload (e.g. { uri, type })
collapseId?: string, // APNs collapse-id for grouping
})
Internals:
- Looks up all tokens for the DID in
_push_tokens - Builds APNs payload, sends via HTTP/2 to Apple's push gateway
- If Apple responds with invalid/expired token, deletes it from
_push_tokens(self-cleaning) - Fire-and-forget — delivery failures are logged via
emit()but don't throw - No queuing for v1. Hooks run async after flush (like label rules). A queue can be added behind the same API later.
grain: Hook Implementations
One on-commit hook file per notification trigger:
server/hooks/
on-login.ts (existing)
on-commit-favorite.ts (new)
on-commit-comment.ts (new)
on-commit-follow.ts (new)
Example: Favorites
export default defineHook(
"on-commit",
{ collections: ["social.grain.favorite"] },
async ({ action, record, repo, db, lookup, push }) => {
if (action !== "create") return;
const [gallery] = await db.query(
`SELECT did AS author, uri FROM "social.grain.gallery" WHERE uri = $1`,
[record.subject],
);
if (!gallery || gallery.author === repo) return;
const profiles = await lookup("app.bsky.actor.profile", "did", [repo]);
const actor = profiles.get(repo);
await push.send({
did: gallery.author,
title: "New favorite",
body: `${actor?.value.displayName ?? "Someone"} favorited your gallery`,
data: { type: "gallery-favorite", uri: gallery.uri },
});
},
);
Comments and follows follow the same pattern. Mentions and replies parse facets or look up parent comment authors but use the same shape.
The existing getNotifications query is unchanged — it remains the source of truth for the notification list. Push alerts the user to come look.
grain-native: APNs Registration & Handling
Token Registration Flow
- On app launch (after auth), call
UNUserNotificationCenter.requestAuthorization() - On grant, call
UIApplication.shared.registerForRemoteNotifications() - In
AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken, send the hex-encoded token tosocial.hatk.push.registerTokenvia XRPC client - Re-register on every app launch (token can change; server upserts)
Handling Incoming Pushes
- Tap on a notification reads
data.typeanddata.urifrom the payload - Routes to the appropriate view: gallery detail for favorites/comments, profile for follows
Unregistration
- On logout, call
social.hatk.push.unregisterTokento remove the token server-side
Entitlements
- Add Push Notifications capability in Xcode
- Add
aps-environmententitlement (development/production) - Register bundle ID with APNs in Apple Developer portal
What's NOT in v1
- No FCM / web push (table has
platformcolumn for future use) - No queuing / retry system (async inline delivery, failures logged)
- No notification preferences / muting (all types push to all registered devices)
- No rich notifications (images, actions) — title + body + data only