feat(msg-bus): add file and folder mutation notoficaton + tests

This commit is contained in:
Edouard Vanbelle
2026-09-10 07:15:09 +02:00
parent d20c792056
commit d850e9c100
7 changed files with 626 additions and 30 deletions
+144 -21
View File
@@ -17,7 +17,10 @@
//! to folders the caller can't `Read`:
//!
//! - Topics: [`Topic::Folder`] and [`Topic::UserAuthz`]
//! - Events: [`RealtimeEvent::FileCreated`], [`RealtimeEvent::FolderCreated`]
//! - Events: [`RealtimeEvent::FileCreated`], [`RealtimeEvent::FileRenamed`],
//! [`RealtimeEvent::FileMoved`], [`RealtimeEvent::FileDeleted`],
//! [`RealtimeEvent::FolderCreated`], [`RealtimeEvent::FolderRenamed`],
//! [`RealtimeEvent::FolderMoved`], [`RealtimeEvent::FolderDeleted`]
//!
//! Adding a variant is a one-line change plus a match arm in `to_wire_key` /
//! `parse` / `required_perm`. Other topics (`file:{id}`, `job:{id}`,
@@ -181,6 +184,36 @@ pub enum RealtimeEvent {
parent_id: Uuid,
actor: Uuid,
},
/// A file was renamed. `parent_id` unchanged — same folder.
FileRenamed {
file_id: Uuid,
old_name: String,
new_name: String,
parent_id: Uuid,
actor: Uuid,
},
/// A file was moved between folders. Fanned out on BOTH the source
/// and destination folder topics — subscribers to either see the
/// event once. `from` / `to` are the folder UUIDs; a move
/// involving a drive root would be `Option<Uuid>` in a future
/// variant, but MVP mutations all address a real folder.
FileMoved {
file_id: Uuid,
name: String,
from: Uuid,
to: Uuid,
actor: Uuid,
},
/// A file was deleted (trashed OR permanently removed — the wire
/// doesn't distinguish, and clients treat both as "disappears from
/// the folder view"). `parent_id` is the folder the file used to
/// live in — snapshotted before the delete since the row may be
/// gone by publish time.
FileDeleted {
file_id: Uuid,
parent_id: Uuid,
actor: Uuid,
},
/// A sub-folder was created inside `parent_id`.
FolderCreated {
folder_id: Uuid,
@@ -188,6 +221,30 @@ pub enum RealtimeEvent {
parent_id: Uuid,
actor: Uuid,
},
/// A folder was renamed. `parent_id` unchanged.
FolderRenamed {
folder_id: Uuid,
old_name: String,
new_name: String,
parent_id: Uuid,
actor: Uuid,
},
/// A folder was moved between parents. Fanned out on BOTH source
/// and destination folder topics.
FolderMoved {
folder_id: Uuid,
name: String,
from: Uuid,
to: Uuid,
actor: Uuid,
},
/// A folder was deleted (trashed or permanent — see `FileDeleted`
/// for the same wire-collapse rationale).
FolderDeleted {
folder_id: Uuid,
parent_id: Uuid,
actor: Uuid,
},
}
// ════════════════════════════════════════════════════════════════════════════
@@ -394,26 +451,92 @@ mod tests {
fn event_serializes_with_snake_case_discriminator() {
// The `#[serde(tag = "event")]` shape is the WS wire contract for
// the `rt.event` JSON-RPC notification's `params.event` field. Pin
// it with a snapshot so accidental rename of the enum variant
// fails the test instead of silently breaking clients.
let ev = RealtimeEvent::FileCreated {
file_id: Uuid::nil(),
name: "notes.md".into(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
};
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["event"], "file_created");
assert_eq!(json["name"], "notes.md");
let ev = RealtimeEvent::FolderCreated {
folder_id: Uuid::nil(),
name: "docs".into(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
};
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["event"], "folder_created");
// every variant's discriminator with a snapshot so an accidental
// rename fails the test instead of silently breaking clients —
// the AsyncAPI spec's `event` enum mirrors these exact strings.
let cases: &[(RealtimeEvent, &str)] = &[
(
RealtimeEvent::FileCreated {
file_id: Uuid::nil(),
name: "notes.md".into(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
},
"file_created",
),
(
RealtimeEvent::FileRenamed {
file_id: Uuid::nil(),
old_name: "a.md".into(),
new_name: "b.md".into(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
},
"file_renamed",
),
(
RealtimeEvent::FileMoved {
file_id: Uuid::nil(),
name: "a.md".into(),
from: Uuid::nil(),
to: Uuid::nil(),
actor: Uuid::nil(),
},
"file_moved",
),
(
RealtimeEvent::FileDeleted {
file_id: Uuid::nil(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
},
"file_deleted",
),
(
RealtimeEvent::FolderCreated {
folder_id: Uuid::nil(),
name: "docs".into(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
},
"folder_created",
),
(
RealtimeEvent::FolderRenamed {
folder_id: Uuid::nil(),
old_name: "old".into(),
new_name: "new".into(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
},
"folder_renamed",
),
(
RealtimeEvent::FolderMoved {
folder_id: Uuid::nil(),
name: "docs".into(),
from: Uuid::nil(),
to: Uuid::nil(),
actor: Uuid::nil(),
},
"folder_moved",
),
(
RealtimeEvent::FolderDeleted {
folder_id: Uuid::nil(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
},
"folder_deleted",
),
];
for (ev, expected) in cases {
let json = serde_json::to_value(ev).unwrap();
assert_eq!(
json["event"], *expected,
"wire discriminator mismatch for {ev:?}"
);
}
}
#[test]
@@ -5,7 +5,7 @@ use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::file_lifecycle::FileLifecycleHook;
use crate::application::ports::file_ports::FileManagementUseCase;
use crate::application::ports::resource_access_hook::ResourceAccessHook;
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileWritePort};
use crate::application::ports::storage_ports::{CopyFolderTreeResult, FileReadPort, FileWritePort};
use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::services::external_mount_router::{MountRouter, ResolvedId};
use crate::application::services::mount_dto::{audit_mount_write, mount_file_dto, mount_parent_id};
@@ -57,6 +57,20 @@ pub struct FileManagementService {
/// (stub/test builders); production DI wires it in.
storage_usage:
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
/// Realtime message bus. When wired, delete / rename / move
/// mutations publish their corresponding `RealtimeEvent` on
/// `Topic::Folder(parent_id)` (both source AND destination for
/// move) after the DB commit. `None` silently no-ops the publish
/// path — same pattern as `bus` on FileUploadService.
bus: Option<Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>>,
/// Read repository — needed by the mutation publish path
/// (delete / rename / move) to snapshot the file's pre-mutation
/// parent folder BEFORE the write commits: delete removes the row,
/// move rewrites `folder_id`. Without it we couldn't publish on
/// the correct `Topic::Folder(parent)` (delete) or fan out on the
/// source-side folder (move). Optional so stubs stay minimal; when
/// unwired, the affected publishes silently no-op.
file_read: Option<Arc<FileBlobReadRepository>>,
}
impl FileManagementService {
@@ -68,7 +82,7 @@ impl FileManagementService {
pub fn with_trash(
file_repository: Arc<FileBlobWriteRepository>,
trash_service: Option<Arc<TrashService>>,
_file_read: Option<Arc<FileBlobReadRepository>>,
file_read: Option<Arc<FileBlobReadRepository>>,
_folder_repo: Option<Arc<FolderDbRepository>>,
content_cache: Option<Arc<FileContentCache>>,
authz: Arc<PgAclEngine>,
@@ -83,9 +97,21 @@ impl FileManagementService {
resource_access_hook: None,
drive_repo: None,
storage_usage: None,
bus: None,
file_read,
}
}
/// Wire the realtime message bus. When set, delete / rename / move
/// mutations publish on the affected folder topics after commit.
pub fn with_realtime_bus(
mut self,
bus: Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>,
) -> Self {
self.bus = Some(bus);
self
}
/// Sets the lifecycle hook dispatcher (thumbnails, audio metadata, …).
pub fn with_file_lifecycle_hook(mut self, hook: Arc<dyn FileLifecycleHook>) -> Self {
self.file_lifecycle_hook = Some(hook);
@@ -181,6 +207,50 @@ impl FileManagementService {
self
}
/// Snapshot the (uuid, name, parent-folder-uuid) of a file BEFORE
/// a mutation, so the realtime publish path has a stable
/// `Topic::Folder(parent)` to address even after the write commits
/// (delete removes the row; move rewrites `folder_id`).
///
/// Returns `None` when:
/// - `file_read` is unwired (stub / test builder),
/// - the file can't be read (already gone, permission failure —
/// the caller is responsible for AuthZ, this is only a
/// best-effort snapshot),
/// - the file is at drive-root (no parent folder, nothing to
/// publish on),
/// - the id can't be parsed as a `Uuid` (mount id or malformed).
///
/// All `None` paths silently skip the publish — never fail the
/// mutation. The bus is best-effort.
async fn snapshot_for_publish(&self, file_id: &str) -> Option<(Uuid, String, Uuid)> {
let file_read = self.file_read.as_ref()?;
let file = file_read.get_file(file_id).await.ok()?;
let parts = file.into_parts();
let file_uuid = Uuid::parse_str(&parts.id).ok()?;
let parent_uuid = Uuid::parse_str(parts.folder_id.as_deref()?).ok()?;
Some((file_uuid, parts.name, parent_uuid))
}
/// Publish `FileDeleted` on the file's parent folder topic. Called
/// by both the trash and permanent-delete paths so subscribers see
/// one event regardless of which happened. Silent no-op when the
/// bus isn't wired or the pre-mutation snapshot failed (drive-root
/// file, mount, unwired `file_read`).
fn publish_file_deleted(&self, caller_id: Uuid, snapshot: Option<(Uuid, String, Uuid)>) {
if let (Some(bus), Some((file_uuid, _name, parent_uuid))) = (&self.bus, snapshot) {
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic};
bus.publish(
&Topic::Folder(parent_uuid),
RealtimeEvent::FileDeleted {
file_id: file_uuid,
parent_id: parent_uuid,
actor: caller_id,
},
);
}
}
/// Engine check for a file resource. Parses the id into a `Uuid` and
/// requires the specified permission.
async fn require_file_perm(
@@ -462,8 +532,41 @@ impl FileManagementUseCase for FileManagementService {
}
}
// Snapshot source parent BEFORE the write — after `move_file`
// the row's `folder_id` reflects the destination, so we'd lose
// the from-side for the fan-out.
let source_snapshot = self.snapshot_for_publish(file_id).await;
let dto = self.move_file(file_id, folder_id, caller_id).await?;
// Realtime fan-out on BOTH source and destination folder
// topics. Subscribers to the source see the file "gone" from
// their view; subscribers to the destination see it "appear".
// Silent no-op when the bus isn't wired, the source snapshot
// failed (drive-root file, mount), or the destination is
// drive-root (`dto.folder_id = None`). Any of those cases
// matches the "no interested subscribers" invariant so
// silently skipping is honest.
if let (Some(bus), Some((file_uuid, name, source_uuid)), Some(dest_str)) =
(&self.bus, source_snapshot, dto.folder_id.as_deref())
&& let Ok(dest_uuid) = Uuid::parse_str(dest_str)
&& source_uuid != dest_uuid
{
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic};
let event = RealtimeEvent::FileMoved {
file_id: file_uuid,
name,
from: source_uuid,
to: dest_uuid,
actor: caller_id,
};
// Publish twice — subscribers to either folder see the
// event exactly once because they're only subscribed to
// one of the two topics.
bus.publish(&Topic::Folder(source_uuid), event.clone());
bus.publish(&Topic::Folder(dest_uuid), event);
}
// Cross-drive move invalidates the file's `owner_cache` entry
// in the authz engine — the cache assumed drive_id stability
// that no longer holds. Without this call the drive-role
@@ -559,7 +662,39 @@ impl FileManagementUseCase for FileManagementService {
}
self.require_file_perm(file_id, Permission::Update, caller_id)
.await?;
self.rename_file(file_id, new_name, caller_id).await
// Snapshot old_name pre-rename so the publish carries both
// sides of the transition. `parent_id` is the same before and
// after (rename doesn't move) so we can safely reuse it from
// the post-mutation DTO.
let old_name = self
.snapshot_for_publish(file_id)
.await
.map(|(_, name, _)| name);
let dto = self.rename_file(file_id, new_name, caller_id).await?;
// Realtime publish AFTER commit. Silent no-op when the bus
// isn't wired, the pre-fetch failed (old_name = None), or the
// file has no folder (`dto.folder_id = None` — drive-root).
if let (Some(bus), Some(old_name), Some(parent_str)) =
(&self.bus, old_name, dto.folder_id.as_deref())
&& let (Ok(file_uuid), Ok(parent_uuid)) =
(Uuid::parse_str(&dto.id), Uuid::parse_str(parent_str))
{
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic};
bus.publish(
&Topic::Folder(parent_uuid),
RealtimeEvent::FileRenamed {
file_id: file_uuid,
old_name,
new_name: dto.name.clone(),
parent_id: parent_uuid,
actor: caller_id,
},
);
}
Ok(dto)
}
async fn delete_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
@@ -572,7 +707,15 @@ impl FileManagementUseCase for FileManagementService {
}
self.require_file_perm(id, Permission::Delete, caller_id)
.await?;
self.delete_file(id).await
// Snapshot the pre-delete parent so the publish path has a
// `Topic::Folder(parent)` to address — the row is gone by the
// time `delete_file` returns.
let snapshot = self.snapshot_for_publish(id).await;
self.delete_file(id).await?;
self.publish_file_deleted(caller_id, snapshot);
Ok(())
}
/// Smart delete: trash-first with dedup reference cleanup.
@@ -597,6 +740,15 @@ impl FileManagementUseCase for FileManagementService {
self.require_file_perm(id, Permission::Delete, caller_id)
.await?;
// Snapshot the pre-mutation parent so both the trash and the
// fallback permanent-delete path can publish `FileDeleted` on
// the right folder topic. Trash leaves the row in place but
// `is_trashed=TRUE` makes it disappear from folder listings —
// subscribers should see the same "gone from this folder"
// event either way.
let snapshot = self.snapshot_for_publish(id).await;
// Step 1: Try trash (soft delete — file row stays, blob stays referenced)
if let Some(trash) = &self.trash_service {
info!("Moving file to trash: {}", id);
@@ -610,6 +762,7 @@ impl FileManagementUseCase for FileManagementService {
// Do NOT decrement blob ref here — the file row still exists
// (is_trashed = TRUE). The trigger will decrement when the
// row is actually DELETEd during trash emptying.
self.publish_file_deleted(caller_id, snapshot);
return Ok(true); // trashed
}
Err(err) => {
@@ -625,7 +778,7 @@ impl FileManagementUseCase for FileManagementService {
// Step 2: Permanent delete — trigger handles blob ref_count
self.delete_file(id).await?;
self.publish_file_deleted(caller_id, snapshot);
Ok(false) // permanently deleted
}
@@ -813,6 +813,28 @@ impl FolderUseCase for FolderService {
drive_repo.invalidate_default_drive_all();
}
// Realtime publish AFTER commit. Root folders (`parent_id() = None`)
// have no parent folder topic to publish on — the drive's
// display-name change is handled by the readable/default-drive
// cache invalidations above, not the bus. Silent no-op if the
// bus isn't wired.
if let (Some(bus), Some(parent_str)) = (&self.bus, folder.parent_id())
&& let (Ok(folder_uuid), Ok(parent_uuid)) =
(Uuid::parse_str(renamed.id()), Uuid::parse_str(parent_str))
{
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic};
bus.publish(
&Topic::Folder(parent_uuid),
RealtimeEvent::FolderRenamed {
folder_id: folder_uuid,
old_name: folder.name().to_owned(),
new_name: renamed.name().to_owned(),
parent_id: parent_uuid,
actor: caller_id,
},
);
}
Ok(FolderDto::from(renamed))
}
@@ -938,6 +960,18 @@ impl FolderUseCase for FolderService {
}
}
// Snapshot source parent BEFORE the move — the post-move
// `folder.parent_id()` is the destination. Best-effort: if the
// lookup fails or the folder has no parent (root — can't be
// moved anyway per drive_semantics), the publish path below
// silently skips.
let source_parent_uuid = self
.folder_storage
.get_folder(id)
.await
.ok()
.and_then(|f| f.parent_id().and_then(|p| Uuid::parse_str(p).ok()));
let parent_ref = dto.parent_id.as_deref();
let folder = self
.folder_storage
@@ -950,6 +984,29 @@ impl FolderUseCase for FolderService {
)
})?;
// Realtime fan-out on BOTH source and destination folder
// topics. Same shape as `FileMoved` — subscribers to either
// see the event exactly once. Silent no-op when the bus isn't
// wired, the source snapshot failed, or the destination is
// drive-root (`folder.parent_id() = None`).
if let (Some(bus), Some(source_uuid), Some(dest_str)) =
(&self.bus, source_parent_uuid, folder.parent_id())
&& let (Ok(folder_uuid), Ok(dest_uuid)) =
(Uuid::parse_str(folder.id()), Uuid::parse_str(dest_str))
&& source_uuid != dest_uuid
{
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic};
let event = RealtimeEvent::FolderMoved {
folder_id: folder_uuid,
name: folder.name().to_owned(),
from: source_uuid,
to: dest_uuid,
actor: caller_id,
};
bus.publish(&Topic::Folder(source_uuid), event.clone());
bus.publish(&Topic::Folder(dest_uuid), event);
}
// Cross-drive move flushes the authz engine's `owner_cache`
// — every descendant's cached `Resource → drive_id` mapping
// just got stale via the cascade trigger, and we don't (yet)
@@ -1028,6 +1085,16 @@ impl FolderUseCase for FolderService {
.await
.unwrap_or_default();
// Pre-delete snapshot for the bus publish — post-DELETE the
// row is gone and we can't recover `parent_id`. Best-effort;
// failures fall through to a silent skip below.
let publish_snapshot: Option<(Uuid, Uuid)> =
self.folder_storage.get_folder(id).await.ok().and_then(|f| {
let folder_uuid = Uuid::parse_str(f.id()).ok()?;
let parent_uuid = Uuid::parse_str(f.parent_id()?).ok()?;
Some((folder_uuid, parent_uuid))
});
self.folder_storage.delete_folder(id).await.map_err(|e| {
DomainError::internal_error(
"FolderStorage",
@@ -1039,6 +1106,22 @@ impl FolderUseCase for FolderService {
self.file_lifecycle.on_file_deleted(file_id);
}
// Realtime publish AFTER the DELETE commits. Root folders
// (no parent) can't be deleted through this endpoint per the
// mount / drive-root guards above, so `publish_snapshot` is
// effectively always Some for regular deletes.
if let (Some(bus), Some((folder_uuid, parent_uuid))) = (&self.bus, publish_snapshot) {
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic};
bus.publish(
&Topic::Folder(parent_uuid),
RealtimeEvent::FolderDeleted {
folder_id: folder_uuid,
parent_id: parent_uuid,
actor: caller_id,
},
);
}
Ok(())
}
}