diff --git a/packages/backend/server/src/models/__tests__/blob.spec.ts b/packages/backend/server/src/models/__tests__/blob.spec.ts new file mode 100644 index 000000000..98156d966 --- /dev/null +++ b/packages/backend/server/src/models/__tests__/blob.spec.ts @@ -0,0 +1,173 @@ +import test from 'ava'; + +import { createModule } from '../../__tests__/create-module'; +import { Mockers } from '../../__tests__/mocks'; +import { Models } from '..'; + +const module = await createModule(); +const models = module.get(Models); + +test.after.always(async () => { + await module.close(); +}); + +test('should upsert blob', async t => { + const workspace = await module.create(Mockers.Workspace); + + // add + const blob = await models.blob.upsert({ + workspaceId: workspace.id, + key: 'test-key', + mime: 'text/plain', + size: 100, + }); + + t.is(blob.workspaceId, workspace.id); + t.is(blob.key, 'test-key'); + t.is(blob.mime, 'text/plain'); + t.is(blob.size, 100); + t.is(blob.deletedAt, null); + t.truthy(blob.createdAt); + + // update + const blob2 = await models.blob.upsert({ + workspaceId: workspace.id, + key: 'test-key', + mime: 'text/html', + size: 200, + }); + + t.is(blob2.workspaceId, workspace.id); + t.is(blob2.key, 'test-key'); + t.is(blob2.mime, 'text/html'); + t.is(blob2.size, 200); + t.is(blob2.deletedAt, null); + + // make sure only one blob is created + const blobs = await models.blob.list(workspace.id); + t.is(blobs.length, 1); + t.deepEqual(blobs[0], blob2); +}); + +test('should delete blob', async t => { + const workspace = await module.create(Mockers.Workspace); + const blob = await models.blob.upsert({ + workspaceId: workspace.id, + key: 'test-key', + mime: 'text/plain', + size: 100, + }); + + await models.blob.delete(workspace.id, blob.key); + + const blob2 = await models.blob.get(workspace.id, blob.key); + + t.truthy(blob2); + t.truthy(blob2?.deletedAt); +}); + +test('should delete blob permanently', async t => { + const workspace = await module.create(Mockers.Workspace); + const blob = await models.blob.upsert({ + workspaceId: workspace.id, + key: 'test-key', + mime: 'text/plain', + size: 100, + }); + + await models.blob.delete(workspace.id, blob.key, true); + + const blob2 = await models.blob.get(workspace.id, blob.key); + t.is(blob2, null); +}); + +test('should list blobs', async t => { + const workspace = await module.create(Mockers.Workspace); + const blob1 = await models.blob.upsert({ + workspaceId: workspace.id, + key: 'test-key', + mime: 'text/plain', + size: 100, + }); + + const blob2 = await models.blob.upsert({ + workspaceId: workspace.id, + key: 'test-key2', + mime: 'text/plain', + size: 200, + }); + + const blobs = await models.blob.list(workspace.id); + + t.is(blobs.length, 2); + blobs.sort((a, b) => a.key.localeCompare(b.key)); + t.is(blobs[0].key, blob1.key); + t.is(blobs[1].key, blob2.key); +}); + +test('should list deleted blobs', async t => { + const workspace = await module.create(Mockers.Workspace); + const blob = await models.blob.upsert({ + workspaceId: workspace.id, + key: 'test-key', + mime: 'text/plain', + size: 100, + }); + + await models.blob.delete(workspace.id, blob.key); + + const blobs = await models.blob.listDeleted(workspace.id); + + t.is(blobs.length, 1); + t.is(blobs[0].key, blob.key); + t.truthy(blobs[0].deletedAt); + + // delete permanently + await models.blob.delete(workspace.id, blob.key, true); + + const blobs2 = await models.blob.listDeleted(workspace.id); + + t.is(blobs2.length, 0); +}); + +test('should get blob', async t => { + const workspace = await module.create(Mockers.Workspace); + const blob = await models.blob.upsert({ + workspaceId: workspace.id, + key: 'test-key', + mime: 'text/plain', + size: 100, + }); + + const blob2 = await models.blob.get(workspace.id, blob.key); + + t.truthy(blob2); + t.is(blob2?.key, blob.key); +}); + +test('should total blob size in workspace', async t => { + const workspace = await module.create(Mockers.Workspace); + + // default size is 0 + const size1 = await models.blob.totalSize(workspace.id); + + t.is(size1, 0); + + await models.blob.upsert({ + workspaceId: workspace.id, + key: 'test-key', + mime: 'text/plain', + size: 100, + }); + + await models.blob.upsert({ + workspaceId: workspace.id, + key: 'test-key2', + mime: 'text/plain', + size: 200, + }); + + const size2 = await models.blob.totalSize(workspace.id); + + t.is(size2, 300); +}); diff --git a/packages/backend/server/src/models/blob.ts b/packages/backend/server/src/models/blob.ts new file mode 100644 index 000000000..65f88d124 --- /dev/null +++ b/packages/backend/server/src/models/blob.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { BaseModel } from './base'; + +export type CreateBlobInput = Prisma.BlobUncheckedCreateInput; + +/** + * Blob Model + */ +@Injectable() +export class BlobModel extends BaseModel { + async upsert(blob: CreateBlobInput) { + return await this.db.blob.upsert({ + where: { + workspaceId_key: { + workspaceId: blob.workspaceId, + key: blob.key, + }, + }, + update: { + mime: blob.mime, + size: blob.size, + }, + create: { + workspaceId: blob.workspaceId, + key: blob.key, + mime: blob.mime, + size: blob.size, + }, + }); + } + + async delete(workspaceId: string, key: string, permanently = false) { + if (permanently) { + await this.db.blob.deleteMany({ + where: { + workspaceId, + key, + }, + }); + this.logger.log(`deleted blob ${workspaceId}/${key} permanently`); + return; + } + + await this.db.blob.update({ + where: { + workspaceId_key: { + workspaceId, + key, + }, + }, + data: { + deletedAt: new Date(), + }, + }); + } + + async get(workspaceId: string, key: string) { + return await this.db.blob.findUnique({ + where: { + workspaceId_key: { + workspaceId, + key, + }, + }, + }); + } + + async list(workspaceId: string) { + return await this.db.blob.findMany({ + where: { + workspaceId, + deletedAt: null, + }, + }); + } + + async listDeleted(workspaceId: string) { + return await this.db.blob.findMany({ + where: { + workspaceId, + deletedAt: { not: null }, + }, + }); + } + + async totalSize(workspaceId: string) { + const sum = await this.db.blob.aggregate({ + where: { + workspaceId, + deletedAt: null, + }, + _sum: { + size: true, + }, + }); + + return sum._sum.size ?? 0; + } +} diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 70fc15ba5..77bc6774a 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -7,6 +7,7 @@ import { import { ModuleRef } from '@nestjs/core'; import { ApplyType } from '../base'; +import { BlobModel } from './blob'; import { CommentModel } from './comment'; import { CommentAttachmentModel } from './comment-attachment'; import { AppConfigModel } from './config'; @@ -52,6 +53,7 @@ const MODELS = { appConfig: AppConfigModel, comment: CommentModel, commentAttachment: CommentAttachmentModel, + blob: BlobModel, }; type ModelsType = { @@ -103,6 +105,7 @@ const ModelsSymbolProvider: ExistingProvider = { }) export class ModelsModule {} +export * from './blob'; export * from './comment'; export * from './comment-attachment'; export * from './common';