Skip to main content

nodedb_lite/
event.rs

1//! Local change event broadcast for app-level reactivity.
2//!
3//! Lightweight CDC for NodeDB-Lite: apps can subscribe to local write events
4//! for real-time UI updates, caching invalidation, etc. Events include
5//! both user writes and CRDT sync merges (inbound deltas from Origin).
6//!
7//! **Platform-agnostic:** Works on native (iOS/Android/desktop) and WASM
8//! (browsers). Uses a callback registry (`Fn(&LiteChangeEvent)`) instead
9//! of Tokio channels — no async runtime required.
10
11use std::sync::{Arc, Mutex};
12
13use serde::{Deserialize, Serialize};
14
15/// A local change event emitted after a successful write.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct LiteChangeEvent {
18    /// Collection affected.
19    pub collection: String,
20    /// Document/row ID.
21    pub document_id: String,
22    /// Operation type.
23    pub op: LiteChangeOp,
24    /// Timestamp (epoch milliseconds).
25    pub timestamp_ms: u64,
26    /// Whether this event originated from CRDT sync (inbound delta from Origin).
27    pub from_sync: bool,
28}
29
30/// Operation types for Lite change events.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub enum LiteChangeOp {
33    Insert,
34    Update,
35    Delete,
36}
37
38/// Subscription handle. Automatically unsubscribes on drop.
39pub struct Subscription {
40    id: u64,
41    stream: Arc<LiteChangeStreamInner>,
42}
43
44impl Drop for Subscription {
45    fn drop(&mut self) {
46        let mut cbs = self
47            .stream
48            .callbacks
49            .lock()
50            .unwrap_or_else(|p| p.into_inner());
51        cbs.retain(|(id, _)| *id != self.id);
52    }
53}
54
55/// Type-erased callback for change events.
56/// `Send + Sync` for native (multi-threaded Tokio); trivially satisfied on WASM.
57type ChangeCallback = Box<dyn Fn(&LiteChangeEvent) + Send + Sync>;
58
59struct LiteChangeStreamInner {
60    callbacks: Mutex<Vec<(u64, ChangeCallback)>>,
61    next_id: std::sync::atomic::AtomicU64,
62    events_published: std::sync::atomic::AtomicU64,
63}
64
65/// Local change event bus for NodeDB-Lite.
66///
67/// Subscribers register callbacks that fire synchronously on each write.
68/// No persistence, no durability — purely for app-level reactivity.
69///
70/// Works on all platforms: native (iOS/Android/desktop) and WASM (browser).
71pub struct LiteChangeStream {
72    inner: Arc<LiteChangeStreamInner>,
73}
74
75impl LiteChangeStream {
76    pub fn new() -> Self {
77        Self {
78            inner: Arc::new(LiteChangeStreamInner {
79                callbacks: Mutex::new(Vec::new()),
80                next_id: std::sync::atomic::AtomicU64::new(1),
81                events_published: std::sync::atomic::AtomicU64::new(0),
82            }),
83        }
84    }
85
86    /// Subscribe to local change events. Returns a handle that unsubscribes on drop.
87    ///
88    /// The callback fires synchronously on each `publish()` call — keep it fast.
89    /// For async processing, use the callback to send into your own channel.
90    pub fn subscribe<F>(&self, callback: F) -> Subscription
91    where
92        F: Fn(&LiteChangeEvent) + Send + Sync + 'static,
93    {
94        let id = self
95            .inner
96            .next_id
97            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
98        let mut cbs = self
99            .inner
100            .callbacks
101            .lock()
102            .unwrap_or_else(|p| p.into_inner());
103        cbs.push((id, Box::new(callback)));
104        Subscription {
105            id,
106            stream: Arc::clone(&self.inner),
107        }
108    }
109
110    /// Publish a change event to all subscribers.
111    pub fn publish(&self, event: &LiteChangeEvent) {
112        self.inner
113            .events_published
114            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
115        let cbs = self
116            .inner
117            .callbacks
118            .lock()
119            .unwrap_or_else(|p| p.into_inner());
120        for (_, cb) in cbs.iter() {
121            cb(event);
122        }
123    }
124
125    /// Publish a user-originated write event.
126    pub fn publish_write(&self, collection: &str, document_id: &str, op: LiteChangeOp) {
127        let event = LiteChangeEvent {
128            collection: collection.to_string(),
129            document_id: document_id.to_string(),
130            op,
131            timestamp_ms: crate::runtime::now_millis(),
132            from_sync: false,
133        };
134        self.publish(&event);
135    }
136
137    /// Publish a CRDT sync merge event (inbound delta from Origin).
138    pub fn publish_sync(&self, collection: &str, document_id: &str, op: LiteChangeOp) {
139        let event = LiteChangeEvent {
140            collection: collection.to_string(),
141            document_id: document_id.to_string(),
142            op,
143            timestamp_ms: crate::runtime::now_millis(),
144            from_sync: true,
145        };
146        self.publish(&event);
147    }
148
149    /// Total events published since creation.
150    pub fn events_published(&self) -> u64 {
151        self.inner
152            .events_published
153            .load(std::sync::atomic::Ordering::Relaxed)
154    }
155
156    /// Number of active subscribers.
157    pub fn subscriber_count(&self) -> usize {
158        self.inner
159            .callbacks
160            .lock()
161            .unwrap_or_else(|p| p.into_inner())
162            .len()
163    }
164}
165
166impl Default for LiteChangeStream {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::sync::atomic::{AtomicU64, Ordering};
176
177    #[test]
178    fn publish_and_receive() {
179        let stream = LiteChangeStream::new();
180        let received = Arc::new(AtomicU64::new(0));
181        let received_clone = Arc::clone(&received);
182
183        let _sub = stream.subscribe(move |_event| {
184            received_clone.fetch_add(1, Ordering::Relaxed);
185        });
186
187        stream.publish_write("users", "u1", LiteChangeOp::Insert);
188        assert_eq!(received.load(Ordering::Relaxed), 1);
189    }
190
191    #[test]
192    fn sync_events_flagged() {
193        let stream = LiteChangeStream::new();
194        let was_sync = Arc::new(std::sync::atomic::AtomicBool::new(false));
195        let was_sync_clone = Arc::clone(&was_sync);
196
197        let _sub = stream.subscribe(move |event| {
198            was_sync_clone.store(event.from_sync, std::sync::atomic::Ordering::Relaxed);
199        });
200
201        stream.publish_sync("orders", "o1", LiteChangeOp::Update);
202        assert!(was_sync.load(std::sync::atomic::Ordering::Relaxed));
203    }
204
205    #[test]
206    fn unsubscribe_on_drop() {
207        let stream = LiteChangeStream::new();
208        let count = Arc::new(AtomicU64::new(0));
209        let count_clone = Arc::clone(&count);
210
211        let sub = stream.subscribe(move |_| {
212            count_clone.fetch_add(1, Ordering::Relaxed);
213        });
214        assert_eq!(stream.subscriber_count(), 1);
215
216        stream.publish_write("a", "1", LiteChangeOp::Insert);
217        assert_eq!(count.load(Ordering::Relaxed), 1);
218
219        drop(sub);
220        assert_eq!(stream.subscriber_count(), 0);
221
222        stream.publish_write("a", "2", LiteChangeOp::Insert);
223        assert_eq!(count.load(Ordering::Relaxed), 1); // No increment — unsubscribed.
224    }
225
226    #[test]
227    fn multiple_subscribers() {
228        let stream = LiteChangeStream::new();
229        let count1 = Arc::new(AtomicU64::new(0));
230        let count2 = Arc::new(AtomicU64::new(0));
231        let c1 = Arc::clone(&count1);
232        let c2 = Arc::clone(&count2);
233
234        let _sub1 = stream.subscribe(move |_| {
235            c1.fetch_add(1, Ordering::Relaxed);
236        });
237        let _sub2 = stream.subscribe(move |_| {
238            c2.fetch_add(1, Ordering::Relaxed);
239        });
240
241        stream.publish_write("x", "1", LiteChangeOp::Delete);
242        assert_eq!(count1.load(Ordering::Relaxed), 1);
243        assert_eq!(count2.load(Ordering::Relaxed), 1);
244    }
245
246    #[test]
247    fn no_subscriber_doesnt_panic() {
248        let stream = LiteChangeStream::new();
249        stream.publish_write("test", "t1", LiteChangeOp::Delete);
250        assert_eq!(stream.events_published(), 1);
251    }
252}