Real-Time Notification System - Developer Guide¶
Overview¶
The LBS Foundry notification system provides real-time WebSocket-based notifications for database entity changes. It eliminates the need for polling and enables sub-100ms latency for live match updates, collaborative editing, and cache invalidation.
Architecture¶
Marten (Foundry API) → ContractChangeListener → Redis Pub/Sub → NotificationService
↓
WebSocket Server(s)
↓
React Clients
Key Components¶
- Marten ContractChangeListener: Automatically detects IContract changes in Foundry API
- Redis Pub/Sub: Distributed message bus (channel:
foundry:db_changes) - Notification Service: Standalone WebSocket server (
LBS.NotificationService) - TypeScript SDK: Framework-agnostic
FoundryNotificationClient(wrap it in your own React/Svelte bindings) - Python SDK: Async client library for backend services and scripts
Quick Start¶
TypeScript Client¶
import { FoundryNotificationClient } from '@luckboxstudios/foundry-sdk';
const client = new FoundryNotificationClient({
url: 'wss://api.ballr.live/ws',
token: clerkToken
});
client.on('notification', (msg) => {
console.log('Entity changed:', msg.type, msg.id);
});
await client.connect();
await client.subscribe('RugbyGameState', 'match-123');
// Subscribe to ALL entities of a type (wildcard)
await client.subscribeToType('Team');
Python Client¶
import asyncio
from foundry_sdk import FoundryNotificationClient, NotificationClientConfig
async def main():
client = FoundryNotificationClient(NotificationClientConfig(
url='wss://api.ballr.live/ws',
token=auth_token
))
client.on('notification', lambda msg: print(f"Entity changed: {msg.type} {msg.id}"))
await client.connect()
await client.subscribe('RugbyGameState', 'match-123')
# Subscribe to ALL entities of a type (wildcard)
await client.subscribe_to_type('Team')
# Keep connection alive
while True:
await asyncio.sleep(1)
asyncio.run(main())
React¶
Note: The SDK does not currently provide a
useNotificationsReact hook. It exports only the framework-agnosticFoundryNotificationClient. Wrap that client in your own hook or effect as shown below.
import { useEffect, useRef, useState } from 'react';
import {
FoundryNotificationClient,
type NotificationMessage,
} from '@luckboxstudios/foundry-sdk';
function MatchPage({ matchId }: { matchId: string }) {
const clientRef = useRef<FoundryNotificationClient | null>(null);
const [lastNotification, setLastNotification] = useState<NotificationMessage | null>(null);
useEffect(() => {
const client = new FoundryNotificationClient({
url: 'wss://api.ballr.live/ws',
token: clerkToken,
});
clientRef.current = client;
client.on('notification', (msg) => setLastNotification(msg));
let active = true;
void client.connect().then(() => {
if (active) {
void client.subscribe('RugbyGameState', matchId);
}
});
return () => {
active = false;
void client.disconnect();
clientRef.current = null;
};
}, [matchId]);
// lastNotification updates whenever a notification arrives
}
WebSocket Protocol¶
Message Types¶
Client → Server¶
Subscribe
{
"messageType": "subscribe",
"type": "Team",
"id": "team-123",
"subscriptionId": "optional-client-id"
}
Unsubscribe
Ping (Heartbeat)
Server → Client¶
Notification (Minimal)
{
"messageType": "notification",
"type": "Team",
"id": "team-123",
"changeType": "updated",
"payloadType": "minimal",
"timestamp": "2025-10-13T10:30:00Z"
}
Notification (Full Payload)
{
"messageType": "notification",
"type": "RugbyGameState",
"id": "match-123",
"changeType": "updated",
"payloadType": "full",
"data": {
"homeScore": 24,
"awayScore": 18,
"period": "SecondHalf",
"minute": 67
},
"timestamp": "2025-10-13T10:30:00Z"
}
Subscription Success
Error
Authorization¶
Contract-Based Authorization¶
Authorization is determined by the contract's [RequiresRoles] attribute. The system uses the same security infrastructure as query/command authorization.
Default Behavior: - All entity types require authentication (no anonymous subscriptions) - Authorization is checked against the contract's role requirements
Authorization Rules:
// Public entities (anyone can subscribe if authenticated)
[RequiresRoles(RoleDefinition.Public)]
public sealed record TeamContract : IContract { }
// Member entities (requires Member role or higher)
[RequiresRoles(RoleDefinition.Member)]
public class UserContract : IContract { }
// Admin entities (requires Admin role)
[RequiresRoles(RoleDefinition.Admin)]
public sealed record ServiceAccountContract : IContract { }
Authorization Flow:
- Client subscribes:
subscribe('Team', 'team-123') - System resolves
'Team'→TeamContracttype - System checks
TeamContracthas[RequiresRoles(RoleDefinition.Public)] - System validates user has required role via role hierarchy
- Subscription allowed/denied
Adding New Entity Types¶
-
Define contract with
[RequiresRoles]attribute: -
Ensure contract implements
IContract: Required for automatic change detection -
No configuration needed: Authorization is code-based, not config-based
-
ContractChangeListener automatically detects changes: All
IContracttypes are monitored
Payload Strategies¶
Minimal Payload (Default)¶
Client receives notification, then fetches data via query API.
Use for: - Collaborative editing (team selection, roster management) - Administrative changes - Low-frequency updates
Benefits: - Small message size - Always fresh data from database - No risk of stale data
Example:
client.on('notification', async (msg) => {
if (msg.payloadType === 'minimal') {
// Refetch from API
const team = await foundryClient.query(queries.getTeam({ id: msg.id }));
updateUI(team);
}
});
Full Payload¶
Complete entity data included in notification.
Use for:
- Live match updates (RugbyGameState, Fixture during live games)
- High-frequency scenarios where many users watch the same entity
- Prevents thundering herd when thousands refetch simultaneously
Implementation:
Mark contracts with [FullPayload] attribute:
[DataContract(Name = "RugbyGameState")]
[RequiresRoles(RoleDefinition.Member)]
[FullPayload] // Include full entity data in notifications
public sealed record RugbyGameStateContract : IContract
{
// Contract properties...
}
Example:
client.on('notification', (msg) => {
if (msg.payloadType === 'full') {
// Data already included, update UI directly
updateMatchScore(msg.data);
}
});
When to Use: - High-frequency updates (live scores, game state) - Many simultaneous viewers of same entity - Sub-second update requirements
When NOT to Use: - Large entities (>10KB) - Administrative/infrequent changes - Collaborative editing (prefer minimal + refetch for consistency)
Backend Implementation¶
Adding Notification Support to New Read Models¶
Notifications work automatically for all read models that implement IContract. No triggers or registration needed!
Requirements:
1. Read model must implement IContract interface
2. Read model must be registered with Marten (via projection)
3. Read model must have [DataContract(Name = "...")] attribute
Example:
[DataContract(Name = "MyNewEntity")]
public class MyNewEntityContract : IContract
{
public MyNewEntityId Id { get; set; }
public string Name { get; set; }
// ... other properties
}
// That's it! Notifications will automatically fire when this entity is saved
Notification Flow¶
- Projection Updates Read Model: Marten projection updates IContract entity
- ContractChangeListener Fires: Marten's
IChangeListener.AfterCommitAsync()is called - DatabaseChangeNotification Created: Internal message includes Type, Id, ChangeType, Timestamp
- Redis Pub/Sub: Published to
foundry:db_changeschannel - NotificationService Receives: Subscribes to Redis, gets the message
- Payload Strategy: Determines minimal vs full payload based on entity type
- WebSocket Routing: Sends NotificationMessage to all subscribed client connections
Key Points:
- Notifications fire automatically when documentSession.SaveChangesAsync() is called
- Type is derived from [DataContract] attribute (e.g., "Competition", "Team")
- ChangeType is one of: "created", "updated", "deleted"
- No database triggers or manual registration required
Client-Side Integration¶
React Query Cache Invalidation (TypeScript)¶
Uses
FoundryNotificationClientdirectly — the SDK does not ship auseNotificationshook.
import { useEffect, useState } from 'react';
import {
FoundryNotificationClient,
type NotificationMessage,
} from '@luckboxstudios/foundry-sdk';
import { useQueryClient } from '@tanstack/react-query';
function useAutoRefresh() {
const queryClient = useQueryClient();
const [lastNotification, setLastNotification] = useState<NotificationMessage | null>(null);
useEffect(() => {
const client = new FoundryNotificationClient({ url: wsUrl, token: clerkToken });
client.on('notification', (msg) => setLastNotification(msg));
void client.connect();
return () => void client.disconnect();
}, []);
useEffect(() => {
if (!lastNotification) return;
// Invalidate relevant queries
switch (lastNotification.type) {
case 'Team':
queryClient.invalidateQueries(['team', lastNotification.id]);
queryClient.invalidateQueries(['teams']); // List queries too
break;
case 'RugbyGameState':
queryClient.invalidateQueries(['match', lastNotification.id]);
queryClient.invalidateQueries(['live-matches']);
break;
case 'Player':
queryClient.invalidateQueries(['player', lastNotification.id]);
queryClient.invalidateQueries(['players']);
queryClient.invalidateQueries(['team-roster']); // Player might be on roster
break;
}
}, [lastNotification, queryClient]);
}
Python Backend Integration¶
import asyncio
from foundry_sdk import (
FoundryClient, FoundryClientConfig,
FoundryNotificationClient, NotificationClientConfig
)
async def run_data_sync():
"""
Example: Sync data changes to external system in real-time.
"""
# Query client for fetching data
query_client = FoundryClient(FoundryClientConfig(
base_url="https://api.foundry.com",
auth_token=auth_token
))
# Notification client for real-time updates
notification_client = FoundryNotificationClient(NotificationClientConfig(
url='wss://api.ballr.live/ws',
token=auth_token
))
async def handle_change(msg):
if msg.payload_type == 'full':
# Full payload included - use directly
await sync_to_external_system(msg.type, msg.id, msg.data)
else:
# Minimal payload - fetch fresh data
query = {"QueryType": "GetById", "Type": msg.type, "Id": msg.id}
result = await query_client.query(query)
await sync_to_external_system(msg.type, msg.id, result.data)
notification_client.on('notification', lambda msg: asyncio.create_task(handle_change(msg)))
notification_client.on('connected', lambda: print("Connected - syncing changes"))
notification_client.on('error', lambda e: print(f"Error: {e}"))
await notification_client.connect()
# Subscribe to all changes for specific entity types
await notification_client.subscribe_to_type('Team')
await notification_client.subscribe_to_type('Player')
await notification_client.subscribe_to_type('Fixture')
# Run until interrupted
try:
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
await notification_client.disconnect()
async def sync_to_external_system(entity_type: str, entity_id: str, data: dict):
"""Sync entity to external system (e.g., analytics, search index)."""
print(f"Syncing {entity_type}:{entity_id} to external system")
# ... implementation
if __name__ == "__main__":
asyncio.run(run_data_sync())
Svelte Store Integration¶
// src/lib/stores/notifications.ts
import { writable } from 'svelte/store';
import { FoundryNotificationClient } from '@luckboxstudios/foundry-sdk';
export const matchScores = writable<Record<string, unknown>>({});
let client: FoundryNotificationClient | null = null;
export async function initializeNotifications(token: string): Promise<void> {
client = new FoundryNotificationClient({
url: import.meta.env.VITE_WS_URL,
token
});
client.on('notification', (msg) => {
if (msg.type === 'RugbyGameState' && msg.payloadType === 'full') {
matchScores.update((state) => ({
...state,
[msg.id]: msg.data
}));
}
});
await client.connect();
}
Performance Considerations¶
Connection Limits¶
Configure in appsettings.json:
{
"NotificationService": {
"MaxSubscriptionsPerConnection": 100,
"ConnectionTimeoutSeconds": 3600,
"HeartbeatIntervalSeconds": 30
}
}
Scaling¶
- Horizontal Scaling: Run multiple NotificationService instances behind load balancer
- Redis Pub/Sub: Broadcasts messages to all instances
- Stateless Connections: Connection state stored in Redis, survives instance restarts
Monitoring¶
OpenTelemetry metrics exposed:
// Connection metrics
foundry.notifications.connections.opened
foundry.notifications.connections.closed
foundry.notifications.connections.active
// Subscription metrics
foundry.notifications.subscriptions.created
foundry.notifications.subscriptions.removed
foundry.notifications.subscriptions.active
foundry.notifications.subscriptions.per_connection
// Notification metrics
foundry.notifications.sent
foundry.notifications.failed
foundry.notifications.latency_ms
// Authorization metrics
foundry.notifications.auth.attempts
foundry.notifications.auth.failures
foundry.notifications.authorization.failures
Testing¶
Unit Tests¶
[Fact]
public async Task Subscribe_PublicEntity_Succeeds()
{
// Arrange
var subscriptionManager = new SubscriptionManager(
distributedStore,
authValidator,
metrics,
logger);
var user = new ClaimsPrincipal(); // Anonymous user
var request = new SubscriptionRequest { Type = "Team", Id = "team-123" };
// Act
var result = await subscriptionManager.SubscribeAsync("conn-1", request, user);
// Assert
Assert.True(result.Success);
Assert.NotNull(result.SubscriptionId);
}
Integration Tests¶
[Fact]
public async Task End_To_End_Notification_Flow()
{
// 1. Connect WebSocket client
var client = await WebSocketTestClient.ConnectAsync("ws://localhost:5000/ws?token=test");
// 2. Subscribe
await client.SendAsync(new { messageType = "subscribe", type = "Team", id = "team-123" });
var subResponse = await client.ReceiveAsync<SubscriptionSuccessMessage>();
Assert.Equal("subscription_success", subResponse.MessageType);
// 3. Update entity in database
await using var session = documentStore.LightweightSession();
var team = await session.LoadAsync<Team>("team-123");
team.Name = "Updated Name";
await session.SaveChangesAsync();
// 4. Verify notification received
var notification = await client.ReceiveAsync<NotificationMessage>(timeout: TimeSpan.FromSeconds(5));
Assert.Equal("Team", notification.Type);
Assert.Equal("team-123", notification.Id);
Assert.Equal("updated", notification.ChangeType);
}
Manual Testing with Browser Console¶
// Connect
const ws = new WebSocket('wss://api.ballr.live/ws?token=YOUR_TOKEN');
ws.onopen = () => {
console.log('Connected');
// Subscribe
ws.send(JSON.stringify({
messageType: 'subscribe',
type: 'Team',
id: 'team-123'
}));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
console.log('Received:', msg);
};
// Update entity in another tab/browser to trigger notification
Troubleshooting¶
Client Not Receiving Notifications¶
Check:
1. WebSocket connection established: client.isConnected()
2. Subscription successful: Check for subscription_success message
3. Authorization: Does user have permission for this entity type?
4. Entity type registered: Check logs for entity type discovery
Debug:
const client = new FoundryNotificationClient({
url: 'wss://api.ballr.live/ws',
token: clerkToken,
debug: true // Enable console logging
});
Notifications Not Firing for New Read Model¶
Check:
1. Does read model implement IContract?
2. [DataContract(Name = "...")] attribute present?
3. Read model registered with Marten (via projection)?
4. ContractChangeListener registered? Check Foundry API startup logs
Debug in Foundry API logs:
info: Publishing contract change: EntityType=MyEntity, Id=xxx, Operation=updated
dbug: Published notification to foundry:db_changes (1 subscribers)
If no logs appear:
- Check that ContractChangeListener is registered in MartenConfiguration.cs
- Verify read model implements IContract
- Ensure documentSession.SaveChangesAsync() is being called
High Latency¶
Check: 1. Redis connection healthy? 2. Multiple NotificationService instances competing for connections? 3. Database under load? 4. Network latency between client and server?
Monitor:
# Check OpenTelemetry metrics
curl http://localhost:5000/metrics | grep foundry_notifications_latency
Best Practices¶
Client-Side¶
- One client per application: Create singleton notification client
- Unsubscribe on unmount: Clean up subscriptions in
useEffectcleanup - Invalidate caches, don't update directly: Let React Query refetch fresh data
- Handle disconnections gracefully: Show UI feedback for connection state
- Debounce rapid updates: Multiple notifications? Debounce refetches
Server-Side¶
- Keep entity types in config: Don't hardcode in authorization logic
- Use full payload sparingly: Only for high-frequency, high-traffic entities
- Monitor connection counts: Set alerts for unusual spikes
- Test authorization: Ensure private entities aren't leaking
References¶
- ADR: Real-Time Notification System - Architecture decision record
- Security Guide - Authentication and authorization patterns
- TypeScript SDK - TypeScript/React client library documentation
- Python SDK - Python async client library documentation
- Common Tasks - Daily development workflows