refactor(msg-bus): prefer MessageBus as Realtime

This commit is contained in:
Edouard Vanbelle
2026-09-11 00:25:25 +02:00
parent 1d280c161c
commit 7918fff47b
51 changed files with 295 additions and 227 deletions
@@ -1,13 +1,13 @@
//! Realtime message-bus port — the seam every service publishes through and
//! every WS session subscribes on.
//! Message-bus port — the seam every service publishes through and every WS
//! session subscribes on.
//!
//! # Design (see `docs/plan/message-bus.md`)
//!
//! - [`RealtimeBus`] is the **local-facing** trait: services publish, the WS
//! - [`MessageBus`] is the **local-facing** trait: services publish, the WS
//! handler subscribes. It never involves the network.
//! - [`BusReplicator`] is the OPTIONAL seam that mirrors local publishes to
//! and from a broker (pg `LISTEN/NOTIFY`, RabbitMQ, NATS). Callers see only
//! [`RealtimeBus`]; a real replicator plugs into the in-process impl without
//! [`MessageBus`]; a real replicator plugs into the in-process impl without
//! touching consumers. Day-1 impl is [`NoopReplicator`].
//!
//! # MVP scope
@@ -17,10 +17,10 @@
//! to folders the caller can't `Read`:
//!
//! - Topics: [`Topic::Folder`] and [`Topic::UserAuthz`]
//! - Events: [`RealtimeEvent::FileCreated`], [`RealtimeEvent::FileRenamed`],
//! [`RealtimeEvent::FileMoved`], [`RealtimeEvent::FileDeleted`],
//! [`RealtimeEvent::FolderCreated`], [`RealtimeEvent::FolderRenamed`],
//! [`RealtimeEvent::FolderMoved`], [`RealtimeEvent::FolderDeleted`]
//! - Events: [`MessageBusEvent::FileCreated`], [`MessageBusEvent::FileRenamed`],
//! [`MessageBusEvent::FileMoved`], [`MessageBusEvent::FileDeleted`],
//! [`MessageBusEvent::FolderCreated`], [`MessageBusEvent::FolderRenamed`],
//! [`MessageBusEvent::FolderMoved`], [`MessageBusEvent::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}`,
@@ -47,7 +47,7 @@ use crate::common::errors::DomainError;
// Topic — a typed key on the bus
// ════════════════════════════════════════════════════════════════════════════
/// A topic on the realtime bus. Typed enum, not a string — prevents typos
/// A topic on the message bus. Typed enum, not a string — prevents typos
/// and gives exhaustive matching in the AuthZ dispatch and the wire encoder.
///
/// Encodes to a stable dotted wire key that maps naturally onto RabbitMQ
@@ -157,7 +157,7 @@ pub enum AuthzCheck {
}
// ════════════════════════════════════════════════════════════════════════════
// RealtimeEvent — the payload
// MessageBusEvent — the payload
// ════════════════════════════════════════════════════════════════════════════
/// A fact that has just become true. Emitted by services AFTER commit,
@@ -176,7 +176,7 @@ pub enum AuthzCheck {
/// per project convention.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum RealtimeEvent {
pub enum MessageBusEvent {
/// A file was created inside `parent_id`.
FileCreated {
file_id: Uuid,
@@ -315,7 +315,7 @@ pub mod error_code {
}
// ════════════════════════════════════════════════════════════════════════════
// RealtimeBus — the port
// MessageBus — the port
// ════════════════════════════════════════════════════════════════════════════
/// The local-facing message bus. Fire-and-forget publish, stream subscribe.
@@ -326,11 +326,11 @@ pub mod error_code {
///
/// `subscribe` returns a `Stream` so the impl can change (broadcast, mpsc,
/// pg listener) without churn at the consumer.
pub trait RealtimeBus: Send + Sync + 'static {
pub trait MessageBus: Send + Sync + 'static {
/// Fan an event out to every current subscriber of `topic`. Never
/// blocks; slow subscribers are dropped by the impl (they'll reconnect
/// and refetch).
fn publish(&self, topic: &Topic, event: RealtimeEvent);
fn publish(&self, topic: &Topic, event: MessageBusEvent);
/// Subscribe to `topic`. The returned stream yields events until the
/// subscriber is dropped or the impl kicks it out (e.g. for lagging
@@ -338,15 +338,15 @@ pub trait RealtimeBus: Send + Sync + 'static {
fn subscribe(&self, topic: &Topic) -> BusStream;
}
/// Boxed stream returned by [`RealtimeBus::subscribe`]. Aliased so
/// Boxed stream returned by [`MessageBus::subscribe`]. Aliased so
/// consumers don't need to spell out the `Pin<Box<...>>` shape.
pub type BusStream = Pin<Box<dyn Stream<Item = RealtimeEvent> + Send>>;
pub type BusStream = Pin<Box<dyn Stream<Item = MessageBusEvent> + Send>>;
// ════════════════════════════════════════════════════════════════════════════
// BusReplicator — the multi-instance seam (day-1 noop)
// ════════════════════════════════════════════════════════════════════════════
/// Cross-instance replicator. Sits BESIDE [`RealtimeBus`], not in front of
/// Cross-instance replicator. Sits BESIDE [`MessageBus`], not in front of
/// it — the bus does the local fan-out; the replicator forwards outbound
/// publishes to the broker (pg NOTIFY, RabbitMQ, NATS) and injects inbound
/// broker messages back into the local bus.
@@ -358,7 +358,7 @@ pub trait BusReplicator: Send + Sync + 'static {
/// Called by the local bus for every publish. Fire-and-forget — must not
/// block or await; forwarding to the broker happens on a background task
/// owned by the impl.
fn on_local_publish(&self, topic: &Topic, event: &RealtimeEvent);
fn on_local_publish(&self, topic: &Topic, event: &MessageBusEvent);
/// Long-running consumer task: reads remote messages and re-publishes
/// locally. Returns when `shutdown` is notified — DI calls
@@ -382,7 +382,7 @@ pub struct NoopReplicator;
#[async_trait::async_trait]
impl BusReplicator for NoopReplicator {
fn on_local_publish(&self, _topic: &Topic, _event: &RealtimeEvent) {
fn on_local_publish(&self, _topic: &Topic, _event: &MessageBusEvent) {
// Intentionally empty. Local fan-out already happened in the bus.
}
@@ -466,9 +466,9 @@ mod tests {
// 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)] = &[
let cases: &[(MessageBusEvent, &str)] = &[
(
RealtimeEvent::FileCreated {
MessageBusEvent::FileCreated {
file_id: Uuid::nil(),
name: "notes.md".into(),
parent_id: Uuid::nil(),
@@ -477,7 +477,7 @@ mod tests {
"file_created",
),
(
RealtimeEvent::FileRenamed {
MessageBusEvent::FileRenamed {
file_id: Uuid::nil(),
old_name: "a.md".into(),
new_name: "b.md".into(),
@@ -487,7 +487,7 @@ mod tests {
"file_renamed",
),
(
RealtimeEvent::FileMoved {
MessageBusEvent::FileMoved {
file_id: Uuid::nil(),
name: "a.md".into(),
from: Uuid::nil(),
@@ -497,7 +497,7 @@ mod tests {
"file_moved",
),
(
RealtimeEvent::FileDeleted {
MessageBusEvent::FileDeleted {
file_id: Uuid::nil(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
@@ -505,7 +505,7 @@ mod tests {
"file_deleted",
),
(
RealtimeEvent::FolderCreated {
MessageBusEvent::FolderCreated {
folder_id: Uuid::nil(),
name: "docs".into(),
parent_id: Uuid::nil(),
@@ -514,7 +514,7 @@ mod tests {
"folder_created",
),
(
RealtimeEvent::FolderRenamed {
MessageBusEvent::FolderRenamed {
folder_id: Uuid::nil(),
old_name: "old".into(),
new_name: "new".into(),
@@ -524,7 +524,7 @@ mod tests {
"folder_renamed",
),
(
RealtimeEvent::FolderMoved {
MessageBusEvent::FolderMoved {
folder_id: Uuid::nil(),
name: "docs".into(),
from: Uuid::nil(),
@@ -534,7 +534,7 @@ mod tests {
"folder_moved",
),
(
RealtimeEvent::FolderDeleted {
MessageBusEvent::FolderDeleted {
folder_id: Uuid::nil(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
@@ -542,7 +542,7 @@ mod tests {
"folder_deleted",
),
(
RealtimeEvent::AuthzChanged {
MessageBusEvent::AuthzChanged {
affected_folders: vec![Uuid::nil()],
},
"authz_changed",
@@ -562,14 +562,14 @@ mod tests {
let file_id = Uuid::new_v4();
let parent_id = Uuid::new_v4();
let actor = Uuid::new_v4();
let original = RealtimeEvent::FileCreated {
let original = MessageBusEvent::FileCreated {
file_id,
name: "a.txt".into(),
parent_id,
actor,
};
let json = serde_json::to_string(&original).unwrap();
let decoded: RealtimeEvent = serde_json::from_str(&json).unwrap();
let decoded: MessageBusEvent = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, original);
}
@@ -618,7 +618,7 @@ mod tests {
// on_local_publish is a no-op that should not panic or spawn work.
repl.on_local_publish(
&Topic::Folder(Uuid::nil()),
&RealtimeEvent::FileCreated {
&MessageBusEvent::FileCreated {
file_id: Uuid::nil(),
name: "x".into(),
parent_id: Uuid::nil(),
+1 -1
View File
@@ -18,11 +18,11 @@ pub mod file_lifecycle;
pub mod file_ports;
pub mod folder_ports;
pub mod inbound;
pub mod message_bus_ports;
pub mod music_ports;
pub mod opaque_ports;
pub mod outbound;
pub mod plugin_ports;
pub mod realtime_ports;
pub mod recent_ports;
pub mod resource_access_hook;
pub mod share_ports;
@@ -57,12 +57,12 @@ 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
/// Message bus. When wired, delete / rename / move
/// mutations publish their corresponding `MessageBusEvent` 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>>,
bus: Option<Arc<dyn crate::application::ports::message_bus_ports::MessageBus>>,
/// 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,
@@ -102,11 +102,11 @@ impl FileManagementService {
}
}
/// Wire the realtime message bus. When set, delete / rename / move
/// Wire the message bus. When set, delete / rename / move
/// mutations publish on the affected folder topics after commit.
pub fn with_realtime_bus(
pub fn with_message_bus(
mut self,
bus: Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>,
bus: Arc<dyn crate::application::ports::message_bus_ports::MessageBus>,
) -> Self {
self.bus = Some(bus);
self
@@ -208,7 +208,7 @@ impl FileManagementService {
}
/// Snapshot the (uuid, name, parent-folder-uuid) of a file BEFORE
/// a mutation, so the realtime publish path has a stable
/// a mutation, so the message-bus publish path has a stable
/// `Topic::Folder(parent)` to address even after the write commits
/// (delete removes the row; move rewrites `folder_id`).
///
@@ -239,10 +239,10 @@ impl FileManagementService {
/// 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};
use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish(
&Topic::Folder(parent_uuid),
RealtimeEvent::FileDeleted {
MessageBusEvent::FileDeleted {
file_id: file_uuid,
parent_id: parent_uuid,
actor: caller_id,
@@ -539,7 +539,7 @@ impl FileManagementUseCase for FileManagementService {
let dto = self.move_file(file_id, folder_id, caller_id).await?;
// Realtime fan-out on BOTH source and destination folder
// Bus 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
@@ -552,8 +552,8 @@ impl FileManagementUseCase for FileManagementService {
&& 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 {
use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
let event = MessageBusEvent::FileMoved {
file_id: file_uuid,
name,
from: source_uuid,
@@ -674,7 +674,7 @@ impl FileManagementUseCase for FileManagementService {
let dto = self.rename_file(file_id, new_name, caller_id).await?;
// Realtime publish AFTER commit. Silent no-op when the bus
// Bus 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)) =
@@ -682,10 +682,10 @@ impl FileManagementUseCase for FileManagementService {
&& 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};
use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish(
&Topic::Folder(parent_uuid),
RealtimeEvent::FileRenamed {
MessageBusEvent::FileRenamed {
file_id: file_uuid,
old_name,
new_name: dto.name.clone(),
@@ -56,13 +56,13 @@ pub struct FileUploadService {
/// (`create_file_from_owned_blob_with_perms`); `None` in minimal test
/// wiring.
instant_upload: Option<InstantUploadDeps>,
/// Realtime message bus. When wired, `upload_file_streaming`
/// Message bus. When wired, `upload_file_streaming`
/// publishes a `FileCreated` event on `Topic::Folder(parent_id)`
/// after the DB commit — subscribers see the new file appear in
/// their live folder view. Optional so stub / test factories can
/// build the service without a bus; a `None` bus is a silent no-op
/// on the publish path.
bus: Option<Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>>,
bus: Option<Arc<dyn crate::application::ports::message_bus_ports::MessageBus>>,
}
/// Everything the instant-upload path needs beyond the upload service's own
@@ -117,13 +117,13 @@ impl FileUploadService {
self
}
/// Wire the realtime message bus. Enables live folder-view updates:
/// Wire the message bus. Enables live folder-view updates:
/// after `upload_file_streaming` commits, a `FileCreated` event
/// fires on `Topic::Folder(parent_id)` — subscribers see the new
/// file appear without polling.
pub fn with_realtime_bus(
pub fn with_message_bus(
mut self,
bus: Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>,
bus: Arc<dyn crate::application::ports::message_bus_ports::MessageBus>,
) -> Self {
self.bus = Some(bus);
self
@@ -476,7 +476,7 @@ impl FileUploadUseCase for FileUploadService {
// "I just uploaded X" UX matches the pre-SvelteKit behaviour.
self.notify_file_accessed(caller_id, &dto.id);
// Realtime fan-out AFTER commit — subscribers to the parent
// Bus fan-out AFTER commit — subscribers to the parent
// folder's topic see the new file appear live. Silent no-op if
// the bus isn't wired (stubs / tests) or the file landed at
// drive-root (no folder id → nothing to publish on).
@@ -484,10 +484,10 @@ impl FileUploadUseCase for FileUploadService {
&& let (Ok(parent_uuid), Ok(file_uuid)) =
(Uuid::parse_str(parent_folder_id), Uuid::parse_str(&dto.id))
{
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic};
use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish(
&Topic::Folder(parent_uuid),
RealtimeEvent::FileCreated {
MessageBusEvent::FileCreated {
file_id: file_uuid,
name: dto.name.clone(),
parent_id: parent_uuid,
+16 -16
View File
@@ -49,13 +49,13 @@ pub struct FolderService {
/// on cross-drive MOVE. Silently skipped when unwired (stubs).
storage_usage:
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
/// Realtime message bus. When wired, `create_folder_with_perms`
/// Message bus. When wired, `create_folder_with_perms`
/// publishes a `FolderCreated` event on `Topic::Folder(parent_id)`
/// after the DB commit — subscribers see the new folder appear in
/// their live folder view. Optional so stub / test factories can
/// build the service without a bus; a `None` bus is a silent no-op
/// on the publish path (no fan-out, no audit).
bus: Option<Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>>,
bus: Option<Arc<dyn crate::application::ports::message_bus_ports::MessageBus>>,
}
impl FolderService {
@@ -77,12 +77,12 @@ impl FolderService {
}
}
/// Wire the realtime message bus. Enables live folder-view updates:
/// Wire the message bus. Enables live folder-view updates:
/// after `create_folder_with_perms` commits, a `FolderCreated` event
/// fires on `Topic::Folder(parent_id)`. Off in stubs / tests.
pub fn with_realtime_bus(
pub fn with_message_bus(
mut self,
bus: Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>,
bus: Arc<dyn crate::application::ports::message_bus_ports::MessageBus>,
) -> Self {
self.bus = Some(bus);
self
@@ -406,10 +406,10 @@ impl FolderUseCase for FolderService {
parent_uuid_for_publish,
Uuid::parse_str(folder.id()),
) {
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic};
use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish(
&Topic::Folder(parent_uuid),
RealtimeEvent::FolderCreated {
MessageBusEvent::FolderCreated {
folder_id: folder_uuid,
name: folder.name().to_owned(),
parent_id: parent_uuid,
@@ -813,7 +813,7 @@ impl FolderUseCase for FolderService {
drive_repo.invalidate_default_drive_all();
}
// Realtime publish AFTER commit. Root folders (`parent_id() = None`)
// Bus 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
@@ -822,10 +822,10 @@ impl FolderUseCase for FolderService {
&& 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};
use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish(
&Topic::Folder(parent_uuid),
RealtimeEvent::FolderRenamed {
MessageBusEvent::FolderRenamed {
folder_id: folder_uuid,
old_name: folder.name().to_owned(),
new_name: renamed.name().to_owned(),
@@ -984,7 +984,7 @@ impl FolderUseCase for FolderService {
)
})?;
// Realtime fan-out on BOTH source and destination folder
// Bus 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
@@ -995,8 +995,8 @@ impl FolderUseCase for FolderService {
(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 {
use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
let event = MessageBusEvent::FolderMoved {
folder_id: folder_uuid,
name: folder.name().to_owned(),
from: source_uuid,
@@ -1106,15 +1106,15 @@ impl FolderUseCase for FolderService {
self.file_lifecycle.on_file_deleted(file_id);
}
// Realtime publish AFTER the DELETE commits. Root folders
// Bus 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};
use crate::application::ports::message_bus_ports::{MessageBusEvent, Topic};
bus.publish(
&Topic::Folder(parent_uuid),
RealtimeEvent::FolderDeleted {
MessageBusEvent::FolderDeleted {
folder_id: folder_uuid,
parent_id: parent_uuid,
actor: caller_id,