fix page history generation

This commit is contained in:
Philipinho
2024-06-07 16:23:23 +01:00
parent 942917072b
commit 2afbede8ce
8 changed files with 122 additions and 99 deletions

View File

@ -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 (
<div className={classes.sidebarFlex}>

View File

@ -13,6 +13,7 @@ export function usePageHistoryListQuery(
queryKey: ["page-history-list", pageId],
queryFn: () => getPageHistoryList(pageId),
enabled: !!pageId,
gcTime: 0,
});
}

View File

@ -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],

View File

@ -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);

View File

@ -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],
})

View File

@ -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<string, NodeJS.Timeout>();
lastEditTimeMap = new Map<string, number>();
constructor(
private readonly pageRepo: PageRepo,
private readonly pageHistoryRepo: PageHistoryRepo,
) {}
async onChange(data: onChangePayload): Promise<void> {
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<void> {
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,
);
}
}
}

View File

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

View File

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