diff --git a/migrations/20260625000000_folder_tree_modified_at.sql b/migrations/20260625000000_folder_tree_modified_at.sql new file mode 100644 index 00000000..881d320d --- /dev/null +++ b/migrations/20260625000000_folder_tree_modified_at.sql @@ -0,0 +1,104 @@ +-- Folder rollup ETag: introduce `storage.folders.tree_modified_at`, +-- which is bumped whenever any descendant (file or folder) changes. +-- +-- Motivation: WebDAV / NextCloud sync clients use a collection's ETag +-- to decide "did anything change inside this folder since I last +-- looked?". Until now `Folder::etag()` returned the folder UUID +-- (constant for the row's life), which made the answer always "no" — +-- forcing clients to do periodic deep PROPFIND walks to discover new +-- files. With this column, `Folder::etag()` becomes +-- `{id_short}-{tree_modified_at}` and clients can do O(changed) +-- recursion instead of O(tree). +-- +-- The two triggers cascade an update timestamp up the ltree ancestor +-- chain on every file write and every folder mutation. Performance +-- ceiling: O(depth) row updates per mutation; deep concurrent writes +-- to the same root subtree can contend on the root row. + +ALTER TABLE storage.folders + ADD COLUMN tree_modified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- Backfill existing rows: collapse the rollup timestamp to the +-- per-folder updated_at. Clients re-walking after deploy will see +-- one batch of "looks new to me" responses, which they handle as a +-- content-match-no-download — the expected one-time resync wave. +UPDATE storage.folders SET tree_modified_at = updated_at; + + +-- File-side trigger: any INSERT/UPDATE/DELETE on storage.files +-- bumps the file's parent folder + all its ancestors in the ltree. +-- Root-level files (folder_id IS NULL) have no ancestors and do not +-- trigger any folder bump — the root listing isn't an etag-emitting +-- collection in OxiCloud's model (no virtual root folder row). +CREATE OR REPLACE FUNCTION storage.bump_folder_tree_from_file() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +DECLARE + target_folder_id UUID; + target_lpath ltree; +BEGIN + target_folder_id := COALESCE(NEW.folder_id, OLD.folder_id); + IF target_folder_id IS NULL THEN + RETURN COALESCE(NEW, OLD); + END IF; + + SELECT lpath INTO target_lpath + FROM storage.folders + WHERE id = target_folder_id; + + IF target_lpath IS NULL THEN + RETURN COALESCE(NEW, OLD); + END IF; + + -- `lpath @> target_lpath` matches the target folder AND every + -- ancestor up to the root. The GiST index on lpath keeps this + -- to an index range scan even on deep trees. + UPDATE storage.folders + SET tree_modified_at = NOW() + WHERE lpath @> target_lpath; + + RETURN COALESCE(NEW, OLD); +END; +$$; + +CREATE TRIGGER files_bump_folder_tree_etag + AFTER INSERT OR UPDATE OR DELETE ON storage.files + FOR EACH ROW EXECUTE FUNCTION storage.bump_folder_tree_from_file(); + + +-- Folder-side trigger: covers creates, deletes, renames, and moves. +-- A folder move changes its lpath — the OLD chain and NEW chain +-- both need bumping (old parents lost a child, new parents gained +-- one). Self-exclusion (id <> the changed row) avoids the row +-- bumping itself, which is meaningless and would amplify +-- contention on hot paths. +-- +-- The `pg_trigger_depth() > 1` guard breaks recursion: when this +-- trigger UPDATEs ancestor rows below, those UPDATEs would fire +-- the same trigger again. Without the guard, a single child +-- creation would cascade an unbounded number of upward writes. +CREATE OR REPLACE FUNCTION storage.bump_folder_tree_from_folder() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + IF pg_trigger_depth() > 1 THEN + RETURN COALESCE(NEW, OLD); + END IF; + + IF TG_OP IN ('DELETE', 'UPDATE') AND OLD.lpath IS NOT NULL THEN + UPDATE storage.folders + SET tree_modified_at = NOW() + WHERE lpath @> OLD.lpath AND id <> OLD.id; + END IF; + + IF TG_OP IN ('INSERT', 'UPDATE') AND NEW.lpath IS NOT NULL THEN + UPDATE storage.folders + SET tree_modified_at = NOW() + WHERE lpath @> NEW.lpath AND id <> NEW.id; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$; + +CREATE TRIGGER folders_bump_folder_tree_etag + AFTER INSERT OR UPDATE OR DELETE ON storage.folders + FOR EACH ROW EXECUTE FUNCTION storage.bump_folder_tree_from_folder(); diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index 68311240..4e58857d 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -30,8 +30,17 @@ pub struct Folder { /// Creation timestamp created_at: u64, - /// Last modification timestamp + /// Last modification timestamp of THIS folder row (rename, move, + /// metadata change). Does NOT bump when descendants change — + /// that signal lives on `tree_modified_at`. modified_at: u64, + + /// Latest `modified_at`-equivalent across the entire descendant + /// subtree. Bumped by a PostgreSQL trigger on any file or folder + /// write under this folder's ltree subtree. Source of the + /// HTTP ETag emitted in PROPFIND/GET/HEAD responses — see + /// [`Folder::etag`] for the formula and rationale. + tree_modified_at: u64, } // We no longer need this module, now we use a String directly @@ -47,6 +56,7 @@ impl Default for Folder { owner_id: None, created_at: 0, modified_at: 0, + tree_modified_at: 0, } } } @@ -92,10 +102,15 @@ impl Folder { owner_id, created_at: now, modified_at: now, + tree_modified_at: now, }) } - /// Creates a folder with specific timestamps (for reconstruction) + /// Creates a folder with specific timestamps (for reconstruction). + /// `tree_modified_at` defaults to `modified_at` — appropriate for + /// in-memory construction; database loads should always go via + /// [`Folder::with_timestamps_and_tree`] so the rollup value + /// reflects DB reality. pub fn with_timestamps( id: String, name: String, @@ -104,7 +119,7 @@ impl Folder { created_at: u64, modified_at: u64, ) -> FolderResult { - Self::with_timestamps_and_owner( + Self::with_timestamps_and_tree( id, name, storage_path, @@ -112,10 +127,15 @@ impl Folder { None, created_at, modified_at, + modified_at, ) } - /// Creates a folder with specific timestamps and owner (for DB reconstruction) + /// Creates a folder with specific timestamps and owner (legacy + /// constructor — `tree_modified_at` defaults to `modified_at`). + /// Prefer [`Folder::with_timestamps_and_tree`] for DB reconstruction + /// so the rollup ETag reflects descendant activity, not just this + /// row's own metadata. pub fn with_timestamps_and_owner( id: String, name: String, @@ -125,12 +145,36 @@ impl Folder { created_at: u64, modified_at: u64, ) -> FolderResult { - // Validate folder name + Self::with_timestamps_and_tree( + id, + name, + storage_path, + parent_id, + owner_id, + created_at, + modified_at, + modified_at, + ) + } + + /// Full constructor used by the PG repository when reading rows. + /// `tree_modified_at` comes from the trigger-maintained column on + /// `storage.folders` and feeds [`Folder::etag`]. + #[allow(clippy::too_many_arguments)] + pub fn with_timestamps_and_tree( + id: String, + name: String, + storage_path: StoragePath, + parent_id: Option, + owner_id: Option, + created_at: u64, + modified_at: u64, + tree_modified_at: u64, + ) -> FolderResult { if let Err(reason) = validate_storage_name(&name) { return Err(FolderError::InvalidFolderName(format!("{name}: {reason}"))); } - // Store the path string for serialization compatibility let path_string = storage_path.to_string(); Ok(Self { @@ -142,6 +186,7 @@ impl Folder { owner_id, created_at, modified_at, + tree_modified_at, }) } @@ -178,27 +223,39 @@ impl Folder { self.owner_id } - /// Opaque ETag string (raw, NOT HTTP-quoted). Handlers wrap in - /// `"…"` themselves at the HTTP boundary. + /// Latest descendant-write timestamp, maintained by a Postgres + /// trigger that walks the ltree ancestor chain on every file or + /// folder write inside this folder's subtree. See migration + /// `20260625000000_folder_tree_modified_at.sql` for the trigger + /// definition. + pub fn tree_modified_at(&self) -> u64 { + self.tree_modified_at + } + + /// Opaque HTTP ETag string (raw, NOT HTTP-quoted). Handlers wrap + /// in `"…"` themselves at the HTTP boundary. /// - /// **Current formula**: the folder's UUID — stable for the life of - /// the row, does NOT change when descendants are added/modified/ - /// deleted. This matches today's behaviour in every existing - /// folder ETag emission site and is the de-facto v1 contract. + /// **Formula**: `{id[..16]}-{tree_modified_at}`. /// - /// **Known limitation**: NextCloud's sync engine relies on a - /// collection's ETag changing whenever any descendant changes — - /// that's the signal it uses to decide "recurse into this folder - /// to find what's new". A constant ETag breaks NC's incremental - /// sync (forces periodic deep recrawl). - /// - /// A follow-up PR will introduce `storage.folders.tree_modified_at` - /// (bumped by trigger on any descendant write) and switch this - /// method to `format!("{}-{}", id_short, tree_modified_at)`. That - /// PR will be ETag-breaking — all clients re-walk once — so it's - /// kept separate from this refactor. - pub fn etag(&self) -> &str { - &self.id + /// - The 16-char UUID prefix gives the folder its identity + /// component — keeps two empty same-mtime folders distinct. + /// - `tree_modified_at` (Unix seconds) is the actual signal: + /// bumped by trigger whenever ANY descendant (file or + /// sub-folder, at any depth) is created, modified, deleted, + /// or moved. This is the contract NextCloud's sync engine + /// relies on — "did anything change inside this collection + /// since I last looked?". Until this column existed, the + /// answer was always "no" because the folder UUID never + /// changed; clients had to do periodic deep PROPFIND walks + /// to discover web-uploaded files. + /// - Renaming the folder itself does NOT change the etag's + /// identity portion (UUID is stable across renames). The + /// trigger does bump `tree_modified_at` on rename via the + /// folder-side trigger, so the etag still changes — which is + /// correct, the parent collection's listing changed. + pub fn etag(&self) -> String { + let prefix: String = self.id.chars().take(16).collect(); + format!("{}-{}", prefix, self.tree_modified_at) } /// Creates a new Folder instance from a DTO @@ -214,7 +271,11 @@ impl Folder { // Create storage_path from the string let storage_path = StoragePath::from_string(&path); - // Create directly without validation to avoid errors in DTO conversions + // Create directly without validation to avoid errors in DTO + // conversions. `tree_modified_at` defaults to `modified_at`: + // DTO round-trips lose the real rollup signal, so callers + // that need a freshly-rolled-up etag must reload from the + // repository. Self { id, name, @@ -224,6 +285,7 @@ impl Folder { owner_id: None, created_at, modified_at, + tree_modified_at: modified_at, } } @@ -261,6 +323,10 @@ impl Folder { owner_id: self.owner_id, created_at: self.created_at, modified_at: now, + // Renaming bumps both self and descendant rollup — + // ancestors' listings now show a new name, so the + // collection has materially changed. + tree_modified_at: now, }) } @@ -293,6 +359,7 @@ impl Folder { owner_id: self.owner_id, created_at: self.created_at, modified_at: now, + tree_modified_at: now, }) } @@ -366,4 +433,90 @@ mod tests { assert_eq!(renamed.name(), "new_name"); assert_eq!(renamed.id(), "123"); // The ID doesn't change } + + /// The folder ETag is `{id[..16]}-{tree_modified_at}`. Two + /// fixtures with identical id-prefix + tree_modified_at must + /// produce byte-identical ETags — that's what NC's incremental + /// sync relies on across PROPFIND cycles. + #[test] + fn test_etag_combines_id_prefix_and_tree_modified_at() { + let folder = Folder::with_timestamps_and_tree( + "0123456789abcdefZZZZZZZZ".to_string(), + "folder".to_string(), + StoragePath::from_string("/folder"), + None, + None, + 1_000, + 2_000, + 5_000, + ) + .unwrap(); + + assert_eq!(folder.tree_modified_at(), 5_000); + assert_eq!(folder.etag(), "0123456789abcdef-5000"); + } + + /// Two folders with the same `tree_modified_at` but different + /// IDs must NOT collide on ETag — the id prefix is the identity + /// portion that keeps them distinct. + #[test] + fn test_etag_distinct_folders_same_tree_mtime() { + let a = Folder::with_timestamps_and_tree( + "aaaaaaaaaaaaaaaaZZZZZZZZ".to_string(), + "a".to_string(), + StoragePath::from_string("/a"), + None, + None, + 0, + 0, + 42, + ) + .unwrap(); + let b = Folder::with_timestamps_and_tree( + "bbbbbbbbbbbbbbbbZZZZZZZZ".to_string(), + "b".to_string(), + StoragePath::from_string("/b"), + None, + None, + 0, + 0, + 42, + ) + .unwrap(); + + assert_ne!(a.etag(), b.etag()); + } + + /// `tree_modified_at` is the actual change-detection signal — + /// the trigger bumps it for descendant writes. Renaming the + /// folder bumps both `modified_at` and `tree_modified_at` + /// (the parent collection's listing changed), and the etag + /// must reflect that — otherwise NC won't notice the rename. + #[test] + fn test_etag_changes_when_tree_modified_at_changes() { + let folder_a = Folder::with_timestamps_and_tree( + "abcd1234efgh5678ZZZZZZZZ".to_string(), + "folder".to_string(), + StoragePath::from_string("/folder"), + None, + None, + 1_000, + 2_000, + 3_000, + ) + .unwrap(); + let folder_b = Folder::with_timestamps_and_tree( + "abcd1234efgh5678ZZZZZZZZ".to_string(), + "folder".to_string(), + StoragePath::from_string("/folder"), + None, + None, + 1_000, + 2_000, + 4_000, + ) + .unwrap(); + + assert_ne!(folder_a.etag(), folder_b.etag()); + } } diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 508370c6..8e653464 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -20,10 +20,25 @@ use crate::domain::services::authorization::ResourceKind; use crate::domain::services::path_service::StoragePath; /// Type alias for folder metadata rows from SQL queries. -type FolderRow = (String, String, String, Option, Uuid, i64, i64); +/// Tuple order: id, name, path, parent_id, user_id, created_at, +/// modified_at, tree_modified_at. The trailing `tree_modified_at` +/// feeds [`Folder::etag`] — every SELECT here must include +/// `EXTRACT(EPOCH FROM tree_modified_at)::bigint`. +type FolderRow = (String, String, String, Option, Uuid, i64, i64, i64); -/// Type alias for paginated folder rows (includes total_count). -type FolderRowPaginated = (String, String, String, Option, Uuid, i64, i64, i64); +/// Type alias for paginated folder rows (includes total_count as +/// the last element after `tree_modified_at`). +type FolderRowPaginated = ( + String, + String, + String, + Option, + Uuid, + i64, + i64, + i64, + i64, +); /// Type alias for folder rows with optional user_id. type FolderRowOptUser = ( @@ -34,6 +49,7 @@ type FolderRowOptUser = ( Option, i64, i64, + i64, ); /// PostgreSQL-backed folder repository. @@ -68,6 +84,7 @@ impl FolderDbRepository { /// /// The `path` comes directly from the materialized `path` column — no /// extra queries needed. + #[allow(clippy::too_many_arguments)] fn row_to_folder( id: String, name: String, @@ -76,9 +93,10 @@ impl FolderDbRepository { user_id: Option, created_at: i64, modified_at: i64, + tree_modified_at: i64, ) -> Result { let storage_path = StoragePath::from_string(&path); - Folder::with_timestamps_and_owner( + Folder::with_timestamps_and_tree( id, name, storage_path, @@ -86,6 +104,7 @@ impl FolderDbRepository { user_id, created_at as u64, modified_at as u64, + tree_modified_at as u64, ) .map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}"))) } @@ -116,14 +135,15 @@ impl FolderRepository for FolderDbRepository { )); }; - let row = sqlx::query_as::<_, (String, String, i64, i64)>( + let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>( r#" INSERT INTO storage.folders (name, parent_id, user_id) VALUES ($1, $2::uuid, $3) RETURNING id::text, path, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint "#, ) .bind(&name) @@ -143,15 +163,25 @@ impl FolderRepository for FolderDbRepository { DomainError::internal_error("FolderDb", format!("insert: {e}")) })?; - Self::row_to_folder(row.0, name, row.1, parent_id, Some(user_id), row.2, row.3) + Self::row_to_folder( + row.0, + name, + row.1, + parent_id, + Some(user_id), + row.2, + row.3, + row.4, + ) } async fn get_folder(&self, id: &str) -> Result { - let row = sqlx::query_as::<_, (String, String, String, Option, Uuid, i64, i64)>( + let row = sqlx::query_as::<_, FolderRow>( r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint FROM storage.folders WHERE id = $1::uuid AND NOT is_trashed "#, @@ -162,7 +192,7 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", id))?; - Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6) + Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7) } async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result { @@ -174,11 +204,12 @@ impl FolderRepository for FolderDbRepository { return Err(DomainError::not_found("Folder", "empty path")); } - let row = sqlx::query_as::<_, (String, String, String, Option, Uuid, i64, i64)>( + let row = sqlx::query_as::<_, FolderRow>( r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint FROM storage.folders WHERE path = $1 AND NOT is_trashed "#, @@ -189,7 +220,7 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", lookup))?; - Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6) + Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7) } #[allow(clippy::type_complexity)] @@ -199,7 +230,8 @@ impl FolderRepository for FolderDbRepository { r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed ORDER BY name @@ -213,7 +245,8 @@ impl FolderRepository for FolderDbRepository { r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed ORDER BY name @@ -225,8 +258,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma) + .map(|(id, name, path, pid, uid, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) }) .collect() } @@ -242,7 +275,8 @@ impl FolderRepository for FolderDbRepository { r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint FROM storage.folders WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed ORDER BY name @@ -257,7 +291,8 @@ impl FolderRepository for FolderDbRepository { r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint FROM storage.folders WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed ORDER BY name @@ -270,8 +305,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma) + .map(|(id, name, path, pid, uid, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) }) .collect() } @@ -293,6 +328,7 @@ impl FolderRepository for FolderDbRepository { SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint, COUNT(*) OVER() AS total_count FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed @@ -311,6 +347,7 @@ impl FolderRepository for FolderDbRepository { SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint, COUNT(*) OVER() AS total_count FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed @@ -327,15 +364,15 @@ impl FolderRepository for FolderDbRepository { // total_count is identical in every row; 0 when the result set is empty. let total = if include_total { - Some(rows.first().map_or(0, |r| r.7) as usize) + Some(rows.first().map_or(0, |r| r.8) as usize) } else { None }; let folders: Result, DomainError> = rows .into_iter() - .map(|(id, name, path, pid, uid, ca, ma, _total)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma) + .map(|(id, name, path, pid, uid, ca, ma, tma, _total)| { + Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) }) .collect(); Ok((folders?, total)) @@ -358,6 +395,7 @@ impl FolderRepository for FolderDbRepository { SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint, COUNT(*) OVER() AS total_count FROM storage.folders WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed @@ -377,6 +415,7 @@ impl FolderRepository for FolderDbRepository { SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint, COUNT(*) OVER() AS total_count FROM storage.folders WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed @@ -393,15 +432,15 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?; let total = if include_total { - Some(rows.first().map_or(0, |r| r.7) as usize) + Some(rows.first().map_or(0, |r| r.8) as usize) } else { None }; let folders: Result, DomainError> = rows .into_iter() - .map(|(id, name, path, pid, uid, ca, ma, _total)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma) + .map(|(id, name, path, pid, uid, ca, ma, tma, _total)| { + Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) }) .collect(); Ok((folders?, total)) @@ -411,14 +450,15 @@ impl FolderRepository for FolderDbRepository { // The BEFORE UPDATE trigger recomputes path/lpath for this row; // the AFTER UPDATE cascade trigger then batch-updates all // descendants in a single UPDATE using the GiST lpath index. - let row = sqlx::query_as::<_, (String, String, String, Option, Uuid, i64, i64)>( + let row = sqlx::query_as::<_, FolderRow>( r#" UPDATE storage.folders SET name = $1, updated_at = NOW() WHERE id = $2::uuid AND NOT is_trashed RETURNING id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint "#, ) .bind(&new_name) @@ -435,7 +475,7 @@ impl FolderRepository for FolderDbRepository { })? .ok_or_else(|| DomainError::not_found("Folder", id))?; - Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6) + Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7) } async fn move_folder( @@ -446,14 +486,15 @@ impl FolderRepository for FolderDbRepository { // The BEFORE UPDATE trigger recomputes path/lpath for this row; // the AFTER UPDATE cascade trigger then batch-updates all // descendants in a single UPDATE using the GiST lpath index. - let row = sqlx::query_as::<_, (String, String, String, Option, Uuid, i64, i64)>( + let row = sqlx::query_as::<_, FolderRow>( r#" UPDATE storage.folders SET parent_id = $1::uuid, updated_at = NOW() WHERE id = $2::uuid AND NOT is_trashed RETURNING id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint "#, ) .bind(new_parent_id) @@ -463,7 +504,7 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))? .ok_or_else(|| DomainError::not_found("Folder", id))?; - Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6) + Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7) } async fn delete_folder(&self, id: &str) -> Result<(), DomainError> { @@ -622,7 +663,7 @@ impl FolderRepository for FolderDbRepository { } async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result { - let row = sqlx::query_as::<_, (String, String, i64, i64)>( + let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>( r#" INSERT INTO storage.folders (name, parent_id, user_id) VALUES ($1, NULL, $2) @@ -630,7 +671,8 @@ impl FolderRepository for FolderDbRepository { RETURNING id::text, path, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint "#, ) .bind(&name) @@ -640,17 +682,18 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?; match row { - Some((id, path, ca, ma)) => { - Self::row_to_folder(id, name.clone(), path, None, Some(user_id), ca, ma) + Some((id, path, ca, ma, tma)) => { + Self::row_to_folder(id, name.clone(), path, None, Some(user_id), ca, ma, tma) } None => { // Already exists — fetch it - let existing = sqlx::query_as::<_, (String, String, i64, i64)>( + let existing = sqlx::query_as::<_, (String, String, i64, i64, i64)>( r#" SELECT id::text, path, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint FROM storage.folders WHERE name = $1 AND user_id = $2 AND parent_id IS NULL "#, @@ -668,6 +711,7 @@ impl FolderRepository for FolderDbRepository { Some(user_id), existing.2, existing.3, + existing.4, ) } } @@ -682,7 +726,8 @@ impl FolderRepository for FolderDbRepository { let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ fo.user_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ - EXTRACT(EPOCH FROM fo.updated_at)::bigint \ + EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ FROM storage.folders fo \ WHERE fo.is_trashed = false \ AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \ @@ -697,8 +742,8 @@ impl FolderRepository for FolderDbRepository { })?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma)| { - Self::row_to_folder(id, name, path, pid, uid, ca, ma) + .map(|(id, name, path, pid, uid, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma) }) .collect() } @@ -744,7 +789,8 @@ impl FolderRepository for FolderDbRepository { "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ fo.user_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ - EXTRACT(EPOCH FROM fo.updated_at)::bigint \ + EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ FROM storage.folders fo \ WHERE fo.user_id = $1 \ AND fo.is_trashed = false \ @@ -768,8 +814,8 @@ impl FolderRepository for FolderDbRepository { return rows .into_iter() - .map(|(id, name, path, pid, uid, ca, ma)| { - Self::row_to_folder(id, name, path, pid, uid, ca, ma) + .map(|(id, name, path, pid, uid, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma) }) .collect(); } @@ -780,7 +826,8 @@ impl FolderRepository for FolderDbRepository { "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ fo.user_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ - EXTRACT(EPOCH FROM fo.updated_at)::bigint \ + EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ FROM storage.folders fo \ WHERE fo.parent_id = $1::uuid \ AND fo.user_id = $2 \ @@ -798,7 +845,8 @@ impl FolderRepository for FolderDbRepository { "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ fo.user_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ - EXTRACT(EPOCH FROM fo.updated_at)::bigint \ + EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ FROM storage.folders fo \ WHERE fo.parent_id IS NULL \ AND fo.user_id = $1 \ @@ -838,8 +886,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma)| { - Self::row_to_folder(id, name, path, pid, uid, ca, ma) + .map(|(id, name, path, pid, uid, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma) }) .collect() } @@ -866,7 +914,8 @@ impl FolderRepository for FolderDbRepository { "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \ fo.user_id, \ EXTRACT(EPOCH FROM fo.created_at)::bigint, \ - EXTRACT(EPOCH FROM fo.updated_at)::bigint \ + EXTRACT(EPOCH FROM fo.updated_at)::bigint, \ + EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \ FROM storage.folders fo \ WHERE fo.user_id = $1 \ AND fo.is_trashed = false \ @@ -893,8 +942,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma)| { - Self::row_to_folder(id, name, path, pid, uid, ca, ma) + .map(|(id, name, path, pid, uid, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma) }) .collect() } @@ -914,7 +963,8 @@ impl FolderRepository for FolderDbRepository { r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed @@ -939,7 +989,8 @@ impl FolderRepository for FolderDbRepository { r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed @@ -962,8 +1013,8 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?; rows.into_iter() - .map(|(id, name, path, pid, uid, ca, ma)| { - Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma) + .map(|(id, name, path, pid, uid, ca, ma, tma)| { + Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma) }) .collect() }