Files
speckle-server/packages/server/modules/shared/utils/subscriptions.ts
T
2025-08-20 11:35:04 +03:00

465 lines
15 KiB
TypeScript

import type { MaybeAsync, Nullable } from '@speckle/shared'
import { getRedisUrl } from '@/modules/shared/helpers/envHelper'
import { RedisPubSub } from 'graphql-redis-subscriptions'
import Redis from 'ioredis'
import { withFilter } from 'graphql-subscriptions'
import type { GraphQLContext } from '@/modules/shared/helpers/typeHelper'
import type {
ProjectCommentsUpdatedMessage,
ProjectFileImportUpdatedMessage,
ProjectModelsUpdatedMessage,
ProjectPendingModelsUpdatedMessage,
ProjectPendingVersionsUpdatedMessage,
ProjectUpdatedMessage,
ProjectVersionsPreviewGeneratedMessage,
ProjectVersionsUpdatedMessage,
SubscriptionProjectAutomationsUpdatedArgs,
SubscriptionProjectCommentsUpdatedArgs,
SubscriptionProjectFileImportUpdatedArgs,
SubscriptionProjectModelsUpdatedArgs,
SubscriptionProjectPendingModelsUpdatedArgs,
SubscriptionProjectPendingVersionsUpdatedArgs,
SubscriptionProjectTriggeredAutomationsStatusUpdatedArgs,
SubscriptionProjectUpdatedArgs,
SubscriptionProjectVersionsPreviewGeneratedArgs,
SubscriptionProjectVersionsUpdatedArgs,
SubscriptionSubscribeFn,
SubscriptionViewerUserActivityBroadcastedArgs,
UserProjectsUpdatedMessage,
ViewerResourceItem,
ViewerUserActivityMessage,
GendoAiRender,
SubscriptionProjectVersionGendoAiRenderUpdatedArgs,
SubscriptionProjectVersionGendoAiRenderCreatedArgs,
CommentThreadActivityMessage,
SubscriptionCommentThreadActivityArgs,
MutationUserViewerActivityBroadcastArgs,
SubscriptionUserViewerActivityArgs,
SubscriptionCommentActivityArgs,
StreamUpdateInput,
ProjectUpdateInput,
SubscriptionStreamUpdatedArgs,
SubscriptionStreamDeletedArgs,
SubscriptionBranchCreatedArgs,
SubscriptionBranchUpdatedArgs,
BranchUpdateInput,
UpdateModelInput,
SubscriptionBranchDeletedArgs,
BranchDeleteInput,
DeleteModelInput,
SubscriptionCommitCreatedArgs,
CommitCreateInput,
SubscriptionCommitUpdatedArgs,
CommitUpdateInput,
SubscriptionWorkspaceProjectsUpdatedArgs,
WorkspaceProjectsUpdatedMessage,
SubscriptionWorkspaceUpdatedArgs,
WorkspaceUpdatedMessage,
ProjectAccSyncItemsUpdatedMessage,
SubscriptionProjectAccSyncItemsUpdatedArgs
} from '@/modules/core/graph/generated/graphql'
import type { Merge, OverrideProperties, SetNonNullable } from 'type-fest'
import type {
ModelGraphQLReturn,
ProjectGraphQLReturn,
VersionGraphQLReturn
} from '@/modules/core/helpers/graphTypes'
import type { CommentGraphQLReturn } from '@/modules/comments/helpers/graphTypes'
import type { FileUploadGraphQLReturn } from '@/modules/fileuploads/helpers/types'
import type {
ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn,
ProjectAutomationsUpdatedMessageGraphQLReturn
} from '@/modules/automate/helpers/graphTypes'
import type { CommentRecord } from '@/modules/comments/helpers/types'
import type { CommitRecord } from '@/modules/core/helpers/types'
import type { BranchRecord } from '@/modules/core/helpers/types'
import type { WorkspaceGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes'
import type { AccSyncItemGraphQLReturn } from '@/modules/acc/helpers/graphTypes'
/**
* GraphQL Subscription PubSub instance
*/
export const pubsub = new RedisPubSub({
publisher: new Redis(getRedisUrl()),
subscriber: new Redis(getRedisUrl())
})
/**
* Subscription event keys
*/
// OLD:
export enum StreamSubscriptions {
UserStreamAdded = 'USER_STREAM_ADDED',
UserStreamRemoved = 'USER_STREAM_REMOVED',
StreamUpdated = 'STREAM_UPDATED',
StreamDeleted = 'STREAM_DELETED'
}
export enum CommitSubscriptions {
CommitCreated = 'COMMIT_CREATED',
CommitUpdated = 'COMMIT_UPDATED',
CommitDeleted = 'COMMIT_DELETED'
}
export enum BranchSubscriptions {
BranchCreated = 'BRANCH_CREATED',
BranchUpdated = 'BRANCH_UPDATED',
BranchDeleted = 'BRANCH_DELETED'
}
export enum CommentSubscriptions {
ViewerActivity = 'VIEWER_ACTIVITY',
CommentActivity = 'COMMENT_ACTIVITY',
CommentThreadActivity = 'COMMENT_THREAD_ACTIVITY'
}
// NEW:
export enum UserSubscriptions {
UserProjectsUpdated = 'USER_PROJECTS_UPDATED'
}
export enum ProjectSubscriptions {
ProjectUpdated = 'PROJECT_UPDATED',
ProjectModelsUpdated = 'PROJECT_MODELS_UPDATED',
ProjectVersionsUpdated = 'PROJECT_VERSIONS_UPDATED',
ProjectVersionsPreviewGenerated = 'PROJECT_VERSIONS_PREVIEW_GENERATED',
ProjectCommentsUpdated = 'PROJECT_COMMENTS_UPDATED',
ProjectAccSyncItemUpdated = 'PROJECT_ACC_SYNC_ITEM_UPDATED',
// old beta subscription:
ProjectTriggeredAutomationsStatusUpdated = 'PROJECT_TRIGGERED_AUTOMATION_STATUS_UPDATED',
ProjectAutomationsUpdated = 'PROJECT_AUTOMATIONS_UPDATED',
ProjectVersionGendoAIRenderUpdated = 'PROJECT_VERSION_GENDO_AI_RENDER_UPDATED',
ProjectVersionGendoAIRenderCreated = 'PROJECT_VERSION_GENDO_AI_RENDER_CREATED'
}
export enum ViewerSubscriptions {
UserActivityBroadcasted = 'VIEWER_USER_ACTIVITY_BROADCASTED'
}
export enum FileImportSubscriptions {
ProjectPendingModelsUpdated = 'PROJECT_PENDING_MODELS_UPDATED',
ProjectPendingVersionsUpdated = 'PROJECT_PENDING_VERSIONS_UPDATED',
ProjectFileImportUpdated = 'PROJECT_FILE_IMPORT_UPDATED'
}
export enum TestSubscriptions {
Ping = 'PING'
}
export enum WorkspaceSubscriptions {
WorkspaceProjectsUpdated = 'WORKSPACE_PROJECTS_UPDATED',
WorkspaceUpdated = 'WORKSPACE_UPDATED'
}
type NoVariables = Record<string, never>
// Add mappings between expected event constant, its payload and variables
type SubscriptionTypeMap = {
[UserSubscriptions.UserProjectsUpdated]: {
payload: {
userProjectsUpdated: Merge<
UserProjectsUpdatedMessage,
{ project: Nullable<ProjectGraphQLReturn> }
>
ownerId: string
}
variables: NoVariables
}
[ProjectSubscriptions.ProjectUpdated]: {
payload: {
projectUpdated: Merge<
ProjectUpdatedMessage,
{ project: Nullable<ProjectGraphQLReturn> }
>
}
variables: SubscriptionProjectUpdatedArgs
}
[ProjectSubscriptions.ProjectVersionGendoAIRenderUpdated]: {
payload: {
projectVersionGendoAIRenderUpdated: GendoAiRender
}
variables: SubscriptionProjectVersionGendoAiRenderUpdatedArgs
}
[ProjectSubscriptions.ProjectVersionGendoAIRenderCreated]: {
payload: {
projectVersionGendoAIRenderCreated: GendoAiRender
}
variables: SubscriptionProjectVersionGendoAiRenderCreatedArgs
}
[ProjectSubscriptions.ProjectModelsUpdated]: {
payload: {
projectModelsUpdated: Merge<
ProjectModelsUpdatedMessage,
{ model: Nullable<ModelGraphQLReturn> }
>
projectId: string
}
variables: SubscriptionProjectModelsUpdatedArgs
}
[ProjectSubscriptions.ProjectVersionsUpdated]: {
payload: {
projectVersionsUpdated: Merge<
ProjectVersionsUpdatedMessage,
{ version: Nullable<Omit<VersionGraphQLReturn, 'branchId'>> }
>
projectId: string
}
variables: SubscriptionProjectVersionsUpdatedArgs
}
[ProjectSubscriptions.ProjectVersionsPreviewGenerated]: {
payload: {
projectVersionsPreviewGenerated: ProjectVersionsPreviewGeneratedMessage
}
variables: SubscriptionProjectVersionsPreviewGeneratedArgs
}
[ViewerSubscriptions.UserActivityBroadcasted]: {
payload: {
viewerUserActivityBroadcasted: SetNonNullable<ViewerUserActivityMessage, 'userId'>
projectId: string
resourceItems: ViewerResourceItem[]
userId: Nullable<string>
}
variables: SubscriptionViewerUserActivityBroadcastedArgs
}
[ProjectSubscriptions.ProjectCommentsUpdated]: {
payload: {
projectCommentsUpdated: Merge<
ProjectCommentsUpdatedMessage,
{ comment: Nullable<CommentGraphQLReturn> }
>
projectId: string
resourceItems: ViewerResourceItem[]
}
variables: SubscriptionProjectCommentsUpdatedArgs
}
[ProjectSubscriptions.ProjectAccSyncItemUpdated]: {
payload: {
projectAccSyncItemsUpdated: Merge<
ProjectAccSyncItemsUpdatedMessage,
{ accSyncItem: Nullable<AccSyncItemGraphQLReturn> }
>
projectId: string
}
variables: SubscriptionProjectAccSyncItemsUpdatedArgs
}
[FileImportSubscriptions.ProjectPendingModelsUpdated]: {
payload: {
projectPendingModelsUpdated: Merge<
ProjectPendingModelsUpdatedMessage,
{ model: FileUploadGraphQLReturn }
>
projectId: string
}
variables: SubscriptionProjectPendingModelsUpdatedArgs
}
[FileImportSubscriptions.ProjectPendingVersionsUpdated]: {
payload: {
projectPendingVersionsUpdated: Merge<
ProjectPendingVersionsUpdatedMessage,
{ version: FileUploadGraphQLReturn }
>
projectId: string
}
variables: SubscriptionProjectPendingVersionsUpdatedArgs
}
[FileImportSubscriptions.ProjectFileImportUpdated]: {
payload: {
projectFileImportUpdated: Merge<
ProjectFileImportUpdatedMessage,
{ upload: FileUploadGraphQLReturn }
>
projectId: string
}
variables: SubscriptionProjectFileImportUpdatedArgs
}
[ProjectSubscriptions.ProjectTriggeredAutomationsStatusUpdated]: {
payload: {
projectTriggeredAutomationsStatusUpdated: ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn
projectId: string
}
variables: SubscriptionProjectTriggeredAutomationsStatusUpdatedArgs
}
[ProjectSubscriptions.ProjectAutomationsUpdated]: {
payload: {
projectAutomationsUpdated: ProjectAutomationsUpdatedMessageGraphQLReturn
projectId: string
}
variables: SubscriptionProjectAutomationsUpdatedArgs
}
[CommentSubscriptions.CommentThreadActivity]: {
payload: {
commentThreadActivity: OverrideProperties<
CommentThreadActivityMessage,
{ reply?: CommentRecord }
>
streamId: string
commentId: string
}
variables: SubscriptionCommentThreadActivityArgs
}
[CommentSubscriptions.ViewerActivity]: {
payload: {
userViewerActivity: MutationUserViewerActivityBroadcastArgs
streamId: string
resourceId: string
authorId: string
}
variables: SubscriptionUserViewerActivityArgs
}
[CommentSubscriptions.CommentActivity]: {
payload: {
commentActivity: {
type: 'comment-added'
comment: CommentRecord
}
streamId: string
resourceIds: string
}
variables: SubscriptionCommentActivityArgs
}
/**
* OLD ONES
*/
[StreamSubscriptions.UserStreamAdded]: {
payload: {
userStreamAdded: { id: string; sharedBy?: string }
ownerId: string
}
variables: NoVariables
}
[StreamSubscriptions.UserStreamRemoved]: {
payload: {
userStreamRemoved: { id: string; revokedBy?: string }
ownerId: string
}
variables: NoVariables
}
[StreamSubscriptions.StreamUpdated]: {
payload: { streamUpdated: StreamUpdateInput | ProjectUpdateInput; id: string }
variables: SubscriptionStreamUpdatedArgs
}
[StreamSubscriptions.StreamDeleted]: {
payload: { streamDeleted: { streamId: string }; streamId: string }
variables: SubscriptionStreamDeletedArgs
}
[BranchSubscriptions.BranchCreated]: {
payload: { branchCreated: BranchRecord; streamId: string }
variables: SubscriptionBranchCreatedArgs
}
[BranchSubscriptions.BranchUpdated]: {
payload: {
branchUpdated: BranchUpdateInput | UpdateModelInput
streamId: string
branchId: string
}
variables: SubscriptionBranchUpdatedArgs
}
[BranchSubscriptions.BranchDeleted]: {
payload: { branchDeleted: BranchDeleteInput | DeleteModelInput; streamId: string }
variables: SubscriptionBranchDeletedArgs
}
[CommitSubscriptions.CommitCreated]: {
payload: {
commitCreated: CommitCreateInput & { id: string; authorId: string }
streamId: string
}
variables: SubscriptionCommitCreatedArgs
}
[CommitSubscriptions.CommitUpdated]: {
payload: { commitUpdated: CommitUpdateInput; streamId: string; commitId: string }
variables: SubscriptionCommitUpdatedArgs
}
[CommitSubscriptions.CommitDeleted]: {
payload: {
commitDeleted: CommitRecord & { streamId: string; branchId: string }
streamId: string
}
variables: SubscriptionCommitUpdatedArgs
}
[TestSubscriptions.Ping]: {
payload: { ping: string }
variables: NoVariables
}
[WorkspaceSubscriptions.WorkspaceProjectsUpdated]: {
payload: {
workspaceProjectsUpdated: Merge<
WorkspaceProjectsUpdatedMessage,
{ project: Nullable<ProjectGraphQLReturn> }
>
workspaceId: string
}
variables: SubscriptionWorkspaceProjectsUpdatedArgs
}
[WorkspaceSubscriptions.WorkspaceUpdated]: {
payload: {
workspaceUpdated: Merge<
WorkspaceUpdatedMessage,
{ workspace: WorkspaceGraphQLReturn }
>
}
variables: SubscriptionWorkspaceUpdatedArgs
}
} & { [k in SubscriptionEvent]: { payload: unknown; variables: unknown } }
type SubscriptionEvent =
| CommitSubscriptions
| CommentSubscriptions
| FileImportSubscriptions
| ProjectSubscriptions
| StreamSubscriptions
| UserSubscriptions
| ViewerSubscriptions
| BranchSubscriptions
| TestSubscriptions
| WorkspaceSubscriptions
/**
* Publish a GQL subscription event
*/
export const publish = <T extends SubscriptionEvent>(
event: T,
payload: SubscriptionTypeMap[T]['payload']
) => pubsub.publish(event, payload)
export type PublishSubscription = typeof publish
/**
* Subscribe to a GQL subscription and use the filter function to filter subscribers
* depending on the payload, variables and/or GQL context.
*
* Additionally clear "request" caches after each event, so that dataloaders/authloaders don't
* get cached
*/
export const filteredSubscribe = <T extends SubscriptionEvent>(
event: T,
filterFn: (
payload: SubscriptionTypeMap[T]['payload'],
variables: SubscriptionTypeMap[T]['variables'],
context: GraphQLContext
) => MaybeAsync<boolean>
) => {
// we need to cast to graphql codegen types cause they're not fully compatible
// with our version of graphql-subscriptions
// https://github.com/dotansimha/graphql-code-generator/issues/7197#issuecomment-1098014584
return withFilter(
() => pubsub.asyncIterator([event]),
async (...args: Parameters<typeof filterFn>) => {
const [, , ctx] = args
// Clear ctx cache on return false/throw, otherwise subsequent iterations
// will have a stale cache.
// No need to do this on return true, as the cache will be cleared in the formatResponse handler
try {
const res = await filterFn(...args)
if (!res) ctx.clearCache()
return res
} catch (e) {
ctx.clearCache()
throw e
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as unknown as SubscriptionSubscribeFn<any, any, any, any>
}