Skip to content

Let notifications collapse repeat events onto one row - #704

Merged
ddon merged 1 commit into
BeamLabEU:mainfrom
alexdont:notifications-upsert
Aug 11, 2026
Merged

Let notifications collapse repeat events onto one row#704
ddon merged 1 commit into
BeamLabEU:mainfrom
alexdont:notifications-upsert

Conversation

@alexdont

Copy link
Copy Markdown
Contributor

From the integration report, item #3.

The gap

"3 new comments on earlier chapters" is what every mature product does with a
run of related events, and there was no way to say it. create_inapp/2 only
ever inserts, and the only broadcast fired on insert.

So a host that wanted collapsing reached past the API: queried the
Notification schema directly, update_alled the row, then re-broadcast
:notification_created by hand. One that tried it with a schemaless write
stringified metadata into the jsonb column and 500'd that user's bell — which
is the cost of leaving a schema as the only way to do something.

upsert_inapp/3

Takes a key: refresh the row already standing for it, or post a new one.

upsert_inapp(user_uuid,"comments:chapter:42",%{text: "3 new comments"})

Text, icon and link are replaced; other metadata keys are merged, so keys
the caller doesn't mention survive. inserted_at moves to now, so a refreshed
entry sorts as new.

The rule worth stating: only unseen rows collapse

Folding a new event into something the user has already read would hide it
inside a row they've finished with, and the unread count wouldn't move. That's
the same reasoning as the unseen-first ordering in #702. Once read, the next
event is news again and gets its own row.

Dismissed rows are never resurrected either, and a row that vanishes between the
read and the write posts a fresh one rather than losing the event.

Broadcasting

{:notification_created, n} for a new row, {:notification_updated, n} for a
refresh — so an already-open bell reflects it without the caller broadcasting
anything. NotificationsBell handles the new event; Events' docs list it.

Testing

Seven integration tests, including the three refusals (seen, dismissed, other
user) and the merge-not-replace behaviour.

Two mutations checked: collapsing into read rows fails the suite; replacing
metadata instead of merging fails it twice.

Writing them caught two real errors before this went anywhere — updated_at
isn't a column on this schema, and the row-count guard on update_all was the
only thing standing between a dismissed notification and resurrection.

149 notification + LiveView tests pass, credo --strict clean, compiles with
--warnings-as-errors. No version bump or CHANGELOG entry.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NTj7hm3fpCTcFvKLRtgppW

"3 new comments on earlier chapters" is what every mature product does with
a run of related events, and there was no way to say it. `create_inapp/2`
only ever inserts, and the only broadcast fired on insert — so a host that
wanted collapsing queried the Notification schema directly, `update_all`ed
the row, and re-broadcast `:notification_created` by hand. One that tried it
with a schemaless write stringified `metadata` into the jsonb column and
500'd that user's bell.
`upsert_inapp/3` takes a key: refresh the row already standing for it, or
post a new one. Text, icon and link are replaced, other metadata keys are
merged, and `inserted_at` moves to now so a refreshed entry sorts as new.
Only UNSEEN rows collapse, which is the rule worth stating. Folding a new
event into something the user has already read would hide it inside a row
they have finished with, and the unread count would not move — the same
reasoning as unseen-first ordering. Once read, the next event is news again
and gets its own row.
Broadcasts either way, `:notification_updated` for a refresh, so a bell
that is already open reflects it without the caller broadcasting anything.
Dismissed rows are never resurrected, and a row that disappears between the
read and the write posts a fresh one rather than losing the event.
Two mutations checked: collapsing into read rows fails the suite, and
replacing metadata instead of merging fails it twice. Writing the tests
also caught two real errors — `updated_at` isn't a column on this schema,
and the row-count guard was the only thing standing between a dismissed
notification and resurrection.
@ddon
ddon merged commit efa76c6 into BeamLabEU:mainAug 11, 2026
ddon pushed a commit that referenced this pull request Aug 11, 2026
Review docs for all three PRs in dev_docs/pull_requests/2026/.
#704 — two bugs on the read-then-write race in `upsert_inapp/3`:
* The `update_all` filtered on `uuid` alone, so the unseen/undismissed rule
held only in the read. A row dismissed or read in between was refreshed
anyway — the update reported success and the event ended up recorded only
on a row that will never be shown again. The guard now appears in the
write too, which also makes the fallback reachable for the reason it was
written.
* The fallback posted the replacement row without its dedupe key, so it
could never be found again: every later event for that key opened a new
row. One lost race turned collapsing off permanently.
#705 — the level-follows-consequence rule was applied to one branch of three.
Strict mode refuses every mismatch (`PhoenixKitWeb.Users.Auth` answers
`{:warning, _}` exactly as it answers `{:error, _}`), so a strict-mode host
logging someone out over a browser update recorded it at `:info` — below the
default threshold, i.e. not at all. Extracted `log_mismatch/2`; non-strict
behaviour is unchanged in all three branches, which the untouched existing
tests demonstrate.
#703 — the warning was emitted with `IO.warn/1`, which registers a compiler
diagnostic and so failed the build on any host using `--warnings-as-errors`,
on upgrade, over a mix.exs condition that is not a regression in their code.
That contradicted the PR's own guarantee and the rescue guards written to
back it up. Now written straight to stderr; pinned by a test asserting no
diagnostic is registered.
Also updates deps (phoenix 1.8.10, hackney 4.7.3) and corrects the
`version/0` doc example, which still claimed "1.3.3".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@alexdont@ddon