feat(drive): complete updated_by created_by
This commit is contained in:
@@ -22,11 +22,12 @@ use crate::domain::services::path_service::StoragePath;
|
||||
|
||||
/// Type alias for folder metadata rows from SQL queries.
|
||||
/// Tuple order: id, name, path, parent_id, user_id, drive_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`.
|
||||
/// created_at, modified_at, tree_modified_at, created_by, updated_by.
|
||||
/// The trailing `tree_modified_at` feeds [`Folder::etag`] — every
|
||||
/// SELECT here must include `EXTRACT(EPOCH FROM tree_modified_at)::bigint`.
|
||||
/// `drive_id` is the post-D0 `NOT NULL` scope axis for path-based
|
||||
/// lookups.
|
||||
/// lookups. `created_by` / `updated_by` are the §14 provenance
|
||||
/// columns, nullable because the FK is `ON DELETE SET NULL`.
|
||||
type FolderRow = (
|
||||
String,
|
||||
String,
|
||||
@@ -37,10 +38,12 @@ type FolderRow = (
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
);
|
||||
|
||||
/// Type alias for paginated folder rows (includes total_count as
|
||||
/// the last element after `tree_modified_at`).
|
||||
/// the last element after the §14 provenance columns).
|
||||
type FolderRowPaginated = (
|
||||
String,
|
||||
String,
|
||||
@@ -51,10 +54,13 @@ type FolderRowPaginated = (
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
i64,
|
||||
);
|
||||
|
||||
/// Type alias for folder rows with optional user_id.
|
||||
/// Includes the §14 provenance columns `created_by` / `updated_by`.
|
||||
type FolderRowOptUser = (
|
||||
String,
|
||||
String,
|
||||
@@ -65,6 +71,8 @@ type FolderRowOptUser = (
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
);
|
||||
|
||||
/// PostgreSQL-backed folder repository.
|
||||
@@ -98,7 +106,9 @@ impl FolderDbRepository {
|
||||
/// Convert a database row into a `Folder` domain entity.
|
||||
///
|
||||
/// The `path` comes directly from the materialized `path` column — no
|
||||
/// extra queries needed.
|
||||
/// extra queries needed. `created_by` / `updated_by` carry the
|
||||
/// §14 provenance signal through the entity layer; both are
|
||||
/// `Option<Uuid>` because the FK is `ON DELETE SET NULL`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn row_to_folder(
|
||||
id: String,
|
||||
@@ -110,9 +120,11 @@ impl FolderDbRepository {
|
||||
created_at: i64,
|
||||
modified_at: i64,
|
||||
tree_modified_at: i64,
|
||||
created_by: Option<Uuid>,
|
||||
updated_by: Option<Uuid>,
|
||||
) -> Result<Folder, DomainError> {
|
||||
let storage_path = StoragePath::from_string(&path);
|
||||
Folder::with_timestamps_and_tree(
|
||||
Folder::with_timestamps_tree_and_provenance(
|
||||
id,
|
||||
name,
|
||||
storage_path,
|
||||
@@ -122,6 +134,8 @@ impl FolderDbRepository {
|
||||
created_at as u64,
|
||||
modified_at as u64,
|
||||
tree_modified_at as u64,
|
||||
created_by,
|
||||
updated_by,
|
||||
)
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}")))
|
||||
}
|
||||
@@ -139,10 +153,11 @@ impl FolderDbRepository {
|
||||
|
||||
let rows = sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
SELECT id::text, name, path, parent_id::text, user_id,
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE id = ANY($1) AND NOT is_trashed
|
||||
"#,
|
||||
@@ -153,7 +168,9 @@ impl FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7))
|
||||
.map(|r| {
|
||||
Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7, r.8, r.9, r.10)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -163,6 +180,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
&self,
|
||||
name: String,
|
||||
parent_id: Option<String>,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Folder, DomainError> {
|
||||
// Derive (user_id, drive_id) from parent folder in one round-trip.
|
||||
// Root-level folders require the caller to have set up the home
|
||||
@@ -184,28 +202,34 @@ impl FolderRepository for FolderDbRepository {
|
||||
));
|
||||
};
|
||||
|
||||
// D0 dual-write: drive_id alongside user_id (drops in D7), plus
|
||||
// provenance columns created_by/updated_by. The repo derives
|
||||
// created_by from user_id because the parent's owner is the
|
||||
// creator on personal drives (the only kind that exists in D0).
|
||||
// D2 plumbs the real caller_id when shared drives let other
|
||||
// members write into a drive they don't own.
|
||||
let row = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
|
||||
// D0 dual-write: drive_id alongside user_id (drops in D7); plus
|
||||
// §14 provenance — `created_by` / `updated_by` bind to the caller
|
||||
// ($5), NOT to the parent folder's `user_id`. Pre-D2 they're
|
||||
// silently equivalent (only the parent's owner can write); the
|
||||
// distinction matters once shared drives let an Editor mutate
|
||||
// a folder owned by someone else.
|
||||
//
|
||||
// RETURNING also surfaces the two provenance columns so the
|
||||
// built entity / DTO carries fresh values without a re-read.
|
||||
let row = sqlx::query_as::<_, (String, String, i64, i64, i64, Option<Uuid>, Option<Uuid>)>(
|
||||
r#"
|
||||
INSERT INTO storage.folders
|
||||
(name, parent_id, user_id, drive_id, created_by, updated_by)
|
||||
VALUES ($1, $2::uuid, $3, $4, $3, $3)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $5)
|
||||
RETURNING id::text,
|
||||
path,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by,
|
||||
updated_by
|
||||
"#,
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&parent_id)
|
||||
.bind(user_id)
|
||||
.bind(drive_id)
|
||||
.bind(caller_id)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -230,6 +254,9 @@ impl FolderRepository for FolderDbRepository {
|
||||
row.2,
|
||||
row.3,
|
||||
row.4,
|
||||
// Fresh from RETURNING — caller_id was bound to both columns.
|
||||
row.5,
|
||||
row.6,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -239,7 +266,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
"#,
|
||||
@@ -260,6 +288,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
row.6,
|
||||
row.7,
|
||||
row.8,
|
||||
row.9,
|
||||
row.10,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -288,7 +318,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE path = $1 AND drive_id = $2 AND NOT is_trashed
|
||||
"#,
|
||||
@@ -310,6 +341,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
row.6,
|
||||
row.7,
|
||||
row.8,
|
||||
row.9,
|
||||
row.10,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -321,7 +354,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND NOT is_trashed
|
||||
ORDER BY name
|
||||
@@ -336,7 +370,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL AND NOT is_trashed
|
||||
ORDER BY name
|
||||
@@ -348,8 +383,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -366,7 +401,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
|
||||
ORDER BY name
|
||||
@@ -382,7 +418,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
|
||||
ORDER BY name
|
||||
@@ -395,8 +432,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, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -419,6 +456,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND NOT is_trashed
|
||||
@@ -438,6 +476,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL AND NOT is_trashed
|
||||
@@ -454,16 +493,18 @@ 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.9) as usize)
|
||||
Some(rows.first().map_or(0, |r| r.11) as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma)
|
||||
})
|
||||
.map(
|
||||
|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
Ok((folders?, total))
|
||||
}
|
||||
@@ -486,6 +527,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed
|
||||
@@ -506,6 +548,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed
|
||||
@@ -522,41 +565,55 @@ 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.9) as usize)
|
||||
Some(rows.first().map_or(0, |r| r.11) as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let folders: Result<Vec<Folder>, DomainError> = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma)
|
||||
})
|
||||
.map(
|
||||
|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub, _total)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
Ok((folders?, total))
|
||||
}
|
||||
|
||||
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError> {
|
||||
async fn rename_folder(
|
||||
&self,
|
||||
id: &str,
|
||||
new_name: String,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Folder, DomainError> {
|
||||
// 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.
|
||||
// That multi-row rewrite can deadlock against the tree-ETag
|
||||
// flusher's id-ordered ancestor bump — retry instead of failing
|
||||
// the user's operation (40P01 only; 23505 still maps below).
|
||||
//
|
||||
// §14: `updated_by = $3` (caller_id) — the caller mutated this
|
||||
// row, not the row's owner. In D2 a shared-drive member can
|
||||
// rename a row they don't own; the previous `updated_by = user_id`
|
||||
// would have silently recorded the wrong principal.
|
||||
let row = retry_on_deadlock("folders.rename", || {
|
||||
sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
UPDATE storage.folders
|
||||
SET name = $1, updated_at = NOW(), updated_by = user_id
|
||||
SET name = $1, updated_at = NOW(), updated_by = $3
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
"#,
|
||||
)
|
||||
.bind(&new_name)
|
||||
.bind(id)
|
||||
.bind(caller_id)
|
||||
.fetch_optional(self.pool())
|
||||
})
|
||||
.await
|
||||
@@ -580,6 +637,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
row.6,
|
||||
row.7,
|
||||
row.8,
|
||||
row.9,
|
||||
row.10,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -587,25 +646,30 @@ impl FolderRepository for FolderDbRepository {
|
||||
&self,
|
||||
id: &str,
|
||||
new_parent_id: Option<&str>,
|
||||
caller_id: Uuid,
|
||||
) -> Result<Folder, DomainError> {
|
||||
// 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.
|
||||
// Retried on deadlock vs the tree-ETag flusher (see rename_folder).
|
||||
//
|
||||
// §14: `updated_by = $3` (caller_id), see rename_folder.
|
||||
let row = retry_on_deadlock("folders.move", || {
|
||||
sqlx::query_as::<_, FolderRow>(
|
||||
r#"
|
||||
UPDATE storage.folders
|
||||
SET parent_id = $1::uuid, updated_at = NOW(), updated_by = user_id
|
||||
SET parent_id = $1::uuid, updated_at = NOW(), updated_by = $3
|
||||
WHERE id = $2::uuid AND NOT is_trashed
|
||||
RETURNING id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
"#,
|
||||
)
|
||||
.bind(new_parent_id)
|
||||
.bind(id)
|
||||
.bind(caller_id)
|
||||
.fetch_optional(self.pool())
|
||||
})
|
||||
.await
|
||||
@@ -622,6 +686,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
row.6,
|
||||
row.7,
|
||||
row.8,
|
||||
row.9,
|
||||
row.10,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -697,7 +763,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
|
||||
// ── Trash operations ──
|
||||
|
||||
async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> {
|
||||
async fn move_to_trash(&self, folder_id: &str, caller_id: Uuid) -> Result<(), DomainError> {
|
||||
// Soft-delete the whole subtree in one statement: the root flips
|
||||
// `is_trashed` and records `original_parent_id` so restore knows
|
||||
// where to put it back; every descendant (folder or file) that
|
||||
@@ -712,6 +778,10 @@ impl FolderRepository for FolderDbRepository {
|
||||
// `/g9-tree/file.txt` still resolved 207 even though the parent
|
||||
// collection was gone) — a class of data-integrity drift that
|
||||
// confused desktop-sync tree walks.
|
||||
//
|
||||
// §14: all three CTE branches stamp `updated_by = $2`
|
||||
// (caller_id). The cascade is "the caller trashed this
|
||||
// subtree", not "each owner trashed their own row".
|
||||
let result = retry_on_deadlock("folders.trash", || {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
@@ -721,7 +791,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
trashed_at = NOW(),
|
||||
original_parent_id = parent_id,
|
||||
updated_at = NOW(),
|
||||
updated_by = user_id
|
||||
updated_by = $2
|
||||
WHERE id = $1::uuid AND NOT is_trashed
|
||||
RETURNING id, lpath
|
||||
),
|
||||
@@ -730,7 +800,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
SET is_trashed = TRUE,
|
||||
trashed_at = NOW(),
|
||||
updated_at = NOW(),
|
||||
updated_by = f.user_id
|
||||
updated_by = $2
|
||||
FROM trash_root tr
|
||||
WHERE f.lpath <@ tr.lpath
|
||||
AND f.id != tr.id
|
||||
@@ -742,7 +812,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
SET is_trashed = TRUE,
|
||||
trashed_at = NOW(),
|
||||
updated_at = NOW(),
|
||||
updated_by = fi.user_id
|
||||
updated_by = $2
|
||||
FROM trash_root tr
|
||||
JOIN storage.folders f ON f.lpath <@ tr.lpath
|
||||
WHERE fi.folder_id = f.id
|
||||
@@ -753,6 +823,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
"#,
|
||||
)
|
||||
.bind(folder_id)
|
||||
.bind(caller_id)
|
||||
.fetch_one(self.pool())
|
||||
})
|
||||
.await
|
||||
@@ -769,6 +840,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
&self,
|
||||
folder_id: &str,
|
||||
_original_path: &str,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), DomainError> {
|
||||
// Inverse of the cascade in `move_to_trash`: restore the root
|
||||
// (BEFORE UPDATE trigger recomputes path/lpath via the parent_id
|
||||
@@ -778,6 +850,10 @@ impl FolderRepository for FolderDbRepository {
|
||||
// *before* this folder went to trash have `original_*` set, so
|
||||
// they correctly stay in trash and continue to show up as
|
||||
// top-level trash entries via `storage.trash_items`.
|
||||
//
|
||||
// §14: all three CTE branches stamp `updated_by = $2`
|
||||
// (caller_id). Restoration is "the caller restored this
|
||||
// subtree", regardless of who originally owned each row.
|
||||
let result = retry_on_deadlock("folders.restore", || {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
r#"
|
||||
@@ -788,7 +864,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
parent_id = COALESCE(original_parent_id, parent_id),
|
||||
original_parent_id = NULL,
|
||||
updated_at = NOW(),
|
||||
updated_by = user_id
|
||||
updated_by = $2
|
||||
WHERE id = $1::uuid AND is_trashed
|
||||
RETURNING id, lpath
|
||||
),
|
||||
@@ -797,7 +873,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
SET is_trashed = FALSE,
|
||||
trashed_at = NULL,
|
||||
updated_at = NOW(),
|
||||
updated_by = f.user_id
|
||||
updated_by = $2
|
||||
FROM restore_root rr
|
||||
WHERE f.lpath <@ rr.lpath
|
||||
AND f.id != rr.id
|
||||
@@ -810,7 +886,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
SET is_trashed = FALSE,
|
||||
trashed_at = NULL,
|
||||
updated_at = NOW(),
|
||||
updated_by = fi.user_id
|
||||
updated_by = $2
|
||||
FROM restore_root rr
|
||||
JOIN storage.folders f ON f.lpath <@ rr.lpath
|
||||
WHERE fi.folder_id = f.id
|
||||
@@ -822,6 +898,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
"#,
|
||||
)
|
||||
.bind(folder_id)
|
||||
.bind(caller_id)
|
||||
.fetch_one(self.pool())
|
||||
})
|
||||
.await
|
||||
@@ -911,16 +988,25 @@ impl FolderRepository for FolderDbRepository {
|
||||
ca,
|
||||
ma,
|
||||
tma,
|
||||
// INSERT stamped both provenance columns from user_id
|
||||
// (D0 dual-write); D2 will plumb the real caller_id.
|
||||
Some(user_id),
|
||||
Some(user_id),
|
||||
),
|
||||
None => {
|
||||
// Already exists — fetch it
|
||||
let existing = sqlx::query_as::<_, (String, String, i64, i64, i64)>(
|
||||
// Already exists — fetch it. SELECT also pulls the §14
|
||||
// provenance columns so the entity layer reflects DB truth.
|
||||
let existing = sqlx::query_as::<
|
||||
_,
|
||||
(String, String, i64, i64, i64, Option<Uuid>, Option<Uuid>),
|
||||
>(
|
||||
r#"
|
||||
SELECT id::text,
|
||||
path,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE name = $1 AND user_id = $2 AND parent_id IS NULL
|
||||
"#,
|
||||
@@ -940,6 +1026,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
existing.2,
|
||||
existing.3,
|
||||
existing.4,
|
||||
existing.5,
|
||||
existing.6,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -955,7 +1043,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
fo.user_id, fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.is_trashed = false \
|
||||
AND fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = $1::uuid) \
|
||||
@@ -970,8 +1059,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
})?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1018,7 +1107,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
fo.user_id, fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.user_id = $1 \
|
||||
AND fo.is_trashed = false \
|
||||
@@ -1042,8 +1132,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
|
||||
return rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
@@ -1055,7 +1145,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
fo.user_id, fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.parent_id = $1::uuid \
|
||||
AND fo.user_id = $2 \
|
||||
@@ -1074,7 +1165,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
fo.user_id, fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.parent_id IS NULL \
|
||||
AND fo.user_id = $1 \
|
||||
@@ -1114,8 +1206,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, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1143,7 +1235,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
fo.user_id, fo.drive_id, \
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.user_id = $1 \
|
||||
AND fo.is_trashed = false \
|
||||
@@ -1170,8 +1263,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, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1192,7 +1285,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE parent_id = $1::uuid
|
||||
AND NOT is_trashed
|
||||
@@ -1218,7 +1312,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
SELECT id::text, name, path, parent_id::text, user_id, drive_id,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint,
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint
|
||||
EXTRACT(EPOCH FROM tree_modified_at)::bigint,
|
||||
created_by, updated_by
|
||||
FROM storage.folders
|
||||
WHERE parent_id IS NULL
|
||||
AND NOT is_trashed
|
||||
@@ -1241,8 +1336,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("suggest: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma)
|
||||
.map(|(id, name, path, pid, uid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, Some(uid), did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user