feat(#113): 100% blob storage model — PostgreSQL metadata + DedupService blobs
BREAKING CHANGE: Storage model completely rewritten. All file/folder metadata now lives in PostgreSQL (storage schema). File content stored as content-addressable blobs via DedupService. Filesystem directories are no longer used for user storage. New components: - storage.folders / storage.files / storage.trash_items (PG schema) - FolderDbRepository: virtual folders backed by PG - FileBlobReadRepository: file reads via PG metadata + dedup blobs - FileBlobWriteRepository: file writes via PG metadata + dedup blobs - TrashDbRepository: soft-delete trash using is_trashed flags Removed legacy FS components (~5500 lines deleted): - FolderFsRepository, FileFsReadRepository, FileFsWriteRepository - CompositeFileRepository, ParallelFileProcessor - IdMappingService, IdMappingOptimizer, FileMetadataCache - BufferPool, FileSystemUtils, RepositoryErrors - TrashFsRepository, FolderFsRepositoryTrash DI rewired: build_app_state() now requires PgPool (no FS fallback). FileUploadService.new_with_read() and FileRetrievalService.new_with_cache() constructors added for blob model (no write-behind needed). Closes #113
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
//! 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}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
//! PostgreSQL-backed folder repository.
|
||||
//!
|
||||
//! Implements `FolderRepository` (and thus `FolderStoragePort`) using the
|
||||
//! `storage.folders` table. Folders are purely virtual — no physical
|
||||
//! directories are created on the filesystem.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::folder::Folder;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
/// PostgreSQL-backed folder repository.
|
||||
///
|
||||
/// All folder metadata lives in the `storage.folders` table. The physical
|
||||
/// filesystem is never touched for folder operations.
|
||||
pub struct FolderDbRepository {
|
||||
pool: Option<Arc<PgPool>>,
|
||||
}
|
||||
|
||||
impl FolderDbRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool: Some(pool) }
|
||||
}
|
||||
|
||||
/// Creates a stub instance for `AppState::default()`.
|
||||
/// This is never called in production — only used for route scaffolding.
|
||||
pub fn new_stub() -> Self {
|
||||
Self { pool: None }
|
||||
}
|
||||
|
||||
/// Get the pool, panicking if stub.
|
||||
fn pool(&self) -> &PgPool {
|
||||
self.pool.as_deref().expect("FolderDbRepository: pool not available (stub instance)")
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────
|
||||
|
||||
/// Build the full virtual path for a folder by walking up the `parent_id` chain.
|
||||
async fn build_folder_path(&self, folder_id: &str) -> Result<StoragePath, DomainError> {
|
||||
// CTE-based recursive query to build path segments
|
||||
let _rows = sqlx::query_as::<_, (String,)>(
|
||||
r#"
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id, name, parent_id
|
||||
FROM storage.folders
|
||||
WHERE id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT f.id, f.name, f.parent_id
|
||||
FROM storage.folders f
|
||||
JOIN ancestors a ON f.id = a.parent_id
|
||||
)
|
||||
SELECT name FROM ancestors ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.bind(folder_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("path query: {e}")))?;
|
||||
|
||||
// Actually we need a proper ordering. Let me rewrite with depth tracking.
|
||||
// Re-query with depth.
|
||||
let rows = sqlx::query_as::<_, (String, i32)>(
|
||||
r#"
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id, name, parent_id, 0 AS depth
|
||||
FROM storage.folders
|
||||
WHERE id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT f.id, f.name, f.parent_id, a.depth + 1
|
||||
FROM storage.folders f
|
||||
JOIN ancestors a ON f.id = a.parent_id
|
||||
)
|
||||
SELECT name, depth FROM ancestors ORDER BY depth DESC
|
||||
"#,
|
||||
)
|
||||
.bind(folder_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("path query: {e}")))?;
|
||||
|
||||
let path_parts: Vec<&str> = rows.iter().map(|(name, _)| name.as_str()).collect();
|
||||
let path_str = path_parts.join("/");
|
||||
Ok(StoragePath::from_string(&path_str))
|
||||
}
|
||||
|
||||
/// Convert a database row into a `Folder` domain entity.
|
||||
async fn row_to_folder(
|
||||
&self,
|
||||
id: String,
|
||||
name: String,
|
||||
parent_id: Option<String>,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
) -> Result<Folder, DomainError> {
|
||||
let storage_path = self.build_folder_path(&id).await?;
|
||||
Folder::with_timestamps(
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
parent_id,
|
||||
created_at as u64,
|
||||
modified_at as u64,
|
||||
)
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FolderRepository for FolderDbRepository {
|
||||
async fn create_folder(
|
||||
&self,
|
||||
name: String,
|
||||
parent_id: Option<String>,
|
||||
) -> Result<Folder, DomainError> {
|
||||
// Derive user_id from parent folder. Root-level folders require the
|
||||
// caller to have set up the home folder beforehand (done during user
|
||||
// registration).
|
||||
let user_id: String = if let Some(ref pid) = parent_id {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT user_id FROM storage.folders WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(pid)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("parent lookup: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", pid))?
|
||||
} else {
|
||||
return Err(DomainError::internal_error(
|
||||
"FolderDb",
|
||||
"Cannot create root folder without user_id — use create_home_folder instead",
|
||||
));
|
||||
};
|
||||
|
||||
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
||||
r#"
|
||||
INSERT INTO storage.folders (name, parent_id, user_id)
|
||||
VALUES ($1, $2::uuid, $3)
|
||||
RETURNING id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&parent_id)
|
||||
.bind(&user_id)
|
||||
.fetch_one(self.pool())
|
||||
.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(
|
||||
"Folder",
|
||||
format!("{name} already exists in parent"),
|
||||
);
|
||||
}
|
||||
}
|
||||
DomainError::internal_error("FolderDb", format!("insert: {e}"))
|
||||
})?;
|
||||
|
||||
self.row_to_folder(row.0, name, parent_id, row.1, row.2)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>, i64, i64)>(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||
|
||||
self.row_to_folder(row.0, row.1, row.2, row.3, row.4)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_folder_by_path(
|
||||
&self,
|
||||
storage_path: &StoragePath,
|
||||
) -> Result<Folder, DomainError> {
|
||||
// Walk the path segments to find the folder.
|
||||
let path_str = storage_path.to_string();
|
||||
let segments: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect();
|
||||
|
||||
if segments.is_empty() {
|
||||
return Err(DomainError::not_found("Folder", "empty path"));
|
||||
}
|
||||
|
||||
let mut current_parent: Option<String> = None;
|
||||
let mut current_id = String::new();
|
||||
|
||||
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())
|
||||
.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())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("path walk: {e}")))?
|
||||
.ok_or_else(|| {
|
||||
DomainError::not_found("Folder", format!("segment '{segment}' in path"))
|
||||
})?;
|
||||
|
||||
current_id = row.0;
|
||||
current_parent = Some(current_id.clone());
|
||||
}
|
||||
|
||||
self.get_folder(¤t_id).await
|
||||
}
|
||||
|
||||
async fn list_folders(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let rows: Vec<(String, String, Option<String>, i64, i64)> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND NOT is_trashed
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.bind(pid)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL AND NOT is_trashed
|
||||
ORDER BY name
|
||||
"#,
|
||||
)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
|
||||
|
||||
let mut folders = Vec::with_capacity(rows.len());
|
||||
for (id, name, pid, ca, ma) in rows {
|
||||
folders.push(self.row_to_folder(id, name, pid, ca, ma).await?);
|
||||
}
|
||||
Ok(folders)
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
include_total: bool,
|
||||
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
||||
let total = if include_total {
|
||||
let count: i64 = if let Some(pid) = parent_id {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed",
|
||||
)
|
||||
.bind(pid)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed",
|
||||
)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("count: {e}")))?;
|
||||
Some(count as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rows: Vec<(String, String, Option<String>, i64, i64)> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND NOT is_trashed
|
||||
ORDER BY name
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL AND NOT is_trashed
|
||||
ORDER BY name
|
||||
LIMIT $1 OFFSET $2
|
||||
"#,
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?;
|
||||
|
||||
let mut folders = Vec::with_capacity(rows.len());
|
||||
for (id, name, pid, ca, ma) in rows {
|
||||
folders.push(self.row_to_folder(id, name, pid, ca, ma).await?);
|
||||
}
|
||||
Ok((folders, total))
|
||||
}
|
||||
|
||||
async fn rename_folder(
|
||||
&self,
|
||||
id: &str,
|
||||
new_name: String,
|
||||
) -> Result<Folder, DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.folders
|
||||
SET name = $1, updated_at = NOW()
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(&new_name)
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.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(
|
||||
"Folder",
|
||||
format!("{new_name} already exists"),
|
||||
);
|
||||
}
|
||||
}
|
||||
DomainError::internal_error("FolderDb", format!("rename: {e}"))
|
||||
})?;
|
||||
|
||||
self.get_folder(id).await
|
||||
}
|
||||
|
||||
async fn move_folder(
|
||||
&self,
|
||||
id: &str,
|
||||
new_parent_id: Option<&str>,
|
||||
) -> Result<Folder, DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.folders
|
||||
SET parent_id = $1::uuid, updated_at = NOW()
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(new_parent_id)
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?;
|
||||
|
||||
self.get_folder(id).await
|
||||
}
|
||||
|
||||
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
|
||||
// Hard delete folder and all descendants (CASCADE handles children)
|
||||
let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid")
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("delete: {e}")))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("Folder", id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn folder_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError> {
|
||||
// Try to find by walking the path
|
||||
match self.get_folder_by_path(storage_path).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) if e.to_string().contains("not found") => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
||||
self.build_folder_path(id).await
|
||||
}
|
||||
|
||||
// ── Trash operations ──
|
||||
|
||||
async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> {
|
||||
// Soft-delete: set is_trashed = true and remember original parent
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.folders
|
||||
SET is_trashed = TRUE,
|
||||
trashed_at = NOW(),
|
||||
original_parent_id = parent_id,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(folder_id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("trash: {e}")))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("Folder", folder_id));
|
||||
}
|
||||
|
||||
// Also trash all files inside the folder (recursively)
|
||||
sqlx::query(
|
||||
r#"
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id FROM storage.folders WHERE id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id
|
||||
)
|
||||
UPDATE storage.files
|
||||
SET is_trashed = TRUE, trashed_at = NOW(), original_folder_id = folder_id
|
||||
WHERE folder_id IN (SELECT id FROM descendants) AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(folder_id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("trash files: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_from_trash(
|
||||
&self,
|
||||
folder_id: &str,
|
||||
_original_path: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
// Restore: set is_trashed = false, restore parent_id from original_parent_id
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.folders
|
||||
SET is_trashed = FALSE,
|
||||
trashed_at = NULL,
|
||||
parent_id = COALESCE(original_parent_id, parent_id),
|
||||
original_parent_id = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1::uuid AND is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(folder_id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("restore: {e}")))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("Folder", folder_id));
|
||||
}
|
||||
|
||||
// Also restore files that were trashed with this folder
|
||||
sqlx::query(
|
||||
r#"
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id FROM storage.folders WHERE id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id
|
||||
)
|
||||
UPDATE storage.files
|
||||
SET is_trashed = FALSE,
|
||||
trashed_at = NULL,
|
||||
folder_id = COALESCE(original_folder_id, folder_id),
|
||||
original_folder_id = NULL
|
||||
WHERE folder_id IN (SELECT id FROM descendants) AND is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(folder_id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("restore files: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError> {
|
||||
// Permanently delete — CASCADE handles children
|
||||
let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid")
|
||||
.bind(folder_id)
|
||||
.execute(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("perm delete: {e}")))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::not_found("Folder", folder_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extra helpers for blob-storage bootstrap ──
|
||||
|
||||
impl FolderDbRepository {
|
||||
/// Creates a root-level home folder for a user.
|
||||
/// This is called during user registration.
|
||||
pub async fn create_home_folder(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<Folder, DomainError> {
|
||||
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
||||
r#"
|
||||
INSERT INTO storage.folders (name, parent_id, user_id)
|
||||
VALUES ($1, NULL, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(user_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
|
||||
|
||||
match row {
|
||||
Some((id, ca, ma)) => {
|
||||
self.row_to_folder(id, name.to_string(), None, ca, ma).await
|
||||
}
|
||||
None => {
|
||||
// Already exists — fetch it
|
||||
let existing = sqlx::query_as::<_, (String, i64, i64)>(
|
||||
r#"
|
||||
SELECT id::text,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
WHERE name = $1 AND user_id = $2 AND parent_id IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(user_id)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FolderDb", format!("home fetch: {e}"))
|
||||
})?;
|
||||
self.row_to_folder(existing.0, name.to_string(), None, existing.1, existing.2)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns user_id for a given folder. Used by file repositories.
|
||||
pub async fn get_folder_user_id(&self, folder_id: &str) -> Result<String, DomainError> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT user_id FROM storage.folders WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(folder_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("user_id lookup: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", folder_id))
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@ mod settings_pg_repository;
|
||||
mod transaction_utils;
|
||||
mod user_pg_repository;
|
||||
|
||||
// ── Blob-storage repositories ──
|
||||
pub mod file_blob_read_repository;
|
||||
pub mod file_blob_write_repository;
|
||||
pub mod folder_db_repository;
|
||||
pub mod trash_db_repository;
|
||||
|
||||
pub use address_book_pg_repository::AddressBookPgRepository;
|
||||
pub use calendar_event_pg_repository::CalendarEventPgRepository;
|
||||
pub use calendar_pg_repository::CalendarPgRepository;
|
||||
@@ -18,7 +24,11 @@ pub use contact_group_pg_repository::ContactGroupPgRepository;
|
||||
pub use contact_persistence_dto::*;
|
||||
pub use contact_pg_repository::ContactPgRepository;
|
||||
pub use favorites_pg_repository::FavoritesPgRepository;
|
||||
pub use file_blob_read_repository::FileBlobReadRepository;
|
||||
pub use file_blob_write_repository::FileBlobWriteRepository;
|
||||
pub use folder_db_repository::FolderDbRepository;
|
||||
pub use recent_items_pg_repository::RecentItemsPgRepository;
|
||||
pub use session_pg_repository::SessionPgRepository;
|
||||
pub use settings_pg_repository::SettingsPgRepository;
|
||||
pub use trash_db_repository::TrashDbRepository;
|
||||
pub use user_pg_repository::UserPgRepository;
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user