-
Notifications
You must be signed in to change notification settings - Fork 37
feat: replace Socket.IO real-time sync with GraphQL subscriptions #118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
0xdevcollins
merged 5 commits into
boundlessfi:main
from
0xDeon:feature/graphql-subscriptions
Feb 25, 2026
Merged
Changes from 1 commit
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
2d64e25
feat: replace Socket.IO real-time sync with GraphQL subscriptions
0xDeon 6501d9f
fix(tests): resolve lint errors in bounty subscription test
0xDeon aa1262e
fix: address CodeRabbit recommendations for subscription stability an…
0xDeon af35903
feat: ensure cache invalidation covers all bounty list variants
0xDeon 7102de9
chore: merge upstream/main and resolve conflicts in query-keys.ts
0xDeon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ); | ||
| } | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| })); | ||
|
|
||
| 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); | ||
| expect(subscribedQueries).toContain((BOUNTY_UPDATED_SUBSCRIPTION as any).loc?.source?.body); | ||
| expect(subscribedQueries).toContain((BOUNTY_DELETED_SUBSCRIPTION as any).loc?.source?.body); | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }); | ||
|
|
||
| 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() | ||
| }); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| */ | ||
0xDeon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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() }); | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]); | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.