fix: critical bugs from deep audit
- Fix copy_files() data loss: implement real copy_file across full stack (FileWritePort, FileManagementUseCase, stubs, service, repository with atomic CTE + dedup ref_count increment, batch_operations caller) - Fix plaintext password in replace_default_admin: hash password via PasswordHasherPort before User::new() - Fix CalendarService hardcoded user_id: unify CalendarUseCase trait with explicit user_id parameter on all methods, remove zombie _for_user duplicates and hardcoded 'current_user_id', update 20 CalDAV handler call sites - Previous session: migrate DedupService to PostgreSQL (storage.blobs), atomic CTEs with compensation for file/folder repository operations
This commit is contained in:
@@ -5,6 +5,6 @@ pub mod pg;
|
||||
|
||||
// Re-exportar para facilitar acceso
|
||||
pub use pg::{
|
||||
FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository,
|
||||
SessionPgRepository, TrashDbRepository, UserPgRepository,
|
||||
FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, SessionPgRepository,
|
||||
TrashDbRepository, UserPgRepository,
|
||||
};
|
||||
|
||||
@@ -1,271 +1,265 @@
|
||||
//! PostgreSQL + Blob-backed file read repository.
|
||||
//!
|
||||
//! Implements `FileReadPort` using:
|
||||
//! - `storage.files` table for metadata lookups
|
||||
//! - `DedupPort` for reading content-addressable blobs from the filesystem
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::dedup_ports::DedupPort;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
use super::folder_db_repository::FolderDbRepository;
|
||||
|
||||
/// File read repository backed by PostgreSQL metadata + blob storage.
|
||||
pub struct FileBlobReadRepository {
|
||||
pool: Arc<PgPool>,
|
||||
dedup: Arc<dyn DedupPort>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
}
|
||||
|
||||
impl FileBlobReadRepository {
|
||||
pub fn new(
|
||||
pool: Arc<PgPool>,
|
||||
dedup: Arc<dyn DedupPort>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
dedup,
|
||||
folder_repo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a virtual StoragePath for a file.
|
||||
async fn build_file_path(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
file_name: &str,
|
||||
) -> Result<StoragePath, DomainError> {
|
||||
if let Some(fid) = folder_id {
|
||||
let folder_path = self.folder_repo.get_folder_path(fid).await?;
|
||||
Ok(folder_path.join(file_name))
|
||||
} else {
|
||||
Ok(StoragePath::from_string(file_name))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a database row into a `File` domain entity.
|
||||
async fn row_to_file(
|
||||
&self,
|
||||
id: String,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
size: i64,
|
||||
mime_type: String,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
) -> Result<File, DomainError> {
|
||||
let storage_path = self
|
||||
.build_file_path(folder_id.as_deref(), &name)
|
||||
.await?;
|
||||
File::with_timestamps(
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
size as u64,
|
||||
mime_type,
|
||||
folder_id,
|
||||
created_at as u64,
|
||||
modified_at as u64,
|
||||
)
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
|
||||
}
|
||||
|
||||
/// Get the blob hash for a file.
|
||||
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT blob_hash FROM storage.files WHERE id = $1::uuid AND NOT is_trashed",
|
||||
)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("hash lookup: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileReadPort for FileBlobReadRepository {
|
||||
async fn get_file(&self, id: &str) -> Result<File, DomainError> {
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
||||
r#"
|
||||
SELECT id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.files
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("get: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("File", id))?;
|
||||
|
||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_files(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
) -> Result<Vec<File>, DomainError> {
|
||||
let rows: Vec<(String, String, Option<String>, i64, String, i64, i64)> =
|
||||
if let Some(fid) = folder_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.files
|
||||
WHERE folder_id = $1::uuid AND NOT is_trashed
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.bind(fid)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.files
|
||||
WHERE folder_id IS NULL AND NOT is_trashed
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
|
||||
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, size, mime, ca, ma) in rows {
|
||||
files.push(self.row_to_file(id, name, fid, size, mime, ca, ma).await?);
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
let blob_hash = self.get_blob_hash(id).await?;
|
||||
self.dedup.read_blob(&blob_hash).await
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
// Read blob as bytes and wrap in a single-chunk stream.
|
||||
// For very large files, a true streaming implementation from the
|
||||
// blob file would be better, but DedupPort API currently returns bytes.
|
||||
let blob_hash = self.get_blob_hash(id).await?;
|
||||
let content = self.dedup.read_blob_bytes(&blob_hash).await?;
|
||||
|
||||
let stream = futures::stream::once(async move { Ok(content) });
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
let blob_hash = self.get_blob_hash(id).await?;
|
||||
let content = self.dedup.read_blob_bytes(&blob_hash).await?;
|
||||
|
||||
let start = start as usize;
|
||||
let end = end.map_or(content.len(), |e| e as usize).min(content.len());
|
||||
|
||||
if start >= content.len() {
|
||||
return Ok(Box::new(futures::stream::empty()));
|
||||
}
|
||||
|
||||
let slice = content.slice(start..end);
|
||||
let stream = futures::stream::once(async move { Ok(slice) });
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError> {
|
||||
let blob_hash = self.get_blob_hash(id).await?;
|
||||
self.dedup.read_blob_bytes(&blob_hash).await
|
||||
}
|
||||
|
||||
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
||||
let row = sqlx::query_as::<_, (String, Option<String>)>(
|
||||
r#"
|
||||
SELECT name, folder_id::text
|
||||
FROM storage.files
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("File", id))?;
|
||||
|
||||
self.build_file_path(row.1.as_deref(), &row.0).await
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError> {
|
||||
// Walk the path to find the parent folder, searching by folder names
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
|
||||
if segments.is_empty() {
|
||||
return Err(DomainError::not_found("Folder", "empty path"));
|
||||
}
|
||||
|
||||
// For path "a/b/c/file.txt", the parent folder path is "a/b/c"
|
||||
// But we don't know which part is folders vs filename.
|
||||
// Walk segments trying to find matching folders.
|
||||
let mut current_parent: Option<String> = None;
|
||||
|
||||
for segment in &segments {
|
||||
let row = if let Some(ref pid) = current_parent {
|
||||
sqlx::query_as::<_, (String,)>(
|
||||
r#"
|
||||
SELECT id::text FROM storage.folders
|
||||
WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(segment)
|
||||
.bind(pid)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<_, (String,)>(
|
||||
r#"
|
||||
SELECT id::text FROM storage.folders
|
||||
WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(segment)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path walk: {e}")))?;
|
||||
|
||||
match row {
|
||||
Some(r) => current_parent = Some(r.0),
|
||||
None => break, // This segment is not a folder → it's the filename
|
||||
}
|
||||
}
|
||||
|
||||
current_parent.ok_or_else(|| {
|
||||
DomainError::not_found("Folder", format!("parent for path: {path}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
//! PostgreSQL + Blob-backed file read repository.
|
||||
//!
|
||||
//! Implements `FileReadPort` using:
|
||||
//! - `storage.files` table for metadata lookups
|
||||
//! - `DedupPort` for reading content-addressable blobs from the filesystem
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::dedup_ports::DedupPort;
|
||||
use crate::application::ports::storage_ports::FileReadPort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
use super::folder_db_repository::FolderDbRepository;
|
||||
|
||||
/// File read repository backed by PostgreSQL metadata + blob storage.
|
||||
pub struct FileBlobReadRepository {
|
||||
pool: Arc<PgPool>,
|
||||
dedup: Arc<dyn DedupPort>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
}
|
||||
|
||||
impl FileBlobReadRepository {
|
||||
pub fn new(
|
||||
pool: Arc<PgPool>,
|
||||
dedup: Arc<dyn DedupPort>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
dedup,
|
||||
folder_repo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a virtual StoragePath for a file.
|
||||
async fn build_file_path(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
file_name: &str,
|
||||
) -> Result<StoragePath, DomainError> {
|
||||
if let Some(fid) = folder_id {
|
||||
let folder_path = self.folder_repo.get_folder_path(fid).await?;
|
||||
Ok(folder_path.join(file_name))
|
||||
} else {
|
||||
Ok(StoragePath::from_string(file_name))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a database row into a `File` domain entity.
|
||||
async fn row_to_file(
|
||||
&self,
|
||||
id: String,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
size: i64,
|
||||
mime_type: String,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
) -> Result<File, DomainError> {
|
||||
let storage_path = self.build_file_path(folder_id.as_deref(), &name).await?;
|
||||
File::with_timestamps(
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
size as u64,
|
||||
mime_type,
|
||||
folder_id,
|
||||
created_at as u64,
|
||||
modified_at as u64,
|
||||
)
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
|
||||
}
|
||||
|
||||
/// Get the blob hash for a file.
|
||||
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT blob_hash FROM storage.files WHERE id = $1::uuid AND NOT is_trashed",
|
||||
)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("hash lookup: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileReadPort for FileBlobReadRepository {
|
||||
async fn get_file(&self, id: &str) -> Result<File, DomainError> {
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
||||
r#"
|
||||
SELECT id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.files
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("get: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("File", id))?;
|
||||
|
||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
|
||||
let rows: Vec<(String, String, Option<String>, i64, String, i64, i64)> =
|
||||
if let Some(fid) = folder_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.files
|
||||
WHERE folder_id = $1::uuid AND NOT is_trashed
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.bind(fid)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.files
|
||||
WHERE folder_id IS NULL AND NOT is_trashed
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
|
||||
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, size, mime, ca, ma) in rows {
|
||||
files.push(self.row_to_file(id, name, fid, size, mime, ca, ma).await?);
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
let blob_hash = self.get_blob_hash(id).await?;
|
||||
self.dedup.read_blob(&blob_hash).await
|
||||
}
|
||||
|
||||
async fn get_file_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
// Read blob as bytes and wrap in a single-chunk stream.
|
||||
// For very large files, a true streaming implementation from the
|
||||
// blob file would be better, but DedupPort API currently returns bytes.
|
||||
let blob_hash = self.get_blob_hash(id).await?;
|
||||
let content = self.dedup.read_blob_bytes(&blob_hash).await?;
|
||||
|
||||
let stream = futures::stream::once(async move { Ok(content) });
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
async fn get_file_range_stream(
|
||||
&self,
|
||||
id: &str,
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
let blob_hash = self.get_blob_hash(id).await?;
|
||||
let content = self.dedup.read_blob_bytes(&blob_hash).await?;
|
||||
|
||||
let start = start as usize;
|
||||
let end = end.map_or(content.len(), |e| e as usize).min(content.len());
|
||||
|
||||
if start >= content.len() {
|
||||
return Ok(Box::new(futures::stream::empty()));
|
||||
}
|
||||
|
||||
let slice = content.slice(start..end);
|
||||
let stream = futures::stream::once(async move { Ok(slice) });
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError> {
|
||||
let blob_hash = self.get_blob_hash(id).await?;
|
||||
self.dedup.read_blob_bytes(&blob_hash).await
|
||||
}
|
||||
|
||||
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
||||
let row = sqlx::query_as::<_, (String, Option<String>)>(
|
||||
r#"
|
||||
SELECT name, folder_id::text
|
||||
FROM storage.files
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("File", id))?;
|
||||
|
||||
self.build_file_path(row.1.as_deref(), &row.0).await
|
||||
}
|
||||
|
||||
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError> {
|
||||
// Walk the path to find the parent folder, searching by folder names
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
|
||||
if segments.is_empty() {
|
||||
return Err(DomainError::not_found("Folder", "empty path"));
|
||||
}
|
||||
|
||||
// For path "a/b/c/file.txt", the parent folder path is "a/b/c"
|
||||
// But we don't know which part is folders vs filename.
|
||||
// Walk segments trying to find matching folders.
|
||||
let mut current_parent: Option<String> = None;
|
||||
|
||||
for segment in &segments {
|
||||
let row = if let Some(ref pid) = current_parent {
|
||||
sqlx::query_as::<_, (String,)>(
|
||||
r#"
|
||||
SELECT id::text FROM storage.folders
|
||||
WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(segment)
|
||||
.bind(pid)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<_, (String,)>(
|
||||
r#"
|
||||
SELECT id::text FROM storage.folders
|
||||
WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(segment)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("path walk: {e}")))?;
|
||||
|
||||
match row {
|
||||
Some(r) => current_parent = Some(r.0),
|
||||
None => break, // This segment is not a folder → it's the filename
|
||||
}
|
||||
}
|
||||
|
||||
current_parent
|
||||
.ok_or_else(|| DomainError::not_found("Folder", format!("parent for path: {path}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,435 +1,526 @@
|
||||
//! PostgreSQL + Blob-backed file write repository.
|
||||
//!
|
||||
//! Implements `FileWritePort` using:
|
||||
//! - `storage.files` table for metadata
|
||||
//! - `DedupPort` for content-addressable blob storage on the filesystem
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use sqlx::PgPool;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::dedup_ports::DedupPort;
|
||||
use crate::application::ports::storage_ports::FileWritePort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
use super::folder_db_repository::FolderDbRepository;
|
||||
|
||||
/// File write repository backed by PostgreSQL metadata + blob storage.
|
||||
pub struct FileBlobWriteRepository {
|
||||
pool: Arc<PgPool>,
|
||||
dedup: Arc<dyn DedupPort>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
}
|
||||
|
||||
impl FileBlobWriteRepository {
|
||||
pub fn new(
|
||||
pool: Arc<PgPool>,
|
||||
dedup: Arc<dyn DedupPort>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
dedup,
|
||||
folder_repo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a virtual StoragePath for a file from its DB metadata.
|
||||
async fn build_file_path(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
file_name: &str,
|
||||
) -> Result<StoragePath, DomainError> {
|
||||
if let Some(fid) = folder_id {
|
||||
let folder_path = self.folder_repo.get_folder_path(fid).await?;
|
||||
Ok(folder_path.join(file_name))
|
||||
} else {
|
||||
Ok(StoragePath::from_string(file_name))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a database row into a `File` domain entity.
|
||||
async fn row_to_file(
|
||||
&self,
|
||||
id: String,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
size: i64,
|
||||
mime_type: String,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
) -> Result<File, DomainError> {
|
||||
let storage_path = self
|
||||
.build_file_path(folder_id.as_deref(), &name)
|
||||
.await?;
|
||||
File::with_timestamps(
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
size as u64,
|
||||
mime_type,
|
||||
folder_id,
|
||||
created_at as u64,
|
||||
modified_at as u64,
|
||||
)
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
|
||||
}
|
||||
|
||||
/// Derive user_id from the parent folder, or error if folder_id is None.
|
||||
async fn resolve_user_id(&self, folder_id: Option<&str>) -> Result<String, DomainError> {
|
||||
match folder_id {
|
||||
Some(fid) => self.folder_repo.get_folder_user_id(fid).await,
|
||||
None => Err(DomainError::internal_error(
|
||||
"FileBlobWrite",
|
||||
"folder_id is required to determine file owner",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileWritePort for FileBlobWriteRepository {
|
||||
async fn save_file(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> Result<File, DomainError> {
|
||||
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
|
||||
let size = content.len() as i64;
|
||||
|
||||
// Store content in blob store
|
||||
let dedup_result = self
|
||||
.dedup
|
||||
.store_bytes(&content, Some(content_type.clone()))
|
||||
.await?;
|
||||
let blob_hash = dedup_result.hash().to_string();
|
||||
|
||||
// Insert file metadata
|
||||
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
||||
r#"
|
||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6)
|
||||
RETURNING id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&folder_id)
|
||||
.bind(&user_id)
|
||||
.bind(&blob_hash)
|
||||
.bind(size)
|
||||
.bind(&content_type)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db_err) = e {
|
||||
if db_err.code().as_deref() == Some("23505") {
|
||||
return DomainError::already_exists(
|
||||
"File",
|
||||
format!("{name} already exists in folder"),
|
||||
);
|
||||
}
|
||||
}
|
||||
DomainError::internal_error("FileBlobWrite", format!("insert: {e}"))
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"💾 BLOB WRITE: {} ({} bytes, hash: {})",
|
||||
name,
|
||||
size,
|
||||
&blob_hash[..12]
|
||||
);
|
||||
|
||||
self.row_to_file(
|
||||
row.0,
|
||||
name,
|
||||
folder_id,
|
||||
size,
|
||||
content_type,
|
||||
row.1,
|
||||
row.2,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn save_file_from_stream(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> Result<File, DomainError> {
|
||||
use futures::StreamExt;
|
||||
|
||||
// Collect stream into bytes (blobs are content-addressed, need full content for hash)
|
||||
let mut content = Vec::new();
|
||||
let mut stream = stream;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobWrite", format!("stream read: {e}"))
|
||||
})?;
|
||||
content.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
self.save_file(name, folder_id, content_type, content).await
|
||||
}
|
||||
|
||||
async fn move_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
target_folder_id: Option<String>,
|
||||
) -> Result<File, DomainError> {
|
||||
// If moving to a different folder, get the new user_id (must be same user)
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
||||
r#"
|
||||
UPDATE storage.files
|
||||
SET folder_id = $1::uuid, updated_at = NOW()
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(&target_folder_id)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id))?;
|
||||
|
||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rename_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
new_name: &str,
|
||||
) -> Result<File, DomainError> {
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
||||
r#"
|
||||
UPDATE storage.files
|
||||
SET name = $1, updated_at = NOW()
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(new_name)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db_err) = e {
|
||||
if db_err.code().as_deref() == Some("23505") {
|
||||
return DomainError::already_exists(
|
||||
"File",
|
||||
format!("{new_name} already exists"),
|
||||
);
|
||||
}
|
||||
}
|
||||
DomainError::internal_error("FileBlobWrite", format!("rename: {e}"))
|
||||
})?
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id))?;
|
||||
|
||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
|
||||
// Get blob_hash before deleting so we can decrement ref
|
||||
let hash = sqlx::query_scalar::<_, String>(
|
||||
"SELECT blob_hash FROM storage.files WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("hash lookup: {e}")))?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM storage.files WHERE id = $1::uuid")
|
||||
.bind(id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("delete: {e}")))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("File", id));
|
||||
}
|
||||
|
||||
// Decrement blob reference
|
||||
if let Some(h) = hash {
|
||||
if let Err(e) = self.dedup.remove_reference(&h).await {
|
||||
tracing::warn!("Failed to decrement blob ref for {}: {}", &h[..12], e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_file_content(
|
||||
&self,
|
||||
file_id: &str,
|
||||
content: Vec<u8>,
|
||||
) -> Result<(), DomainError> {
|
||||
// Get old blob hash to decrement ref
|
||||
let old_hash = sqlx::query_scalar::<_, String>(
|
||||
"SELECT blob_hash FROM storage.files WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("old hash: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id))?;
|
||||
|
||||
// Store new content
|
||||
let new_size = content.len() as i64;
|
||||
let dedup_result = self.dedup.store_bytes(&content, None).await?;
|
||||
let new_hash = dedup_result.hash().to_string();
|
||||
|
||||
// Update file metadata
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.files
|
||||
SET blob_hash = $1, size = $2, updated_at = NOW()
|
||||
WHERE id = $3::uuid
|
||||
"#,
|
||||
)
|
||||
.bind(&new_hash)
|
||||
.bind(new_size)
|
||||
.bind(file_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("update: {e}")))?;
|
||||
|
||||
// Decrement old blob ref (only if hash changed)
|
||||
if old_hash != new_hash {
|
||||
if let Err(e) = self.dedup.remove_reference(&old_hash).await {
|
||||
tracing::warn!(
|
||||
"Failed to decrement old blob ref {}: {}",
|
||||
&old_hash[..12],
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
size: u64,
|
||||
) -> Result<(File, PathBuf), DomainError> {
|
||||
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
|
||||
|
||||
// For deferred registration we use a placeholder hash.
|
||||
// The write-behind cache will call update_file_content later.
|
||||
let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
||||
r#"
|
||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6)
|
||||
RETURNING id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&folder_id)
|
||||
.bind(&user_id)
|
||||
.bind(placeholder_hash)
|
||||
.bind(size as i64)
|
||||
.bind(&content_type)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
|
||||
|
||||
let file = self
|
||||
.row_to_file(
|
||||
row.0.clone(),
|
||||
name,
|
||||
folder_id,
|
||||
size as i64,
|
||||
content_type,
|
||||
row.1,
|
||||
row.2,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The target_path is not meaningful for blob storage (content goes to .blobs/)
|
||||
// but the WriteBehindCache API requires it. We return a synthetic path.
|
||||
let target_path = PathBuf::from(format!(".pending/{}", row.0));
|
||||
|
||||
Ok((file, target_path))
|
||||
}
|
||||
|
||||
// ── Trash operations ──
|
||||
|
||||
async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.files
|
||||
SET is_trashed = TRUE,
|
||||
trashed_at = NOW(),
|
||||
original_folder_id = folder_id,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(file_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("trash: {e}")))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("File", file_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_from_trash(
|
||||
&self,
|
||||
file_id: &str,
|
||||
_original_path: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.files
|
||||
SET is_trashed = FALSE,
|
||||
trashed_at = NULL,
|
||||
folder_id = COALESCE(original_folder_id, folder_id),
|
||||
original_folder_id = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1::uuid AND is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(file_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("restore: {e}")))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("File", file_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError> {
|
||||
// Same as delete_file — removes from DB and decrements blob ref
|
||||
self.delete_file(file_id).await
|
||||
}
|
||||
}
|
||||
//! PostgreSQL + Blob-backed file write repository.
|
||||
//!
|
||||
//! Implements `FileWritePort` using:
|
||||
//! - `storage.files` table for metadata
|
||||
//! - `DedupPort` for content-addressable blob storage on the filesystem
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use sqlx::PgPool;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::dedup_ports::DedupPort;
|
||||
use crate::application::ports::storage_ports::FileWritePort;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
use super::folder_db_repository::FolderDbRepository;
|
||||
|
||||
/// File write repository backed by PostgreSQL metadata + blob storage.
|
||||
pub struct FileBlobWriteRepository {
|
||||
pool: Arc<PgPool>,
|
||||
dedup: Arc<dyn DedupPort>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
}
|
||||
|
||||
impl FileBlobWriteRepository {
|
||||
pub fn new(
|
||||
pool: Arc<PgPool>,
|
||||
dedup: Arc<dyn DedupPort>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
dedup,
|
||||
folder_repo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a virtual StoragePath for a file from its DB metadata.
|
||||
async fn build_file_path(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
file_name: &str,
|
||||
) -> Result<StoragePath, DomainError> {
|
||||
if let Some(fid) = folder_id {
|
||||
let folder_path = self.folder_repo.get_folder_path(fid).await?;
|
||||
Ok(folder_path.join(file_name))
|
||||
} else {
|
||||
Ok(StoragePath::from_string(file_name))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a database row into a `File` domain entity.
|
||||
async fn row_to_file(
|
||||
&self,
|
||||
id: String,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
size: i64,
|
||||
mime_type: String,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
) -> Result<File, DomainError> {
|
||||
let storage_path = self.build_file_path(folder_id.as_deref(), &name).await?;
|
||||
File::with_timestamps(
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
size as u64,
|
||||
mime_type,
|
||||
folder_id,
|
||||
created_at as u64,
|
||||
modified_at as u64,
|
||||
)
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
|
||||
}
|
||||
|
||||
/// Derive user_id from the parent folder, or error if folder_id is None.
|
||||
async fn resolve_user_id(&self, folder_id: Option<&str>) -> Result<String, DomainError> {
|
||||
match folder_id {
|
||||
Some(fid) => self.folder_repo.get_folder_user_id(fid).await,
|
||||
None => Err(DomainError::internal_error(
|
||||
"FileBlobWrite",
|
||||
"folder_id is required to determine file owner",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileWritePort for FileBlobWriteRepository {
|
||||
async fn save_file(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
content: Vec<u8>,
|
||||
) -> Result<File, DomainError> {
|
||||
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
|
||||
let size = content.len() as i64;
|
||||
|
||||
// Store content in blob store
|
||||
let dedup_result = self
|
||||
.dedup
|
||||
.store_bytes(&content, Some(content_type.clone()))
|
||||
.await?;
|
||||
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)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6)
|
||||
RETURNING id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&folder_id)
|
||||
.bind(&user_id)
|
||||
.bind(&blob_hash)
|
||||
.bind(size)
|
||||
.bind(&content_type)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(row) => row,
|
||||
Err(e) => {
|
||||
// ── Compensation: undo the blob ref so it doesn't become orphaned ──
|
||||
if let Err(rollback_err) = self.dedup.remove_reference(&blob_hash).await {
|
||||
tracing::error!(
|
||||
"Blob orphaned after failed INSERT — hash: {}, err: {}",
|
||||
&blob_hash[..12],
|
||||
rollback_err
|
||||
);
|
||||
}
|
||||
if let sqlx::Error::Database(ref db_err) = e {
|
||||
if db_err.code().as_deref() == Some("23505") {
|
||||
return Err(DomainError::already_exists(
|
||||
"File",
|
||||
format!("{name} already exists in folder"),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Err(DomainError::internal_error(
|
||||
"FileBlobWrite",
|
||||
format!("insert: {e}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"💾 BLOB WRITE: {} ({} bytes, hash: {})",
|
||||
name,
|
||||
size,
|
||||
&blob_hash[..12]
|
||||
);
|
||||
|
||||
self.row_to_file(row.0, name, folder_id, size, content_type, row.1, row.2)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn save_file_from_stream(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
) -> Result<File, DomainError> {
|
||||
use futures::StreamExt;
|
||||
|
||||
// Collect stream into bytes (blobs are content-addressed, need full content for hash)
|
||||
let mut content = Vec::new();
|
||||
let mut stream = stream;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobWrite", format!("stream read: {e}"))
|
||||
})?;
|
||||
content.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
self.save_file(name, folder_id, content_type, content).await
|
||||
}
|
||||
|
||||
async fn move_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
target_folder_id: Option<String>,
|
||||
) -> Result<File, DomainError> {
|
||||
// If moving to a different folder, get the new user_id (must be same user)
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
||||
r#"
|
||||
UPDATE storage.files
|
||||
SET folder_id = $1::uuid, updated_at = NOW()
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(&target_folder_id)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id))?;
|
||||
|
||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn copy_file(
|
||||
&self,
|
||||
file_id: &str,
|
||||
target_folder_id: Option<String>,
|
||||
) -> Result<File, DomainError> {
|
||||
// Atomic CTE: read source file → insert new row with same blob_hash → increment ref_count.
|
||||
// Single round-trip; blob content is NOT copied (dedup makes this zero-copy).
|
||||
let target_fid = target_folder_id.clone();
|
||||
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
i64,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
WITH src AS (
|
||||
SELECT name, folder_id, user_id, blob_hash, size, mime_type
|
||||
FROM storage.files
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
),
|
||||
new_file AS (
|
||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
|
||||
SELECT name,
|
||||
COALESCE($2::uuid, folder_id),
|
||||
user_id,
|
||||
blob_hash,
|
||||
size,
|
||||
mime_type
|
||||
FROM src
|
||||
RETURNING id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
blob_hash
|
||||
)
|
||||
SELECT * FROM new_file
|
||||
"#,
|
||||
)
|
||||
.bind(file_id)
|
||||
.bind(&target_fid)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db_err) = e {
|
||||
if db_err.code().as_deref() == Some("23505") {
|
||||
return DomainError::already_exists(
|
||||
"File",
|
||||
"File with that name already exists in target folder".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
DomainError::internal_error("FileBlobWrite", format!("copy: {e}"))
|
||||
})?
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id))?;
|
||||
|
||||
let blob_hash = &row.7;
|
||||
|
||||
// Increment blob reference count (best-effort; INSERT already succeeded)
|
||||
if let Err(e) = self.dedup.add_reference(blob_hash).await {
|
||||
tracing::warn!(
|
||||
"Failed to increment blob ref for copy {}: {}",
|
||||
&blob_hash[..12],
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"📋 BLOB COPY: {} (hash: {}, zero-copy via dedup)",
|
||||
row.1,
|
||||
&blob_hash[..12]
|
||||
);
|
||||
|
||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<File, DomainError> {
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
||||
r#"
|
||||
UPDATE storage.files
|
||||
SET name = $1, updated_at = NOW()
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(new_name)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db_err) = e {
|
||||
if db_err.code().as_deref() == Some("23505") {
|
||||
return DomainError::already_exists(
|
||||
"File",
|
||||
format!("{new_name} already exists"),
|
||||
);
|
||||
}
|
||||
}
|
||||
DomainError::internal_error("FileBlobWrite", format!("rename: {e}"))
|
||||
})?
|
||||
.ok_or_else(|| DomainError::not_found("File", file_id))?;
|
||||
|
||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
|
||||
// Atomic DELETE RETURNING — one round-trip instead of SELECT + DELETE
|
||||
let hash = sqlx::query_scalar::<_, String>(
|
||||
"DELETE FROM storage.files WHERE id = $1::uuid RETURNING blob_hash",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("delete: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("File", id))?;
|
||||
|
||||
// Decrement blob reference (best-effort after successful DELETE)
|
||||
if let Err(e) = self.dedup.remove_reference(&hash).await {
|
||||
tracing::warn!("Failed to decrement blob ref for {}: {}", &hash[..12], e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_file_content(
|
||||
&self,
|
||||
file_id: &str,
|
||||
content: Vec<u8>,
|
||||
) -> Result<(), DomainError> {
|
||||
// Store new content first (blob store is idempotent)
|
||||
let new_size = content.len() as i64;
|
||||
let dedup_result = self.dedup.store_bytes(&content, None).await?;
|
||||
let new_hash = dedup_result.hash().to_string();
|
||||
|
||||
// Atomic CTE: capture old hash then update in one round-trip, no TOCTOU.
|
||||
// The `old` CTE locks + reads the row *before* the update touches it.
|
||||
let old_hash = match sqlx::query_scalar::<_, String>(
|
||||
r#"
|
||||
WITH old AS (
|
||||
SELECT id, blob_hash FROM storage.files WHERE id = $3::uuid FOR UPDATE
|
||||
)
|
||||
UPDATE storage.files f
|
||||
SET blob_hash = $1, size = $2, updated_at = NOW()
|
||||
FROM old
|
||||
WHERE f.id = old.id
|
||||
RETURNING old.blob_hash
|
||||
"#,
|
||||
)
|
||||
.bind(&new_hash)
|
||||
.bind(new_size)
|
||||
.bind(file_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(Some(old)) => old,
|
||||
Ok(None) => {
|
||||
// File not found — compensate: remove the new blob ref
|
||||
if let Err(e) = self.dedup.remove_reference(&new_hash).await {
|
||||
tracing::error!("Blob orphaned after missing file: {}", e);
|
||||
}
|
||||
return Err(DomainError::not_found("File", file_id));
|
||||
}
|
||||
Err(e) => {
|
||||
// UPDATE failed — compensate: remove the new blob ref
|
||||
if let Err(rollback_err) = self.dedup.remove_reference(&new_hash).await {
|
||||
tracing::error!(
|
||||
"Blob orphaned after failed UPDATE — hash: {}, err: {}",
|
||||
&new_hash[..12],
|
||||
rollback_err
|
||||
);
|
||||
}
|
||||
return Err(DomainError::internal_error(
|
||||
"FileBlobWrite",
|
||||
format!("update: {e}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Decrement old blob ref (only if hash changed, best-effort)
|
||||
if old_hash != new_hash {
|
||||
if let Err(e) = self.dedup.remove_reference(&old_hash).await {
|
||||
tracing::warn!(
|
||||
"Failed to decrement old blob ref {}: {}",
|
||||
&old_hash[..12],
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn register_file_deferred(
|
||||
&self,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
content_type: String,
|
||||
size: u64,
|
||||
) -> Result<(File, PathBuf), DomainError> {
|
||||
let user_id = self.resolve_user_id(folder_id.as_deref()).await?;
|
||||
|
||||
// For deferred registration we use a placeholder hash.
|
||||
// The write-behind cache will call update_file_content later.
|
||||
let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
||||
r#"
|
||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6)
|
||||
RETURNING id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&folder_id)
|
||||
.bind(&user_id)
|
||||
.bind(placeholder_hash)
|
||||
.bind(size as i64)
|
||||
.bind(&content_type)
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?;
|
||||
|
||||
let file = self
|
||||
.row_to_file(
|
||||
row.0.clone(),
|
||||
name,
|
||||
folder_id,
|
||||
size as i64,
|
||||
content_type,
|
||||
row.1,
|
||||
row.2,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The target_path is not meaningful for blob storage (content goes to .blobs/)
|
||||
// but the WriteBehindCache API requires it. We return a synthetic path.
|
||||
let target_path = PathBuf::from(format!(".pending/{}", row.0));
|
||||
|
||||
Ok((file, target_path))
|
||||
}
|
||||
|
||||
// ── Trash operations ──
|
||||
|
||||
async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.files
|
||||
SET is_trashed = TRUE,
|
||||
trashed_at = NOW(),
|
||||
original_folder_id = folder_id,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(file_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("trash: {e}")))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("File", file_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_from_trash(
|
||||
&self,
|
||||
file_id: &str,
|
||||
_original_path: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.files
|
||||
SET is_trashed = FALSE,
|
||||
trashed_at = NULL,
|
||||
folder_id = COALESCE(original_folder_id, folder_id),
|
||||
original_folder_id = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1::uuid AND is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(file_id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("restore: {e}")))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("File", file_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError> {
|
||||
// Same as delete_file — removes from DB and decrements blob ref
|
||||
self.delete_file(file_id).await
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,191 +1,173 @@
|
||||
//! PostgreSQL-backed trash repository.
|
||||
//!
|
||||
//! Implements `TrashRepository` using soft-delete columns in `storage.files`
|
||||
//! and `storage.folders`. There is no separate trash table — trashed items
|
||||
//! are files/folders with `is_trashed = TRUE`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::{DomainError, Result};
|
||||
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
|
||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
|
||||
/// Default retention period (days) used when computing deletion_date.
|
||||
const _DEFAULT_RETENTION_DAYS: i64 = 30;
|
||||
|
||||
/// PostgreSQL-backed trash repository using soft-delete flags.
|
||||
pub struct TrashDbRepository {
|
||||
pool: Arc<PgPool>,
|
||||
retention_days: i64,
|
||||
}
|
||||
|
||||
impl TrashDbRepository {
|
||||
pub fn new(pool: Arc<PgPool>, retention_days: u32) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
retention_days: retention_days as i64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a trash_items view row into a TrashedItem entity.
|
||||
fn row_to_trashed_item(
|
||||
&self,
|
||||
id: Uuid,
|
||||
name: String,
|
||||
item_type: String,
|
||||
user_id: String,
|
||||
trashed_at: Option<DateTime<Utc>>,
|
||||
) -> TrashedItem {
|
||||
let trashed_at = trashed_at.unwrap_or_else(Utc::now);
|
||||
let deletion_date = trashed_at + chrono::Duration::days(self.retention_days);
|
||||
|
||||
let item_type_enum = match item_type.as_str() {
|
||||
"folder" => TrashedItemType::Folder,
|
||||
_ => TrashedItemType::File,
|
||||
};
|
||||
|
||||
let user_uuid = Uuid::parse_str(&user_id).unwrap_or_else(|_| Uuid::nil());
|
||||
|
||||
// In the soft-delete model, the trash entry ID is the same as the
|
||||
// original item ID since there is no separate trash table.
|
||||
TrashedItem::from_raw(
|
||||
id, // trash entry id (same as original)
|
||||
id, // original item id
|
||||
user_uuid, // owner
|
||||
item_type_enum,
|
||||
name.clone(),
|
||||
String::new(), // original_path — not stored separately in soft-delete model
|
||||
trashed_at,
|
||||
deletion_date,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TrashRepository for TrashDbRepository {
|
||||
async fn add_to_trash(&self, _item: &TrashedItem) -> Result<()> {
|
||||
// No-op: the actual flagging is done by FileWritePort::move_to_trash
|
||||
// or FolderRepository::move_to_trash. This method exists for interface
|
||||
// compatibility with the TrashService.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> {
|
||||
let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
|
||||
r#"
|
||||
SELECT id, name, item_type, user_id, trashed_at
|
||||
FROM storage.trash_items
|
||||
WHERE user_id = $1
|
||||
ORDER BY trashed_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id.to_string())
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("TrashDb", format!("list: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, name, item_type, uid, trashed_at)| {
|
||||
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_trash_item(
|
||||
&self,
|
||||
id: &Uuid,
|
||||
user_id: &Uuid,
|
||||
) -> Result<Option<TrashedItem>> {
|
||||
let row = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
|
||||
r#"
|
||||
SELECT id, name, item_type, user_id, trashed_at
|
||||
FROM storage.trash_items
|
||||
WHERE id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id.to_string())
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("TrashDb", format!("get: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(row.map(|(id, name, item_type, uid, trashed_at)| {
|
||||
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
|
||||
}))
|
||||
}
|
||||
|
||||
async fn restore_from_trash(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> {
|
||||
// No-op: the actual restore is done by FileWritePort::restore_from_trash
|
||||
// or FolderRepository::restore_from_trash. The TrashService also removes
|
||||
// the index entry — which in the soft-delete model means the flag is
|
||||
// already cleared.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_permanently(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> {
|
||||
// No-op: the actual delete is done by FileWritePort::delete_file_permanently
|
||||
// or FolderRepository::delete_folder_permanently.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_trash(&self, user_id: &Uuid) -> Result<()> {
|
||||
// Delete all trashed files for this user
|
||||
sqlx::query(
|
||||
"DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE",
|
||||
)
|
||||
.bind(user_id.to_string())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("TrashDb", format!("clear files: {e}"))
|
||||
})?;
|
||||
|
||||
// Delete all trashed folders for this user
|
||||
sqlx::query(
|
||||
"DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE",
|
||||
)
|
||||
.bind(user_id.to_string())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("TrashDb", format!("clear folders: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_expired_items(&self) -> Result<Vec<TrashedItem>> {
|
||||
let cutoff = Utc::now() - chrono::Duration::days(self.retention_days);
|
||||
|
||||
let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
|
||||
r#"
|
||||
SELECT id, name, item_type, user_id, trashed_at
|
||||
FROM storage.trash_items
|
||||
WHERE trashed_at < $1
|
||||
ORDER BY trashed_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(cutoff)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("TrashDb", format!("expired: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, name, item_type, uid, trashed_at)| {
|
||||
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
//! PostgreSQL-backed trash repository.
|
||||
//!
|
||||
//! Implements `TrashRepository` using soft-delete columns in `storage.files`
|
||||
//! and `storage.folders`. There is no separate trash table — trashed items
|
||||
//! are files/folders with `is_trashed = TRUE`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::{DomainError, Result};
|
||||
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
|
||||
use crate::domain::repositories::trash_repository::TrashRepository;
|
||||
|
||||
/// Default retention period (days) used when computing deletion_date.
|
||||
const _DEFAULT_RETENTION_DAYS: i64 = 30;
|
||||
|
||||
/// PostgreSQL-backed trash repository using soft-delete flags.
|
||||
pub struct TrashDbRepository {
|
||||
pool: Arc<PgPool>,
|
||||
retention_days: i64,
|
||||
}
|
||||
|
||||
impl TrashDbRepository {
|
||||
pub fn new(pool: Arc<PgPool>, retention_days: u32) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
retention_days: retention_days as i64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a trash_items view row into a TrashedItem entity.
|
||||
fn row_to_trashed_item(
|
||||
&self,
|
||||
id: Uuid,
|
||||
name: String,
|
||||
item_type: String,
|
||||
user_id: String,
|
||||
trashed_at: Option<DateTime<Utc>>,
|
||||
) -> TrashedItem {
|
||||
let trashed_at = trashed_at.unwrap_or_else(Utc::now);
|
||||
let deletion_date = trashed_at + chrono::Duration::days(self.retention_days);
|
||||
|
||||
let item_type_enum = match item_type.as_str() {
|
||||
"folder" => TrashedItemType::Folder,
|
||||
_ => TrashedItemType::File,
|
||||
};
|
||||
|
||||
let user_uuid = Uuid::parse_str(&user_id).unwrap_or_else(|_| Uuid::nil());
|
||||
|
||||
// In the soft-delete model, the trash entry ID is the same as the
|
||||
// original item ID since there is no separate trash table.
|
||||
TrashedItem::from_raw(
|
||||
id, // trash entry id (same as original)
|
||||
id, // original item id
|
||||
user_uuid, // owner
|
||||
item_type_enum,
|
||||
name.clone(),
|
||||
String::new(), // original_path — not stored separately in soft-delete model
|
||||
trashed_at,
|
||||
deletion_date,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TrashRepository for TrashDbRepository {
|
||||
async fn add_to_trash(&self, _item: &TrashedItem) -> Result<()> {
|
||||
// No-op: the actual flagging is done by FileWritePort::move_to_trash
|
||||
// or FolderRepository::move_to_trash. This method exists for interface
|
||||
// compatibility with the TrashService.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> {
|
||||
let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
|
||||
r#"
|
||||
SELECT id, name, item_type, user_id, trashed_at
|
||||
FROM storage.trash_items
|
||||
WHERE user_id = $1
|
||||
ORDER BY trashed_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id.to_string())
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, name, item_type, uid, trashed_at)| {
|
||||
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>> {
|
||||
let row = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
|
||||
r#"
|
||||
SELECT id, name, item_type, user_id, trashed_at
|
||||
FROM storage.trash_items
|
||||
WHERE id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id.to_string())
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?;
|
||||
|
||||
Ok(row.map(|(id, name, item_type, uid, trashed_at)| {
|
||||
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
|
||||
}))
|
||||
}
|
||||
|
||||
async fn restore_from_trash(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> {
|
||||
// No-op: the actual restore is done by FileWritePort::restore_from_trash
|
||||
// or FolderRepository::restore_from_trash. The TrashService also removes
|
||||
// the index entry — which in the soft-delete model means the flag is
|
||||
// already cleared.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_permanently(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> {
|
||||
// No-op: the actual delete is done by FileWritePort::delete_file_permanently
|
||||
// or FolderRepository::delete_folder_permanently.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_trash(&self, user_id: &Uuid) -> Result<()> {
|
||||
// Delete all trashed files for this user
|
||||
sqlx::query("DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE")
|
||||
.bind(user_id.to_string())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("TrashDb", format!("clear files: {e}")))?;
|
||||
|
||||
// Delete all trashed folders for this user
|
||||
sqlx::query("DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE")
|
||||
.bind(user_id.to_string())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("TrashDb", format!("clear folders: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_expired_items(&self) -> Result<Vec<TrashedItem>> {
|
||||
let cutoff = Utc::now() - chrono::Duration::days(self.retention_days);
|
||||
|
||||
let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option<DateTime<Utc>>)>(
|
||||
r#"
|
||||
SELECT id, name, item_type, user_id, trashed_at
|
||||
FROM storage.trash_items
|
||||
WHERE trashed_at < $1
|
||||
ORDER BY trashed_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(cutoff)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("TrashDb", format!("expired: {e}")))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, name, item_type, uid, trashed_at)| {
|
||||
self.row_to_trashed_item(id, name, item_type, uid, trashed_at)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,9 +209,7 @@ impl DedupService {
|
||||
}
|
||||
|
||||
// Atomic write: temp file → rename
|
||||
let temp_path = self
|
||||
.temp_root
|
||||
.join(format!("{}.tmp", uuid::Uuid::new_v4()));
|
||||
let temp_path = self.temp_root.join(format!("{}.tmp", uuid::Uuid::new_v4()));
|
||||
fs::write(&temp_path, content).await.map_err(|e| {
|
||||
DomainError::internal_error("Dedup", format!("Failed to write temp blob: {}", e))
|
||||
})?;
|
||||
@@ -258,10 +256,7 @@ impl DedupService {
|
||||
let file_size = fs::metadata(source_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"Dedup",
|
||||
format!("Failed to get file metadata: {}", e),
|
||||
)
|
||||
DomainError::internal_error("Dedup", format!("Failed to get file metadata: {}", e))
|
||||
})?
|
||||
.len();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user