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);
+ }
+ }
+ }
+}