Skip to content

fix: bind Participant identity to the connection, not the token (#88) - #89

Merged
izolyte merged 2 commits into
mainfrom
fix/88-participant-connection-identity
Jul 19, 2026
Merged

fix: bind Participant identity to the connection, not the token (#88)#89
izolyte merged 2 commits into
mainfrom
fix/88-participant-connection-identity

Conversation

@izolyte

@izolyte izolyte commented Jul 19, 2026

Copy link
Copy Markdown
Owner

The Room socket dropped on every reconnect and refresh. The wss handshake completed (101) and the token authenticated, but the server then immediately sent a socket.io DISCONNECT and the client sat on Reconnecting… until it exhausted its budget and read Lost connection.

Root cause

Participant.tokenHash was @unique and RoomPresenceService.recordJoin always called participant.create(). The client stores the Room Token (roomSessionService.store) and replays the same token on every (re)connect — F5 replays the stored token and socket.io's reconnection: true replays it automatically. recordLeave only sets disconnectedAt, never freeing the hash, so the stale row blocked the next insert with Prisma P2002. The design used the token as participant identity when it should have used the connection.

This broke, in order of severity:

  • Any transient reconnect (Wi-Fi blip, sleep, tab throttle) killed the Room permanently — refresh then failed the same way.
  • Refresh for both Sender and Receiver.
  • Two Receivers joining in the same second got byte-identical JWTs (second-granularity iat, no nonce) → same hash → the second couldn't connect.
  • Phantom receiverCount after a crash/redeploy left disconnectedAt = null rows counted forever.

Fix — a Participant is one connection, not one token

  • Drop the unique index on tokenHash (migration); keep the column as an audit trail of who joined.
  • Each connection inserts its own row (own id, already tracked per socket.id in the gateway), so reconnects and same-second joins can't collide.
  • Startup reconciliation (onApplicationBootstrap) marks rows left connected by an ungraceful shutdown as disconnected, clearing phantom counts. Assumes a single api instance — commented as such for when that changes.
  • Removes the now-dead findByTokenHash from the repository.

Verified

Local: jest (235 pass, incl. new reconnect + startup-sweep specs on RoomPresenceService), eslint, tsc --noEmit all green. Migration is a plain DROP INDEX, applied by the existing prisma migrate deploy on boot. Live socket reconnect against the deployed stack not yet exercised — verify after deploy by refreshing a Room and confirming the socket reconnects instead of looping.

Closes #88

Summary by CodeRabbit

  • Bug Fixes

    • Improved participant handling during reconnects, allowing repeated connections to be tracked independently.
    • Automatically clears stale connected-participant records when the service starts after an unexpected shutdown.
    • Preserved accurate room presence counts across reconnects and restarts.
  • Tests

    • Added coverage for reconnect behavior and startup cleanup scenarios.

The Room socket dropped every reconnect and refresh: Participant.tokenHash
was @unique and recordJoin always create()d, but the client replays the
stored Room Token on each (re)connect (F5, socket.io retry). The stale row
blocked the next insert with P2002, so the gateway disconnected the socket
and the client looped on "Reconnecting…" until it gave up.

A Participant is one connection, not one token:
- drop the unique index on tokenHash (kept as an audit column)
- each connection inserts its own row (own id, already tracked per socket),
  so reconnects and two Receivers joining in the same second never collide
- startup reconciliation marks rows left connected by an ungraceful shutdown
  as disconnected, clearing phantom receiver counts after a crash/redeploy

Drops the now-dead findByTokenHash from the repository.

Closes #88
@izolyte izolyte added this to the Phase 1 milestone Jul 19, 2026
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@izolyte, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 55 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 651e1b30-dcb1-4791-9d8c-7bfaf3814b93

📥 Commits

Reviewing files that changed from the base of the PR and between 91556a1 and 02a2cac.

📒 Files selected for processing (1)
  • apps/api/prisma/migrations/20260719000000_participant_tokenhash_drop_unique/migration.sql
📝 Walkthrough

Walkthrough

Participant.tokenHash is no longer unique, allowing repeated connections to create separate participant rows. Repository implementations now bulk-disconnect stale participants, and RoomPresenceService runs this reconciliation during application bootstrap with test coverage for reconnects and startup state.

Changes

Participant presence lifecycle

Layer / File(s) Summary
Allow repeated participant tokens
apps/api/prisma/schema.prisma, apps/api/prisma/migrations/.../migration.sql
Removes the uniqueness constraint from Participant.tokenHash and documents its repeated use across connections and sessions.
Add bulk disconnect repository operation
apps/api/src/domain/participant/participant.repository.ts, apps/api/src/domain/participant/participant.repository.fake.ts, apps/api/src/infrastructure/persistence/repositories/prisma-participant.repository.ts
Removes token-hash lookup and adds markAllDisconnected, updating currently connected participants and returning the affected count.
Reconcile presence during bootstrap
apps/api/src/room/room-presence.service.ts, apps/api/src/room/room-presence.service.spec.ts
Runs stale-participant cleanup on application bootstrap, logs reconciled rows, and tests repeated-token rejoins plus clean and stale startup state.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RoomPresenceService
  participant ParticipantRepository
  participant ParticipantDatabase
  RoomPresenceService->>ParticipantRepository: markAllDisconnected(new Date())
  ParticipantRepository->>ParticipantDatabase: update connected participants
  ParticipantDatabase-->>ParticipantRepository: updated row count
  ParticipantRepository-->>RoomPresenceService: reconciled count
Loading

Possibly related PRs

  • izolyte/synxor#53: Introduces the related room presence and participant repository join/leave flows.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the main change: binding participant identity to the connection instead of the token.
Linked Issues check ✅ Passed The changes remove the tokenHash unique collision, support reconnects/duplicate joins, and add startup reconciliation as required by #88.
Out of Scope Changes check ✅ Passed The PR stays focused on participant identity, reconnect handling, and cleanup, with tests and schema/repository updates only.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/88-participant-connection-identity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/api/prisma/migrations/20260719000000_participant_tokenhash_drop_unique/migration.sql (1)

4-4: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid locking the table during index deletion.

A standard DROP INDEX acquires an ACCESS EXCLUSIVE lock, which will block all read and write queries on the Participant table until the drop completes. For a live database, consider dropping the index concurrently to prevent downtime.

Note that DROP INDEX CONCURRENTLY cannot be executed inside a database transaction. In Prisma, you can instruct the migration engine not to wrap this specific script in a transaction by adding -- prisma DisableTransaction at the top of the file.

💡 Proposed change
+-- prisma DisableTransaction
 -- DropIndex
 -- tokenHash is no longer a participant identity: each socket connection is its
 -- own Participant row, so the same token legitimately repeats across reconnects.
-DROP INDEX "Participant_tokenHash_key";
+DROP INDEX CONCURRENTLY "Participant_tokenHash_key";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/api/prisma/migrations/20260719000000_participant_tokenhash_drop_unique/migration.sql`
at line 4, Update the migration statement dropping "Participant_tokenHash_key"
to use concurrent index deletion, and add Prisma’s DisableTransaction directive
at the top of the migration so it runs outside a transaction.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@apps/api/prisma/migrations/20260719000000_participant_tokenhash_drop_unique/migration.sql`:
- Line 4: Update the migration statement dropping "Participant_tokenHash_key" to
use concurrent index deletion, and add Prisma’s DisableTransaction directive at
the top of the migration so it runs outside a transaction.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c74a7bff-2575-41be-be4a-c05df66645b2

📥 Commits

Reviewing files that changed from the base of the PR and between 9ee52d1 and 91556a1.

📒 Files selected for processing (7)
  • apps/api/prisma/migrations/20260719000000_participant_tokenhash_drop_unique/migration.sql
  • apps/api/prisma/schema.prisma
  • apps/api/src/domain/participant/participant.repository.fake.ts
  • apps/api/src/domain/participant/participant.repository.ts
  • apps/api/src/infrastructure/persistence/repositories/prisma-participant.repository.ts
  • apps/api/src/room/room-presence.service.spec.ts
  • apps/api/src/room/room-presence.service.ts

@izolyte

izolyte commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

Applied — switched to DROP INDEX CONCURRENTLY with -- prisma DisableTransaction (02a2cac). The index is a plain unique index, not constraint-backed, so it drops concurrently without an ALTER TABLE path.

@izolyte
izolyte merged commit 31588c1 into main Jul 19, 2026
6 checks passed
@izolyte
izolyte deleted the fix/88-participant-connection-identity branch July 19, 2026 17:08
Sign up for free to 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.

fix: Room socket dies on reconnect/refresh — Participant.tokenHash unique collision

1 participant