diff --git a/packages/backend/server/src/__tests__/models/user-doc.spec.ts b/packages/backend/server/src/__tests__/models/user-doc.spec.ts new file mode 100644 index 0000000000..9e213c0ea8 --- /dev/null +++ b/packages/backend/server/src/__tests__/models/user-doc.spec.ts @@ -0,0 +1,96 @@ +import { randomUUID } from 'node:crypto'; + +import ava, { TestFn } from 'ava'; + +import { Config } from '../../base/config'; +import { type User, UserModel } from '../../models/user'; +import { UserDocModel } from '../../models/user-doc'; +import { createTestingModule, type TestingModule } from '../utils'; + +interface Context { + config: Config; + module: TestingModule; + user: UserModel; + doc: UserDocModel; +} + +const test = ava as TestFn; + +test.before(async t => { + const module = await createTestingModule(); + + t.context.user = module.get(UserModel); + t.context.doc = module.get(UserDocModel); + t.context.config = module.get(Config); + t.context.module = module; +}); + +let user: User; + +test.beforeEach(async t => { + await t.context.module.initTestingDB(); + user = await t.context.user.create({ + email: 'test@affine.pro', + }); +}); + +test.after(async t => { + await t.context.module.close(); +}); + +test('should upsert a doc', async t => { + const docId = randomUUID(); + const doc = await t.context.doc.upsert({ + spaceId: user.id, + docId, + blob: Buffer.from('hello'), + timestamp: Date.now(), + editorId: user.id, + }); + t.truthy(doc.updatedAt); + // add a new one + const docId2 = randomUUID(); + const doc2 = await t.context.doc.upsert({ + spaceId: user.id, + docId: docId2, + blob: Buffer.from('world'), + timestamp: Date.now(), + editorId: user.id, + }); + t.truthy(doc2.updatedAt); + // update the first one + const doc3 = await t.context.doc.upsert({ + spaceId: user.id, + docId, + blob: Buffer.from('world'), + timestamp: Date.now() + 1000, + editorId: user.id, + }); + t.truthy(doc3.updatedAt); + t.true(doc3.updatedAt > doc.updatedAt); + // get all docs timestamps + const timestamps = await t.context.doc.findTimestampsByUserId(user.id); + t.deepEqual(timestamps, { + [docId]: doc3.updatedAt.getTime(), + [docId2]: doc2.updatedAt.getTime(), + }); +}); + +test('should get a doc', async t => { + const docId = randomUUID(); + const doc = await t.context.doc.upsert({ + spaceId: user.id, + docId, + blob: Buffer.from('hello'), + timestamp: Date.now(), + editorId: user.id, + }); + t.truthy(doc.updatedAt); + const doc2 = await t.context.doc.get(user.id, docId); + t.truthy(doc2); + t.is(doc2!.docId, docId); + t.deepEqual(doc2!.blob, Buffer.from('hello')); + // get a non-exist doc + const doc3 = await t.context.doc.get(user.id, randomUUID()); + t.is(doc3, null); +}); diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 44067c03e5..745d652bbe 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -13,6 +13,7 @@ import { PageModel } from './page'; import { MODELS_SYMBOL } from './provider'; import { SessionModel } from './session'; import { UserModel } from './user'; +import { UserDocModel } from './user-doc'; import { UserFeatureModel } from './user-feature'; import { VerificationTokenModel } from './verification-token'; import { WorkspaceModel } from './workspace'; @@ -28,6 +29,7 @@ const MODELS = { userFeature: UserFeatureModel, workspaceFeature: WorkspaceFeatureModel, doc: DocModel, + userDoc: UserDocModel, }; type ModelsType = { @@ -85,6 +87,7 @@ export * from './feature'; export * from './page'; export * from './session'; export * from './user'; +export * from './user-doc'; export * from './user-feature'; export * from './verification-token'; export * from './workspace'; diff --git a/packages/backend/server/src/models/user-doc.ts b/packages/backend/server/src/models/user-doc.ts new file mode 100644 index 0000000000..22e5e0adfb --- /dev/null +++ b/packages/backend/server/src/models/user-doc.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; + +import { BaseModel } from './base'; +import { Doc } from './common'; + +/** + * User Doc Model + */ +@Injectable() +export class UserDocModel extends BaseModel { + async upsert(doc: Doc) { + const row = await this.db.userSnapshot.upsert({ + where: { + userId_id: { + userId: doc.spaceId, + id: doc.docId, + }, + }, + update: { + blob: doc.blob, + updatedAt: new Date(doc.timestamp), + }, + create: { + userId: doc.spaceId, + id: doc.docId, + blob: doc.blob, + createdAt: new Date(doc.timestamp), + updatedAt: new Date(doc.timestamp), + }, + select: { + updatedAt: true, + }, + }); + return row; + } + + async get(userId: string, docId: string): Promise { + const row = await this.db.userSnapshot.findUnique({ + where: { + userId_id: { + userId, + id: docId, + }, + }, + }); + + if (!row) { + return null; + } + + return { + spaceId: row.userId, + docId: row.id, + blob: row.blob, + timestamp: row.updatedAt.getTime(), + editorId: row.userId, + }; + } + + /** + * Find the timestamps of user docs by userId. + * + * @param after Only return timestamps after this timestamp. + */ + async findTimestampsByUserId(userId: string, after?: number) { + const snapshots = await this.db.userSnapshot.findMany({ + select: { + id: true, + updatedAt: true, + }, + where: { + userId, + ...(after + ? { + updatedAt: { + gt: new Date(after), + }, + } + : {}), + }, + }); + + const result: Record = {}; + + snapshots.forEach(s => { + result[s.id] = s.updatedAt.getTime(); + }); + return result; + } + + /** + * Delete a user doc by userId and docId. + */ + async delete(userId: string, docId: string) { + await this.db.userSnapshot.deleteMany({ + where: { + userId, + id: docId, + }, + }); + this.logger.log(`Deleted user ${userId} doc ${docId}`); + } + + /** + * Delete all user docs by userId. + */ + async deleteAllByUserId(userId: string) { + const { count } = await this.db.userSnapshot.deleteMany({ + where: { + userId, + }, + }); + this.logger.log(`Deleted user ${userId} ${count} docs`); + return count; + } +}