perf: fix issues #1,#2,#3,#13 from performance audit
- #1 image_transcode: dedicated rayon pool + moka cache (no tokio blocking) - #2 ltree materialized paths: eliminate N+1 folder/file queries - #3 compute_content_hash: read blob_hash column instead of loading file into RAM - #13 event delegation + DocumentFragment: ~15 delegated listeners replace ~19k per-item listeners Backend: 82/82 tests pass, cargo check clean. Frontend: ui.js and app.js syntax-validated via Node.js.
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
//! Implements `FileReadPort` using:
|
||||
//! - `storage.files` table for metadata lookups
|
||||
//! - `DedupPort` for reading content-addressable blobs from the filesystem
|
||||
//!
|
||||
//! File paths are resolved by JOINing with `storage.folders.path` (the
|
||||
//! materialized path column), so no recursive CTEs or N+1 queries are needed.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
@@ -15,18 +18,14 @@ 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>,
|
||||
/// Lightweight cache: file_id → blob_hash.
|
||||
/// Populated by `get_file()`, consumed by `get_blob_hash()`.
|
||||
/// Populated by `get_file()`, consumed by `resolve_blob_hash()`.
|
||||
/// Avoids an extra SQL round-trip on the hot download path.
|
||||
hash_cache: std::sync::Mutex<HashMap<String, String>>,
|
||||
}
|
||||
@@ -35,42 +34,35 @@ impl FileBlobReadRepository {
|
||||
pub fn new(
|
||||
pool: Arc<PgPool>,
|
||||
dedup: Arc<dyn DedupPort>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
_folder_repo: Arc<super::folder_db_repository::FolderDbRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
dedup,
|
||||
folder_repo,
|
||||
hash_cache: std::sync::Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))
|
||||
/// Build a `StoragePath` from the materialized folder path + file name.
|
||||
fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath {
|
||||
match folder_path {
|
||||
Some(fp) if !fp.is_empty() => StoragePath::from_string(&format!("{fp}/{file_name}")),
|
||||
_ => StoragePath::from_string(file_name),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn row_to_file(
|
||||
&self,
|
||||
fn row_to_file(
|
||||
id: String,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
folder_path: 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?;
|
||||
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
|
||||
File::with_timestamps(
|
||||
id,
|
||||
name,
|
||||
@@ -84,9 +76,9 @@ impl FileBlobReadRepository {
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
|
||||
}
|
||||
|
||||
/// Get the blob hash for a file.
|
||||
/// Resolve the blob hash for a file (internal helper).
|
||||
/// Checks the in-memory cache first (populated by `get_file`).
|
||||
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
|
||||
async fn resolve_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
|
||||
// Fast path: already cached from a prior get_file call
|
||||
if let Some(hash) = self.hash_cache.lock().unwrap().remove(file_id) {
|
||||
return Ok(hash);
|
||||
@@ -109,23 +101,26 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
i64,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
String, // id
|
||||
String, // name
|
||||
Option<String>, // folder_id
|
||||
Option<String>, // folder path
|
||||
i64, // size
|
||||
String, // mime_type
|
||||
i64, // created_at
|
||||
i64, // updated_at
|
||||
String, // blob_hash
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
SELECT id::text, name, folder_id::text, size, mime_type,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
blob_hash
|
||||
FROM storage.files
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint,
|
||||
fi.blob_hash
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.id = $1::uuid AND NOT fi.is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -139,23 +134,24 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
self.hash_cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(id.to_string(), row.7.clone());
|
||||
.insert(id.to_string(), row.8.clone());
|
||||
|
||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
||||
.await
|
||||
Self::row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7)
|
||||
}
|
||||
|
||||
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError> {
|
||||
let rows: Vec<(String, String, Option<String>, i64, String, i64, i64)> =
|
||||
let rows: Vec<(String, String, Option<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
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed
|
||||
ORDER BY fi.name
|
||||
"#,
|
||||
)
|
||||
.bind(fid)
|
||||
@@ -164,12 +160,14 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
} 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
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.folder_id IS NULL AND NOT fi.is_trashed
|
||||
ORDER BY fi.name
|
||||
"#,
|
||||
)
|
||||
.fetch_all(self.pool.as_ref())
|
||||
@@ -177,38 +175,19 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
|
||||
|
||||
// ── N+1 fix: resolve the folder path ONCE (all rows share
|
||||
// the same folder_id when listing a specific folder). ──
|
||||
let shared_folder_path = if let Some(fid) = folder_id {
|
||||
Some(self.folder_repo.get_folder_path(fid).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
rows.into_iter()
|
||||
.map(|(id, name, fid, fpath, size, mime, ca, ma)| {
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, size, mime, ca, ma) in rows {
|
||||
let storage_path = match &shared_folder_path {
|
||||
Some(fp) => fp.join(&name),
|
||||
None => StoragePath::from_string(&name),
|
||||
};
|
||||
let file = File::with_timestamps(
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
size as u64,
|
||||
mime,
|
||||
fid,
|
||||
ca as u64,
|
||||
ma as u64,
|
||||
)
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))?;
|
||||
files.push(file);
|
||||
}
|
||||
Ok(files)
|
||||
async fn get_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
|
||||
self.resolve_blob_hash(file_id).await
|
||||
}
|
||||
|
||||
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
|
||||
let blob_hash = self.get_blob_hash(id).await?;
|
||||
let blob_hash = self.resolve_blob_hash(id).await?;
|
||||
self.dedup.read_blob(&blob_hash).await
|
||||
}
|
||||
|
||||
@@ -218,7 +197,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
// True streaming: reads the blob file in 64 KB chunks.
|
||||
// Memory usage is ~64 KB regardless of file size.
|
||||
let blob_hash = self.get_blob_hash(id).await?;
|
||||
let blob_hash = self.resolve_blob_hash(id).await?;
|
||||
let stream = self.dedup.read_blob_stream(&blob_hash).await?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
@@ -231,7 +210,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
||||
// True range streaming: seeks to `start` and reads only the requested range.
|
||||
// A 1 MB range on a 1 GB file uses ~64 KB of RAM.
|
||||
let blob_hash = self.get_blob_hash(id).await?;
|
||||
let blob_hash = self.resolve_blob_hash(id).await?;
|
||||
let stream = self
|
||||
.dedup
|
||||
.read_blob_range_stream(&blob_hash, start, end)
|
||||
@@ -242,16 +221,17 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError> {
|
||||
// For RPi targets, mmap is less beneficial than streaming.
|
||||
// Keep as a fallback that loads content for small/medium files.
|
||||
let blob_hash = self.get_blob_hash(id).await?;
|
||||
let blob_hash = self.resolve_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
|
||||
SELECT fi.name, fo.path
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.id = $1::uuid AND NOT fi.is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -260,11 +240,10 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
.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
|
||||
Ok(Self::make_file_path(row.1.as_deref(), &row.0))
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -272,49 +251,30 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
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 a path like "Home - user/Docs/file.txt", the parent folder path
|
||||
// is everything except the last segment: "Home - user/Docs"
|
||||
// We try the longest folder path first.
|
||||
let folder_path = segments[..segments.len() - 1].join("/");
|
||||
|
||||
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
|
||||
}
|
||||
if folder_path.is_empty() {
|
||||
return Err(DomainError::not_found(
|
||||
"Folder",
|
||||
format!("parent for path: {path}"),
|
||||
));
|
||||
}
|
||||
|
||||
current_parent
|
||||
.ok_or_else(|| DomainError::not_found("Folder", format!("parent for path: {path}")))
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT id::text FROM storage.folders WHERE path = $1 AND NOT is_trashed",
|
||||
)
|
||||
.bind(&folder_path)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("parent lookup: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", format!("parent for path: {path}")))
|
||||
}
|
||||
|
||||
/// Direct SQL lookup: split path into folder segments + filename,
|
||||
/// walk the folder hierarchy, then match the file by name + folder_id.
|
||||
/// O(depth) queries instead of O(total_files).
|
||||
/// Direct SQL lookup using materialized folder paths.
|
||||
/// O(1) query instead of O(depth) folder walk.
|
||||
async fn find_file_by_path(&self, path: &str) -> Result<Option<File>, DomainError> {
|
||||
let path = path.trim_start_matches('/').trim_end_matches('/');
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
@@ -323,62 +283,40 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Last segment is the filename, preceding segments are folders
|
||||
// Last segment is the filename, preceding segments are the folder path
|
||||
let filename = segments[segments.len() - 1];
|
||||
let folder_segments = &segments[..segments.len() - 1];
|
||||
let folder_path = segments[..segments.len() - 1].join("/");
|
||||
|
||||
// Walk folder hierarchy to find parent folder_id
|
||||
let mut current_parent: Option<String> = None;
|
||||
for segment in folder_segments {
|
||||
let row = if let Some(ref pid) = current_parent {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"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_scalar::<_, String>(
|
||||
"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(id) => current_parent = Some(id),
|
||||
None => return Ok(None), // Folder not found → file doesn't exist at this path
|
||||
}
|
||||
}
|
||||
|
||||
// Now find the file by name + folder_id
|
||||
let row = if let Some(ref fid) = current_parent {
|
||||
sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
||||
let row = if folder_path.is_empty() {
|
||||
// File at root level (no parent folder)
|
||||
sqlx::query_as::<_, (String, String, Option<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 name = $1 AND folder_id = $2::uuid AND NOT is_trashed
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint
|
||||
FROM storage.files fi
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fi.name = $1 AND fi.folder_id IS NULL AND NOT fi.is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(filename)
|
||||
.bind(fid)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<_, (String, String, Option<String>, i64, String, i64, i64)>(
|
||||
// File inside a folder — look up by folder path + filename
|
||||
sqlx::query_as::<_, (String, String, Option<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 name = $1 AND folder_id IS NULL AND NOT is_trashed
|
||||
SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path,
|
||||
fi.size, fi.mime_type,
|
||||
EXTRACT(EPOCH FROM fi.created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM fi.updated_at)::bigint
|
||||
FROM storage.files fi
|
||||
JOIN storage.folders fo ON fo.id = fi.folder_id
|
||||
WHERE fo.path = $1 AND fi.name = $2 AND NOT fi.is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(&folder_path)
|
||||
.bind(filename)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
@@ -386,9 +324,9 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("find file: {e}")))?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(
|
||||
self.row_to_file(r.0, r.1, r.2, r.3, r.4, r.5, r.6).await?,
|
||||
)),
|
||||
Some(r) => Ok(Some(Self::row_to_file(
|
||||
r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7,
|
||||
)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
//! Implements `FileWritePort` using:
|
||||
//! - `storage.files` table for metadata
|
||||
//! - `DedupPort` for content-addressable blob storage on the filesystem
|
||||
//!
|
||||
//! File paths are resolved by querying the materialized `storage.folders.path`
|
||||
//! column (O(1) per lookup), so no recursive CTEs are needed.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
@@ -13,7 +16,6 @@ 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;
|
||||
@@ -38,32 +40,49 @@ impl FileBlobWriteRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a virtual StoragePath for a file from its DB metadata.
|
||||
async fn build_file_path(
|
||||
/// Build a `StoragePath` from the materialized folder path + file name.
|
||||
fn make_file_path(folder_path: Option<&str>, file_name: &str) -> StoragePath {
|
||||
match folder_path {
|
||||
Some(fp) if !fp.is_empty() => StoragePath::from_string(&format!("{fp}/{file_name}")),
|
||||
_ => StoragePath::from_string(file_name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the materialized folder path. O(1) — no recursive CTE.
|
||||
async fn lookup_folder_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))
|
||||
) -> Result<Option<String>, DomainError> {
|
||||
match folder_id {
|
||||
Some(fid) => {
|
||||
let path: String = sqlx::query_scalar(
|
||||
"SELECT path FROM storage.folders WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(fid)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobWrite", format!("folder path: {e}"))
|
||||
})?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", fid))?;
|
||||
Ok(Some(path))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn row_to_file(
|
||||
&self,
|
||||
fn row_to_file(
|
||||
id: String,
|
||||
name: String,
|
||||
folder_id: Option<String>,
|
||||
folder_path: 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?;
|
||||
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
|
||||
File::with_timestamps(
|
||||
id,
|
||||
name,
|
||||
@@ -159,8 +178,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
&blob_hash[..12]
|
||||
);
|
||||
|
||||
self.row_to_file(row.0, name, folder_id, size, content_type, row.1, row.2)
|
||||
.await
|
||||
let folder_path = self.lookup_folder_path(folder_id.as_deref()).await?;
|
||||
Self::row_to_file(row.0, name, folder_id, folder_path, size, content_type, row.1, row.2)
|
||||
}
|
||||
|
||||
async fn save_file_from_temp(
|
||||
@@ -232,16 +251,17 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
&blob_hash[..12]
|
||||
);
|
||||
|
||||
self.row_to_file(
|
||||
let folder_path = self.lookup_folder_path(folder_id.as_deref()).await?;
|
||||
Self::row_to_file(
|
||||
row.0,
|
||||
name,
|
||||
folder_id,
|
||||
folder_path,
|
||||
size as i64,
|
||||
content_type,
|
||||
row.1,
|
||||
row.2,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn move_file(
|
||||
@@ -267,8 +287,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
.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
|
||||
let folder_path = self.lookup_folder_path(row.2.as_deref()).await?;
|
||||
Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6)
|
||||
}
|
||||
|
||||
async fn copy_file(
|
||||
@@ -350,8 +370,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
&blob_hash[..12]
|
||||
);
|
||||
|
||||
self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6)
|
||||
.await
|
||||
let folder_path = self.lookup_folder_path(row.2.as_deref()).await?;
|
||||
Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6)
|
||||
}
|
||||
|
||||
async fn rename_file(&self, file_id: &str, new_name: &str) -> Result<File, DomainError> {
|
||||
@@ -379,8 +399,8 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
})?
|
||||
.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
|
||||
let folder_path = self.lookup_folder_path(row.2.as_deref()).await?;
|
||||
Self::row_to_file(row.0, row.1, row.2, folder_path, row.3, row.4, row.5, row.6)
|
||||
}
|
||||
|
||||
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
|
||||
@@ -502,17 +522,17 @@ impl FileWritePort for FileBlobWriteRepository {
|
||||
.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?;
|
||||
let folder_path = self.lookup_folder_path(folder_id.as_deref()).await?;
|
||||
let file = Self::row_to_file(
|
||||
row.0.clone(),
|
||||
name,
|
||||
folder_id,
|
||||
folder_path,
|
||||
size as i64,
|
||||
content_type,
|
||||
row.1,
|
||||
row.2,
|
||||
)?;
|
||||
|
||||
// The target_path is not meaningful for blob storage (content goes to .blobs/)
|
||||
// but the WriteBehindCache API requires it. We return a synthetic path.
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
//! Implements `FolderRepository` (and thus `FolderStoragePort`) using the
|
||||
//! `storage.folders` table. Folders are purely virtual — no physical
|
||||
//! directories are created on the filesystem.
|
||||
//!
|
||||
//! Folder paths are **materialized** in a `path TEXT` column maintained by
|
||||
//! database triggers, so reading a folder's full path is always O(1) — no
|
||||
//! recursive CTEs or N+1 queries.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
@@ -41,43 +45,20 @@ impl FolderDbRepository {
|
||||
|
||||
// ── 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> {
|
||||
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,
|
||||
///
|
||||
/// The `path` comes directly from the materialized `path` column — no
|
||||
/// extra queries needed.
|
||||
fn row_to_folder(
|
||||
id: String,
|
||||
name: String,
|
||||
path: String,
|
||||
parent_id: Option<String>,
|
||||
user_id: Option<String>,
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
) -> Result<Folder, DomainError> {
|
||||
let storage_path = self.build_folder_path(&id).await?;
|
||||
let storage_path = StoragePath::from_string(&path);
|
||||
Folder::with_timestamps_and_owner(
|
||||
id,
|
||||
name,
|
||||
@@ -117,11 +98,12 @@ impl FolderRepository for FolderDbRepository {
|
||||
));
|
||||
};
|
||||
|
||||
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
||||
let row = sqlx::query_as::<_, (String, String, i64, i64)>(
|
||||
r#"
|
||||
INSERT INTO storage.folders (name, parent_id, user_id)
|
||||
VALUES ($1, $2::uuid, $3)
|
||||
RETURNING id::text,
|
||||
path,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
@@ -143,14 +125,13 @@ impl FolderRepository for FolderDbRepository {
|
||||
DomainError::internal_error("FolderDb", format!("insert: {e}"))
|
||||
})?;
|
||||
|
||||
self.row_to_folder(row.0, name, parent_id, Some(user_id), row.1, row.2)
|
||||
.await
|
||||
Self::row_to_folder(row.0, name, row.1, parent_id, Some(user_id), row.2, row.3)
|
||||
}
|
||||
|
||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
||||
let row = sqlx::query_as::<_, (String, String, Option<String>, String, i64, i64)>(
|
||||
let row = sqlx::query_as::<_, (String, String, String, Option<String>, String, i64, i64)>(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -163,61 +144,41 @@ impl FolderRepository for FolderDbRepository {
|
||||
.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, Some(row.3), row.4, row.5).await
|
||||
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
|
||||
}
|
||||
|
||||
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();
|
||||
// Strip leading '/' if present — DB stores "Home - user/Docs", not "/Home - user/Docs"
|
||||
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
|
||||
|
||||
if segments.is_empty() {
|
||||
if lookup.is_empty() {
|
||||
return Err(DomainError::not_found("Folder", "empty path"));
|
||||
}
|
||||
|
||||
let mut current_parent: Option<String> = None;
|
||||
let mut current_id = String::new();
|
||||
let row = sqlx::query_as::<_, (String, String, String, Option<String>, String, i64, i64)>(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
WHERE path = $1 AND NOT is_trashed
|
||||
"#,
|
||||
)
|
||||
.bind(lookup)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", lookup))?;
|
||||
|
||||
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
|
||||
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
|
||||
}
|
||||
|
||||
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError> {
|
||||
let rows: Vec<(String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -231,7 +192,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -244,11 +205,11 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
|
||||
|
||||
let mut folders = Vec::with_capacity(rows.len());
|
||||
for (id, name, pid, uid, ca, ma) in rows {
|
||||
folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?);
|
||||
}
|
||||
Ok(folders)
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner(
|
||||
@@ -256,12 +217,10 @@ impl FolderRepository for FolderDbRepository {
|
||||
parent_id: Option<&str>,
|
||||
owner_id: &str,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
let rows: Vec<(String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
// For sub-folders the owner is implicit (parent belongs to user),
|
||||
// but we still filter to be safe.
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -274,10 +233,9 @@ impl FolderRepository for FolderDbRepository {
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
} else {
|
||||
// Root-level: only this user's home folders
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -291,11 +249,11 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
|
||||
|
||||
let mut folders = Vec::with_capacity(rows.len());
|
||||
for (id, name, pid, uid, ca, ma) in rows {
|
||||
folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?);
|
||||
}
|
||||
Ok(folders)
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn list_folders_paginated(
|
||||
@@ -326,10 +284,10 @@ impl FolderRepository for FolderDbRepository {
|
||||
None
|
||||
};
|
||||
|
||||
let rows: Vec<(String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -346,7 +304,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -362,11 +320,13 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?;
|
||||
|
||||
let mut folders = Vec::with_capacity(rows.len());
|
||||
for (id, name, pid, uid, ca, ma) in rows {
|
||||
folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?);
|
||||
}
|
||||
Ok((folders, total))
|
||||
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
||||
})
|
||||
.collect();
|
||||
Ok((folders?, total))
|
||||
}
|
||||
|
||||
async fn list_folders_by_owner_paginated(
|
||||
@@ -400,10 +360,10 @@ impl FolderRepository for FolderDbRepository {
|
||||
None
|
||||
};
|
||||
|
||||
let rows: Vec<(String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
let rows: Vec<(String, String, String, Option<String>, String, i64, i64)> = if let Some(pid) = parent_id {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -421,7 +381,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id::text, name, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -438,14 +398,18 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
|
||||
|
||||
let mut folders = Vec::with_capacity(rows.len());
|
||||
for (id, name, pid, uid, ca, ma) in rows {
|
||||
folders.push(self.row_to_folder(id, name, pid, Some(uid), ca, ma).await?);
|
||||
}
|
||||
Ok((folders, total))
|
||||
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, uid, ca, ma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
||||
})
|
||||
.collect();
|
||||
Ok((folders?, total))
|
||||
}
|
||||
|
||||
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError> {
|
||||
// The BEFORE UPDATE trigger on `name` will recompute path/lpath
|
||||
// and cascade to descendants automatically.
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.folders
|
||||
@@ -474,6 +438,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
id: &str,
|
||||
new_parent_id: Option<&str>,
|
||||
) -> Result<Folder, DomainError> {
|
||||
// The BEFORE UPDATE trigger on `parent_id` will recompute path/lpath
|
||||
// and cascade to descendants automatically.
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE storage.folders
|
||||
@@ -505,16 +471,31 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
let path_str = storage_path.to_string();
|
||||
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
|
||||
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM storage.folders WHERE path = $1 AND NOT is_trashed)",
|
||||
)
|
||||
.bind(lookup)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("exists: {e}")))?;
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
||||
self.build_folder_path(id).await
|
||||
let path: String = sqlx::query_scalar(
|
||||
"SELECT path FROM storage.folders WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("get_path: {e}")))?
|
||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||
|
||||
Ok(StoragePath::from_string(&path))
|
||||
}
|
||||
|
||||
// ── Trash operations ──
|
||||
@@ -566,6 +547,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
_original_path: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
// Atomic CTE: restore folder + all descendant files in a single statement.
|
||||
// The BEFORE UPDATE trigger on parent_id will recompute path/lpath
|
||||
// automatically when original_parent_id is restored.
|
||||
let result = sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
WITH restore_folder AS (
|
||||
@@ -632,12 +615,13 @@ impl FolderDbRepository {
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<Folder, DomainError> {
|
||||
let row = sqlx::query_as::<_, (String, i64, i64)>(
|
||||
let row = sqlx::query_as::<_, (String, String, i64, i64)>(
|
||||
r#"
|
||||
INSERT INTO storage.folders (name, parent_id, user_id)
|
||||
VALUES ($1, NULL, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id::text,
|
||||
path,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
"#,
|
||||
@@ -649,12 +633,13 @@ impl FolderDbRepository {
|
||||
.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, Some(user_id.to_string()), ca, ma).await,
|
||||
Some((id, path, ca, ma)) => Self::row_to_folder(id, name.to_string(), path, None, Some(user_id.to_string()), ca, ma),
|
||||
None => {
|
||||
// Already exists — fetch it
|
||||
let existing = sqlx::query_as::<_, (String, i64, i64)>(
|
||||
let existing = sqlx::query_as::<_, (String, String, i64, i64)>(
|
||||
r#"
|
||||
SELECT id::text,
|
||||
path,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||
FROM storage.folders
|
||||
@@ -666,8 +651,7 @@ impl FolderDbRepository {
|
||||
.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, Some(user_id.to_string()), existing.1, existing.2)
|
||||
.await
|
||||
Self::row_to_folder(existing.0, name.to_string(), existing.1, None, Some(user_id.to_string()), existing.2, existing.3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user