Skip to main content
Spacedrive’s event system broadcasts real-time updates to all connected clients using a unified resource event architecture that eliminates per-resource event variants in favor of generic, horizontally-scalable events.

Overview

The event bus enables reactive UI updates by notifying clients when data changes. The system uses:
  • Generic Resource Events: A single event type (ResourceChanged) handles all database entities
  • Path-Scoped Subscriptions: Subscribe to events affecting specific directories or files
  • Infrastructure Events: Specialized events for jobs, sync, and system lifecycle
  • Automatic Emission: Events are emitted automatically by the TransactionManager - no manual calls needed

Event Types

Resource Events

Generic events that work for ALL resources (files, tags, albums, locations, etc.):
Event::ResourceChanged {
    resource_type: String,     // e.g., "file", "tag", "album", "location"
    resource: serde_json::Value, // Full resource data as JSON
    metadata: Option<ResourceMetadata>, // Cache hints and path scopes
}

Event::ResourceChangedBatch {
    resource_type: String,
    resources: serde_json::Value,  // Array of resources
    metadata: Option<ResourceMetadata>,
}

Event::ResourceDeleted {
    resource_type: String,
    resource_id: Uuid,
}
Supported Resources:
  • file - Files and directories (Entry entity)
  • tag - User tags
  • collection - File collections
  • location - Indexed locations
  • device - Devices in the network
  • volume - Storage volumes (replaces deprecated volume events)
  • sidecar - Generated thumbnails and metadata
  • user_metadata - User-added metadata (notes, favorites, etc.)
  • content_identity - Deduplicated content records
Volume events (VolumeAdded, VolumeUpdated, etc.) and indexing events (IndexingStarted, IndexingProgress, etc.) are deprecated. Use ResourceChanged for volumes and job events for indexing progress.

Infrastructure Events

Specialized events for system operations: Core Lifecycle:
  • CoreStarted, CoreShutdown - Daemon lifecycle
Library Management:
  • LibraryCreated, LibraryOpened, LibraryClosed, LibraryDeleted
  • Refresh - Invalidate all frontend caches
Jobs:
  • JobQueued, JobStarted, JobProgress, JobCompleted, JobFailed, JobCancelled
Sync:
  • SyncStateChanged - Sync state transitions
  • SyncActivity - Peer sync activity
  • SyncConnectionChanged - Peer connections
  • SyncError - Sync errors
Volumes (deprecated - use ResourceChanged with resource_type: "volume"):
  • VolumeAdded, VolumeRemoved, VolumeUpdated
  • VolumeMountChanged, VolumeSpeedTested
Indexing (deprecated - use job events):
  • IndexingStarted, IndexingProgress, IndexingCompleted, IndexingFailed
Filesystem:
  • FsRawChange - Raw filesystem watcher events (before database resolution)

Event Emission

Events are emitted automatically when using the TransactionManager:
// NO manual event emission needed!
pub async fn create_collection(
    tm: &TransactionManager,
    library: Arc<Library>,
    name: String,
) -> Result<Collection> {
    let model = collection::ActiveModel {
        id: NotSet,
        uuid: Set(Uuid::new_v4()),
        name: Set(name),
        // ...
    };

    // TM handles: DB write + sync log + event emission
    let collection = tm.commit::<collection::Model, Collection>(library, model).await?;

    Ok(collection) // ResourceChanged event already emitted!
}
The TransactionManager emits ResourceChanged after successful commits, ensuring:
  • ✅ Events always match database state
  • ✅ No forgotten emissions
  • ✅ Automatic sync log integration

Manual Emission (Infrastructure Only)

Only use manual emission for infrastructure events:
// Jobs, sync, and system events
event_bus.emit(Event::JobStarted {
    job_id: job.id.to_string(),
    job_type: "IndexLocation".to_string(),
});

Path-Scoped Subscriptions

Subscribe to events affecting specific directories or files:
use sd_core::infra::event::SubscriptionFilter;

// Subscribe to changes in a specific directory
let filter = SubscriptionFilter::PathScoped {
    resource_type: "file".to_string(),
    path_scope: SdPath::physical(device_slug, "/Users/james/Photos"),
};

let mut subscriber = event_bus.subscribe_filtered(vec![filter]);

while let Ok(event) = subscriber.recv().await {
    // Only receives events affecting /Users/james/Photos
    println!("Event: {:?}", event);
}
The ResourceMetadata field includes affected_paths that indicate which directories/files changed:
pub struct ResourceMetadata {
    pub no_merge_fields: Vec<String>,  // Fields to replace, not merge
    pub alternate_ids: Vec<Uuid>,       // Alternate IDs for matching
    pub affected_paths: Vec<SdPath>,    // Paths affected by this event
}
Path matching supports:
  • Physical paths: Match by device slug + path prefix
  • Content IDs: Match by content identifier
  • Cloud paths: Match by service + bucket + path
  • Sidecar paths: Match by content ID

Client Integration

TypeScript (useNormalizedQuery)

The useNormalizedQuery hook automatically subscribes to resource events and updates the cache:
import { useNormalizedQuery } from '@sd/client';

// Automatically subscribes to ResourceChanged events for "tag"
const tags = useNormalizedQuery({
  resource_type: 'tag',
  query: api.tags.list(),
});

// UI automatically updates when tags change!
The normalized cache:
  1. Subscribes to ResourceChanged events matching the resource type
  2. Deserializes the JSON resource using generated TypeScript types
  3. Updates the local cache
  4. Triggers React re-renders

Swift

// Generic event handler works for ALL resources
actor EventCacheUpdater {
    let cache: NormalizedCache

    func handleEvent(_ event: Event) async {
        switch event.kind {
        case .ResourceChanged(let resourceType, let resourceJSON):
            // Generic decode via type registry
            let resource = try ResourceTypeRegistry.decode(
                resourceType: resourceType,
                from: resourceJSON
            )
            await cache.updateEntity(resource)

        case .ResourceDeleted(let resourceType, let resourceId):
            await cache.deleteEntity(resourceType: resourceType, id: resourceId)

        default:
            break
        }
    }
}

CLI Event Monitoring

Monitor events in real-time using the CLI:
# Monitor all events
sd events monitor

# Filter by event type
sd events monitor --event-type JobProgress,JobCompleted

# Filter by library
sd events monitor --library-id <uuid>

# Filter by job
sd events monitor --job-id <id>

# Show timestamps
sd events monitor --timestamps

# Verbose mode (full JSON)
sd events monitor --verbose --pretty
Available Filters:
  • -t, --event-type - Comma-separated event types (e.g., ResourceChanged,JobProgress)
  • -l, --library-id - Filter by library UUID
  • -j, --job-id - Filter by job ID
  • -d, --device-id - Filter by device UUID
  • --timestamps - Show event timestamps
  • -v, --verbose - Show full event JSON
  • -p, --pretty - Pretty-print JSON output
Example Output:
Monitoring events - Press Ctrl+C to exit
═══════════════════════════════════════════════════════
Connected to event stream

JobStarted: Job started: IndexLocation (a1b2c3d4)
JobProgress: Job progress: IndexLocation (a1b2c3d4) - 45.2% - Scanning directory
ResourceChangedBatch: Resources changed: file (127 items)
JobCompleted: Job completed: IndexLocation (a1b2c3d4)

Implementation Reference

Event enum: core/src/infra/event/mod.rs
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub enum Event {
    // Core lifecycle
    CoreStarted,
    CoreShutdown,

    // Library events
    LibraryOpened { id: Uuid, name: String, path: PathBuf },
    LibraryClosed { id: Uuid, name: String },

    // Generic resource events
    ResourceChanged {
        resource_type: String,
        resource: serde_json::Value,
        metadata: Option<ResourceMetadata>,
    },
    ResourceChangedBatch {
        resource_type: String,
        resources: serde_json::Value,
        metadata: Option<ResourceMetadata>,
    },
    ResourceDeleted {
        resource_type: String,
        resource_id: Uuid,
    },

    // Jobs, sync, volumes, indexing...
    // (See full enum in source)
}
Event bus: core/src/infra/event/mod.rs
pub struct EventBus {
    sender: broadcast::Sender<Event>,
    subscribers: Arc<RwLock<Vec<FilteredSubscriber>>>,
}

impl EventBus {
    // Subscribe to all events
    pub fn subscribe(&self) -> EventSubscriber;

    // Subscribe with path/resource filters
    pub fn subscribe_filtered(&self, filters: Vec<SubscriptionFilter>) -> EventSubscriber;

    // Emit an event
    pub fn emit(&self, event: Event);
}

Benefits

Backend

  • Zero Manual Emission: TransactionManager handles all resource events
  • Type Safety: Events always match actual resources
  • Centralized: Single point of emission prevents drift
  • Scalable: Adding new resources requires no event code

Frontend

  • Zero Boilerplate: One event handler for all resource types
  • Type Registry: Automatic deserialization via generated types
  • Path Scoping: Subscribe only to relevant directory changes
  • Cache Integration: useNormalizedQuery handles subscriptions automatically

Developer Experience

  • No Event Variants: ~40 variants eliminated → 3 generic events
  • No Manual Calls: Never call event_bus.emit() for resources
  • No Client Changes: Adding a 100th resource type = zero event handling updates
  • CLI Debugging: Monitor events in real-time with filtering