refactor(server): split HistoryModel from DocModel (#10604)

This commit is contained in:
fengmk2
2025-03-05 12:10:28 +00:00
parent fed0e0add3
commit 687c26304a
9 changed files with 374 additions and 330 deletions

View File

@@ -0,0 +1,177 @@
import { randomUUID } from 'node:crypto';
import ava, { TestFn } from 'ava';
import { createTestingModule, type TestingModule } from '../../__tests__/utils';
import { Config } from '../../base';
import { DocModel } from '../doc';
import { HistoryModel } from '../history';
import { type User, UserModel } from '../user';
import { type Workspace, WorkspaceModel } from '../workspace';
interface Context {
config: Config;
module: TestingModule;
user: UserModel;
workspace: WorkspaceModel;
doc: DocModel;
history: HistoryModel;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule();
t.context.user = module.get(UserModel);
t.context.workspace = module.get(WorkspaceModel);
t.context.doc = module.get(DocModel);
t.context.history = module.get(HistoryModel);
t.context.config = module.get(Config);
t.context.module = module;
});
let user: User;
let workspace: Workspace;
test.beforeEach(async t => {
await t.context.module.initTestingDB();
user = await t.context.user.create({
email: 'test@affine.pro',
});
workspace = await t.context.workspace.create(user.id);
});
test.after(async t => {
await t.context.module.close();
});
test('should create a history record', async t => {
const snapshot = {
spaceId: workspace.id,
docId: randomUUID(),
blob: Buffer.from('blob1'),
timestamp: Date.now(),
editorId: user.id,
};
await t.context.doc.upsert(snapshot);
const created = await t.context.history.create(snapshot, 1000);
t.truthy(created);
t.deepEqual(created.timestamp, snapshot.timestamp);
t.deepEqual(created.editor, {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
});
const history = await t.context.history.get(
snapshot.spaceId,
snapshot.docId,
snapshot.timestamp
);
t.deepEqual(history, {
...created,
blob: snapshot.blob,
});
});
test('should return null when history timestamp not match', async t => {
const snapshot = {
spaceId: workspace.id,
docId: randomUUID(),
blob: Buffer.from('blob1'),
timestamp: Date.now(),
editorId: user.id,
};
await t.context.doc.upsert(snapshot);
await t.context.history.create(snapshot, 1000);
const history = await t.context.history.get(
snapshot.spaceId,
snapshot.docId,
snapshot.timestamp + 1
);
t.is(history, null);
});
test('should find history records', async t => {
const docId = randomUUID();
const snapshot1 = {
spaceId: workspace.id,
docId,
blob: Buffer.from('blob1'),
timestamp: Date.now() - 1000,
editorId: user.id,
};
const snapshot2 = {
spaceId: workspace.id,
docId,
blob: Buffer.from('blob2'),
timestamp: Date.now(),
editorId: user.id,
};
await t.context.history.create(snapshot1, 1000);
await t.context.history.create(snapshot2, 1000);
let histories = await t.context.history.findMany(workspace.id, docId);
t.is(histories.length, 2);
t.deepEqual(histories[0].timestamp, snapshot2.timestamp);
t.deepEqual(histories[0].editor, {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
});
t.deepEqual(histories[1].timestamp, snapshot1.timestamp);
t.deepEqual(histories[1].editor, {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
});
// only take 1 history, order by timestamp desc
histories = await t.context.history.findMany(workspace.id, docId, {
take: 1,
});
t.is(histories.length, 1);
t.deepEqual(histories[0].timestamp, snapshot2.timestamp);
t.deepEqual(histories[0].editor, {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
});
// get empty history
histories = await t.context.history.findMany(workspace.id, docId, {
before: Date.now() - 1000000,
});
t.is(histories.length, 0);
});
test('should get latest history', async t => {
const docId = randomUUID();
const snapshot1 = {
spaceId: workspace.id,
docId,
blob: Buffer.from('blob1'),
timestamp: Date.now() - 1000,
editorId: user.id,
};
const snapshot2 = {
spaceId: workspace.id,
docId,
blob: Buffer.from('blob2'),
timestamp: Date.now(),
editorId: user.id,
};
await t.context.history.create(snapshot1, 1000);
await t.context.history.create(snapshot2, 1000);
const history = await t.context.history.getLatest(workspace.id, docId);
t.truthy(history);
t.deepEqual(history!.timestamp, snapshot2.timestamp);
t.deepEqual(history!.editor, {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
});
// return null when no history
const emptyHistory = await t.context.history.getLatest(
workspace.id,
randomUUID()
);
t.is(emptyHistory, null);
});

View File

@@ -1,3 +1,4 @@
export * from './doc';
export * from './feature';
export * from './role';
export * from './user';

View File

@@ -0,0 +1,7 @@
import { Prisma } from '@prisma/client';
export const publicUserSelect = {
id: true,
name: true,
avatarUrl: true,
} satisfies Prisma.UserSelect;

View File

@@ -4,34 +4,10 @@ import type { Update } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { BaseModel } from './base';
import type { Doc, DocEditor } from './common';
import { Doc, publicUserSelect } from './common';
export interface DocRecord extends Doc {}
export interface DocHistorySimple {
timestamp: number;
editor: DocEditor | null;
}
export interface DocHistory {
blob: Buffer;
timestamp: number;
editor: DocEditor | null;
}
export interface DocHistoryFilter {
/**
* timestamp to filter histories before.
*/
before?: number;
/**
* limit the number of histories to return.
*
* Default to `100`.
*/
take?: number;
}
export type DocMetaUpsertInput = Omit<
Prisma.WorkspaceDocUncheckedCreateInput,
'workspaceId' | 'docId'
@@ -71,16 +47,6 @@ export class DocModel extends BaseModel {
};
}
private get userSelectFields() {
return {
select: {
id: true,
name: true,
avatarUrl: true,
},
};
}
async createUpdates(updates: DocRecord[]) {
return await this.db.update.createMany({
data: updates.map(r => this.docRecordToUpdate(r)),
@@ -148,148 +114,6 @@ export class DocModel extends BaseModel {
// #endregion
// #region History
/**
* Create a doc history with a max age.
*/
async createHistory(
snapshot: Doc,
maxAge: number
): Promise<DocHistorySimple> {
const row = await this.db.snapshotHistory.create({
select: {
timestamp: true,
createdByUser: this.userSelectFields,
},
data: {
workspaceId: snapshot.spaceId,
id: snapshot.docId,
timestamp: new Date(snapshot.timestamp),
blob: snapshot.blob,
createdBy: snapshot.editorId,
expiredAt: new Date(Date.now() + maxAge),
},
});
return {
timestamp: row.timestamp.getTime(),
editor: row.createdByUser,
};
}
/**
* Find doc history by workspaceId and docId.
*
* Only including timestamp, createdByUser
*/
async findHistories(
workspaceId: string,
docId: string,
filter?: DocHistoryFilter
): Promise<DocHistorySimple[]> {
const rows = await this.db.snapshotHistory.findMany({
select: {
timestamp: true,
createdByUser: this.userSelectFields,
},
where: {
workspaceId,
id: docId,
timestamp: {
lt: filter?.before ? new Date(filter.before) : new Date(),
},
},
orderBy: {
timestamp: 'desc',
},
take: filter?.take ?? 100,
});
return rows.map(r => ({
timestamp: r.timestamp.getTime(),
editor: r.createdByUser,
}));
}
/**
* Get the history of a doc at a specific timestamp.
*
* Including blob and createdByUser
*/
async getHistory(
workspaceId: string,
docId: string,
timestamp: number
): Promise<DocHistory | null> {
const row = await this.db.snapshotHistory.findUnique({
where: {
workspaceId_id_timestamp: {
workspaceId,
id: docId,
timestamp: new Date(timestamp),
},
},
include: {
createdByUser: this.userSelectFields,
},
});
if (!row) {
return null;
}
return {
blob: row.blob,
timestamp: row.timestamp.getTime(),
editor: row.createdByUser,
};
}
/**
* Get the latest history of a doc.
*
* Only including timestamp, createdByUser
*/
async getLatestHistory(
workspaceId: string,
docId: string
): Promise<DocHistorySimple | null> {
const row = await this.db.snapshotHistory.findFirst({
where: {
workspaceId,
id: docId,
},
select: {
timestamp: true,
createdByUser: this.userSelectFields,
},
orderBy: {
timestamp: 'desc',
},
});
if (!row) {
return null;
}
return {
timestamp: row.timestamp.getTime(),
editor: row.createdByUser,
};
}
/**
* Delete expired histories.
*/
async deleteExpiredHistories() {
const { count } = await this.db.snapshotHistory.deleteMany({
where: {
expiredAt: {
lte: new Date(),
},
},
});
this.logger.log(`Deleted ${count} expired histories`);
return count;
}
// #endregion
// #region Doc
/**
@@ -356,8 +180,8 @@ export class DocModel extends BaseModel {
select: {
createdAt: true,
updatedAt: true,
createdByUser: this.userSelectFields,
updatedByUser: this.userSelectFields,
createdByUser: { select: publicUserSelect },
updatedByUser: { select: publicUserSelect },
},
});
}

View File

@@ -0,0 +1,169 @@
import { Injectable } from '@nestjs/common';
import { BaseModel } from './base';
import { Doc, DocEditor, publicUserSelect } from './common';
export interface DocHistorySimple {
timestamp: number;
editor: DocEditor | null;
}
export interface DocHistory {
blob: Buffer;
timestamp: number;
editor: DocEditor | null;
}
export interface DocHistoryFilter {
/**
* timestamp to filter histories before.
*/
before?: number;
/**
* limit the number of histories to return.
*
* Default to `100`.
*/
take?: number;
}
@Injectable()
export class HistoryModel extends BaseModel {
/**
* Create a doc history with a max age.
*/
async create(snapshot: Doc, maxAge: number): Promise<DocHistorySimple> {
const row = await this.db.snapshotHistory.create({
select: {
timestamp: true,
createdByUser: { select: publicUserSelect },
},
data: {
workspaceId: snapshot.spaceId,
id: snapshot.docId,
timestamp: new Date(snapshot.timestamp),
blob: snapshot.blob,
createdBy: snapshot.editorId,
expiredAt: new Date(Date.now() + maxAge),
},
});
this.logger.log(
`Created history ${row.timestamp} for ${snapshot.docId} in ${snapshot.spaceId}`
);
return {
timestamp: row.timestamp.getTime(),
editor: row.createdByUser,
};
}
/**
* Find doc history by workspaceId and docId.
*
* Only including timestamp, createdByUser
*/
async findMany(
workspaceId: string,
docId: string,
filter?: DocHistoryFilter
): Promise<DocHistorySimple[]> {
const rows = await this.db.snapshotHistory.findMany({
select: {
timestamp: true,
createdByUser: { select: publicUserSelect },
},
where: {
workspaceId,
id: docId,
timestamp: {
lt: filter?.before ? new Date(filter.before) : new Date(),
},
},
orderBy: {
timestamp: 'desc',
},
take: filter?.take ?? 100,
});
return rows.map(r => ({
timestamp: r.timestamp.getTime(),
editor: r.createdByUser,
}));
}
/**
* Get the history of a doc at a specific timestamp.
*
* Including blob and createdByUser
*/
async get(
workspaceId: string,
docId: string,
timestamp: number
): Promise<DocHistory | null> {
const row = await this.db.snapshotHistory.findUnique({
where: {
workspaceId_id_timestamp: {
workspaceId,
id: docId,
timestamp: new Date(timestamp),
},
},
include: {
createdByUser: { select: publicUserSelect },
},
});
if (!row) {
return null;
}
return {
blob: row.blob,
timestamp: row.timestamp.getTime(),
editor: row.createdByUser,
};
}
/**
* Get the latest history of a doc.
*
* Only including timestamp, createdByUser
*/
async getLatest(
workspaceId: string,
docId: string
): Promise<DocHistorySimple | null> {
const row = await this.db.snapshotHistory.findFirst({
where: {
workspaceId,
id: docId,
},
select: {
timestamp: true,
createdByUser: { select: publicUserSelect },
},
orderBy: {
timestamp: 'desc',
},
});
if (!row) {
return null;
}
return {
timestamp: row.timestamp.getTime(),
editor: row.createdByUser,
};
}
/**
* Clean expired histories.
*/
async cleanExpired() {
const { count } = await this.db.snapshotHistory.deleteMany({
where: {
expiredAt: {
lte: new Date(),
},
},
});
this.logger.log(`Deleted ${count} expired histories`);
return count;
}
}

View File

@@ -10,6 +10,7 @@ import { ApplyType } from '../base';
import { DocModel } from './doc';
import { DocUserModel } from './doc-user';
import { FeatureModel } from './feature';
import { HistoryModel } from './history';
import { MODELS_SYMBOL } from './provider';
import { SessionModel } from './session';
import { UserModel } from './user';
@@ -32,6 +33,7 @@ const MODELS = {
userDoc: UserDocModel,
workspaceUser: WorkspaceUserModel,
docUser: DocUserModel,
history: HistoryModel,
};
type ModelsType = {
@@ -87,6 +89,7 @@ export * from './common';
export * from './doc';
export * from './doc-user';
export * from './feature';
export * from './history';
export * from './session';
export * from './user';
export * from './user-doc';