refactor(lifecycle hooks): simplify integration of new services

* make more coherent lifecycles
  * remove specific implementation on different handlers (they do not need to know existence of ThumbnailSerice nor AudioMetadataService)
  * reduce risk of orphean objects
  * ensure additional services are correctly wired (ex: Thumbnail generation was not covering all upload cases)
  * more details on docs/architecture/file-and-blob-lifecycle.md :

```rust
// application/ports/file_lifecycle.rs
pub trait FileLifecycleHook {

    fn on_file_created(file_id, blob_hash, content_type, is_new_blob);
    fn on_file_updated(file_id, blob_hash, content_type);
    fn on_file_copied(file_id, blob_hash, content_type, source_id)
    fn on_file_deleted(file_id);
}

// application/ports/blob_lifecycle.rs
pub trait BlobLifecycleHook {

    fn on_blob_created(blob_hash, content_type);
    fn on_blob_deleted(blob_hash);
}
```
This commit is contained in:
Edouard Vanbelle
2026-05-22 13:10:47 +02:00
parent 9206669ee6
commit 73f0b0fa47
19 changed files with 842 additions and 403 deletions
@@ -202,10 +202,11 @@ impl FileBlobWriteRepository {
Ok(new_hash.to_string())
}
}
impl FileWritePort for FileBlobWriteRepository {
async fn save_file_from_temp(
/// Like [`FileWritePort::save_file_from_temp`] but also returns whether the
/// blob was genuinely new (`true`) or a dedup hit (`false`).
/// Used by [`FileUploadService`] to pass `is_new_blob` to lifecycle hooks.
pub async fn save_file_from_temp_with_dedup(
&self,
name: String,
folder_id: Option<String>,
@@ -213,18 +214,16 @@ impl FileWritePort for FileBlobWriteRepository {
temp_path: &std::path::Path,
size: u64,
pre_computed_hash: Option<String>,
) -> Result<File, DomainError> {
) -> Result<(File, bool), DomainError> {
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
// True streaming: pass pre-computed hash (or let dedup compute it).
// When hash is pre-computed, zero extra disk reads.
let dedup_result = self
.dedup
.store_from_file(temp_path, Some(content_type.clone()), pre_computed_hash)
.await?;
let is_new_blob = !dedup_result.was_deduplicated();
let blob_hash = dedup_result.hash().to_string();
// Insert file metadata — if this fails, compensate by removing the blob ref
let row = match sqlx::query_as::<_, (String, i64, i64)>(
r#"
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
@@ -275,7 +274,7 @@ impl FileWritePort for FileBlobWriteRepository {
);
let folder_path = self.lookup_folder_path(folder_id.as_deref()).await?;
Self::row_to_file(
let file = Self::row_to_file(
row.0,
name,
folder_id,
@@ -285,8 +284,32 @@ impl FileWritePort for FileBlobWriteRepository {
row.1,
row.2,
Some(user_id),
blob_hash.clone(),
blob_hash,
)?;
Ok((file, is_new_blob))
}
}
impl FileWritePort for FileBlobWriteRepository {
async fn save_file_from_temp(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
temp_path: &std::path::Path,
size: u64,
pre_computed_hash: Option<String>,
) -> Result<File, DomainError> {
self.save_file_from_temp_with_dedup(
name,
folder_id,
content_type,
temp_path,
size,
pre_computed_hash,
)
.await
.map(|(file, _)| file)
}
async fn move_file(