Notification Pipeline
The single enqueueNotification path: policies, quiet hours, push delivery.
The one rule
Every producer calls the internal mutation enqueueNotification (or enqueueNotificationHelper from within another mutation). Never schedule a push send directly (ctx.scheduler.runAfter to the push action) — that bypasses preferences, quiet hours, unread counting, and the retry pipeline.
Policy evaluation
evaluatePolicy is pure and runs inside the enqueueing mutation, so the preferences read and the insert share one snapshot:
- No preferences row → allow everything (default opt-in).
pushEnabled: falsesuppresses push;inAppEnabled: falsesuppresses in-app.- A disabled type suppresses push only (in-app still shows).
- Quiet hours suppress push only (timezone-aware; evaluated at enqueue time).
The result is stored on the row as policyResult.
Status vs channelStatus
statusdrives the pipeline:pending → sent | failed.channelStatusrecords per-channel truth: inserting the row is in-app delivery (inApp: "delivered"); push startspendingand moves tosent | failed | suppressed.- If every channel is suppressed, status is
sent— nothing to deliver.
Delivery and races
- Push delivery is an internal action on a workpool (3 retries), idempotent on terminal status, sending per active Expo token.
- The workpool enqueue writes an
enqueuedAtclaim; the 5-minute cron re-enqueues unclaimed rows whosescheduledAtpassed. If the cron and a manual process race the same row, the two claims OCC-conflict and only one commits — deliberate duplicate-push protection. - The unread sharded counter increments only when in-app is an effective channel (
countsTowardUnread), and decrements under the same condition — it cannot drift.
Mobile firewall
toMobileFormat() in convex/notification/queries.ts is the field firewall: new backend fields are invisible to the mobile app until explicitly added there.
The app's half of the path, from the permission pre-prompt to push token registration and the Android default channel, is on Mobile Notifications.