Using graphql subscriptions correctly

Solved in my second post

I still have some questions regarding Redis store though, any help appreciated!

I was using a cell with polling to grab recent notifications that have been assigned to a user. It looked like this:

export const QUERY: TypedDocumentNode<
  FindNotificationsByOwnerQuery,
  FindNotificationsByOwnerQueryVariables
> = gql`
  query FindNotificationsByOwnerQuery($id: Int!) {
    notificationsByOwner: notificationsByOwner(id: $id) {
      ...NotificationInfo
    }
  }
`

And the resolver:

export const notificationsByOwner: QueryResolvers['notificationsByOwner'] = ({
  id,
}) => {
  if (context.currentUser?.id !== id || !hasPermission('notifications:read')) {
    throw new ForbiddenError('You are not authorized to perform this action')
  }

  return db.notification.findMany({
    where: {
      users: { some: { id: id } },
    },
    orderBy: {
      updatedAt: 'asc',
    },
  })
}

Now I’m trying to switch over to subscriptions and have removed the cell in favor of a NotificationSub component which looks like this:

import { useEffect, useState } from 'react'

import { Notification } from 'types/graphql'

import { useSubscription } from '@redwoodjs/web'

import { useAuth } from 'src/auth'

const NEW_NOTIFICATION_SUBSCRIPTION = gql`
  subscription ListenForNewNotifications($userId: Int!) {
    newNotification(userId: $userId) {
      id
      content
      title
      createdAt
      updatedAt
      link
      type
      viewed
      users {
        id
      }
    }
  }
`

const NotificationSub = () => {
  const { currentUser } = useAuth()
  const [notificationFeed, setNotificationFeed] = useState([])

  const { data, error } = useSubscription(NEW_NOTIFICATION_SUBSCRIPTION, {
    variables: { userId: currentUser?.id },
    skip: !currentUser,
  })

  useEffect(() => {
    if (error) {
      console.error('Subscription error:', error)
    }
  }, [error])

  useEffect(() => {
    if (data) {
      const notification = data.newNotification

      if (notification) {
        const newNotification: Notification = {
          id: notification.id,
          content: notification.content,
          title: notification.title,
          users: notification.user,
        }

        setNotificationFeed((prevFeed) => [...prevFeed, newNotification])
      }
    }
  }, [data])

  return (
    <div>
      {notificationFeed.map((notification) => (
        <div key={notification.id}>{notification.content}</div>
      ))}
    </div>
  )
}

export default NotificationSub

And under api/src/subscriptions I have a newNotification.ts sub file:

import gql from 'graphql-tag'
import { User } from 'types/graphql'

import type { PubSub } from '@redwoodjs/realtime'

export const schema = gql`
  type Subscription {
    newNotification(userId: Int!): Notification! @requireAuth
  }
`

export type NotificationChannel = {
  newNotification: [
    userId: number,
    payload: {
      id: number
      title: string
      content: string
      users: User[]
    }
  ]
}

export type NotificationChannelType = PubSub<NotificationChannel>

const newNotification = {
  newNotification: {
    subscribe: (
      _,
      { userId },
      { pubSub }: { pubSub: NotificationChannelType }
    ) => {
      return pubSub.subscribe('newNotification', userId)
    },
    resolve: (payload) => {
      return payload
    },
  },
}

export default newNotification

I followed the docs and enabled realtime with the server file and haven’t touched those except for adding realtime to the createGraphQLHandler. A bit concerning to me was that when I hovered over the realtime import it said:

Only supported in a server deploy and not allowed with GraphQLHandler config

Issue

So, a notification can be sent to many users, hence the Users relationship on Notification. I make mutations in other parts of my codebase and I tried to follow the subscription demo examples to make sure the subscription resolver and component look correct. However, I am unable to get a payload when:

  1. I run a mutation elsewhere in the code that creates a new row in the Notification table
  2. I use prisma studio to insert a new row in the Notification table

Console and network dev tools don’t show anything, no payload or error. My current thinking is that I’m understanding the mutation → subscription incorrectly. A mutation could look like:

export const CREATE_NOTIFICATION = gql`
  mutation CreateNotification($userIds: [Int!]!, $input: CreateNotificationInput!) {
    createNotification(userIds: $userIds, input: $input) {
      id
    }
  }
`

And then inside the resolver on the API side I connect it to the users by their ids. My question is, how does the subscription resolver pick up if a new row was inserted for specific user? Sure I send the userId with useSubscription as a variable, but on the resolver end I don’t understand how this line of code picks up on any changes in the database when a notification is added for a user that matches the userId?

      return pubSub.subscribe('newNotification', userId)

You know sometimes you write things out and it just occurs to you. Anyway, I think my issue stems from being rather new to this topic and misunderstanding what subscriptions are. My initial idea was that they’d be pieces of code that listens for changes in a table. However, it appears subscriptions live on a layer above the database and use “channels” or topics to communicate when data is published. That is why I am not seeing anything. I am missing the publish part.

This was not quite clear to me, but I believe the solution would be to add something like this to the create mutation resolver:

export const createNotification: MutationResolvers['createNotification'] =
  async ({ userIds, input }) => {
    const notification = await db.notification.create({
      data: {
        ...input,
        users: { connect: userIds.map((id: number) => ({ id: id })) },
      },
    })

    // Publish the event to the subscription channel
    const pubSub = context.pubSub as NotificationChannelType
    userIds.forEach((userId) => {
      pubSub.publish('newNotification', userId, notification)
    })

    return notification
  }

Now this works. So essentially every time you mutate something, you have to publish it to the topic that your subscriber is listening to. I’ll have to explore the redis store on render to see how it all works with persistency. Right now, if I’m on the page that displays the notifications I am subscribed to they go away if I change to a different route/page and come back.

Overall I am wondering where this leaves cells though? What I liked about the cell query with polling is that if I implement it on a layout level, the notifications are visible everywhere even if I refresh the page, because the cell queries the database each time. With subscriptions I don’t query the database, but instead listen to an abstract topic. I suppose this is where redis comes in handy, instead of the in-memory store.

I enabled Redis in realtime.ts and created an instance on render. I can see that when I publish a new Notification the instance records a new connection on render. No errors anywhere, the subscriptions updates with a new published notification. Seems like it works flawlessly. I am using the free tier on render if that matters. They mention that a restart will wipe the data, but I’m not restarting redis.

However, I don’t seem to get a persistent subscription component. Is the goal of using Redis that the subscriptions don’t go away if I switch to a different page/route? Once new mutations are published, should the NotificationSub component display the most recent notifications with this setup?

Edit: I added this to my realtime.ts to check my keys:

const publishClient = new Redis(process.env.REDIS_URL) // grabbed from render.com
const subscribeClient = new Redis(process.env.REDIS_URL)

subscribeClient
  .keys('*')
  .then((keys) => {
    logger.info('keys on redis: , keys)
  })
  .catch((err) => {
    logger.error('Error fetching keys:', err)
  })

Turns out keys is empty. I wonder if the free tier on render.com is not persisting these entries after all. I’ll try with another host.

Edit2: So I tried it on a different host and I can see that the connections are established, but no write/read occurs. My Redis database is essentially empty, no key value pairs.

Is there anything else you have to specify when enabling Redis? I am creating the publishClient and subscribeClient using new Redis(connection string from env) and I have uncommented them below in the RedwoodRealtimeOptions. I also attach realtime to my graphql handler config.

How does the publish or subscribe resolver know which store to use? Is it pubsub?

import type { PubSub } from '@redwoodjs/realtime'
...
export type NotificationChannelType = PubSub<NotificationChannel>

const newNotification = {
  newNotification: {
    subscribe: (
      _,
      { userId },
      { pubSub }: { pubSub: NotificationChannelType }
    ) => {
      return pubSub.subscribe('newNotification', userId)
    },
    resolve: (payload) => {
      return payload
    },
  },
}

Edit3 : Redis still eludes me, any pointers on that would be helpful. but I’ve set up the following workflow now:
0. Added a cell to the layout so notifications are “global”

  1. Notification cell grabs all notifications for the logged in user
  2. Wraps a Notification context around a child component (I implemented the subscription inside the context and created a history state)
  3. The child component renders the history state instead of the direct result from the cell

This works very well, since the context automatically adds new notifications to the history state and that’s what the child component renders. Thus, the cell only fires once on the initial load with the most recent data from the database and then the subscription context appends new notifications to the history state. When I refresh the app or move to another page, the user sees the most recent notifications because the cell pulls from the database and the context doesn’t have to add anything new until a new subscription is published somewhere from the app.

I think this is definitely where context shines and I’m really enjoying this DX.