mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-14 15:14:56 -04:00
feat: persist notes on realtime note unload and on interval
The realtime note map has been moved into its own class to separate the realtime note business logic from the storing logic. Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
49b4d2e070
commit
4746c30c26
22 changed files with 619 additions and 102 deletions
|
@ -19,14 +19,15 @@ We also provide an `.env.example` file containing a minimal configuration in the
|
||||||
|
|
||||||
## General
|
## General
|
||||||
|
|
||||||
| environment variable | default | example | description |
|
| environment variable | default | example | description |
|
||||||
|--------------------------|-----------|------------------------------|---------------------------------------------------------------------------------------------------|
|
|--------------------------|-----------|------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `HD_DOMAIN` | - | `https://md.example.com` | The URL the HedgeDoc instance runs on. |
|
| `HD_DOMAIN` | - | `https://md.example.com` | The URL the HedgeDoc instance runs on. |
|
||||||
| `PORT` | 3000 | | The port the HedgeDoc instance runs on. |
|
| `PORT` | 3000 | | The port the HedgeDoc instance runs on. |
|
||||||
| `HD_RENDERER_ORIGIN` | HD_DOMAIN | | The URL the renderer runs on. If omitted this will be same as `HD_DOMAIN`. |
|
| `HD_RENDERER_ORIGIN` | HD_DOMAIN | | The URL the renderer runs on. If omitted this will be same as `HD_DOMAIN`. |
|
||||||
| `HD_LOGLEVEL` | warn | | The loglevel that should be used. Options are `error`, `warn`, `info`, `debug` or `trace`. |
|
| `HD_LOGLEVEL` | warn | | The loglevel that should be used. Options are `error`, `warn`, `info`, `debug` or `trace`. |
|
||||||
| `HD_FORBIDDEN_NOTE_IDS` | - | `notAllowed, alsoNotAllowed` | A list of note ids (separated by `,`), that are not allowed to be created or requested by anyone. |
|
| `HD_FORBIDDEN_NOTE_IDS` | - | `notAllowed, alsoNotAllowed` | A list of note ids (separated by `,`), that are not allowed to be created or requested by anyone. |
|
||||||
| `HD_MAX_DOCUMENT_LENGTH` | 100000 | | The maximum length of any one document. Changes to this will impact performance for your users. |
|
| `HD_MAX_DOCUMENT_LENGTH` | 100000 | | The maximum length of any one document. Changes to this will impact performance for your users. |
|
||||||
|
| `HD_PERSIST_INTERVAL` | 10 | `0`, `5`, `10`, `20` | The time interval in **minutes** for the periodic note revision creation during realtime editing. `0` deactivates the periodic note revision creation. |
|
||||||
|
|
||||||
### Why should I want to run my renderer on a different (sub-)domain?
|
### Why should I want to run my renderer on a different (sub-)domain?
|
||||||
|
|
||||||
|
|
|
@ -49,6 +49,7 @@
|
||||||
"cli-color": "2.0.3",
|
"cli-color": "2.0.3",
|
||||||
"connect-typeorm": "2.0.0",
|
"connect-typeorm": "2.0.0",
|
||||||
"cookie": "0.5.0",
|
"cookie": "0.5.0",
|
||||||
|
"diff": "5.1.0",
|
||||||
"express-session": "1.17.3",
|
"express-session": "1.17.3",
|
||||||
"file-type": "16.5.4",
|
"file-type": "16.5.4",
|
||||||
"joi": "17.6.0",
|
"joi": "17.6.0",
|
||||||
|
@ -82,6 +83,7 @@
|
||||||
"@types/cli-color": "2.0.2",
|
"@types/cli-color": "2.0.2",
|
||||||
"@types/cookie": "0.5.1",
|
"@types/cookie": "0.5.1",
|
||||||
"@types/cookie-signature": "1.0.4",
|
"@types/cookie-signature": "1.0.4",
|
||||||
|
"@types/diff": "5.0.2",
|
||||||
"@types/express": "4.17.13",
|
"@types/express": "4.17.13",
|
||||||
"@types/express-session": "1.17.5",
|
"@types/express-session": "1.17.5",
|
||||||
"@types/jest": "28.1.6",
|
"@types/jest": "28.1.6",
|
||||||
|
|
|
@ -19,6 +19,7 @@ describe('appConfig', () => {
|
||||||
const invalidPort = 'not-a-port';
|
const invalidPort = 'not-a-port';
|
||||||
const loglevel = Loglevel.TRACE;
|
const loglevel = Loglevel.TRACE;
|
||||||
const invalidLoglevel = 'not-a-loglevel';
|
const invalidLoglevel = 'not-a-loglevel';
|
||||||
|
const invalidPersistInterval = -1;
|
||||||
|
|
||||||
describe('correctly parses config', () => {
|
describe('correctly parses config', () => {
|
||||||
it('when given correct and complete environment variables', async () => {
|
it('when given correct and complete environment variables', async () => {
|
||||||
|
@ -29,6 +30,7 @@ describe('appConfig', () => {
|
||||||
HD_RENDERER_ORIGIN: rendererOrigin,
|
HD_RENDERER_ORIGIN: rendererOrigin,
|
||||||
PORT: port.toString(),
|
PORT: port.toString(),
|
||||||
HD_LOGLEVEL: loglevel,
|
HD_LOGLEVEL: loglevel,
|
||||||
|
HD_PERSIST_INTERVAL: '100',
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -40,6 +42,7 @@ describe('appConfig', () => {
|
||||||
expect(config.rendererOrigin).toEqual(rendererOrigin);
|
expect(config.rendererOrigin).toEqual(rendererOrigin);
|
||||||
expect(config.port).toEqual(port);
|
expect(config.port).toEqual(port);
|
||||||
expect(config.loglevel).toEqual(loglevel);
|
expect(config.loglevel).toEqual(loglevel);
|
||||||
|
expect(config.persistInterval).toEqual(100);
|
||||||
restore();
|
restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -50,6 +53,7 @@ describe('appConfig', () => {
|
||||||
HD_DOMAIN: domain,
|
HD_DOMAIN: domain,
|
||||||
PORT: port.toString(),
|
PORT: port.toString(),
|
||||||
HD_LOGLEVEL: loglevel,
|
HD_LOGLEVEL: loglevel,
|
||||||
|
HD_PERSIST_INTERVAL: '100',
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -61,6 +65,7 @@ describe('appConfig', () => {
|
||||||
expect(config.rendererOrigin).toEqual(domain);
|
expect(config.rendererOrigin).toEqual(domain);
|
||||||
expect(config.port).toEqual(port);
|
expect(config.port).toEqual(port);
|
||||||
expect(config.loglevel).toEqual(loglevel);
|
expect(config.loglevel).toEqual(loglevel);
|
||||||
|
expect(config.persistInterval).toEqual(100);
|
||||||
restore();
|
restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -71,6 +76,7 @@ describe('appConfig', () => {
|
||||||
HD_DOMAIN: domain,
|
HD_DOMAIN: domain,
|
||||||
HD_RENDERER_ORIGIN: rendererOrigin,
|
HD_RENDERER_ORIGIN: rendererOrigin,
|
||||||
HD_LOGLEVEL: loglevel,
|
HD_LOGLEVEL: loglevel,
|
||||||
|
HD_PERSIST_INTERVAL: '100',
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -82,6 +88,7 @@ describe('appConfig', () => {
|
||||||
expect(config.rendererOrigin).toEqual(rendererOrigin);
|
expect(config.rendererOrigin).toEqual(rendererOrigin);
|
||||||
expect(config.port).toEqual(3000);
|
expect(config.port).toEqual(3000);
|
||||||
expect(config.loglevel).toEqual(loglevel);
|
expect(config.loglevel).toEqual(loglevel);
|
||||||
|
expect(config.persistInterval).toEqual(100);
|
||||||
restore();
|
restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -92,6 +99,7 @@ describe('appConfig', () => {
|
||||||
HD_DOMAIN: domain,
|
HD_DOMAIN: domain,
|
||||||
HD_RENDERER_ORIGIN: rendererOrigin,
|
HD_RENDERER_ORIGIN: rendererOrigin,
|
||||||
PORT: port.toString(),
|
PORT: port.toString(),
|
||||||
|
HD_PERSIST_INTERVAL: '100',
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -103,6 +111,54 @@ describe('appConfig', () => {
|
||||||
expect(config.rendererOrigin).toEqual(rendererOrigin);
|
expect(config.rendererOrigin).toEqual(rendererOrigin);
|
||||||
expect(config.port).toEqual(port);
|
expect(config.port).toEqual(port);
|
||||||
expect(config.loglevel).toEqual(Loglevel.WARN);
|
expect(config.loglevel).toEqual(Loglevel.WARN);
|
||||||
|
expect(config.persistInterval).toEqual(100);
|
||||||
|
restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when no HD_PERSIST_INTERVAL is set', () => {
|
||||||
|
const restore = mockedEnv(
|
||||||
|
{
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
HD_DOMAIN: domain,
|
||||||
|
HD_RENDERER_ORIGIN: rendererOrigin,
|
||||||
|
HD_LOGLEVEL: loglevel,
|
||||||
|
PORT: port.toString(),
|
||||||
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const config = appConfig();
|
||||||
|
expect(config.domain).toEqual(domain);
|
||||||
|
expect(config.rendererOrigin).toEqual(rendererOrigin);
|
||||||
|
expect(config.port).toEqual(port);
|
||||||
|
expect(config.loglevel).toEqual(Loglevel.TRACE);
|
||||||
|
expect(config.persistInterval).toEqual(10);
|
||||||
|
restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('when HD_PERSIST_INTERVAL is zero', () => {
|
||||||
|
const restore = mockedEnv(
|
||||||
|
{
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
HD_DOMAIN: domain,
|
||||||
|
HD_RENDERER_ORIGIN: rendererOrigin,
|
||||||
|
HD_LOGLEVEL: loglevel,
|
||||||
|
PORT: port.toString(),
|
||||||
|
HD_PERSIST_INTERVAL: '0',
|
||||||
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const config = appConfig();
|
||||||
|
expect(config.domain).toEqual(domain);
|
||||||
|
expect(config.rendererOrigin).toEqual(rendererOrigin);
|
||||||
|
expect(config.port).toEqual(port);
|
||||||
|
expect(config.loglevel).toEqual(Loglevel.TRACE);
|
||||||
|
expect(config.persistInterval).toEqual(0);
|
||||||
restore();
|
restore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -210,5 +266,23 @@ describe('appConfig', () => {
|
||||||
expect(() => appConfig()).toThrow('HD_LOGLEVEL');
|
expect(() => appConfig()).toThrow('HD_LOGLEVEL');
|
||||||
restore();
|
restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('when given a negative HD_PERSIST_INTERVAL', () => {
|
||||||
|
const restore = mockedEnv(
|
||||||
|
{
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
HD_DOMAIN: domain,
|
||||||
|
PORT: port.toString(),
|
||||||
|
HD_LOGLEVEL: invalidLoglevel,
|
||||||
|
HD_PERSIST_INTERVAL: invalidPersistInterval.toString(),
|
||||||
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clear: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(() => appConfig()).toThrow('HD_PERSIST_INTERVAL');
|
||||||
|
restore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,6 +14,7 @@ export interface AppConfig {
|
||||||
rendererOrigin: string;
|
rendererOrigin: string;
|
||||||
port: number;
|
port: number;
|
||||||
loglevel: Loglevel;
|
loglevel: Loglevel;
|
||||||
|
persistInterval: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = Joi.object({
|
const schema = Joi.object({
|
||||||
|
@ -41,6 +42,12 @@ const schema = Joi.object({
|
||||||
.default(Loglevel.WARN)
|
.default(Loglevel.WARN)
|
||||||
.optional()
|
.optional()
|
||||||
.label('HD_LOGLEVEL'),
|
.label('HD_LOGLEVEL'),
|
||||||
|
persistInterval: Joi.number()
|
||||||
|
.integer()
|
||||||
|
.min(0)
|
||||||
|
.default(10)
|
||||||
|
.optional()
|
||||||
|
.label('HD_PERSIST_INTERVAL'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default registerAs('appConfig', () => {
|
export default registerAs('appConfig', () => {
|
||||||
|
@ -50,6 +57,7 @@ export default registerAs('appConfig', () => {
|
||||||
rendererOrigin: process.env.HD_RENDERER_ORIGIN,
|
rendererOrigin: process.env.HD_RENDERER_ORIGIN,
|
||||||
port: parseOptionalNumber(process.env.PORT),
|
port: parseOptionalNumber(process.env.PORT),
|
||||||
loglevel: process.env.HD_LOGLEVEL,
|
loglevel: process.env.HD_LOGLEVEL,
|
||||||
|
persistInterval: process.env.HD_PERSIST_INTERVAL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
abortEarly: false,
|
abortEarly: false,
|
||||||
|
|
|
@ -15,5 +15,6 @@ export default registerAs(
|
||||||
rendererOrigin: 'md-renderer.example.com',
|
rendererOrigin: 'md-renderer.example.com',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
loglevel: Loglevel.ERROR,
|
loglevel: Loglevel.ERROR,
|
||||||
|
persistInterval: 10,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
@ -168,6 +168,7 @@ describe('FrontendConfigService', () => {
|
||||||
rendererOrigin: domain,
|
rendererOrigin: domain,
|
||||||
port: 3000,
|
port: 3000,
|
||||||
loglevel: Loglevel.ERROR,
|
loglevel: Loglevel.ERROR,
|
||||||
|
persistInterval: 10,
|
||||||
};
|
};
|
||||||
const authConfig: AuthConfig = {
|
const authConfig: AuthConfig = {
|
||||||
...emptyAuthConfig,
|
...emptyAuthConfig,
|
||||||
|
@ -322,6 +323,7 @@ describe('FrontendConfigService', () => {
|
||||||
rendererOrigin: renderOrigin ?? domain,
|
rendererOrigin: renderOrigin ?? domain,
|
||||||
port: 3000,
|
port: 3000,
|
||||||
loglevel: Loglevel.ERROR,
|
loglevel: Loglevel.ERROR,
|
||||||
|
persistInterval: 10,
|
||||||
};
|
};
|
||||||
const authConfig: AuthConfig = {
|
const authConfig: AuthConfig = {
|
||||||
...emptyAuthConfig,
|
...emptyAuthConfig,
|
||||||
|
|
|
@ -42,6 +42,13 @@ async function bootstrap(): Promise<void> {
|
||||||
// Setup code must be added there!
|
// Setup code must be added there!
|
||||||
await setupApp(app, appConfig, authConfig, mediaConfig, logger);
|
await setupApp(app, appConfig, authConfig, mediaConfig, logger);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* enableShutdownHooks consumes memory by starting listeners. In cases where you are running multiple Nest apps in a
|
||||||
|
* single Node process (e.g., when running parallel tests with Jest), Node may complain about excessive listener processes.
|
||||||
|
* For this reason, enableShutdownHooks is not enabled in the tests.
|
||||||
|
*/
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
await app.listen(appConfig.port);
|
await app.listen(appConfig.port);
|
||||||
logger.log(`Listening on port ${appConfig.port}`, 'AppBootstrap');
|
logger.log(`Listening on port ${appConfig.port}`, 'AppBootstrap');
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {
|
||||||
import { GroupsService } from '../groups/groups.service';
|
import { GroupsService } from '../groups/groups.service';
|
||||||
import { HistoryEntry } from '../history/history-entry.entity';
|
import { HistoryEntry } from '../history/history-entry.entity';
|
||||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||||
|
import { RealtimeNoteStore } from '../realtime/realtime-note/realtime-note-store.service';
|
||||||
import { RealtimeNoteService } from '../realtime/realtime-note/realtime-note.service';
|
import { RealtimeNoteService } from '../realtime/realtime-note/realtime-note.service';
|
||||||
import { Revision } from '../revisions/revision.entity';
|
import { Revision } from '../revisions/revision.entity';
|
||||||
import { RevisionsService } from '../revisions/revisions.service';
|
import { RevisionsService } from '../revisions/revisions.service';
|
||||||
|
@ -45,6 +46,7 @@ export class NotesService {
|
||||||
private noteConfig: NoteConfig,
|
private noteConfig: NoteConfig,
|
||||||
@Inject(forwardRef(() => AliasService)) private aliasService: AliasService,
|
@Inject(forwardRef(() => AliasService)) private aliasService: AliasService,
|
||||||
private realtimeNoteService: RealtimeNoteService,
|
private realtimeNoteService: RealtimeNoteService,
|
||||||
|
private realtimeNoteStore: RealtimeNoteStore,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(NotesService.name);
|
this.logger.setContext(NotesService.name);
|
||||||
}
|
}
|
||||||
|
@ -119,10 +121,7 @@ export class NotesService {
|
||||||
*/
|
*/
|
||||||
async getNoteContent(note: Note): Promise<string> {
|
async getNoteContent(note: Note): Promise<string> {
|
||||||
return (
|
return (
|
||||||
this.realtimeNoteService
|
this.realtimeNoteStore.find(note.id)?.getYDoc().getCurrentContent() ??
|
||||||
.getRealtimeNote(note.id)
|
|
||||||
?.getYDoc()
|
|
||||||
.getCurrentContent() ??
|
|
||||||
(await this.revisionsService.getLatestRevision(note)).content
|
(await this.revisionsService.getLatestRevision(note)).content
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
50
src/realtime/realtime-note/realtime-note-store.service.ts
Normal file
50
src/realtime/realtime-note/realtime-note-store.service.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { Note } from '../../notes/note.entity';
|
||||||
|
import { RealtimeNote } from './realtime-note';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RealtimeNoteStore {
|
||||||
|
private noteIdToRealtimeNote = new Map<string, RealtimeNote>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new {@link RealtimeNote} for the given {@link Note} and memorizes it.
|
||||||
|
*
|
||||||
|
* @param note The note for which the realtime note should be created
|
||||||
|
* @param initialContent The initial content for the realtime note
|
||||||
|
* @throws Error if there is already an realtime note for the given note.
|
||||||
|
* @return The created realtime note
|
||||||
|
*/
|
||||||
|
public create(note: Note, initialContent: string): RealtimeNote {
|
||||||
|
if (this.noteIdToRealtimeNote.has(note.id)) {
|
||||||
|
throw new Error(`Realtime note for note ${note.id} already exists.`);
|
||||||
|
}
|
||||||
|
const realtimeNote = new RealtimeNote(note, initialContent);
|
||||||
|
realtimeNote.on('destroy', () => {
|
||||||
|
this.noteIdToRealtimeNote.delete(note.id);
|
||||||
|
});
|
||||||
|
this.noteIdToRealtimeNote.set(note.id, realtimeNote);
|
||||||
|
return realtimeNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a {@link RealtimeNote} that is linked to the given {@link Note} id.
|
||||||
|
* @param noteId The id of the {@link Note}
|
||||||
|
* @return A {@link RealtimeNote} or {@code undefined} if no instance is existing.
|
||||||
|
*/
|
||||||
|
public find(noteId: string): RealtimeNote | undefined {
|
||||||
|
return this.noteIdToRealtimeNote.get(noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all registered {@link RealtimeNote realtime notes}.
|
||||||
|
*/
|
||||||
|
public getAllRealtimeNotes(): RealtimeNote[] {
|
||||||
|
return [...this.noteIdToRealtimeNote.values()];
|
||||||
|
}
|
||||||
|
}
|
70
src/realtime/realtime-note/realtime-note-store.spec.ts
Normal file
70
src/realtime/realtime-note/realtime-note-store.spec.ts
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
|
||||||
|
import { Note } from '../../notes/note.entity';
|
||||||
|
import * as realtimeNoteModule from './realtime-note';
|
||||||
|
import { RealtimeNote } from './realtime-note';
|
||||||
|
import { RealtimeNoteStore } from './realtime-note-store.service';
|
||||||
|
import { mockRealtimeNote } from './test-utils/mock-realtime-note';
|
||||||
|
import { WebsocketAwareness } from './websocket-awareness';
|
||||||
|
import { WebsocketDoc } from './websocket-doc';
|
||||||
|
|
||||||
|
describe('RealtimeNoteStore', () => {
|
||||||
|
let realtimeNoteStore: RealtimeNoteStore;
|
||||||
|
let mockedNote: Note;
|
||||||
|
let mockedRealtimeNote: RealtimeNote;
|
||||||
|
let realtimeNoteConstructorSpy: jest.SpyInstance;
|
||||||
|
const mockedContent = 'mockedContent';
|
||||||
|
const mockedNoteId = 'mockedNoteId';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
jest.resetModules();
|
||||||
|
|
||||||
|
realtimeNoteStore = new RealtimeNoteStore();
|
||||||
|
|
||||||
|
mockedNote = Mock.of<Note>({ id: mockedNoteId });
|
||||||
|
mockedRealtimeNote = mockRealtimeNote(
|
||||||
|
mockedNote,
|
||||||
|
Mock.of<WebsocketDoc>(),
|
||||||
|
Mock.of<WebsocketAwareness>(),
|
||||||
|
);
|
||||||
|
realtimeNoteConstructorSpy = jest
|
||||||
|
.spyOn(realtimeNoteModule, 'RealtimeNote')
|
||||||
|
.mockReturnValue(mockedRealtimeNote);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can create a new realtime note if it doesn't exist yet", () => {
|
||||||
|
expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe(
|
||||||
|
mockedRealtimeNote,
|
||||||
|
);
|
||||||
|
expect(realtimeNoteConstructorSpy).toBeCalledWith(
|
||||||
|
mockedNote,
|
||||||
|
mockedContent,
|
||||||
|
);
|
||||||
|
expect(realtimeNoteStore.find(mockedNoteId)).toBe(mockedRealtimeNote);
|
||||||
|
expect(realtimeNoteStore.getAllRealtimeNotes()).toStrictEqual([
|
||||||
|
mockedRealtimeNote,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if a realtime note has already been created for the given note', () => {
|
||||||
|
expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe(
|
||||||
|
mockedRealtimeNote,
|
||||||
|
);
|
||||||
|
expect(() => realtimeNoteStore.create(mockedNote, mockedContent)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a note if it gets destroyed', () => {
|
||||||
|
expect(realtimeNoteStore.create(mockedNote, mockedContent)).toBe(
|
||||||
|
mockedRealtimeNote,
|
||||||
|
);
|
||||||
|
mockedRealtimeNote.emit('destroy');
|
||||||
|
expect(realtimeNoteStore.find(mockedNoteId)).toBe(undefined);
|
||||||
|
expect(realtimeNoteStore.getAllRealtimeNotes()).toStrictEqual([]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -4,12 +4,14 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
import { LoggerModule } from '../../logger/logger.module';
|
import { LoggerModule } from '../../logger/logger.module';
|
||||||
import { PermissionsModule } from '../../permissions/permissions.module';
|
import { PermissionsModule } from '../../permissions/permissions.module';
|
||||||
import { RevisionsModule } from '../../revisions/revisions.module';
|
import { RevisionsModule } from '../../revisions/revisions.module';
|
||||||
import { SessionModule } from '../../session/session.module';
|
import { SessionModule } from '../../session/session.module';
|
||||||
import { UsersModule } from '../../users/users.module';
|
import { UsersModule } from '../../users/users.module';
|
||||||
|
import { RealtimeNoteStore } from './realtime-note-store.service';
|
||||||
import { RealtimeNoteService } from './realtime-note.service';
|
import { RealtimeNoteService } from './realtime-note.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
@ -19,8 +21,9 @@ import { RealtimeNoteService } from './realtime-note.service';
|
||||||
PermissionsModule,
|
PermissionsModule,
|
||||||
SessionModule,
|
SessionModule,
|
||||||
RevisionsModule,
|
RevisionsModule,
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
exports: [RealtimeNoteService],
|
exports: [RealtimeNoteService, RealtimeNoteStore],
|
||||||
providers: [RealtimeNoteService],
|
providers: [RealtimeNoteService, RealtimeNoteStore],
|
||||||
})
|
})
|
||||||
export class RealtimeNoteModule {}
|
export class RealtimeNoteModule {}
|
||||||
|
|
|
@ -3,26 +3,38 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
|
|
||||||
|
import { AppConfig } from '../../config/app.config';
|
||||||
|
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||||
import { Note } from '../../notes/note.entity';
|
import { Note } from '../../notes/note.entity';
|
||||||
import { Revision } from '../../revisions/revision.entity';
|
import { Revision } from '../../revisions/revision.entity';
|
||||||
import { RevisionsService } from '../../revisions/revisions.service';
|
import { RevisionsService } from '../../revisions/revisions.service';
|
||||||
import * as realtimeNoteModule from './realtime-note';
|
|
||||||
import { RealtimeNote } from './realtime-note';
|
import { RealtimeNote } from './realtime-note';
|
||||||
|
import { RealtimeNoteStore } from './realtime-note-store.service';
|
||||||
import { RealtimeNoteService } from './realtime-note.service';
|
import { RealtimeNoteService } from './realtime-note.service';
|
||||||
|
import { mockAwareness } from './test-utils/mock-awareness';
|
||||||
import { mockRealtimeNote } from './test-utils/mock-realtime-note';
|
import { mockRealtimeNote } from './test-utils/mock-realtime-note';
|
||||||
import { WebsocketAwareness } from './websocket-awareness';
|
import { mockWebsocketDoc } from './test-utils/mock-websocket-doc';
|
||||||
|
import { waitForOtherPromisesToFinish } from './test-utils/wait-for-other-promises-to-finish';
|
||||||
import { WebsocketDoc } from './websocket-doc';
|
import { WebsocketDoc } from './websocket-doc';
|
||||||
|
|
||||||
describe('RealtimeNoteService', () => {
|
describe('RealtimeNoteService', () => {
|
||||||
let realtimeNoteService: RealtimeNoteService;
|
|
||||||
let mockedNote: Note;
|
|
||||||
let mockedRealtimeNote: RealtimeNote;
|
|
||||||
let realtimeNoteConstructorSpy: jest.SpyInstance;
|
|
||||||
let revisionsService: RevisionsService;
|
|
||||||
const mockedContent = 'mockedContent';
|
const mockedContent = 'mockedContent';
|
||||||
const mockedNoteId = 'mockedNoteId';
|
const mockedNoteId = 'mockedNoteId';
|
||||||
|
let websocketDoc: WebsocketDoc;
|
||||||
|
let mockedNote: Note;
|
||||||
|
let mockedRealtimeNote: RealtimeNote;
|
||||||
|
let realtimeNoteService: RealtimeNoteService;
|
||||||
|
let revisionsService: RevisionsService;
|
||||||
|
let realtimeNoteStore: RealtimeNoteStore;
|
||||||
|
let consoleLoggerService: ConsoleLoggerService;
|
||||||
|
let mockedAppConfig: AppConfig;
|
||||||
|
let addIntervalSpy: jest.SpyInstance;
|
||||||
|
let setIntervalSpy: jest.SpyInstance;
|
||||||
|
let clearIntervalSpy: jest.SpyInstance;
|
||||||
|
let deleteIntervalSpy: jest.SpyInstance;
|
||||||
|
|
||||||
function mockGetLatestRevision(latestRevisionExists: boolean) {
|
function mockGetLatestRevision(latestRevisionExists: boolean) {
|
||||||
jest
|
jest
|
||||||
|
@ -42,62 +54,188 @@ describe('RealtimeNoteService', () => {
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
|
|
||||||
revisionsService = Mock.of<RevisionsService>({
|
websocketDoc = mockWebsocketDoc();
|
||||||
getLatestRevision: jest.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
realtimeNoteService = new RealtimeNoteService(revisionsService);
|
|
||||||
|
|
||||||
mockedNote = Mock.of<Note>({ id: mockedNoteId });
|
mockedNote = Mock.of<Note>({ id: mockedNoteId });
|
||||||
mockedRealtimeNote = mockRealtimeNote(
|
mockedRealtimeNote = mockRealtimeNote(
|
||||||
Mock.of<WebsocketDoc>(),
|
mockedNote,
|
||||||
Mock.of<WebsocketAwareness>(),
|
websocketDoc,
|
||||||
|
mockAwareness(),
|
||||||
|
);
|
||||||
|
|
||||||
|
revisionsService = Mock.of<RevisionsService>({
|
||||||
|
getLatestRevision: jest.fn(),
|
||||||
|
createRevision: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleLoggerService = Mock.of<ConsoleLoggerService>({
|
||||||
|
error: jest.fn(),
|
||||||
|
});
|
||||||
|
realtimeNoteStore = Mock.of<RealtimeNoteStore>({
|
||||||
|
find: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
getAllRealtimeNotes: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedAppConfig = Mock.of<AppConfig>({ persistInterval: 0 });
|
||||||
|
|
||||||
|
const schedulerRegistry = Mock.of<SchedulerRegistry>({
|
||||||
|
addInterval: jest.fn(),
|
||||||
|
deleteInterval: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
addIntervalSpy = jest.spyOn(schedulerRegistry, 'addInterval');
|
||||||
|
deleteIntervalSpy = jest.spyOn(schedulerRegistry, 'deleteInterval');
|
||||||
|
setIntervalSpy = jest.spyOn(global, 'setInterval');
|
||||||
|
clearIntervalSpy = jest.spyOn(global, 'clearInterval');
|
||||||
|
|
||||||
|
realtimeNoteService = new RealtimeNoteService(
|
||||||
|
revisionsService,
|
||||||
|
consoleLoggerService,
|
||||||
|
realtimeNoteStore,
|
||||||
|
schedulerRegistry,
|
||||||
|
mockedAppConfig,
|
||||||
);
|
);
|
||||||
realtimeNoteConstructorSpy = jest
|
|
||||||
.spyOn(realtimeNoteModule, 'RealtimeNote')
|
|
||||||
.mockReturnValue(mockedRealtimeNote);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a new realtime note if it doesn't exist yet", async () => {
|
it("creates a new realtime note if it doesn't exist yet", async () => {
|
||||||
mockGetLatestRevision(true);
|
mockGetLatestRevision(true);
|
||||||
|
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||||
|
jest
|
||||||
|
.spyOn(realtimeNoteStore, 'create')
|
||||||
|
.mockImplementation(() => mockedRealtimeNote);
|
||||||
|
mockedAppConfig.persistInterval = 0;
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
||||||
).resolves.toBe(mockedRealtimeNote);
|
).resolves.toBe(mockedRealtimeNote);
|
||||||
expect(realtimeNoteConstructorSpy).toBeCalledWith(
|
|
||||||
mockedNoteId,
|
expect(realtimeNoteStore.find).toBeCalledWith(mockedNoteId);
|
||||||
mockedContent,
|
expect(realtimeNoteStore.create).toBeCalledWith(mockedNote, mockedContent);
|
||||||
);
|
expect(setIntervalSpy).not.toBeCalled();
|
||||||
expect(realtimeNoteService.getRealtimeNote(mockedNoteId)).toBe(
|
});
|
||||||
mockedRealtimeNote,
|
|
||||||
);
|
describe('with periodic timer', () => {
|
||||||
|
it('starts a timer if config has set an interval', async () => {
|
||||||
|
mockGetLatestRevision(true);
|
||||||
|
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||||
|
jest
|
||||||
|
.spyOn(realtimeNoteStore, 'create')
|
||||||
|
.mockImplementation(() => mockedRealtimeNote);
|
||||||
|
mockedAppConfig.persistInterval = 10;
|
||||||
|
|
||||||
|
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
|
||||||
|
|
||||||
|
expect(setIntervalSpy).toBeCalledWith(
|
||||||
|
expect.any(Function),
|
||||||
|
1000 * 60 * 10,
|
||||||
|
);
|
||||||
|
expect(addIntervalSpy).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops the timer if the realtime note gets destroyed', async () => {
|
||||||
|
mockGetLatestRevision(true);
|
||||||
|
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||||
|
jest
|
||||||
|
.spyOn(realtimeNoteStore, 'create')
|
||||||
|
.mockImplementation(() => mockedRealtimeNote);
|
||||||
|
mockedAppConfig.persistInterval = 10;
|
||||||
|
|
||||||
|
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
|
||||||
|
mockedRealtimeNote.emit('destroy');
|
||||||
|
expect(deleteIntervalSpy).toBeCalled();
|
||||||
|
expect(clearIntervalSpy).toBeCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails if the requested note doesn't exist", async () => {
|
it("fails if the requested note doesn't exist", async () => {
|
||||||
mockGetLatestRevision(false);
|
mockGetLatestRevision(false);
|
||||||
|
|
||||||
|
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
||||||
).rejects.toBe(`Revision for note mockedNoteId not found.`);
|
).rejects.toBe(`Revision for note mockedNoteId not found.`);
|
||||||
expect(realtimeNoteConstructorSpy).not.toBeCalled();
|
expect(realtimeNoteStore.create).not.toBeCalled();
|
||||||
expect(realtimeNoteService.getRealtimeNote(mockedNoteId)).toBeUndefined();
|
expect(realtimeNoteStore.find).toBeCalledWith(mockedNoteId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("doesn't create a new realtime note if there is already one", async () => {
|
it("doesn't create a new realtime note if there is already one", async () => {
|
||||||
mockGetLatestRevision(true);
|
mockGetLatestRevision(true);
|
||||||
|
|
||||||
|
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||||
|
jest
|
||||||
|
.spyOn(realtimeNoteStore, 'create')
|
||||||
|
.mockImplementation(() => mockedRealtimeNote);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
||||||
).resolves.toBe(mockedRealtimeNote);
|
).resolves.toBe(mockedRealtimeNote);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(realtimeNoteStore, 'find')
|
||||||
|
.mockImplementation(() => mockedRealtimeNote);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
||||||
).resolves.toBe(mockedRealtimeNote);
|
).resolves.toBe(mockedRealtimeNote);
|
||||||
expect(realtimeNoteConstructorSpy).toBeCalledTimes(1);
|
expect(realtimeNoteStore.create).toBeCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes the realtime from the map if the realtime note is destroyed', async () => {
|
it('saves a realtime note if it gets destroyed', async () => {
|
||||||
mockGetLatestRevision(true);
|
mockGetLatestRevision(true);
|
||||||
await expect(
|
const mockedCurrentContent = 'mockedCurrentContent';
|
||||||
realtimeNoteService.getOrCreateRealtimeNote(mockedNote),
|
|
||||||
).resolves.toBe(mockedRealtimeNote);
|
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||||
mockedRealtimeNote.emit('destroy');
|
jest
|
||||||
expect(realtimeNoteService.getRealtimeNote(mockedNoteId)).toBeUndefined();
|
.spyOn(realtimeNoteStore, 'create')
|
||||||
|
.mockImplementation(() => mockedRealtimeNote);
|
||||||
|
jest
|
||||||
|
.spyOn(websocketDoc, 'getCurrentContent')
|
||||||
|
.mockReturnValue(mockedCurrentContent);
|
||||||
|
|
||||||
|
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
|
||||||
|
|
||||||
|
const createRevisionSpy = jest
|
||||||
|
.spyOn(revisionsService, 'createRevision')
|
||||||
|
.mockImplementation(() => Promise.resolve(Mock.of<Revision>()));
|
||||||
|
|
||||||
|
mockedRealtimeNote.emit('beforeDestroy');
|
||||||
|
expect(createRevisionSpy).toBeCalledWith(mockedNote, mockedCurrentContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs errors that occur during saving on destroy', async () => {
|
||||||
|
mockGetLatestRevision(true);
|
||||||
|
const mockedCurrentContent = 'mockedCurrentContent';
|
||||||
|
|
||||||
|
jest.spyOn(realtimeNoteStore, 'find').mockImplementation(() => undefined);
|
||||||
|
jest
|
||||||
|
.spyOn(realtimeNoteStore, 'create')
|
||||||
|
.mockImplementation(() => mockedRealtimeNote);
|
||||||
|
jest
|
||||||
|
.spyOn(websocketDoc, 'getCurrentContent')
|
||||||
|
.mockReturnValue(mockedCurrentContent);
|
||||||
|
jest
|
||||||
|
.spyOn(revisionsService, 'createRevision')
|
||||||
|
.mockImplementation(() => Promise.reject('mocked error'));
|
||||||
|
|
||||||
|
const logSpy = jest.spyOn(consoleLoggerService, 'error');
|
||||||
|
|
||||||
|
await realtimeNoteService.getOrCreateRealtimeNote(mockedNote);
|
||||||
|
mockedRealtimeNote.emit('beforeDestroy');
|
||||||
|
|
||||||
|
await waitForOtherPromisesToFinish();
|
||||||
|
expect(logSpy).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroys every realtime note on application shutdown', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(realtimeNoteStore, 'getAllRealtimeNotes')
|
||||||
|
.mockReturnValue([mockedRealtimeNote]);
|
||||||
|
|
||||||
|
const destroySpy = jest.spyOn(mockedRealtimeNote, 'destroy');
|
||||||
|
|
||||||
|
realtimeNoteService.beforeApplicationShutdown();
|
||||||
|
|
||||||
|
expect(destroySpy).toBeCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,17 +3,47 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Optional } from '@mrdrogdrog/optional';
|
||||||
|
import { BeforeApplicationShutdown, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||||
|
|
||||||
|
import appConfiguration, { AppConfig } from '../../config/app.config';
|
||||||
|
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||||
import { Note } from '../../notes/note.entity';
|
import { Note } from '../../notes/note.entity';
|
||||||
import { RevisionsService } from '../../revisions/revisions.service';
|
import { RevisionsService } from '../../revisions/revisions.service';
|
||||||
import { RealtimeNote } from './realtime-note';
|
import { RealtimeNote } from './realtime-note';
|
||||||
|
import { RealtimeNoteStore } from './realtime-note-store.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RealtimeNoteService {
|
export class RealtimeNoteService implements BeforeApplicationShutdown {
|
||||||
constructor(private revisionsService: RevisionsService) {}
|
constructor(
|
||||||
|
private revisionsService: RevisionsService,
|
||||||
|
private readonly logger: ConsoleLoggerService,
|
||||||
|
private realtimeNoteStore: RealtimeNoteStore,
|
||||||
|
private schedulerRegistry: SchedulerRegistry,
|
||||||
|
@Inject(appConfiguration.KEY)
|
||||||
|
private appConfig: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
private noteIdToRealtimeNote = new Map<string, RealtimeNote>();
|
beforeApplicationShutdown(): void {
|
||||||
|
this.realtimeNoteStore
|
||||||
|
.getAllRealtimeNotes()
|
||||||
|
.forEach((realtimeNote) => realtimeNote.destroy());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the current content from the given {@link RealtimeNote} and creates a new {@link Revision} for the linked {@link Note}.
|
||||||
|
*
|
||||||
|
* @param realtimeNote The realtime note for which a revision should be created
|
||||||
|
*/
|
||||||
|
public saveRealtimeNote(realtimeNote: RealtimeNote): void {
|
||||||
|
this.revisionsService
|
||||||
|
.createRevision(
|
||||||
|
realtimeNote.getNote(),
|
||||||
|
realtimeNote.getYDoc().getCurrentContent(),
|
||||||
|
)
|
||||||
|
.catch((reason) => this.logger.error(reason));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates or reuses a {@link RealtimeNote} that is handling the real time editing of the {@link Note} which is identified by the given note id.
|
* Creates or reuses a {@link RealtimeNote} that is handling the real time editing of the {@link Note} which is identified by the given note id.
|
||||||
|
@ -23,13 +53,13 @@ export class RealtimeNoteService {
|
||||||
*/
|
*/
|
||||||
public async getOrCreateRealtimeNote(note: Note): Promise<RealtimeNote> {
|
public async getOrCreateRealtimeNote(note: Note): Promise<RealtimeNote> {
|
||||||
return (
|
return (
|
||||||
this.noteIdToRealtimeNote.get(note.id) ??
|
this.realtimeNoteStore.find(note.id) ??
|
||||||
(await this.createNewRealtimeNote(note))
|
(await this.createNewRealtimeNote(note))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new {@link RealtimeNote} for the given {@link Note} and memorizes it.
|
* Creates a new {@link RealtimeNote} for the given {@link Note}.
|
||||||
*
|
*
|
||||||
* @param note The note for which the realtime note should be created
|
* @param note The note for which the realtime note should be created
|
||||||
* @throws NotInDBError if note doesn't exist or has no revisions.
|
* @throws NotInDBError if note doesn't exist or has no revisions.
|
||||||
|
@ -38,20 +68,37 @@ export class RealtimeNoteService {
|
||||||
private async createNewRealtimeNote(note: Note): Promise<RealtimeNote> {
|
private async createNewRealtimeNote(note: Note): Promise<RealtimeNote> {
|
||||||
const initialContent = (await this.revisionsService.getLatestRevision(note))
|
const initialContent = (await this.revisionsService.getLatestRevision(note))
|
||||||
.content;
|
.content;
|
||||||
const realtimeNote = new RealtimeNote(note.id, initialContent);
|
const realtimeNote = this.realtimeNoteStore.create(note, initialContent);
|
||||||
realtimeNote.on('destroy', () => {
|
realtimeNote.on('beforeDestroy', () => {
|
||||||
this.noteIdToRealtimeNote.delete(note.id);
|
this.saveRealtimeNote(realtimeNote);
|
||||||
});
|
});
|
||||||
this.noteIdToRealtimeNote.set(note.id, realtimeNote);
|
this.startPersistTimer(realtimeNote);
|
||||||
return realtimeNote;
|
return realtimeNote;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves a {@link RealtimeNote} that is linked to the given {@link Note} id.
|
* Starts a timer that persists the realtime note in a periodic interval depending on the {@link AppConfig}.
|
||||||
* @param noteId The id of the {@link Note}
|
|
||||||
* @return A {@link RealtimeNote} or {@code undefined} if no instance is existing.
|
* @param realtimeNote The realtime note for which the timer should be started
|
||||||
*/
|
*/
|
||||||
public getRealtimeNote(noteId: string): RealtimeNote | undefined {
|
private startPersistTimer(realtimeNote: RealtimeNote): void {
|
||||||
return this.noteIdToRealtimeNote.get(noteId);
|
Optional.of(this.appConfig.persistInterval)
|
||||||
|
.filter((value) => value > 0)
|
||||||
|
.ifPresent((persistInterval) => {
|
||||||
|
const intervalId = setInterval(
|
||||||
|
this.saveRealtimeNote.bind(this, realtimeNote),
|
||||||
|
persistInterval * 60 * 1000,
|
||||||
|
);
|
||||||
|
this.schedulerRegistry.addInterval(
|
||||||
|
`periodic-persist-${realtimeNote.getNote().id}`,
|
||||||
|
intervalId,
|
||||||
|
);
|
||||||
|
realtimeNote.on('destroy', () => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
this.schedulerRegistry.deleteInterval(
|
||||||
|
`periodic-persist-${realtimeNote.getNote().id}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,9 @@
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
|
||||||
|
import { Note } from '../../notes/note.entity';
|
||||||
import { RealtimeNote } from './realtime-note';
|
import { RealtimeNote } from './realtime-note';
|
||||||
import { mockAwareness } from './test-utils/mock-awareness';
|
import { mockAwareness } from './test-utils/mock-awareness';
|
||||||
import { mockConnection } from './test-utils/mock-connection';
|
import { mockConnection } from './test-utils/mock-connection';
|
||||||
|
@ -15,6 +18,7 @@ import { WebsocketDoc } from './websocket-doc';
|
||||||
describe('realtime note', () => {
|
describe('realtime note', () => {
|
||||||
let mockedDoc: WebsocketDoc;
|
let mockedDoc: WebsocketDoc;
|
||||||
let mockedAwareness: WebsocketAwareness;
|
let mockedAwareness: WebsocketAwareness;
|
||||||
|
let mockedNote: Note;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
|
@ -27,6 +31,8 @@ describe('realtime note', () => {
|
||||||
jest
|
jest
|
||||||
.spyOn(websocketAwarenessModule, 'WebsocketAwareness')
|
.spyOn(websocketAwarenessModule, 'WebsocketAwareness')
|
||||||
.mockImplementation(() => mockedAwareness);
|
.mockImplementation(() => mockedAwareness);
|
||||||
|
|
||||||
|
mockedNote = Mock.of<Note>({ id: 'mock-note' });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
@ -34,8 +40,13 @@ describe('realtime note', () => {
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can return the given note', () => {
|
||||||
|
const sut = new RealtimeNote(mockedNote, 'nothing');
|
||||||
|
expect(sut.getNote()).toBe(mockedNote);
|
||||||
|
});
|
||||||
|
|
||||||
it('can connect and disconnect clients', () => {
|
it('can connect and disconnect clients', () => {
|
||||||
const sut = new RealtimeNote('mock-note', 'nothing');
|
const sut = new RealtimeNote(mockedNote, 'nothing');
|
||||||
const client1 = mockConnection(true);
|
const client1 = mockConnection(true);
|
||||||
sut.addClient(client1);
|
sut.addClient(client1);
|
||||||
expect(sut.getConnections()).toStrictEqual([client1]);
|
expect(sut.getConnections()).toStrictEqual([client1]);
|
||||||
|
@ -46,13 +57,13 @@ describe('realtime note', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a y-doc and y-awareness', () => {
|
it('creates a y-doc and y-awareness', () => {
|
||||||
const sut = new RealtimeNote('mock-note', 'nothing');
|
const sut = new RealtimeNote(mockedNote, 'nothing');
|
||||||
expect(sut.getYDoc()).toBe(mockedDoc);
|
expect(sut.getYDoc()).toBe(mockedDoc);
|
||||||
expect(sut.getAwareness()).toBe(mockedAwareness);
|
expect(sut.getAwareness()).toBe(mockedAwareness);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('destroys y-doc and y-awareness on self-destruction', () => {
|
it('destroys y-doc and y-awareness on self-destruction', () => {
|
||||||
const sut = new RealtimeNote('mock-note', 'nothing');
|
const sut = new RealtimeNote(mockedNote, 'nothing');
|
||||||
const docDestroy = jest.spyOn(mockedDoc, 'destroy');
|
const docDestroy = jest.spyOn(mockedDoc, 'destroy');
|
||||||
const awarenessDestroy = jest.spyOn(mockedAwareness, 'destroy');
|
const awarenessDestroy = jest.spyOn(mockedAwareness, 'destroy');
|
||||||
sut.destroy();
|
sut.destroy();
|
||||||
|
@ -61,7 +72,7 @@ describe('realtime note', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits destroy event on destruction', async () => {
|
it('emits destroy event on destruction', async () => {
|
||||||
const sut = new RealtimeNote('mock-note', 'nothing');
|
const sut = new RealtimeNote(mockedNote, 'nothing');
|
||||||
const destroyPromise = new Promise<void>((resolve) => {
|
const destroyPromise = new Promise<void>((resolve) => {
|
||||||
sut.once('destroy', () => {
|
sut.once('destroy', () => {
|
||||||
resolve();
|
resolve();
|
||||||
|
@ -72,7 +83,7 @@ describe('realtime note', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("doesn't destroy a destroyed note", () => {
|
it("doesn't destroy a destroyed note", () => {
|
||||||
const sut = new RealtimeNote('mock-note', 'nothing');
|
const sut = new RealtimeNote(mockedNote, 'nothing');
|
||||||
sut.destroy();
|
sut.destroy();
|
||||||
expect(() => sut.destroy()).toThrow();
|
expect(() => sut.destroy()).toThrow();
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,11 +8,13 @@ import { EventEmitter } from 'events';
|
||||||
import TypedEventEmitter, { EventMap } from 'typed-emitter';
|
import TypedEventEmitter, { EventMap } from 'typed-emitter';
|
||||||
import { Awareness } from 'y-protocols/awareness';
|
import { Awareness } from 'y-protocols/awareness';
|
||||||
|
|
||||||
|
import { Note } from '../../notes/note.entity';
|
||||||
import { WebsocketAwareness } from './websocket-awareness';
|
import { WebsocketAwareness } from './websocket-awareness';
|
||||||
import { WebsocketConnection } from './websocket-connection';
|
import { WebsocketConnection } from './websocket-connection';
|
||||||
import { WebsocketDoc } from './websocket-doc';
|
import { WebsocketDoc } from './websocket-doc';
|
||||||
|
|
||||||
export type RealtimeNoteEvents = {
|
export type RealtimeNoteEvents = {
|
||||||
|
beforeDestroy: () => void;
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -29,12 +31,12 @@ export class RealtimeNote extends (EventEmitter as TypedEventEmitterConstructor<
|
||||||
private readonly clients = new Set<WebsocketConnection>();
|
private readonly clients = new Set<WebsocketConnection>();
|
||||||
private isClosing = false;
|
private isClosing = false;
|
||||||
|
|
||||||
constructor(private readonly noteId: string, initialContent: string) {
|
constructor(private readonly note: Note, initialContent: string) {
|
||||||
super();
|
super();
|
||||||
this.logger = new Logger(`${RealtimeNote.name} ${noteId}`);
|
this.logger = new Logger(`${RealtimeNote.name} ${note.id}`);
|
||||||
this.websocketDoc = new WebsocketDoc(this, initialContent);
|
this.websocketDoc = new WebsocketDoc(this, initialContent);
|
||||||
this.websocketAwareness = new WebsocketAwareness(this);
|
this.websocketAwareness = new WebsocketAwareness(this);
|
||||||
this.logger.debug(`New realtime session for note ${noteId} created.`);
|
this.logger.debug(`New realtime session for note ${note.id} created.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -76,6 +78,7 @@ export class RealtimeNote extends (EventEmitter as TypedEventEmitterConstructor<
|
||||||
throw new Error('Note already destroyed');
|
throw new Error('Note already destroyed');
|
||||||
}
|
}
|
||||||
this.logger.debug('Destroying realtime note.');
|
this.logger.debug('Destroying realtime note.');
|
||||||
|
this.emit('beforeDestroy');
|
||||||
this.isClosing = true;
|
this.isClosing = true;
|
||||||
this.websocketDoc.destroy();
|
this.websocketDoc.destroy();
|
||||||
this.websocketAwareness.destroy();
|
this.websocketAwareness.destroy();
|
||||||
|
@ -118,4 +121,13 @@ export class RealtimeNote extends (EventEmitter as TypedEventEmitterConstructor<
|
||||||
public getAwareness(): Awareness {
|
public getAwareness(): Awareness {
|
||||||
return this.websocketAwareness;
|
return this.websocketAwareness;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the {@link Note note} that is edited.
|
||||||
|
*
|
||||||
|
* @return the {@link Note note}
|
||||||
|
*/
|
||||||
|
public getNote(): Note {
|
||||||
|
return this.note;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,18 +7,26 @@ import { EventEmitter } from 'events';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import TypedEmitter from 'typed-emitter';
|
import TypedEmitter from 'typed-emitter';
|
||||||
|
|
||||||
|
import { Note } from '../../../notes/note.entity';
|
||||||
import { RealtimeNote, RealtimeNoteEvents } from '../realtime-note';
|
import { RealtimeNote, RealtimeNoteEvents } from '../realtime-note';
|
||||||
import { WebsocketAwareness } from '../websocket-awareness';
|
import { WebsocketAwareness } from '../websocket-awareness';
|
||||||
import { WebsocketDoc } from '../websocket-doc';
|
import { WebsocketDoc } from '../websocket-doc';
|
||||||
|
import { mockAwareness } from './mock-awareness';
|
||||||
|
import { mockWebsocketDoc } from './mock-websocket-doc';
|
||||||
|
|
||||||
class MockRealtimeNote extends (EventEmitter as new () => TypedEmitter<RealtimeNoteEvents>) {
|
class MockRealtimeNote extends (EventEmitter as new () => TypedEmitter<RealtimeNoteEvents>) {
|
||||||
constructor(
|
constructor(
|
||||||
|
private note: Note,
|
||||||
private doc: WebsocketDoc,
|
private doc: WebsocketDoc,
|
||||||
private awareness: WebsocketAwareness,
|
private awareness: WebsocketAwareness,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getNote(): Note {
|
||||||
|
return this.note;
|
||||||
|
}
|
||||||
|
|
||||||
public getYDoc(): WebsocketDoc {
|
public getYDoc(): WebsocketDoc {
|
||||||
return this.doc;
|
return this.doc;
|
||||||
}
|
}
|
||||||
|
@ -30,6 +38,10 @@ class MockRealtimeNote extends (EventEmitter as new () => TypedEmitter<RealtimeN
|
||||||
public removeClient(): void {
|
public removeClient(): void {
|
||||||
//left blank for mock
|
//left blank for mock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
//left blank for mock
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -38,8 +50,15 @@ class MockRealtimeNote extends (EventEmitter as new () => TypedEmitter<RealtimeN
|
||||||
* @param awareness Defines the return value for `getAwareness`
|
* @param awareness Defines the return value for `getAwareness`
|
||||||
*/
|
*/
|
||||||
export function mockRealtimeNote(
|
export function mockRealtimeNote(
|
||||||
doc: WebsocketDoc,
|
note?: Note,
|
||||||
awareness: WebsocketAwareness,
|
doc?: WebsocketDoc,
|
||||||
|
awareness?: WebsocketAwareness,
|
||||||
): RealtimeNote {
|
): RealtimeNote {
|
||||||
return Mock.from<RealtimeNote>(new MockRealtimeNote(doc, awareness));
|
return Mock.from<RealtimeNote>(
|
||||||
|
new MockRealtimeNote(
|
||||||
|
note ?? Mock.of<Note>(),
|
||||||
|
doc ?? mockWebsocketDoc(),
|
||||||
|
awareness ?? mockAwareness(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,5 +14,6 @@ export function mockWebsocketDoc(): WebsocketDoc {
|
||||||
return Mock.of<WebsocketDoc>({
|
return Mock.of<WebsocketDoc>({
|
||||||
on: jest.fn(),
|
on: jest.fn(),
|
||||||
destroy: jest.fn(),
|
destroy: jest.fn(),
|
||||||
|
getCurrentContent: jest.fn(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits until all other pending promises are processed.
|
||||||
|
*/
|
||||||
|
export async function waitForOtherPromisesToFinish(): Promise<void> {
|
||||||
|
return await new Promise((resolve) => process.nextTick(resolve));
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import { Mock } from 'ts-mockery';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
import * as yProtocolsAwarenessModule from 'y-protocols/awareness';
|
import * as yProtocolsAwarenessModule from 'y-protocols/awareness';
|
||||||
|
|
||||||
|
import { Note } from '../../notes/note.entity';
|
||||||
import { User } from '../../users/user.entity';
|
import { User } from '../../users/user.entity';
|
||||||
import * as realtimeNoteModule from './realtime-note';
|
import * as realtimeNoteModule from './realtime-note';
|
||||||
import { RealtimeNote } from './realtime-note';
|
import { RealtimeNote } from './realtime-note';
|
||||||
|
@ -38,7 +39,11 @@ describe('websocket connection', () => {
|
||||||
jest.resetModules();
|
jest.resetModules();
|
||||||
mockedDoc = mockWebsocketDoc();
|
mockedDoc = mockWebsocketDoc();
|
||||||
mockedAwareness = mockAwareness();
|
mockedAwareness = mockAwareness();
|
||||||
mockedRealtimeNote = mockRealtimeNote(mockedDoc, mockedAwareness);
|
mockedRealtimeNote = mockRealtimeNote(
|
||||||
|
Mock.of<Note>(),
|
||||||
|
mockedDoc,
|
||||||
|
mockedAwareness,
|
||||||
|
);
|
||||||
mockedWebsocket = Mock.of<WebSocket>({});
|
mockedWebsocket = Mock.of<WebSocket>({});
|
||||||
mockedUser = Mock.of<User>({});
|
mockedUser = Mock.of<User>({});
|
||||||
mockedWebsocketTransporter = mockWebsocketTransporter();
|
mockedWebsocketTransporter = mockWebsocketTransporter();
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import { AuthToken } from '../auth/auth-token.entity';
|
import { AuthToken } from '../auth/auth-token.entity';
|
||||||
|
@ -99,7 +100,7 @@ describe('RevisionsService', () => {
|
||||||
|
|
||||||
describe('getRevision', () => {
|
describe('getRevision', () => {
|
||||||
it('returns a revision', async () => {
|
it('returns a revision', async () => {
|
||||||
const note = {} as Note;
|
const note = Mock.of<Note>({});
|
||||||
const revision = Revision.create('', '', note) as Revision;
|
const revision = Revision.create('', '', note) as Revision;
|
||||||
jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(revision);
|
jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(revision);
|
||||||
expect(await service.getRevision({} as Note, 1)).toEqual(revision);
|
expect(await service.getRevision({} as Note, 1)).toEqual(revision);
|
||||||
|
@ -192,4 +193,48 @@ describe('RevisionsService', () => {
|
||||||
expect(userInfo.anonymousUserCount).toEqual(2);
|
expect(userInfo.anonymousUserCount).toEqual(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createRevision', () => {
|
||||||
|
it('creates a new revision', async () => {
|
||||||
|
const note = Mock.of<Note>({});
|
||||||
|
const oldContent = 'old content\n';
|
||||||
|
const newContent = 'new content\n';
|
||||||
|
|
||||||
|
const oldRevision = Mock.of<Revision>({ content: oldContent });
|
||||||
|
jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(oldRevision);
|
||||||
|
jest
|
||||||
|
.spyOn(revisionRepo, 'save')
|
||||||
|
.mockImplementation((revision) =>
|
||||||
|
Promise.resolve(revision as Revision),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createdRevision = await service.createRevision(note, newContent);
|
||||||
|
expect(createdRevision).not.toBeUndefined();
|
||||||
|
expect(createdRevision?.content).toBe(newContent);
|
||||||
|
await expect(createdRevision?.note).resolves.toBe(note);
|
||||||
|
expect(createdRevision?.patch).toMatchInlineSnapshot(`
|
||||||
|
"Index: markdownContent
|
||||||
|
===================================================================
|
||||||
|
--- markdownContent
|
||||||
|
+++ markdownContent
|
||||||
|
@@ -1,1 +1,1 @@
|
||||||
|
-old content
|
||||||
|
+new content
|
||||||
|
"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("won't create a revision if content is unchanged", async () => {
|
||||||
|
const note = Mock.of<Note>({});
|
||||||
|
const oldContent = 'old content\n';
|
||||||
|
|
||||||
|
const oldRevision = Mock.of<Revision>({ content: oldContent });
|
||||||
|
jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(oldRevision);
|
||||||
|
const saveSpy = jest.spyOn(revisionRepo, 'save').mockImplementation();
|
||||||
|
|
||||||
|
const createdRevision = await service.createRevision(note, oldContent);
|
||||||
|
expect(createdRevision).toBeUndefined();
|
||||||
|
expect(saveSpy).not.toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { createPatch } from 'diff';
|
||||||
import { Equal, Repository } from 'typeorm';
|
import { Equal, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { NotInDBError } from '../errors/errors';
|
import { NotInDBError } from '../errors/errors';
|
||||||
|
@ -51,10 +52,10 @@ export class RevisionsService {
|
||||||
note: Equal(note),
|
note: Equal(note),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const latestRevison = await this.getLatestRevision(note);
|
const latestRevision = await this.getLatestRevision(note);
|
||||||
// get all revisions except the latest
|
// get all revisions except the latest
|
||||||
const oldRevisions = revisions.filter(
|
const oldRevisions = revisions.filter(
|
||||||
(item) => item.id !== latestRevison.id,
|
(item) => item.id !== latestRevision.id,
|
||||||
);
|
);
|
||||||
// delete the old revisions
|
// delete the old revisions
|
||||||
return await this.revisionRepository.remove(oldRevisions);
|
return await this.revisionRepository.remove(oldRevisions);
|
||||||
|
@ -91,21 +92,6 @@ export class RevisionsService {
|
||||||
return revision;
|
return revision;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFirstRevision(note: Note): Promise<Revision> {
|
|
||||||
const revision = await this.revisionRepository.findOne({
|
|
||||||
where: {
|
|
||||||
note: Equal(note),
|
|
||||||
},
|
|
||||||
order: {
|
|
||||||
createdAt: 'ASC',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (revision === null) {
|
|
||||||
throw new NotInDBError(`Revision for note ${note.id} not found.`);
|
|
||||||
}
|
|
||||||
return revision;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRevisionUserInfo(revision: Revision): Promise<RevisionUserInfo> {
|
async getRevisionUserInfo(revision: Revision): Promise<RevisionUserInfo> {
|
||||||
// get a deduplicated list of all authors
|
// get a deduplicated list of all authors
|
||||||
let authors = await Promise.all(
|
let authors = await Promise.all(
|
||||||
|
@ -156,14 +142,22 @@ export class RevisionsService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
createRevision(content: string): Revision {
|
async createRevision(
|
||||||
// TODO: Add previous revision
|
note: Note,
|
||||||
// TODO: Calculate patch
|
newContent: string,
|
||||||
|
): Promise<Revision | undefined> {
|
||||||
// TODO: Save metadata
|
// TODO: Save metadata
|
||||||
return this.revisionRepository.create({
|
const latestRevision = await this.getLatestRevision(note);
|
||||||
content: content,
|
const oldContent = latestRevision.content;
|
||||||
length: content.length,
|
if (oldContent === newContent) {
|
||||||
patch: '',
|
return undefined;
|
||||||
});
|
}
|
||||||
|
const patch = createPatch(
|
||||||
|
'markdownContent',
|
||||||
|
latestRevision.content,
|
||||||
|
newContent,
|
||||||
|
);
|
||||||
|
const revision = Revision.create(newContent, patch, note);
|
||||||
|
return await this.revisionRepository.save(revision);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
16
yarn.lock
16
yarn.lock
|
@ -1659,6 +1659,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/diff@npm:5.0.2":
|
||||||
|
version: 5.0.2
|
||||||
|
resolution: "@types/diff@npm:5.0.2"
|
||||||
|
checksum: 8fbc419b5aca33f494026bf5f70e026f76367689677ef114f9c078ac738d7dbe96e6dda3fd8290e4a7c35281e2b60b034e3d7e3c968b850cf06a21279e7ddcbe
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/eslint-scope@npm:^3.7.3":
|
"@types/eslint-scope@npm:^3.7.3":
|
||||||
version: 3.7.4
|
version: 3.7.4
|
||||||
resolution: "@types/eslint-scope@npm:3.7.4"
|
resolution: "@types/eslint-scope@npm:3.7.4"
|
||||||
|
@ -3959,6 +3966,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"diff@npm:5.1.0":
|
||||||
|
version: 5.1.0
|
||||||
|
resolution: "diff@npm:5.1.0"
|
||||||
|
checksum: c7bf0df7c9bfbe1cf8a678fd1b2137c4fb11be117a67bc18a0e03ae75105e8533dbfb1cda6b46beb3586ef5aed22143ef9d70713977d5fb1f9114e21455fba90
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"diff@npm:^4.0.1":
|
"diff@npm:^4.0.1":
|
||||||
version: 4.0.2
|
version: 4.0.2
|
||||||
resolution: "diff@npm:4.0.2"
|
resolution: "diff@npm:4.0.2"
|
||||||
|
@ -5347,6 +5361,7 @@ __metadata:
|
||||||
"@types/cookie": 0.5.1
|
"@types/cookie": 0.5.1
|
||||||
"@types/cookie-signature": 1.0.4
|
"@types/cookie-signature": 1.0.4
|
||||||
"@types/cron": 2.0.0
|
"@types/cron": 2.0.0
|
||||||
|
"@types/diff": 5.0.2
|
||||||
"@types/express": 4.17.13
|
"@types/express": 4.17.13
|
||||||
"@types/express-session": 1.17.5
|
"@types/express-session": 1.17.5
|
||||||
"@types/jest": 28.1.6
|
"@types/jest": 28.1.6
|
||||||
|
@ -5369,6 +5384,7 @@ __metadata:
|
||||||
cli-color: 2.0.3
|
cli-color: 2.0.3
|
||||||
connect-typeorm: 2.0.0
|
connect-typeorm: 2.0.0
|
||||||
cookie: 0.5.0
|
cookie: 0.5.0
|
||||||
|
diff: 5.1.0
|
||||||
eslint: 8.21.0
|
eslint: 8.21.0
|
||||||
eslint-config-prettier: 8.5.0
|
eslint-config-prettier: 8.5.0
|
||||||
eslint-plugin-import: 2.26.0
|
eslint-plugin-import: 2.26.0
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue