From 2afbede8ced50bd1112f264befda5323303042fe Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 7 Jun 2024 16:23:23 +0100 Subject: [PATCH] fix page history generation --- .../components/history-modal-body.tsx | 7 +- .../queries/page-history-query.ts | 1 + apps/server/src/app.module.ts | 2 + .../collaboration/collaboration.gateway.ts | 42 +++++++---- .../src/collaboration/collaboration.module.ts | 4 +- .../extensions/history.extension.ts | 73 ------------------- .../extensions/persistence.extension.ts | 48 +++++++++--- .../listeners/history.listener.ts | 44 +++++++++++ 8 files changed, 122 insertions(+), 99 deletions(-) delete mode 100644 apps/server/src/collaboration/extensions/history.extension.ts create mode 100644 apps/server/src/collaboration/listeners/history.listener.ts diff --git a/apps/client/src/features/page-history/components/history-modal-body.tsx b/apps/client/src/features/page-history/components/history-modal-body.tsx index 0158c4e4..199601fc 100644 --- a/apps/client/src/features/page-history/components/history-modal-body.tsx +++ b/apps/client/src/features/page-history/components/history-modal-body.tsx @@ -4,13 +4,18 @@ import classes from "./history.module.css"; import { useAtom } from "jotai"; import { activeHistoryIdAtom } from "@/features/page-history/atoms/history-atoms"; import HistoryView from "@/features/page-history/components/history-view"; +import { useEffect } from "react"; interface Props { pageId: string; } export default function HistoryModalBody({ pageId }: Props) { - const [activeHistoryId] = useAtom(activeHistoryIdAtom); + const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); + + useEffect(() => { + setActiveHistoryId(""); + }, [pageId]); return (
diff --git a/apps/client/src/features/page-history/queries/page-history-query.ts b/apps/client/src/features/page-history/queries/page-history-query.ts index 0fbb5dba..32c1dafc 100644 --- a/apps/client/src/features/page-history/queries/page-history-query.ts +++ b/apps/client/src/features/page-history/queries/page-history-query.ts @@ -13,6 +13,7 @@ export function usePageHistoryListQuery( queryKey: ["page-history-list", pageId], queryFn: () => getPageHistoryList(pageId), enabled: !!pageId, + gcTime: 0, }); } diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 61386986..2d1a4131 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -10,6 +10,7 @@ import { StorageModule } from './integrations/storage/storage.module'; import { MailModule } from './integrations/mail/mail.module'; import { QueueModule } from './integrations/queue/queue.module'; import { StaticModule } from './integrations/static/static.module'; +import { EventEmitterModule } from '@nestjs/event-emitter'; @Module({ imports: [ @@ -26,6 +27,7 @@ import { StaticModule } from './integrations/static/static.module'; MailModule.forRootAsync({ imports: [EnvironmentModule], }), + EventEmitterModule.forRoot(), ], controllers: [AppController], providers: [AppService], diff --git a/apps/server/src/collaboration/collaboration.gateway.ts b/apps/server/src/collaboration/collaboration.gateway.ts index 40369926..dddb60d3 100644 --- a/apps/server/src/collaboration/collaboration.gateway.ts +++ b/apps/server/src/collaboration/collaboration.gateway.ts @@ -1,29 +1,43 @@ -import { Server as HocuspocusServer } from '@hocuspocus/server'; +import { Hocuspocus, Server as HocuspocusServer } from '@hocuspocus/server'; import { IncomingMessage } from 'http'; import WebSocket from 'ws'; import { AuthenticationExtension } from './extensions/authentication.extension'; import { PersistenceExtension } from './extensions/persistence.extension'; import { Injectable } from '@nestjs/common'; -import { HistoryExtension } from './extensions/history.extension'; +import { Redis } from '@hocuspocus/extension-redis'; +import { EnvironmentService } from '../integrations/environment/environment.service'; +import { createRetryStrategy, parseRedisUrl, RedisConfig } from '../helpers'; @Injectable() export class CollaborationGateway { + private hocuspocus: Hocuspocus; + private redisConfig: RedisConfig; + constructor( private authenticationExtension: AuthenticationExtension, private persistenceExtension: PersistenceExtension, - private historyExtension: HistoryExtension, - ) {} + private environmentService: EnvironmentService, + ) { + this.redisConfig = parseRedisUrl(this.environmentService.getRedisUrl()); - private hocuspocus = HocuspocusServer.configure({ - debounce: 5000, - maxDebounce: 10000, - unloadImmediately: false, - extensions: [ - this.authenticationExtension, - this.persistenceExtension, - this.historyExtension, - ], - }); + this.hocuspocus = HocuspocusServer.configure({ + debounce: 5000, + maxDebounce: 10000, + unloadImmediately: false, + extensions: [ + this.authenticationExtension, + this.persistenceExtension, + new Redis({ + host: this.redisConfig.host, + port: this.redisConfig.port, + options: { + password: this.redisConfig.password, + retryStrategy: createRetryStrategy(), + }, + }), + ], + }); + } handleConnection(client: WebSocket, request: IncomingMessage): any { this.hocuspocus.handleConnection(client, request); diff --git a/apps/server/src/collaboration/collaboration.module.ts b/apps/server/src/collaboration/collaboration.module.ts index 64137b88..e233eb89 100644 --- a/apps/server/src/collaboration/collaboration.module.ts +++ b/apps/server/src/collaboration/collaboration.module.ts @@ -6,15 +6,15 @@ import { HttpAdapterHost } from '@nestjs/core'; import { CollabWsAdapter } from './adapter/collab-ws.adapter'; import { IncomingMessage } from 'http'; import { WebSocket } from 'ws'; -import { HistoryExtension } from './extensions/history.extension'; import { TokenModule } from '../core/auth/token.module'; +import { HistoryListener } from './listeners/history.listener'; @Module({ providers: [ CollaborationGateway, AuthenticationExtension, PersistenceExtension, - HistoryExtension, + HistoryListener, ], imports: [TokenModule], }) diff --git a/apps/server/src/collaboration/extensions/history.extension.ts b/apps/server/src/collaboration/extensions/history.extension.ts deleted file mode 100644 index 1d675bb7..00000000 --- a/apps/server/src/collaboration/extensions/history.extension.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - Extension, - onChangePayload, - onDisconnectPayload, -} from '@hocuspocus/server'; -import { Injectable, Logger } from '@nestjs/common'; -import { PageRepo } from '@docmost/db/repos/page/page.repo'; -import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; -import { getPageId } from '../collaboration.util'; - -@Injectable() -export class HistoryExtension implements Extension { - private readonly logger = new Logger(HistoryExtension.name); - - ACTIVE_EDITING_INTERVAL = 10 * 60 * 1000; // 10 minutes - historyIntervalMap = new Map(); - lastEditTimeMap = new Map(); - - constructor( - private readonly pageRepo: PageRepo, - private readonly pageHistoryRepo: PageHistoryRepo, - ) {} - - async onChange(data: onChangePayload): Promise { - const pageId = getPageId(data.documentName); - - this.lastEditTimeMap.set(pageId, Date.now()); - - if (!this.historyIntervalMap.has(pageId)) { - const historyInterval = setInterval(() => { - if (this.isActiveEditing(pageId)) { - this.recordHistory(pageId); - } - }, this.ACTIVE_EDITING_INTERVAL); - this.historyIntervalMap.set(pageId, historyInterval); - } - } - - async onDisconnect(data: onDisconnectPayload): Promise { - const pageId = getPageId(data.documentName); - if (data.clientsCount === 0) { - if (this.historyIntervalMap.has(pageId)) { - clearInterval(this.historyIntervalMap.get(pageId)); - this.historyIntervalMap.delete(pageId); - this.lastEditTimeMap.delete(pageId); - } - } - } - - isActiveEditing(pageId: string): boolean { - const lastEditTime = this.lastEditTimeMap.get(pageId); - if (!lastEditTime) { - return false; - } - return Date.now() - lastEditTime < this.ACTIVE_EDITING_INTERVAL; - } - - async recordHistory(pageId: string) { - try { - const page = await this.pageRepo.findById(pageId, { - includeContent: true, - }); - // Todo: compare if data is the same as the previous version - await this.pageHistoryRepo.saveHistory(page); - this.logger.debug(`New history created for: ${pageId}`); - } catch (err) { - this.logger.error( - `An error occurred saving page history for: ${pageId}`, - err, - ); - } - } -} diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 25b46be4..f901243d 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -8,12 +8,20 @@ import { Injectable, Logger } from '@nestjs/common'; import { TiptapTransformer } from '@hocuspocus/transformer'; import { getPageId, jsonToText, tiptapExtensions } from '../collaboration.util'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB } from '@docmost/db/types/kysely.types'; +import { executeTx } from '@docmost/db/utils'; +import { EventEmitter2 } from '@nestjs/event-emitter'; @Injectable() export class PersistenceExtension implements Extension { private readonly logger = new Logger(PersistenceExtension.name); - constructor(private readonly pageRepo: PageRepo) {} + constructor( + private readonly pageRepo: PageRepo, + @InjectKysely() private readonly db: KyselyDB, + private eventEmitter: EventEmitter2, + ) {} async onLoadDocument(data: onLoadDocumentPayload) { const { documentName, document } = data; @@ -72,16 +80,38 @@ export class PersistenceExtension implements Extension { const textContent = jsonToText(tiptapJson); try { - await this.pageRepo.updatePage( - { - content: tiptapJson, - textContent: textContent, - ydoc: ydocState, + let page = null; + + await executeTx(this.db, async (trx) => { + page = await this.pageRepo.findById(pageId, { + withLock: true, + trx, + }); + + if (!page) { + this.logger.error(`Page with id ${pageId} not found`); + return; + } + + await this.pageRepo.updatePage( + { + content: tiptapJson, + textContent: textContent, + ydoc: ydocState, + lastUpdatedById: context.user.id, + }, + pageId, + trx, + ); + }); + + this.eventEmitter.emit('collab.page.updated', { + page: { + ...page, lastUpdatedById: context.user.id, - updatedAt: new Date(), + content: tiptapJson, }, - pageId, - ); + }); } catch (err) { this.logger.error(`Failed to update page ${pageId}`, err); } diff --git a/apps/server/src/collaboration/listeners/history.listener.ts b/apps/server/src/collaboration/listeners/history.listener.ts new file mode 100644 index 00000000..aae69ed4 --- /dev/null +++ b/apps/server/src/collaboration/listeners/history.listener.ts @@ -0,0 +1,44 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo'; +import { Page } from '@docmost/db/types/entity.types'; +import { isDeepStrictEqual } from 'node:util'; + +export class UpdatedPageEvent { + page: Page; +} + +@Injectable() +export class HistoryListener { + private readonly logger = new Logger(HistoryListener.name); + + constructor(private readonly pageHistoryRepo: PageHistoryRepo) {} + + @OnEvent('collab.page.updated') + async handleCreatePageHistory(event: UpdatedPageEvent) { + const { page } = event; + + const pageCreationTime = new Date(page.createdAt).getTime(); + const currentTime = Date.now(); + const TEN_MINUTES = 10 * 60 * 1000; + + if (currentTime - pageCreationTime < TEN_MINUTES) { + return; + } + + const lastHistory = await this.pageHistoryRepo.findPageLastHistory(page.id); + + if ( + !lastHistory || + (!isDeepStrictEqual(lastHistory.content, page.content) && + currentTime - new Date(lastHistory.createdAt).getTime() >= TEN_MINUTES) + ) { + try { + await this.pageHistoryRepo.saveHistory(page); + this.logger.debug(`New history created for: ${page.id}`); + } catch (err) { + this.logger.error(`Failed to create history for: ${page.id}`, err); + } + } + } +}