Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
);
}
```
162 changes: 162 additions & 0 deletions hooks/__tests__/use-bounty-subscription.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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 }]),
allListKeys: [
['Bounties', 'lists'],
['ActiveBounties'],
['OrganizationBounties'],
['ProjectBounties'],
],
},
}));

// 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: { loc?: { source?: { body: string } } }) => query.loc?.source?.body ?? ''),
}));

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 unknown as { loc: { source: { body: string } } }).loc.source.body);
expect(subscribedQueries).toContain((BOUNTY_UPDATED_SUBSCRIPTION as unknown as { loc: { source: { body: string } } }).loc.source.body);
expect(subscribedQueries).toContain((BOUNTY_DELETED_SUBSCRIPTION as unknown as { loc: { source: { body: string } } }).loc.source.body);
});

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).toHaveBeenCalledTimes(4); // Once for each entry in allListKeys
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['Bounties', 'lists']
});
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['ActiveBounties']
});
});

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).toHaveBeenCalledTimes(5); // 4 for lists + 1 for detail
});

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).toHaveBeenCalledTimes(4);
});
});
42 changes: 42 additions & 0 deletions hooks/use-bounty-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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();

const invalidateAllLists = () => {
bountyKeys.allListKeys.forEach(queryKey => {
queryClient.invalidateQueries({ queryKey });
});
};

// Handle bountyCreated: invalidate all bounty list queries
useGraphQLSubscription<BountyCreatedData>(BOUNTY_CREATED_SUBSCRIPTION, {}, () => {
invalidateAllLists();
});

// Handle bountyUpdated: invalidate the specific bounty detail query and all lists
useGraphQLSubscription<BountyUpdatedData>(BOUNTY_UPDATED_SUBSCRIPTION, {}, (data) => {
queryClient.invalidateQueries({ queryKey: bountyKeys.detail(data.bountyUpdated.id) });
invalidateAllLists();
});

// Handle bountyDeleted: remove the specific detail and invalidate all lists
useGraphQLSubscription<BountyDeletedData>(BOUNTY_DELETED_SUBSCRIPTION, {}, (data) => {
queryClient.removeQueries({ queryKey: bountyKeys.detail(data.bountyDeleted.id) });
invalidateAllLists();
});
}
53 changes: 53 additions & 0 deletions hooks/use-graphql-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useEffect, useRef } 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
) {
// Hold latest callbacks in refs so we don't restart the subscription when they change
const onDataRef = useRef(onData);
const onErrorRef = useRef(onError);

useEffect(() => {
onDataRef.current = onData;
onErrorRef.current = onError;
}, [onData, onError]);

// Track variables as a string to avoid reference-based flapping
const variablesString = JSON.stringify(variables);

// Track query as a string
const queryString = typeof query === 'string' ? query : print(query);

useEffect(() => {
const unsubscribe = wsClient.subscribe<T>(
{ query: queryString, variables: JSON.parse(variablesString) },
{
next: ({ data }) => data && onDataRef.current(data),
error: (err) => {
console.error('[GraphQL Subscription] Error:', err);
onErrorRef.current?.(err);
},
complete: () => { },
}
);

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

This file was deleted.

Loading
Loading