{"record":{"id":"ff373fd85b5233e9","repo":"toeverything/AFFiNE","slug":"not-in-space","errorCode":"not_in_space","errorMessage":"You should join in Space ${spaceId} before broadcasting messages.","messagePattern":"You should join in Space (.+?) before broadcasting messages\\.","errorType":"exception","errorClass":"NotInSpace","httpStatus":403,"severity":"error","filePath":"packages/backend/server/src/core/sync/gateway.ts","lineNumber":1626,"sourceCode":"\n  room(spaceId: string, roomType: RoomType = 'sync') {\n    return `${this.spaceType}:${Room(spaceId, roomType)}`;\n  }\n\n  async leave(spaceId: string, roomType: RoomType = 'sync') {\n    if (!this.in(spaceId, roomType)) {\n      return;\n    }\n    return this.client.leave(this.room(spaceId, roomType));\n  }\n\n  in(spaceId: string, roomType: RoomType = 'sync') {\n    return this.client.rooms.has(this.room(spaceId, roomType));\n  }\n\n  assertIn(spaceId: string, roomType: RoomType = 'sync') {\n    if (!this.client.rooms.has(this.room(spaceId, roomType))) {\n      throw new NotInSpace({ spaceId });\n    }\n  }\n\n  abstract assertAccessible(\n    spaceId: string,\n    userId: string,\n    action: WorkspaceAction\n  ): Promise<void>;\n\n  async push(\n    spaceId: string,\n    docId: string,\n    updates: Buffer[],\n    editorId: string,\n    expectedPermissionGeneration?: number,\n    writeIntent: 'update_doc' | 'create_doc' = 'update_doc'\n  ) {\n    this.assertIn(spaceId);","sourceCodeStart":1608,"sourceCodeEnd":1644,"githubUrl":"https://github.com/toeverything/AFFiNE/blob/2af30773aecd567f09b346e7b72fc69143144057/packages/backend/server/src/core/sync/gateway.ts#L1608-L1644","documentation":"Thrown by the collaborative sync WebSocket gateway when a socket emits a sync or awareness message for a space room it never joined. SyncSocketAdapter.assertIn checks client.rooms for `${spaceType}:${spaceId}` (plus `:awareness` suffix for awareness rooms) before push/diff/delete/timestamps and before 'space:load-awarenesses' / 'space:update-awareness' broadcasts. Rooms are only entered via the join handler, which also runs permission checks.","triggerScenarios":"Emitting 'doc:update' push/diff/delete/timestamps for a spaceId before the space/doc join ack returns; broadcasting 'space:update-awareness' without having joined the `${docId}:awareness` room; a prior join that failed assertAccessible (no permission) so the room was never entered; resuming pushes on a reconnecting socket without re-joining rooms; sending spaceType/spaceId values that differ from the ones used at join time.","commonSituations":"Custom sync clients racing the join handshake against the first update; awareness traffic sent right after doc:join but before the awareness room join; using the userspace adapter with a workspace id so the room key mismatches; proxies dropping the join ack.","solutions":["Await the join acknowledgement for the space room (and the `${docId}:awareness` room for awareness traffic) before emitting any doc:update or awareness message","Make sure the spaceType and spaceId in every message exactly match the values used in the join call","On socket reconnect, re-join all previously joined rooms before resuming update pushes","If the join itself fails, surface that permission error to the user instead of continuing to push updates"],"exampleFix":"// before\nsocket.emit('doc:update', { spaceType: 'workspace', spaceId, docId, updates }); // may throw not_in_space\n\n// after\nawait joinSpace(socket, spaceId, docId); // resolves only after the join ack\nsocket.emit('doc:update', { spaceType: 'workspace', spaceId, docId, updates });","handlingStrategy":"validation","validationCode":"// track joined rooms client-side and gate every emission\nconst joined = new Set<string>();\nsocket.on('space:join-ack', ({ spaceId, roomType }) => joined.add(`${spaceId}:${roomType ?? 'sync'}`));\nfunction assertJoined(spaceId: string, roomType = 'sync') {\n  if (!joined.has(`${spaceId}:${roomType}`)) {\n    throw new Error(`not joined: ${spaceId}:${roomType}`);\n  }\n}","typeGuard":"function isRoomJoined(joined: Set<string>, spaceId: string, roomType = 'sync'): boolean {\n  return joined.has(`${spaceId}:${roomType}`);\n}","tryCatchPattern":"try {\n  await pushUpdates(spaceId, docId, updates);\n} catch (e) {\n  if (e?.code === 'not_in_space') {\n    await joinSpace(socket, spaceId, docId); // re-join, then retry exactly once\n    await pushUpdates(spaceId, docId, updates);\n  } else {\n    throw e;\n  }\n}","preventionTips":["Treat join as an awaited prerequisite: never emit doc:update or awareness messages before the join ack","Join the `${docId}:awareness` room separately before broadcasting awareness updates","Keep a client-side set of joined rooms and clear it on disconnect/reconnect so stale state cannot be trusted","Log the spaceType/spaceId of failed pushes to catch adapter mismatches early"],"tags":["websocket","collaboration","rooms","sync"],"backgroundTag":"websocket-room-not-joined","analyzedSha":"2af30773aecd567f09b346e7b72fc69143144057","analyzedAt":"2026-08-18T21:16:52.546Z","contentChangedAt":"2026-08-18T21:16:52.546Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}