From d2baaef27d7b7ceedd981626abdae037c35d977c Mon Sep 17 00:00:00 2001 From: wiaderwek Date: Fri, 26 Jan 2024 17:21:09 +0100 Subject: [PATCH] BC-5736 - Redis for tldraw (#4542) * Introduce redis for tldraw --------- Co-authored-by: blazejpass Co-authored-by: Tomasz Wiaderek Co-authored-by: davwas Co-authored-by: WojciechGrancow <116577704+WojciechGrancow@users.noreply.github.com> Co-authored-by: Cedric Evers <12080057+CeEv@users.noreply.github.com> --- .github/workflows/dependency-review.yml | 3 +- apps/server/src/modules/tldraw/config.ts | 12 +- .../api-test/tldraw.controller.api.spec.ts | 2 +- .../controller/api-test/tldraw.ws.api.spec.ts | 57 +- .../tldraw/controller/tldraw.controller.ts | 2 +- .../modules/tldraw/controller/tldraw.ws.ts | 17 +- .../server/src/modules/tldraw/domain/index.ts | 1 + .../tldraw/domain/ws-shared-doc.do.spec.ts | 170 +--- .../modules/tldraw/domain/ws-shared-doc.do.ts | 78 +- .../entities/tldraw-drawing.entity.spec.ts | 16 +- .../tldraw/entities/tldraw-drawing.entity.ts | 56 +- .../src/modules/tldraw/factory/index.ts | 1 - apps/server/src/modules/tldraw/index.ts | 1 + .../close-connection.loggable.spec.ts | 24 + .../loggable/close-connection.loggable.ts | 19 + .../src/modules/tldraw/loggable/index.ts | 8 + .../mongo-transaction-error.loggable.spec.ts | 24 + .../mongo-transaction-error.loggable.ts | 19 + .../loggable/redis-error.loggable.spec.ts | 25 + .../tldraw/loggable/redis-error.loggable.ts | 19 + .../redis-publish-error.loggable.spec.ts | 25 + .../loggable/redis-publish-error.loggable.ts | 19 + .../websocket-close-error.loggable.spec.ts | 1 - .../loggable/websocket-error.loggable.spec.ts | 20 + .../loggable/websocket-error.loggable.ts | 19 + .../websocket-message-error.loggable.spec.ts | 24 + .../websocket-message-error.loggable.ts | 19 + .../ws-shared-doc-error.loggable.spec.ts | 26 + .../loggable/ws-shared-doc-error.loggable.ts | 19 + apps/server/src/modules/tldraw/redis/index.ts | 1 + .../tldraw/redis/tldraw-redis.factory.spec.ts | 54 + .../tldraw/redis/tldraw-redis.factory.ts | 31 + apps/server/src/modules/tldraw/repo/index.ts | 1 + .../modules/tldraw/repo/key.factory.spec.ts | 106 ++ .../src/modules/tldraw/repo/key.factory.ts | 54 + .../tldraw/repo/tldraw-board.repo.spec.ts | 169 +-- .../modules/tldraw/repo/tldraw-board.repo.ts | 62 +- .../modules/tldraw/repo/tldraw.repo.spec.ts | 59 +- .../src/modules/tldraw/repo/tldraw.repo.ts | 52 +- .../src/modules/tldraw/repo/y-mongodb.spec.ts | 262 +++++ .../src/modules/tldraw/repo/y-mongodb.ts | 279 +++++ .../src/modules/tldraw/service/index.ts | 2 +- .../tldraw/service/tldraw.service.spec.ts | 17 +- .../modules/tldraw/service/tldraw.service.ts | 2 +- .../tldraw/service/tldraw.ws.service.spec.ts | 958 ++++++++++++++++-- .../tldraw/service/tldraw.ws.service.ts | 369 +++++-- .../src/modules/tldraw/testing/index.ts | 3 + .../src/modules/tldraw/testing/testConfig.ts | 12 + .../{factory => testing}/tldraw.factory.ts | 11 +- .../src/modules/tldraw/tldraw-test.module.ts | 3 +- .../modules/tldraw/tldraw-ws-test.module.ts | 23 +- .../src/modules/tldraw/tldraw-ws.module.ts | 34 +- .../src/modules/tldraw/tldraw.module.ts | 20 +- .../awareness-connections-update-type.ts | 5 + apps/server/src/modules/tldraw/types/index.ts | 4 +- .../modules/tldraw/types/persistence-type.ts | 6 - .../types/redis-connection-type.enum.ts | 4 + .../tldraw/types/y-transaction-type.ts | 3 + .../testing/factory/tldraw.ws.factory.ts | 11 +- apps/server/src/shared/testing/index.ts | 1 + .../testing/web-socket-ready-state-enum.ts | 4 + config/default.schema.json | 15 +- config/test.json | 2 +- package-lock.json | 411 +++++--- package.json | 13 +- 65 files changed, 2945 insertions(+), 844 deletions(-) create mode 100644 apps/server/src/modules/tldraw/domain/index.ts delete mode 100644 apps/server/src/modules/tldraw/factory/index.ts create mode 100644 apps/server/src/modules/tldraw/loggable/close-connection.loggable.spec.ts create mode 100644 apps/server/src/modules/tldraw/loggable/close-connection.loggable.ts create mode 100644 apps/server/src/modules/tldraw/loggable/index.ts create mode 100644 apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.spec.ts create mode 100644 apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.ts create mode 100644 apps/server/src/modules/tldraw/loggable/redis-error.loggable.spec.ts create mode 100644 apps/server/src/modules/tldraw/loggable/redis-error.loggable.ts create mode 100644 apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.spec.ts create mode 100644 apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.ts create mode 100644 apps/server/src/modules/tldraw/loggable/websocket-error.loggable.spec.ts create mode 100644 apps/server/src/modules/tldraw/loggable/websocket-error.loggable.ts create mode 100644 apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.spec.ts create mode 100644 apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.ts create mode 100644 apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.spec.ts create mode 100644 apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.ts create mode 100644 apps/server/src/modules/tldraw/redis/index.ts create mode 100644 apps/server/src/modules/tldraw/redis/tldraw-redis.factory.spec.ts create mode 100644 apps/server/src/modules/tldraw/redis/tldraw-redis.factory.ts create mode 100644 apps/server/src/modules/tldraw/repo/key.factory.spec.ts create mode 100644 apps/server/src/modules/tldraw/repo/key.factory.ts create mode 100644 apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts create mode 100644 apps/server/src/modules/tldraw/repo/y-mongodb.ts create mode 100644 apps/server/src/modules/tldraw/testing/index.ts create mode 100644 apps/server/src/modules/tldraw/testing/testConfig.ts rename apps/server/src/modules/tldraw/{factory => testing}/tldraw.factory.ts (61%) create mode 100644 apps/server/src/modules/tldraw/types/awareness-connections-update-type.ts delete mode 100644 apps/server/src/modules/tldraw/types/persistence-type.ts create mode 100644 apps/server/src/modules/tldraw/types/redis-connection-type.enum.ts create mode 100644 apps/server/src/modules/tldraw/types/y-transaction-type.ts create mode 100644 apps/server/src/shared/testing/web-socket-ready-state-enum.ts diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 73aaec3be87..d0f7127061a 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -13,7 +13,6 @@ jobs: - name: 'Dependency Review' uses: actions/dependency-review-action@v3 with: - allow-licenses: AGPL-3.0-only, LGPL-3.0, MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, X11, 0BSD, GPL-3.0 AND BSD-3-Clause-Clear, Unlicense - allow-dependencies-licenses: 'pkg:npm/parse-mongo-url' + allow-licenses: AGPL-3.0-only, LGPL-3.0, MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, X11, 0BSD, GPL-3.0, Unlicense # temporarily ignore dependency error for upgrade mongodb 4.9 to 4.11, remove when mikroORM is upgraded to 5.9 allow-ghsas: 'GHSA-vxvm-qww3-2fh7' diff --git a/apps/server/src/modules/tldraw/config.ts b/apps/server/src/modules/tldraw/config.ts index 50219da6516..9025adb9a25 100644 --- a/apps/server/src/modules/tldraw/config.ts +++ b/apps/server/src/modules/tldraw/config.ts @@ -3,29 +3,27 @@ import { Configuration } from '@hpi-schul-cloud/commons'; export interface TldrawConfig { NEST_LOG_LEVEL: string; INCOMING_REQUEST_TIMEOUT: number; - TLDRAW_DB_COLLECTION_NAME: string; TLDRAW_DB_FLUSH_SIZE: string; - TLDRAW_DB_MULTIPLE_COLLECTIONS: boolean; CONNECTION_STRING: string; FEATURE_TLDRAW_ENABLED: boolean; TLDRAW_PING_TIMEOUT: number; TLDRAW_GC_ENABLED: number; + REDIS_URI: string; API_HOST: number; + TLDRAW_MAX_DOCUMENT_SIZE: number; } -const tldrawConnectionString: string = Configuration.get('TLDRAW_DB_URL') as string; - const tldrawConfig = { NEST_LOG_LEVEL: Configuration.get('NEST_LOG_LEVEL') as string, INCOMING_REQUEST_TIMEOUT: Configuration.get('INCOMING_REQUEST_TIMEOUT_API') as number, - TLDRAW_DB_COLLECTION_NAME: Configuration.get('TLDRAW__DB_COLLECTION_NAME') as string, TLDRAW_DB_FLUSH_SIZE: Configuration.get('TLDRAW__DB_FLUSH_SIZE') as number, - TLDRAW_DB_MULTIPLE_COLLECTIONS: Configuration.get('TLDRAW__DB_MULTIPLE_COLLECTIONS') as boolean, FEATURE_TLDRAW_ENABLED: Configuration.get('FEATURE_TLDRAW_ENABLED') as boolean, - CONNECTION_STRING: tldrawConnectionString, + CONNECTION_STRING: Configuration.get('TLDRAW_DB_URL') as string, TLDRAW_PING_TIMEOUT: Configuration.get('TLDRAW__PING_TIMEOUT') as number, TLDRAW_GC_ENABLED: Configuration.get('TLDRAW__GC_ENABLED') as boolean, + REDIS_URI: Configuration.has('REDIS_URI') ? (Configuration.get('REDIS_URI') as string) : null, API_HOST: Configuration.get('API_HOST') as string, + TLDRAW_MAX_DOCUMENT_SIZE: Configuration.get('TLDRAW__MAX_DOCUMENT_SIZE') as number, }; export const SOCKET_PORT = Configuration.get('TLDRAW__SOCKET_PORT') as number; diff --git a/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts index 6d04d1d5871..ca9f9b43a1c 100644 --- a/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts +++ b/apps/server/src/modules/tldraw/controller/api-test/tldraw.controller.api.spec.ts @@ -7,7 +7,7 @@ import { Logger } from '@src/core/logger'; import { TldrawService } from '../../service'; import { TldrawController } from '..'; import { TldrawRepo } from '../../repo'; -import { tldrawEntityFactory } from '../../factory'; +import { tldrawEntityFactory } from '../../testing'; const baseRouteName = '/tldraw-document'; describe('tldraw controller (api)', () => { diff --git a/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts b/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts index fae5c23050f..e1ddd788e2a 100644 --- a/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts +++ b/apps/server/src/modules/tldraw/controller/api-test/tldraw.ws.api.spec.ts @@ -3,16 +3,23 @@ import { Test } from '@nestjs/testing'; import WebSocket from 'ws'; import { TextEncoder } from 'util'; import { INestApplication } from '@nestjs/common'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { createConfigModuleOptions } from '@src/config'; +import { Logger } from '@src/core/logger'; import { of, throwError } from 'rxjs'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ConfigModule } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { AxiosError, AxiosHeaders, AxiosResponse } from 'axios'; import { axiosResponseFactory } from '@shared/testing'; -import { WsCloseCodeEnum, WsCloseMessageEnum } from '../../types'; -import { TldrawWsTestModule } from '../../tldraw-ws-test.module'; +import { TldrawRedisFactory } from '../../redis'; +import { TldrawDrawing } from '../../entities'; import { TldrawWsService } from '../../service'; -import { TestConnection } from '../../testing/test-connection'; -import { TldrawWs } from '../tldraw.ws'; +import { TldrawBoardRepo, TldrawRepo, YMongodb } from '../../repo'; +import { TestConnection, tldrawTestConfig } from '../../testing'; +import { MetricsService } from '../../metrics'; +import { TldrawWs } from '..'; +import { WsCloseCodeEnum, WsCloseMessageEnum } from '../../types'; describe('WebSocketController (WsAdapter)', () => { let app: INestApplication; @@ -29,14 +36,32 @@ describe('WebSocketController (WsAdapter)', () => { beforeAll(async () => { const testingModule = await Test.createTestingModule({ - imports: [TldrawWsTestModule], + imports: [ + MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), + ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), + ], providers: [ + TldrawWs, + TldrawWsService, + TldrawBoardRepo, + YMongodb, + MetricsService, + TldrawRedisFactory, + { + provide: TldrawRepo, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, { provide: HttpService, useValue: createMock(), }, ], }).compile(); + gateway = testingModule.get(TldrawWs); wsService = testingModule.get(TldrawWsService); httpService = testingModule.get(HttpService); @@ -49,10 +74,6 @@ describe('WebSocketController (WsAdapter)', () => { await app.close(); }); - beforeEach(() => { - jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); - }); - afterEach(() => { jest.clearAllMocks(); }); @@ -63,7 +84,6 @@ describe('WebSocketController (WsAdapter)', () => { jest.spyOn(Uint8Array.prototype, 'reduce').mockReturnValueOnce(1); ws = await TestConnection.setupWs(wsUrl, 'TEST'); - const { buffer } = getMessage(); return { handleConnectionSpy, buffer }; @@ -71,6 +91,7 @@ describe('WebSocketController (WsAdapter)', () => { it(`should handle connection`, async () => { const { handleConnectionSpy, buffer } = await setup(); + ws.send(buffer, () => {}); expect(handleConnectionSpy).toHaveBeenCalledTimes(1); @@ -110,10 +131,10 @@ describe('WebSocketController (WsAdapter)', () => { it(`should handle 2 connections at same doc and data transfer`, async () => { const { handleConnectionSpy, ws2, buffer } = await setup(); + ws.send(buffer); ws2.send(buffer); - expect(handleConnectionSpy).toHaveBeenCalled(); expect(handleConnectionSpy).toHaveBeenCalledTimes(2); handleConnectionSpy.mockRestore(); @@ -140,7 +161,7 @@ describe('WebSocketController (WsAdapter)', () => { expect(wsCloseSpy).toHaveBeenCalledWith( WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, - WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE + Buffer.from(WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE) ); httpGetCallSpy.mockRestore(); @@ -157,7 +178,7 @@ describe('WebSocketController (WsAdapter)', () => { expect(wsCloseSpy).toHaveBeenCalledWith( WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, - WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE + Buffer.from(WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE) ); httpGetCallSpy.mockRestore(); @@ -170,10 +191,12 @@ describe('WebSocketController (WsAdapter)', () => { const setup = () => { const setupConnectionSpy = jest.spyOn(wsService, 'setupWSConnection'); const wsCloseSpy = jest.spyOn(WebSocket.prototype, 'close'); + const closeConnSpy = jest.spyOn(wsService, 'closeConn').mockRejectedValue(new Error('error')); return { setupConnectionSpy, wsCloseSpy, + closeConnSpy, }; }; @@ -186,7 +209,7 @@ describe('WebSocketController (WsAdapter)', () => { expect(wsCloseSpy).toHaveBeenCalledWith( WsCloseCodeEnum.WS_CLIENT_BAD_REQUEST_CODE, - WsCloseMessageEnum.WS_CLIENT_BAD_REQUEST_MESSAGE + Buffer.from(WsCloseMessageEnum.WS_CLIENT_BAD_REQUEST_MESSAGE) ); wsCloseSpy.mockRestore(); @@ -211,7 +234,7 @@ describe('WebSocketController (WsAdapter)', () => { expect(wsCloseSpy).toHaveBeenCalledWith( WsCloseCodeEnum.WS_CLIENT_NOT_FOUND_CODE, - WsCloseMessageEnum.WS_CLIENT_NOT_FOUND_MESSAGE + Buffer.from(WsCloseMessageEnum.WS_CLIENT_NOT_FOUND_MESSAGE) ); wsCloseSpy.mockRestore(); @@ -232,7 +255,7 @@ describe('WebSocketController (WsAdapter)', () => { expect(wsCloseSpy).toHaveBeenCalledWith( WsCloseCodeEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_CODE, - WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE + Buffer.from(WsCloseMessageEnum.WS_CLIENT_UNAUTHORISED_CONNECTION_MESSAGE) ); wsCloseSpy.mockRestore(); @@ -281,7 +304,7 @@ describe('WebSocketController (WsAdapter)', () => { expect(setupConnectionSpy).toHaveBeenCalledWith(expect.anything(), 'TEST'); expect(wsCloseSpy).toHaveBeenCalledWith( WsCloseCodeEnum.WS_CLIENT_FAILED_CONNECTION_CODE, - WsCloseMessageEnum.WS_CLIENT_FAILED_CONNECTION_MESSAGE + Buffer.from(WsCloseMessageEnum.WS_CLIENT_FAILED_CONNECTION_MESSAGE) ); wsCloseSpy.mockRestore(); diff --git a/apps/server/src/modules/tldraw/controller/tldraw.controller.ts b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts index 3bc7137f5ec..78694c1b6e3 100644 --- a/apps/server/src/modules/tldraw/controller/tldraw.controller.ts +++ b/apps/server/src/modules/tldraw/controller/tldraw.controller.ts @@ -1,7 +1,7 @@ import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Controller, Delete, ForbiddenException, HttpCode, NotFoundException, Param } from '@nestjs/common'; import { ApiValidationError } from '@shared/common'; -import { TldrawService } from '../service/tldraw.service'; +import { TldrawService } from '../service'; import { TldrawDeleteParams } from './tldraw.params'; @ApiTags('Tldraw Document') diff --git a/apps/server/src/modules/tldraw/controller/tldraw.ws.ts b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts index 66fd4eab543..c211f6eff31 100644 --- a/apps/server/src/modules/tldraw/controller/tldraw.ws.ts +++ b/apps/server/src/modules/tldraw/controller/tldraw.ws.ts @@ -8,7 +8,7 @@ import { Logger } from '@src/core/logger'; import { AxiosError } from 'axios'; import { firstValueFrom } from 'rxjs'; import { HttpService } from '@nestjs/axios'; -import { WebsocketCloseErrorLoggable } from '../loggable/websocket-close-error.loggable'; +import { WebsocketCloseErrorLoggable } from '../loggable'; import { TldrawConfig, SOCKET_PORT } from '../config'; import { WsCloseCodeEnum, WsCloseMessageEnum } from '../types'; import { TldrawWsService } from '../service'; @@ -68,7 +68,7 @@ export class TldrawWs implements OnGatewayInit, OnGatewayConnection { } try { - this.tldrawWsService.setupWSConnection(client, docName); + await this.tldrawWsService.setupWSConnection(client, docName); } catch (err) { this.closeClientAndLogError( client, @@ -79,17 +79,8 @@ export class TldrawWs implements OnGatewayInit, OnGatewayConnection { } } - public afterInit(): void { - this.tldrawWsService.setPersistence({ - bindState: async (docName, ydoc) => { - await this.tldrawWsService.updateDocument(docName, ydoc); - }, - writeState: async (docName) => { - // This is called when all connections to the document are closed. - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access - await this.tldrawWsService.flushDocument(docName); - }, - }); + public async afterInit(): Promise { + await this.tldrawWsService.createDbIndex(); } private getDocNameFromRequest(request: Request): string { diff --git a/apps/server/src/modules/tldraw/domain/index.ts b/apps/server/src/modules/tldraw/domain/index.ts new file mode 100644 index 00000000000..6e30b3fa99e --- /dev/null +++ b/apps/server/src/modules/tldraw/domain/index.ts @@ -0,0 +1 @@ +export * from './ws-shared-doc.do'; diff --git a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts index 54f43cb23ed..791e4108f8e 100644 --- a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts +++ b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.spec.ts @@ -1,169 +1,19 @@ -import { Test } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import WebSocket from 'ws'; -import { WsAdapter } from '@nestjs/platform-ws'; -import { CoreModule } from '@src/core'; -import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions } from '@src/config'; -import { createMock } from '@golevelup/ts-jest'; -import * as AwarenessProtocol from 'y-protocols/awareness'; -import { HttpService } from '@nestjs/axios'; -import { config } from '../config'; -import { TldrawBoardRepo } from '../repo/tldraw-board.repo'; -import { TldrawWsService } from '../service'; import { WsSharedDocDo } from './ws-shared-doc.do'; -import { TldrawWs } from '../controller'; -import { TestConnection } from '../testing/test-connection'; describe('WsSharedDocDo', () => { - let app: INestApplication; - let ws: WebSocket; - let service: TldrawWsService; - - const gatewayPort = 3346; - const wsUrl = TestConnection.getWsUrl(gatewayPort); - - jest.useFakeTimers(); - - beforeAll(async () => { - const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; - const testingModule = await Test.createTestingModule({ - imports, - providers: [ - TldrawWs, - TldrawBoardRepo, - { - provide: TldrawWsService, - useValue: createMock(), - }, - { - provide: HttpService, - useValue: createMock(), - }, - ], - }).compile(); - - service = testingModule.get(TldrawWsService); - app = testingModule.createNestApplication(); - app.useWebSocketAdapter(new WsAdapter(app)); - jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); - await app.init(); + beforeAll(() => { + jest.useFakeTimers(); }); - afterAll(async () => { - await app.close(); - }); - - describe('ydoc client awareness change handler', () => { - const setup = async () => { - ws = await TestConnection.setupWs(wsUrl, 'TEST'); - - class MockAwareness { - on = jest.fn(); - } - const doc = new WsSharedDocDo('TEST', service); - doc.awareness = new MockAwareness() as unknown as AwarenessProtocol.Awareness; - const awarenessMetaMock = new Map(); - awarenessMetaMock.set(1, { clock: 11, lastUpdated: 21 }); - awarenessMetaMock.set(2, { clock: 12, lastUpdated: 22 }); - awarenessMetaMock.set(3, { clock: 13, lastUpdated: 23 }); - const awarenessStatesMock = new Map(); - awarenessStatesMock.set(1, { updating: '21' }); - awarenessStatesMock.set(2, { updating: '22' }); - awarenessStatesMock.set(3, { updating: '23' }); - doc.awareness.states = awarenessStatesMock; - doc.awareness.meta = awarenessMetaMock; - - const sendSpy = jest.spyOn(service, 'send').mockReturnValue(); - - const mockIDs = new Set(); - const mockConns = new Map>(); - mockConns.set(ws, mockIDs); - doc.conns = mockConns; - - return { - sendSpy, - doc, - mockIDs, - mockConns, - }; - }; - - describe('when adding two clients states', () => { - it('should have two registered clients states', async () => { - const { sendSpy, doc, mockIDs } = await setup(); - const awarenessUpdate = { - added: [1, 3], - updated: [], - removed: [], - }; - doc.awarenessChangeHandler(awarenessUpdate, ws); - - expect(mockIDs.size).toBe(2); - expect(mockIDs.has(1)).toBe(true); - expect(mockIDs.has(3)).toBe(true); - expect(mockIDs.has(2)).toBe(false); - expect(sendSpy).toBeCalled(); - - ws.close(); - sendSpy.mockRestore(); - }); - }); - - describe('when removing one of two existing clients states', () => { - it('should have one registered client state', async () => { - const { sendSpy, doc, mockIDs } = await setup(); - let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { - added: [1, 3], - updated: [], - removed: [], - }; - - doc.awarenessChangeHandler(awarenessUpdate, ws); - - awarenessUpdate = { - added: [], - updated: [], - removed: [1], - }; - - doc.awarenessChangeHandler(awarenessUpdate, ws); - - expect(mockIDs.size).toBe(1); - expect(mockIDs.has(1)).toBe(false); - expect(mockIDs.has(3)).toBe(true); - expect(sendSpy).toBeCalled(); - - ws.close(); - sendSpy.mockRestore(); - }); - }); - - describe('when updating client state', () => { - it('should not change number of states', async () => { - const { sendSpy, doc, mockIDs } = await setup(); - let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { - added: [1], - updated: [], - removed: [], - }; - - doc.awarenessChangeHandler(awarenessUpdate, ws); - - awarenessUpdate = { - added: [], - updated: [1], - removed: [], - }; - - doc.awarenessChangeHandler(awarenessUpdate, ws); - - expect(mockIDs.size).toBe(1); - expect(mockIDs.has(1)).toBe(true); - expect(sendSpy).toBeCalled(); + describe('constructor', () => { + describe('when constructor is called', () => { + it('should create a new object with correct properties', () => { + const doc = new WsSharedDocDo('docname'); - ws.close(); - sendSpy.mockRestore(); + expect(doc).toBeInstanceOf(WsSharedDocDo); + expect(doc.name).toEqual('docname'); + expect(doc.awarenessChannel).toEqual('docname-awareness'); + expect(doc.awareness).toBeDefined(); }); }); }); diff --git a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts index a7084ada0da..a008955da07 100644 --- a/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts +++ b/apps/server/src/modules/tldraw/domain/ws-shared-doc.do.ts @@ -1,88 +1,26 @@ -import { Doc } from 'yjs'; import WebSocket from 'ws'; -import { Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness'; -import { encoding } from 'lib0'; -import { TldrawWsService } from '@modules/tldraw/service'; -import { WSMessageType } from '../types/connection-enum'; +import { Doc } from 'yjs'; +import { Awareness } from 'y-protocols/awareness'; export class WsSharedDocDo extends Doc { public name: string; - public conns: Map>; + public connections: Map>; public awareness: Awareness; + public awarenessChannel: string; + /** * @param {string} name - * @param {TldrawWsService} tldrawService * @param {boolean} gcEnabled */ - constructor(name: string, private tldrawService: TldrawWsService, gcEnabled = true) { + constructor(name: string, gcEnabled = true) { super({ gc: gcEnabled }); this.name = name; - this.conns = new Map(); + this.connections = new Map(); this.awareness = new Awareness(this); this.awareness.setLocalState(null); - - this.awareness.on('update', this.awarenessChangeHandler); - this.on('update', (update: Uint8Array, origin, doc: WsSharedDocDo) => { - this.tldrawService.updateHandler(update, origin, doc); - }); - } - - /** - * @param {{ added: Array, updated: Array, removed: Array }} changes - * @param {WebSocket | null} wsConnection Origin is the connection that made the change - */ - public awarenessChangeHandler = ( - { added, updated, removed }: { added: Array; updated: Array; removed: Array }, - wsConnection: WebSocket | null - ): void => { - const changedClients = this.manageClientsConnections({ added, updated, removed }, wsConnection); - const buff = this.prepareAwarenessMessage(changedClients); - this.sendAwarenessMessage(buff); - }; - - /** - * @param {{ added: Array, updated: Array, removed: Array }} changes - * @param {WebSocket | null} wsConnection Origin is the connection that made the change - */ - private manageClientsConnections( - { added, updated, removed }: { added: Array; updated: Array; removed: Array }, - wsConnection: WebSocket | null - ): number[] { - const changedClients = added.concat(updated, removed); - if (wsConnection !== null) { - const connControlledIDs = this.conns.get(wsConnection); - if (connControlledIDs !== undefined) { - added.forEach((clientID) => { - connControlledIDs.add(clientID); - }); - removed.forEach((clientID) => { - connControlledIDs.delete(clientID); - }); - } - } - return changedClients; - } - - /** - * @param changedClients array of changed clients - */ - private prepareAwarenessMessage(changedClients: number[]): Uint8Array { - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, WSMessageType.AWARENESS); - encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(this.awareness, changedClients)); - const message = encoding.toUint8Array(encoder); - return message; - } - - /** - * @param {{ Uint8Array }} buff encoded message about changes - */ - private sendAwarenessMessage(buff: Uint8Array): void { - this.conns.forEach((_, c) => { - this.tldrawService.send(this, c, buff); - }); + this.awarenessChannel = `${name}-awareness`; } } diff --git a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts index a85ae26319c..2698056a0ef 100644 --- a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts +++ b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.spec.ts @@ -1,4 +1,5 @@ import { setupEntities } from '@shared/testing'; +import { tldrawEntityFactory } from '../testing'; import { TldrawDrawing } from './tldraw-drawing.entity'; describe('tldraw entity', () => { @@ -9,20 +10,9 @@ describe('tldraw entity', () => { describe('constructor', () => { describe('when creating a tldraw doc', () => { it('should create drawing', () => { - const tldraw = new TldrawDrawing({ - docName: 'test', - version: 'v1_tst', - value: 'bindatamock', - _id: 'test-id', - clock: 0, - action: 'update', - }); - expect(tldraw).toBeInstanceOf(TldrawDrawing); - }); + const tldraw = tldrawEntityFactory.build(); - it('should throw with empty docName', () => { - const call = () => new TldrawDrawing({ docName: '', version: 'v1_tst', value: 'bindatamock', _id: 'test-id' }); - expect(call).toThrow(); + expect(tldraw).toBeInstanceOf(TldrawDrawing); }); }); }); diff --git a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts index b6db76a3f2e..daaa93090e5 100644 --- a/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts +++ b/apps/server/src/modules/tldraw/entities/tldraw-drawing.entity.ts @@ -1,25 +1,19 @@ -import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; -import { BadRequestException } from '@nestjs/common'; -import { ObjectId } from '@mikro-orm/mongodb'; +import { Entity, Index, Property } from '@mikro-orm/core'; +import { BaseEntity } from '@shared/domain/entity/base.entity'; -@Entity({ tableName: 'drawings' }) -export class TldrawDrawing { - constructor(props: TldrawDrawingProps) { - if (!props.docName) throw new BadRequestException('Tldraw element should have name.'); - this.docName = props.docName; - this.version = props.version; - this.value = props.value; - if (typeof props.clock === 'number') { - this.clock = props.clock; - } - if (props.action) { - this.action = props.action; - } - } - - @PrimaryKey() - _id!: ObjectId; +export interface TldrawDrawingProps { + id?: string; + docName: string; + version: string; + clock?: number; + action?: string; + value: Buffer; + part?: number; +} +@Entity({ tableName: 'drawings' }) +@Index({ properties: ['version', 'docName', 'action', 'clock', 'part'] }) +export class TldrawDrawing extends BaseEntity { @Property({ nullable: false }) docName: string; @@ -27,20 +21,24 @@ export class TldrawDrawing { version: string; @Property({ nullable: false }) - value: string; + value: Buffer; @Property({ nullable: true }) clock?: number; @Property({ nullable: true }) action?: string; -} -export interface TldrawDrawingProps { - _id?: string; - docName: string; - version: string; - clock?: number; - action?: string; - value: string; + @Property({ nullable: true }) + part?: number; + + constructor(props: TldrawDrawingProps) { + super(); + this.docName = props.docName; + this.version = props.version; + this.value = props.value; + this.clock = props.clock; + this.action = props.action; + this.part = props.part; + } } diff --git a/apps/server/src/modules/tldraw/factory/index.ts b/apps/server/src/modules/tldraw/factory/index.ts deleted file mode 100644 index 7a5f39169bf..00000000000 --- a/apps/server/src/modules/tldraw/factory/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './tldraw.factory'; diff --git a/apps/server/src/modules/tldraw/index.ts b/apps/server/src/modules/tldraw/index.ts index 8966e72549e..1db3579d445 100644 --- a/apps/server/src/modules/tldraw/index.ts +++ b/apps/server/src/modules/tldraw/index.ts @@ -1,3 +1,4 @@ export * from './tldraw.module'; export * from './tldraw-test.module'; export * from './tldraw-ws.module'; +export * from './tldraw-ws-test.module'; diff --git a/apps/server/src/modules/tldraw/loggable/close-connection.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/close-connection.loggable.spec.ts new file mode 100644 index 00000000000..3b32f617c1a --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/close-connection.loggable.spec.ts @@ -0,0 +1,24 @@ +import { CloseConnectionLoggable } from './close-connection.loggable'; + +describe('CloseConnectionLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const error = new Error('test'); + const loggable = new CloseConnectionLoggable(error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Close web socket connection error', + type: 'CLOSE_WEB_SOCKET_CONNECTION_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/close-connection.loggable.ts b/apps/server/src/modules/tldraw/loggable/close-connection.loggable.ts new file mode 100644 index 00000000000..6f19ddaae3a --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/close-connection.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class CloseConnectionLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Close web socket connection error`, + type: `CLOSE_WEB_SOCKET_CONNECTION_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/index.ts b/apps/server/src/modules/tldraw/loggable/index.ts new file mode 100644 index 00000000000..4b5746f33dc --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/index.ts @@ -0,0 +1,8 @@ +export * from './mongo-transaction-error.loggable'; +export * from './redis-error.loggable'; +export * from './redis-publish-error.loggable'; +export * from './websocket-error.loggable'; +export * from './websocket-close-error.loggable'; +export * from './websocket-message-error.loggable'; +export * from './ws-shared-doc-error.loggable'; +export * from './close-connection.loggable'; diff --git a/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.spec.ts new file mode 100644 index 00000000000..e109ece222f --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.spec.ts @@ -0,0 +1,24 @@ +import { MongoTransactionErrorLoggable } from './mongo-transaction-error.loggable'; + +describe('MongoTransactionErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const error = new Error('test'); + const loggable = new MongoTransactionErrorLoggable(error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Error while saving transaction', + type: 'MONGO_TRANSACTION_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.ts new file mode 100644 index 00000000000..15153388f3c --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/mongo-transaction-error.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class MongoTransactionErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Error while saving transaction`, + type: `MONGO_TRANSACTION_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/redis-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/redis-error.loggable.spec.ts new file mode 100644 index 00000000000..1208015c2a8 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/redis-error.loggable.spec.ts @@ -0,0 +1,25 @@ +import { RedisErrorLoggable } from './redis-error.loggable'; + +describe('RedisGeneralErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const type = 'SUB'; + const error = new Error('test'); + const loggable = new RedisErrorLoggable(type, error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Redis SUB error', + type: 'REDIS_SUB_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/redis-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/redis-error.loggable.ts new file mode 100644 index 00000000000..3ef9e3bbcfe --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/redis-error.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class RedisErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly connectionType: 'PUB' | 'SUB', private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Redis ${this.connectionType} error`, + type: `REDIS_${this.connectionType}_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.spec.ts new file mode 100644 index 00000000000..f942d4d4091 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.spec.ts @@ -0,0 +1,25 @@ +import { RedisPublishErrorLoggable } from './redis-publish-error.loggable'; + +describe('RedisPublishErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const type = 'document'; + const error = new Error('test'); + const loggable = new RedisPublishErrorLoggable(type, error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Error while publishing document state to Redis', + type: 'REDIS_PUBLISH_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.ts new file mode 100644 index 00000000000..f96a21bfba0 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/redis-publish-error.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class RedisPublishErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly type: 'document' | 'awareness', private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Error while publishing ${this.type} state to Redis`, + type: `REDIS_PUBLISH_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts index ba0b21c9714..b14fb64c1e7 100644 --- a/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts +++ b/apps/server/src/modules/tldraw/loggable/websocket-close-error.loggable.spec.ts @@ -7,7 +7,6 @@ describe('WebsocketCloseErrorLoggable', () => { const errorMessage = 'message'; const loggable = new WebsocketCloseErrorLoggable(error, errorMessage); - return { loggable, error, errorMessage }; }; diff --git a/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.spec.ts new file mode 100644 index 00000000000..40c4ae922f3 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.spec.ts @@ -0,0 +1,20 @@ +import { WebsocketErrorLoggable } from './websocket-error.loggable'; + +describe('WebsocketErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const error = new Error('test'); + + const loggable = new WebsocketErrorLoggable(error); + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ message: 'Websocket error', error, type: 'WEBSOCKET_ERROR' }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.ts new file mode 100644 index 00000000000..ab58549c220 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-error.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class WebsocketErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: 'Websocket error', + type: 'WEBSOCKET_ERROR', + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.spec.ts new file mode 100644 index 00000000000..272efcf618b --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.spec.ts @@ -0,0 +1,24 @@ +import { WebsocketMessageErrorLoggable } from './websocket-message-error.loggable'; + +describe('WebsocketMessageErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const error = new Error('test'); + const loggable = new WebsocketMessageErrorLoggable(error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Error while handling websocket message', + type: 'WEBSOCKET_MESSAGE_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.ts new file mode 100644 index 00000000000..0309c5aa21b --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/websocket-message-error.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class WebsocketMessageErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Error while handling websocket message`, + type: `WEBSOCKET_MESSAGE_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.spec.ts b/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.spec.ts new file mode 100644 index 00000000000..d18fcff8e9a --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.spec.ts @@ -0,0 +1,26 @@ +import { WsSharedDocErrorLoggable } from './ws-shared-doc-error.loggable'; + +describe('WsSharedDocErrorLoggable', () => { + describe('getLogMessage', () => { + const setup = () => { + const docName = 'docname'; + const message = 'error message'; + const error = new Error('test'); + const loggable = new WsSharedDocErrorLoggable(docName, message, error); + + return { loggable, error }; + }; + + it('should return a loggable message', () => { + const { loggable, error } = setup(); + + const message = loggable.getLogMessage(); + + expect(message).toEqual({ + message: 'Error in document docname: error message', + type: 'WSSHAREDDOC_ERROR', + error, + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.ts b/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.ts new file mode 100644 index 00000000000..4ddd8102ed0 --- /dev/null +++ b/apps/server/src/modules/tldraw/loggable/ws-shared-doc-error.loggable.ts @@ -0,0 +1,19 @@ +import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; + +export class WsSharedDocErrorLoggable implements Loggable { + private error: Error | undefined; + + constructor(private readonly docName: string, private readonly message: string, private readonly err: unknown) { + if (err instanceof Error) { + this.error = err; + } + } + + getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage { + return { + message: `Error in document ${this.docName}: ${this.message}`, + type: `WSSHAREDDOC_ERROR`, + error: this.error, + }; + } +} diff --git a/apps/server/src/modules/tldraw/redis/index.ts b/apps/server/src/modules/tldraw/redis/index.ts new file mode 100644 index 00000000000..a35dca09755 --- /dev/null +++ b/apps/server/src/modules/tldraw/redis/index.ts @@ -0,0 +1 @@ +export * from './tldraw-redis.factory'; diff --git a/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.spec.ts b/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.spec.ts new file mode 100644 index 00000000000..7517093b787 --- /dev/null +++ b/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.spec.ts @@ -0,0 +1,54 @@ +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { createConfigModuleOptions } from '@src/config'; +import { INestApplication } from '@nestjs/common'; +import { WsAdapter } from '@nestjs/platform-ws'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Logger } from '@src/core/logger'; +import { TldrawConfig } from '../config'; +import { tldrawTestConfig } from '../testing'; +import { TldrawRedisFactory } from './tldraw-redis.factory'; + +describe('TldrawRedisFactory', () => { + let app: INestApplication; + let configService: ConfigService; + let logger: DeepMocked; + let redisFactory: DeepMocked; + + beforeAll(async () => { + const testingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig))], + providers: [ + TldrawRedisFactory, + { + provide: Logger, + useValue: createMock(), + }, + ], + }).compile(); + + configService = testingModule.get(ConfigService); + logger = testingModule.get(Logger); + redisFactory = testingModule.get(TldrawRedisFactory); + app = testingModule.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should check if factory was created', () => { + expect(redisFactory).toBeDefined(); + }); + + describe('constructor', () => { + it('should throw if REDIS_URI is not set', () => { + const configSpy = jest.spyOn(configService, 'get').mockReturnValue(null); + + expect(() => new TldrawRedisFactory(configService, logger)).toThrow('REDIS_URI is not set'); + configSpy.mockRestore(); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.ts b/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.ts new file mode 100644 index 00000000000..b5e6ad8c65b --- /dev/null +++ b/apps/server/src/modules/tldraw/redis/tldraw-redis.factory.ts @@ -0,0 +1,31 @@ +import { Redis } from 'ioredis'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Logger } from '@src/core/logger'; +import { TldrawConfig } from '../config'; +import { RedisErrorLoggable } from '../loggable'; +import { RedisConnectionTypeEnum } from '../types'; + +@Injectable() +export class TldrawRedisFactory { + private readonly redisUri: string; + + constructor(private readonly configService: ConfigService, private readonly logger: Logger) { + this.logger.setContext(TldrawRedisFactory.name); + this.redisUri = this.configService.get('REDIS_URI'); + + if (!this.redisUri) { + throw new Error('REDIS_URI is not set'); + } + } + + public build(connectionType: RedisConnectionTypeEnum) { + const redis = new Redis(this.redisUri, { + maxRetriesPerRequest: null, + }); + + redis.on('error', (err) => this.logger.warning(new RedisErrorLoggable(connectionType, err))); + + return redis; + } +} diff --git a/apps/server/src/modules/tldraw/repo/index.ts b/apps/server/src/modules/tldraw/repo/index.ts index 3cc9ad02bf7..0552c6c0191 100644 --- a/apps/server/src/modules/tldraw/repo/index.ts +++ b/apps/server/src/modules/tldraw/repo/index.ts @@ -1,2 +1,3 @@ export * from './tldraw-board.repo'; export * from './tldraw.repo'; +export * from './y-mongodb'; diff --git a/apps/server/src/modules/tldraw/repo/key.factory.spec.ts b/apps/server/src/modules/tldraw/repo/key.factory.spec.ts new file mode 100644 index 00000000000..fa33c5b2a5e --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/key.factory.spec.ts @@ -0,0 +1,106 @@ +import { ObjectId } from 'bson'; +import { KeyFactory } from './key.factory'; + +describe('KeyFactory', () => { + describe('createForUpdate', () => { + describe('when clock is not passed', () => { + const setup = () => { + const params = { docName: new ObjectId().toHexString() }; + + return { params }; + }; + + it('should return a object that support the interface UniqueKey and clock is not defined', () => { + const { params } = setup(); + + const result = KeyFactory.createForUpdate(params.docName); + + expect(result).toEqual({ + docName: params.docName, + version: 'v1', + action: 'update', + clock: undefined, + }); + }); + }); + + describe('when positive clock number is passed', () => { + const setup = () => { + const params = { docName: new ObjectId().toHexString(), clock: 2 }; + + return { params }; + }; + + it('should return a object that support the interface UniqueKey and pass the clock number', () => { + const { params } = setup(); + + const result = KeyFactory.createForUpdate(params.docName, params.clock); + + expect(result).toEqual({ + docName: params.docName, + version: 'v1', + action: 'update', + clock: params.clock, + }); + }); + }); + + describe('when clock number -1 is passed', () => { + const setup = () => { + const params = { docName: new ObjectId().toHexString(), clock: -1 }; + + return { params }; + }; + + it('should return a object that support the interface UniqueKey and pass the clock number', () => { + const { params } = setup(); + + const result = KeyFactory.createForUpdate(params.docName, params.clock); + + expect(result).toEqual({ + docName: params.docName, + version: 'v1', + action: 'update', + clock: params.clock, + }); + }); + }); + + describe('when clock lower then -1 is passed', () => { + const setup = () => { + const params = { docName: new ObjectId().toHexString(), clock: -2 }; + + return { params }; + }; + + it('should throw an invalid clock number error', () => { + const { params } = setup(); + + expect(() => KeyFactory.createForUpdate(params.docName, params.clock)).toThrowError(); + }); + }); + }); + + describe('createForInsert', () => { + describe('when docName passed', () => { + const setup = () => { + const params = { docName: new ObjectId().toHexString() }; + + return { params }; + }; + + it('should return a object that support the interface UniqueKey', () => { + const { params } = setup(); + + const result = KeyFactory.createForInsert(params.docName); + + expect(result).toEqual({ + docName: params.docName, + version: 'v1_sv', + action: undefined, + clock: undefined, + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/repo/key.factory.ts b/apps/server/src/modules/tldraw/repo/key.factory.ts new file mode 100644 index 00000000000..55c1b6c363c --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/key.factory.ts @@ -0,0 +1,54 @@ +enum DatabaseAction { + UPDATE = 'update', +} + +enum Version { + V1_SV = 'v1_sv', + V1 = 'v1', +} + +interface UniqueKey { + version: Version; + action?: DatabaseAction; + docName: string; + clock?: number; +} + +export class KeyFactory { + static checkValidClock(clock?: number): void { + if (clock && clock < -1) { + throw new Error('Invalid clock value is passed to KeyFactory'); + } + } + + static createForUpdate(docName: string, clock?: number): UniqueKey { + KeyFactory.checkValidClock(clock); + let uniqueKey: UniqueKey; + + if (clock !== undefined) { + uniqueKey = { + docName, + version: Version.V1, + action: DatabaseAction.UPDATE, + clock, + }; + } else { + uniqueKey = { + docName, + version: Version.V1, + action: DatabaseAction.UPDATE, + }; + } + + return uniqueKey; + } + + static createForInsert(docName: string): UniqueKey { + const uniqueKey = { + docName, + version: Version.V1_SV, + }; + + return uniqueKey; + } +} diff --git a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts index 9636e561e39..003794ab427 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.spec.ts @@ -2,41 +2,54 @@ import { Test } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import WebSocket from 'ws'; import { WsAdapter } from '@nestjs/platform-ws'; -import { CoreModule } from '@src/core'; -import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions } from '@src/config'; -import { createMock } from '@golevelup/ts-jest'; import { Doc } from 'yjs'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { HttpService } from '@nestjs/axios'; +import { Logger } from '@src/core/logger'; +import { ConfigModule } from '@nestjs/config'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { createConfigModuleOptions } from '@src/config'; import * as YjsUtils from '../utils/ydoc-utils'; -import { config } from '../config'; import { TldrawBoardRepo } from './tldraw-board.repo'; -import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { WsSharedDocDo } from '../domain'; import { TldrawWsService } from '../service'; +import { TestConnection, tldrawTestConfig } from '../testing'; +import { TldrawDrawing } from '../entities'; import { TldrawWs } from '../controller'; -import { TestConnection } from '../testing/test-connection'; +import { MetricsService } from '../metrics'; +import { TldrawRepo } from './tldraw.repo'; +import { YMongodb } from './y-mongodb'; +import { TldrawRedisFactory } from '../redis'; describe('TldrawBoardRepo', () => { let app: INestApplication; let repo: TldrawBoardRepo; let ws: WebSocket; - let service: TldrawWsService; + let logger: DeepMocked; const gatewayPort = 3346; const wsUrl = TestConnection.getWsUrl(gatewayPort); - jest.useFakeTimers(); - beforeAll(async () => { - const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; const testingModule = await Test.createTestingModule({ - imports, + imports: [ + MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), + ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), + ], providers: [ TldrawWs, + TldrawWsService, TldrawBoardRepo, + YMongodb, + MetricsService, + TldrawRedisFactory, + { + provide: TldrawRepo, + useValue: createMock(), + }, { - provide: TldrawWsService, - useValue: createMock(), + provide: Logger, + useValue: createMock(), }, { provide: HttpService, @@ -45,12 +58,17 @@ describe('TldrawBoardRepo', () => { ], }).compile(); - service = testingModule.get(TldrawWsService); - repo = testingModule.get(TldrawBoardRepo); + repo = testingModule.get(TldrawBoardRepo); + logger = testingModule.get(Logger); app = testingModule.createNestApplication(); app.useWebSocketAdapter(new WsAdapter(app)); - jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); await app.init(); + + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.resetAllMocks(); }); afterAll(async () => { @@ -60,27 +78,18 @@ describe('TldrawBoardRepo', () => { it('should check if repo and its properties are set correctly', () => { expect(repo).toBeDefined(); expect(repo.mdb).toBeDefined(); - expect(repo.configService).toBeDefined(); - expect(repo.flushSize).toBeDefined(); - expect(repo.multipleCollections).toBeDefined(); - expect(repo.connectionString).toBeDefined(); - expect(repo.collectionName).toBeDefined(); }); describe('updateDocument', () => { describe('when document receives empty update', () => { const setup = async () => { - const doc = new WsSharedDocDo('TEST2', service); + const doc = new WsSharedDocDo('TEST2'); ws = await TestConnection.setupWs(wsUrl, 'TEST2'); - const wsSet = new Set(); - wsSet.add(ws); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - doc.conns.set(ws, wsSet); - const storeGetYDocSpy = jest - .spyOn(repo.mdb, 'getYDoc') - .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); - const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockImplementation(() => Promise.resolve(1)); + const wsSet: Set = new Set(); + wsSet.add(0); + doc.connections.set(ws, wsSet); + const storeGetYDocSpy = jest.spyOn(repo.mdb, 'getYDoc').mockResolvedValueOnce(new WsSharedDocDo('TEST')); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdateTransactional').mockResolvedValueOnce(1); return { doc, @@ -93,6 +102,7 @@ describe('TldrawBoardRepo', () => { const { doc, storeUpdateSpy, storeGetYDocSpy } = await setup(); await repo.updateDocument('TEST2', doc); + expect(storeUpdateSpy).toHaveBeenCalledTimes(0); storeUpdateSpy.mockRestore(); storeGetYDocSpy.mockRestore(); @@ -103,24 +113,22 @@ describe('TldrawBoardRepo', () => { describe('when document receive update', () => { const setup = async () => { const clientMessageMock = 'test-message'; - const doc = new WsSharedDocDo('TEST', service); + const doc = new WsSharedDocDo('TEST'); ws = await TestConnection.setupWs(wsUrl, 'TEST'); - const wsSet = new Set(); - wsSet.add(ws); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - doc.conns.set(ws, wsSet); - const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockImplementation(() => Promise.resolve(1)); - const storeGetYDocSpy = jest - .spyOn(repo.mdb, 'getYDoc') - .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); + const wsSet: Set = new Set(); + wsSet.add(0); + doc.connections.set(ws, wsSet); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdateTransactional').mockResolvedValue(1); + const storeGetYDocSpy = jest.spyOn(repo.mdb, 'getYDoc').mockResolvedValueOnce(new WsSharedDocDo('TEST')); const byteArray = new TextEncoder().encode(clientMessageMock); + const errorLogSpy = jest.spyOn(logger, 'warning'); return { doc, byteArray, storeUpdateSpy, storeGetYDocSpy, + errorLogSpy, }; }; @@ -129,6 +137,7 @@ describe('TldrawBoardRepo', () => { await repo.updateDocument('TEST', doc); doc.emit('update', [byteArray, undefined, doc]); + expect(storeUpdateSpy).toHaveBeenCalled(); expect(storeUpdateSpy).toHaveBeenCalledTimes(1); storeUpdateSpy.mockRestore(); @@ -141,9 +150,7 @@ describe('TldrawBoardRepo', () => { describe('getYDocFromMdb', () => { describe('when taking doc data from db', () => { const setup = () => { - const storeGetYDocSpy = jest - .spyOn(repo.mdb, 'getYDoc') - .mockImplementation(() => Promise.resolve(new WsSharedDocDo('TEST', service))); + const storeGetYDocSpy = jest.spyOn(repo.mdb, 'getYDoc').mockResolvedValueOnce(new WsSharedDocDo('TEST')); return { storeGetYDocSpy, @@ -152,8 +159,10 @@ describe('TldrawBoardRepo', () => { it('should return ydoc', async () => { const { storeGetYDocSpy } = setup(); - expect(await repo.getYDocFromMdb('test')).toBeInstanceOf(Doc); + const result = await repo.getYDocFromMdb('test'); + + expect(result).toBeInstanceOf(Doc); storeGetYDocSpy.mockRestore(); }); }); @@ -161,23 +170,46 @@ describe('TldrawBoardRepo', () => { describe('updateStoredDocWithDiff', () => { describe('when the difference between update and current drawing is more than 0', () => { - const setup = () => { - const calculateDiffSpy = jest.spyOn(YjsUtils, 'calculateDiff').mockImplementationOnce(() => 1); - const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate').mockResolvedValueOnce(Promise.resolve(1)); + const setup = (shouldStoreUpdateThrowError: boolean) => { + const calculateDiffSpy = jest.spyOn(YjsUtils, 'calculateDiff').mockReturnValueOnce(1); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdateTransactional'); + + if (shouldStoreUpdateThrowError) { + storeUpdateSpy.mockRejectedValueOnce(new Error('test error')); + } else { + storeUpdateSpy.mockResolvedValueOnce(1); + } return { calculateDiffSpy, + errorLogSpy, storeUpdateSpy, }; }; - it('should call store update method', () => { - const { storeUpdateSpy, calculateDiffSpy } = setup(); + it('should call store update method', async () => { + const { calculateDiffSpy, storeUpdateSpy } = setup(false); + // const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdateTransactional').mockResolvedValueOnce(1); const diffArray = new Uint8Array(); - repo.updateStoredDocWithDiff('test', diffArray); + + await repo.updateStoredDocWithDiff('test', diffArray); expect(storeUpdateSpy).toHaveBeenCalled(); + calculateDiffSpy.mockRestore(); + storeUpdateSpy.mockRestore(); + }); + it('should log error if update fails', async () => { + const { calculateDiffSpy, errorLogSpy, storeUpdateSpy } = setup(true); + // const storeUpdateSpy = jest + // .spyOn(repo.mdb, 'storeUpdateTransactional') + // .mockRejectedValueOnce(new Error('test error')); + const diffArray = new Uint8Array(); + await expect(repo.updateStoredDocWithDiff('test', diffArray)).rejects.toThrow('test error'); + + expect(storeUpdateSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); calculateDiffSpy.mockRestore(); storeUpdateSpy.mockRestore(); }); @@ -185,8 +217,8 @@ describe('TldrawBoardRepo', () => { describe('when the difference between update and current drawing is 0', () => { const setup = () => { - const calculateDiffSpy = jest.spyOn(YjsUtils, 'calculateDiff').mockImplementationOnce(() => 0); - const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdate'); + const calculateDiffSpy = jest.spyOn(YjsUtils, 'calculateDiff').mockReturnValueOnce(0); + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdateTransactional'); return { calculateDiffSpy, @@ -194,13 +226,13 @@ describe('TldrawBoardRepo', () => { }; }; - it('should not call store update method', () => { + it('should not call store update method', async () => { const { storeUpdateSpy, calculateDiffSpy } = setup(); const diffArray = new Uint8Array(); - repo.updateStoredDocWithDiff('test', diffArray); - expect(storeUpdateSpy).not.toHaveBeenCalled(); + await repo.updateStoredDocWithDiff('test', diffArray); + expect(storeUpdateSpy).not.toHaveBeenCalled(); calculateDiffSpy.mockRestore(); storeUpdateSpy.mockRestore(); }); @@ -209,18 +241,35 @@ describe('TldrawBoardRepo', () => { describe('flushDocument', () => { const setup = () => { - const flushDocumentSpy = jest.spyOn(repo.mdb, 'flushDocument').mockResolvedValueOnce(Promise.resolve()); + const flushDocumentSpy = jest.spyOn(repo.mdb, 'flushDocumentTransactional').mockResolvedValueOnce(); return { flushDocumentSpy }; }; - it('should call flush method on mdbPersistence', async () => { + it('should call flush method on YMongo', async () => { const { flushDocumentSpy } = setup(); + await repo.flushDocument('test'); expect(flushDocumentSpy).toHaveBeenCalled(); - flushDocumentSpy.mockRestore(); }); }); + + describe('storeUpdate', () => { + const setup = () => { + const storeUpdateSpy = jest.spyOn(repo.mdb, 'storeUpdateTransactional'); + + return { storeUpdateSpy }; + }; + + it('should call store update method on YMongo', async () => { + const { storeUpdateSpy } = setup(); + + await repo.storeUpdate('test', new Uint8Array()); + + expect(storeUpdateSpy).toHaveBeenCalled(); + storeUpdateSpy.mockRestore(); + }); + }); }); diff --git a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts index 8f3b3187158..c7eddec7b04 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw-board.repo.ts @@ -1,52 +1,33 @@ import { Injectable } from '@nestjs/common'; -import { MongodbPersistence } from 'y-mongodb-provider'; -import { ConfigService } from '@nestjs/config'; import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'; -import { TldrawConfig } from '../config'; +import { Logger } from '@src/core/logger'; +import { MongoTransactionErrorLoggable } from '../loggable'; import { calculateDiff } from '../utils'; -import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { WsSharedDocDo } from '../domain'; +import { YMongodb } from './y-mongodb'; @Injectable() export class TldrawBoardRepo { - public connectionString: string; - - public collectionName: string; - - public flushSize: number; - - public multipleCollections: boolean; - - public mdb: MongodbPersistence; - - constructor(public readonly configService: ConfigService) { - this.connectionString = this.configService.get('CONNECTION_STRING'); - this.collectionName = this.configService.get('TLDRAW_DB_COLLECTION_NAME') ?? 'drawings'; - this.flushSize = this.configService.get('TLDRAW_DB_FLUSH_SIZE') ?? 400; - this.multipleCollections = this.configService.get('TLDRAW_DB_MULTIPLE_COLLECTIONS'); + constructor(readonly mdb: YMongodb, private readonly logger: Logger) { + this.logger.setContext(TldrawBoardRepo.name); + } - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment - this.mdb = new MongodbPersistence(this.connectionString, { - collectionName: 'drawings', - flushSize: 400, - multipleCollections: false, - }); + public async createDbIndex(): Promise { + await this.mdb.createIndex(); } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // eslint-disable-next-line consistent-return public async getYDocFromMdb(docName: string): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access const yDoc = await this.mdb.getYDoc(docName); - if (yDoc instanceof Doc) { - return yDoc; - } + return yDoc; } - public updateStoredDocWithDiff(docName: string, diff: Uint8Array): void { + public async updateStoredDocWithDiff(docName: string, diff: Uint8Array): Promise { const calc = calculateDiff(diff); if (calc > 0) { - void this.mdb.storeUpdate(docName, diff); + await this.mdb.storeUpdateTransactional(docName, diff).catch((err) => { + this.logger.warning(new MongoTransactionErrorLoggable(err)); + throw err; + }); } } @@ -54,19 +35,20 @@ export class TldrawBoardRepo { const persistedYdoc = await this.getYDocFromMdb(docName); const persistedStateVector = encodeStateVector(persistedYdoc); const diff = encodeStateAsUpdate(ydoc, persistedStateVector); - this.updateStoredDocWithDiff(docName, diff); + await this.updateStoredDocWithDiff(docName, diff); applyUpdate(ydoc, encodeStateAsUpdate(persistedYdoc)); - ydoc.on('update', (update: Uint8Array) => { - void this.mdb.storeUpdate(docName, update); - }); + ydoc.on('update', (update: Uint8Array) => this.mdb.storeUpdateTransactional(docName, update)); persistedYdoc.destroy(); } public async flushDocument(docName: string): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access - await this.mdb.flushDocument(docName); + await this.mdb.flushDocumentTransactional(docName); + } + + public async storeUpdate(docName: string, update: Uint8Array): Promise { + await this.mdb.storeUpdateTransactional(docName, update); } } diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts index 5c075669431..3e7a7fc4bdb 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.spec.ts @@ -1,28 +1,33 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Test, TestingModule } from '@nestjs/testing'; import { cleanupCollections } from '@shared/testing'; -import { MongoMemoryDatabaseModule } from '@infra/database'; +import { MikroORM } from '@mikro-orm/core'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; import { NotFoundException } from '@nestjs/common'; -import { tldrawEntityFactory } from '../factory'; +import { tldrawEntityFactory, tldrawTestConfig } from '../testing'; import { TldrawDrawing } from '../entities'; import { TldrawRepo } from './tldraw.repo'; +import { TldrawWsTestModule } from '..'; -describe(TldrawRepo.name, () => { - let module: TestingModule; +describe('TldrawRepo', () => { + let testingModule: TestingModule; let repo: TldrawRepo; let em: EntityManager; + let orm: MikroORM; beforeAll(async () => { - module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] })], - providers: [TldrawRepo], + testingModule = await Test.createTestingModule({ + imports: [TldrawWsTestModule, ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig))], }).compile(); - repo = module.get(TldrawRepo); - em = module.get(EntityManager); + + repo = testingModule.get(TldrawRepo); + em = testingModule.get(EntityManager); + orm = testingModule.get(MikroORM); }); afterAll(async () => { - await module.close(); + await testingModule.close(); }); afterEach(async () => { @@ -31,13 +36,22 @@ describe(TldrawRepo.name, () => { describe('create', () => { describe('when called', () => { - it('should create new drawing node', async () => { + const setup = async () => { const drawing = tldrawEntityFactory.build(); await repo.create(drawing); em.clear(); + return { + drawing, + }; + }; + + it('should create new drawing node', async () => { + const { drawing } = await setup(); + const result = await em.find(TldrawDrawing, {}); + expect(result[0]._id).toEqual(drawing._id); }); @@ -64,7 +78,9 @@ describe(TldrawRepo.name, () => { it('should return the object', async () => { const { drawing } = await setup(); + const result = await repo.findByDocName(drawing.docName); + expect(result[0].docName).toEqual(drawing.docName); expect(result[0]._id).toEqual(drawing._id); }); @@ -77,10 +93,19 @@ describe(TldrawRepo.name, () => { describe('delete', () => { describe('when finding by docName and deleting all records', () => { - it('should delete all records', async () => { + const setup = async () => { const drawing = tldrawEntityFactory.build(); + await repo.create(drawing); + return { + drawing, + }; + }; + + it('should delete all records', async () => { + const { drawing } = await setup(); + const results = await repo.findByDocName(drawing.docName); await repo.delete(results); @@ -89,4 +114,14 @@ describe(TldrawRepo.name, () => { }); }); }); + + describe('ensureIndexes', () => { + it('should call getSchemaGenerator().ensureIndexes()', async () => { + const ormSpy = jest.spyOn(orm, 'getSchemaGenerator'); + + await repo.ensureIndexes(); + + expect(ormSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts index d8eb4330bd2..d81da755e9f 100644 --- a/apps/server/src/modules/tldraw/repo/tldraw.repo.ts +++ b/apps/server/src/modules/tldraw/repo/tldraw.repo.ts @@ -1,24 +1,62 @@ import { EntityManager } from '@mikro-orm/mongodb'; import { Injectable, NotFoundException } from '@nestjs/common'; +import { BulkWriteResult, Collection, Sort } from 'mongodb'; +import { MikroORM } from '@mikro-orm/core'; import { TldrawDrawing } from '../entities'; @Injectable() export class TldrawRepo { - constructor(private readonly _em: EntityManager) {} + constructor(private readonly em: EntityManager, private readonly orm: MikroORM) {} - async create(entity: TldrawDrawing): Promise { - await this._em.persistAndFlush(entity); + public async create(entity: TldrawDrawing): Promise { + await this.em.persistAndFlush(entity); } - async findByDocName(docName: string): Promise { - const domainObject = await this._em.find(TldrawDrawing, { docName }); + public async findByDocName(docName: string): Promise { + const domainObject = await this.em.find(TldrawDrawing, { docName }); if (domainObject.length === 0) { throw new NotFoundException(`There is no '${docName}' for this docName`); } return domainObject; } - async delete(entity: TldrawDrawing | TldrawDrawing[]): Promise { - await this._em.removeAndFlush(entity); + public async delete(entity: TldrawDrawing | TldrawDrawing[]): Promise { + await this.em.removeAndFlush(entity); + } + + public get(query: object): Promise { + const collection = this.getCollection(); + return collection.findOne(query, { allowDiskUse: true }); + } + + public async put(query: object, values: object): Promise { + const collection = this.getCollection(); + await collection.updateOne(query, { $set: values }, { upsert: true }); + return this.get(query); + } + + public del(query: object): Promise { + const collection = this.getCollection(); + const bulk = collection.initializeOrderedBulkOp(); + bulk.find(query).delete(); + return bulk.execute(); + } + + public readAsCursor(query: object, opts: { limit?: number; reverse?: boolean } = {}): Promise { + const { limit = 0, reverse = false } = opts; + + const collection = this.getCollection(); + const sortQuery: Sort = reverse ? { clock: -1, part: 1 } : { clock: 1, part: 1 }; + const curs = collection.find(query, { allowDiskUse: true }).sort(sortQuery).limit(limit); + + return curs.toArray(); + } + + public getCollection(): Collection { + return this.em.getCollection(TldrawDrawing); + } + + public async ensureIndexes(): Promise { + await this.orm.getSchemaGenerator().ensureIndexes(); } } diff --git a/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts b/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts new file mode 100644 index 00000000000..63be0060c94 --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/y-mongodb.spec.ts @@ -0,0 +1,262 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections } from '@shared/testing'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { ConfigModule } from '@nestjs/config'; +import { Logger } from '@src/core/logger'; +import { createMock } from '@golevelup/ts-jest'; +import * as Yjs from 'yjs'; +import { createConfigModuleOptions } from '@src/config'; +import { HttpService } from '@nestjs/axios'; +import { TldrawRedisFactory } from '../redis'; +import { tldrawEntityFactory, tldrawTestConfig } from '../testing'; +import { TldrawDrawing } from '../entities'; +import { TldrawWs } from '../controller'; +import { TldrawWsService } from '../service'; +import { MetricsService } from '../metrics'; +import { TldrawBoardRepo } from './tldraw-board.repo'; +import { TldrawRepo } from './tldraw.repo'; +import { YMongodb } from './y-mongodb'; + +jest.mock('yjs', () => { + const moduleMock: unknown = { + __esModule: true, + ...jest.requireActual('yjs'), + }; + return moduleMock; +}); + +describe('YMongoDb', () => { + let testingModule: TestingModule; + let mdb: YMongodb; + let repo: TldrawRepo; + let em: EntityManager; + + beforeAll(async () => { + testingModule = await Test.createTestingModule({ + imports: [ + MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), + ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), + ], + providers: [ + TldrawWs, + TldrawWsService, + TldrawBoardRepo, + TldrawRepo, + YMongodb, + MetricsService, + TldrawRedisFactory, + { + provide: Logger, + useValue: createMock(), + }, + { + provide: HttpService, + useValue: createMock(), + }, + ], + }).compile(); + + mdb = testingModule.get(YMongodb); + repo = testingModule.get(TldrawRepo); + em = testingModule.get(EntityManager); + }); + + afterAll(async () => { + await testingModule.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('storeUpdateTransactional', () => { + describe('when clock is defined', () => { + const setup = async () => { + const drawing = tldrawEntityFactory.build({ clock: 1 }); + await em.persistAndFlush(drawing); + em.clear(); + + const update = new Uint8Array([2, 2]); + + return { drawing, update }; + }; + + it('should create new document with updates in the database', async () => { + const { drawing, update } = await setup(); + + await mdb.storeUpdateTransactional(drawing.docName, update); + const docs = await em.findAndCount(TldrawDrawing, { docName: drawing.docName }); + + expect(docs.length).toEqual(2); + }); + }); + + describe('when clock is undefined', () => { + const setup = async () => { + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValueOnce(); + const drawing = tldrawEntityFactory.build({ clock: undefined }); + + await em.persistAndFlush(drawing); + em.clear(); + + return { + applyUpdateSpy, + drawing, + }; + }; + + it('should call applyUpdate and create new document with updates in the database', async () => { + const { applyUpdateSpy, drawing } = await setup(); + + await mdb.storeUpdateTransactional(drawing.docName, new Uint8Array([2, 2])); + const docs = await em.findAndCount(TldrawDrawing, { docName: drawing.docName }); + + expect(applyUpdateSpy).toHaveBeenCalled(); + expect(docs.length).toEqual(2); + }); + }); + }); + + describe('flushDocumentTransactional', () => { + const setup = async () => { + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValue(); + + const drawing1 = tldrawEntityFactory.build({ clock: 1, part: undefined }); + const drawing2 = tldrawEntityFactory.build({ clock: 2, part: undefined }); + const drawing3 = tldrawEntityFactory.build({ clock: 3, part: undefined }); + const drawing4 = tldrawEntityFactory.build({ clock: 4, part: undefined }); + + await em.persistAndFlush([drawing1, drawing2, drawing3, drawing4]); + em.clear(); + + return { + applyUpdateSpy, + drawing1, + }; + }; + + it('should merge multiple documents with the same name in the database into two (one main document and one with update)', async () => { + const { applyUpdateSpy, drawing1 } = await setup(); + + await mdb.flushDocumentTransactional(drawing1.docName); + const docs = await em.findAndCount(TldrawDrawing, { docName: drawing1.docName }); + + expect(docs.length).toEqual(2); + applyUpdateSpy.mockRestore(); + }); + }); + + describe('createIndex', () => { + const setup = () => { + const ensureIndexesSpy = jest.spyOn(repo, 'ensureIndexes').mockResolvedValueOnce(); + + return { + ensureIndexesSpy, + }; + }; + + it('should create index', async () => { + const { ensureIndexesSpy } = setup(); + + await mdb.createIndex(); + + expect(ensureIndexesSpy).toHaveBeenCalled(); + }); + }); + + describe('getYDoc', () => { + describe('when getting document with well defined parts', () => { + const setup = async () => { + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValue(); + + const drawing1 = tldrawEntityFactory.build({ clock: 1, part: 1 }); + const drawing2 = tldrawEntityFactory.build({ clock: 1, part: 2 }); + const drawing3 = tldrawEntityFactory.build({ clock: 2, part: 1 }); + + await em.persistAndFlush([drawing1, drawing2, drawing3]); + em.clear(); + + return { + applyUpdateSpy, + drawing1, + drawing2, + drawing3, + }; + }; + + it('should return ydoc', async () => { + const { applyUpdateSpy } = await setup(); + + const doc = await mdb.getYDoc('test-name'); + + expect(doc).toBeDefined(); + applyUpdateSpy.mockRestore(); + }); + }); + + describe('when getting document with missing parts', () => { + const setup = async () => { + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValue(); + + const drawing1 = tldrawEntityFactory.build({ clock: 1, part: 1 }); + const drawing4 = tldrawEntityFactory.build({ clock: 1, part: 3 }); + const drawing5 = tldrawEntityFactory.build({ clock: 1, part: 4 }); + + await em.persistAndFlush([drawing1, drawing4, drawing5]); + em.clear(); + + return { + applyUpdateSpy, + }; + }; + + it('should not return ydoc', async () => { + const { applyUpdateSpy } = await setup(); + + const doc = await mdb.getYDoc('test-name'); + + expect(doc).toBeUndefined(); + applyUpdateSpy.mockRestore(); + }); + }); + + describe('when getting document with part undefined', () => { + const setup = async () => { + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValue(); + const drawing1 = tldrawEntityFactory.build({ part: undefined }); + const drawing2 = tldrawEntityFactory.build({ part: undefined }); + const drawing3 = tldrawEntityFactory.build({ part: undefined }); + + await em.persistAndFlush([drawing1, drawing2, drawing3]); + em.clear(); + + return { + applyUpdateSpy, + }; + }; + + it('should return ydoc from the database', async () => { + const { applyUpdateSpy } = await setup(); + + const doc = await mdb.getYDoc('test-name'); + + expect(doc).toBeDefined(); + applyUpdateSpy.mockRestore(); + }); + + describe('when single entity size is greater than MAX_DOCUMENT_SIZE', () => { + it('should return ydoc from the database', async () => { + // @ts-expect-error test-case + mdb.maxDocumentSize = 1; + const { applyUpdateSpy } = await setup(); + + const doc = await mdb.getYDoc('test-name'); + + expect(doc).toBeDefined(); + applyUpdateSpy.mockRestore(); + }); + }); + }); + }); +}); diff --git a/apps/server/src/modules/tldraw/repo/y-mongodb.ts b/apps/server/src/modules/tldraw/repo/y-mongodb.ts new file mode 100644 index 00000000000..e9a1657f5a2 --- /dev/null +++ b/apps/server/src/modules/tldraw/repo/y-mongodb.ts @@ -0,0 +1,279 @@ +import { ConfigService } from '@nestjs/config'; +import * as promise from 'lib0/promise'; +import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'; +import { Injectable } from '@nestjs/common'; +import { Logger } from '@src/core/logger'; +import { Buffer } from 'buffer'; +import * as binary from 'lib0/binary'; +import * as encoding from 'lib0/encoding'; +import { BulkWriteResult } from 'mongodb'; +import { MongoTransactionErrorLoggable } from '../loggable'; +import { TldrawDrawing } from '../entities'; +import { TldrawConfig } from '../config'; +import { YTransaction } from '../types'; +import { TldrawRepo } from './tldraw.repo'; +import { KeyFactory } from './key.factory'; + +@Injectable() +export class YMongodb { + private readonly maxDocumentSize: number; + + private readonly flushSize: number; + + private readonly _transact: >(docName: string, fn: () => T) => T; + + // scope the queue of the transaction to each docName + // this should allow concurrency for different rooms + private tr: { docName?: Promise } = {}; + + constructor( + private readonly configService: ConfigService, + private readonly repo: TldrawRepo, + private readonly logger: Logger + ) { + this.logger.setContext(YMongodb.name); + + this.flushSize = this.configService.get('TLDRAW_DB_FLUSH_SIZE'); + this.maxDocumentSize = this.configService.get('TLDRAW_MAX_DOCUMENT_SIZE'); + + // execute a transaction on a database + // this will ensure that other processes are currently not writing + this._transact = >(docName: string, fn: () => T): T => { + if (!this.tr[docName]) { + this.tr[docName] = promise.resolve(); + } + + const currTr = this.tr[docName] as T; + let nextTr: Promise = promise.resolve(null); + + nextTr = (async () => { + await currTr; + + let res: YTransaction | null; + try { + res = await fn(); + } catch (err) { + this.logger.warning(new MongoTransactionErrorLoggable(err)); + } + + // once the last transaction for a given docName resolves, remove it from the queue + if (this.tr[docName] === nextTr) { + delete this.tr[docName]; + } + + return res; + })(); + + this.tr[docName] = nextTr; + + return this.tr[docName] as T; + }; + } + + public async createIndex(): Promise { + await this.repo.ensureIndexes(); + } + + public getYDoc(docName: string): Promise { + return this._transact(docName, async (): Promise => { + const updates = await this.getMongoUpdates(docName); + const ydoc = new Doc(); + ydoc.transact(() => { + for (const element of updates) { + applyUpdate(ydoc, element); + } + }); + if (updates.length > this.flushSize) { + await this.flushDocument(docName, encodeStateAsUpdate(ydoc), encodeStateVector(ydoc)); + } + return ydoc; + }); + } + + public storeUpdateTransactional(docName: string, update: Uint8Array): Promise { + return this._transact(docName, () => this.storeUpdate(docName, update)); + } + + public flushDocumentTransactional(docName: string): Promise { + return this._transact(docName, async () => { + const updates = await this.getMongoUpdates(docName); + const { update, sv } = this.mergeUpdates(updates); + await this.flushDocument(docName, update, sv); + }); + } + + private async clearUpdatesRange(docName: string, from: number, to: number): Promise { + return this.repo.del({ + docName, + clock: { + $gte: from, + $lt: to, + }, + }); + } + + private getMongoBulkData(query: object, opts: object): Promise { + return this.repo.readAsCursor(query, opts); + } + + private mergeDocsTogether(doc: TldrawDrawing, docs: TldrawDrawing[], docIndex: number): Buffer[] { + const parts = [Buffer.from(doc.value.buffer)]; + let currentPartId: number | undefined = doc.part; + for (let i = docIndex + 1; i < docs.length; i += 1) { + const part = docs[i]; + + if (!this.isSameClock(part, doc)) { + break; + } + + this.checkIfPartIsNextPartAfterCurrent(part, currentPartId); + + parts.push(Buffer.from(part.value.buffer)); + currentPartId = part.part; + } + + return parts; + } + + /** + * Convert the mongo document array to an array of values (as buffers) + */ + private convertMongoUpdates(docs: TldrawDrawing[]): Buffer[] { + if (!Array.isArray(docs) || !docs.length) return []; + + const updates: Buffer[] = []; + for (let i = 0; i < docs.length; i += 1) { + const doc = docs[i]; + + if (!doc.part) { + updates.push(Buffer.from(doc.value.buffer)); + } + + if (doc.part === 1) { + // merge the docs together that got split because of mongodb size limits + const parts = this.mergeDocsTogether(doc, docs, i); + updates.push(Buffer.concat(parts)); + } + } + return updates; + } + + /** + * Get all document updates for a specific document. + */ + private async getMongoUpdates(docName: string, opts = {}): Promise { + const uniqueKey = KeyFactory.createForUpdate(docName); + const docs = await this.getMongoBulkData(uniqueKey, opts); + + return this.convertMongoUpdates(docs); + } + + private async getCurrentUpdateClock(docName: string): Promise { + const updates = await this.getMongoBulkData( + { + ...KeyFactory.createForUpdate(docName, 0), + clock: { + $gte: 0, + $lt: binary.BITS32, + }, + }, + { reverse: true, limit: 1 } + ); + + const clock = this.extractClock(updates); + + return clock; + } + + private async writeStateVector(docName: string, sv: Uint8Array, clock: number): Promise { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, clock); + encoding.writeVarUint8Array(encoder, sv); + const uniqueKey = KeyFactory.createForInsert(docName); + + await this.repo.put(uniqueKey, { + value: Buffer.from(encoding.toUint8Array(encoder)), + }); + } + + private async storeUpdate(docName: string, update: Uint8Array): Promise { + const clock: number = await this.getCurrentUpdateClock(docName); + + if (clock === -1) { + // make sure that a state vector is always written, so we can search for available documents + const ydoc = new Doc(); + applyUpdate(ydoc, update); + const sv = encodeStateVector(ydoc); + + await this.writeStateVector(docName, sv, 0); + } + + const value = Buffer.from(update); + // if our buffer exceeds maxDocumentSize, we store the update in multiple documents + if (value.length <= this.maxDocumentSize) { + const uniqueKey = KeyFactory.createForUpdate(docName, clock + 1); + + await this.repo.put(uniqueKey, { + value, + }); + } else { + const totalChunks = Math.ceil(value.length / this.maxDocumentSize); + + const putPromises: Promise[] = []; + for (let i = 0; i < totalChunks; i += 1) { + const start = i * this.maxDocumentSize; + const end = Math.min(start + this.maxDocumentSize, value.length); + const chunk = value.subarray(start, end); + + putPromises.push( + this.repo.put({ ...KeyFactory.createForUpdate(docName, clock + 1), part: i + 1 }, { value: chunk }) + ); + } + + await Promise.all(putPromises); + } + + return clock + 1; + } + + /** + * For now this is a helper method that creates a Y.Doc and then re-encodes a document update. + * In the future this will be handled by Yjs without creating a Y.Doc (constant memory consumption). + */ + private mergeUpdates(updates: Array): { update: Uint8Array; sv: Uint8Array } { + const ydoc = new Doc(); + ydoc.transact(() => { + for (const element of updates) { + applyUpdate(ydoc, element); + } + }); + return { update: encodeStateAsUpdate(ydoc), sv: encodeStateVector(ydoc) }; + } + + /** + * Merge all MongoDB documents of the same yjs document together. + */ + private async flushDocument(docName: string, stateAsUpdate: Uint8Array, stateVector: Uint8Array): Promise { + const clock = await this.storeUpdate(docName, stateAsUpdate); + await this.writeStateVector(docName, stateVector, clock); + await this.clearUpdatesRange(docName, 0, clock); + return clock; + } + + private isSameClock(doc1: TldrawDrawing, doc2: TldrawDrawing): boolean { + return doc1.clock === doc2.clock; + } + + private checkIfPartIsNextPartAfterCurrent(part: TldrawDrawing, currentPartId: number | undefined): void { + if (part.part === undefined || currentPartId !== part.part - 1) { + throw new Error('Could not merge updates together because a part is missing'); + } + } + + private extractClock(updates: TldrawDrawing[]): number { + if (updates.length === 0 || updates[0].clock == null) { + return -1; + } + return updates[0].clock; + } +} diff --git a/apps/server/src/modules/tldraw/service/index.ts b/apps/server/src/modules/tldraw/service/index.ts index 2bc9f981432..f80171da7ba 100644 --- a/apps/server/src/modules/tldraw/service/index.ts +++ b/apps/server/src/modules/tldraw/service/index.ts @@ -1,2 +1,2 @@ -export * from './tldraw.ws.service'; export * from './tldraw.service'; +export * from './tldraw.ws.service'; diff --git a/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts index 546ab739bb0..fb25911e99b 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.service.spec.ts @@ -2,13 +2,15 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityManager } from '@mikro-orm/mongodb'; import { MongoMemoryDatabaseModule } from '@infra/database'; import { cleanupCollections } from '@shared/testing'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; import { NotFoundException } from '@nestjs/common'; import { TldrawDrawing } from '../entities'; -import { tldrawEntityFactory } from '../factory'; +import { tldrawEntityFactory, tldrawTestConfig } from '../testing'; import { TldrawRepo } from '../repo/tldraw.repo'; import { TldrawService } from './tldraw.service'; -describe(TldrawService.name, () => { +describe('TldrawService', () => { let module: TestingModule; let service: TldrawService; let repo: TldrawRepo; @@ -16,7 +18,10 @@ describe(TldrawService.name, () => { beforeAll(async () => { module = await Test.createTestingModule({ - imports: [MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] })], + imports: [ + MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), + ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), + ], providers: [TldrawService, TldrawRepo], }).compile(); @@ -31,7 +36,7 @@ describe(TldrawService.name, () => { afterEach(async () => { await cleanupCollections(em); - jest.resetAllMocks(); + jest.clearAllMocks(); }); describe('delete', () => { @@ -41,11 +46,9 @@ describe(TldrawService.name, () => { await repo.create(drawing); const result = await repo.findByDocName(drawing.docName); - - expect(result.length).toEqual(1); - await service.deleteByDocName(drawing.docName); + expect(result.length).toEqual(1); await expect(repo.findByDocName(drawing.docName)).rejects.toThrow(NotFoundException); }); diff --git a/apps/server/src/modules/tldraw/service/tldraw.service.ts b/apps/server/src/modules/tldraw/service/tldraw.service.ts index 4e0aa3db8db..8001a72ed0f 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.service.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { TldrawRepo } from '../repo/tldraw.repo'; +import { TldrawRepo } from '../repo'; @Injectable() export class TldrawService { diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts index 5c902244766..7ff817f09de 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.spec.ts @@ -2,24 +2,36 @@ import { Test } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import WebSocket from 'ws'; import { WsAdapter } from '@nestjs/platform-ws'; -import { CoreModule } from '@src/core'; -import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions } from '@src/config'; import { TextEncoder } from 'util'; +import * as Yjs from 'yjs'; import * as SyncProtocols from 'y-protocols/sync'; import * as AwarenessProtocol from 'y-protocols/awareness'; +import * as Ioredis from 'ioredis'; import { encoding } from 'lib0'; import { TldrawWsFactory } from '@shared/testing/factory/tldraw.ws.factory'; import { HttpService } from '@nestjs/axios'; -import { createMock } from '@golevelup/ts-jest'; -import { MetricsService } from '../metrics'; -import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; -import { config } from '../config'; -import { TldrawBoardRepo } from '../repo'; +import { WebSocketReadyStateEnum } from '@shared/testing'; +import { Logger } from '@src/core/logger'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ConfigModule } from '@nestjs/config'; +import { createConfigModuleOptions } from '@src/config'; +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { TldrawRedisFactory } from '../redis'; import { TldrawWs } from '../controller'; +import { TldrawDrawing } from '../entities'; +import { TldrawBoardRepo, TldrawRepo, YMongodb } from '../repo'; +import { TestConnection, tldrawTestConfig } from '../testing'; +import { WsSharedDocDo } from '../domain'; import { TldrawWsService } from '.'; -import { TestConnection } from '../testing/test-connection'; +import { MetricsService } from '../metrics'; +jest.mock('yjs', () => { + const moduleMock: unknown = { + __esModule: true, + ...jest.requireActual('yjs'), + }; + return moduleMock; +}); jest.mock('y-protocols/awareness', () => { const moduleMock: unknown = { __esModule: true, @@ -39,6 +51,8 @@ describe('TldrawWSService', () => { let app: INestApplication; let ws: WebSocket; let service: TldrawWsService; + let boardRepo: TldrawBoardRepo; + let logger: DeepMocked; const gatewayPort = 3346; const wsUrl = TestConnection.getWsUrl(gatewayPort); @@ -48,17 +62,27 @@ describe('TldrawWSService', () => { setTimeout(resolve, ms); }); - jest.useFakeTimers(); - beforeAll(async () => { - const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config))]; const testingModule = await Test.createTestingModule({ - imports, + imports: [ + MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), + ConfigModule.forRoot(createConfigModuleOptions(tldrawTestConfig)), + ], providers: [ TldrawWs, - TldrawBoardRepo, TldrawWsService, + TldrawBoardRepo, + YMongodb, MetricsService, + TldrawRedisFactory, + { + provide: TldrawRepo, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, { provide: HttpService, useValue: createMock(), @@ -66,10 +90,11 @@ describe('TldrawWSService', () => { ], }).compile(); - service = testingModule.get(TldrawWsService); + service = testingModule.get(TldrawWsService); + boardRepo = testingModule.get(TldrawBoardRepo); + logger = testingModule.get(Logger); app = testingModule.createNestApplication(); app.useWebSocketAdapter(new WsAdapter(app)); - jest.useFakeTimers({ advanceTimers: true, doNotFake: ['setInterval', 'clearInterval', 'setTimeout'] }); await app.init(); }); @@ -77,6 +102,11 @@ describe('TldrawWSService', () => { await app.close(); }); + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + const createMessage = (values: number[]) => { const encoder = encoding.createEncoder(); values.forEach((val) => { @@ -85,6 +115,7 @@ describe('TldrawWSService', () => { encoding.writeVarUint(encoder, 0); encoding.writeVarUint(encoder, 1); const msg = encoding.toUint8Array(encoder); + return { msg, }; @@ -92,8 +123,6 @@ describe('TldrawWSService', () => { it('should check if service properties are set correctly', () => { expect(service).toBeDefined(); - expect(service.pingTimeout).toBeDefined(); - expect(service.persistence).toBeDefined(); }); describe('send', () => { @@ -102,7 +131,7 @@ describe('TldrawWSService', () => { ws = await TestConnection.setupWs(wsUrl, 'TEST'); const clientMessageMock = 'test-message'; - const closeConSpy = jest.spyOn(service, 'closeConn').mockImplementationOnce(() => {}); + const closeConSpy = jest.spyOn(service, 'closeConn').mockResolvedValueOnce(); const sendSpy = jest.spyOn(service, 'send'); const doc = TldrawWsFactory.createWsSharedDocDo(); const byteArray = new TextEncoder().encode(clientMessageMock); @@ -123,19 +152,97 @@ describe('TldrawWSService', () => { expect(sendSpy).toThrow(); expect(sendSpy).toHaveBeenCalledWith(doc, ws, byteArray); expect(closeConSpy).toHaveBeenCalled(); - ws.close(); sendSpy.mockRestore(); }); }); - describe('when websocket has ready state different than 0 or 1', () => { + describe('when client is not connected to WS and close connection throws error', () => { + const setup = () => { + const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.OPEN); + const clientMessageMock = 'test-message'; + + const closeConSpy = jest.spyOn(service, 'closeConn').mockRejectedValue(new Error('error')); + jest.spyOn(socketMock, 'send').mockImplementation((...args: unknown[]) => { + args.forEach((arg) => { + if (typeof arg === 'function') { + arg(new Error('error')); + } + }); + }); + const sendSpy = jest.spyOn(service, 'send'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const byteArray = new TextEncoder().encode(clientMessageMock); + + return { + socketMock, + closeConSpy, + errorLogSpy, + sendSpy, + doc, + byteArray, + }; + }; + + it('should log error', async () => { + const { socketMock, closeConSpy, errorLogSpy, sendSpy, doc, byteArray } = setup(); + + service.send(doc, socketMock, byteArray); + + await delay(100); + + expect(sendSpy).toHaveBeenCalledWith(doc, socketMock, byteArray); + expect(closeConSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); + closeConSpy.mockRestore(); + sendSpy.mockRestore(); + }); + }); + + describe('when web socket has ready state CLOSED and close connection throws error', () => { + const setup = () => { + const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.CLOSED); + const clientMessageMock = 'test-message'; + + const closeConSpy = jest.spyOn(service, 'closeConn').mockRejectedValue(new Error('error')); + const sendSpy = jest.spyOn(service, 'send'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + const byteArray = new TextEncoder().encode(clientMessageMock); + + return { + socketMock, + closeConSpy, + errorLogSpy, + sendSpy, + doc, + byteArray, + }; + }; + + it('should log error', async () => { + const { socketMock, closeConSpy, errorLogSpy, sendSpy, doc, byteArray } = setup(); + + service.send(doc, socketMock, byteArray); + + await delay(100); + + expect(sendSpy).toHaveBeenCalledWith(doc, socketMock, byteArray); + expect(closeConSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); + closeConSpy.mockRestore(); + sendSpy.mockRestore(); + }); + }); + + describe('when websocket has ready state different than Open (1) or Connecting (0)', () => { const setup = () => { const clientMessageMock = 'test-message'; const closeConSpy = jest.spyOn(service, 'closeConn'); const sendSpy = jest.spyOn(service, 'send'); const doc = TldrawWsFactory.createWsSharedDocDo(); - const socketMock = TldrawWsFactory.createWebsocket(3); + const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.CLOSED); const byteArray = new TextEncoder().encode(clientMessageMock); return { @@ -155,62 +262,67 @@ describe('TldrawWSService', () => { expect(sendSpy).toHaveBeenCalledWith(doc, socketMock, byteArray); expect(sendSpy).toHaveBeenCalledTimes(1); expect(closeConSpy).toHaveBeenCalled(); - closeConSpy.mockRestore(); sendSpy.mockRestore(); }); }); - describe('when websocket has ready state 0', () => { + describe('when websocket has ready state Open (0)', () => { const setup = async () => { ws = await TestConnection.setupWs(wsUrl, 'TEST'); const clientMessageMock = 'test-message'; const sendSpy = jest.spyOn(service, 'send'); + jest.spyOn(Ioredis.Redis.prototype, 'publish').mockResolvedValueOnce(1); const doc = TldrawWsFactory.createWsSharedDocDo(); - const socketMock = TldrawWsFactory.createWebsocket(0); - doc.conns.set(socketMock, new Set()); + const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.OPEN); + doc.connections.set(socketMock, new Set()); const encoder = encoding.createEncoder(); encoding.writeVarUint(encoder, 2); const updateByteArray = new TextEncoder().encode(clientMessageMock); encoding.writeVarUint8Array(encoder, updateByteArray); const msg = encoding.toUint8Array(encoder); + return { sendSpy, doc, msg, + socketMock, }; }; it('should call send in updateHandler', async () => { - const { sendSpy, doc, msg } = await setup(); + const { sendSpy, doc, msg, socketMock } = await setup(); - service.updateHandler(msg, {}, doc); + service.updateHandler(msg, socketMock, doc); expect(sendSpy).toHaveBeenCalled(); - ws.close(); sendSpy.mockRestore(); }); }); - describe('when received message of specific type', () => { + describe('when received message of type specific type', () => { const setup = async (messageValues: number[]) => { ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish'); const sendSpy = jest.spyOn(service, 'send'); const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate'); const syncProtocolUpdateSpy = jest .spyOn(SyncProtocols, 'readSyncMessage') - .mockImplementationOnce((dec, enc) => { + .mockImplementationOnce((_dec, enc) => { enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; return 1; }); - const doc = new WsSharedDocDo('TEST', service); + const doc = new WsSharedDocDo('TEST'); const { msg } = createMessage(messageValues); return { sendSpy, + errorLogSpy, + publishSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, @@ -224,7 +336,6 @@ describe('TldrawWSService', () => { service.messageHandler(ws, doc, msg); expect(sendSpy).toHaveBeenCalledTimes(1); - ws.close(); sendSpy.mockRestore(); applyAwarenessUpdateSpy.mockRestore(); @@ -233,11 +344,10 @@ describe('TldrawWSService', () => { it('should not call send method when received message of type AWARENESS', async () => { const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([1, 1, 0]); - service.messageHandler(ws, doc, msg); - expect(sendSpy).toHaveBeenCalledTimes(0); - expect(applyAwarenessUpdateSpy).toHaveBeenCalledTimes(1); + service.messageHandler(ws, doc, msg); + expect(sendSpy).not.toHaveBeenCalled(); ws.close(); sendSpy.mockRestore(); applyAwarenessUpdateSpy.mockRestore(); @@ -246,15 +356,66 @@ describe('TldrawWSService', () => { it('should do nothing when received message unknown type', async () => { const { sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = await setup([2]); + service.messageHandler(ws, doc, msg); expect(sendSpy).toHaveBeenCalledTimes(0); expect(applyAwarenessUpdateSpy).toHaveBeenCalledTimes(0); + ws.close(); + sendSpy.mockRestore(); + applyAwarenessUpdateSpy.mockRestore(); + syncProtocolUpdateSpy.mockRestore(); + }); + }); + + describe('when publishing AWARENESS has errors', () => { + const setup = async (messageValues: number[]) => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const errorLogSpy = jest.spyOn(logger, 'warning'); + const publishSpy = jest + .spyOn(Ioredis.Redis.prototype, 'publish') + .mockImplementationOnce((_channel, _message, cb) => { + if (cb) { + cb(new Error('error')); + } + return Promise.resolve(0); + }); + const sendSpy = jest.spyOn(service, 'send'); + const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate'); + const syncProtocolUpdateSpy = jest + .spyOn(SyncProtocols, 'readSyncMessage') + .mockImplementationOnce((_dec, enc) => { + enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; + return 1; + }); + const doc = new WsSharedDocDo('TEST'); + const { msg } = createMessage(messageValues); + + return { + sendSpy, + errorLogSpy, + publishSpy, + applyAwarenessUpdateSpy, + syncProtocolUpdateSpy, + doc, + msg, + }; + }; + + it('should log error', async () => { + const { publishSpy, errorLogSpy, sendSpy, applyAwarenessUpdateSpy, syncProtocolUpdateSpy, doc, msg } = + await setup([1, 1, 0]); + + service.messageHandler(ws, doc, msg); + expect(sendSpy).not.toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); ws.close(); sendSpy.mockRestore(); applyAwarenessUpdateSpy.mockRestore(); syncProtocolUpdateSpy.mockRestore(); + publishSpy.mockRestore(); }); }); @@ -266,7 +427,7 @@ describe('TldrawWSService', () => { jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce(() => { throw new Error('error'); }); - const doc = new WsSharedDocDo('TEST', service); + const doc = new WsSharedDocDo('TEST'); const { msg } = createMessage([0]); return { @@ -279,10 +440,9 @@ describe('TldrawWSService', () => { it('should not call send method', async () => { const { sendSpy, doc, msg } = await setup(); - service.messageHandler(ws, doc, msg); + expect(() => service.messageHandler(ws, doc, msg)).toThrow('error'); expect(sendSpy).toHaveBeenCalledTimes(0); - ws.close(); sendSpy.mockRestore(); }); @@ -292,170 +452,782 @@ describe('TldrawWSService', () => { const setup = async () => { ws = await TestConnection.setupWs(wsUrl, 'TEST'); - const doc = new WsSharedDocDo('TEST', service); + const doc = new WsSharedDocDo('TEST'); doc.awareness.states = new Map(); doc.awareness.states.set(1, ['test1']); doc.awareness.states.set(2, ['test2']); - const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockImplementationOnce(() => {}); - const sendSpy = jest.spyOn(service, 'send'); - const getYDocSpy = jest.spyOn(service, 'getYDoc').mockImplementationOnce(() => doc); + const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockReturnValueOnce(); + const sendSpy = jest.spyOn(service, 'send').mockImplementation(() => {}); + const getYDocSpy = jest.spyOn(service, 'getYDoc').mockResolvedValueOnce(doc); + const closeConnSpy = jest.spyOn(service, 'closeConn').mockResolvedValue(); const { msg } = createMessage([0]); - jest.spyOn(AwarenessProtocol, 'encodeAwarenessUpdate').mockImplementationOnce(() => msg); + jest.spyOn(AwarenessProtocol, 'encodeAwarenessUpdate').mockReturnValueOnce(msg); return { messageHandlerSpy, sendSpy, getYDocSpy, + closeConnSpy, }; }; it('should send to every client', async () => { - const { messageHandlerSpy, sendSpy, getYDocSpy } = await setup(); + const { messageHandlerSpy, sendSpy, getYDocSpy, closeConnSpy } = await setup(); - service.setupWSConnection(ws); + await service.setupWSConnection(ws, 'TEST'); + await delay(20); + ws.emit('pong'); - expect(sendSpy).toHaveBeenCalledTimes(3); + await delay(20); + expect(sendSpy).toHaveBeenCalledTimes(3); ws.close(); messageHandlerSpy.mockRestore(); sendSpy.mockRestore(); getYDocSpy.mockRestore(); + closeConnSpy.mockRestore(); }); }); }); + describe('on websocket error', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + + return { + errorLogSpy, + }; + }; + + it('should log error', async () => { + const { errorLogSpy } = await setup(); + + await service.setupWSConnection(ws, 'TEST'); + ws.emit('error', new Error('error')); + + expect(errorLogSpy).toHaveBeenCalled(); + ws.close(); + }); + }); + describe('closeConn', () => { - describe('when trying to close already closed connection', () => { + describe('when there is no error', () => { const setup = async () => { ws = await TestConnection.setupWs(wsUrl); - jest.spyOn(ws, 'close').mockImplementationOnce(() => { - throw new Error('some error'); - }); + const flushDocumentSpy = jest.spyOn(boardRepo, 'flushDocument').mockResolvedValueOnce(); + const redisUnsubscribeSpy = jest.spyOn(Ioredis.Redis.prototype, 'unsubscribe').mockResolvedValueOnce(1); + const closeConnSpy = jest.spyOn(service, 'closeConn'); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); + + return { + flushDocumentSpy, + redisUnsubscribeSpy, + closeConnSpy, + }; }; - it('should throw error', async () => { - await setup(); - try { - const doc = TldrawWsFactory.createWsSharedDocDo(); - service.closeConn(doc, ws); - } catch (err) { - expect(err).toBeDefined(); - } + it('should close connection', async () => { + const { flushDocumentSpy, redisUnsubscribeSpy, closeConnSpy } = await setup(); + + await service.setupWSConnection(ws, 'TEST'); + expect(closeConnSpy).toHaveBeenCalled(); ws.close(); + closeConnSpy.mockRestore(); + flushDocumentSpy.mockRestore(); + redisUnsubscribeSpy.mockRestore(); }); }); - describe('when ping failed', () => { + describe('when close connection fails', () => { const setup = async () => { - ws = await TestConnection.setupWs(wsUrl, 'TEST'); + ws = await TestConnection.setupWs(wsUrl); + + const flushDocumentSpy = jest.spyOn(boardRepo, 'flushDocument').mockResolvedValueOnce(); + const redisUnsubscribeSpy = jest.spyOn(Ioredis.Redis.prototype, 'unsubscribe').mockResolvedValueOnce(1); + const closeConnSpy = jest.spyOn(service, 'closeConn').mockRejectedValueOnce(new Error('error')); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const sendSpyError = jest.spyOn(service, 'send').mockReturnValue(); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); + + return { + flushDocumentSpy, + redisUnsubscribeSpy, + closeConnSpy, + errorLogSpy, + sendSpyError, + }; + }; + + it('should log error', async () => { + const { flushDocumentSpy, redisUnsubscribeSpy, closeConnSpy, errorLogSpy, sendSpyError } = await setup(); + + await service.setupWSConnection(ws, 'TEST'); + + await delay(100); + + expect(closeConnSpy).toHaveBeenCalled(); + + ws.close(); + await delay(100); + expect(errorLogSpy).toHaveBeenCalled(); + flushDocumentSpy.mockRestore(); + redisUnsubscribeSpy.mockRestore(); + closeConnSpy.mockRestore(); + sendSpyError.mockRestore(); + }); + }); + + describe('when unsubscribing from Redis fails', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + const doc = TldrawWsFactory.createWsSharedDocDo(); + doc.connections.set(ws, new Set()); + + const flushDocumentSpy = jest.spyOn(boardRepo, 'flushDocument').mockResolvedValueOnce(); + const redisUnsubscribeSpy = jest + .spyOn(Ioredis.Redis.prototype, 'unsubscribe') + .mockImplementationOnce((...args: unknown[]) => { + args.forEach((arg) => { + if (typeof arg === 'function') { + arg(new Error('error')); + } + }); + return Promise.resolve(0); + }); + const errorLogSpy = jest.spyOn(logger, 'warning'); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); + + return { + doc, + flushDocumentSpy, + redisUnsubscribeSpy, + errorLogSpy, + }; + }; - const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockImplementationOnce(() => {}); + it('should log error', async () => { + const { doc, errorLogSpy, flushDocumentSpy, redisUnsubscribeSpy } = await setup(); + + await service.closeConn(doc, ws); + + await delay(100); + + expect(redisUnsubscribeSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); + flushDocumentSpy.mockRestore(); + redisUnsubscribeSpy.mockRestore(); + }); + }); + + describe('when unsubscribing from Redis throw error', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + const doc = TldrawWsFactory.createWsSharedDocDo(); + doc.connections.set(ws, new Set()); + + const flushDocumentSpy = jest.spyOn(boardRepo, 'flushDocument').mockResolvedValueOnce(); + const redisUnsubscribeSpy = jest + .spyOn(Ioredis.Redis.prototype, 'unsubscribe') + .mockRejectedValue(new Error('error')); const closeConnSpy = jest.spyOn(service, 'closeConn'); - jest.spyOn(ws, 'ping').mockImplementationOnce(() => { - throw new Error('error'); - }); + const errorLogSpy = jest.spyOn(logger, 'warning'); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); + + return { + doc, + flushDocumentSpy, + redisUnsubscribeSpy, + closeConnSpy, + errorLogSpy, + }; + }; + + it('should log error', async () => { + const { doc, errorLogSpy, flushDocumentSpy, redisUnsubscribeSpy, closeConnSpy } = await setup(); + + await service.closeConn(doc, ws); + await delay(200); + + expect(redisUnsubscribeSpy).toHaveBeenCalled(); + expect(closeConnSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); + closeConnSpy.mockRestore(); + flushDocumentSpy.mockRestore(); + redisUnsubscribeSpy.mockRestore(); + }); + }); + + describe('when updating new document fails', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + const closeConnSpy = jest.spyOn(service, 'closeConn'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const updateDocSpy = jest.spyOn(boardRepo, 'updateDocument'); + const sendSpy = jest.spyOn(service, 'send').mockImplementation(() => {}); + + return { + closeConnSpy, + errorLogSpy, + updateDocSpy, + sendSpy, + }; + }; + + it('should log error', async () => { + const { sendSpy, errorLogSpy, updateDocSpy, closeConnSpy } = await setup(); + updateDocSpy.mockRejectedValueOnce(new Error('error')); + + await expect(service.setupWSConnection(ws, 'test-update-fail')).rejects.toThrow('error'); + ws.close(); + + expect(errorLogSpy).toHaveBeenCalled(); + closeConnSpy.mockRestore(); + sendSpy.mockRestore(); + ws.close(); + }); + }); + + describe('when pong not received', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockReturnValueOnce(); + const closeConnSpy = jest.spyOn(service, 'closeConn').mockImplementation(() => Promise.resolve()); + const pingSpy = jest.spyOn(ws, 'ping').mockImplementationOnce(() => {}); + const sendSpy = jest.spyOn(service, 'send').mockImplementation(() => {}); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); return { messageHandlerSpy, closeConnSpy, + pingSpy, + sendSpy, + clearIntervalSpy, }; }; it('should close connection', async () => { - const { messageHandlerSpy, closeConnSpy } = await setup(); + const { messageHandlerSpy, closeConnSpy, pingSpy, sendSpy, clearIntervalSpy } = await setup(); - service.setupWSConnection(ws); + await service.setupWSConnection(ws, 'TEST'); - await delay(10); + await delay(20); expect(closeConnSpy).toHaveBeenCalled(); + expect(clearIntervalSpy).toHaveBeenCalled(); + ws.close(); + messageHandlerSpy.mockRestore(); + pingSpy.mockRestore(); + closeConnSpy.mockRestore(); + sendSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + }); + }); + + describe('when pong not received and close connection fails', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + + const messageHandlerSpy = jest.spyOn(service, 'messageHandler').mockReturnValueOnce(); + const closeConnSpy = jest.spyOn(service, 'closeConn').mockRejectedValue(new Error('error')); + const pingSpy = jest.spyOn(ws, 'ping').mockImplementation(() => {}); + const sendSpy = jest.spyOn(service, 'send').mockImplementation(() => {}); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + jest.spyOn(boardRepo, 'updateDocument').mockImplementationOnce(() => Promise.resolve()); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); + + return { + messageHandlerSpy, + closeConnSpy, + pingSpy, + sendSpy, + clearIntervalSpy, + errorLogSpy, + }; + }; + + it('should log error', async () => { + const { messageHandlerSpy, closeConnSpy, pingSpy, sendSpy, clearIntervalSpy, errorLogSpy } = await setup(); + + await service.setupWSConnection(ws, 'TEST'); + await delay(200); + + expect(closeConnSpy).toHaveBeenCalled(); + expect(clearIntervalSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); ws.close(); messageHandlerSpy.mockRestore(); + pingSpy.mockRestore(); closeConnSpy.mockRestore(); + sendSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + }); + }); + + describe('when flushDocument failed', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const doc = TldrawWsFactory.createWsSharedDocDo(); + doc.connections.set(ws, new Set()); + + const flushDocumentSpy = jest.spyOn(boardRepo, 'flushDocument'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + + return { + doc, + flushDocumentSpy, + errorLogSpy, + }; + }; + + it('should log error', async () => { + const { doc, flushDocumentSpy, errorLogSpy } = await setup(); + flushDocumentSpy.mockRejectedValueOnce(new Error('error')); + + await expect(service.closeConn(doc, ws)).rejects.toThrow('error'); + + expect(flushDocumentSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); + ws.close(); }); }); }); + describe('updateHandler', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + const sendSpy = jest.spyOn(service, 'send').mockReturnValueOnce(); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish').mockResolvedValueOnce(1); + + const doc = TldrawWsFactory.createWsSharedDocDo(); + const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.OPEN); + doc.connections.set(socketMock, new Set()); + const msg = new Uint8Array([0]); + + return { + doc, + sendSpy, + socketMock, + msg, + errorLogSpy, + publishSpy, + }; + }; + + it('should call send method', async () => { + const { sendSpy, doc, socketMock, msg } = await setup(); + + service.updateHandler(msg, socketMock, doc); + + expect(sendSpy).toHaveBeenCalled(); + ws.close(); + }); + }); + + describe('databaseUpdateHandler', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + const storeUpdateSpy = jest.spyOn(boardRepo, 'storeUpdate').mockResolvedValueOnce(); + + return { + storeUpdateSpy, + }; + }; + + it('should call send method', async () => { + const { storeUpdateSpy } = await setup(); + + await service.databaseUpdateHandler('test', new Uint8Array()); + + expect(storeUpdateSpy).toHaveBeenCalled(); + ws.close(); + }); + }); + + describe('when publish to Redis has errors', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + const sendSpy = jest.spyOn(service, 'send').mockReturnValueOnce(); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const publishSpy = jest + .spyOn(Ioredis.Redis.prototype, 'publish') + .mockImplementationOnce((_channel, _message, cb) => { + if (cb) { + cb(new Error('error')); + } + return Promise.resolve(0); + }); + + const doc = TldrawWsFactory.createWsSharedDocDo(); + const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.OPEN); + doc.connections.set(socketMock, new Set()); + const msg = new Uint8Array([0]); + + return { + doc, + sendSpy, + socketMock, + msg, + errorLogSpy, + publishSpy, + }; + }; + + it('should log error', async () => { + const { doc, socketMock, msg, errorLogSpy, publishSpy } = await setup(); + + service.updateHandler(msg, socketMock, doc); + + expect(errorLogSpy).toHaveBeenCalled(); + ws.close(); + publishSpy.mockRestore(); + }); + }); + + describe('when publish to Redis throws errors', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + const sendSpy = jest.spyOn(service, 'send').mockReturnValueOnce(); + const errorLogSpy = jest.spyOn(logger, 'warning'); + const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish').mockRejectedValueOnce(new Error('error')); + + const doc = TldrawWsFactory.createWsSharedDocDo(); + const socketMock = TldrawWsFactory.createWebsocket(WebSocketReadyStateEnum.OPEN); + doc.connections.set(socketMock, new Set()); + const msg = new Uint8Array([0]); + + return { + doc, + sendSpy, + socketMock, + msg, + errorLogSpy, + publishSpy, + }; + }; + + it('should log error', async () => { + const { doc, socketMock, msg, errorLogSpy, publishSpy } = await setup(); + + service.updateHandler(msg, socketMock, doc); + + await delay(20); + + expect(errorLogSpy).toHaveBeenCalled(); + ws.close(); + publishSpy.mockRestore(); + }); + }); + describe('messageHandler', () => { describe('when message is received', () => { const setup = async (messageValues: number[]) => { ws = await TestConnection.setupWs(wsUrl, 'TEST'); + const errorLogSpy = jest.spyOn(logger, 'warning'); const messageHandlerSpy = jest.spyOn(service, 'messageHandler'); - const readSyncMessageSpy = jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce((dec, enc) => { + const readSyncMessageSpy = jest.spyOn(SyncProtocols, 'readSyncMessage').mockImplementationOnce((_dec, enc) => { enc.bufs = [new Uint8Array(2), new Uint8Array(2)]; return 1; }); + const publishSpy = jest.spyOn(Ioredis.Redis.prototype, 'publish'); + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); const { msg } = createMessage(messageValues); return { - messageHandlerSpy, msg, + messageHandlerSpy, readSyncMessageSpy, + errorLogSpy, + publishSpy, }; }; it('should handle message', async () => { - const { messageHandlerSpy, msg, readSyncMessageSpy } = await setup([0, 1]); + const { messageHandlerSpy, msg, readSyncMessageSpy, publishSpy } = await setup([0, 1]); + publishSpy.mockResolvedValueOnce(1); - service.setupWSConnection(ws); + await service.setupWSConnection(ws, 'TEST'); ws.emit('message', msg); - expect(messageHandlerSpy).toHaveBeenCalledTimes(1); + await delay(20); + expect(messageHandlerSpy).toHaveBeenCalledTimes(1); ws.close(); messageHandlerSpy.mockRestore(); readSyncMessageSpy.mockRestore(); + publishSpy.mockRestore(); + }); + + it('should log error when publish to Redis throws', async () => { + const { errorLogSpy, publishSpy } = await setup([1, 1]); + publishSpy.mockRejectedValueOnce(new Error('error')); + + await service.setupWSConnection(ws, 'TEST'); + + expect(errorLogSpy).toHaveBeenCalled(); + ws.close(); }); }); }); describe('getYDoc', () => { describe('when getting yDoc by name', () => { - it('should assign to service.doc and return instance', () => { + it('should assign to service docs map and return instance', async () => { + jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce({}); const docName = 'get-test'; - const doc = service.getYDoc(docName); + const doc = await service.getYDoc(docName); + expect(doc).toBeInstanceOf(WsSharedDocDo); expect(service.docs.get(docName)).not.toBeUndefined(); }); + + describe('when subscribing to redis channel', () => { + const setup = () => { + const redisSubscribeSpy = jest.spyOn(Ioredis.Redis.prototype, 'subscribe').mockResolvedValueOnce(1); + const redisOnSpy = jest.spyOn(Ioredis.Redis.prototype, 'on'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + + return { + redisOnSpy, + redisSubscribeSpy, + errorLogSpy, + }; + }; + + it('should register new listener', () => { + const { redisOnSpy, redisSubscribeSpy } = setup(); + + const doc = service.getYDoc('test-redis'); + + expect(doc).toBeDefined(); + expect(redisOnSpy).toHaveBeenCalled(); + redisSubscribeSpy.mockRestore(); + redisSubscribeSpy.mockRestore(); + }); + }); + + describe('when subscribing to redis channel fails', () => { + const setup = () => { + const redisSubscribeSpy = jest + .spyOn(Ioredis.Redis.prototype, 'subscribe') + .mockImplementationOnce((...args: unknown[]) => { + args.forEach((arg) => { + if (typeof arg === 'function') { + arg(new Error('error')); + } + }); + return Promise.resolve(0); + }); + const redisOnSpy = jest.spyOn(Ioredis.Redis.prototype, 'on'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + + return { + redisOnSpy, + redisSubscribeSpy, + errorLogSpy, + }; + }; + + it('should log error', async () => { + const { errorLogSpy, redisSubscribeSpy, redisOnSpy } = setup(); + + await service.getYDoc('test-redis-fail'); + + expect(redisSubscribeSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); + redisSubscribeSpy.mockRestore(); + redisOnSpy.mockRestore(); + }); + }); + }); + + describe('when subscribing to redis channel throws error', () => { + const setup = () => { + const redisSubscribeSpy = jest + .spyOn(Ioredis.Redis.prototype, 'subscribe') + .mockRejectedValue(new Error('error')); + const redisOnSpy = jest.spyOn(Ioredis.Redis.prototype, 'on'); + const errorLogSpy = jest.spyOn(logger, 'warning'); + + return { + redisOnSpy, + redisSubscribeSpy, + errorLogSpy, + }; + }; + + it('should log error', async () => { + const { errorLogSpy, redisSubscribeSpy, redisOnSpy } = setup(); + + await service.getYDoc('test-redis-fail-2'); + + await delay(500); + + expect(redisSubscribeSpy).toHaveBeenCalled(); + expect(errorLogSpy).toHaveBeenCalled(); + redisSubscribeSpy.mockRestore(); + redisOnSpy.mockRestore(); + }); }); }); - describe('updateDocument', () => { + describe('redisMessageHandler', () => { const setup = () => { - const updateDocumentSpy = jest.spyOn(service, 'updateDocument').mockImplementation(() => Promise.resolve()); + const applyUpdateSpy = jest.spyOn(Yjs, 'applyUpdate').mockReturnValueOnce(); + const applyAwarenessUpdateSpy = jest.spyOn(AwarenessProtocol, 'applyAwarenessUpdate').mockReturnValueOnce(); - return { updateDocumentSpy }; + const doc = new WsSharedDocDo('TEST'); + doc.awarenessChannel = 'TEST-AWARENESS'; + + return { + doc, + applyUpdateSpy, + applyAwarenessUpdateSpy, + }; }; - it('should call update method', async () => { - const { updateDocumentSpy } = setup(); - await service.updateDocument('test', TldrawWsFactory.createWsSharedDocDo()); + describe('when channel name is the same as docName', () => { + it('should call applyUpdate', () => { + const { doc, applyUpdateSpy } = setup(); - expect(updateDocumentSpy).toHaveBeenCalled(); + service.redisMessageHandler(Buffer.from('TEST'), Buffer.from('message'), doc); - updateDocumentSpy.mockRestore(); + expect(applyUpdateSpy).toHaveBeenCalled(); + }); }); - }); - describe('flushDocument', () => { - const setup = () => { - const flushDocumentSpy = jest.spyOn(service, 'flushDocument').mockResolvedValueOnce(Promise.resolve()); + describe('when channel name is the same as docAwarenessChannel name', () => { + it('should call applyAwarenessUpdate', () => { + const { doc, applyAwarenessUpdateSpy } = setup(); + + service.redisMessageHandler(Buffer.from('TEST-AWARENESS'), Buffer.from('message'), doc); - return { flushDocumentSpy }; + expect(applyAwarenessUpdateSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('awarenessUpdateHandler', () => { + const setup = async () => { + ws = await TestConnection.setupWs(wsUrl); + + class MockAwareness { + on = jest.fn(); + } + + const doc = new WsSharedDocDo('TEST-AUH'); + doc.awareness = new MockAwareness() as unknown as AwarenessProtocol.Awareness; + const awarenessMetaMock = new Map(); + awarenessMetaMock.set(1, { clock: 11, lastUpdated: 21 }); + awarenessMetaMock.set(2, { clock: 12, lastUpdated: 22 }); + awarenessMetaMock.set(3, { clock: 13, lastUpdated: 23 }); + const awarenessStatesMock = new Map(); + awarenessStatesMock.set(1, { updating: '21' }); + awarenessStatesMock.set(2, { updating: '22' }); + awarenessStatesMock.set(3, { updating: '23' }); + doc.awareness.states = awarenessStatesMock; + doc.awareness.meta = awarenessMetaMock; + + const sendSpy = jest.spyOn(service, 'send').mockReturnValue(); + + const mockIDs = new Set(); + const mockConns = new Map>(); + mockConns.set(ws, mockIDs); + doc.connections = mockConns; + + return { + sendSpy, + doc, + mockIDs, + mockConns, + }; }; - it('should call flush method', async () => { - const { flushDocumentSpy } = setup(); - await service.flushDocument('test'); + describe('when adding two clients states', () => { + it('should have two registered clients states', async () => { + const { sendSpy, doc, mockIDs } = await setup(); + const awarenessUpdate = { + added: [1, 3], + updated: [], + removed: [], + }; + + service.awarenessUpdateHandler(awarenessUpdate, ws, doc); + + expect(mockIDs.size).toBe(2); + expect(mockIDs.has(1)).toBe(true); + expect(mockIDs.has(3)).toBe(true); + expect(mockIDs.has(2)).toBe(false); + expect(sendSpy).toBeCalled(); + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when removing one of two existing clients states', () => { + it('should have one registered client state', async () => { + const { sendSpy, doc, mockIDs } = await setup(); + let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { + added: [1, 3], + updated: [], + removed: [], + }; - expect(flushDocumentSpy).toHaveBeenCalled(); + service.awarenessUpdateHandler(awarenessUpdate, ws, doc); + awarenessUpdate = { + added: [], + updated: [], + removed: [1], + }; + service.awarenessUpdateHandler(awarenessUpdate, ws, doc); - flushDocumentSpy.mockRestore(); + expect(mockIDs.size).toBe(1); + expect(mockIDs.has(1)).toBe(false); + expect(mockIDs.has(3)).toBe(true); + expect(sendSpy).toBeCalled(); + ws.close(); + sendSpy.mockRestore(); + }); + }); + + describe('when updating client state', () => { + it('should not change number of states', async () => { + const { sendSpy, doc, mockIDs } = await setup(); + let awarenessUpdate: { added: number[]; updated: number[]; removed: number[] } = { + added: [1], + updated: [], + removed: [], + }; + + service.awarenessUpdateHandler(awarenessUpdate, ws, doc); + awarenessUpdate = { + added: [], + updated: [1], + removed: [], + }; + service.awarenessUpdateHandler(awarenessUpdate, ws, doc); + + expect(mockIDs.size).toBe(1); + expect(mockIDs.has(1)).toBe(true); + expect(sendSpy).toBeCalled(); + + ws.close(); + sendSpy.mockRestore(); + }); }); }); }); diff --git a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts index ad93b060eac..2a34230392a 100644 --- a/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts +++ b/apps/server/src/modules/tldraw/service/tldraw.ws.service.ts @@ -2,64 +2,67 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import WebSocket from 'ws'; import { applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'; -import { encoding, decoding, map } from 'lib0'; +import { decoding, encoding, map } from 'lib0'; import { readSyncMessage, writeSyncStep1, writeUpdate } from 'y-protocols/sync'; -import { encodeStateAsUpdate } from 'yjs'; -import { Persitence, WSConnectionState, WSMessageType } from '../types'; +import { applyUpdate, encodeStateAsUpdate } from 'yjs'; +import { Buffer } from 'node:buffer'; +import { Redis } from 'ioredis'; +import { Logger } from '@src/core/logger'; +import { TldrawRedisFactory } from '../redis'; +import { + CloseConnectionLoggable, + RedisPublishErrorLoggable, + WebsocketErrorLoggable, + WebsocketMessageErrorLoggable, + WsSharedDocErrorLoggable, +} from '../loggable'; import { TldrawConfig } from '../config'; -import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; +import { AwarenessConnectionsUpdate, RedisConnectionTypeEnum, WSConnectionState, WSMessageType } from '../types'; +import { WsSharedDocDo } from '../domain'; import { TldrawBoardRepo } from '../repo'; import { MetricsService } from '../metrics'; @Injectable() export class TldrawWsService { - public pingTimeout: number; + public docs = new Map(); - public persistence: Persitence | null = null; + private readonly pingTimeout: number; - public docs = new Map(); + private readonly gcEnabled: boolean; + + public readonly sub: Redis; + + private readonly pub: Redis; constructor( private readonly configService: ConfigService, private readonly tldrawBoardRepo: TldrawBoardRepo, - private readonly metricsService: MetricsService + private readonly logger: Logger, + private readonly metricsService: MetricsService, + private readonly tldrawRedisFactory: TldrawRedisFactory ) { + this.logger.setContext(TldrawWsService.name); this.pingTimeout = this.configService.get('TLDRAW_PING_TIMEOUT'); - } + this.gcEnabled = this.configService.get('TLDRAW_GC_ENABLED'); - public setPersistence(persistence_: Persitence): void { - this.persistence = persistence_; + this.sub = this.tldrawRedisFactory.build(RedisConnectionTypeEnum.SUBSCRIBE); + this.pub = this.tldrawRedisFactory.build(RedisConnectionTypeEnum.PUBLISH); } /** * @param {WsSharedDocDo} doc * @param {WebSocket} ws */ - public closeConn(doc: WsSharedDocDo, ws: WebSocket): void { - if (doc.conns.has(ws)) { - const controlledIds = doc.conns.get(ws) as Set; - doc.conns.delete(ws); + public async closeConn(doc: WsSharedDocDo, ws: WebSocket): Promise { + if (doc.connections.has(ws)) { + const controlledIds = doc.connections.get(ws) as Set; + doc.connections.delete(ws); removeAwarenessStates(doc.awareness, Array.from(controlledIds), null); - if (doc.conns.size === 0 && this.persistence !== null) { - // if persisted, we store state and destroy ydocument - this.persistence - .writeState(doc.name, doc) - .then(() => { - doc.destroy(); - return null; - }) - .catch(() => {}); - this.docs.delete(doc.name); - this.metricsService.decrementNumberOfBoardsOnServerCounter(); - } + await this.storeStateAndDestroyYDocIfPersisted(doc); this.metricsService.decrementNumberOfUsersOnServerCounter(); } - try { - ws.close(); - } catch (err) { - throw new Error('Cannot close the connection. It is possible that connection is already closed.'); - } + ws.close(); } /** @@ -69,17 +72,18 @@ export class TldrawWsService { */ public send(doc: WsSharedDocDo, conn: WebSocket, message: Uint8Array): void { if (conn.readyState !== WSConnectionState.CONNECTING && conn.readyState !== WSConnectionState.OPEN) { - this.closeConn(doc, conn); - } - try { - conn.send(message, (err: Error | undefined) => { - if (err != null) { - this.closeConn(doc, conn); - } + this.closeConn(doc, conn).catch((err) => { + this.logger.warning(new CloseConnectionLoggable(err)); }); - } catch (e) { - this.closeConn(doc, conn); } + + conn.send(message, (err) => { + if (err) { + this.closeConn(doc, conn).catch((e) => { + this.logger.warning(new CloseConnectionLoggable(e)); + }); + } + }); } /** @@ -88,32 +92,62 @@ export class TldrawWsService { * @param {WsSharedDocDo} doc */ public updateHandler(update: Uint8Array, origin, doc: WsSharedDocDo): void { - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, WSMessageType.SYNC); - writeUpdate(encoder, update); - const message = encoding.toUint8Array(encoder); - doc.conns.forEach((_, conn) => { - this.send(doc, conn, message); - }); + const isOriginWSConn = doc.connections.has(origin as WebSocket); + if (isOriginWSConn) { + this.publishUpdateToRedis(doc, update, 'document'); + } + + this.propagateUpdate(update, doc); + } + + /** + * @param {string} docName + * @param {Uint8Array} update + */ + public async databaseUpdateHandler(docName: string, update: Uint8Array) { + await this.tldrawBoardRepo.storeUpdate(docName, update); } + /** + * @param {AwarenessConnectionsUpdate} connectionsUpdate + * @param {WebSocket | null} wsConnection Origin is the connection that made the change + * @param {WsSharedDocDo} doc + */ + public awarenessUpdateHandler = ( + connectionsUpdate: AwarenessConnectionsUpdate, + wsConnection: WebSocket | null, + doc: WsSharedDocDo + ): void => { + const changedClients = this.manageClientsConnections(connectionsUpdate, wsConnection, doc); + const buff = this.prepareAwarenessMessage(changedClients, doc); + this.sendAwarenessMessage(buff, doc); + }; + /** * Gets a Y.Doc by name, whether in memory or on disk * * @param {string} docName - the name of the Y.Doc to find or create - * @param {boolean} gc - whether to allow gc on the doc (applies only when created) - * @return {WsSharedDocDo} */ - public getYDoc(docName: string, gc = true): WsSharedDocDo { - return map.setIfUndefined(this.docs, docName, () => { - const doc = new WsSharedDocDo(docName, this, gc); - if (this.persistence !== null) { - this.persistence.bindState(docName, doc).catch(() => {}); - } + public async getYDoc(docName: string) { + const wsSharedDocDo = await map.setIfUndefined(this.docs, docName, async () => { + const doc = new WsSharedDocDo(docName, this.gcEnabled); + this.registerAwarenessUpdateHandler(doc); + this.registerUpdateHandler(doc); + this.subscribeToRedisChannels(doc); + + await this.updateDocument(docName, doc); + this.registerDatabaseUpdateHandler(doc); + this.docs.set(docName, doc); this.metricsService.incrementNumberOfBoardsOnServerCounter(); return doc; }); + + return wsSharedDocDo; + } + + public async createDbIndex(): Promise { + await this.tldrawBoardRepo.createDbIndex(); } public messageHandler(conn: WebSocket, doc: WsSharedDocDo, message: Uint8Array): void { @@ -134,28 +168,51 @@ export class TldrawWsService { } break; case WSMessageType.AWARENESS: { - applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn); + const update = decoding.readVarUint8Array(decoder); + this.publishUpdateToRedis(doc, update, 'awareness'); break; } default: break; } } catch (err) { - doc.emit('error', [err]); + this.logger.warning(new WebsocketMessageErrorLoggable(err)); + throw err; } } + /** + * @param {{ Buffer }} channel redis channel + * @param {{ Buffer }} update update message + * @param {{ WsSharedDocDo }} doc ydoc + */ + public redisMessageHandler = (channel: Buffer, update: Buffer, doc: WsSharedDocDo): void => { + const channelId = channel.toString(); + + if (channelId === doc.name) { + applyUpdate(doc, update, this.sub); + } + + if (channelId === doc.awarenessChannel) { + applyAwarenessUpdate(doc.awareness, update, this.sub); + } + }; + /** * @param {WebSocket} ws * @param {string} docName */ - public setupWSConnection(ws: WebSocket, docName = 'GLOBAL'): void { + public async setupWSConnection(ws: WebSocket, docName: string) { ws.binaryType = 'arraybuffer'; + // get doc, initialize if it does not exist yet - const doc = this.getYDoc(docName, true); - doc.conns.set(ws, new Set()); + const doc = await this.getYDoc(docName); + doc.connections.set(ws, new Set()); + + ws.on('error', (err) => { + this.logger.warning(new WebsocketErrorLoggable(err)); + }); - // listen and reply to events ws.on('message', (message: ArrayBufferLike) => { this.messageHandler(ws, doc, new Uint8Array(message)); }); @@ -163,58 +220,190 @@ export class TldrawWsService { // send initial doc state to client as update this.sendInitialState(ws, doc); - // Check if connection is still alive + // check if connection is still alive let pongReceived = true; const pingInterval = setInterval(() => { - const hasConn = doc.conns.has(ws); - - if (pongReceived) { - if (!hasConn) return; + if (pongReceived && doc.connections.has(ws)) { pongReceived = false; - - try { - ws.ping(); - } catch (e) { - this.closeConn(doc, ws); - clearInterval(pingInterval); - } + ws.ping(); return; } - if (hasConn) { - this.closeConn(doc, ws); - } - + this.closeConn(doc, ws).catch((err) => { + this.logger.warning(new CloseConnectionLoggable(err)); + }); clearInterval(pingInterval); }, this.pingTimeout); + ws.on('close', () => { - this.closeConn(doc, ws); + this.closeConn(doc, ws).catch((err) => { + this.logger.warning(new CloseConnectionLoggable(err)); + }); clearInterval(pingInterval); }); + ws.on('pong', () => { pongReceived = true; }); + { - const encoder = encoding.createEncoder(); - encoding.writeVarUint(encoder, WSMessageType.SYNC); - writeSyncStep1(encoder, doc); - this.send(doc, ws, encoding.toUint8Array(encoder)); + const syncEncoder = encoding.createEncoder(); + encoding.writeVarUint(syncEncoder, WSMessageType.SYNC); + writeSyncStep1(syncEncoder, doc); + this.send(doc, ws, encoding.toUint8Array(syncEncoder)); + const awarenessStates = doc.awareness.getStates(); if (awarenessStates.size > 0) { - encoding.writeVarUint(encoder, WSMessageType.AWARENESS); - encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys()))); - this.send(doc, ws, encoding.toUint8Array(encoder)); + const awarenessEncoder = encoding.createEncoder(); + encoding.writeVarUint(awarenessEncoder, WSMessageType.AWARENESS); + encoding.writeVarUint8Array( + awarenessEncoder, + encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys())) + ); + this.send(doc, ws, encoding.toUint8Array(awarenessEncoder)); } } this.metricsService.incrementNumberOfUsersOnServerCounter(); } - public async updateDocument(docName: string, ydoc: WsSharedDocDo): Promise { - await this.tldrawBoardRepo.updateDocument(docName, ydoc); + private async storeStateAndDestroyYDocIfPersisted(doc: WsSharedDocDo) { + if (doc.connections.size === 0) { + // if persisted, we store state and destroy ydocument + try { + await this.tldrawBoardRepo.flushDocument(doc.name); + this.unsubscribeFromRedisChannels(doc); + doc.destroy(); + } catch (err) { + this.logger.warning(new WsSharedDocErrorLoggable(doc.name, 'Error while flushing doc', err)); + throw err; + } + + this.docs.delete(doc.name); + this.metricsService.decrementNumberOfBoardsOnServerCounter(); + } + } + + private propagateUpdate(update: Uint8Array, doc: WsSharedDocDo): void { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.SYNC); + writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + + doc.connections.forEach((_, conn) => { + this.send(doc, conn, message); + }); + } + + /** + * @param changedClients array of changed clients + * @param {WsSharedDocDo} doc + */ + private prepareAwarenessMessage(changedClients: number[], doc: WsSharedDocDo): Uint8Array { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, WSMessageType.AWARENESS); + encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(doc.awareness, changedClients)); + const message = encoding.toUint8Array(encoder); + return message; + } + + /** + * @param {{ Uint8Array }} buff encoded message about changes + * @param {WsSharedDocDo} doc + */ + private sendAwarenessMessage(buff: Uint8Array, doc: WsSharedDocDo): void { + doc.connections.forEach((_, c) => { + this.send(doc, c, buff); + }); } - public async flushDocument(docName: string): Promise { - await this.tldrawBoardRepo.flushDocument(docName); + /** + * @param connectionsUpdate + * @param {WebSocket | null} wsConnection Origin is the connection that made the change + * @param {WsSharedDocDo} doc + */ + private manageClientsConnections( + connectionsUpdate: AwarenessConnectionsUpdate, + wsConnection: WebSocket | null, + doc: WsSharedDocDo + ): number[] { + const changedClients = connectionsUpdate.added.concat(connectionsUpdate.updated, connectionsUpdate.removed); + if (wsConnection !== null) { + const connControlledIDs = doc.connections.get(wsConnection); + if (connControlledIDs !== undefined) { + connectionsUpdate.added.forEach((clientID) => { + connControlledIDs.add(clientID); + }); + connectionsUpdate.removed.forEach((clientID) => { + connControlledIDs.delete(clientID); + }); + } + } + return changedClients; + } + + private registerAwarenessUpdateHandler(doc: WsSharedDocDo) { + doc.awareness.on('update', (connectionsUpdate: AwarenessConnectionsUpdate, wsConnection: WebSocket | null) => + this.awarenessUpdateHandler(connectionsUpdate, wsConnection, doc) + ); + } + + private registerUpdateHandler(doc: WsSharedDocDo) { + doc.on('update', (update: Uint8Array, origin) => this.updateHandler(update, origin, doc)); + } + + private registerDatabaseUpdateHandler(doc: WsSharedDocDo) { + doc.on('update', (update: Uint8Array) => this.databaseUpdateHandler(doc.name, update)); + } + + private subscribeToRedisChannels(doc: WsSharedDocDo) { + this.sub + .subscribe(doc.name, doc.awarenessChannel, (err) => { + if (err) { + this.logger.warning(new WsSharedDocErrorLoggable(doc.name, 'Error while subscribing to Redis channels', err)); + } + }) + .catch((err) => { + this.logger.warning(new WsSharedDocErrorLoggable(doc.name, 'Error while subscribing to Redis channels', err)); + }); + this.sub.on('messageBuffer', (channel, message) => this.redisMessageHandler(channel, message, doc)); + } + + private unsubscribeFromRedisChannels(doc: WsSharedDocDo) { + this.sub + .unsubscribe(doc.name, doc.awarenessChannel, (err) => { + if (err) { + this.logger.warning( + new WsSharedDocErrorLoggable(doc.name, 'Error while unsubscribing from Redis channels', err) + ); + } + }) + .catch((err) => { + this.logger.warning( + new WsSharedDocErrorLoggable(doc.name, 'Error while unsubscribing from Redis channels', err) + ); + }); + } + + private async updateDocument(docName: string, doc: WsSharedDocDo) { + try { + await this.tldrawBoardRepo.updateDocument(docName, doc); + } catch (err) { + this.logger.warning(new WsSharedDocErrorLoggable(doc.name, 'Error while updating document', err)); + throw err; + } + } + + private publishUpdateToRedis(doc: WsSharedDocDo, update: Uint8Array, type: 'awareness' | 'document') { + const channel = type === 'awareness' ? doc.awarenessChannel : doc.name; + this.pub + .publish(channel, Buffer.from(update), (err) => { + if (err) { + this.logger.warning(new RedisPublishErrorLoggable('awareness', err)); + } + }) + .catch((err) => { + this.logger.warning(new RedisPublishErrorLoggable('awareness', err)); + }); } private sendInitialState(ws: WebSocket, doc: WsSharedDocDo): void { diff --git a/apps/server/src/modules/tldraw/testing/index.ts b/apps/server/src/modules/tldraw/testing/index.ts new file mode 100644 index 00000000000..8fb6b0f961c --- /dev/null +++ b/apps/server/src/modules/tldraw/testing/index.ts @@ -0,0 +1,3 @@ +export * from './tldraw.factory'; +export * from './test-connection'; +export * from './testConfig'; diff --git a/apps/server/src/modules/tldraw/testing/testConfig.ts b/apps/server/src/modules/tldraw/testing/testConfig.ts new file mode 100644 index 00000000000..fb345fcba1c --- /dev/null +++ b/apps/server/src/modules/tldraw/testing/testConfig.ts @@ -0,0 +1,12 @@ +import { config } from '../config'; + +export const tldrawTestConfig = () => { + const conf = config(); + if (!conf.REDIS_URI) { + conf.REDIS_URI = 'redis://127.0.0.1:6379'; + } + conf.TLDRAW_DB_FLUSH_SIZE = 2; + conf.TLDRAW_PING_TIMEOUT = 0; + conf.TLDRAW_MAX_DOCUMENT_SIZE = 3; + return conf; +}; diff --git a/apps/server/src/modules/tldraw/factory/tldraw.factory.ts b/apps/server/src/modules/tldraw/testing/tldraw.factory.ts similarity index 61% rename from apps/server/src/modules/tldraw/factory/tldraw.factory.ts rename to apps/server/src/modules/tldraw/testing/tldraw.factory.ts index c6e80ec2329..33d869f0017 100644 --- a/apps/server/src/modules/tldraw/factory/tldraw.factory.ts +++ b/apps/server/src/modules/tldraw/testing/tldraw.factory.ts @@ -1,15 +1,18 @@ import { BaseFactory } from '@shared/testing/factory/base.factory'; +import { ObjectId } from '@mikro-orm/mongodb'; import { TldrawDrawing, TldrawDrawingProps } from '../entities'; export const tldrawEntityFactory = BaseFactory.define( TldrawDrawing, ({ sequence }) => { return { - _id: 'test-id', - id: 'test-id', + id: new ObjectId().toHexString(), docName: 'test-name', - value: 'test-value', - version: `test-version-${sequence}`, + value: Buffer.from('test'), + version: `v1`, + action: 'update', + clock: sequence, + part: sequence, }; } ); diff --git a/apps/server/src/modules/tldraw/tldraw-test.module.ts b/apps/server/src/modules/tldraw/tldraw-test.module.ts index 21a3d73f28a..3e603db2bd0 100644 --- a/apps/server/src/modules/tldraw/tldraw-test.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-test.module.ts @@ -10,6 +10,7 @@ import { TldrawController } from './controller/tldraw.controller'; import { MetricsService } from './metrics'; import { TldrawRepo } from './repo/tldraw.repo'; import { TldrawService } from './service/tldraw.service'; +import { TldrawRedisFactory } from './redis'; const imports = [ MongoMemoryDatabaseModule.forRoot({ ...defaultMikroOrmOptions }), @@ -17,7 +18,7 @@ const imports = [ ConfigModule.forRoot(createConfigModuleOptions(config)), HttpModule, ]; -const providers = [Logger, TldrawService, TldrawRepo, MetricsService]; +const providers = [Logger, TldrawService, TldrawRepo, MetricsService, TldrawRedisFactory]; @Module({ imports, providers, diff --git a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts index 7a80aac20de..7769260a09d 100644 --- a/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-ws-test.module.ts @@ -3,15 +3,32 @@ import { MongoMemoryDatabaseModule, MongoDatabaseModuleOptions } from '@infra/da import { CoreModule } from '@src/core'; import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions } from '@src/config'; +import { LoggerModule } from '@src/core/logger'; import { HttpModule } from '@nestjs/axios'; import { MetricsService } from './metrics'; -import { TldrawBoardRepo } from './repo'; +import { TldrawBoardRepo, TldrawRepo, YMongodb } from './repo'; import { TldrawWsService } from './service'; import { config } from './config'; import { TldrawWs } from './controller'; +import { TldrawDrawing } from './entities'; +import { TldrawRedisFactory } from './redis'; -const imports = [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config)), HttpModule]; -const providers = [TldrawWs, TldrawBoardRepo, TldrawWsService, MetricsService]; +const imports = [ + HttpModule, + LoggerModule, + CoreModule, + MongoMemoryDatabaseModule.forRoot({ entities: [TldrawDrawing] }), + ConfigModule.forRoot(createConfigModuleOptions(config)), +]; +const providers = [ + TldrawWs, + TldrawWsService, + TldrawBoardRepo, + TldrawRepo, + YMongodb, + MetricsService, + TldrawRedisFactory, +]; @Module({ imports, providers, diff --git a/apps/server/src/modules/tldraw/tldraw-ws.module.ts b/apps/server/src/modules/tldraw/tldraw-ws.module.ts index 8ed614a510e..9a3552cccb4 100644 --- a/apps/server/src/modules/tldraw/tldraw-ws.module.ts +++ b/apps/server/src/modules/tldraw/tldraw-ws.module.ts @@ -1,17 +1,39 @@ -import { Module } from '@nestjs/common'; +import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { createConfigModuleOptions } from '@src/config'; +import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME, TLDRAW_DB_URL } from '@src/config'; import { CoreModule } from '@src/core'; -import { Logger } from '@src/core/logger'; +import { LoggerModule } from '@src/core/logger'; +import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; +import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { HttpModule } from '@nestjs/axios'; +import { TldrawDrawing } from './entities'; import { MetricsService } from './metrics'; -import { TldrawBoardRepo } from './repo'; +import { TldrawBoardRepo, TldrawRepo, YMongodb } from './repo'; import { TldrawWsService } from './service'; import { TldrawWs } from './controller'; import { config } from './config'; +import { TldrawRedisFactory } from './redis'; +const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { + findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + new NotFoundException(`The requested ${entityName}: ${where} has not been found.`), +}; @Module({ - imports: [CoreModule, ConfigModule.forRoot(createConfigModuleOptions(config)), HttpModule], - providers: [Logger, TldrawWs, TldrawWsService, TldrawBoardRepo, MetricsService], + imports: [ + HttpModule, + LoggerModule, + CoreModule, + MikroOrmModule.forRoot({ + ...defaultMikroOrmOptions, + type: 'mongo', + clientUrl: TLDRAW_DB_URL, + password: DB_PASSWORD, + user: DB_USERNAME, + entities: [TldrawDrawing], + }), + ConfigModule.forRoot(createConfigModuleOptions(config)), + ], + providers: [TldrawWs, TldrawWsService, TldrawBoardRepo, TldrawRepo, YMongodb, MetricsService, TldrawRedisFactory], }) export class TldrawWsModule {} diff --git a/apps/server/src/modules/tldraw/tldraw.module.ts b/apps/server/src/modules/tldraw/tldraw.module.ts index fa5ebf59d02..5c43cfb5780 100644 --- a/apps/server/src/modules/tldraw/tldraw.module.ts +++ b/apps/server/src/modules/tldraw/tldraw.module.ts @@ -2,18 +2,17 @@ import { Module, NotFoundException } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { createConfigModuleOptions, DB_PASSWORD, DB_USERNAME, TLDRAW_DB_URL } from '@src/config'; import { CoreModule } from '@src/core'; -import { Logger } from '@src/core/logger'; +import { LoggerModule } from '@src/core/logger'; import { MikroOrmModule, MikroOrmModuleSyncOptions } from '@mikro-orm/nestjs'; -import { AuthenticationModule } from '@modules/authentication/authentication.module'; -import { RabbitMQWrapperTestModule } from '@infra/rabbitmq'; +import { AuthenticationModule } from '@src/modules/authentication/authentication.module'; +import { RabbitMQWrapperModule } from '@infra/rabbitmq'; import { Dictionary, IPrimaryKey } from '@mikro-orm/core'; import { AuthorizationModule } from '@modules/authorization'; -import { TldrawDrawing } from './entities'; import { config } from './config'; -import { TldrawService } from './service/tldraw.service'; -import { TldrawBoardRepo } from './repo'; -import { TldrawController } from './controller/tldraw.controller'; -import { TldrawRepo } from './repo/tldraw.repo'; +import { TldrawDrawing } from './entities'; +import { TldrawController } from './controller'; +import { TldrawService } from './service'; +import { TldrawBoardRepo, TldrawRepo, YMongodb } from './repo'; const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { findOneOrFailHandler: (entityName: string, where: Dictionary | IPrimaryKey) => @@ -23,10 +22,11 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { @Module({ imports: [ + LoggerModule, AuthorizationModule, AuthenticationModule, CoreModule, - RabbitMQWrapperTestModule, + RabbitMQWrapperModule, MikroOrmModule.forRoot({ ...defaultMikroOrmOptions, type: 'mongo', @@ -37,7 +37,7 @@ const defaultMikroOrmOptions: MikroOrmModuleSyncOptions = { }), ConfigModule.forRoot(createConfigModuleOptions(config)), ], - providers: [Logger, TldrawService, TldrawBoardRepo, TldrawRepo], + providers: [TldrawService, TldrawBoardRepo, TldrawRepo, YMongodb], controllers: [TldrawController], }) export class TldrawModule {} diff --git a/apps/server/src/modules/tldraw/types/awareness-connections-update-type.ts b/apps/server/src/modules/tldraw/types/awareness-connections-update-type.ts new file mode 100644 index 00000000000..77e5ab1b99e --- /dev/null +++ b/apps/server/src/modules/tldraw/types/awareness-connections-update-type.ts @@ -0,0 +1,5 @@ +export type AwarenessConnectionsUpdate = { + added: Array; + updated: Array; + removed: Array; +}; diff --git a/apps/server/src/modules/tldraw/types/index.ts b/apps/server/src/modules/tldraw/types/index.ts index 957e55aab3f..eafecf4993c 100644 --- a/apps/server/src/modules/tldraw/types/index.ts +++ b/apps/server/src/modules/tldraw/types/index.ts @@ -1,3 +1,5 @@ export * from './connection-enum'; +export * from './y-transaction-type'; export * from './ws-close-enum'; -export * from './persistence-type'; +export * from './awareness-connections-update-type'; +export * from './redis-connection-type.enum'; diff --git a/apps/server/src/modules/tldraw/types/persistence-type.ts b/apps/server/src/modules/tldraw/types/persistence-type.ts deleted file mode 100644 index ee8d4510275..00000000000 --- a/apps/server/src/modules/tldraw/types/persistence-type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { WsSharedDocDo } from '../domain/ws-shared-doc.do'; - -export type Persitence = { - bindState: (docName: string, ydoc: WsSharedDocDo) => Promise; - writeState: (docName: string, ydoc: WsSharedDocDo) => Promise; -}; diff --git a/apps/server/src/modules/tldraw/types/redis-connection-type.enum.ts b/apps/server/src/modules/tldraw/types/redis-connection-type.enum.ts new file mode 100644 index 00000000000..a0e34661a98 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/redis-connection-type.enum.ts @@ -0,0 +1,4 @@ +export enum RedisConnectionTypeEnum { + PUBLISH = 'PUB', + SUBSCRIBE = 'SUB', +} diff --git a/apps/server/src/modules/tldraw/types/y-transaction-type.ts b/apps/server/src/modules/tldraw/types/y-transaction-type.ts new file mode 100644 index 00000000000..cee97047960 --- /dev/null +++ b/apps/server/src/modules/tldraw/types/y-transaction-type.ts @@ -0,0 +1,3 @@ +import { Doc } from 'yjs'; + +export type YTransaction = Doc | number | void; diff --git a/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts b/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts index d5059777cef..aecd2494336 100644 --- a/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts +++ b/apps/server/src/shared/testing/factory/tldraw.ws.factory.ts @@ -1,12 +1,17 @@ import { WsSharedDocDo } from '@modules/tldraw/domain/ws-shared-doc.do'; import WebSocket from 'ws'; +import { WebSocketReadyStateEnum } from '@shared/testing'; export class TldrawWsFactory { public static createWsSharedDocDo(): WsSharedDocDo { - return { conns: new Map(), destroy: () => {} } as WsSharedDocDo; + return { connections: new Map(), destroy: () => {} } as WsSharedDocDo; } - public static createWebsocket(readyState: number): WebSocket { - return { readyState, close: () => {} } as WebSocket; + public static createWebsocket(readyState: WebSocketReadyStateEnum): WebSocket { + return { + readyState, + close: () => {}, + send: () => {}, + } as unknown as WebSocket; } } diff --git a/apps/server/src/shared/testing/index.ts b/apps/server/src/shared/testing/index.ts index afb9facad15..8a859b9db7e 100644 --- a/apps/server/src/shared/testing/index.ts +++ b/apps/server/src/shared/testing/index.ts @@ -4,3 +4,4 @@ export * from './cleanup-collections'; export * from './map-user-to-current-user'; export * from './test-api-client'; export * from './test-xApiKey-client'; +export * from './web-socket-ready-state-enum'; diff --git a/apps/server/src/shared/testing/web-socket-ready-state-enum.ts b/apps/server/src/shared/testing/web-socket-ready-state-enum.ts new file mode 100644 index 00000000000..a847c38b619 --- /dev/null +++ b/apps/server/src/shared/testing/web-socket-ready-state-enum.ts @@ -0,0 +1,4 @@ +export enum WebSocketReadyStateEnum { + OPEN = 0, + CLOSED = 3, +} diff --git a/config/default.schema.json b/config/default.schema.json index 74ac8324f8b..2379c4b520d 100644 --- a/config/default.schema.json +++ b/config/default.schema.json @@ -1442,7 +1442,7 @@ "TLDRAW": { "type": "object", "description": "Tldraw managing variables.", - "required": ["PING_TIMEOUT", "SOCKET_PORT","GC_ENABLED", "DB_COLLECTION_NAME", "DB_FLUSH_SIZE", "DB_MULTIPLE_COLLECTIONS"], + "required": ["PING_TIMEOUT", "SOCKET_PORT","GC_ENABLED", "DB_FLUSH_SIZE", "MAX_DOCUMENT_SIZE"], "properties": { "SOCKET_PORT": { "type": "number", @@ -1456,26 +1456,21 @@ "type": "boolean", "description": "If tldraw garbage collector should be enabled" }, - "DB_COLLECTION_NAME": { - "type": "string", - "description": "Collection name in which tldraw drawing are stored" - }, "DB_FLUSH_SIZE": { "type": "integer", "description": "DB collection flushing size" }, - "DB_MULTIPLE_COLLECTIONS": { - "type": "boolean", - "description": "DB collection allowing multiple collections for drawing" + "MAX_DOCUMENT_SIZE": { + "type": "number", + "description": "Maximum size of a single tldraw document in mongo" } }, "default": { "SOCKET_PORT": 3345, "PING_TIMEOUT": 10000, "GC_ENABLED": true, - "DB_COLLECTION_NAME": "drawings", "DB_FLUSH_SIZE": 400, - "DB_MULTIPLE_COLLECTIONS": false + "MAX_DOCUMENT_SIZE": 15000000 } }, "TLDRAW_DB_URL": { diff --git a/config/test.json b/config/test.json index 0d48bb2fb7a..addbab932a3 100644 --- a/config/test.json +++ b/config/test.json @@ -69,8 +69,8 @@ "SOCKET_PORT": 3346, "PING_TIMEOUT": 1, "GC_ENABLED": true, - "DB_COLLECTION_NAME": "drawings", "DB_FLUSH_SIZE": 400, + "MAX_DOCUMENT_SIZE": 15000000, "DB_MULTIPLE_COLLECTIONS": false }, "SCHULCONNEX_CLIENT": { diff --git a/package-lock.json b/package-lock.json index 277388c000f..ce5da48dd52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,9 +34,9 @@ "@nestjs/microservices": "^10.2.4", "@nestjs/passport": "^10.0.1", "@nestjs/platform-express": "^10.2.4", - "@nestjs/platform-ws": "^10.2.4", + "@nestjs/platform-ws": "^10.3.0", "@nestjs/swagger": "^7.1.10", - "@nestjs/websockets": "^10.2.4", + "@nestjs/websockets": "^10.3.0", "@types/cache-manager-redis-store": "^2.0.1", "@types/connect-redis": "^0.0.19", "@types/gm": "^1.25.1", @@ -86,6 +86,7 @@ "html-entities": "^2.3.2", "i18next": "^23.3.0", "i18next-fs-backend": "^2.1.5", + "ioredis": "^5.3.2", "jose": "^1.28.1", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^2.0.5", @@ -137,10 +138,9 @@ "urlsafe-base64": "^1.0.0", "uuid": "^8.3.0", "winston": "^3.8.2", - "ws": "^7.5.7", - "y-mongodb-provider": "^0.1.7", - "y-protocols": "^1.0.5", - "yjs": "^13.6.7" + "ws": "^8.16.0", + "y-protocols": "^1.0.6", + "yjs": "^13.6.10" }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", @@ -172,6 +172,7 @@ "@types/source-map-support": "^0.5.3", "@types/supertest": "^2.0.12", "@types/uuid": "^8.3.4", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^5.47.1", "@typescript-eslint/parser": "^5.47.1", "@typescript-eslint/typescript-estree": "^5.47.1", @@ -3510,6 +3511,11 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -4669,6 +4675,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", + "optional": true, "dependencies": { "sparse-bitfield": "^3.0.3" } @@ -5153,12 +5160,12 @@ } }, "node_modules/@nestjs/platform-ws": { - "version": "10.2.7", - "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.2.7.tgz", - "integrity": "sha512-4H4AeCQgM29Dju+zQb70Jt0JgWhQssOB8mh9n9icsSJ4B/joa+X7OiBBSjn72HZelj0tvX1gal6PaAhEaOdmGQ==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.3.1.tgz", + "integrity": "sha512-hqNSFh/h6/55BPzWMTH2kQJJqCBWiSCoYRXSiXzU2USNYaQYlLjxDIRTk6b54ud0vf96IMOIwdscL8+EoBOhJA==", "dependencies": { "tslib": "2.6.2", - "ws": "8.14.2" + "ws": "8.16.0" }, "funding": { "type": "opencollective", @@ -5170,26 +5177,6 @@ "rxjs": "^7.1.0" } }, - "node_modules/@nestjs/platform-ws/node_modules/ws": { - "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/@nestjs/schematics": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.2.tgz", @@ -5351,9 +5338,9 @@ } }, "node_modules/@nestjs/websockets": { - "version": "10.2.7", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.2.7.tgz", - "integrity": "sha512-NKJMubkwpUBsudbiyjuLZDT/W68K+fS/pe3vG5Ur8QoPn+fkI9SFCiQw27Cv4K0qVX2eGJ41yNmVfu61zGa4CQ==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.1.tgz", + "integrity": "sha512-4GckGRWQ6Ce0YnIoAysQof5a+/TZruLjbD8YHzWSbhykX33EJbK4mKYWSiL3pEI6w0RhwlpMU1cW7cFxV/gyjQ==", "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -6238,11 +6225,10 @@ } }, "node_modules/@types/ws": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.2.2.tgz", - "integrity": "sha512-NOn5eIcgWLOo6qW8AcuLZ7G8PycXu0xTxxkS6Q18VWFxgPUSOwV0pBj2a/4viNZVu25i7RIB7GttdkAIUUXOOg==", - "optional": true, - "peer": true, + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "devOptional": true, "dependencies": { "@types/node": "*" } @@ -7415,6 +7401,28 @@ "follow-redirects": "^1.14.4" } }, + "node_modules/aws-crt/node_modules/ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/aws-sdk": { "version": "2.1375.0", "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1375.0.tgz", @@ -8681,6 +8689,14 @@ "node": ">=0.10.0" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cmake-js": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-6.3.0.tgz", @@ -13465,6 +13481,53 @@ "node": ">=0.10.0" } }, + "node_modules/ioredis": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", + "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -16605,6 +16668,11 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, "node_modules/lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", @@ -16621,6 +16689,11 @@ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -17065,7 +17138,8 @@ "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "optional": true }, "node_modules/memory-stream": { "version": "0.0.3", @@ -18327,6 +18401,28 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/mqtt/node_modules/ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "optional": true, + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/mquery": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.3.tgz", @@ -22773,6 +22869,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", + "optional": true, "dependencies": { "memory-pager": "^1.0.2" } @@ -22943,6 +23040,11 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "node_modules/static-eval": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", @@ -25384,15 +25486,15 @@ } }, "node_modules/ws": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", - "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -25440,71 +25542,6 @@ "node": ">=0.4" } }, - "node_modules/y-mongodb-provider": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/y-mongodb-provider/-/y-mongodb-provider-0.1.8.tgz", - "integrity": "sha512-yV+rtS9nBEMqb6fG6sqyWNpMGzmTYe7hPiwWwWyrzK4frjMnkrQvJvyUiWjZI7eFFSKYzxYHucGEFA0j3QJEgA==", - "dependencies": { - "lib0": "^0.2.85", - "mongodb": "^6.1.0" - }, - "peerDependencies": { - "yjs": "^13.6.8" - } - }, - "node_modules/y-mongodb-provider/node_modules/bson": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.1.0.tgz", - "integrity": "sha512-yiQ3KxvpVoRpx1oD1uPz4Jit9tAVTJgjdmjDKtUErkOoL9VNoF8Dd58qtAOL5E40exx2jvAT9sqdRSK/r+SHlA==", - "engines": { - "node": ">=16.20.1" - } - }, - "node_modules/y-mongodb-provider/node_modules/mongodb": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.1.0.tgz", - "integrity": "sha512-AvzNY0zMkpothZ5mJAaIo2bGDjlJQqqAbn9fvtVgwIIUPEfdrqGxqNjjbuKyrgQxg2EvCmfWdjq+4uj96c0YPw==", - "dependencies": { - "@mongodb-js/saslprep": "^1.1.0", - "bson": "^6.1.0", - "mongodb-connection-string-url": "^2.6.0" - }, - "engines": { - "node": ">=16.20.1" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0", - "gcp-metadata": "^5.2.0", - "kerberos": "^2.0.1", - "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.2.2", - "socks": "^2.7.1" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, "node_modules/y-protocols": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", @@ -25713,11 +25750,11 @@ } }, "node_modules/yjs": { - "version": "13.6.8", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.8.tgz", - "integrity": "sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==", + "version": "13.6.11", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.11.tgz", + "integrity": "sha512-FvRRJKX9u270dOLkllGF/UDCWwmIv2Z+ucM4v1QO1TuxdmoiMnSUXH1HAcOKOrkBEhQtPTkxep7tD2DrQB+l0g==", "dependencies": { - "lib0": "^0.2.74" + "lib0": "^0.2.86" }, "engines": { "node": ">=16.0.0", @@ -28229,6 +28266,11 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -29092,6 +29134,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.0.tgz", "integrity": "sha512-Xfijy7HvfzzqiOAhAepF4SGN5e9leLkMvg/OPOF97XemjfVCYN/oWa75wnkc6mltMSTwY+XlbhWgUOJmkFspSw==", + "optional": true, "requires": { "sparse-bitfield": "^3.0.3" } @@ -29363,20 +29406,12 @@ } }, "@nestjs/platform-ws": { - "version": "10.2.7", - "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.2.7.tgz", - "integrity": "sha512-4H4AeCQgM29Dju+zQb70Jt0JgWhQssOB8mh9n9icsSJ4B/joa+X7OiBBSjn72HZelj0tvX1gal6PaAhEaOdmGQ==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.3.1.tgz", + "integrity": "sha512-hqNSFh/h6/55BPzWMTH2kQJJqCBWiSCoYRXSiXzU2USNYaQYlLjxDIRTk6b54ud0vf96IMOIwdscL8+EoBOhJA==", "requires": { "tslib": "2.6.2", - "ws": "8.14.2" - }, - "dependencies": { - "ws": { - "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", - "requires": {} - } + "ws": "8.16.0" } }, "@nestjs/schematics": { @@ -29477,9 +29512,9 @@ } }, "@nestjs/websockets": { - "version": "10.2.7", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.2.7.tgz", - "integrity": "sha512-NKJMubkwpUBsudbiyjuLZDT/W68K+fS/pe3vG5Ur8QoPn+fkI9SFCiQw27Cv4K0qVX2eGJ41yNmVfu61zGa4CQ==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.1.tgz", + "integrity": "sha512-4GckGRWQ6Ce0YnIoAysQof5a+/TZruLjbD8YHzWSbhykX33EJbK4mKYWSiL3pEI6w0RhwlpMU1cW7cFxV/gyjQ==", "requires": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -30306,11 +30341,10 @@ } }, "@types/ws": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.2.2.tgz", - "integrity": "sha512-NOn5eIcgWLOo6qW8AcuLZ7G8PycXu0xTxxkS6Q18VWFxgPUSOwV0pBj2a/4viNZVu25i7RIB7GttdkAIUUXOOg==", - "optional": true, - "peer": true, + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "devOptional": true, "requires": { "@types/node": "*" } @@ -31173,6 +31207,14 @@ "requires": { "follow-redirects": "^1.14.4" } + }, + "ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "optional": true, + "peer": true, + "requires": {} } } }, @@ -32159,6 +32201,11 @@ } } }, + "cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" + }, "cmake-js": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-6.3.0.tgz", @@ -35713,6 +35760,37 @@ "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=" }, + "ioredis": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", + "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", + "requires": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==" + } + } + }, "ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -38076,6 +38154,11 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=" }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, "lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", @@ -38092,6 +38175,11 @@ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, "lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -38461,7 +38549,8 @@ "memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "optional": true }, "memory-stream": { "version": "0.0.3", @@ -39368,6 +39457,14 @@ "requires": { "safe-buffer": "~5.2.0" } + }, + "ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "optional": true, + "peer": true, + "requires": {} } } }, @@ -42819,6 +42916,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=", + "optional": true, "requires": { "memory-pager": "^1.0.2" } @@ -42966,6 +43064,11 @@ } } }, + "standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "static-eval": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", @@ -44825,9 +44928,9 @@ } }, "ws": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", - "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", "requires": {} }, "xml2js": { @@ -44857,32 +44960,6 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, - "y-mongodb-provider": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/y-mongodb-provider/-/y-mongodb-provider-0.1.8.tgz", - "integrity": "sha512-yV+rtS9nBEMqb6fG6sqyWNpMGzmTYe7hPiwWwWyrzK4frjMnkrQvJvyUiWjZI7eFFSKYzxYHucGEFA0j3QJEgA==", - "requires": { - "lib0": "^0.2.85", - "mongodb": "^6.1.0" - }, - "dependencies": { - "bson": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.1.0.tgz", - "integrity": "sha512-yiQ3KxvpVoRpx1oD1uPz4Jit9tAVTJgjdmjDKtUErkOoL9VNoF8Dd58qtAOL5E40exx2jvAT9sqdRSK/r+SHlA==" - }, - "mongodb": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.1.0.tgz", - "integrity": "sha512-AvzNY0zMkpothZ5mJAaIo2bGDjlJQqqAbn9fvtVgwIIUPEfdrqGxqNjjbuKyrgQxg2EvCmfWdjq+4uj96c0YPw==", - "requires": { - "@mongodb-js/saslprep": "^1.1.0", - "bson": "^6.1.0", - "mongodb-connection-string-url": "^2.6.0" - } - } - } - }, "y-protocols": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", @@ -45042,11 +45119,11 @@ } }, "yjs": { - "version": "13.6.8", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.8.tgz", - "integrity": "sha512-ZPq0hpJQb6f59B++Ngg4cKexDJTvfOgeiv0sBc4sUm8CaBWH7OQC4kcCgrqbjJ/B2+6vO49exvTmYfdlPtcjbg==", + "version": "13.6.11", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.11.tgz", + "integrity": "sha512-FvRRJKX9u270dOLkllGF/UDCWwmIv2Z+ucM4v1QO1TuxdmoiMnSUXH1HAcOKOrkBEhQtPTkxep7tD2DrQB+l0g==", "requires": { - "lib0": "^0.2.74" + "lib0": "^0.2.86" } }, "yn": { diff --git a/package.json b/package.json index c4d7d4026e5..7d1e3b3d5c2 100644 --- a/package.json +++ b/package.json @@ -129,9 +129,9 @@ "@nestjs/microservices": "^10.2.4", "@nestjs/passport": "^10.0.1", "@nestjs/platform-express": "^10.2.4", - "@nestjs/platform-ws": "^10.2.4", + "@nestjs/platform-ws": "^10.3.0", "@nestjs/swagger": "^7.1.10", - "@nestjs/websockets": "^10.2.4", + "@nestjs/websockets": "^10.3.0", "@types/cache-manager-redis-store": "^2.0.1", "@types/connect-redis": "^0.0.19", "@types/gm": "^1.25.1", @@ -181,6 +181,7 @@ "html-entities": "^2.3.2", "i18next": "^23.3.0", "i18next-fs-backend": "^2.1.5", + "ioredis": "^5.3.2", "jose": "^1.28.1", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^2.0.5", @@ -232,10 +233,9 @@ "urlsafe-base64": "^1.0.0", "uuid": "^8.3.0", "winston": "^3.8.2", - "ws": "^7.5.7", - "y-mongodb-provider": "^0.1.7", - "y-protocols": "^1.0.5", - "yjs": "^13.6.7" + "ws": "^8.16.0", + "y-protocols": "^1.0.6", + "yjs": "^13.6.10" }, "devDependencies": { "@aws-sdk/client-s3": "^3.352.0", @@ -267,6 +267,7 @@ "@types/source-map-support": "^0.5.3", "@types/supertest": "^2.0.12", "@types/uuid": "^8.3.4", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^5.47.1", "@typescript-eslint/parser": "^5.47.1", "@typescript-eslint/typescript-estree": "^5.47.1",