feat(server): add Blob Model (#12894)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced comprehensive management for workspace blobs, including
creating, updating, soft and permanent deletion, and retrieval of blobs.
- Added the ability to list all blobs, list deleted blobs, and calculate
the total size of blobs within a workspace.

- **Tests**
- Added extensive automated tests to validate blob creation, updating,
deletion, retrieval, and aggregation functionalities.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #12894** 👈
  * **PR #12897**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
fengmk2
2025-07-03 13:17:59 +08:00
committed by GitHub
parent 2ea3c3da9d
commit 062537c2cf
3 changed files with 277 additions and 0 deletions

View File

@@ -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);
});

View File

@@ -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;
}
}

View File

@@ -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';