MemberJunction / MemberJunction/MJ
Feature: Configurable real-time entity change notifications (cross-tab, user, org scopes)
- Dominant language
- TSQL
- Stars
- 29
- Forks
- 6
- Avg merge
- 2d 1h
- Merged PRs (30d)
- 323
Description
## Summary
The current real-time status push system (`PushStatusResolver` + `ListenForEntityMessages`) is scoped to a single browser session. When User A saves an entity, only the exact tab/session that initiated the save receives the WebSocket notification. Other tabs for the same user — and all other users — are unaware of the change until they manually refresh.
This feature request proposes expanding the notification system to support configurable scopes:
- **Same-user, cross-tab**: All sessions for the same user receive notifications
- **Same-organization / tenant**: All users in an org receive notifications
- **Per-entity opt-in**: Entity-level metadata controlling which entities participate in broadcast notifications
---
## Current Architecture
### How it works today
1. Client establishes a WebSocket connection via `graphql-ws` and subscribes to `statusUpdates(sessionId: $sessionId)`
2. When a GraphQL mutation (Create/Update/Delete) executes, `ResolverBase.ListenForEntityMessages()` subscribes to `MJGlobal.Instance.GetEventListener()`
3. On entity save, `BaseEntity` raises a `BaseEntityEvent` on the MJGlobal event bus
4. The listener in `ResolverBase` publishes to `PUSH_STATUS_UPDATES_TOPIC` with `{ message, sessionId: userPayload.sessionId }`
5. `PushStatusResolver`'s subscription filter (`payload.sessionId === args.sessionId`) ensures only the originating session receives the message
### Key files
| File | Role |
|------|------|
| `packages/MJServer/src/generic/PushStatusResolver.ts` | GraphQL subscription resolver with session-scoped filter |
| `packages/MJServer/src/generic/ResolverBase.ts` | `ListenForEntityMessages()` — subscribes to MJGlobal events and publishes to PubSub |
| `packages/MJServer/src/index.ts` | WebSocket server setup, PubSub initialization |
| `packages/GraphQLDataProvider/src/graphQLDataProvider.ts` | Client-side `PushStatusUpdates()` method |
| `packages/MJGlobal/src/Global.ts` | Process-local RxJS event bus |
---
## Existing Bug: Closure-Captured SessionId in ListenForEntityMessages
`ListenForEntityMessages` (ResolverBase.ts) deduplicates MJGlobal event subscriptions by **entity type name** using a process-global `EventSubscriptions` Map. It captures `userPayload.sessionId` in a closure when the subscription is first registered for a given entity type.
**The problem**: If User A saves an `Org` entity first (registering the subscription), then User B later saves an `Org` entity, the notification is published with **User A's sessionId** — because the closure still references User A's `userPayload`. User B receives nothing; User A may receive a spurious notification.
This bug should be fixed as a prerequisite to (or as part of) any broader scoping work. The fix naturally falls out of the proposed architecture changes below.
---
## Proposed Changes
### Phase 1: Same-User Cross-Tab Notifications (+ Bug Fix)
**Goal**: All browser tabs for the same authenticated user receive entity change notifications.
#### Server — `ResolverBase.ListenForEntityMessages`
This is the most architecturally significant change. The current approach of closure-capturing a single `userPayload` per entity type must be replaced.
**Proposed approach**: Instead of publishing with the closure-captured `sessionId`, extract the user identity from the entity event itself at fire time:
```typescript
// Current (broken for multi-user):
pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
message: msg,
sessionId: userPayload.sessionId // closure-captured, stale
});
// Proposed:
const contextUser = baseEntityEvent.baseEntity.ContextCurrentUser;
pubSub.publish(PUSH_STATUS_UPDATES_USER_TOPIC, {
message: msg,
userId: contextUser?.ID,
sessionId: userPayload.sessionId // keep for backwards compat
});
```
`BaseEntity.ContextCurrentUser` is set to the requesting user's `UserInfo` in the server context and is available on the MJGlobal event's `baseEntity` — the data is already there, just not being used.
**Note**: `ContextCurrentUser` may be `null` for background jobs or system-user saves. The publishing logic should handle this gracefully (fall back to session-scoped or skip broadcast).
#### Server — `PushStatusResolver`
Add a new subscription endpoint (or extend the existing one):
```typescript
// Option A: New endpoint (better for backwards compatibility)
@Subscription(() => PushStatusNotification, {
topics: PUSH_STATUS_UPDATES_USER_TOPIC,
filter: ({ payload, context }) => {
// Security: verify the authenticated user matches the payload's userId
return payload.userId === context.userPayload.userRecord.ID;
},
})
userStatusUpdates(@Root() notification): PushStatusNotification { ... }
// Option B: Extend existing endpoint with scope argument
@Subscription(() => PushStatusNotification, {
topics: [PUSH_STATUS_UPDATES_TOPIC, PUSH_STATUS_UPDATES_USER_TOPIC],
filter: ({ payload, args, context }) => {
if (args.scope === 'user') {
return payload.userId === context.userPayload.userRecord.ID;
}
return payload.sessionId === args.sessionId;
},
})
statusUpdates(@Root() notification, @Arg('scope') scope: string, ...): PushStatusNotification { ... }
```
**Security consideration**: The filter must validate the subscription against the authenticated user from the WebSocket context — not just trust a client-supplied `userId` argument. The WebSocket context already has `userPayload` from JWT authentication, so this is straightforward.
#### Server — WebSocket Context
Currently, the WebSocket `connectionParams` only passes `Authorization`. The `x-session-id` header is NOT sent in WebSocket `connectionParams`. For user-scoped subscriptions this is fine (userId comes from JWT), but if session-scoped subscriptions need to coexist, consider also passing `sessionId` in `connectionParams`.
#### Client — `GraphQLDataProvider`
Add a new `PushUserStatusUpdates()` method (or extend `PushStatusUpdates` with a scope parameter). The new method would:
- Subscribe to the `userStatusUpdates` GraphQL subscription
- Reuse the existing WebSocket connection (already shared via `getOrCreateWSClient()`)
- Pipe messages to an RxJS Subject, same pattern as current implementation
#### Sizing: **Medium PR** (~5-6 files across server + client)
---
### Phase 2: Organization/Tenant-Scoped Notifications
**Goal**: All users within the same organization or tenant receive entity change notifications.
#### Additional Server Changes
- New topic: `PUSH_STATUS_UPDATES_ORG_TOPIC`
- `ListenForEntityMessages` extracts `orgId` from `ContextCurrentUser.TenantContext?.TenantID` (if multi-tenancy middleware is configured) or from a custom lookup
- New subscription endpoint `orgStatusUpdates(orgId: ID!)` with a filter that validates the authenticated user belongs to the requested org
- Security guard must verify org membership, not just trust the client-supplied `orgId`
#### Dependency on Multi-Tenancy
`UserInfo.TenantContext` is only populated when multi-tenancy middleware is configured. Deployments without multi-tenancy would need an alternative org ID source (e.g., `LinkedEntityRecordID` lookup or a custom field).
#### Sizing: **Medium-Large PR** (requires multi-tenancy + security guards)
---
### Phase 2 Addendum: Per-Entity Broadcast Opt-In
**Goal**: Allow entity metadata to control which entities participate in broadcast notifications, so high-traffic entities (audit logs, telemetry, etc.) don't flood subscribers.
#### Proposed Approach
- New boolean field on `EntityInfo`: `AllowBroadcastNotifications` (default: `false` or `true` depending on desired default behavior)
- `ListenForEntityMessages` checks this flag before publishing to user/org-scoped topics
- Session-scoped notifications (original behavior) should remain unaffected by this flag
#### Alternative (no schema change)
An allowlist/denylist in server configuration (e.g., environment variable or config file) could serve as a short-term substitute without requiring a schema migration + codegen cycle.
#### Sizing: **Large PR** if schema-based (migration + codegen + server logic), **Small PR** if config-based
---
### Infrastructure Consideration: Distributed PubSub
The current PubSub engine is `graphql-subscriptions` v2 — **purely in-memory, single-process**. This means:
- In a multi-instance deployment (load-balanced MJAPI), a save on Server A only notifies clients connected to Server A
- Server B's clients receive nothing
For single-instance deployments this is fine. For horizontally-scaled deployments, the PubSub would need to be backed by an external broker.
#### Options
1. **`graphql-redis-subscriptions`**: Drop-in replacement for the `PubSub` instance. The `PubSubEngine` interface is compatible. Requires adding a Redis dependency (note: Redis is already used in MJ for caching via `RedisLocalStorageProvider`).
2. **Leverage existing Redis infrastructure**: The `RedisLocalStorageProvider` already uses Redis pub/sub for `cacheInvalidation` events. A similar pattern could route status update events through Redis and into the local `PubSubManager`.
#### Sizing: **Medium PR** but operationally significant (new runtime dependency for PubSub path)
---
## Data Already Available
A key finding from the codebase analysis: **most of the data needed for broader scoping already exists** on entity events — it's just not being used in the publishing path.
| Data Point | Where It Lives | Available Today? |
|-----------|---------------|-----------------|
| `sessionId` | `userPayload.sessionId` (HTTP header) | Yes (but closure-captured incorrectly) |
| `userId` | `baseEntity.ContextCurrentUser.ID` | Yes |
| `userEmail` | `baseEntity.ContextCurrentUser.Email` | Yes |
| `tenantId` / `orgId` | `ContextCurrentUser.TenantContext?.TenantID` | Yes (if multi-tenancy configured) |
| Entity type | `baseEntity.EntityInfo.Name` | Yes |
| Record ID | `baseEntity.PrimaryKey` | Yes |
| Save type | `baseEntityEvent.saveSubType` ('create' / 'update') | Yes |
---
## Recommended Implementation Order
1. **Fix the closure-capture bug** in `ListenForEntityMessages` — this is a correctness issue independent of new features
2. **Same-user cross-tab** — lowest-hanging fruit, biggest UX win, forces the architectural fix from step 1
3. **Per-entity opt-in** (config-based initially) — prevents notification storms before enabling broader scopes
4. **Org/tenant-scoped notifications** — builds on the user-scoped foundation
5. **Redis-backed PubSub** — only needed for multi-instance deployments
---
## Questions for Discussion
- Should the existing `statusUpdates` subscription remain unchanged for backwards compatibility, or is a breaking change acceptable?
- What should the default broadcast scope be for new deployments?
- Should per-entity opt-in default to broadcasting enabled or disabled?
- Is there interest in including the entity type, record ID, and change type in the notification payload? This would allow clients to do targeted refreshes instead of blanket re-fetches.
- For org-scoped notifications, should there be a mechanism beyond `TenantContext` for determining org membership?
Contributor guide
Assessment
This issue has not been assessed yet.