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:
@@ -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();
|
||||||
+179
-26
@@ -30,8 +30,17 @@ pub struct Folder {
|
|||||||
/// Creation timestamp
|
/// Creation timestamp
|
||||||
created_at: u64,
|
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,
|
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
|
// We no longer need this module, now we use a String directly
|
||||||
@@ -47,6 +56,7 @@ impl Default for Folder {
|
|||||||
owner_id: None,
|
owner_id: None,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
modified_at: 0,
|
modified_at: 0,
|
||||||
|
tree_modified_at: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,10 +102,15 @@ impl Folder {
|
|||||||
owner_id,
|
owner_id,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
modified_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(
|
pub fn with_timestamps(
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -104,7 +119,7 @@ impl Folder {
|
|||||||
created_at: u64,
|
created_at: u64,
|
||||||
modified_at: u64,
|
modified_at: u64,
|
||||||
) -> FolderResult<Self> {
|
) -> FolderResult<Self> {
|
||||||
Self::with_timestamps_and_owner(
|
Self::with_timestamps_and_tree(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
storage_path,
|
storage_path,
|
||||||
@@ -112,10 +127,15 @@ impl Folder {
|
|||||||
None,
|
None,
|
||||||
created_at,
|
created_at,
|
||||||
modified_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(
|
pub fn with_timestamps_and_owner(
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -125,12 +145,36 @@ impl Folder {
|
|||||||
created_at: u64,
|
created_at: u64,
|
||||||
modified_at: u64,
|
modified_at: u64,
|
||||||
) -> FolderResult<Self> {
|
) -> 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) {
|
if let Err(reason) = validate_storage_name(&name) {
|
||||||
return Err(FolderError::InvalidFolderName(format!("{name}: {reason}")));
|
return Err(FolderError::InvalidFolderName(format!("{name}: {reason}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the path string for serialization compatibility
|
|
||||||
let path_string = storage_path.to_string();
|
let path_string = storage_path.to_string();
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -142,6 +186,7 @@ impl Folder {
|
|||||||
owner_id,
|
owner_id,
|
||||||
created_at,
|
created_at,
|
||||||
modified_at,
|
modified_at,
|
||||||
|
tree_modified_at,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,27 +223,39 @@ impl Folder {
|
|||||||
self.owner_id
|
self.owner_id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opaque ETag string (raw, NOT HTTP-quoted). Handlers wrap in
|
/// Latest descendant-write timestamp, maintained by a Postgres
|
||||||
/// `"…"` themselves at the HTTP boundary.
|
/// 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
|
/// **Formula**: `{id[..16]}-{tree_modified_at}`.
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// **Known limitation**: NextCloud's sync engine relies on a
|
/// - The 16-char UUID prefix gives the folder its identity
|
||||||
/// collection's ETag changing whenever any descendant changes —
|
/// component — keeps two empty same-mtime folders distinct.
|
||||||
/// that's the signal it uses to decide "recurse into this folder
|
/// - `tree_modified_at` (Unix seconds) is the actual signal:
|
||||||
/// to find what's new". A constant ETag breaks NC's incremental
|
/// bumped by trigger whenever ANY descendant (file or
|
||||||
/// sync (forces periodic deep recrawl).
|
/// sub-folder, at any depth) is created, modified, deleted,
|
||||||
///
|
/// or moved. This is the contract NextCloud's sync engine
|
||||||
/// A follow-up PR will introduce `storage.folders.tree_modified_at`
|
/// relies on — "did anything change inside this collection
|
||||||
/// (bumped by trigger on any descendant write) and switch this
|
/// since I last looked?". Until this column existed, the
|
||||||
/// method to `format!("{}-{}", id_short, tree_modified_at)`. That
|
/// answer was always "no" because the folder UUID never
|
||||||
/// PR will be ETag-breaking — all clients re-walk once — so it's
|
/// changed; clients had to do periodic deep PROPFIND walks
|
||||||
/// kept separate from this refactor.
|
/// to discover web-uploaded files.
|
||||||
pub fn etag(&self) -> &str {
|
/// - Renaming the folder itself does NOT change the etag's
|
||||||
&self.id
|
/// 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
|
/// Creates a new Folder instance from a DTO
|
||||||
@@ -214,7 +271,11 @@ impl Folder {
|
|||||||
// Create storage_path from the string
|
// Create storage_path from the string
|
||||||
let storage_path = StoragePath::from_string(&path);
|
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 {
|
Self {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -224,6 +285,7 @@ impl Folder {
|
|||||||
owner_id: None,
|
owner_id: None,
|
||||||
created_at,
|
created_at,
|
||||||
modified_at,
|
modified_at,
|
||||||
|
tree_modified_at: modified_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +323,10 @@ impl Folder {
|
|||||||
owner_id: self.owner_id,
|
owner_id: self.owner_id,
|
||||||
created_at: self.created_at,
|
created_at: self.created_at,
|
||||||
modified_at: now,
|
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,
|
owner_id: self.owner_id,
|
||||||
created_at: self.created_at,
|
created_at: self.created_at,
|
||||||
modified_at: now,
|
modified_at: now,
|
||||||
|
tree_modified_at: now,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,4 +433,90 @@ mod tests {
|
|||||||
assert_eq!(renamed.name(), "new_name");
|
assert_eq!(renamed.name(), "new_name");
|
||||||
assert_eq!(renamed.id(), "123"); // The ID doesn't change
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,25 @@ use crate::domain::services::authorization::ResourceKind;
|
|||||||
use crate::domain::services::path_service::StoragePath;
|
use crate::domain::services::path_service::StoragePath;
|
||||||
|
|
||||||
/// Type alias for folder metadata rows from SQL queries.
|
/// Type alias for folder metadata rows from SQL queries.
|
||||||
type FolderRow = (String, String, String, Option<String>, Uuid, i64, i64);
|
/// Tuple order: id, name, path, parent_id, user_id, created_at,
|
||||||
|
/// modified_at, tree_modified_at. The trailing `tree_modified_at`
|
||||||
|
/// feeds [`Folder::etag`] — every SELECT here must include
|
||||||
|
/// `EXTRACT(EPOCH FROM tree_modified_at)::bigint`.
|
||||||
|
type FolderRow = (String, String, String, Option<String>, Uuid, i64, i64, i64);
|
||||||
|
|
||||||
/// Type alias for paginated folder rows (includes total_count).
|
/// Type alias for paginated folder rows (includes total_count as
|
||||||
type FolderRowPaginated = (String, String, String, Option<String>, Uuid, i64, i64, i64);
|
/// the last element after `tree_modified_at`).
|
||||||
|
type FolderRowPaginated = (
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Uuid,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
);
|
||||||
|
|
||||||
/// Type alias for folder rows with optional user_id.
|
/// Type alias for folder rows with optional user_id.
|
||||||
type FolderRowOptUser = (
|
type FolderRowOptUser = (
|
||||||
@@ -34,6 +49,7 @@ type FolderRowOptUser = (
|
|||||||
Option<Uuid>,
|
Option<Uuid>,
|
||||||
i64,
|
i64,
|
||||||
i64,
|
i64,
|
||||||
|
i64,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// PostgreSQL-backed folder repository.
|
/// PostgreSQL-backed folder repository.
|
||||||
@@ -68,6 +84,7 @@ impl FolderDbRepository {
|
|||||||
///
|
///
|
||||||
/// The `path` comes directly from the materialized `path` column — no
|
/// The `path` comes directly from the materialized `path` column — no
|
||||||
/// extra queries needed.
|
/// extra queries needed.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn row_to_folder(
|
fn row_to_folder(
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -76,9 +93,10 @@ impl FolderDbRepository {
|
|||||||
user_id: Option<Uuid>,
|
user_id: Option<Uuid>,
|
||||||
created_at: i64,
|
created_at: i64,
|
||||||
modified_at: i64,
|
modified_at: i64,
|
||||||
|
tree_modified_at: i64,
|
||||||
) -> Result<Folder, DomainError> {
|
) -> Result<Folder, DomainError> {
|
||||||
let storage_path = StoragePath::from_string(&path);
|
let storage_path = StoragePath::from_string(&path);
|
||||||
Folder::with_timestamps_and_owner(
|
Folder::with_timestamps_and_tree(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
storage_path,
|
storage_path,
|
||||||
@@ -86,6 +104,7 @@ impl FolderDbRepository {
|
|||||||
user_id,
|
user_id,
|
||||||
created_at as u64,
|
created_at as u64,
|
||||||
modified_at as u64,
|
modified_at as u64,
|
||||||
|
tree_modified_at as u64,
|
||||||
)
|
)
|
||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
|
.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#"
|
r#"
|
||||||
INSERT INTO storage.folders (name, parent_id, user_id)
|
INSERT INTO storage.folders (name, parent_id, user_id)
|
||||||
VALUES ($1, $2::uuid, $3)
|
VALUES ($1, $2::uuid, $3)
|
||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
path,
|
path,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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)
|
.bind(&name)
|
||||||
@@ -143,15 +163,25 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
DomainError::internal_error("FolderDb", format!("insert: {e}"))
|
DomainError::internal_error("FolderDb", format!("insert: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Self::row_to_folder(row.0, name, row.1, parent_id, Some(user_id), row.2, row.3)
|
Self::row_to_folder(
|
||||||
|
row.0,
|
||||||
|
name,
|
||||||
|
row.1,
|
||||||
|
parent_id,
|
||||||
|
Some(user_id),
|
||||||
|
row.2,
|
||||||
|
row.3,
|
||||||
|
row.4,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError> {
|
||||||
let row = sqlx::query_as::<_, (String, String, String, Option<String>, Uuid, i64, i64)>(
|
let row = sqlx::query_as::<_, FolderRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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
|
FROM storage.folders
|
||||||
WHERE id = $1::uuid AND NOT is_trashed
|
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}")))?
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))?
|
||||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
||||||
|
|
||||||
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6)
|
Self::row_to_folder(row.0, row.1, row.2, row.3, Some(row.4), row.5, row.6, row.7)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
|
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError> {
|
||||||
@@ -174,11 +204,12 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
return Err(DomainError::not_found("Folder", "empty path"));
|
return Err(DomainError::not_found("Folder", "empty path"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let row = sqlx::query_as::<_, (String, String, String, Option<String>, Uuid, i64, i64)>(
|
let row = sqlx::query_as::<_, FolderRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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
|
FROM storage.folders
|
||||||
WHERE path = $1 AND NOT is_trashed
|
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}")))?
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("path lookup: {e}")))?
|
||||||
.ok_or_else(|| DomainError::not_found("Folder", lookup))?;
|
.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)]
|
#[allow(clippy::type_complexity)]
|
||||||
@@ -199,7 +230,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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
|
FROM storage.folders
|
||||||
WHERE parent_id = $1::uuid AND NOT is_trashed
|
WHERE parent_id = $1::uuid AND NOT is_trashed
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
@@ -213,7 +245,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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
|
FROM storage.folders
|
||||||
WHERE parent_id IS NULL AND NOT is_trashed
|
WHERE parent_id IS NULL AND NOT is_trashed
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
@@ -225,8 +258,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(id, name, path, pid, uid, ca, ma)| {
|
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
|
||||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -242,7 +275,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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
|
FROM storage.folders
|
||||||
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
|
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
@@ -257,7 +291,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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
|
FROM storage.folders
|
||||||
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
|
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
@@ -270,8 +305,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(id, name, path, pid, uid, ca, ma)| {
|
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
|
||||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -293,6 +328,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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,
|
||||||
COUNT(*) OVER() AS total_count
|
COUNT(*) OVER() AS total_count
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
WHERE parent_id = $1::uuid AND NOT is_trashed
|
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,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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,
|
||||||
COUNT(*) OVER() AS total_count
|
COUNT(*) OVER() AS total_count
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
WHERE parent_id IS NULL AND NOT is_trashed
|
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.
|
// total_count is identical in every row; 0 when the result set is empty.
|
||||||
let total = if include_total {
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let folders: Result<Vec<Folder>, DomainError> = rows
|
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, name, path, pid, uid, ca, ma, _total)| {
|
.map(|(id, name, path, pid, uid, ca, ma, tma, _total)| {
|
||||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok((folders?, total))
|
Ok((folders?, total))
|
||||||
@@ -358,6 +395,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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,
|
||||||
COUNT(*) OVER() AS total_count
|
COUNT(*) OVER() AS total_count
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
|
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,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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,
|
||||||
COUNT(*) OVER() AS total_count
|
COUNT(*) OVER() AS total_count
|
||||||
FROM storage.folders
|
FROM storage.folders
|
||||||
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
|
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}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?;
|
||||||
|
|
||||||
let total = if include_total {
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let folders: Result<Vec<Folder>, DomainError> = rows
|
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, name, path, pid, uid, ca, ma, _total)| {
|
.map(|(id, name, path, pid, uid, ca, ma, tma, _total)| {
|
||||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok((folders?, total))
|
Ok((folders?, total))
|
||||||
@@ -411,14 +450,15 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
|
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
|
||||||
// the AFTER UPDATE cascade trigger then batch-updates all
|
// the AFTER UPDATE cascade trigger then batch-updates all
|
||||||
// descendants in a single UPDATE using the GiST lpath index.
|
// descendants in a single UPDATE using the GiST lpath index.
|
||||||
let row = sqlx::query_as::<_, (String, String, String, Option<String>, Uuid, i64, i64)>(
|
let row = sqlx::query_as::<_, FolderRow>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE storage.folders
|
UPDATE storage.folders
|
||||||
SET name = $1, updated_at = NOW()
|
SET name = $1, updated_at = NOW()
|
||||||
WHERE id = $2::uuid AND NOT is_trashed
|
WHERE id = $2::uuid AND NOT is_trashed
|
||||||
RETURNING id::text, name, path, parent_id::text, user_id,
|
RETURNING id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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)
|
.bind(&new_name)
|
||||||
@@ -435,7 +475,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
})?
|
})?
|
||||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
.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(
|
async fn move_folder(
|
||||||
@@ -446,14 +486,15 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
|
// The BEFORE UPDATE trigger recomputes path/lpath for this row;
|
||||||
// the AFTER UPDATE cascade trigger then batch-updates all
|
// the AFTER UPDATE cascade trigger then batch-updates all
|
||||||
// descendants in a single UPDATE using the GiST lpath index.
|
// descendants in a single UPDATE using the GiST lpath index.
|
||||||
let row = sqlx::query_as::<_, (String, String, String, Option<String>, Uuid, i64, i64)>(
|
let row = sqlx::query_as::<_, FolderRow>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE storage.folders
|
UPDATE storage.folders
|
||||||
SET parent_id = $1::uuid, updated_at = NOW()
|
SET parent_id = $1::uuid, updated_at = NOW()
|
||||||
WHERE id = $2::uuid AND NOT is_trashed
|
WHERE id = $2::uuid AND NOT is_trashed
|
||||||
RETURNING id::text, name, path, parent_id::text, user_id,
|
RETURNING id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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)
|
.bind(new_parent_id)
|
||||||
@@ -463,7 +504,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?
|
||||||
.ok_or_else(|| DomainError::not_found("Folder", id))?;
|
.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> {
|
async fn delete_folder(&self, id: &str) -> Result<(), DomainError> {
|
||||||
@@ -622,7 +663,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result<Folder, DomainError> {
|
async fn create_home_folder(&self, user_id: Uuid, name: String) -> Result<Folder, DomainError> {
|
||||||
let row = sqlx::query_as::<_, (String, String, i64, i64)>(
|
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO storage.folders (name, parent_id, user_id)
|
INSERT INTO storage.folders (name, parent_id, user_id)
|
||||||
VALUES ($1, NULL, $2)
|
VALUES ($1, NULL, $2)
|
||||||
@@ -630,7 +671,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
path,
|
path,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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)
|
.bind(&name)
|
||||||
@@ -640,17 +682,18 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?;
|
||||||
|
|
||||||
match row {
|
match row {
|
||||||
Some((id, path, ca, ma)) => {
|
Some((id, path, ca, ma, tma)) => {
|
||||||
Self::row_to_folder(id, name.clone(), path, None, Some(user_id), ca, ma)
|
Self::row_to_folder(id, name.clone(), path, None, Some(user_id), ca, ma, tma)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Already exists — fetch it
|
// Already exists — fetch it
|
||||||
let existing = sqlx::query_as::<_, (String, String, i64, i64)>(
|
let existing = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id::text,
|
SELECT id::text,
|
||||||
path,
|
path,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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
|
FROM storage.folders
|
||||||
WHERE name = $1 AND user_id = $2 AND parent_id IS NULL
|
WHERE name = $1 AND user_id = $2 AND parent_id IS NULL
|
||||||
"#,
|
"#,
|
||||||
@@ -668,6 +711,7 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
Some(user_id),
|
Some(user_id),
|
||||||
existing.2,
|
existing.2,
|
||||||
existing.3,
|
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, \
|
let sql = "SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||||
fo.user_id, \
|
fo.user_id, \
|
||||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
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 \
|
FROM storage.folders fo \
|
||||||
WHERE fo.is_trashed = false \
|
WHERE fo.is_trashed = false \
|
||||||
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
|
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
|
||||||
@@ -697,8 +742,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(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)
|
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -744,7 +789,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||||
fo.user_id, \
|
fo.user_id, \
|
||||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
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 \
|
FROM storage.folders fo \
|
||||||
WHERE fo.user_id = $1 \
|
WHERE fo.user_id = $1 \
|
||||||
AND fo.is_trashed = false \
|
AND fo.is_trashed = false \
|
||||||
@@ -768,8 +814,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
|
|
||||||
return rows
|
return rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(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)
|
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
@@ -780,7 +826,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||||
fo.user_id, \
|
fo.user_id, \
|
||||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
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 \
|
FROM storage.folders fo \
|
||||||
WHERE fo.parent_id = $1::uuid \
|
WHERE fo.parent_id = $1::uuid \
|
||||||
AND fo.user_id = $2 \
|
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, \
|
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||||
fo.user_id, \
|
fo.user_id, \
|
||||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
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 \
|
FROM storage.folders fo \
|
||||||
WHERE fo.parent_id IS NULL \
|
WHERE fo.parent_id IS NULL \
|
||||||
AND fo.user_id = $1 \
|
AND fo.user_id = $1 \
|
||||||
@@ -838,8 +886,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(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)
|
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -866,7 +914,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
"SELECT fo.id::text, fo.name, fo.path, fo.parent_id::text, \
|
||||||
fo.user_id, \
|
fo.user_id, \
|
||||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
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 \
|
FROM storage.folders fo \
|
||||||
WHERE fo.user_id = $1 \
|
WHERE fo.user_id = $1 \
|
||||||
AND fo.is_trashed = false \
|
AND fo.is_trashed = false \
|
||||||
@@ -893,8 +942,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("descendant search: {e}")))?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(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)
|
Self::row_to_folder(id, name, path, pid, uid, ca, ma, tma)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -914,7 +963,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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
|
FROM storage.folders
|
||||||
WHERE parent_id = $1::uuid
|
WHERE parent_id = $1::uuid
|
||||||
AND NOT is_trashed
|
AND NOT is_trashed
|
||||||
@@ -939,7 +989,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
r#"
|
r#"
|
||||||
SELECT id::text, name, path, parent_id::text, user_id,
|
SELECT id::text, name, path, parent_id::text, user_id,
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
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
|
FROM storage.folders
|
||||||
WHERE parent_id IS NULL
|
WHERE parent_id IS NULL
|
||||||
AND NOT is_trashed
|
AND NOT is_trashed
|
||||||
@@ -962,8 +1013,8 @@ impl FolderRepository for FolderDbRepository {
|
|||||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
|
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(id, name, path, pid, uid, ca, ma)| {
|
.map(|(id, name, path, pid, uid, ca, ma, tma)| {
|
||||||
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma)
|
Self::row_to_folder(id, name, path, pid, Some(uid), ca, ma, tma)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user