feat(etag): cascade tag change on each folerd's parent
on a file or folder change, the etag of each parent will be updated, this is O(n)
this is required to ensure NextCloud client will be aware of changes
In the future a Cursor will be better, this will be for OxiCloud-desktop
This commit is contained in:
+179
-26
@@ -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> {
|
||||
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<Self> {
|
||||
// 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<String>,
|
||||
owner_id: Option<Uuid>,
|
||||
created_at: u64,
|
||||
modified_at: u64,
|
||||
tree_modified_at: u64,
|
||||
) -> FolderResult<Self> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user