Skip to content

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 useNotifications React hook. It exports only the framework-agnostic FoundryNotificationClient. 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

{
  "messageType": "unsubscribe",
  "subscriptionId": "Team:team-123"
}

Ping (Heartbeat)

{
  "messageType": "ping"
}

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

{
  "messageType": "subscription_success",
  "subscriptionId": "Team:team-123"
}

Error

{
  "messageType": "error",
  "error": "Not authorized to subscribe to this entity type"
}

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:

  1. Client subscribes: subscribe('Team', 'team-123')
  2. System resolves 'Team'TeamContract type
  3. System checks TeamContract has [RequiresRoles(RoleDefinition.Public)]
  4. System validates user has required role via role hierarchy
  5. Subscription allowed/denied

Adding New Entity Types

  1. Define contract with [RequiresRoles] attribute:

    [DataContract(Name = "MyEntity")]
    [RequiresRoles(RoleDefinition.Member)]  // Specify required roles
    public sealed record MyEntityContract : IContract { }
    

  2. Ensure contract implements IContract: Required for automatic change detection

  3. No configuration needed: Authorization is code-based, not config-based

  4. ContractChangeListener automatically detects changes: All IContract types 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

  1. Projection Updates Read Model: Marten projection updates IContract entity
  2. ContractChangeListener Fires: Marten's IChangeListener.AfterCommitAsync() is called
  3. DatabaseChangeNotification Created: Internal message includes Type, Id, ChangeType, Timestamp
  4. Redis Pub/Sub: Published to foundry:db_changes channel
  5. NotificationService Receives: Subscribes to Redis, gets the message
  6. Payload Strategy: Determines minimal vs full payload based on entity type
  7. 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 FoundryNotificationClient directly — the SDK does not ship a useNotifications hook.

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

  1. One client per application: Create singleton notification client
  2. Unsubscribe on unmount: Clean up subscriptions in useEffect cleanup
  3. Invalidate caches, don't update directly: Let React Query refetch fresh data
  4. Handle disconnections gracefully: Show UI feedback for connection state
  5. Debounce rapid updates: Multiple notifications? Debounce refetches

Server-Side

  1. Keep entity types in config: Don't hardcode in authorization logic
  2. Use full payload sparingly: Only for high-frequency, high-traffic entities
  3. Monitor connection counts: Set alerts for unusual spikes
  4. Test authorization: Ensure private entities aren't leaking

References