feat(server): doc model (#9834)

close CLOUD-104
This commit is contained in:
fengmk2
2025-02-06 02:50:27 +00:00
parent 077a1b38ac
commit b40f007ccf
5 changed files with 1087 additions and 0 deletions
@@ -0,0 +1,14 @@
import type { User } from '@prisma/client';
export interface Doc {
/**
* Can be workspace or user id.
*/
spaceId: string;
docId: string;
blob: Buffer;
timestamp: number;
editorId?: string;
}
export type DocEditor = Pick<User, 'id' | 'name' | 'avatarUrl'>;
@@ -1,2 +1,3 @@
export * from './doc';
export * from './feature';
export * from './page';
+480
View File
@@ -0,0 +1,480 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import type { Update } from '@prisma/client';
import { BaseModel } from './base';
import type { Doc, DocEditor } from './common';
export interface DocRecord extends Doc {
// TODO: deprecated field, remove in the future
seq: number | null;
}
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;
}
/**
* Workspace Doc Model
*
* This model is responsible for managing the workspace docs, including:
* - Updates: the changes made to the doc.
* - History: the doc history of the doc.
* - Doc: the doc itself.
*/
@Injectable()
export class DocModel extends BaseModel {
// #region Update
private updateToDocRecord(row: Update): DocRecord {
return {
spaceId: row.workspaceId,
docId: row.id,
blob: row.blob,
timestamp: row.createdAt.getTime(),
editorId: row.createdBy || undefined,
seq: row.seq,
};
}
private docRecordToUpdate(record: DocRecord): Update {
return {
workspaceId: record.spaceId,
id: record.docId,
blob: record.blob,
createdAt: new Date(record.timestamp),
createdBy: record.editorId || null,
seq: record.seq,
};
}
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)),
});
}
/**
* Find updates by workspaceId and docId.
*/
async findUpdates(workspaceId: string, docId: string): Promise<DocRecord[]> {
const rows = await this.db.update.findMany({
where: {
workspaceId,
id: docId,
},
orderBy: {
createdAt: 'asc',
},
});
return rows.map(r => this.updateToDocRecord(r));
}
/**
* Get the pending updates count by workspaceId and docId.
*/
async getUpdateCount(workspaceId: string, docId: string) {
return await this.db.update.count({
where: {
workspaceId,
id: docId,
},
});
}
/**
* Get the global pending updates count.
*/
async getGlobalUpdateCount() {
return await this.db.update.count();
}
/**
* Delete updates by workspaceId, docId, and createdAts.
*/
async deleteUpdates(
workspaceId: string,
docId: string,
timestamps: number[]
) {
const { count } = await this.db.update.deleteMany({
where: {
workspaceId,
id: docId,
createdAt: {
in: timestamps.map(t => new Date(t)),
},
},
});
this.logger.log(
`Deleted ${count} updates for workspace ${workspaceId} doc ${docId}`
);
return count;
}
// #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,
};
}
// #endregion
// #region Doc
/**
* insert or update a doc.
*/
async upsert(doc: Doc) {
const { spaceId, docId, blob, timestamp, editorId } = doc;
const updatedAt = new Date(timestamp);
// CONCERNS:
// i. Because we save the real user's last seen action time as `updatedAt`,
// it's possible to simply compare the `updatedAt` to determine if the snapshot is older than the one we are going to save.
//
// ii. Prisma doesn't support `upsert` with additional `where` condition along side unique constraint.
// In our case, we need to manually check the `updatedAt` to avoid overriding the newer snapshot.
// where: { workspaceId_id: {}, updatedAt: { lt: updatedAt } }
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
const result: { updatedAt: Date }[] = await this.db.$queryRaw`
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "created_at", "updated_at", "created_by", "updated_by")
VALUES (${spaceId}, ${docId}, ${blob}, DEFAULT, ${updatedAt}, ${editorId}, ${editorId})
ON CONFLICT ("workspace_id", "guid")
DO UPDATE SET "blob" = ${blob}, "updated_at" = ${updatedAt}, "updated_by" = ${editorId}
WHERE "snapshots"."workspace_id" = ${spaceId} AND "snapshots"."guid" = ${docId} AND "snapshots"."updated_at" <= ${updatedAt}
RETURNING "snapshots"."workspace_id" as "workspaceId", "snapshots"."guid" as "id", "snapshots"."updated_at" as "updatedAt"
`;
// if the condition `snapshot.updatedAt > updatedAt` is true, by which means the snapshot has already been updated by other process,
// the updates has been applied to current `doc` must have been seen by the other process as well.
// The `updatedSnapshot` will be `undefined` in this case.
return result.at(0);
}
/**
* Get a doc by workspaceId and docId.
*/
async get(workspaceId: string, docId: string): Promise<Doc | null> {
const row = await this.db.snapshot.findUnique({
where: {
workspaceId_id: {
workspaceId,
id: docId,
},
},
});
if (!row) {
return null;
}
return {
spaceId: row.workspaceId,
docId: row.id,
blob: row.blob,
timestamp: row.updatedAt.getTime(),
editorId: row.updatedBy || undefined,
};
}
async getMeta(workspaceId: string, docId: string) {
return await this.db.snapshot.findUnique({
where: {
workspaceId_id: {
workspaceId,
id: docId,
},
},
select: {
createdAt: true,
updatedAt: true,
createdByUser: this.userSelectFields,
updatedByUser: this.userSelectFields,
},
});
}
/**
* @deprecated updates do not rely on seq number anymore
*/
async increaseSeq(workspaceId: string, docId: string, increment: number) {
const MAX_SEQ_NUM = 0x3fffffff; // u31
const { seq } = await this.db.snapshot.update({
select: {
seq: true,
},
where: {
workspaceId_id: {
workspaceId,
id: docId,
},
},
data: {
seq: {
increment,
},
},
});
if (!seq) {
return increment;
}
// reset
if (seq >= MAX_SEQ_NUM) {
await this.db.snapshot.update({
select: {
seq: true,
},
where: {
workspaceId_id: {
workspaceId,
id: docId,
},
},
data: {
seq: 0,
},
});
}
return seq;
}
/**
* Delete a doc and it's updates and snapshots.
*/
@Transactional()
async delete(workspaceId: string, docId: string) {
const ident = { where: { workspaceId, id: docId } };
const { count: snapshots } = await this.db.snapshot.deleteMany(ident);
const { count: updates } = await this.db.update.deleteMany(ident);
const { count: histories } =
await this.db.snapshotHistory.deleteMany(ident);
this.logger.log(
`Deleted workspace ${workspaceId} doc ${docId}, including ${snapshots} snapshots, ${updates} updates, and ${histories} histories`
);
}
/**
* Delete the whole workspace's docs and their updates and snapshots.
*/
@Transactional()
async deleteAllByWorkspaceId(workspaceId: string) {
const ident = { where: { workspaceId } };
const { count: snapshots } = await this.db.snapshot.deleteMany(ident);
const { count: updates } = await this.db.update.deleteMany(ident);
const { count: histories } =
await this.db.snapshotHistory.deleteMany(ident);
this.logger.log(
`Deleted workspace ${workspaceId} all docs, including ${snapshots} snapshots, ${updates} updates, and ${histories} histories`
);
return snapshots;
}
/**
* Find the timestamps of docs by workspaceId.
*
* @param after Only return timestamps after this timestamp.
*/
async findTimestampsByWorkspaceId(workspaceId: string, after?: number) {
const snapshots = await this.db.snapshot.findMany({
select: {
id: true,
updatedAt: true,
},
where: {
workspaceId,
...(after
? {
updatedAt: {
gt: new Date(after),
},
}
: {}),
},
});
const updates = await this.db.update.groupBy({
where: {
workspaceId,
...(after
? {
// [createdAt] in updates table is indexed, so it's fast
createdAt: {
gt: new Date(after),
},
}
: {}),
},
by: ['id'],
_max: {
createdAt: true,
},
});
const result: Record<string, number> = {};
snapshots.forEach(s => {
result[s.id] = s.updatedAt.getTime();
});
updates.forEach(u => {
if (u._max.createdAt) {
result[u.id] = u._max.createdAt.getTime();
}
});
return result;
}
// #endregion
}
@@ -7,6 +7,7 @@ import {
import { ModuleRef } from '@nestjs/core';
import { ApplyType } from '../base';
import { DocModel } from './doc';
import { FeatureModel } from './feature';
import { PageModel } from './page';
import { MODELS_SYMBOL } from './provider';
@@ -26,6 +27,7 @@ const MODELS = {
page: PageModel,
userFeature: UserFeatureModel,
workspaceFeature: WorkspaceFeatureModel,
doc: DocModel,
};
type ModelsType = {
@@ -78,6 +80,7 @@ const ModelsSymbolProvider: ExistingProvider = {
export class ModelsModule {}
export * from './common';
export * from './doc';
export * from './feature';
export * from './page';
export * from './session';