2026-02-14 20:22:19 +01:00
|
|
|
|
//! 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.
|
2026-02-16 13:10:44 +01:00
|
|
|
|
//!
|
|
|
|
|
|
//! 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.
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
|
|
|
|
|
use sqlx::PgPool;
|
|
|
|
|
|
use std::sync::Arc;
|
2026-03-07 18:05:52 +01:00
|
|
|
|
use uuid::Uuid;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-06-10 22:03:49 +02:00
|
|
|
|
use super::transaction_utils::retry_on_deadlock;
|
2026-05-28 00:44:20 +02:00
|
|
|
|
use crate::application::dtos::folder_dto::{FolderResourceCursor, FolderResourceRow};
|
2026-02-14 20:22:19 +01:00
|
|
|
|
use crate::common::errors::DomainError;
|
|
|
|
|
|
use crate::domain::entities::folder::Folder;
|
|
|
|
|
|
use crate::domain::repositories::folder_repository::FolderRepository;
|
2026-05-28 00:44:20 +02:00
|
|
|
|
use crate::domain::services::authorization::ResourceKind;
|
2026-09-04 22:19:29 +02:00
|
|
|
|
use crate::domain::services::path_service::{StoragePath, normalize_storage_name_owned};
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-03-04 23:55:08 +01:00
|
|
|
|
/// Type alias for folder metadata rows from SQL queries.
|
2026-07-03 01:33:19 +02:00
|
|
|
|
/// Tuple order: id, name, path, parent_id, drive_id, created_at,
|
|
|
|
|
|
/// modified_at, tree_modified_at, created_by, updated_by.
|
2026-06-19 10:51:13 +02:00
|
|
|
|
/// The trailing `tree_modified_at` feeds [`Folder::etag`] — every
|
|
|
|
|
|
/// SELECT here must include `EXTRACT(EPOCH FROM tree_modified_at)::bigint`.
|
2026-06-19 07:49:33 +02:00
|
|
|
|
/// `drive_id` is the post-D0 `NOT NULL` scope axis for path-based
|
2026-06-19 10:51:13 +02:00
|
|
|
|
/// lookups. `created_by` / `updated_by` are the §14 provenance
|
|
|
|
|
|
/// columns, nullable because the FK is `ON DELETE SET NULL`.
|
2026-07-02 21:18:01 +02:00
|
|
|
|
///
|
2026-07-03 01:33:19 +02:00
|
|
|
|
/// Post-D7-step-6: `storage.folders.user_id` dropped, so the tuple
|
|
|
|
|
|
/// no longer carries it. The domain entity's `user_id` field is
|
|
|
|
|
|
/// populated with `None` at `row_to_folder` construction.
|
2026-07-18 16:12:04 +00:00
|
|
|
|
/// `id` / `parent_id` decode as binary `Uuid` (16 bytes on the wire vs 36
|
|
|
|
|
|
/// as `::text`, and the server skips the cast); `row_to_folder` renders
|
|
|
|
|
|
/// them to `String` once app-side — the round-6 `row_to_file` shape
|
|
|
|
|
|
/// (benches/ROUND6.md §10) applied to the folder listings.
|
2026-06-19 07:49:33 +02:00
|
|
|
|
type FolderRow = (
|
2026-07-18 16:12:04 +00:00
|
|
|
|
Uuid,
|
2026-06-19 07:49:33 +02:00
|
|
|
|
String,
|
|
|
|
|
|
String,
|
2026-07-18 16:12:04 +00:00
|
|
|
|
Option<Uuid>,
|
2026-06-19 07:49:33 +02:00
|
|
|
|
Uuid,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
i64,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
Option<Uuid>,
|
|
|
|
|
|
Option<Uuid>,
|
2026-06-19 07:49:33 +02:00
|
|
|
|
);
|
2026-06-06 17:02:50 +02:00
|
|
|
|
|
2026-07-26 14:42:04 +02:00
|
|
|
|
/// `FolderRow` + trailing `(is_favorite, is_shared)` — the caller-scoped
|
|
|
|
|
|
/// booleans populated by per-row EXISTS in `search_folders`. Split out so
|
|
|
|
|
|
/// the many `sqlx::query_as::<_, FolderRowWithFlags>(...)` sites stay
|
|
|
|
|
|
/// terse instead of repeating a 12-element inline tuple.
|
|
|
|
|
|
type FolderRowWithFlags = (
|
|
|
|
|
|
Uuid,
|
|
|
|
|
|
String,
|
|
|
|
|
|
String,
|
|
|
|
|
|
Option<Uuid>,
|
|
|
|
|
|
Uuid,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
Option<Uuid>,
|
|
|
|
|
|
Option<Uuid>,
|
|
|
|
|
|
bool,
|
|
|
|
|
|
bool,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/// Return shape of `search_folders`: two parallel vecs aligned 1:1
|
|
|
|
|
|
/// (folder domain entity + `(is_favorite, is_shared)` caller flags).
|
|
|
|
|
|
/// Kept as a type alias so the SQL builder + trait implementations
|
|
|
|
|
|
/// share one name (`clippy::type_complexity`).
|
|
|
|
|
|
pub(crate) type FoldersWithFlags = (Vec<Folder>, Vec<(bool, bool)>);
|
|
|
|
|
|
|
|
|
|
|
|
/// Convert `FolderRowWithFlags` rows into the `(Vec<Folder>, caller_flags)`
|
|
|
|
|
|
/// pair `search_folders` returns. Keeps the two parallel vecs aligned
|
|
|
|
|
|
/// 1:1 by index.
|
|
|
|
|
|
fn build_folders_with_flags(
|
|
|
|
|
|
rows: Vec<FolderRowWithFlags>,
|
|
|
|
|
|
) -> Result<FoldersWithFlags, DomainError> {
|
|
|
|
|
|
let mut folders = Vec::with_capacity(rows.len());
|
|
|
|
|
|
let mut flags = Vec::with_capacity(rows.len());
|
|
|
|
|
|
for (id, name, path, pid, did, ca, ma, tma, cb, ub, is_fav, is_shr) in rows {
|
|
|
|
|
|
let folder =
|
|
|
|
|
|
FolderDbRepository::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)?;
|
|
|
|
|
|
folders.push(folder);
|
|
|
|
|
|
flags.push((is_fav, is_shr));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok((folders, flags))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-26 21:31:04 +02:00
|
|
|
|
/// Row projected by `fetch_ancestor_walk`. One per folder in the
|
|
|
|
|
|
/// leaf→root walk (root order is reversed to root-first by the caller).
|
|
|
|
|
|
/// `has_folder_grant` = caller has a `role_grants` row on THIS folder;
|
|
|
|
|
|
/// `has_drive_grant` = caller has drive-membership on the containing drive.
|
|
|
|
|
|
/// Either grant satisfies Read; the split lets the service pick the right
|
|
|
|
|
|
/// `AccessSource` kind (`Drive` vs `DirectShare`).
|
|
|
|
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
|
|
|
|
pub struct AncestorRow {
|
|
|
|
|
|
pub id: Uuid,
|
|
|
|
|
|
pub name: String,
|
|
|
|
|
|
pub parent_id: Option<Uuid>,
|
|
|
|
|
|
pub drive_id: Uuid,
|
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
|
pub depth: i32,
|
|
|
|
|
|
pub has_folder_grant: bool,
|
|
|
|
|
|
pub has_drive_grant: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 17:02:50 +02:00
|
|
|
|
/// Type alias for paginated folder rows (includes total_count as
|
2026-07-02 21:18:01 +02:00
|
|
|
|
/// the last element after the §14 provenance columns). Same
|
2026-07-03 01:33:19 +02:00
|
|
|
|
/// column set as [`FolderRow`] plus the trailing count.
|
2026-06-06 17:02:50 +02:00
|
|
|
|
type FolderRowPaginated = (
|
2026-07-18 16:12:04 +00:00
|
|
|
|
Uuid,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
String,
|
|
|
|
|
|
String,
|
2026-07-18 16:12:04 +00:00
|
|
|
|
Option<Uuid>,
|
2026-06-19 07:49:33 +02:00
|
|
|
|
Uuid,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
i64,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
i64,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
Option<Uuid>,
|
|
|
|
|
|
Option<Uuid>,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
i64,
|
|
|
|
|
|
);
|
2026-03-04 23:55:08 +01:00
|
|
|
|
|
2026-07-02 00:45:11 +02:00
|
|
|
|
/// SQL `EXISTS (…)` predicate — true when the caller (bound to `$1`) has
|
|
|
|
|
|
/// any active `role_grants` on the drive owning `fo` (the aliased folder
|
|
|
|
|
|
/// row). Group memberships (direct + transitive) are expanded inline via
|
|
|
|
|
|
/// `storage.caller_group_ids($1)` (recursive; see migration
|
|
|
|
|
|
/// `20260901000002_caller_group_ids_function.sql`).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Used by every drive-scoped folder query in this repo:
|
|
|
|
|
|
/// - `list_root_folders_for_caller` / `_paginated`
|
|
|
|
|
|
/// - `search_folders` (all three branches)
|
|
|
|
|
|
/// - `list_descendant_folders`
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **Alias contract**: queries splicing this in MUST alias
|
|
|
|
|
|
/// `storage.folders` as `fo`. `$1` is reserved for `caller_id`; other
|
|
|
|
|
|
/// bind params start at `$2`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// This mirrors — but is not shared with — the drive-membership shape in
|
|
|
|
|
|
/// `drive_pg_repository::list_readable_by` (uses `JOIN` on `d.id`) and
|
|
|
|
|
|
/// the media-scoping subqueries in `file_blob_read_repository` (use
|
|
|
|
|
|
/// `IN (SELECT d.id …)` with the `include_in_photo_index` policy
|
|
|
|
|
|
/// filter). When the grant model changes, update all sites in parallel.
|
|
|
|
|
|
const CALLER_CAN_READ_DRIVE: &str = "EXISTS (\
|
|
|
|
|
|
SELECT 1 \
|
|
|
|
|
|
FROM storage.role_grants g \
|
|
|
|
|
|
WHERE g.resource_type = 'drive' \
|
|
|
|
|
|
AND g.resource_id = fo.drive_id \
|
|
|
|
|
|
AND (g.expires_at IS NULL OR g.expires_at > NOW()) \
|
|
|
|
|
|
AND ( \
|
|
|
|
|
|
(g.subject_type = 'user' AND g.subject_id = $1) \
|
|
|
|
|
|
OR (g.subject_type = 'group' AND g.subject_id IN \
|
|
|
|
|
|
(SELECT storage.caller_group_ids($1))) \
|
|
|
|
|
|
) \
|
|
|
|
|
|
)";
|
|
|
|
|
|
|
2026-02-14 20:22:19 +01:00
|
|
|
|
/// 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 ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// Convert a database row into a `Folder` domain entity.
|
2026-02-16 13:10:44 +01:00
|
|
|
|
///
|
|
|
|
|
|
/// The `path` comes directly from the materialized `path` column — no
|
2026-06-19 10:51:13 +02:00
|
|
|
|
/// extra queries needed. `created_by` / `updated_by` carry the
|
|
|
|
|
|
/// §14 provenance signal through the entity layer; both are
|
|
|
|
|
|
/// `Option<Uuid>` because the FK is `ON DELETE SET NULL`.
|
2026-06-06 17:02:50 +02:00
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
2026-02-16 13:10:44 +01:00
|
|
|
|
fn row_to_folder(
|
2026-07-18 16:12:04 +00:00
|
|
|
|
id: Uuid,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
name: String,
|
2026-02-16 13:10:44 +01:00
|
|
|
|
path: String,
|
2026-07-18 16:12:04 +00:00
|
|
|
|
parent_id: Option<Uuid>,
|
2026-06-19 07:49:33 +02:00
|
|
|
|
drive_id: Uuid,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
created_at: i64,
|
|
|
|
|
|
modified_at: i64,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
tree_modified_at: i64,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
created_by: Option<Uuid>,
|
|
|
|
|
|
updated_by: Option<Uuid>,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
) -> Result<Folder, DomainError> {
|
2026-07-17 13:48:37 +00:00
|
|
|
|
Folder::from_materialized_row(
|
2026-07-18 16:12:04 +00:00
|
|
|
|
id.to_string(),
|
2026-02-14 20:22:19 +01:00
|
|
|
|
name,
|
2026-07-17 13:48:37 +00:00
|
|
|
|
path,
|
2026-07-18 16:12:04 +00:00
|
|
|
|
parent_id.map(|u| u.to_string()),
|
2026-06-19 07:49:33 +02:00
|
|
|
|
drive_id,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
created_at as u64,
|
|
|
|
|
|
modified_at as u64,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
tree_modified_at as u64,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
created_by,
|
|
|
|
|
|
updated_by,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
)
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
|
|
|
|
|
|
}
|
2026-06-19 11:27:11 +00:00
|
|
|
|
|
|
|
|
|
|
/// Batch-fetch folders by id — the by-ids counterpart of `get_folder`,
|
|
|
|
|
|
/// resolving a page of ACL grants or favorites in ONE query instead of
|
|
|
|
|
|
/// one per id. Same `NOT is_trashed` filter and column mapping as
|
|
|
|
|
|
/// `get_folder`; missing or trashed ids drop out and callers re-associate
|
|
|
|
|
|
/// by id; ordering is not guaranteed.
|
|
|
|
|
|
pub async fn get_folders_by_ids(&self, ids: &[String]) -> Result<Vec<Folder>, DomainError> {
|
|
|
|
|
|
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
|
|
|
|
|
|
if uuid_ids.is_empty() {
|
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let rows = sqlx::query_as::<_, FolderRow>(
|
|
|
|
|
|
r#"
|
2026-07-18 16:12:04 +00:00
|
|
|
|
SELECT id, name, path, parent_id, drive_id,
|
2026-06-19 11:27:11 +00:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
|
|
|
|
|
created_by, updated_by
|
2026-06-19 11:27:11 +00:00
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE id = ANY($1) AND NOT is_trashed
|
|
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(&uuid_ids)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
rows.into_iter()
|
2026-07-03 01:33:19 +02:00
|
|
|
|
.map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7, r.8, r.9))
|
2026-06-19 11:27:11 +00:00
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl FolderRepository for FolderDbRepository {
|
|
|
|
|
|
async fn create_folder(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
name: String,
|
|
|
|
|
|
parent_id: Option<String>,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
caller_id: Uuid,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
) -> Result<Folder, DomainError> {
|
2026-09-04 22:19:29 +02:00
|
|
|
|
// Belt-and-suspenders NFC normalization at the last touch before the
|
|
|
|
|
|
// DB write. Every caller that lands here — REST `POST /api/folders`,
|
|
|
|
|
|
// WebDAV `MKCOL`, NextCloud `MKCOL`, batch create, WebDAV/NC `COPY`
|
|
|
|
|
|
// fall-through — passes the raw client-supplied name. macOS Finder /
|
|
|
|
|
|
// Android sync clients emit NFD path segments; if we bind them raw
|
|
|
|
|
|
// the DB row's bytes don't match NFC-normalizing clients' subsequent
|
|
|
|
|
|
// PROPFINDs (see AtalayaLabs/OxiCloud#706). Canonicalising here is
|
|
|
|
|
|
// the single choke-point that closes every entry-point audited on
|
|
|
|
|
|
// 2026-09-03 without asking each handler to remember. Fast-path
|
|
|
|
|
|
// `is_nfc_quick` inside `normalize_storage_name_owned` returns the
|
|
|
|
|
|
// owned string unchanged for names already in NFC — every visible
|
|
|
|
|
|
// ASCII name, every browser-composed non-ASCII name — so this costs
|
|
|
|
|
|
// one table-lookup on the hot path.
|
|
|
|
|
|
let name = normalize_storage_name_owned(name);
|
|
|
|
|
|
|
2026-07-02 21:18:01 +02:00
|
|
|
|
// Derive `drive_id` from the parent folder. Root-level folders
|
|
|
|
|
|
// are reserved for the atomic drive-creation transaction in
|
|
|
|
|
|
// `DrivePgRepository::create_personal_drive_atomic` (see
|
|
|
|
|
|
// `docs/plan/drive.md` §3) — the no-orphan-root-folder trigger
|
|
|
|
|
|
// enforces this at the DB level.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Post-D7: only `drive_id` is fetched from the parent. The
|
|
|
|
|
|
// legacy `user_id` column is no longer written to on new rows
|
|
|
|
|
|
// (migration `20260902000000_files_folders_user_id_nullable.sql`);
|
|
|
|
|
|
// provenance flows through `created_by` / `updated_by` (§14).
|
|
|
|
|
|
let drive_id: Uuid = if let Some(ref pid) = parent_id {
|
|
|
|
|
|
sqlx::query_scalar::<_, Uuid>(
|
|
|
|
|
|
"SELECT drive_id FROM storage.folders WHERE id = $1::uuid",
|
2026-06-18 13:29:41 +02:00
|
|
|
|
)
|
|
|
|
|
|
.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))?
|
2026-02-14 20:22:19 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
return Err(DomainError::internal_error(
|
|
|
|
|
|
"FolderDb",
|
2026-06-19 12:28:30 +02:00
|
|
|
|
"Cannot create root folder — root folders are reserved for the \
|
|
|
|
|
|
atomic drive-creation transaction in DrivePgRepository::\
|
|
|
|
|
|
create_personal_drive_atomic (docs/plan/drive.md §3). The \
|
|
|
|
|
|
no-orphan-root-folder trigger enforces this at the DB level.",
|
2026-02-14 20:22:19 +01:00
|
|
|
|
));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-02 21:18:01 +02:00
|
|
|
|
// Post-D7: no `user_id` in the INSERT column list — the column
|
|
|
|
|
|
// is nullable and copied rows / new rows leave it NULL.
|
|
|
|
|
|
// `created_by` / `updated_by` carry §14 provenance (both bind to
|
|
|
|
|
|
// the caller — pre-D2 that's silently the parent's owner too,
|
|
|
|
|
|
// but the distinction matters once shared drives let an Editor
|
|
|
|
|
|
// mutate a folder owned by someone else).
|
2026-06-19 10:51:13 +02:00
|
|
|
|
//
|
2026-07-02 21:18:01 +02:00
|
|
|
|
// RETURNING surfaces the two provenance columns so the built
|
|
|
|
|
|
// entity / DTO carries fresh values without a re-read.
|
2026-07-18 16:12:04 +00:00
|
|
|
|
let row = sqlx::query_as::<
|
|
|
|
|
|
_,
|
|
|
|
|
|
(
|
|
|
|
|
|
Uuid,
|
|
|
|
|
|
Option<Uuid>,
|
|
|
|
|
|
String,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
i64,
|
|
|
|
|
|
Option<Uuid>,
|
|
|
|
|
|
Option<Uuid>,
|
|
|
|
|
|
),
|
|
|
|
|
|
>(
|
2026-02-14 20:22:19 +01:00
|
|
|
|
r#"
|
2026-06-18 13:29:41 +02:00
|
|
|
|
INSERT INTO storage.folders
|
2026-07-02 21:18:01 +02:00
|
|
|
|
(name, parent_id, drive_id, created_by, updated_by)
|
|
|
|
|
|
VALUES ($1, $2::uuid, $3, $4, $4)
|
2026-07-18 16:12:04 +00:00
|
|
|
|
RETURNING id,
|
|
|
|
|
|
parent_id,
|
2026-02-16 13:10:44 +01:00
|
|
|
|
path,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
|
|
|
|
|
created_by,
|
|
|
|
|
|
updated_by
|
2026-02-14 20:22:19 +01:00
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(&name)
|
|
|
|
|
|
.bind(&parent_id)
|
2026-06-18 13:29:41 +02:00
|
|
|
|
.bind(drive_id)
|
2026-06-19 10:51:13 +02:00
|
|
|
|
.bind(caller_id)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
.fetch_one(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| {
|
2026-02-15 17:53:25 +01:00
|
|
|
|
if let sqlx::Error::Database(ref db_err) = e
|
2026-02-15 18:04:32 +01:00
|
|
|
|
&& db_err.code().as_deref() == Some("23505")
|
|
|
|
|
|
{
|
|
|
|
|
|
return DomainError::already_exists(
|
|
|
|
|
|
"Folder",
|
|
|
|
|
|
format!("{name} already exists in parent"),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-02-14 20:22:19 +01:00
|
|
|
|
DomainError::internal_error("FolderDb", format!("insert: {e}"))
|
|
|
|
|
|
})?;
|
|
|
|
|
|
|
2026-06-06 17:02:50 +02:00
|
|
|
|
Self::row_to_folder(
|
2026-07-18 16:12:04 +00:00
|
|
|
|
row.0, name, row.2, row.1, drive_id, row.3, row.4, row.5,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
// Fresh from RETURNING — caller_id was bound to both columns.
|
2026-07-18 16:12:04 +00:00
|
|
|
|
row.6, row.7,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
2026-06-06 17:02:50 +02:00
|
|
|
|
let row = sqlx::query_as::<_, FolderRow>(
|
2026-02-14 20:22:19 +01:00
|
|
|
|
r#"
|
2026-07-18 16:12:04 +00:00
|
|
|
|
SELECT id, name, path, parent_id, drive_id,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
|
|
|
|
|
created_by, updated_by
|
2026-02-14 20:22:19 +01:00
|
|
|
|
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))?;
|
|
|
|
|
|
|
2026-06-19 07:49:33 +02:00
|
|
|
|
Self::row_to_folder(
|
2026-07-03 01:33:19 +02:00
|
|
|
|
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
|
2026-06-19 07:49:33 +02:00
|
|
|
|
)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 23:02:17 +02:00
|
|
|
|
async fn get_folder_by_path(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
storage_path: &StoragePath,
|
2026-06-19 07:49:33 +02:00
|
|
|
|
drive_id: Uuid,
|
2026-06-18 23:02:17 +02:00
|
|
|
|
) -> Result<Folder, DomainError> {
|
2026-02-14 20:22:19 +01:00
|
|
|
|
let path_str = storage_path.to_string();
|
2026-02-16 13:10:44 +01:00
|
|
|
|
// Strip leading '/' if present — DB stores "Home - user/Docs", not "/Home - user/Docs"
|
|
|
|
|
|
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
if lookup.is_empty() {
|
2026-02-14 20:22:19 +01:00
|
|
|
|
return Err(DomainError::not_found("Folder", "empty path"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 07:49:33 +02:00
|
|
|
|
// Scoped by drive_id: post-D0 `storage.folders.path` is unique
|
|
|
|
|
|
// only within a single drive. Root-folder names like
|
|
|
|
|
|
// `"Personal"` repeat across drives, so without the drive_id
|
|
|
|
|
|
// filter the planner returns a non-deterministic row — which
|
|
|
|
|
|
// breaks owner-short-circuit checks and crosses drive
|
|
|
|
|
|
// boundaries (the AuthZ axis that replaces the old per-user
|
|
|
|
|
|
// wrapper scoping post-D0).
|
2026-06-06 17:02:50 +02:00
|
|
|
|
let row = sqlx::query_as::<_, FolderRow>(
|
2026-02-16 13:10:44 +01:00
|
|
|
|
r#"
|
2026-07-18 16:12:04 +00:00
|
|
|
|
SELECT id, name, path, parent_id, drive_id,
|
2026-02-16 13:10:44 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
|
|
|
|
|
created_by, updated_by
|
2026-02-16 13:10:44 +01:00
|
|
|
|
FROM storage.folders
|
2026-06-19 07:49:33 +02:00
|
|
|
|
WHERE path = $1 AND drive_id = $2 AND NOT is_trashed
|
2026-02-16 13:10:44 +01:00
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(lookup)
|
2026-06-19 07:49:33 +02:00
|
|
|
|
.bind(drive_id)
|
2026-02-16 13:10:44 +01:00
|
|
|
|
.fetch_optional(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
|
|
|
|
|
|
.ok_or_else(|| DomainError::not_found("Folder", lookup))?;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-06-19 07:49:33 +02:00
|
|
|
|
Self::row_to_folder(
|
2026-07-03 01:33:19 +02:00
|
|
|
|
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
|
2026-06-19 07:49:33 +02:00
|
|
|
|
)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 21:40:38 +01:00
|
|
|
|
#[allow(clippy::type_complexity)]
|
2026-02-14 20:22:19 +01:00
|
|
|
|
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError> {
|
2026-03-04 23:55:08 +01:00
|
|
|
|
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
2026-07-18 16:12:04 +00:00
|
|
|
|
SELECT id, name, path, parent_id, drive_id,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
|
|
|
|
|
created_by, updated_by
|
2026-02-14 20:22:19 +01:00
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id = $1::uuid AND NOT is_trashed
|
|
|
|
|
|
ORDER BY name
|
|
|
|
|
|
"#,
|
2026-03-04 23:55:08 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(pid)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
2026-07-18 16:12:04 +00:00
|
|
|
|
SELECT id, name, path, parent_id, drive_id,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
|
|
|
|
|
created_by, updated_by
|
2026-02-14 20:22:19 +01:00
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id IS NULL AND NOT is_trashed
|
|
|
|
|
|
ORDER BY name
|
|
|
|
|
|
"#,
|
2026-03-04 23:55:08 +01:00
|
|
|
|
)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
rows.into_iter()
|
2026-07-03 01:33:19 +02:00
|
|
|
|
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
2026-02-16 13:10:44 +01:00
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
2026-02-15 23:45:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 00:01:15 +02:00
|
|
|
|
async fn list_root_folders_for_caller(
|
2026-02-15 23:45:11 +01:00
|
|
|
|
&self,
|
2026-07-02 00:01:15 +02:00
|
|
|
|
caller_id: Uuid,
|
2026-02-15 23:45:11 +01:00
|
|
|
|
) -> Result<Vec<Folder>, DomainError> {
|
2026-07-02 00:01:15 +02:00
|
|
|
|
// Drive-scoped root-folder listing: return every root folder
|
|
|
|
|
|
// whose drive the caller has any role_grant on. Group
|
|
|
|
|
|
// memberships (direct + transitive) resolve inline via
|
|
|
|
|
|
// `storage.caller_group_ids($1)`.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Closes `bug_root_folder_listing_legacy_user_id`: pre-D7 this
|
|
|
|
|
|
// query filtered on `folders.user_id = $caller`, which returned
|
|
|
|
|
|
// rows admin had created for other users' drives without ever
|
|
|
|
|
|
// getting a role on them. The drive-membership predicate below
|
|
|
|
|
|
// makes the "admin's own listing" correct without a separate
|
|
|
|
|
|
// filter.
|
|
|
|
|
|
//
|
|
|
|
|
|
// `caller_role` is NOT surfaced here — see the memory
|
|
|
|
|
|
// `project_caller_role_on_file_folder_dto` and the note at the
|
|
|
|
|
|
// top of `folder_repository.rs`. Frontend cross-references
|
|
|
|
|
|
// `/api/drives::caller_role` via `folder.drive_id`.
|
2026-07-02 00:45:11 +02:00
|
|
|
|
let sql = format!(
|
2026-07-18 16:12:04 +00:00
|
|
|
|
"SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
2026-07-03 01:33:19 +02:00
|
|
|
|
fo.drive_id, \
|
2026-07-02 00:45:11 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
|
|
|
|
|
fo.created_by, fo.updated_by \
|
|
|
|
|
|
FROM storage.folders fo \
|
|
|
|
|
|
WHERE fo.parent_id IS NULL \
|
|
|
|
|
|
AND NOT fo.is_trashed \
|
|
|
|
|
|
AND {CALLER_CAN_READ_DRIVE} \
|
|
|
|
|
|
ORDER BY fo.name"
|
|
|
|
|
|
);
|
|
|
|
|
|
let rows: Vec<FolderRow> = sqlx::query_as(&sql)
|
|
|
|
|
|
.bind(caller_id)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
|
DomainError::internal_error("FolderDb", format!("list_root_folders: {e}"))
|
|
|
|
|
|
})?;
|
2026-02-15 23:45:11 +01:00
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
rows.into_iter()
|
2026-07-03 01:33:19 +02:00
|
|
|
|
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
2026-02-16 13:10:44 +01:00
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 01:30:34 +01:00
|
|
|
|
/// Paginated folder listing — single query with `COUNT(*) OVER()` window
|
|
|
|
|
|
/// function so the total matching count comes back alongside the data rows,
|
|
|
|
|
|
/// eliminating a separate COUNT round-trip.
|
2026-03-04 21:40:38 +01:00
|
|
|
|
#[allow(clippy::type_complexity)]
|
2026-02-14 20:22:19 +01:00
|
|
|
|
async fn list_folders_paginated(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
parent_id: Option<&str>,
|
|
|
|
|
|
offset: usize,
|
|
|
|
|
|
limit: usize,
|
|
|
|
|
|
include_total: bool,
|
|
|
|
|
|
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
2026-03-04 23:55:08 +01:00
|
|
|
|
let rows: Vec<FolderRowPaginated> = if let Some(pid) = parent_id {
|
2026-03-03 01:49:18 +01:00
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
2026-07-18 16:12:04 +00:00
|
|
|
|
SELECT id, name, path, parent_id, drive_id,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
2026-03-02 01:30:34 +01:00
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
created_by, updated_by,
|
2026-03-02 01:30:34 +01:00
|
|
|
|
COUNT(*) OVER() AS total_count
|
2026-02-14 20:22:19 +01:00
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id = $1::uuid AND NOT is_trashed
|
|
|
|
|
|
ORDER BY name
|
|
|
|
|
|
LIMIT $2 OFFSET $3
|
|
|
|
|
|
"#,
|
2026-03-03 01:49:18 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(pid)
|
|
|
|
|
|
.bind(limit as i64)
|
|
|
|
|
|
.bind(offset as i64)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
|
r#"
|
2026-07-18 16:12:04 +00:00
|
|
|
|
SELECT id, name, path, parent_id, drive_id,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
2026-03-02 01:30:34 +01:00
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
2026-06-06 17:02:50 +02:00
|
|
|
|
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
created_by, updated_by,
|
2026-03-02 01:30:34 +01:00
|
|
|
|
COUNT(*) OVER() AS total_count
|
2026-02-14 20:22:19 +01:00
|
|
|
|
FROM storage.folders
|
|
|
|
|
|
WHERE parent_id IS NULL AND NOT is_trashed
|
|
|
|
|
|
ORDER BY name
|
|
|
|
|
|
LIMIT $1 OFFSET $2
|
|
|
|
|
|
"#,
|
2026-03-03 01:49:18 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(limit as i64)
|
|
|
|
|
|
.bind(offset as i64)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-03-02 01:30:34 +01:00
|
|
|
|
// total_count is identical in every row; 0 when the result set is empty.
|
|
|
|
|
|
let total = if include_total {
|
2026-07-03 01:33:19 +02:00
|
|
|
|
Some(rows.first().map_or(0, |r| r.10) as usize)
|
2026-03-02 01:30:34 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let folders: Result<Vec<Folder>, DomainError> = rows
|
|
|
|
|
|
.into_iter()
|
2026-07-03 01:33:19 +02:00
|
|
|
|
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub, _total)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
|
|
|
|
|
})
|
2026-02-16 13:10:44 +01:00
|
|
|
|
.collect();
|
|
|
|
|
|
Ok((folders?, total))
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-17 11:10:27 +00:00
|
|
|
|
/// Keyset sub-folder page: `name > $after ORDER BY name LIMIT $limit`,
|
|
|
|
|
|
/// one bounded index-range read off `idx_folders_unique_name` — the
|
|
|
|
|
|
/// cursor predicate is only emitted when a cursor exists (a bound
|
|
|
|
|
|
/// disjunction would block the index condition under generic plans,
|
|
|
|
|
|
/// same rule as `list_files_batch`). Root scope (`parent_id = None`)
|
|
|
|
|
|
/// keeps the trait's in-memory default: roots are one-per-drive, a
|
|
|
|
|
|
/// handful of rows.
|
|
|
|
|
|
async fn list_folders_batch(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
parent_id: Option<&str>,
|
|
|
|
|
|
after_name: Option<&str>,
|
|
|
|
|
|
limit: usize,
|
|
|
|
|
|
) -> Result<Vec<Folder>, DomainError> {
|
|
|
|
|
|
let Some(pid) = parent_id else {
|
|
|
|
|
|
let mut all = self.list_folders(None).await?;
|
|
|
|
|
|
all.sort_by(|a, b| a.name().cmp(b.name()));
|
|
|
|
|
|
return Ok(all
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.filter(|f| after_name.is_none_or(|a| f.name() > a))
|
|
|
|
|
|
.take(limit)
|
|
|
|
|
|
.collect());
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let cursor_pred = if after_name.is_some() {
|
|
|
|
|
|
"AND name > $3"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"AND $3::text IS NULL"
|
|
|
|
|
|
};
|
|
|
|
|
|
let sql = format!(
|
2026-07-18 16:12:04 +00:00
|
|
|
|
"SELECT id, name, path, parent_id, drive_id, \
|
2026-07-17 11:10:27 +00:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM tree_modified_at)::bigint, \
|
|
|
|
|
|
created_by, updated_by \
|
|
|
|
|
|
FROM storage.folders \
|
|
|
|
|
|
WHERE parent_id = $1::uuid AND NOT is_trashed \
|
|
|
|
|
|
{cursor_pred} \
|
|
|
|
|
|
ORDER BY name \
|
|
|
|
|
|
LIMIT $2"
|
|
|
|
|
|
);
|
|
|
|
|
|
let rows: Vec<FolderRow> = sqlx::query_as(&sql)
|
|
|
|
|
|
.bind(pid)
|
|
|
|
|
|
.bind(limit as i64)
|
|
|
|
|
|
.bind(after_name)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("batch: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
rows.into_iter()
|
|
|
|
|
|
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 00:01:15 +02:00
|
|
|
|
/// Paginated companion to `list_root_folders_for_caller` — same
|
|
|
|
|
|
/// drive-membership predicate, adds LIMIT/OFFSET and an optional
|
|
|
|
|
|
/// window-function COUNT so total pages can be surfaced without a
|
|
|
|
|
|
/// second round-trip.
|
|
|
|
|
|
async fn list_root_folders_for_caller_paginated(
|
2026-02-16 00:22:42 +01:00
|
|
|
|
&self,
|
2026-07-02 00:01:15 +02:00
|
|
|
|
caller_id: Uuid,
|
2026-02-16 00:22:42 +01:00
|
|
|
|
offset: usize,
|
|
|
|
|
|
limit: usize,
|
|
|
|
|
|
include_total: bool,
|
|
|
|
|
|
) -> Result<(Vec<Folder>, Option<usize>), DomainError> {
|
2026-07-02 00:45:11 +02:00
|
|
|
|
let sql = format!(
|
2026-07-18 16:12:04 +00:00
|
|
|
|
"SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
2026-07-03 01:33:19 +02:00
|
|
|
|
fo.drive_id, \
|
2026-07-02 00:45:11 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
|
|
|
|
|
fo.created_by, fo.updated_by, \
|
|
|
|
|
|
COUNT(*) OVER() AS total_count \
|
|
|
|
|
|
FROM storage.folders fo \
|
|
|
|
|
|
WHERE fo.parent_id IS NULL \
|
|
|
|
|
|
AND NOT fo.is_trashed \
|
|
|
|
|
|
AND {CALLER_CAN_READ_DRIVE} \
|
|
|
|
|
|
ORDER BY fo.name \
|
|
|
|
|
|
LIMIT $2 OFFSET $3"
|
|
|
|
|
|
);
|
|
|
|
|
|
let rows: Vec<FolderRowPaginated> = sqlx::query_as(&sql)
|
|
|
|
|
|
.bind(caller_id)
|
|
|
|
|
|
.bind(limit as i64)
|
|
|
|
|
|
.bind(offset as i64)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
|
DomainError::internal_error("FolderDb", format!("list_root_folders_paginated: {e}"))
|
|
|
|
|
|
})?;
|
2026-02-16 00:22:42 +01:00
|
|
|
|
|
2026-03-02 01:30:34 +01:00
|
|
|
|
let total = if include_total {
|
2026-07-03 01:33:19 +02:00
|
|
|
|
Some(rows.first().map_or(0, |r| r.10) as usize)
|
2026-03-02 01:30:34 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let folders: Result<Vec<Folder>, DomainError> = rows
|
|
|
|
|
|
.into_iter()
|
2026-07-03 01:33:19 +02:00
|
|
|
|
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub, _total)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
|
|
|
|
|
})
|
2026-02-16 13:10:44 +01:00
|
|
|
|
.collect();
|
|
|
|
|
|
Ok((folders?, total))
|
2026-02-16 00:22:42 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 10:51:13 +02:00
|
|
|
|
async fn rename_folder(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
|
|
|
|
|
new_name: String,
|
|
|
|
|
|
caller_id: Uuid,
|
|
|
|
|
|
) -> Result<Folder, DomainError> {
|
2026-09-04 22:19:29 +02:00
|
|
|
|
// NFC-normalize the client-supplied new name — same reasoning as
|
|
|
|
|
|
// `create_folder` above (WebDAV `MOVE`, NC `MOVE`, REST rename all
|
|
|
|
|
|
// funnel here with raw client bytes). Cheap on the common path.
|
|
|
|
|
|
let new_name = normalize_storage_name_owned(new_name);
|
|
|
|
|
|
|
2026-02-24 17:15:36 +01:00
|
|
|
|
// 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.
|
2026-06-10 22:03:49 +02:00
|
|
|
|
// That multi-row rewrite can deadlock against the tree-ETag
|
|
|
|
|
|
// flusher's id-ordered ancestor bump — retry instead of failing
|
|
|
|
|
|
// the user's operation (40P01 only; 23505 still maps below).
|
2026-06-19 10:51:13 +02:00
|
|
|
|
//
|
|
|
|
|
|
// §14: `updated_by = $3` (caller_id) — the caller mutated this
|
|
|
|
|
|
// row, not the row's owner. In D2 a shared-drive member can
|
|
|
|
|
|
// rename a row they don't own; the previous `updated_by = user_id`
|
|
|
|
|
|
// would have silently recorded the wrong principal.
|
2026-06-10 22:03:49 +02:00
|
|
|
|
let row = retry_on_deadlock("folders.rename", || {
|
|
|
|
|
|
sqlx::query_as::<_, FolderRow>(
|
|
|
|
|
|
r#"
|
|
|
|
|
|
UPDATE storage.folders
|
2026-06-19 10:51:13 +02:00
|
|
|
|
SET name = $1, updated_at = NOW(), updated_by = $3
|
2026-06-10 22:03:49 +02:00
|
|
|
|
WHERE id = $2::uuid AND NOT is_trashed
|
2026-07-18 16:12:04 +00:00
|
|
|
|
RETURNING id, name, path, parent_id, drive_id,
|
2026-06-10 22:03:49 +02:00
|
|
|
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM updated_at)::bigint,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
|
|
|
|
|
created_by, updated_by
|
2026-06-10 22:03:49 +02:00
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(&new_name)
|
|
|
|
|
|
.bind(id)
|
2026-06-19 10:51:13 +02:00
|
|
|
|
.bind(caller_id)
|
2026-06-10 22:03:49 +02:00
|
|
|
|
.fetch_optional(self.pool())
|
|
|
|
|
|
})
|
2026-02-14 20:22:19 +01:00
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| {
|
2026-02-15 17:53:25 +01:00
|
|
|
|
if let sqlx::Error::Database(ref db_err) = e
|
2026-02-15 18:04:32 +01:00
|
|
|
|
&& db_err.code().as_deref() == Some("23505")
|
|
|
|
|
|
{
|
|
|
|
|
|
return DomainError::already_exists("Folder", format!("{new_name} already exists"));
|
|
|
|
|
|
}
|
2026-02-14 20:22:19 +01:00
|
|
|
|
DomainError::internal_error("FolderDb", format!("rename: {e}"))
|
2026-03-02 01:30:34 +01:00
|
|
|
|
})?
|
|
|
|
|
|
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-06-19 07:49:33 +02:00
|
|
|
|
Self::row_to_folder(
|
2026-07-03 01:33:19 +02:00
|
|
|
|
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
|
2026-06-19 07:49:33 +02:00
|
|
|
|
)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn move_folder(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
|
|
|
|
|
new_parent_id: Option<&str>,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
caller_id: Uuid,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
) -> Result<Folder, DomainError> {
|
2026-02-24 17:15:36 +01:00
|
|
|
|
// 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.
|
2026-06-10 22:03:49 +02:00
|
|
|
|
// Retried on deadlock vs the tree-ETag flusher (see rename_folder).
|
2026-06-19 10:51:13 +02:00
|
|
|
|
//
|
|
|
|
|
|
// §14: `updated_by = $3` (caller_id), see rename_folder.
|
2026-06-29 21:11:40 +02:00
|
|
|
|
//
|
|
|
|
|
|
// D6: also sync `drive_id` from the destination parent on
|
|
|
|
|
|
// cross-drive moves. The CTE-derived `dest.drive_id` is
|
|
|
|
|
|
// assigned via COALESCE so a root-level move (no destination —
|
|
|
|
|
|
// `new_parent_id = NULL`) keeps the existing drive_id, mirroring
|
|
|
|
|
|
// the file move path. The cascade trigger
|
|
|
|
|
|
// (`cascade_folder_path`) then propagates the new drive_id to
|
|
|
|
|
|
// every descendant folder + file in the subtree — see
|
|
|
|
|
|
// `migrations/20260807000000_cascade_drive_id_on_folder_move.sql`.
|
2026-06-10 22:03:49 +02:00
|
|
|
|
let row = retry_on_deadlock("folders.move", || {
|
|
|
|
|
|
sqlx::query_as::<_, FolderRow>(
|
|
|
|
|
|
r#"
|
2026-06-29 21:11:40 +02:00
|
|
|
|
WITH dest AS (
|
|
|
|
|
|
SELECT drive_id FROM storage.folders WHERE id = $1::uuid
|
|
|
|
|
|
)
|
|
|
|
|
|
UPDATE storage.folders f
|
|
|
|
|
|
SET parent_id = $1::uuid,
|
|
|
|
|
|
drive_id = COALESCE((SELECT drive_id FROM dest), f.drive_id),
|
|
|
|
|
|
updated_at = NOW(),
|
|
|
|
|
|
updated_by = $3
|
|
|
|
|
|
WHERE f.id = $2::uuid AND NOT f.is_trashed
|
2026-07-18 16:12:04 +00:00
|
|
|
|
RETURNING f.id, f.name, f.path, f.parent_id, f.drive_id,
|
2026-06-29 21:11:40 +02:00
|
|
|
|
EXTRACT(EPOCH FROM f.created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM f.updated_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM f.tree_modified_at)::bigint,
|
|
|
|
|
|
f.created_by, f.updated_by
|
2026-06-10 22:03:49 +02:00
|
|
|
|
"#,
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(new_parent_id)
|
|
|
|
|
|
.bind(id)
|
2026-06-19 10:51:13 +02:00
|
|
|
|
.bind(caller_id)
|
2026-06-10 22:03:49 +02:00
|
|
|
|
.fetch_optional(self.pool())
|
|
|
|
|
|
})
|
2026-02-14 20:22:19 +01:00
|
|
|
|
.await
|
2026-03-02 01:30:34 +01:00
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?
|
|
|
|
|
|
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-06-19 07:49:33 +02:00
|
|
|
|
Self::row_to_folder(
|
2026-07-03 01:33:19 +02:00
|
|
|
|
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, row.8, row.9,
|
2026-06-19 07:49:33 +02:00
|
|
|
|
)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
|
2026-02-26 00:08:38 +01:00
|
|
|
|
// Delete all files whose folder is anywhere in the subtree.
|
|
|
|
|
|
// Uses the GiST-indexed ltree `<@` operator — O(log N) vs the
|
|
|
|
|
|
// O(depth × N) recursive CTE it replaces.
|
2026-06-10 22:03:49 +02:00
|
|
|
|
// Both statements retried on deadlock vs the tree-ETag flusher's
|
|
|
|
|
|
// id-ordered ancestor bump (multi-row exclusive locks).
|
|
|
|
|
|
retry_on_deadlock("folders.delete_files", || {
|
|
|
|
|
|
sqlx::query(
|
|
|
|
|
|
"DELETE FROM storage.files \
|
|
|
|
|
|
WHERE folder_id IN ( \
|
|
|
|
|
|
SELECT id FROM storage.folders \
|
|
|
|
|
|
WHERE lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
|
|
|
|
|
|
)",
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(id)
|
|
|
|
|
|
.execute(self.pool())
|
|
|
|
|
|
})
|
2026-02-20 12:27:52 +01:00
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("delete files: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
// Then delete the folder (CASCADE will remove descendant folders)
|
2026-06-10 22:03:49 +02:00
|
|
|
|
let result = retry_on_deadlock("folders.delete", || {
|
|
|
|
|
|
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}")))?;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
|
|
|
|
|
if result.rows_affected() == 0 {
|
|
|
|
|
|
return Err(DomainError::not_found("Folder", id));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 07:49:33 +02:00
|
|
|
|
async fn folder_exists(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
storage_path: &StoragePath,
|
|
|
|
|
|
drive_id: Uuid,
|
|
|
|
|
|
) -> Result<bool, DomainError> {
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let path_str = storage_path.to_string();
|
|
|
|
|
|
let lookup = path_str.strip_prefix('/').unwrap_or(&path_str);
|
|
|
|
|
|
|
2026-06-19 07:49:33 +02:00
|
|
|
|
// Post-D0 `storage.folders.path` repeats across drives —
|
|
|
|
|
|
// filter by `drive_id` to scope the existence check.
|
2026-02-16 13:10:44 +01:00
|
|
|
|
let exists: bool = sqlx::query_scalar(
|
2026-06-19 07:49:33 +02:00
|
|
|
|
"SELECT EXISTS(SELECT 1 FROM storage.folders \
|
|
|
|
|
|
WHERE path = $1 AND drive_id = $2 AND NOT is_trashed)",
|
2026-02-16 13:10:44 +01:00
|
|
|
|
)
|
|
|
|
|
|
.bind(lookup)
|
2026-06-19 07:49:33 +02:00
|
|
|
|
.bind(drive_id)
|
2026-02-16 13:10:44 +01:00
|
|
|
|
.fetch_one(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("exists: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
Ok(exists)
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError> {
|
2026-02-21 13:33:18 +01:00
|
|
|
|
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))?;
|
2026-02-16 13:10:44 +01:00
|
|
|
|
|
|
|
|
|
|
Ok(StoragePath::from_string(&path))
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Trash operations ──
|
|
|
|
|
|
|
2026-06-19 10:51:13 +02:00
|
|
|
|
async fn move_to_trash(&self, folder_id: &str, caller_id: Uuid) -> Result<(), DomainError> {
|
2026-06-16 23:35:23 +02:00
|
|
|
|
// Soft-delete the whole subtree in one statement: the root flips
|
|
|
|
|
|
// `is_trashed` and records `original_parent_id` so restore knows
|
|
|
|
|
|
// where to put it back; every descendant (folder or file) that
|
|
|
|
|
|
// wasn't already in trash flips `is_trashed` too but leaves the
|
|
|
|
|
|
// `original_*` column NULL. That NULL is the marker the restore
|
|
|
|
|
|
// path uses to tell "cascade-trashed with the root" from
|
|
|
|
|
|
// "independently trashed earlier" — the latter must stay in
|
|
|
|
|
|
// trash even when the root is restored.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Without this cascade, descendants used to remain `is_trashed = false`
|
|
|
|
|
|
// and stay directly addressable by their full path (PROPFIND on
|
|
|
|
|
|
// `/g9-tree/file.txt` still resolved 207 even though the parent
|
|
|
|
|
|
// collection was gone) — a class of data-integrity drift that
|
|
|
|
|
|
// confused desktop-sync tree walks.
|
2026-06-19 10:51:13 +02:00
|
|
|
|
//
|
|
|
|
|
|
// §14: all three CTE branches stamp `updated_by = $2`
|
|
|
|
|
|
// (caller_id). The cascade is "the caller trashed this
|
|
|
|
|
|
// subtree", not "each owner trashed their own row".
|
2026-06-10 22:03:49 +02:00
|
|
|
|
let result = retry_on_deadlock("folders.trash", || {
|
|
|
|
|
|
sqlx::query_scalar::<_, i64>(
|
|
|
|
|
|
r#"
|
2026-06-16 23:35:23 +02:00
|
|
|
|
WITH trash_root AS (
|
2026-06-10 22:03:49 +02:00
|
|
|
|
UPDATE storage.folders
|
|
|
|
|
|
SET is_trashed = TRUE,
|
|
|
|
|
|
trashed_at = NOW(),
|
|
|
|
|
|
original_parent_id = parent_id,
|
2026-06-18 13:29:41 +02:00
|
|
|
|
updated_at = NOW(),
|
2026-06-19 10:51:13 +02:00
|
|
|
|
updated_by = $2
|
2026-06-10 22:03:49 +02:00
|
|
|
|
WHERE id = $1::uuid AND NOT is_trashed
|
2026-06-16 23:35:23 +02:00
|
|
|
|
RETURNING id, lpath
|
|
|
|
|
|
),
|
|
|
|
|
|
trash_descendant_folders AS (
|
|
|
|
|
|
UPDATE storage.folders f
|
|
|
|
|
|
SET is_trashed = TRUE,
|
|
|
|
|
|
trashed_at = NOW(),
|
2026-06-18 13:29:41 +02:00
|
|
|
|
updated_at = NOW(),
|
2026-06-19 10:51:13 +02:00
|
|
|
|
updated_by = $2
|
2026-06-16 23:35:23 +02:00
|
|
|
|
FROM trash_root tr
|
|
|
|
|
|
WHERE f.lpath <@ tr.lpath
|
|
|
|
|
|
AND f.id != tr.id
|
|
|
|
|
|
AND NOT f.is_trashed
|
|
|
|
|
|
RETURNING 1
|
|
|
|
|
|
),
|
|
|
|
|
|
trash_descendant_files AS (
|
|
|
|
|
|
UPDATE storage.files fi
|
|
|
|
|
|
SET is_trashed = TRUE,
|
|
|
|
|
|
trashed_at = NOW(),
|
2026-06-18 13:29:41 +02:00
|
|
|
|
updated_at = NOW(),
|
2026-06-19 10:51:13 +02:00
|
|
|
|
updated_by = $2
|
2026-06-16 23:35:23 +02:00
|
|
|
|
FROM trash_root tr
|
|
|
|
|
|
JOIN storage.folders f ON f.lpath <@ tr.lpath
|
|
|
|
|
|
WHERE fi.folder_id = f.id
|
|
|
|
|
|
AND NOT fi.is_trashed
|
2026-06-10 22:03:49 +02:00
|
|
|
|
RETURNING 1
|
|
|
|
|
|
)
|
2026-06-16 23:35:23 +02:00
|
|
|
|
SELECT COUNT(*) FROM trash_root
|
2026-06-10 22:03:49 +02:00
|
|
|
|
"#,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
)
|
2026-06-10 22:03:49 +02:00
|
|
|
|
.bind(folder_id)
|
2026-06-19 10:51:13 +02:00
|
|
|
|
.bind(caller_id)
|
2026-06-10 22:03:49 +02:00
|
|
|
|
.fetch_one(self.pool())
|
|
|
|
|
|
})
|
2026-02-14 20:22:19 +01:00
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("trash: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
if result == 0 {
|
|
|
|
|
|
return Err(DomainError::not_found("Folder", folder_id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn restore_from_trash(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
folder_id: &str,
|
|
|
|
|
|
_original_path: &str,
|
2026-06-19 10:51:13 +02:00
|
|
|
|
caller_id: Uuid,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
) -> Result<(), DomainError> {
|
2026-06-16 23:35:23 +02:00
|
|
|
|
// Inverse of the cascade in `move_to_trash`: restore the root
|
|
|
|
|
|
// (BEFORE UPDATE trigger recomputes path/lpath via the parent_id
|
|
|
|
|
|
// change), then un-trash every descendant whose `original_*`
|
|
|
|
|
|
// column is NULL — those are the rows we cascade-trashed
|
|
|
|
|
|
// ourselves. Descendants that were independently trashed
|
|
|
|
|
|
// *before* this folder went to trash have `original_*` set, so
|
|
|
|
|
|
// they correctly stay in trash and continue to show up as
|
|
|
|
|
|
// top-level trash entries via `storage.trash_items`.
|
2026-06-19 10:51:13 +02:00
|
|
|
|
//
|
|
|
|
|
|
// §14: all three CTE branches stamp `updated_by = $2`
|
|
|
|
|
|
// (caller_id). Restoration is "the caller restored this
|
|
|
|
|
|
// subtree", regardless of who originally owned each row.
|
2026-06-10 22:03:49 +02:00
|
|
|
|
let result = retry_on_deadlock("folders.restore", || {
|
|
|
|
|
|
sqlx::query_scalar::<_, i64>(
|
|
|
|
|
|
r#"
|
2026-06-16 23:35:23 +02:00
|
|
|
|
WITH restore_root AS (
|
2026-06-10 22:03:49 +02:00
|
|
|
|
UPDATE storage.folders
|
|
|
|
|
|
SET is_trashed = FALSE,
|
|
|
|
|
|
trashed_at = NULL,
|
|
|
|
|
|
parent_id = COALESCE(original_parent_id, parent_id),
|
|
|
|
|
|
original_parent_id = NULL,
|
2026-06-18 13:29:41 +02:00
|
|
|
|
updated_at = NOW(),
|
2026-06-19 10:51:13 +02:00
|
|
|
|
updated_by = $2
|
2026-06-10 22:03:49 +02:00
|
|
|
|
WHERE id = $1::uuid AND is_trashed
|
2026-06-16 23:35:23 +02:00
|
|
|
|
RETURNING id, lpath
|
|
|
|
|
|
),
|
|
|
|
|
|
restore_descendant_folders AS (
|
|
|
|
|
|
UPDATE storage.folders f
|
|
|
|
|
|
SET is_trashed = FALSE,
|
|
|
|
|
|
trashed_at = NULL,
|
2026-06-18 13:29:41 +02:00
|
|
|
|
updated_at = NOW(),
|
2026-06-19 10:51:13 +02:00
|
|
|
|
updated_by = $2
|
2026-06-16 23:35:23 +02:00
|
|
|
|
FROM restore_root rr
|
|
|
|
|
|
WHERE f.lpath <@ rr.lpath
|
|
|
|
|
|
AND f.id != rr.id
|
|
|
|
|
|
AND f.is_trashed
|
|
|
|
|
|
AND f.original_parent_id IS NULL
|
|
|
|
|
|
RETURNING 1
|
|
|
|
|
|
),
|
|
|
|
|
|
restore_descendant_files AS (
|
|
|
|
|
|
UPDATE storage.files fi
|
|
|
|
|
|
SET is_trashed = FALSE,
|
|
|
|
|
|
trashed_at = NULL,
|
2026-06-18 13:29:41 +02:00
|
|
|
|
updated_at = NOW(),
|
2026-06-19 10:51:13 +02:00
|
|
|
|
updated_by = $2
|
2026-06-16 23:35:23 +02:00
|
|
|
|
FROM restore_root rr
|
|
|
|
|
|
JOIN storage.folders f ON f.lpath <@ rr.lpath
|
|
|
|
|
|
WHERE fi.folder_id = f.id
|
|
|
|
|
|
AND fi.is_trashed
|
|
|
|
|
|
AND fi.original_folder_id IS NULL
|
2026-06-10 22:03:49 +02:00
|
|
|
|
RETURNING 1
|
|
|
|
|
|
)
|
2026-06-16 23:35:23 +02:00
|
|
|
|
SELECT COUNT(*) FROM restore_root
|
2026-06-10 22:03:49 +02:00
|
|
|
|
"#,
|
2026-02-14 20:22:19 +01:00
|
|
|
|
)
|
2026-06-10 22:03:49 +02:00
|
|
|
|
.bind(folder_id)
|
2026-06-19 10:51:13 +02:00
|
|
|
|
.bind(caller_id)
|
2026-06-10 22:03:49 +02:00
|
|
|
|
.fetch_one(self.pool())
|
|
|
|
|
|
})
|
2026-02-14 20:22:19 +01:00
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("restore: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
if result == 0 {
|
|
|
|
|
|
return Err(DomainError::not_found("Folder", folder_id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 22:57:31 +02:00
|
|
|
|
async fn list_file_ids_in_subtree(&self, folder_id: &str) -> Result<Vec<String>, DomainError> {
|
|
|
|
|
|
// Same GiST subtree predicate the bulk DELETEs in `delete_folder` /
|
|
|
|
|
|
// `delete_folder_permanently` use — single index scan on
|
|
|
|
|
|
// `storage.folders.lpath`. Returns the file ids the cascade is
|
|
|
|
|
|
// about to reap so the caller can fire `on_file_deleted` per id.
|
|
|
|
|
|
let rows: Vec<String> = sqlx::query_scalar(
|
|
|
|
|
|
"SELECT f.id::text FROM storage.files f \
|
|
|
|
|
|
WHERE f.folder_id IN ( \
|
|
|
|
|
|
SELECT id FROM storage.folders \
|
|
|
|
|
|
WHERE lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
|
|
|
|
|
|
)",
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("list subtree files: {e}")))?;
|
|
|
|
|
|
Ok(rows)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 20:22:19 +01:00
|
|
|
|
async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError> {
|
2026-02-26 00:08:38 +01:00
|
|
|
|
// Delete all files whose folder is anywhere in the subtree
|
2026-06-10 22:03:49 +02:00
|
|
|
|
// (GiST ltree index, same pattern as delete_folder — both
|
|
|
|
|
|
// statements retried on deadlock vs the tree-ETag flusher).
|
|
|
|
|
|
retry_on_deadlock("folders.perm_delete_files", || {
|
|
|
|
|
|
sqlx::query(
|
|
|
|
|
|
"DELETE FROM storage.files \
|
|
|
|
|
|
WHERE folder_id IN ( \
|
|
|
|
|
|
SELECT id FROM storage.folders \
|
|
|
|
|
|
WHERE lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
|
|
|
|
|
|
)",
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.execute(self.pool())
|
|
|
|
|
|
})
|
2026-02-20 12:27:52 +01:00
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("perm delete files: {e}")))?;
|
|
|
|
|
|
|
|
|
|
|
|
// Then permanently delete folder — CASCADE handles descendant folders
|
2026-06-10 22:03:49 +02:00
|
|
|
|
let result = retry_on_deadlock("folders.perm_delete", || {
|
|
|
|
|
|
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}")))?;
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
|
|
|
|
|
if result.rows_affected() == 0 {
|
|
|
|
|
|
return Err(DomainError::not_found("Folder", folder_id));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 12:18:38 +01:00
|
|
|
|
/// Lists every folder in a subtree rooted at `folder_id` (inclusive).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Single GiST-indexed query: `fo.lpath <@ (root's lpath)`.
|
|
|
|
|
|
/// Ordered by `fo.path` so callers can iterate in directory order.
|
2026-03-04 21:40:38 +01:00
|
|
|
|
#[allow(clippy::type_complexity)]
|
2026-02-25 10:28:34 +01:00
|
|
|
|
async fn list_subtree_folders(&self, folder_id: &str) -> Result<Vec<Folder>, DomainError> {
|
2026-07-18 16:12:04 +00:00
|
|
|
|
let sql = "SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
2026-07-03 01:33:19 +02:00
|
|
|
|
fo.drive_id, \
|
2026-02-24 12:18:38 +01:00
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
2026-06-06 17:02:50 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
2026-06-19 10:51:13 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
|
|
|
|
|
fo.created_by, fo.updated_by \
|
2026-02-24 12:18:38 +01:00
|
|
|
|
FROM storage.folders fo \
|
|
|
|
|
|
WHERE fo.is_trashed = false \
|
|
|
|
|
|
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
|
|
|
|
|
|
ORDER BY fo.path";
|
|
|
|
|
|
|
2026-07-03 01:33:19 +02:00
|
|
|
|
let rows: Vec<FolderRow> = sqlx::query_as(sql)
|
2026-02-25 10:28:34 +01:00
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
|
DomainError::internal_error("FolderDb", format!("subtree folders: {e}"))
|
|
|
|
|
|
})?;
|
2026-02-24 12:18:38 +01:00
|
|
|
|
|
|
|
|
|
|
rows.into_iter()
|
2026-07-03 01:33:19 +02:00
|
|
|
|
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
2026-02-24 12:18:38 +01:00
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 17:15:36 +01:00
|
|
|
|
/// SQL-level folder search with name filter, user isolation, and
|
|
|
|
|
|
/// recursive / non-recursive modes.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// - Non-recursive: `WHERE parent_id = $1 AND user_id = $2 [AND LIKE]`
|
|
|
|
|
|
/// - Recursive + folder_id: delegates to `list_descendant_folders`
|
2026-07-02 00:45:11 +02:00
|
|
|
|
/// - Recursive + no folder_id: drive-scoped `EXISTS role_grants` [AND LIKE]
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Post-PR-B: filters by drive-membership grants inline (via
|
|
|
|
|
|
/// `caller_group_ids`) instead of `user_id = $caller`.
|
2026-03-04 21:40:38 +01:00
|
|
|
|
#[allow(clippy::type_complexity)]
|
2026-02-24 17:15:36 +01:00
|
|
|
|
async fn search_folders(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
parent_id: Option<&str>,
|
|
|
|
|
|
name_contains: Option<&str>,
|
2026-07-02 00:45:11 +02:00
|
|
|
|
caller_id: Uuid,
|
2026-02-24 17:15:36 +01:00
|
|
|
|
recursive: bool,
|
2026-07-26 14:42:04 +02:00
|
|
|
|
) -> Result<(Vec<Folder>, Vec<(bool, bool)>), DomainError> {
|
|
|
|
|
|
// Recursive with folder scope → existing optimised ltree scan.
|
|
|
|
|
|
// `list_descendant_folders` doesn't compute caller_flags today —
|
|
|
|
|
|
// return `(false, false)` per row until the ltree path is
|
|
|
|
|
|
// upgraded in a follow-up. Bounded UI impact: subtree-scoped
|
|
|
|
|
|
// searches rarely surface a specific folder as favorited /
|
|
|
|
|
|
// shared, and the flags path elsewhere fills the gap for the
|
|
|
|
|
|
// hot `/api/search` case.
|
2026-02-25 10:28:34 +01:00
|
|
|
|
if recursive && let Some(fid) = parent_id {
|
2026-07-26 14:42:04 +02:00
|
|
|
|
let folders = self
|
2026-07-02 00:45:11 +02:00
|
|
|
|
.list_descendant_folders(fid, name_contains, caller_id)
|
2026-07-26 14:42:04 +02:00
|
|
|
|
.await?;
|
|
|
|
|
|
let flags = vec![(false, false); folders.len()];
|
|
|
|
|
|
return Ok((folders, flags));
|
2026-02-24 17:15:36 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 01:28:59 +00:00
|
|
|
|
// Build optional name filter — use ILIKE (case-insensitive) so the
|
|
|
|
|
|
// GIN trigram index idx_folders_name_trgm is used instead of a seq scan.
|
2026-02-24 17:15:36 +01:00
|
|
|
|
let (name_clause, name_pattern) = match name_contains {
|
2026-03-02 01:28:59 +00:00
|
|
|
|
Some(name) if name.len() >= 3 => (
|
2026-02-24 17:15:36 +01:00
|
|
|
|
if recursive {
|
2026-03-02 01:28:59 +00:00
|
|
|
|
" AND fo.name ILIKE $2"
|
2026-02-24 17:15:36 +01:00
|
|
|
|
} else {
|
2026-03-02 01:28:59 +00:00
|
|
|
|
" AND fo.name ILIKE $3"
|
2026-02-24 17:15:36 +01:00
|
|
|
|
},
|
2026-03-05 14:52:11 +01:00
|
|
|
|
Some(super::like_escape(name)),
|
2026-02-24 17:15:36 +01:00
|
|
|
|
),
|
|
|
|
|
|
_ => ("", None),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-26 14:42:04 +02:00
|
|
|
|
// Per-row caller-flag EXISTS subqueries. Same pattern as
|
|
|
|
|
|
// `search_files_paginated` in the sibling file repo: narrow
|
|
|
|
|
|
// indexes make this a few extra μs per row.
|
|
|
|
|
|
const FAV_SHR_COLUMNS: &str = "EXISTS ( \
|
|
|
|
|
|
SELECT 1 FROM auth.user_favorites uf \
|
|
|
|
|
|
WHERE uf.user_id = $1 \
|
|
|
|
|
|
AND uf.item_id = fo.id::text \
|
|
|
|
|
|
AND uf.item_type = 'folder' \
|
|
|
|
|
|
) AS is_favorite, \
|
|
|
|
|
|
EXISTS ( \
|
|
|
|
|
|
SELECT 1 FROM storage.role_grants g \
|
|
|
|
|
|
WHERE g.resource_id = fo.id \
|
|
|
|
|
|
AND g.resource_type = 'folder' \
|
|
|
|
|
|
) AS is_shared";
|
|
|
|
|
|
|
2026-02-24 17:15:36 +01:00
|
|
|
|
if recursive {
|
2026-07-02 00:45:11 +02:00
|
|
|
|
// Recursive, no folder scope → ALL folders in caller's readable drives
|
2026-02-24 17:15:36 +01:00
|
|
|
|
let sql = format!(
|
2026-07-18 16:12:04 +00:00
|
|
|
|
"SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
2026-07-03 01:33:19 +02:00
|
|
|
|
fo.drive_id, \
|
2026-02-24 17:15:36 +01:00
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
2026-06-06 17:02:50 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
2026-06-19 10:51:13 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
2026-07-26 14:42:04 +02:00
|
|
|
|
fo.created_by, fo.updated_by, \
|
|
|
|
|
|
{FAV_SHR_COLUMNS} \
|
2026-02-24 17:15:36 +01:00
|
|
|
|
FROM storage.folders fo \
|
2026-07-02 00:45:11 +02:00
|
|
|
|
WHERE {CALLER_CAN_READ_DRIVE} \
|
2026-02-24 17:15:36 +01:00
|
|
|
|
AND fo.is_trashed = false \
|
|
|
|
|
|
{name_clause} \
|
|
|
|
|
|
ORDER BY fo.name"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-26 14:42:04 +02:00
|
|
|
|
let rows: Vec<FolderRowWithFlags> = if let Some(ref pattern) = name_pattern {
|
2026-02-25 10:28:34 +01:00
|
|
|
|
sqlx::query_as(&sql)
|
2026-07-02 00:45:11 +02:00
|
|
|
|
.bind(caller_id)
|
2026-02-25 10:28:34 +01:00
|
|
|
|
.bind(pattern)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(&sql)
|
2026-07-02 00:45:11 +02:00
|
|
|
|
.bind(caller_id)
|
2026-02-25 10:28:34 +01:00
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
|
2026-02-24 17:15:36 +01:00
|
|
|
|
|
2026-07-26 14:42:04 +02:00
|
|
|
|
return build_folders_with_flags(rows);
|
2026-02-24 17:15:36 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 00:45:11 +02:00
|
|
|
|
// Non-recursive: direct children of parent_id, restricted to drives
|
|
|
|
|
|
// the caller can read (parent_id already establishes the subtree).
|
2026-02-24 17:15:36 +01:00
|
|
|
|
let sql = if parent_id.is_some() {
|
|
|
|
|
|
format!(
|
2026-07-18 16:12:04 +00:00
|
|
|
|
"SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
2026-07-03 01:33:19 +02:00
|
|
|
|
fo.drive_id, \
|
2026-02-24 17:15:36 +01:00
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
2026-06-06 17:02:50 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
2026-06-19 10:51:13 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
2026-07-26 14:42:04 +02:00
|
|
|
|
fo.created_by, fo.updated_by, \
|
|
|
|
|
|
{FAV_SHR_COLUMNS} \
|
2026-02-24 17:15:36 +01:00
|
|
|
|
FROM storage.folders fo \
|
2026-07-02 00:45:11 +02:00
|
|
|
|
WHERE fo.parent_id = $2::uuid \
|
|
|
|
|
|
AND {CALLER_CAN_READ_DRIVE} \
|
2026-02-24 17:15:36 +01:00
|
|
|
|
AND fo.is_trashed = false \
|
|
|
|
|
|
{name_clause} \
|
|
|
|
|
|
ORDER BY fo.name"
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
2026-07-02 00:45:11 +02:00
|
|
|
|
// Root folders: parent_id IS NULL, params ($1=caller_id, $2=pattern)
|
2026-02-24 17:15:36 +01:00
|
|
|
|
let name_clause_root = match name_contains {
|
2026-03-02 01:28:59 +00:00
|
|
|
|
Some(name) if name.len() >= 3 => " AND fo.name ILIKE $2",
|
2026-02-24 17:15:36 +01:00
|
|
|
|
_ => "",
|
|
|
|
|
|
};
|
|
|
|
|
|
format!(
|
2026-07-18 16:12:04 +00:00
|
|
|
|
"SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
2026-07-03 01:33:19 +02:00
|
|
|
|
fo.drive_id, \
|
2026-02-24 17:15:36 +01:00
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
2026-06-06 17:02:50 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
2026-06-19 10:51:13 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
2026-07-26 14:42:04 +02:00
|
|
|
|
fo.created_by, fo.updated_by, \
|
|
|
|
|
|
{FAV_SHR_COLUMNS} \
|
2026-02-24 17:15:36 +01:00
|
|
|
|
FROM storage.folders fo \
|
|
|
|
|
|
WHERE fo.parent_id IS NULL \
|
2026-07-02 00:45:11 +02:00
|
|
|
|
AND {CALLER_CAN_READ_DRIVE} \
|
2026-02-24 17:15:36 +01:00
|
|
|
|
AND fo.is_trashed = false \
|
|
|
|
|
|
{name_clause_root} \
|
|
|
|
|
|
ORDER BY fo.name"
|
|
|
|
|
|
)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-26 14:42:04 +02:00
|
|
|
|
let rows: Vec<FolderRowWithFlags> = if let Some(pid) = parent_id {
|
2026-02-25 10:28:34 +01:00
|
|
|
|
if let Some(ref pattern) = name_pattern {
|
2026-02-24 17:15:36 +01:00
|
|
|
|
sqlx::query_as(&sql)
|
2026-07-02 00:45:11 +02:00
|
|
|
|
.bind(caller_id)
|
2026-02-25 10:28:34 +01:00
|
|
|
|
.bind(pid)
|
2026-02-24 17:15:36 +01:00
|
|
|
|
.bind(pattern)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(&sql)
|
2026-07-02 00:45:11 +02:00
|
|
|
|
.bind(caller_id)
|
2026-02-25 10:28:34 +01:00
|
|
|
|
.bind(pid)
|
2026-02-24 17:15:36 +01:00
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
2026-02-25 10:28:34 +01:00
|
|
|
|
} else if let Some(ref pattern) = name_pattern {
|
|
|
|
|
|
sqlx::query_as(&sql)
|
2026-07-02 00:45:11 +02:00
|
|
|
|
.bind(caller_id)
|
2026-02-25 10:28:34 +01:00
|
|
|
|
.bind(pattern)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(&sql)
|
2026-07-02 00:45:11 +02:00
|
|
|
|
.bind(caller_id)
|
2026-02-25 10:28:34 +01:00
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
|
2026-02-24 17:15:36 +01:00
|
|
|
|
|
2026-07-26 14:42:04 +02:00
|
|
|
|
build_folders_with_flags(rows)
|
2026-02-24 17:15:36 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 00:45:11 +02:00
|
|
|
|
/// Lists all descendant folders in a subtree using ltree GiST index,
|
|
|
|
|
|
/// scoped to drives the caller can read.
|
2026-02-23 00:17:40 +01:00
|
|
|
|
///
|
|
|
|
|
|
/// Single SQL query: `fo.lpath <@ (root's lpath)` fetches the entire
|
2026-07-02 00:45:11 +02:00
|
|
|
|
/// subtree in one indexed scan. Post-PR-B: drive-membership filter
|
|
|
|
|
|
/// inline via `caller_group_ids`, replacing the legacy
|
|
|
|
|
|
/// `fo.user_id = $caller` predicate.
|
2026-03-04 21:40:38 +01:00
|
|
|
|
#[allow(clippy::type_complexity)]
|
2026-02-23 00:17:40 +01:00
|
|
|
|
async fn list_descendant_folders(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
folder_id: &str,
|
|
|
|
|
|
name_contains: Option<&str>,
|
2026-07-02 00:45:11 +02:00
|
|
|
|
caller_id: Uuid,
|
2026-02-23 00:17:40 +01:00
|
|
|
|
) -> Result<Vec<Folder>, DomainError> {
|
|
|
|
|
|
let (where_extra, name_pattern) = match name_contains {
|
2026-03-05 21:28:51 +01:00
|
|
|
|
Some(name) if name.len() >= 3 => {
|
|
|
|
|
|
(" AND fo.name ILIKE $3", Some(super::like_escape(name)))
|
|
|
|
|
|
}
|
2026-02-23 00:17:40 +01:00
|
|
|
|
_ => ("", None),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let sql = format!(
|
2026-07-18 16:12:04 +00:00
|
|
|
|
"SELECT fo.id, fo.name, fo.path, fo.parent_id, \
|
2026-07-03 01:33:19 +02:00
|
|
|
|
fo.drive_id, \
|
2026-02-23 00:17:40 +01:00
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
2026-06-06 17:02:50 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
2026-07-02 00:45:11 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
|
|
|
|
|
fo.created_by, fo.updated_by \
|
2026-02-23 00:17:40 +01:00
|
|
|
|
FROM storage.folders fo \
|
2026-07-02 00:45:11 +02:00
|
|
|
|
WHERE {CALLER_CAN_READ_DRIVE} \
|
2026-02-23 00:17:40 +01:00
|
|
|
|
AND fo.is_trashed = false \
|
|
|
|
|
|
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $2::uuid) \
|
|
|
|
|
|
AND fo.id != $2::uuid \
|
|
|
|
|
|
{where_extra} \
|
|
|
|
|
|
ORDER BY fo.name"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-03 01:33:19 +02:00
|
|
|
|
let rows: Vec<FolderRow> = if let Some(ref pattern) = name_pattern {
|
2026-02-25 10:28:34 +01:00
|
|
|
|
sqlx::query_as(&sql)
|
2026-07-02 00:45:11 +02:00
|
|
|
|
.bind(caller_id)
|
2026-02-25 10:28:34 +01:00
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.bind(pattern)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sqlx::query_as(&sql)
|
2026-07-02 00:45:11 +02:00
|
|
|
|
.bind(caller_id)
|
2026-02-25 10:28:34 +01:00
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?;
|
2026-02-23 00:17:40 +01:00
|
|
|
|
|
|
|
|
|
|
rows.into_iter()
|
2026-07-03 01:33:19 +02:00
|
|
|
|
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
2026-02-23 00:17:40 +01:00
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
2026-02-23 23:36:38 +01:00
|
|
|
|
|
2026-03-04 21:40:38 +01:00
|
|
|
|
#[allow(clippy::type_complexity)]
|
2026-02-23 23:36:38 +01:00
|
|
|
|
async fn suggest_folders_by_name(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
parent_id: Option<&str>,
|
|
|
|
|
|
query: &str,
|
|
|
|
|
|
limit: usize,
|
2026-07-16 21:07:18 +02:00
|
|
|
|
caller_id: uuid::Uuid,
|
2026-02-23 23:36:38 +01:00
|
|
|
|
) -> Result<Vec<Folder>, DomainError> {
|
2026-07-16 21:07:18 +02:00
|
|
|
|
// Same drive-scope filter as `suggest_files_by_name` — closed as
|
|
|
|
|
|
// AuthZ audit finding #1 (2026-07-12). `CALLER_CAN_READ_DRIVE`
|
|
|
|
|
|
// aliases `storage.folders` as `fo`; the pre-fix query aliased it
|
|
|
|
|
|
// as an unqualified `storage.folders`, so this rewrite adds the
|
|
|
|
|
|
// `fo` alias in every branch.
|
2026-03-05 14:52:11 +01:00
|
|
|
|
let pattern = super::like_escape(query);
|
2026-02-23 23:36:38 +01:00
|
|
|
|
let limit_i64 = limit as i64;
|
|
|
|
|
|
|
2026-03-04 23:55:08 +01:00
|
|
|
|
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
2026-07-16 21:07:18 +02:00
|
|
|
|
sqlx::query_as(&format!(
|
2026-03-04 23:55:08 +01:00
|
|
|
|
r#"
|
2026-07-18 16:12:04 +00:00
|
|
|
|
SELECT fo.id, fo.name, fo.path, fo.parent_id, fo.drive_id,
|
2026-07-16 21:07:18 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint,
|
|
|
|
|
|
fo.created_by, fo.updated_by
|
|
|
|
|
|
FROM storage.folders fo
|
|
|
|
|
|
WHERE {CALLER_CAN_READ_DRIVE}
|
|
|
|
|
|
AND fo.parent_id = $2::uuid
|
|
|
|
|
|
AND NOT fo.is_trashed
|
|
|
|
|
|
AND fo.name ILIKE $3
|
2026-02-23 23:36:38 +01:00
|
|
|
|
ORDER BY CASE
|
2026-07-16 21:07:18 +02:00
|
|
|
|
WHEN fo.name ILIKE $4 THEN 0
|
|
|
|
|
|
WHEN fo.name ILIKE $4 || '%' THEN 1
|
2026-02-23 23:36:38 +01:00
|
|
|
|
ELSE 2
|
|
|
|
|
|
END,
|
2026-07-16 21:07:18 +02:00
|
|
|
|
fo.name
|
|
|
|
|
|
LIMIT $5
|
|
|
|
|
|
"#
|
|
|
|
|
|
))
|
|
|
|
|
|
.bind(caller_id)
|
2026-03-04 23:55:08 +01:00
|
|
|
|
.bind(pid)
|
|
|
|
|
|
.bind(&pattern)
|
|
|
|
|
|
.bind(query)
|
|
|
|
|
|
.bind(limit_i64)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
} else {
|
2026-07-16 21:07:18 +02:00
|
|
|
|
sqlx::query_as(&format!(
|
2026-03-04 23:55:08 +01:00
|
|
|
|
r#"
|
2026-07-18 16:12:04 +00:00
|
|
|
|
SELECT fo.id, fo.name, fo.path, fo.parent_id, fo.drive_id,
|
2026-07-16 21:07:18 +02:00
|
|
|
|
EXTRACT(EPOCH FROM fo.created_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.updated_at)::bigint,
|
|
|
|
|
|
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint,
|
|
|
|
|
|
fo.created_by, fo.updated_by
|
|
|
|
|
|
FROM storage.folders fo
|
|
|
|
|
|
WHERE {CALLER_CAN_READ_DRIVE}
|
|
|
|
|
|
AND fo.parent_id IS NULL
|
|
|
|
|
|
AND NOT fo.is_trashed
|
|
|
|
|
|
AND fo.name ILIKE $2
|
2026-02-23 23:36:38 +01:00
|
|
|
|
ORDER BY CASE
|
2026-07-16 21:07:18 +02:00
|
|
|
|
WHEN fo.name ILIKE $3 THEN 0
|
|
|
|
|
|
WHEN fo.name ILIKE $3 || '%' THEN 1
|
2026-02-23 23:36:38 +01:00
|
|
|
|
ELSE 2
|
|
|
|
|
|
END,
|
2026-07-16 21:07:18 +02:00
|
|
|
|
fo.name
|
|
|
|
|
|
LIMIT $4
|
|
|
|
|
|
"#
|
|
|
|
|
|
))
|
|
|
|
|
|
.bind(caller_id)
|
2026-03-04 23:55:08 +01:00
|
|
|
|
.bind(&pattern)
|
|
|
|
|
|
.bind(query)
|
|
|
|
|
|
.bind(limit_i64)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
|
2026-02-23 23:36:38 +01:00
|
|
|
|
|
|
|
|
|
|
rows.into_iter()
|
2026-07-03 01:33:19 +02:00
|
|
|
|
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
|
|
|
|
|
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
2026-02-23 23:36:38 +01:00
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
2026-05-05 22:41:55 +02:00
|
|
|
|
|
|
|
|
|
|
async fn is_folder_in_subtree(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
candidate_folder_id: &str,
|
|
|
|
|
|
root_folder_id: &str,
|
|
|
|
|
|
) -> Result<bool, DomainError> {
|
2026-05-10 20:43:59 +02:00
|
|
|
|
let (Ok(candidate_uuid), Ok(root_uuid)) = (
|
|
|
|
|
|
Uuid::parse_str(candidate_folder_id),
|
|
|
|
|
|
Uuid::parse_str(root_folder_id),
|
|
|
|
|
|
) else {
|
|
|
|
|
|
return Ok(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-05 22:41:55 +02:00
|
|
|
|
let exists: bool = sqlx::query_scalar(
|
|
|
|
|
|
"SELECT EXISTS (\
|
|
|
|
|
|
SELECT 1 \
|
|
|
|
|
|
FROM storage.folders c, storage.folders r \
|
2026-05-10 20:43:59 +02:00
|
|
|
|
WHERE c.id = $1 \
|
|
|
|
|
|
AND r.id = $2 \
|
2026-05-05 22:41:55 +02:00
|
|
|
|
AND c.is_trashed = false \
|
|
|
|
|
|
AND r.is_trashed = false \
|
|
|
|
|
|
AND c.lpath <@ r.lpath \
|
|
|
|
|
|
)",
|
|
|
|
|
|
)
|
2026-05-10 20:43:59 +02:00
|
|
|
|
.bind(candidate_uuid)
|
|
|
|
|
|
.bind(root_uuid)
|
2026-05-05 22:41:55 +02:00
|
|
|
|
.fetch_one(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
|
DomainError::internal_error("FolderDb", format!("is_folder_in_subtree: {e}"))
|
|
|
|
|
|
})?;
|
|
|
|
|
|
Ok(exists)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn is_file_in_subtree(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
file_id: &str,
|
|
|
|
|
|
root_folder_id: &str,
|
|
|
|
|
|
) -> Result<bool, DomainError> {
|
2026-05-10 20:43:59 +02:00
|
|
|
|
let (Ok(file_uuid), Ok(root_uuid)) =
|
|
|
|
|
|
(Uuid::parse_str(file_id), Uuid::parse_str(root_folder_id))
|
|
|
|
|
|
else {
|
|
|
|
|
|
return Ok(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-05 22:41:55 +02:00
|
|
|
|
let exists: bool = sqlx::query_scalar(
|
|
|
|
|
|
"SELECT EXISTS (\
|
|
|
|
|
|
SELECT 1 \
|
|
|
|
|
|
FROM storage.files f \
|
|
|
|
|
|
JOIN storage.folders parent ON f.folder_id = parent.id \
|
2026-05-10 20:43:59 +02:00
|
|
|
|
JOIN storage.folders root ON root.id = $2 \
|
|
|
|
|
|
WHERE f.id = $1 \
|
2026-05-05 22:41:55 +02:00
|
|
|
|
AND f.is_trashed = false \
|
|
|
|
|
|
AND parent.is_trashed = false \
|
|
|
|
|
|
AND root.is_trashed = false \
|
|
|
|
|
|
AND parent.lpath <@ root.lpath \
|
|
|
|
|
|
)",
|
|
|
|
|
|
)
|
2026-05-10 20:43:59 +02:00
|
|
|
|
.bind(file_uuid)
|
|
|
|
|
|
.bind(root_uuid)
|
2026-05-05 22:41:55 +02:00
|
|
|
|
.fetch_one(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("is_file_in_subtree: {e}")))?;
|
|
|
|
|
|
Ok(exists)
|
|
|
|
|
|
}
|
2026-02-16 16:18:39 +01:00
|
|
|
|
}
|
2026-02-14 20:22:19 +01:00
|
|
|
|
|
2026-02-16 16:18:39 +01:00
|
|
|
|
// ── Extra helpers for blob-storage bootstrap ──
|
|
|
|
|
|
|
|
|
|
|
|
impl FolderDbRepository {
|
2026-06-23 00:44:24 +02:00
|
|
|
|
/// Returns `drive_id` for a given folder. Drives the new permission-floor
|
|
|
|
|
|
/// short-circuit in `PgAclEngine::check_inner` (a caller with any role
|
|
|
|
|
|
/// on the folder's drive automatically passes the check — drive
|
|
|
|
|
|
/// membership is the baseline floor per `drive.md §5`).
|
|
|
|
|
|
pub async fn get_folder_drive_id(&self, folder_id: &str) -> Result<Uuid, DomainError> {
|
|
|
|
|
|
sqlx::query_scalar::<_, Uuid>("SELECT drive_id FROM storage.folders WHERE id = $1::uuid")
|
|
|
|
|
|
.bind(folder_id)
|
|
|
|
|
|
.fetch_optional(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("drive_id lookup: {e}")))?
|
|
|
|
|
|
.ok_or_else(|| DomainError::not_found("Folder", folder_id))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-26 21:31:04 +02:00
|
|
|
|
/// Raw ancestor row returned by the recursive walk. `depth` is 0 at
|
|
|
|
|
|
/// the leaf, growing as we move up. `has_drive_grant` / `has_folder_grant`
|
|
|
|
|
|
/// are the two Read predicates the service uses to identify the
|
|
|
|
|
|
/// share/drive boundary and choose the access-source kind.
|
|
|
|
|
|
pub async fn fetch_ancestor_walk(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
caller_id: Uuid,
|
|
|
|
|
|
leaf_id: Uuid,
|
|
|
|
|
|
) -> Result<Vec<AncestorRow>, DomainError> {
|
|
|
|
|
|
// Recursive CTE walks `parent_id` from the leaf upward. Group ids
|
|
|
|
|
|
// are hoisted into a one-row CTE so `caller_group_ids($1)` fires
|
|
|
|
|
|
// once per query instead of per ancestor row (perf: the function
|
|
|
|
|
|
// is `RECURSIVE` and non-trivial). Grant EXISTS are unions over
|
|
|
|
|
|
// user + group subjects; the drive-grant subquery matches the
|
|
|
|
|
|
// ambient `CALLER_CAN_READ_DRIVE` predicate used elsewhere so
|
|
|
|
|
|
// access decisions stay consistent across the repo.
|
|
|
|
|
|
let sql = r#"
|
|
|
|
|
|
WITH RECURSIVE
|
|
|
|
|
|
groups AS (
|
|
|
|
|
|
SELECT ARRAY(SELECT storage.caller_group_ids($1)) AS ids
|
|
|
|
|
|
),
|
|
|
|
|
|
chain AS (
|
|
|
|
|
|
SELECT id, name, parent_id, drive_id, 0::int AS depth
|
|
|
|
|
|
FROM storage.folders WHERE id = $2::uuid
|
|
|
|
|
|
UNION ALL
|
|
|
|
|
|
SELECT f.id, f.name, f.parent_id, f.drive_id, c.depth + 1
|
|
|
|
|
|
FROM storage.folders f
|
|
|
|
|
|
JOIN chain c ON f.id = c.parent_id
|
|
|
|
|
|
WHERE c.parent_id IS NOT NULL
|
|
|
|
|
|
AND c.depth < 64
|
|
|
|
|
|
)
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
c.id,
|
|
|
|
|
|
c.name,
|
|
|
|
|
|
c.parent_id,
|
|
|
|
|
|
c.drive_id,
|
|
|
|
|
|
c.depth,
|
|
|
|
|
|
EXISTS (
|
|
|
|
|
|
SELECT 1 FROM storage.role_grants g, groups
|
|
|
|
|
|
WHERE g.resource_type = 'folder'
|
|
|
|
|
|
AND g.resource_id = c.id
|
|
|
|
|
|
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
|
|
|
|
|
AND ((g.subject_type = 'user' AND g.subject_id = $1)
|
|
|
|
|
|
OR (g.subject_type = 'group' AND g.subject_id = ANY(groups.ids)))
|
|
|
|
|
|
) AS has_folder_grant,
|
|
|
|
|
|
EXISTS (
|
|
|
|
|
|
SELECT 1 FROM storage.role_grants g, groups
|
|
|
|
|
|
WHERE g.resource_type = 'drive'
|
|
|
|
|
|
AND g.resource_id = c.drive_id
|
|
|
|
|
|
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
|
|
|
|
|
AND ((g.subject_type = 'user' AND g.subject_id = $1)
|
|
|
|
|
|
OR (g.subject_type = 'group' AND g.subject_id = ANY(groups.ids)))
|
|
|
|
|
|
) AS has_drive_grant
|
|
|
|
|
|
FROM chain c
|
|
|
|
|
|
ORDER BY c.depth DESC
|
|
|
|
|
|
"#;
|
|
|
|
|
|
sqlx::query_as::<_, AncestorRow>(sql)
|
|
|
|
|
|
.bind(caller_id)
|
|
|
|
|
|
.bind(leaf_id)
|
|
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("ancestor walk: {e}")))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 00:14:12 +02:00
|
|
|
|
/// Boundary-grant SHARER (`granted_by`) for `AccessSourceDto.subject`.
|
|
|
|
|
|
/// Given the resource (drive or folder) the caller reached the topmost
|
|
|
|
|
|
/// accessible ancestor through, find one active grant THAT CONCERNS
|
|
|
|
|
|
/// THE CALLER (user grant on caller, or group grant on one of the
|
|
|
|
|
|
/// caller's groups) and return `(kind, id, name)` for the user who
|
|
|
|
|
|
/// CREATED that grant — the sharer, not the grantee. The breadcrumb
|
|
|
|
|
|
/// consumer wants "who shared this with me?" not "who has permission?"
|
|
|
|
|
|
/// (Ed 2026-07-27).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Kind is always `user`: `granted_by` references `auth.users` and
|
|
|
|
|
|
/// is never a group (a group can't perform an action). Name is
|
|
|
|
|
|
/// looked up from `auth.users.username` in the same round-trip.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Grant selection prefers a user grant on the caller over a group
|
|
|
|
|
|
/// grant on one of their groups when both exist on the same resource
|
|
|
|
|
|
/// (the more specific one is likely the truer "who shared this with
|
|
|
|
|
|
/// me"). Only the OUTPUT pivots to the grantor.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// No `expires_at` filter — the ancestor-walk guard has already
|
|
|
|
|
|
/// established the caller is authorized to see this ancestor
|
|
|
|
|
|
/// (visibility decision made upstream). See
|
|
|
|
|
|
/// `feedback_trust_grant_janitor_no_expires_at_read` — this is the
|
|
|
|
|
|
/// narrow exception where skipping is safe.
|
|
|
|
|
|
pub async fn fetch_grant_by(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
caller_id: Uuid,
|
|
|
|
|
|
resource_type: &str,
|
|
|
|
|
|
resource_id: Uuid,
|
|
|
|
|
|
) -> Result<Option<(String, Uuid, Option<String>, String)>, DomainError> {
|
|
|
|
|
|
// Same row also carries the caller's role — piggyback the lookup
|
|
|
|
|
|
// so consumers can render "who shared this" AND "what can I do
|
|
|
|
|
|
// with it" from one round-trip (Ed 2026-07-27). The role is the
|
|
|
|
|
|
// grant's own role, i.e. the caller's effective role via THIS
|
|
|
|
|
|
// specific boundary grant. If the caller has additional grants
|
|
|
|
|
|
// via other channels the aggregate effective role may differ;
|
|
|
|
|
|
// `caller_role` on the boundary DTO reflects the boundary grant
|
|
|
|
|
|
// only.
|
|
|
|
|
|
let sql = r#"
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
'user'::text AS kind,
|
|
|
|
|
|
g.granted_by AS id,
|
|
|
|
|
|
(SELECT u.username FROM auth.users u WHERE u.id = g.granted_by) AS name,
|
|
|
|
|
|
g.role::text AS role
|
|
|
|
|
|
FROM storage.role_grants g
|
|
|
|
|
|
WHERE g.resource_type = $2
|
|
|
|
|
|
AND g.resource_id = $3::uuid
|
|
|
|
|
|
AND ( (g.subject_type = 'user' AND g.subject_id = $1)
|
|
|
|
|
|
OR (g.subject_type = 'group' AND g.subject_id IN
|
|
|
|
|
|
(SELECT storage.caller_group_ids($1))) )
|
|
|
|
|
|
ORDER BY g.subject_type = 'user' DESC
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
"#;
|
|
|
|
|
|
sqlx::query_as::<_, (String, Uuid, Option<String>, String)>(sql)
|
|
|
|
|
|
.bind(caller_id)
|
|
|
|
|
|
.bind(resource_type)
|
|
|
|
|
|
.bind(resource_id)
|
|
|
|
|
|
.fetch_optional(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("grant by lookup: {e}")))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-26 21:31:04 +02:00
|
|
|
|
/// Drive header (`id + name + kind`) for the drive-source arm of
|
|
|
|
|
|
/// `AccessSourceDto`. Read-only; no authz gate — the caller already
|
|
|
|
|
|
/// proved drive-membership via the ancestor walk before invoking.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Drive name lives on the drive's root folder, not the drive row
|
|
|
|
|
|
/// itself (`docs/plan/drive.md §3`). The JOIN resolves it; a drive
|
|
|
|
|
|
/// with a NULL `root_folder_id` returns None (backfill invariant
|
|
|
|
|
|
/// violation — surfaced as "drive vanished mid-query" in the caller).
|
|
|
|
|
|
pub async fn fetch_drive_header(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
drive_id: Uuid,
|
|
|
|
|
|
) -> Result<Option<(Uuid, String, String)>, DomainError> {
|
|
|
|
|
|
sqlx::query_as::<_, (Uuid, String, String)>(
|
|
|
|
|
|
"SELECT d.id, fo.name, d.kind::text \
|
|
|
|
|
|
FROM storage.drives d \
|
|
|
|
|
|
JOIN storage.folders fo ON fo.id = d.root_folder_id \
|
|
|
|
|
|
WHERE d.id = $1::uuid",
|
|
|
|
|
|
)
|
|
|
|
|
|
.bind(drive_id)
|
|
|
|
|
|
.fetch_optional(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("drive header lookup: {e}")))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 00:44:20 +02:00
|
|
|
|
/// Cursor-paginated combined listing of sub-folders and files inside
|
|
|
|
|
|
/// `parent_id`, sorted by `order_by`.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// **Authorization must be verified by the caller** before invoking this
|
|
|
|
|
|
/// method — no ownership filter is applied here.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Fetches `limit` rows (caller should pass `desired_page_size + 1` to
|
|
|
|
|
|
/// detect the existence of a next page). Returns raw [`FolderResourceRow`]
|
|
|
|
|
|
/// values; the handler / service layer converts them to DTOs.
|
2026-07-22 00:36:14 +02:00
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
2026-05-28 00:44:20 +02:00
|
|
|
|
pub async fn list_resources_paged(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
parent_id: Uuid,
|
2026-07-22 00:36:14 +02:00
|
|
|
|
caller_id: Uuid,
|
2026-05-28 00:44:20 +02:00
|
|
|
|
limit: usize,
|
|
|
|
|
|
cursor: Option<&FolderResourceCursor>,
|
|
|
|
|
|
order_by: &str,
|
|
|
|
|
|
kinds: Option<&[ResourceKind]>,
|
|
|
|
|
|
reverse: bool,
|
|
|
|
|
|
) -> Result<Vec<FolderResourceRow>, DomainError> {
|
|
|
|
|
|
let include_folders = kinds.is_none_or(|k| k.contains(&ResourceKind::Folder));
|
|
|
|
|
|
let include_files = kinds.is_none_or(|k| k.contains(&ResourceKind::File));
|
|
|
|
|
|
|
|
|
|
|
|
if !include_folders && !include_files {
|
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── CTE branches ────────────────────────────────────────────────────
|
|
|
|
|
|
let folder_branch = r#"
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
'folder'::text AS resource_type,
|
|
|
|
|
|
f.id,
|
|
|
|
|
|
f.name,
|
|
|
|
|
|
f.parent_id AS folder_id,
|
|
|
|
|
|
NULL::text AS mime_type,
|
|
|
|
|
|
-1::bigint AS size,
|
|
|
|
|
|
f.created_at,
|
|
|
|
|
|
f.updated_at AS modified_at,
|
2026-06-29 21:55:47 +02:00
|
|
|
|
f.drive_id,
|
2026-06-06 19:51:51 +02:00
|
|
|
|
NULL::text AS blob_hash,
|
2026-07-20 19:52:51 +02:00
|
|
|
|
f.created_by,
|
|
|
|
|
|
f.updated_by,
|
2026-07-22 00:36:14 +02:00
|
|
|
|
EXISTS (
|
|
|
|
|
|
SELECT 1 FROM auth.user_favorites uf
|
|
|
|
|
|
WHERE uf.user_id = $7::uuid
|
|
|
|
|
|
AND uf.item_id = f.id::text
|
|
|
|
|
|
AND uf.item_type = 'folder'
|
|
|
|
|
|
) AS is_favorite,
|
|
|
|
|
|
EXISTS (
|
|
|
|
|
|
SELECT 1 FROM storage.role_grants g
|
|
|
|
|
|
WHERE g.resource_id = f.id
|
|
|
|
|
|
AND g.resource_type = 'folder'
|
|
|
|
|
|
) AS is_shared,
|
2026-05-28 00:44:20 +02:00
|
|
|
|
LOWER(f.name) AS sort_str,
|
|
|
|
|
|
0::bigint AS type_order,
|
|
|
|
|
|
0::int AS folder_first
|
|
|
|
|
|
FROM storage.folders f
|
|
|
|
|
|
WHERE f.parent_id = $1::uuid AND NOT f.is_trashed
|
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
|
|
let file_branch = r#"
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
'file'::text AS resource_type,
|
|
|
|
|
|
fm.id,
|
|
|
|
|
|
fm.name,
|
|
|
|
|
|
fm.folder_id,
|
|
|
|
|
|
fm.mime_type,
|
|
|
|
|
|
fm.size::bigint,
|
|
|
|
|
|
fm.created_at,
|
|
|
|
|
|
fm.updated_at AS modified_at,
|
2026-06-29 21:55:47 +02:00
|
|
|
|
fm.drive_id,
|
2026-06-06 19:51:51 +02:00
|
|
|
|
fm.blob_hash,
|
2026-07-20 19:52:51 +02:00
|
|
|
|
fm.created_by,
|
|
|
|
|
|
fm.updated_by,
|
2026-07-22 00:36:14 +02:00
|
|
|
|
EXISTS (
|
|
|
|
|
|
SELECT 1 FROM auth.user_favorites uf
|
|
|
|
|
|
WHERE uf.user_id = $7::uuid
|
|
|
|
|
|
AND uf.item_id = fm.id::text
|
|
|
|
|
|
AND uf.item_type = 'file'
|
|
|
|
|
|
) AS is_favorite,
|
|
|
|
|
|
EXISTS (
|
|
|
|
|
|
SELECT 1 FROM storage.role_grants g
|
|
|
|
|
|
WHERE g.resource_id = fm.id
|
|
|
|
|
|
AND g.resource_type = 'file'
|
|
|
|
|
|
) AS is_shared,
|
2026-05-28 00:44:20 +02:00
|
|
|
|
LOWER(fm.name) AS sort_str,
|
|
|
|
|
|
fm.category_order::bigint AS type_order,
|
|
|
|
|
|
1::int AS folder_first
|
|
|
|
|
|
FROM storage.files fm
|
|
|
|
|
|
WHERE fm.folder_id = $1::uuid AND NOT fm.is_trashed
|
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
|
|
// ── Cursor binds ─────────────────────────────────────────────────────
|
|
|
|
|
|
// $1 = parent_id $2 = cursor_str $3 = cursor_int
|
|
|
|
|
|
// $4 = cursor_ts $5 = cursor_id $6 = limit
|
|
|
|
|
|
let cursor_str = cursor.and_then(|c| c.sort_str.clone());
|
|
|
|
|
|
let cursor_int = cursor.and_then(|c| c.sort_int);
|
|
|
|
|
|
let cursor_ts = cursor.and_then(|c| c.sort_ts);
|
|
|
|
|
|
let cursor_id = cursor.map(|c| c.resource_id);
|
|
|
|
|
|
|
2026-07-17 11:10:27 +00:00
|
|
|
|
// ── Per-branch cursor pushdown ───────────────────────────────────────
|
|
|
|
|
|
// The cursor is applied INSIDE each UNION-ALL branch as a sargable
|
|
|
|
|
|
// row-value comparison on base columns — not on the CTE's computed
|
|
|
|
|
|
// columns — and every branch pre-sorts and pre-limits, so Postgres
|
|
|
|
|
|
// reads O(limit) rows per branch instead of rescanning and
|
|
|
|
|
|
// top-N-sorting the entire folder on every page (19.5x on a
|
|
|
|
|
|
// 20k-entry folder, benches/LISTING-KEYSET.md). The "name" sort is
|
|
|
|
|
|
// served by the expression indexes idx_files_folder_lname /
|
|
|
|
|
|
// idx_folders_parent_lname (migration 20260918000000).
|
|
|
|
|
|
//
|
|
|
|
|
|
// Sort-key columns that are CONSTANT within a branch (folder_first,
|
|
|
|
|
|
// the folder branch's type_order = 0 and size = -1) are folded in
|
|
|
|
|
|
// Rust: depending on which group the cursor points into, the branch
|
|
|
|
|
|
// predicate shortens to a row-value over the remaining keys, the
|
|
|
|
|
|
// branch keeps all its rows, or the branch drops out entirely.
|
|
|
|
|
|
enum BranchCursor {
|
|
|
|
|
|
/// The cursor has moved past every row this branch can produce.
|
|
|
|
|
|
Drop,
|
|
|
|
|
|
/// Every row in this branch sorts after the cursor.
|
|
|
|
|
|
All,
|
|
|
|
|
|
/// Row-value comparison over the branch's non-constant sort keys.
|
|
|
|
|
|
Pred(String),
|
|
|
|
|
|
}
|
|
|
|
|
|
use BranchCursor::{All, Drop, Pred};
|
|
|
|
|
|
|
|
|
|
|
|
let has_cursor = cursor.is_some();
|
|
|
|
|
|
// (folder-branch cursor, file-branch cursor, per-branch ORDER BY on
|
|
|
|
|
|
// the branch's output aliases, outer merge ORDER BY)
|
|
|
|
|
|
let (folder_cur, file_cur, branch_order, outer_order) = match order_by {
|
2026-05-28 00:44:20 +02:00
|
|
|
|
"type" => {
|
2026-07-17 11:10:27 +00:00
|
|
|
|
let (op, ord) = if reverse {
|
|
|
|
|
|
("<", "ORDER BY type_order DESC, sort_str DESC, id DESC")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(">", "ORDER BY type_order ASC, sort_str ASC, id ASC")
|
|
|
|
|
|
};
|
|
|
|
|
|
let folder_cur = match cursor_int {
|
|
|
|
|
|
None => All,
|
|
|
|
|
|
// Folder rows have type_order = 0; a cursor sitting on a
|
|
|
|
|
|
// file (type_order > 0) either exhausts the folder group
|
|
|
|
|
|
// (ASC) or precedes all of it (DESC).
|
|
|
|
|
|
Some(c_to) if c_to > 0 => {
|
|
|
|
|
|
if reverse {
|
|
|
|
|
|
All
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Drop
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(_) => Pred(format!("(LOWER(f.name), f.id) {op} ($2, $5::uuid)")),
|
|
|
|
|
|
};
|
|
|
|
|
|
let file_cur = if has_cursor {
|
|
|
|
|
|
Pred(format!(
|
|
|
|
|
|
"(fm.category_order::bigint, LOWER(fm.name), fm.id) {op} ($3, $2, $5::uuid)"
|
|
|
|
|
|
))
|
2026-05-28 00:44:20 +02:00
|
|
|
|
} else {
|
2026-07-17 11:10:27 +00:00
|
|
|
|
All
|
|
|
|
|
|
};
|
|
|
|
|
|
(folder_cur, file_cur, ord, ord)
|
2026-05-28 00:44:20 +02:00
|
|
|
|
}
|
|
|
|
|
|
"modified_at" => {
|
2026-07-17 11:10:27 +00:00
|
|
|
|
let (op, ord) = if reverse {
|
|
|
|
|
|
(">", "ORDER BY modified_at ASC, id ASC")
|
2026-05-28 00:44:20 +02:00
|
|
|
|
} else {
|
2026-07-17 11:10:27 +00:00
|
|
|
|
("<", "ORDER BY modified_at DESC, id DESC")
|
|
|
|
|
|
};
|
|
|
|
|
|
let mk = |col: &str| {
|
|
|
|
|
|
if has_cursor {
|
|
|
|
|
|
Pred(format!("({col}.updated_at, {col}.id) {op} ($4, $5::uuid)"))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
All
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
(mk("f"), mk("fm"), ord, ord)
|
2026-05-28 00:44:20 +02:00
|
|
|
|
}
|
|
|
|
|
|
"created_at" => {
|
2026-07-17 11:10:27 +00:00
|
|
|
|
let (op, ord) = if reverse {
|
|
|
|
|
|
(">", "ORDER BY created_at ASC, id ASC")
|
2026-05-28 00:44:20 +02:00
|
|
|
|
} else {
|
2026-07-17 11:10:27 +00:00
|
|
|
|
("<", "ORDER BY created_at DESC, id DESC")
|
|
|
|
|
|
};
|
|
|
|
|
|
let mk = |col: &str| {
|
|
|
|
|
|
if has_cursor {
|
|
|
|
|
|
Pred(format!("({col}.created_at, {col}.id) {op} ($4, $5::uuid)"))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
All
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
(mk("f"), mk("fm"), ord, ord)
|
2026-05-28 00:44:20 +02:00
|
|
|
|
}
|
|
|
|
|
|
"size" => {
|
2026-07-17 11:10:27 +00:00
|
|
|
|
let (op, ord) = if reverse {
|
|
|
|
|
|
("<", "ORDER BY size DESC, id DESC")
|
2026-05-28 00:44:20 +02:00
|
|
|
|
} else {
|
2026-07-17 11:10:27 +00:00
|
|
|
|
(">", "ORDER BY size ASC, id ASC")
|
|
|
|
|
|
};
|
|
|
|
|
|
let folder_cur = match cursor_int {
|
|
|
|
|
|
None => All,
|
|
|
|
|
|
// Folder rows have size = -1; a cursor sitting on a file
|
|
|
|
|
|
// (size >= 0) exhausts the folder group (ASC) or precedes
|
|
|
|
|
|
// all of it (DESC).
|
|
|
|
|
|
Some(c_sz) if c_sz > -1 => {
|
|
|
|
|
|
if reverse {
|
|
|
|
|
|
All
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Drop
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(_) => Pred(format!("f.id {op} $5::uuid")),
|
|
|
|
|
|
};
|
|
|
|
|
|
let file_cur = if has_cursor {
|
|
|
|
|
|
Pred(format!("(fm.size::bigint, fm.id) {op} ($3, $5::uuid)"))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
All
|
|
|
|
|
|
};
|
|
|
|
|
|
(folder_cur, file_cur, ord, ord)
|
2026-05-28 00:44:20 +02:00
|
|
|
|
}
|
|
|
|
|
|
_ => {
|
2026-07-17 11:10:27 +00:00
|
|
|
|
// "name" (default): folder_first stays ASC so folders always
|
|
|
|
|
|
// precede files; only the alpha order within each group flips
|
|
|
|
|
|
// when reversed. cursor_int carries folder_first (0|1).
|
|
|
|
|
|
let op = if reverse { "<" } else { ">" };
|
|
|
|
|
|
let branch_ord = if reverse {
|
|
|
|
|
|
"ORDER BY sort_str DESC, id DESC"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"ORDER BY sort_str ASC, id ASC"
|
|
|
|
|
|
};
|
|
|
|
|
|
let outer_ord = if reverse {
|
|
|
|
|
|
"ORDER BY folder_first ASC, sort_str DESC, id DESC"
|
2026-05-28 00:44:20 +02:00
|
|
|
|
} else {
|
2026-07-17 11:10:27 +00:00
|
|
|
|
"ORDER BY folder_first ASC, sort_str ASC, id ASC"
|
|
|
|
|
|
};
|
|
|
|
|
|
let (folder_cur, file_cur) = match cursor_int {
|
|
|
|
|
|
None => (All, All),
|
|
|
|
|
|
// Cursor inside the folder group: folders continue after
|
|
|
|
|
|
// the row-value cursor; every file still follows.
|
|
|
|
|
|
Some(0) => (
|
|
|
|
|
|
Pred(format!("(LOWER(f.name), f.id) {op} ($2, $5::uuid)")),
|
|
|
|
|
|
All,
|
|
|
|
|
|
),
|
|
|
|
|
|
// Cursor inside the file group: the folder group is done.
|
|
|
|
|
|
Some(_) => (
|
|
|
|
|
|
Drop,
|
|
|
|
|
|
Pred(format!("(LOWER(fm.name), fm.id) {op} ($2, $5::uuid)")),
|
|
|
|
|
|
),
|
|
|
|
|
|
};
|
|
|
|
|
|
(folder_cur, file_cur, branch_ord, outer_ord)
|
2026-05-28 00:44:20 +02:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-17 11:10:27 +00:00
|
|
|
|
let wrap = |branch: &str, cur: &BranchCursor| -> Option<String> {
|
|
|
|
|
|
let extra = match cur {
|
|
|
|
|
|
Drop => return None,
|
|
|
|
|
|
All => String::new(),
|
|
|
|
|
|
Pred(p) => format!(" AND {p}"),
|
|
|
|
|
|
};
|
|
|
|
|
|
Some(format!(
|
|
|
|
|
|
"(SELECT * FROM ({branch}{extra}) b {branch_order} LIMIT $6)"
|
|
|
|
|
|
))
|
|
|
|
|
|
};
|
|
|
|
|
|
let mut branches = Vec::with_capacity(2);
|
|
|
|
|
|
if include_folders && let Some(b) = wrap(folder_branch, &folder_cur) {
|
|
|
|
|
|
branches.push(b);
|
|
|
|
|
|
}
|
|
|
|
|
|
if include_files && let Some(b) = wrap(file_branch, &file_cur) {
|
|
|
|
|
|
branches.push(b);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Every requested branch dropped out (e.g. folders-only listing with
|
|
|
|
|
|
// the cursor already past the folder group).
|
|
|
|
|
|
if branches.is_empty() {
|
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
|
}
|
|
|
|
|
|
let inner = branches.join(" UNION ALL ");
|
|
|
|
|
|
|
2026-05-28 00:44:20 +02:00
|
|
|
|
let sql = format!(
|
2026-07-17 11:10:27 +00:00
|
|
|
|
"SELECT resource_type, id, name, folder_id, mime_type, size, \
|
2026-07-03 00:48:14 +02:00
|
|
|
|
created_at, modified_at, drive_id, blob_hash, \
|
2026-07-20 19:52:51 +02:00
|
|
|
|
created_by, updated_by, \
|
2026-07-22 00:36:14 +02:00
|
|
|
|
is_favorite, is_shared, \
|
2026-06-29 21:55:47 +02:00
|
|
|
|
sort_str, type_order, folder_first \
|
2026-07-17 11:10:27 +00:00
|
|
|
|
FROM ({inner}) r \
|
|
|
|
|
|
{outer_order} \
|
2026-05-28 00:44:20 +02:00
|
|
|
|
LIMIT $6"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-22 00:36:14 +02:00
|
|
|
|
// 17 columns exceed sqlx's 16-element tuple `FromRow` limit,
|
|
|
|
|
|
// so we can't use `query_as::<_, (T1..T17)>` directly. Instead
|
|
|
|
|
|
// we fetch raw `PgRow`s and decode each column positionally
|
|
|
|
|
|
// into `FolderResourceRow` via `try_get_unchecked(idx)`. This
|
|
|
|
|
|
// matches Dio's positional shape (index decode, no per-row
|
|
|
|
|
|
// column-name HashMap) AND skips the intermediate tuple
|
|
|
|
|
|
// struct + row-to-struct move that a manual `FromRow` impl
|
|
|
|
|
|
// would introduce — one construction, one destination.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Column indices below MUST match the outer `SELECT` list
|
|
|
|
|
|
// above (resource_type, id, name, folder_id, mime_type, size,
|
|
|
|
|
|
// created_at, modified_at, drive_id, blob_hash, created_by,
|
|
|
|
|
|
// updated_by, is_favorite, is_shared, sort_str, type_order,
|
|
|
|
|
|
// folder_first).
|
|
|
|
|
|
let rows = sqlx::query(&sql)
|
2026-05-28 00:44:20 +02:00
|
|
|
|
.bind(parent_id)
|
|
|
|
|
|
.bind(cursor_str)
|
|
|
|
|
|
.bind(cursor_int)
|
|
|
|
|
|
.bind(cursor_ts)
|
|
|
|
|
|
.bind(cursor_id)
|
|
|
|
|
|
.bind(limit as i64)
|
2026-07-22 00:36:14 +02:00
|
|
|
|
.bind(caller_id)
|
2026-05-28 00:44:20 +02:00
|
|
|
|
.fetch_all(self.pool())
|
|
|
|
|
|
.await
|
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
|
DomainError::internal_error("FolderDb", format!("list_resources_paged: {e}"))
|
|
|
|
|
|
})?;
|
|
|
|
|
|
|
2026-07-22 00:36:14 +02:00
|
|
|
|
use sqlx::Row as _;
|
|
|
|
|
|
let decode_err = |col: usize, e: sqlx::Error| -> DomainError {
|
|
|
|
|
|
DomainError::internal_error(
|
|
|
|
|
|
"FolderDb",
|
|
|
|
|
|
format!("list_resources_paged decode col {col}: {e}"),
|
|
|
|
|
|
)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
rows.into_iter()
|
|
|
|
|
|
.map(|r| {
|
|
|
|
|
|
Ok(FolderResourceRow {
|
|
|
|
|
|
resource_type: r.try_get_unchecked(0).map_err(|e| decode_err(0, e))?,
|
|
|
|
|
|
id: r.try_get_unchecked(1).map_err(|e| decode_err(1, e))?,
|
|
|
|
|
|
name: r.try_get_unchecked(2).map_err(|e| decode_err(2, e))?,
|
|
|
|
|
|
parent_id: r.try_get_unchecked(3).map_err(|e| decode_err(3, e))?,
|
|
|
|
|
|
mime_type: r.try_get_unchecked(4).map_err(|e| decode_err(4, e))?,
|
|
|
|
|
|
size: r.try_get_unchecked(5).map_err(|e| decode_err(5, e))?,
|
|
|
|
|
|
created_at: r.try_get_unchecked(6).map_err(|e| decode_err(6, e))?,
|
|
|
|
|
|
modified_at: r.try_get_unchecked(7).map_err(|e| decode_err(7, e))?,
|
|
|
|
|
|
drive_id: r.try_get_unchecked(8).map_err(|e| decode_err(8, e))?,
|
|
|
|
|
|
blob_hash: r.try_get_unchecked(9).map_err(|e| decode_err(9, e))?,
|
|
|
|
|
|
created_by: r.try_get_unchecked(10).map_err(|e| decode_err(10, e))?,
|
|
|
|
|
|
updated_by: r.try_get_unchecked(11).map_err(|e| decode_err(11, e))?,
|
|
|
|
|
|
is_favorite: r.try_get_unchecked(12).map_err(|e| decode_err(12, e))?,
|
|
|
|
|
|
is_shared: r.try_get_unchecked(13).map_err(|e| decode_err(13, e))?,
|
|
|
|
|
|
sort_str: r.try_get_unchecked(14).map_err(|e| decode_err(14, e))?,
|
|
|
|
|
|
type_order: r.try_get_unchecked(15).map_err(|e| decode_err(15, e))?,
|
|
|
|
|
|
folder_first: r.try_get_unchecked(16).map_err(|e| decode_err(16, e))?,
|
|
|
|
|
|
})
|
2026-05-28 00:44:20 +02:00
|
|
|
|
})
|
2026-07-22 00:36:14 +02:00
|
|
|
|
.collect()
|
2026-05-28 00:44:20 +02:00
|
|
|
|
}
|
2026-02-14 20:22:19 +01:00
|
|
|
|
}
|