Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Boundless Bounty is an open-source bounty platform built on Stellar that support
- Node.js/Express
- PostgreSQL
- Redis (caching)
- **GraphQL Subscriptions** via `graphql-ws` (replaces Socket.IO for real-time sync)

**Integrations:**

Expand Down
59 changes: 59 additions & 0 deletions docs/REALTIME_SYNC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Real-Time Synchronization with GraphQL Subscriptions

This document describes the real-time synchronization architecture that replaced the previous Socket.IO-based system.

## Overview

The application uses **GraphQL Subscriptions** via [graphql-ws](https://github.com/enisdenjo/graphql-ws) to receive real-time updates for bounty-related events. When an event occurs on the server (e.g., a bounty is created), a message is sent over a WebSocket connection to all subscribed clients, triggering automatic React Query cache invalidation.

## Architecture

1. **`lib/graphql/ws-client.ts`**: A singleton instance of the `graphql-ws` client.
- Handles WebSocket connection management.
- Injects authentication tokens via `connectionParams`.
- Manages automatic reconnection and retry logic.
2. **`lib/graphql/subscriptions.ts`**: Contains the typed GraphQL subscription documents and their response payload interfaces.
3. **`hooks/use-graphql-subscription.ts`**: A generic React hook that wraps the `wsClient.subscribe` method.
- Manages the subscription lifecycle (subscribes on mount, unsubscribes on unmount).
- Handles cleanup to prevent memory leaks.
4. **`hooks/use-bounty-subscription.ts`**: A high-level hook that aggregates subscriptions for `bountyCreated`, `bountyUpdated`, and `bountyDeleted`.
- Maps each event to specific React Query cache invalidation strategies using the global `bountyKeys` factory.

## Cache Invalidation Strategy

| Event | Action on Client | Cache Affected |
|-------|------------------|----------------|
| `bountyCreated` | `invalidateQueries` | `bountyKeys.lists()` |
| `bountyUpdated` | `invalidateQueries` | `bountyKeys.detail(id)`, `bountyKeys.lists()` |
| `bountyDeleted` | `removeQueries`, `invalidateQueries` | `bountyKeys.detail(id)`, `bountyKeys.lists()` |

## Configuration

The WebSocket endpoint is configured via an environment variable:

```bash
NEXT_PUBLIC_GRAPHQL_WS_URL=ws://your-backend-url/graphql
```

Default value for local development: `ws://localhost:4000/graphql`.

## Authentication

Authentication is handled via the `Better Auth` session token. The `wsClient` automatically retrieves the latest access token and injects it into the WebSocket handshake through the `authorization` header within `connectionParams`.

## Usage

To enable real-time updates in a component or globally:

```tsx
import { useBountySubscription } from '@/hooks/use-bounty-subscription';

function MyComponent() {
// Activate bounty synchronization
useBountySubscription();

return (
// ... UI that React Query will automatically update
);
}
```
156 changes: 156 additions & 0 deletions hooks/__tests__/use-bounty-subscription.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { renderHook } from '@testing-library/react';
import { useQueryClient } from '@tanstack/react-query';
import { useBountySubscription } from '../use-bounty-subscription';
import { wsClient } from '@/lib/graphql/ws-client';
import {
BOUNTY_CREATED_SUBSCRIPTION,
BOUNTY_UPDATED_SUBSCRIPTION,
BOUNTY_DELETED_SUBSCRIPTION,
} from '@/lib/graphql/subscriptions';
import { bountyKeys } from '@/lib/query/query-keys';

// Mock dependencies
jest.mock('@tanstack/react-query', () => ({
useQueryClient: jest.fn(),
}));

jest.mock('@/lib/graphql/ws-client', () => ({
wsClient: {
subscribe: jest.fn(),
},
}));

// Mock query keys factory
jest.mock('@/lib/query/query-keys', () => ({
bountyKeys: {
lists: jest.fn(() => ['Bounties', 'lists']),
detail: jest.fn((id: string) => ['Bounty', { id }]),
},
}));

// Mock graphql-tag
jest.mock('graphql-tag', () => ({
gql: (strings: string[]) => ({
kind: 'Document',
definitions: [],
loc: { source: { body: strings[0].trim() } },
}),
}));

// Mock graphql print
jest.mock('graphql', () => ({
print: jest.fn((query: any) => query.loc.source.body),

Check failure on line 42 in hooks/__tests__/use-bounty-subscription.test.ts

View workflow job for this annotation

GitHub Actions / build-and-lint (24.x)

Unexpected any. Specify a different type
}));

describe('useBountySubscription', () => {
let mockInvalidateQueries: jest.Mock;
let mockRemoveQueries: jest.Mock;
let mockSubscribe: jest.Mock;
const mockUnsubscribe = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
mockInvalidateQueries = jest.fn();
mockRemoveQueries = jest.fn();
(useQueryClient as jest.Mock).mockReturnValue({
invalidateQueries: mockInvalidateQueries,
removeQueries: mockRemoveQueries,
});

mockSubscribe = wsClient.subscribe as jest.Mock;
mockSubscribe.mockReturnValue(mockUnsubscribe);
});

it('should subscribe to all three bounty events on mount', () => {
renderHook(() => useBountySubscription());

expect(mockSubscribe).toHaveBeenCalledTimes(3);

// Check if it subscribes with the correct queries
const subscribedQueries = mockSubscribe.mock.calls.map(call => call[0].query);
expect(subscribedQueries).toContain((BOUNTY_CREATED_SUBSCRIPTION as any).loc?.source?.body);

Check failure on line 71 in hooks/__tests__/use-bounty-subscription.test.ts

View workflow job for this annotation

GitHub Actions / build-and-lint (24.x)

Unexpected any. Specify a different type
expect(subscribedQueries).toContain((BOUNTY_UPDATED_SUBSCRIPTION as any).loc?.source?.body);

Check failure on line 72 in hooks/__tests__/use-bounty-subscription.test.ts

View workflow job for this annotation

GitHub Actions / build-and-lint (24.x)

Unexpected any. Specify a different type
expect(subscribedQueries).toContain((BOUNTY_DELETED_SUBSCRIPTION as any).loc?.source?.body);

Check failure on line 73 in hooks/__tests__/use-bounty-subscription.test.ts

View workflow job for this annotation

GitHub Actions / build-and-lint (24.x)

Unexpected any. Specify a different type
});

it('should cleanup subscriptions on unmount', () => {
const { unmount } = renderHook(() => useBountySubscription());
unmount();
expect(mockUnsubscribe).toHaveBeenCalledTimes(3);
});

it('should invalidate lists when bountyCreated arrives', () => {
renderHook(() => useBountySubscription());

// Find the call for bountyCreated and trigger its callback
const call = mockSubscribe.mock.calls.find(c => c[0].query.includes('subscription BountyCreated'));
const callback = call[1].next;

// Simulate incoming data
callback({
data: {
bountyCreated: {
id: 'bounty-1',
title: 'New Bounty',
status: 'OPEN',
rewardAmount: 100,
rewardCurrency: 'XLM'
}
}
});

expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: bountyKeys.lists()
});
});

it('should invalidate detail and lists when bountyUpdated arrives', () => {
renderHook(() => useBountySubscription());

// Find the call for bountyUpdated and trigger its callback
const call = mockSubscribe.mock.calls.find(c => c[0].query.includes('subscription BountyUpdated'));
const callback = call[1].next;

// Simulate incoming data
callback({
data: {
bountyUpdated: {
id: 'bounty-1',
title: 'Updated Bounty',
status: 'IN_PROGRESS',
rewardAmount: 200,
rewardCurrency: 'XLM'
}
}
});

expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: bountyKeys.detail('bounty-1')
});
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: bountyKeys.lists()
});
});

it('should remove detail and invalidate lists when bountyDeleted arrives', () => {
renderHook(() => useBountySubscription());

// Find the call for bountyDeleted and trigger its callback
const call = mockSubscribe.mock.calls.find(c => c[0].query.includes('subscription BountyDeleted'));
const callback = call[1].next;

// Simulate incoming data
callback({
data: {
bountyDeleted: { id: 'bounty-1' }
}
});

expect(mockRemoveQueries).toHaveBeenCalledWith({
queryKey: bountyKeys.detail('bounty-1')
});
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: bountyKeys.lists()
});
});
});
37 changes: 37 additions & 0 deletions hooks/use-bounty-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useQueryClient } from '@tanstack/react-query';
import { useGraphQLSubscription } from './use-graphql-subscription';
import {
BOUNTY_CREATED_SUBSCRIPTION,
BOUNTY_UPDATED_SUBSCRIPTION,
BOUNTY_DELETED_SUBSCRIPTION,
type BountyCreatedData,
type BountyUpdatedData,
type BountyDeletedData,
} from '@/lib/graphql/subscriptions';
import { bountyKeys } from '@/lib/query/query-keys';

/**
* High-level hook that uses useGraphQLSubscription for all bounty-related events.
* It manages React Query cache invalidation when real-time updates are received.
*/
export function useBountySubscription() {
const queryClient = useQueryClient();

// Handle bountyCreated: invalidate all bounty list queries
useGraphQLSubscription<BountyCreatedData>(BOUNTY_CREATED_SUBSCRIPTION, {}, () => {
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
});

// Handle bountyUpdated: invalidate the specific bounty detail query
useGraphQLSubscription<BountyUpdatedData>(BOUNTY_UPDATED_SUBSCRIPTION, {}, (data) => {
queryClient.invalidateQueries({ queryKey: bountyKeys.detail(data.bountyUpdated.id) });
// Also invalidate lists to ensure consistency across the application
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
});

// Handle bountyDeleted: invalidate both the bounty detail and lists
useGraphQLSubscription<BountyDeletedData>(BOUNTY_DELETED_SUBSCRIPTION, {}, (data) => {
queryClient.removeQueries({ queryKey: bountyKeys.detail(data.bountyDeleted.id) });
queryClient.invalidateQueries({ queryKey: bountyKeys.lists() });
});
}
41 changes: 41 additions & 0 deletions hooks/use-graphql-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { wsClient } from '@/lib/graphql/ws-client';
import { type DocumentNode, print } from 'graphql';

/**
* Generic GraphQL Subscription Hook.
* Manages the lifecycle of a graphql-ws subscription, ensuring cleanup on unmount.
*
* @template T - The response shape of the subscription
* @param query - The subscription document (gql DocumentNode or string)
* @param variables - Subscription variables
* @param onData - Callback triggered on each data event
* @param onError - Optional error callback
*/
export function useGraphQLSubscription<T>(
query: DocumentNode | string,
variables: Record<string, unknown>,
onData: (data: T) => void,
onError?: (error: unknown) => void
) {
useEffect(() => {
// Stringify query if it's a DocumentNode
const queryString = typeof query === 'string' ? query : print(query);

const unsubscribe = wsClient.subscribe<T>(
{ query: queryString, variables },
{
next: ({ data }) => data && onData(data),
error: (err) => {
console.error('[GraphQL Subscription] Error:', err);
onError?.(err);
},
complete: () => { },
}
);

return () => {
unsubscribe();
};
}, [query, variables, onData, onError]);
}
55 changes: 0 additions & 55 deletions hooks/use-socket-sync.ts

This file was deleted.

Loading
Loading