import { Injectable } from '@nestjs/common'; import { Transactional } from '@nestjs-cls/transactional'; import type { Update } from '@prisma/client'; import { Prisma } from '@prisma/client'; import { EventBus, PaginationInput } from '../base'; import { DocIsNotPublic } from '../base/error'; import { BaseModel } from './base'; import { Doc, DocRole, PublicDocMode, publicUserSelect } from './common'; declare global { interface Events { 'doc.created': { workspaceId: string; docId: string; editor?: string; }; 'doc.updated': { workspaceId: string; docId: string; }; } } export type DocMetaUpsertInput = Omit< Prisma.WorkspaceDocUncheckedCreateInput, 'workspaceId' | 'docId' >; /** * 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. * - DocMeta: the doc meta. */ @Injectable() export class DocModel extends BaseModel { constructor(private readonly event: EventBus) { super(); } // #region Update private updateToDocRecord(row: Update): Doc { return { spaceId: row.workspaceId, docId: row.id, blob: row.blob, timestamp: row.createdAt.getTime(), editorId: row.createdBy || undefined, }; } private docRecordToUpdate(record: Doc): Update { return { workspaceId: record.spaceId, id: record.docId, blob: record.blob, createdAt: new Date(record.timestamp), createdBy: record.editorId || null, seq: null, }; } async createUpdates(updates: Doc[]) { 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 { const rows = await this.db.update.findMany({ where: { workspaceId, id: docId, }, orderBy: { createdAt: 'asc', }, take: 100, }); 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(); } async groupedUpdatesCount() { return await this.db.update.groupBy({ by: ['workspaceId', 'id'], _count: true, }); } /** * 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)), }, }, }); if (count > 0) { this.logger.log( `Deleted ${count} updates for workspace ${workspaceId} doc ${docId}` ); } return count; } // #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 { const row = await this.getSnapshot(workspaceId, docId); if (!row) { return null; } return { spaceId: row.workspaceId, docId: row.id, blob: row.blob, timestamp: row.updatedAt.getTime(), editorId: row.updatedBy || undefined, }; } async getSnapshot( workspaceId: string, docId: string, options?: { select?: Select; } ) { return (await this.db.workspaceDoc.findUnique({ where: { workspaceId_docId: { workspaceId, docId, }, }, select: options?.select, })) as Prisma.WorkspaceDocGetPayload<{ select: Select }> | null; } async setDefaultRole(workspaceId: string, docId: string, role: DocRole) { return await this.upsertMeta(workspaceId, docId, { defaultRole: role, }); } async findDefaultRoles(workspaceId: string, docIds: string[]) { const docs = await this.findMetas( docIds.map(docId => ({ workspaceId, docId, })), { select: { defaultRole: true, public: true, }, } ); return docs.map(doc => ({ external: doc?.public ? DocRole.External : null, workspace: doc?.defaultRole ?? DocRole.Manager, })); } async findAuthors(ids: { workspaceId: string; docId: string }[]) { const rows = await this.db.snapshot.findMany({ where: { workspaceId: { in: ids.map(id => id.workspaceId) }, id: { in: ids.map(id => id.docId) }, }, select: { workspaceId: true, id: true, createdAt: true, updatedAt: true, createdByUser: { select: publicUserSelect }, updatedByUser: { select: publicUserSelect }, }, }); const resultMap = new Map( rows.map(row => [`${row.workspaceId}-${row.id}`, row]) ); return ids.map( id => resultMap.get(`${id.workspaceId}-${id.docId}`) ?? null ); } async findMetas