feat(conversations): epoch sync and system events for group membership - #425
Merged
codebestia merged 2 commits intoJul 30, 2026
Merged
Conversation
Membership changes and epoch transitions now go into an ordered, gap-free per-conversation log, so a client that missed commits can fetch exactly what it missed and converge on the same epoch as everyone else. - Chat messages are ordered by (createdAt, id), which is fine for a timeline but wrong for group control: applying a join and a leave in the wrong order derives a different key schedule, and a timestamp cursor can silently skip an event written slightly out of clock order. group_control_events therefore carries its own strictly monotonic sequence, and conversations.epoch records the resulting epoch. - Sequencing is serialized on the conversation row: the epoch is bumped with UPDATE ... RETURNING in the same transaction that assigns the sequence, so a concurrent join and leave are forced into a real order. The unique index on (conversationId, sequence) is the backstop, not the mechanism. - The membership row and its control event commit together. A member written without the epoch bump announcing them would leave every other client unaware of someone who can now decrypt, which is the divergence this log exists to prevent. The live broadcast follows the commit, so a client reacting to the event always finds the membership already in place. - Every event persists a content_type='system' message in one stable shape and links to it, so the timeline and the control log cannot disagree. Live fan-out emits group_system_event, epoch_changed and new_message, the last so existing timeline rendering picks it up unchanged. - GET /conversations/:id/epoch answers "am I behind?" as an integer compare. GET /conversations/:id/group-control returns missed events in replay order with an exclusive cursor, so replaying the same cursor never re-applies an event. POST /conversations/:id/group-control sequences a client MLS commit; the payload is opaque and capped, since the server orders group control rather than interpreting it. - The last member leaving emits nothing: the conversation and its log are deleted, so there is nobody left to reconcile. docs/group-epoch-sync.md documents the log, the endpoints and the catch-up protocol.
|
@noevidence1017 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
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.
closes #369
Summary
Membership changes and epoch transitions now go into an ordered, gap-free per-conversation log. A client that missed commits fetches exactly what it missed, replays it in order, and converges on the same epoch as everyone who was online.
Previously a join emitted a fire-and-forget
member_joinedsocket event and nothing else. A client that was offline for it had no way to learn the group had changed, and no notion of an epoch to reconcile against.Why a separate log
Chat messages are ordered by
(createdAt, id). That is fine for a timeline and wrong for group control:So group control gets
group_control_eventswith a strictly monotonic, gap-freesequenceper conversation, andconversations.epochrecords where the group stands. "Am I behind?" becomes an integer comparison; "catch me up" becomessequence > mine, which cannot skip.epochon each row is the value after the event, so a client compares its own epoch against the newest row and knows exactly how far behind it is.Correctness
Ordering under concurrency. The epoch is bumped with
UPDATE ... RETURNINGin the same transaction that assigns the sequence. That update row-locks the conversation, so a concurrent join and leave are forced into a real order rather than racing for the same sequence number. The unique index on(conversationId, sequence)is the backstop, not the mechanism.Atomicity with the membership change. The membership row and its control event are written in one transaction (
appendGroupControlEventaccepts the caller's transaction handle). A member committed without the epoch bump that announces them would leave every other client unaware of someone who can now decrypt — precisely the divergence this log exists to prevent. The live broadcast happens only after the commit, so a client reacting to the event always finds the membership already in place.Last member leaving emits nothing. That deletes the conversation and its control log, so there is nobody left to reconcile and nothing to reconcile against.
System events
Every control event also persists a
content_type='system'message and links to it by id, so the timeline and the control log can never disagree. The body is one stable shape:{ "type": "group_control", "eventType": "member_added", "conversationId": "…", "epoch": 4, "sequence": 7, "actorUserId": "…", "targetUserId": "…" }Like the existing device-change system messages it is stored unencrypted and carries no private content — only who changed what and the resulting epoch.
Live fan-out, to both the optimized room and the plain conversation id:
group_system_event— the full control event.epoch_changed—{ conversationId, epoch, sequence }for clients that only need to know they must reconcile.new_message— the system message, so existing timeline rendering picks it up with no client change.Both new types are registered in
KNOWN_EVENT_TYPES.Endpoints
The cheap "am I behind?" check.
Missed events ascending by sequence — the order to replay in.
sinceSequenceis exclusive, so replaying with the same cursor never re-applies an event the client already has; omitting it returns the whole log, the first-sync path. Page size defaults to 100, clamped to 500.currentEpoch/latestSequencedescribe where the group is now, so a client knows whether the page finished the catch-up before it even looks athasMore.Members only, payload capped at 64 KiB and stored byte-for-byte. The server sequences group control; it does not interpret it.
All three are membership-gated. The join response additionally carries the resulting
epochandsequence, so the caller needs no follow-up request.Acceptance criteria
GET /conversations/:id/group-control, ascending by gap-free sequence, exclusive cursor, paged without dropping eventscontent_type='system'message per event, linked to the control row, plusgroup_system_event/epoch_changed/new_messageliveTesting
apps/backend/src/__tests__/groupControl.test.tsadds 17 tests over an in-memory store: epoch bump and sequence assignment, gap-free monotonicity across events, the system message and its back-link, opaque payload passthrough, failing loudly on a missing conversation, the three broadcast channels, the no-socket-server case, the catch-up read (after-cursor, exclusive cursor, full log, paging without gaps), and two convergence tests — a client replaying from zero lands on the current epoch, and a replaying client lands on the same epoch as one that saw the events live.conversations.routes.test.tsgains 12 tests for the routes: membership gating on all three endpoints, cursor validation, payload size and emptiness, the sequenced join and leave, the response shape, and the assertion that the last member leaving records no event.Full backend suite: 427 tests across 42 files, all passing.
eslintclean,prettier --checkclean.Migration
apps/backend/drizzle/0001_group_control_events.sql— new enum, thegroup_control_eventstable with its unique(conversation_id, sequence)index, andconversations.epochdefaulting to 0. Additive; existing conversations start at epoch 0 with an empty log, which is exactly what a client with no cursor expects.Note: the audit-logging work for #376 also lands a
0001_migration. If both merge, one journal entry needs renumbering — the two migrations touch entirely separate tables and do not otherwise conflict.docs/group-epoch-sync.mddocuments the log, the ordering guarantees, the socket events and the catch-up protocol.