Merge pull request #426 from EdouardVanbelle/refactor/etag-centralize

refactor & normalize etag for Nextcloud + fix NFC string (important fix)
This commit is contained in:
Dionisio Pozo
2026-06-07 01:26:30 +02:00
committed by GitHub
67 changed files with 2238 additions and 242 deletions
@@ -310,6 +310,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
NULL::text AS blob_hash,
(fld.user_id = $1::uuid) AS is_owner,
uf.created_at AS favorited_at,
fld.path::text AS resource_path,
@@ -332,6 +333,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.blob_hash,
(f.user_id = $1::uuid) AS is_owner,
uf.created_at AS favorited_at,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
@@ -574,6 +576,7 @@ LIMIT $6"
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
blob_hash: row.try_get("blob_hash").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false),
favorited_at: row.get("favorited_at"),
path: row.try_get("resource_path").ok(),
@@ -131,11 +131,11 @@ impl FileBlobReadRepository {
mime_type: String,
created_at: i64,
modified_at: i64,
etag: String,
blob_hash: String,
owner_id: Option<Uuid>,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps_and_etag(
File::with_timestamps_and_blob_hash(
id,
name,
storage_path,
@@ -145,7 +145,7 @@ impl FileBlobReadRepository {
created_at as u64,
modified_at as u64,
owner_id,
etag,
blob_hash,
)
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}")))
}
@@ -226,9 +226,9 @@ impl FileBlobReadRepository {
let mut files = Vec::with_capacity(rows.len());
let mut sort_dates = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, etag, uid, sd) in rows {
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd) in rows {
files.push(Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, etag, uid,
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid,
)?);
sort_dates.push(sd);
}
@@ -410,9 +410,11 @@ impl FileReadPort for FileBlobReadRepository {
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?;
rows.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
})
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect()
}
@@ -466,9 +468,11 @@ impl FileReadPort for FileBlobReadRepository {
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_for_owner: {e}")))?;
rows.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
})
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect()
}
@@ -532,9 +536,11 @@ impl FileReadPort for FileBlobReadRepository {
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("list_batch: {e}")))?;
rows.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
})
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect()
}
@@ -598,9 +604,11 @@ impl FileReadPort for FileBlobReadRepository {
})?;
rows.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
})
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect()
}
@@ -699,8 +707,14 @@ impl FileReadPort for FileBlobReadRepository {
return Ok(None);
}
// Last segment is the filename, preceding segments are the folder path
let filename = segments[segments.len() - 1];
// Last segment is the filename, preceding segments are the
// folder path. NFC-normalize the filename so a NextCloud
// client's NFD-encoded path still hits the NFC row stored
// by a web upload — see `normalize_storage_name` for the
// full rationale.
let filename = crate::domain::services::path_service::normalize_storage_name(
segments[segments.len() - 1],
);
let folder_path = segments[..segments.len() - 1].join("/");
let row = if folder_path.is_empty() {
@@ -815,9 +829,9 @@ impl FileReadPort for FileBlobReadRepository {
while let Some(row) = row_stream.try_next().await.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("subtree stream: {e}"))
})? {
let (id, name, fid, fpath, size, mime, ca, ma, etag, uid) = row;
let (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) = row;
let file = FileBlobReadRepository::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, etag, uid,
id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid,
)?;
yield file;
}
@@ -930,8 +944,8 @@ impl FileReadPort for FileBlobReadRepository {
let files = rows
.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, etag, uid, _total)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect::<Result<Vec<_>, _>>()
@@ -1116,8 +1130,8 @@ impl FileReadPort for FileBlobReadRepository {
let files = rows
.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, etag, uid, _total)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, _total)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect::<Result<Vec<_>, _>>()
@@ -1212,9 +1226,11 @@ impl FileReadPort for FileBlobReadRepository {
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("suggest: {e}")))?;
rows.into_iter()
.map(|(id, name, fid, fpath, size, mime, ca, ma, etag, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, etag, uid)
})
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect()
}
}
@@ -101,10 +101,10 @@ impl FileBlobWriteRepository {
created_at: i64,
modified_at: i64,
owner_id: Option<Uuid>,
etag: String,
blob_hash: String,
) -> Result<File, DomainError> {
let storage_path = Self::make_file_path(folder_path.as_deref(), &name);
File::with_timestamps_and_etag(
File::with_timestamps_and_blob_hash(
id,
name,
storage_path,
@@ -114,7 +114,7 @@ impl FileBlobWriteRepository {
created_at as u64,
modified_at as u64,
owner_id,
etag,
blob_hash,
)
.map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}")))
}
@@ -20,10 +20,25 @@ use crate::domain::services::authorization::ResourceKind;
use crate::domain::services::path_service::StoragePath;
/// Type alias for folder metadata rows from SQL queries.
type FolderRow = (String, String, String, Option<String>, Uuid, i64, i64);
/// Tuple order: id, name, path, parent_id, user_id, created_at,
/// modified_at, tree_modified_at. The trailing `tree_modified_at`
/// feeds [`Folder::etag`] — every SELECT here must include
/// `EXTRACT(EPOCH FROM tree_modified_at)::bigint`.
type FolderRow = (String, String, String, Option<String>, Uuid, i64, i64, i64);
/// Type alias for paginated folder rows (includes total_count).
type FolderRowPaginated = (String, String, String, Option<String>, Uuid, i64, i64, i64);
/// Type alias for paginated folder rows (includes total_count as
/// the last element after `tree_modified_at`).
type FolderRowPaginated = (
String,
String,
String,
Option<String>,
Uuid,
i64,
i64,
i64,
i64,
);
/// Type alias for folder rows with optional user_id.
type FolderRowOptUser = (
@@ -34,6 +49,7 @@ type FolderRowOptUser = (
Option<Uuid>,
i64,
i64,
i64,
);
/// PostgreSQL-backed folder repository.
@@ -68,6 +84,7 @@ impl FolderDbRepository {
///
/// The `path` comes directly from the materialized `path` column — no
/// extra queries needed.
#[allow(clippy::too_many_arguments)]
fn row_to_folder(
id: String,
name: String,
@@ -76,9 +93,10 @@ impl FolderDbRepository {
user_id: Option<Uuid>,
created_at: i64,
modified_at: i64,
tree_modified_at: i64,
) -> Result<Folder, DomainError> {
let storage_path = StoragePath::from_string(&path);
Folder::with_timestamps_and_owner(
Folder::with_timestamps_and_tree(
id,
name,
storage_path,
@@ -86,6 +104,7 @@ impl FolderDbRepository {
user_id,
created_at as u64,
modified_at as u64,
tree_modified_at as u64,
)
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
}
@@ -116,14 +135,15 @@ impl FolderRepository for FolderDbRepository {
));
};
let row = sqlx::query_as::<_, (String, String, i64, i64)>(
let row = sqlx::query_as::<_, (String, String, i64, 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
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
"#,
)
.bind(&name)
@@ -143,15 +163,25 @@ impl FolderRepository for FolderDbRepository {
DomainError::internal_error("FolderDb", format!("insert: {e}"))
})?;
Self::row_to_folder(row.0, name, row.1, parent_id, Some(user_id), row.2, row.3)
Self::row_to_folder(
row.0,
name,
row.1,
parent_id,
Some(user_id),
row.2,
row.3,
row.4,
)
}
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
let row = sqlx::query_as::<_, (String, String, String, Option<String>, Uuid, i64, i64)>(
let row = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE id = $1::uuid AND NOT is_trashed
"#,
@@ -162,7 +192,7 @@ 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, row.3, Some(row.4), row.5, row.6)
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
}
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
@@ -174,11 +204,12 @@ impl FolderRepository for FolderDbRepository {
return Err(DomainError::not_found("Folder", "empty path"));
}
let row = sqlx::query_as::<_, (String, String, String, Option<String>, Uuid, i64, i64)>(
let row = sqlx::query_as::<_, FolderRow>(
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE path = $1 AND NOT is_trashed
"#,
@@ -189,7 +220,7 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", lookup))?;
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
}
#[allow(clippy::type_complexity)]
@@ -199,7 +230,8 @@ impl FolderRepository for FolderDbRepository {
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE parent_id = $1::uuid AND NOT is_trashed
ORDER BY name
@@ -213,7 +245,8 @@ impl FolderRepository for FolderDbRepository {
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE parent_id IS NULL AND NOT is_trashed
ORDER BY name
@@ -225,8 +258,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
})
.collect()
}
@@ -242,7 +275,8 @@ impl FolderRepository for FolderDbRepository {
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
ORDER BY name
@@ -257,7 +291,8 @@ impl FolderRepository for FolderDbRepository {
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
ORDER BY name
@@ -270,8 +305,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
})
.collect()
}
@@ -293,6 +328,7 @@ impl FolderRepository for FolderDbRepository {
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id = $1::uuid AND NOT is_trashed
@@ -311,6 +347,7 @@ impl FolderRepository for FolderDbRepository {
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id IS NULL AND NOT is_trashed
@@ -327,15 +364,15 @@ impl FolderRepository for FolderDbRepository {
// total_count is identical in every row; 0 when the result set is empty.
let total = if include_total {
Some(rows.first().map_or(0, |r| r.7) as usize)
Some(rows.first().map_or(0, |r| r.8) as usize)
} else {
None
};
let folders: Result<Vec<Folder>, DomainError> = rows
.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
})
.collect();
Ok((folders?, total))
@@ -358,6 +395,7 @@ impl FolderRepository for FolderDbRepository {
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
@@ -377,6 +415,7 @@ impl FolderRepository for FolderDbRepository {
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
COUNT(*) OVER() AS total_count
FROM storage.folders
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
@@ -393,15 +432,15 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
let total = if include_total {
Some(rows.first().map_or(0, |r| r.7) as usize)
Some(rows.first().map_or(0, |r| r.8) as usize)
} else {
None
};
let folders: Result<Vec<Folder>, DomainError> = rows
.into_iter()
.map(|(id, name, path, pid, uid, ca, ma, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma, _total)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
})
.collect();
Ok((folders?, total))
@@ -411,14 +450,15 @@ impl FolderRepository for FolderDbRepository {
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
// the AFTER UPDATE cascade trigger then batch-updates all
// descendants in a single UPDATE using the GiST lpath index.
let row = sqlx::query_as::<_, (String, String, String, Option<String>, Uuid, i64, i64)>(
let row = sqlx::query_as::<_, FolderRow>(
r#"
UPDATE storage.folders
SET name = $1, updated_at = NOW()
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
"#,
)
.bind(&new_name)
@@ -435,7 +475,7 @@ impl FolderRepository for FolderDbRepository {
})?
.ok_or_else(|| DomainError::not_found("Folder", id))?;
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
}
async fn move_folder(
@@ -446,14 +486,15 @@ impl FolderRepository for FolderDbRepository {
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
// the AFTER UPDATE cascade trigger then batch-updates all
// descendants in a single UPDATE using the GiST lpath index.
let row = sqlx::query_as::<_, (String, String, String, Option<String>, Uuid, i64, i64)>(
let row = sqlx::query_as::<_, FolderRow>(
r#"
UPDATE storage.folders
SET parent_id = $1::uuid, updated_at = NOW()
WHERE id = $2::uuid AND NOT is_trashed
RETURNING id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
"#,
)
.bind(new_parent_id)
@@ -463,7 +504,7 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?
.ok_or_else(|| DomainError::not_found("Folder", id))?;
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
}
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
@@ -622,7 +663,7 @@ impl FolderRepository for FolderDbRepository {
}
async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result<Folder, DomainError> {
let row = sqlx::query_as::<_, (String, String, i64, i64)>(
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
r#"
INSERT INTO storage.folders (name, parent_id, user_id)
VALUES ($1, NULL, $2)
@@ -630,7 +671,8 @@ impl FolderRepository for FolderDbRepository {
RETURNING id::text,
path,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
"#,
)
.bind(&name)
@@ -640,17 +682,18 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
match row {
Some((id, path, ca, ma)) => {
Self::row_to_folder(id, name.clone(), path, None, Some(user_id), ca, ma)
Some((id, path, ca, ma, tma)) => {
Self::row_to_folder(id, name.clone(), path, None, Some(user_id), ca, ma, tma)
}
None => {
// Already exists — fetch it
let existing = sqlx::query_as::<_, (String, String, i64, i64)>(
let existing = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
r#"
SELECT id::text,
path,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE name = $1 AND user_id = $2 AND parent_id IS NULL
"#,
@@ -668,6 +711,7 @@ impl FolderRepository for FolderDbRepository {
Some(user_id),
existing.2,
existing.3,
existing.4,
)
}
}
@@ -682,7 +726,8 @@ impl FolderRepository for FolderDbRepository {
let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
FROM storage.folders fo \
WHERE fo.is_trashed = false \
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
@@ -697,8 +742,8 @@ impl FolderRepository for FolderDbRepository {
})?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
})
.collect()
}
@@ -744,7 +789,8 @@ impl FolderRepository for FolderDbRepository {
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
FROM storage.folders fo \
WHERE fo.user_id = $1 \
AND fo.is_trashed = false \
@@ -768,8 +814,8 @@ impl FolderRepository for FolderDbRepository {
return rows
.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
})
.collect();
}
@@ -780,7 +826,8 @@ impl FolderRepository for FolderDbRepository {
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
FROM storage.folders fo \
WHERE fo.parent_id = $1::uuid \
AND fo.user_id = $2 \
@@ -798,7 +845,8 @@ impl FolderRepository for FolderDbRepository {
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
FROM storage.folders fo \
WHERE fo.parent_id IS NULL \
AND fo.user_id = $1 \
@@ -838,8 +886,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
})
.collect()
}
@@ -866,7 +914,8 @@ impl FolderRepository for FolderDbRepository {
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
fo.user_id, \
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
EXTRACT(EPOCH FROM fo.updated_at)::bigint \
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
FROM storage.folders fo \
WHERE fo.user_id = $1 \
AND fo.is_trashed = false \
@@ -893,8 +942,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
})
.collect()
}
@@ -914,7 +963,8 @@ impl FolderRepository for FolderDbRepository {
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE parent_id = $1::uuid
AND NOT is_trashed
@@ -939,7 +989,8 @@ impl FolderRepository for FolderDbRepository {
r#"
SELECT id::text, name, path, parent_id::text, user_id,
EXTRACT(EPOCH FROM created_at)::bigint,
EXTRACT(EPOCH FROM updated_at)::bigint
EXTRACT(EPOCH FROM updated_at)::bigint,
EXTRACT(EPOCH FROM tree_modified_at)::bigint
FROM storage.folders
WHERE parent_id IS NULL
AND NOT is_trashed
@@ -962,8 +1013,8 @@ impl FolderRepository for FolderDbRepository {
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
rows.into_iter()
.map(|(id, name, path, pid, uid, ca, ma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
})
.collect()
}
@@ -1100,6 +1151,7 @@ impl FolderDbRepository {
f.created_at,
f.updated_at AS modified_at,
f.user_id,
NULL::text AS blob_hash,
LOWER(f.name) AS sort_str,
0::bigint AS type_order,
0::int AS folder_first
@@ -1118,6 +1170,7 @@ impl FolderDbRepository {
fm.created_at,
fm.updated_at AS modified_at,
fm.user_id,
fm.blob_hash,
LOWER(fm.name) AS sort_str,
fm.category_order::bigint AS type_order,
1::int AS folder_first
@@ -1241,7 +1294,7 @@ impl FolderDbRepository {
let sql = format!(
"WITH resources AS ({cte_inner}) \
SELECT resource_type, id, name, folder_id, mime_type, size, \
created_at, modified_at, user_id, sort_str, type_order, folder_first \
created_at, modified_at, user_id, blob_hash, sort_str, type_order, folder_first \
FROM resources \
{where_clause} \
{order_clause} \
@@ -1249,7 +1302,8 @@ impl FolderDbRepository {
);
// Row: (resource_type, id, name, folder_id, mime_type, size,
// created_at, modified_at, user_id, sort_str, type_order, folder_first)
// created_at, modified_at, user_id, blob_hash,
// sort_str, type_order, folder_first)
type Row = (
String,
Uuid,
@@ -1260,6 +1314,7 @@ impl FolderDbRepository {
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
Uuid,
Option<String>,
String,
i64,
i32,
@@ -1290,9 +1345,10 @@ impl FolderDbRepository {
created_at: r.6,
modified_at: r.7,
owner_id: r.8,
sort_str: r.9,
type_order: r.10,
folder_first: r.11,
blob_hash: r.9,
sort_str: r.10,
type_order: r.11,
folder_first: r.12,
})
.collect())
}
@@ -217,6 +217,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
NULL::text AS blob_hash,
(fld.user_id = $1::uuid) AS is_owner,
ur.accessed_at AS accessed_at,
fld.path::text AS resource_path,
@@ -239,6 +240,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.blob_hash,
(f.user_id = $1::uuid) AS is_owner,
ur.accessed_at AS accessed_at,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
@@ -483,6 +485,7 @@ LIMIT $6"
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
blob_hash: row.try_get("blob_hash").ok(),
is_owner: row.try_get("is_owner").unwrap_or(false),
accessed_at: row.get("accessed_at"),
path: row.try_get("resource_path").ok(),
@@ -261,6 +261,7 @@ impl TrashDbRepository {
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
NULL::text AS blob_hash,
fld.trashed_at AS trashed_at,
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
fld.path::text AS resource_path,
@@ -286,6 +287,7 @@ impl TrashDbRepository {
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.blob_hash,
f.trashed_at AS trashed_at,
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
@@ -473,6 +475,7 @@ LIMIT $6"
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
blob_hash: row.try_get("blob_hash").ok(),
trashed_at,
deletion_date,
path: row.try_get("resource_path").ok(),
@@ -145,6 +145,7 @@ impl PathResolverService {
match resource_type.as_str() {
"folder" => Ok(ResolvedResource::Folder(FolderDto {
etag: id.clone(),
id,
name: name.clone(),
path: res_path,
@@ -160,6 +161,11 @@ impl PathResolverService {
_ => {
let mime = mime_type.unwrap_or_else(|| "application/octet-stream".to_string());
let sz = size.unwrap_or(0) as u64;
// `content_hash`/`etag` are empty here: this resolver
// path doesn't select `blob_hash` from SQL — callers
// are doing existence/type discrimination, not ETag
// emission. If a caller ever needs an ETag from this
// codepath, widen the SELECT and populate properly.
Ok(ResolvedResource::File(FileDto {
id,
name: name.clone(),
@@ -175,6 +181,7 @@ impl PathResolverService {
size_formatted: format_file_size(sz),
owner_id: uid,
sort_date: None,
content_hash: String::new(),
etag: String::new(),
}))
}