fix(notifications): send blast push notifications again - #73
Merged
Conversation
Two bugs prevented chat blast push notifications from being delivered. 1. The dmNotificationMaxAgeMs guard (1h) dropped every blast push. Blast recipients all carry the blast's own created_at rather than a per-recipient time, so once a blast has been working through its audience for longer than maxAgeMs, every remaining recipient permanently fails the check. Blasts are now exempt from the age guard; regular DMs and reactions still honor it. 2. getNewBlasts was missing parentheses around the cursor condition. Because AND binds tighter than OR, the predicate parsed as (chat_allowed(...) AND $cursor IS NULL) OR (to_user_id > $cursor), so chat_allowed() was bypassed entirely once the cursor went non-null — blasts could be sent to users who had not allowed them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getNewBlasts selected created_at bare while DMNotification declares a timestamp field, so notification.timestamp was undefined for every blast. discoveryDB.raw returns any, so tsc never flagged the mismatch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dylanjeffers
added a commit
that referenced
this pull request
Jul 16, 2026
Covers getUnreadMessages, getUnreadReactions, getNewBlasts, and sendDMNotifications with a mocked Knex query recorder and in-memory Redis, including regressions from #73 (blast created_at AS timestamp alias, parenthesized chat_allowed guard) and the blast exemption from the DM max-age cutoff. Exports the three query helpers for direct testing. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Urgency
Blast push notifications are sending zero pushes and have been since the age guard was introduced — every recipient of every blast is silently dropped. This is not a slow degradation: a blast sends no pushes at all, starting from its very first tick.
Bug 1 — blast rows never carried a
timestamp, so the age guard dropped all of themconfig.dmNotificationMaxAgeMs(default 1 hour) filters out notifications whosetimestampis older than the cutoff, so a backlog after downtime doesn't flood users.getUnreadMessagesandgetUnreadReactionsboth alias their time column into the shape the rest of the pipeline expects (chat_message.created_at as timestamp), matching theDMNotificationtype insrc/types/notifications.ts.getNewBlastsdid not. Its raw query ended:The column came back as
created_at, sonotification.timestampwasundefinedon every blast notification. The age guard evaluatesn.notification.timestamp >= sendCutoff, andundefined >= <Date>isfalse— always, regardless of how old the blast actually is. So every blast push was skipped from the first tick onward, not after an hour.numberSkippedTooOldincremented, the cursor advanced, nothing errored. The undefined timestamp also made blasts sort arbitrarily innotificationTimestampComparator.Fix: alias the column, so blasts carry a real timestamp like every other notification type.
Bug 2 — the age guard is wrong for blasts even once the timestamp is populated
With Bug 1 fixed, the age guard now actually engages for blasts — and it would be wrong to let it. Every recipient row carries
chat_blast.created_at, which is when the blast was authored, identical across the whole audience; it is not when that recipient's row was queued. A blast fans out in batches ofblastUserBatchSize(100) per tick, so any blast whose audience takes longer thanmaxAgeMsto work through would cross the cutoff and then drop every remaining recipient.Fix: exempt blasts from the age guard. Blasts are an async fan-out by design and shouldn't face a recency check meant for live messages. Regular DMs and reactions still honor the cap.
Bugs 1 and 2 are independent, and both must land for blasts to deliver reliably: Bug 1 blocks delivery outright; Bug 2 would block it for large audiences once Bug 1 is fixed.
Bug 3 — missing parens in
getNewBlastsbypassedchat_allowedThe WHERE clause read:
ANDbinds tighter thanOR, so Postgres parsed this as(chat_allowed(...) AND $cursor IS NULL) OR (to_user_id > $cursor). On the first page the cursor is null and the left branch applies, but on every subsequent page the cursor is non-null and the predicate collapses toto_user_id > $cursor—chat_allowedis not evaluated at all, so recipients past the first batch could receive a blast they hadn't permitted.This stayed latent precisely because of Bug 1: those pushes were dropped before delivery. Fixing Bug 1 without this would start delivering to users who never allowed the sender, which is why they ship together.
Fix: parenthesize the cursor condition.
Also included
getUnreadMessagesandgetUnreadReactionswrapped the indexed column indate_trunc('milliseconds', ...), which prevents the index from being used. The truncation moves to the bounds instead, preserving the same millisecond semantics while keepingchat_message.created_atandchat_message_reactions.updated_atindexable.console.logcalls that serialized entire notification arrays on every tick.Testing
npm run typecheckpasses andeslint src/tasks/dmNotifications.tsis clean.There is no existing test coverage for
dmNotifications.ts— no harness exists for the Redis cursors plus the discovery/identity DB fan-out — so nothing to update, and the fixes here are not covered by an automated test. Bug 1 in particular is the kind of shape mismatch a single unit test overgetNewBlasts' result would have caught, so coverage for the blast cursor path is worth following up on separately.🤖 Generated with Claude Code