Files
Oxicloud/src/domain/repositories/folder_repository.rs
T
Claude cd4c62042a perf: keyset/LATERAL SQL shapes, auth+blob-cache single-flight, spool buffers, DTO interning
Round 3 of benchmark-gated optimizations (benches/ROUND3.md; every change
gated by a before/after benchmark — an AFTER that did not beat its BEFORE
was to be rolled back; none needed it. Equivalence gates assert identical
row sequences / byte-identical output on every behavior-preserving rewrite):

DB hot paths (local PG16, EXPLAIN-verified):
- Web-UI listing (list_resources_paged): cursor pushed INSIDE the
  folders/files UNION-ALL branches as sargable row-value comparisons with
  per-branch ORDER/LIMIT + two partial expression indexes
  (folder_id, LOWER(name), id). 20k-entry folder: 26.6 -> 1.3 ms/page
  (19.5x); other sort modes at parity or better. New migration
  20260918000000. [benches/LISTING-KEYSET.md section in ROUND3]
- Photos timeline (list_media_files): per-drive CROSS JOIN LATERAL top-N
  on the timeline index, joins moved above the top-N. 50k-photo library:
  97.4 -> 1.6 ms/page (55.7x). The old "LIMIT stops the scan early"
  comment was refuted by EXPLAIN.
- PROPFIND sub-folders (both DAV surfaces): keyset list_folders_batch off
  idx_folders_unique_name replaces COUNT(*) OVER() + LIMIT/OFFSET
  (5k dirs: 79.7 -> 17.9 ms full walk, 4.5x).

Concurrency:
- Basic-auth cache single-flight (moka try_get_with): 8 concurrent DAV
  connections at TTL expiry paid 8 Argon2id runs (2.6 s CPU + 8x64 MiB);
  now 1 (300 ms). Failed verifications remain uncached.
- CachedBlobBackend per-hash single-flight + unique tmp names: 16
  concurrent cold readers = 16 full remote downloads racing truncating
  writes on ONE deterministic .tmp (corruptible cache); now 1 download
  (16x less egress, 2.8x wall on a shared link) and torn files can never
  be renamed into the cache.

I/O and allocations:
- Chunk-assembly reads 64K -> 512K buffers (2.3x, 8x fewer syscalls);
  chunk-spool writes via BufWriter 512K (5.6x, 32x fewer syscalls).
- S3/Azure put_blob_from_bytes_unsynced overrides: dedup settle no longer
  pays a HEAD probe per new chunk (2 RTT -> 1, 1.8x); Azure stops copying
  every chunk (Bytes -> Body, -0.44 ms - 4 MiB alloc per 4 MiB chunk).
- Entity->DTO mapping: Arc<str> interning of closed-set display fields +
  common MIMEs, 1-alloc etag/size formatting, FolderDto moves instead of
  clones. File row: 11 -> 4 allocs; folder row: 11.8 -> 1 (2.1x faster).
- CardDAV REPORT: deleted dead per-contact vCard pre-generation and the
  O(N^2) uid scan whose result was discarded (5k contacts: 55.7 -> 5.7 ms,
  9.8x); byte-identical XML asserted.
- Search-results cache: byte weigher + 32 MiB budget
  (OXICLOUD_SEARCH_CACHE_MAX_BYTES) replaces the 1000-ENTRY cap that let
  ~300 MiB of enriched rows sit in RSS; read latency parity.
- Dropped aws-config + aws-smithy-types (zero references; -82 dep-graph
  nodes, three SDK stacks gone from every build). tokio "process" is now
  an explicit feature (was enabled transitively by aws-config).

Frontend:
- Cached Intl.DateTimeFormat keyed by (locale, options) in formatDate and
  4 sibling callsites: 20k dates 2612 -> 51 ms (51.6x); vitest gate
  asserts output identity across locales and a 3x floor.

Validation: cargo fmt + clippy --all-features --all-targets -D warnings
clean; 518 unit + 548 integration-cfg tests green; new-shape endpoints
smoke-tested end-to-end over HTTP (all 5 listing sort modes with cursor
walks, WebDAV PROPFIND Depth-1, photos timeline, Basic-auth DAV login);
frontend npm run check clean, new vitest gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBsU2qEzny3A8WQUEuMNCr
2026-07-17 11:10:27 +00:00

313 lines
12 KiB
Rust

//! Domain persistence port for the Folder entity.
//!
//! Defines the contract that any folder storage implementation
//! must fulfill. This trait lives in the domain because Folder is a core entity
//! of the system and its persistence contracts belong to the domain layer,
//! following the principles of Clean/Hexagonal Architecture.
//!
//! Concrete implementations (filesystem, PostgreSQL, S3, etc.) live in
//! the infrastructure layer.
use crate::common::errors::DomainError;
use crate::domain::entities::folder::Folder;
use crate::domain::services::path_service::StoragePath;
use uuid::Uuid;
// NOTE on `caller_role` for the two listing methods below:
// We deliberately do NOT compute or return the caller's role per row.
// The frontend already fetches `/api/drives` (which surfaces
// `caller_role` per drive) and cross-references by `folder.drive_id` —
// see `MoveDialog.svelte` and the config/drive page. Adding
// `caller_role` to `FolderDto` would either (a) mean redundant
// server-side work for a client-side concern the client already
// handles, or (b) drag folder-level grant cascades into the query
// which is real cost for a rare edge case. Punted; see
// `project_caller_role_on_file_folder_dto` memory.
/// Domain port for folder persistence.
///
/// Defines the CRUD and management operations required for
/// the Folder entity in the storage system.
pub trait FolderRepository: Send + Sync + 'static {
/// Creates a new folder.
///
/// `caller_id` is stamped into `created_by` and `updated_by`
/// (D0 §14 provenance — authorship belongs to whoever issued the
/// create, not to the parent folder's owner). Pre-D2 they're
/// silently equivalent (only the owner can write); D2 ships
/// shared drives where this distinction matters.
async fn create_folder(
&self,
name: String,
parent_id: Option<String>,
caller_id: Uuid,
) -> Result<Folder, DomainError>;
/// Gets a folder by its ID
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
/// Gets a folder by its storage path within a drive's tree.
///
/// Post-D0, `storage.folders.path` is unique only within a single
/// drive — root-folder names like `"Personal"` repeat across drives.
/// The `drive_id` filter scopes the lookup to a specific drive
/// (caller derives it from its protocol context: NC chroot, native
/// default-drive lookup, WOPI default-drive lookup).
async fn get_folder_by_path(
&self,
storage_path: &StoragePath,
drive_id: Uuid,
) -> Result<Folder, DomainError>;
/// Lists folders within a parent folder
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
/// Lists root-level folders the caller can read — scoped through
/// drive-membership grants (`role_grants` on `resource_type='drive'`)
/// rather than the legacy `folders.user_id` column. Group memberships
/// are expanded inline by `storage.caller_group_ids($caller)` in the
/// SQL. Closes [[bug-root-folder-listing-legacy-user-id]] — root
/// folders admin created for other users but has no role on no
/// longer surface in the admin's `GET /api/folders`.
///
/// Non-root queries (parent_id != None) go through `list_folders`
/// with the parent already permission-checked at the service layer,
/// so this method carries no `parent_id` parameter.
async fn list_root_folders_for_caller(
&self,
caller_id: Uuid,
) -> Result<Vec<Folder>, DomainError>;
/// Lists folders with pagination
async fn list_folders_paginated(
&self,
parent_id: Option<&str>,
offset: usize,
limit: usize,
include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
/// Paginated companion to `list_root_folders_for_caller` — same
/// drive-scoped predicate, adds LIMIT/OFFSET + optional
/// window-function COUNT.
async fn list_root_folders_for_caller_paginated(
&self,
caller_id: Uuid,
offset: usize,
limit: usize,
include_total: bool,
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
/// Keyset-paged listing of `parent_id`'s direct sub-folders in name
/// order — `name > $after_name ORDER BY name LIMIT $limit`, one bounded
/// index-range read per page off the partial unique index
/// `idx_folders_unique_name`. Streaming PROPFIND drains sub-folders
/// with this instead of `COUNT(*) OVER() … LIMIT/OFFSET`, which
/// window-aggregated and rescanned all N sub-folders on every page
/// (4.5x on a 5k-dir parent, benches/FOLDER-KEYSET.md). `has_next`
/// falls out of `rows.len() == limit` — no total needed.
///
/// The default implementation falls back to `list_folders` + in-memory
/// slice so stubs and mocks compile without changes.
async fn list_folders_batch(
&self,
parent_id: Option<&str>,
after_name: Option<&str>,
limit: usize,
) -> Result<Vec<Folder>, DomainError> {
let mut all = self.list_folders(parent_id).await?;
all.sort_by(|a, b| a.name().cmp(b.name()));
Ok(all
.into_iter()
.filter(|f| after_name.is_none_or(|a| f.name() > a))
.take(limit)
.collect())
}
/// Renames a folder. `caller_id` is stamped into `updated_by`
/// alongside the `updated_at = NOW()` bump (§14 provenance).
async fn rename_folder(
&self,
id: &str,
new_name: String,
caller_id: Uuid,
) -> Result<Folder, DomainError>;
/// Moves a folder to another parent. `caller_id` is stamped into
/// `updated_by` alongside the `updated_at = NOW()` bump
/// (§14 provenance).
async fn move_folder(
&self,
id: &str,
new_parent_id: Option<&str>,
caller_id: Uuid,
) -> Result<Folder, DomainError>;
/// Deletes a folder
async fn delete_folder(&self, id: &str) -> Result<(), DomainError>;
/// Checks if a folder exists at the given path within a drive.
///
/// Post-D0 `storage.folders.path` is unique only within a single
/// drive — the `drive_id` filter scopes the existence check.
async fn folder_exists(
&self,
storage_path: &StoragePath,
drive_id: Uuid,
) -> Result<bool, DomainError>;
/// Gets the path of a folder
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError>;
// ── Trash operations ──
/// Moves a folder to the trash. `caller_id` is stamped into
/// `updated_by` for the root row and every cascade-trashed
/// descendant (§14 provenance).
async fn move_to_trash(&self, folder_id: &str, caller_id: Uuid) -> Result<(), DomainError>;
/// Restores a folder from the trash to its original location.
/// `caller_id` is stamped into `updated_by` for the root row and
/// every cascade-restored descendant (§14 provenance).
async fn restore_from_trash(
&self,
folder_id: &str,
original_path: &str,
caller_id: Uuid,
) -> Result<(), DomainError>;
/// Permanently deletes a folder (used by the trash)
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError>;
/// File ids in the subtree rooted at `folder_id` (inclusive).
///
/// Single GiST scan on `storage.folders.lpath`. Service-layer paths
/// that delete a folder via bulk SQL (the PG cascade reaps descendant
/// files transparently) call this BEFORE the delete so they can fire
/// `on_file_deleted` per-file. Without it, file-id-keyed lifecycle
/// data (e.g. `ext-{file_id}.jpg` video thumbnails) leaks past the
/// cascade. See [[bug-folder-cascade-hooks-missing]].
///
/// Default: returns an empty vec (stubs / mocks).
async fn list_file_ids_in_subtree(&self, folder_id: &str) -> Result<Vec<String>, DomainError> {
let _ = folder_id;
Ok(Vec::new())
}
/// Lists every folder in a subtree rooted at `folder_id` (inclusive).
///
/// Uses ltree `<@` for a single GiST-indexed scan. The result is
/// ordered by `path` so callers can iterate in directory order.
///
/// Default: falls back to `list_folders` (one level only).
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<Folder>, DomainError> {
let _ = folder_id;
Ok(Vec::new())
}
/// Lists all descendant folders in a subtree (ltree-based), scoped
/// to drives the caller can read.
///
/// Returns all folders whose lpath is a descendant of the given
/// folder's lpath. Used for recursive search — O(1) SQL via GiST
/// index instead of O(N) recursive traversal. Drive-membership
/// filtering (including group cascade via `caller_group_ids`) is
/// applied inline in the SQL.
///
/// The default implementation returns an empty vec (stubs / mocks).
async fn list_descendant_folders(
&self,
folder_id: &str,
name_contains: Option<&str>,
caller_id: Uuid,
) -> Result<Vec<Folder>, DomainError> {
let _ = (folder_id, name_contains, caller_id);
Ok(Vec::new())
}
/// Search folders with SQL-level filtering by name and scope,
/// restricted to drives the caller can read.
///
/// - **Non-recursive** (`recursive = false`): searches direct children of
/// `parent_id` (or root folders when `None`).
/// - **Recursive with `parent_id`**: delegates to `list_descendant_folders`
/// (ltree GiST-indexed scan).
/// - **Recursive without `parent_id`**: searches ALL folders in drives
/// the caller can read, with optional name filter in SQL.
///
/// The default implementation falls back to `list_folders` + in-memory
/// filter so that stubs and mocks compile without changes.
async fn search_folders(
&self,
parent_id: Option<&str>,
name_contains: Option<&str>,
caller_id: Uuid,
recursive: bool,
) -> Result<Vec<Folder>, DomainError> {
// Recursive with folder_id → use optimised ltree scan
if recursive && let Some(fid) = parent_id {
return self
.list_descendant_folders(fid, name_contains, caller_id)
.await;
}
// Fallback: load + filter in memory (stubs / mocks)
let all = self.list_folders(parent_id).await?;
match name_contains {
Some(q) if !q.is_empty() => {
let q = q.to_lowercase();
Ok(all
.into_iter()
.filter(|f| f.name().to_lowercase().contains(&q))
.collect())
}
_ => Ok(all),
}
}
/// Return up to `limit` folders whose name contains `query` (case-insensitive).
///
/// Results are ordered by relevance (exact > starts-with > contains) for
/// autocomplete suggestions.
///
/// The default implementation falls back to `list_folders` + in-memory
/// filter so that stubs and mocks compile without changes.
async fn suggest_folders_by_name(
&self,
parent_id: Option<&str>,
query: &str,
limit: usize,
) -> Result<Vec<Folder>, DomainError> {
let all = self.list_folders(parent_id).await?;
let q = query.to_lowercase();
let mut matched: Vec<Folder> = all
.into_iter()
.filter(|f| f.name().to_lowercase().contains(&q))
.collect();
matched.truncate(limit);
Ok(matched)
}
/// `true` if `candidate_folder_id` is `root_folder_id` itself or any
/// (transitive) descendant. Default impl fails closed so stubs deny
/// access by default.
async fn is_folder_in_subtree(
&self,
candidate_folder_id: &str,
root_folder_id: &str,
) -> Result<bool, DomainError> {
let _ = (candidate_folder_id, root_folder_id);
Ok(false)
}
/// `true` if `file_id`'s parent folder lies within the subtree rooted
/// at `root_folder_id`.
async fn is_file_in_subtree(
&self,
file_id: &str,
root_folder_id: &str,
) -> Result<bool, DomainError> {
let _ = (file_id, root_folder_id);
Ok(false)
}
}