refactor(services): add file_lifecycle and blob_lifecycle
- complete src/application/ports/blob_lifecycle.rs with traits:
* BlobCreationHook
* BlobDeletionHook
- add src/application/ports/file_lifecycle.rs with traits:
* FileCreatedHook
* FileDeletedHook
* FileUpdatedHook
This commit is contained in:
@@ -1,6 +1,20 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
/// Observer notified by [`DedupService`] when a genuinely new blob is stored
|
||||
/// for the first time (no dedup hit).
|
||||
///
|
||||
/// Register with [`DedupService::add_blob_creation_hook`] during DI wiring.
|
||||
pub trait BlobCreationHook: Send + Sync {
|
||||
/// Called after the new blob's chunks and manifest have been written.
|
||||
/// `blob_hash` is the BLAKE3 hex, `content_type` is the MIME type if known.
|
||||
/// Must be best-effort — must not propagate errors.
|
||||
fn on_blob_created<'a>(
|
||||
&'a self,
|
||||
blob_hash: &'a str,
|
||||
content_type: Option<&'a str>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
|
||||
}
|
||||
/// Observer notified by [`DedupService`] when a blob's ref_count reaches zero
|
||||
/// and it is permanently removed from storage.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
/// Observer notified by [`FileUploadService`] when a new file record is created
|
||||
/// (including dedup hits where the blob already exists).
|
||||
///
|
||||
/// Register with [`FileUploadService::with_file_created_hook`] during DI wiring.
|
||||
pub trait FileCreatedHook: Send + Sync {
|
||||
/// Called after the file record has been persisted.
|
||||
/// `file_id` — opaque file UUID string.
|
||||
/// `blob_hash` — BLAKE3 hex of the blob (may already exist on disk for dedup hits).
|
||||
/// `content_type` — MIME type of the content.
|
||||
/// Must be best-effort — must not propagate errors.
|
||||
fn on_file_created<'a>(
|
||||
&'a self,
|
||||
file_id: &'a str,
|
||||
blob_hash: &'a str,
|
||||
content_type: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
|
||||
}
|
||||
|
||||
/// Observer notified by [`FileUploadService`] when an existing file's blob is
|
||||
/// replaced (WebDAV PUT overwrite, WOPI PutFile, Nextcloud chunked upload).
|
||||
///
|
||||
/// Implement this trait on any service that needs to react to a content swap
|
||||
/// (e.g. thumbnail invalidation + regeneration, search index update).
|
||||
/// Register with [`FileUploadService::with_file_updated_hook`] during DI wiring.
|
||||
///
|
||||
/// The boxed-future return keeps the trait dyn-compatible so multiple
|
||||
/// implementations can be stored as `Vec<Arc<dyn FileUpdatedHook>>`.
|
||||
pub trait FileUpdatedHook: Send + Sync {
|
||||
/// Called after the new blob has been stored and the file record updated.
|
||||
///
|
||||
/// `file_id` is an opaque file UUID string, `blob_hash` is the BLAKE3 hex
|
||||
/// of the new blob, `content_type` is the MIME type of the new content.
|
||||
/// Must be best-effort — must not propagate errors.
|
||||
fn on_file_updated<'a>(
|
||||
&'a self,
|
||||
file_id: &'a str,
|
||||
blob_hash: &'a str,
|
||||
content_type: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
|
||||
}
|
||||
|
||||
/// Observer notified by [`FileManagementService`] when a file is permanently
|
||||
/// deleted (either directly or after being emptied from trash).
|
||||
///
|
||||
/// Register with [`FileManagementService::with_file_deleted_hook`] during DI wiring.
|
||||
pub trait FileDeletedHook: Send + Sync {
|
||||
/// Called after the file record has been removed.
|
||||
/// `file_id` — opaque file UUID string.
|
||||
/// Must be best-effort — must not propagate errors.
|
||||
fn on_file_deleted<'a>(
|
||||
&'a self,
|
||||
file_id: &'a str,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod chunked_upload_ports;
|
||||
pub mod compression_ports;
|
||||
pub mod dedup_ports;
|
||||
pub mod favorites_ports;
|
||||
pub mod file_lifecycle;
|
||||
pub mod file_ports;
|
||||
pub mod inbound;
|
||||
pub mod music_ports;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::file_lifecycle::FileDeletedHook;
|
||||
use crate::application::ports::file_ports::FileManagementUseCase;
|
||||
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileReadPort, FileWritePort};
|
||||
use crate::application::ports::trash_ports::TrashUseCase;
|
||||
@@ -11,7 +12,6 @@ use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlob
|
||||
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
|
||||
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
|
||||
use crate::infrastructure::services::file_content_cache::FileContentCache;
|
||||
use crate::infrastructure::services::thumbnail_service::ThumbnailService;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -26,8 +26,9 @@ pub struct FileManagementService {
|
||||
file_read: Option<Arc<FileBlobReadRepository>>,
|
||||
folder_repo: Option<Arc<FolderDbRepository>>,
|
||||
trash_service: Option<Arc<TrashService>>,
|
||||
thumbnail_service: Option<Arc<ThumbnailService>>,
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
/// Hooks fired after a file is permanently deleted.
|
||||
file_deleted_hooks: Vec<Arc<dyn FileDeletedHook>>,
|
||||
}
|
||||
|
||||
impl FileManagementService {
|
||||
@@ -38,8 +39,8 @@ impl FileManagementService {
|
||||
file_read: None,
|
||||
folder_repo: None,
|
||||
trash_service: None,
|
||||
thumbnail_service: None,
|
||||
content_cache: None,
|
||||
file_deleted_hooks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +50,6 @@ impl FileManagementService {
|
||||
trash_service: Option<Arc<TrashService>>,
|
||||
file_read: Option<Arc<FileBlobReadRepository>>,
|
||||
folder_repo: Option<Arc<FolderDbRepository>>,
|
||||
thumbnail_service: Option<Arc<ThumbnailService>>,
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -57,11 +57,17 @@ impl FileManagementService {
|
||||
file_read,
|
||||
folder_repo,
|
||||
trash_service,
|
||||
thumbnail_service,
|
||||
content_cache,
|
||||
file_deleted_hooks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a hook to fire after a file is permanently deleted.
|
||||
pub fn with_file_deleted_hook(mut self, hook: Arc<dyn FileDeletedHook>) -> Self {
|
||||
self.file_deleted_hooks.push(hook);
|
||||
self
|
||||
}
|
||||
|
||||
/// Verifies ownership via the read repository.
|
||||
async fn verify_owner(&self, file_id: &str, caller_id: Uuid) -> Result<(), DomainError> {
|
||||
if let Some(read) = &self.file_read {
|
||||
@@ -230,15 +236,11 @@ impl FileManagementUseCase for FileManagementService {
|
||||
|
||||
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
|
||||
self.file_repository.delete_file(id).await?;
|
||||
// Invalidate content cache — file no longer exists.
|
||||
if let Some(cc) = &self.content_cache {
|
||||
cc.invalidate(id).await;
|
||||
}
|
||||
// Best-effort thumbnail cleanup
|
||||
if let Some(thumb) = &self.thumbnail_service
|
||||
&& let Err(e) = thumb.delete_thumbnails(id).await
|
||||
{
|
||||
warn!("Failed to delete thumbnails for file {}: {}", id, e);
|
||||
for hook in &self.file_deleted_hooks {
|
||||
hook.on_file_deleted(id).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -283,15 +285,11 @@ impl FileManagementUseCase for FileManagementService {
|
||||
// Step 2: Permanent delete — trigger handles blob ref_count
|
||||
warn!("Permanently deleting file: {}", id);
|
||||
self.file_repository.delete_file(id).await?;
|
||||
// Invalidate content cache — file permanently removed.
|
||||
if let Some(cc) = &self.content_cache {
|
||||
cc.invalidate(id).await;
|
||||
}
|
||||
// Best-effort thumbnail cleanup
|
||||
if let Some(thumb) = &self.thumbnail_service
|
||||
&& let Err(e) = thumb.delete_thumbnails(id).await
|
||||
{
|
||||
warn!("Failed to delete thumbnails for file {}: {}", id, e);
|
||||
for hook in &self.file_deleted_hooks {
|
||||
hook.on_file_deleted(id).await;
|
||||
}
|
||||
info!("File permanently deleted: {}", id);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::ports::file_lifecycle::{FileCreatedHook, FileUpdatedHook};
|
||||
use crate::application::ports::file_ports::FileUploadUseCase;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
|
||||
use crate::application::services::storage_usage_service::StorageUsageService;
|
||||
@@ -52,6 +53,10 @@ pub struct FileUploadService {
|
||||
storage_usage_service: Option<Arc<StorageUsageService>>,
|
||||
/// Content cache — invalidated on file update so stale content is never served.
|
||||
content_cache: Option<Arc<FileContentCache>>,
|
||||
/// Hooks fired after a new file record is created.
|
||||
file_created_hooks: Vec<Arc<dyn FileCreatedHook>>,
|
||||
/// Hooks fired after a file's blob is replaced (e.g. thumbnail refresh).
|
||||
file_updated_hooks: Vec<Arc<dyn FileUpdatedHook>>,
|
||||
}
|
||||
|
||||
impl FileUploadService {
|
||||
@@ -62,6 +67,8 @@ impl FileUploadService {
|
||||
file_read: None,
|
||||
storage_usage_service: None,
|
||||
content_cache: None,
|
||||
file_created_hooks: Vec::new(),
|
||||
file_updated_hooks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +82,8 @@ impl FileUploadService {
|
||||
file_read: Some(file_read),
|
||||
storage_usage_service: None,
|
||||
content_cache: None,
|
||||
file_created_hooks: Vec::new(),
|
||||
file_updated_hooks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +93,18 @@ impl FileUploadService {
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers a hook to fire after a new file record is created.
|
||||
pub fn with_file_created_hook(mut self, hook: Arc<dyn FileCreatedHook>) -> Self {
|
||||
self.file_created_hooks.push(hook);
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers a hook to fire after a file's blob is replaced.
|
||||
pub fn with_file_updated_hook(mut self, hook: Arc<dyn FileUpdatedHook>) -> Self {
|
||||
self.file_updated_hooks.push(hook);
|
||||
self
|
||||
}
|
||||
|
||||
/// Configures the storage usage service
|
||||
pub fn with_storage_usage_service(
|
||||
mut self,
|
||||
@@ -149,6 +170,10 @@ impl FileUploadUseCase for FileUploadService {
|
||||
name, size, dto.id
|
||||
);
|
||||
self.maybe_update_storage_usage(&dto);
|
||||
for hook in &self.file_created_hooks {
|
||||
hook.on_file_created(&dto.id, &dto.etag, &dto.mime_type)
|
||||
.await;
|
||||
}
|
||||
Ok(dto)
|
||||
}
|
||||
|
||||
@@ -301,7 +326,12 @@ impl FileUploadUseCase for FileUploadService {
|
||||
}
|
||||
// Re-read to get fresh DTO with updated etag and timestamps.
|
||||
let updated = file_read.get_file(&file_id).await?;
|
||||
return Ok(FileDto::from(updated));
|
||||
let dto = FileDto::from(updated);
|
||||
for hook in &self.file_updated_hooks {
|
||||
hook.on_file_updated(&file_id, &dto.etag, content_type)
|
||||
.await;
|
||||
}
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
// File doesn't exist — create it via streaming upload
|
||||
|
||||
Reference in New Issue
Block a user