Skip to content

Commit 91a2de0

Browse files
committed
refactor(streams): model CDC and NOTIFY payloads as native typed documents
Replace hardcoded JSON strings in the CDC and NOTIFY views with structured MockDoc / ChangeEvent / NotifyMessage types in data/mock.rs. Views serialize to JSON via sonic_rs at render time, mirroring how the real ConnectionService will deliver nodedb_types::Value documents. MockDoc preserves field insertion order for deterministic output. Unit tests verify serialization order.
1 parent 9bf819a commit 91a2de0

3 files changed

Lines changed: 328 additions & 36 deletions

File tree

nodedb-studio/src/data/mock.rs

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
//! reproduced. NodeDB version numbers are undecided (CLAUDE.md §2), so the
88
//! server stat is a neutral "dev" placeholder rather than an invented version.
99
10+
use serde::ser::{Serialize, SerializeMap, Serializer};
11+
1012
use crate::models::collection::{Collection, StorageMode};
1113
use crate::models::notification::{Notification, NotificationTarget, Severity};
1214
use crate::state::connection::{Capabilities, Capability};
@@ -231,3 +233,291 @@ pub fn notifications() -> Vec<Notification> {
231233
},
232234
]
233235
}
236+
237+
// ── Streams payloads ─────────────────────────────────────────────────────────
238+
//
239+
// The CDC and LISTEN/NOTIFY tails show database records. Those records are
240+
// modelled here as native typed documents (`MockDoc`), mirroring how the real
241+
// `ConnectionService` will hand back `nodedb_types::Value` documents from the
242+
// client. The viewers serialize them to JSON via `sonic_rs` purely for display
243+
// — no JSON strings are carried around as data. `MockDoc` preserves field order
244+
// (unlike `Value::Object`'s `HashMap`), so the rendered JSON is deterministic.
245+
246+
use FieldValue::{Float, Int, Nested, Str};
247+
248+
/// A scalar (or nested-document) field value inside a [`MockDoc`].
249+
pub enum FieldValue {
250+
Str(&'static str),
251+
Int(i64),
252+
Float(f64),
253+
Nested(MockDoc),
254+
}
255+
256+
/// An ordered document: `(key, value)` pairs in display order. Stands in for a
257+
/// `nodedb_types::Value::Object` returned by the client.
258+
pub struct MockDoc(pub Vec<(&'static str, FieldValue)>);
259+
260+
impl Serialize for MockDoc {
261+
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
262+
let mut map = serializer.serialize_map(Some(self.0.len()))?;
263+
for (key, value) in &self.0 {
264+
map.serialize_entry(key, value)?;
265+
}
266+
map.end()
267+
}
268+
}
269+
270+
impl Serialize for FieldValue {
271+
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
272+
match self {
273+
FieldValue::Str(s) => serializer.serialize_str(s),
274+
FieldValue::Int(n) => serializer.serialize_i64(*n),
275+
FieldValue::Float(f) => serializer.serialize_f64(*f),
276+
FieldValue::Nested(doc) => doc.serialize(serializer),
277+
}
278+
}
279+
}
280+
281+
fn doc(fields: Vec<(&'static str, FieldValue)>) -> MockDoc {
282+
MockDoc(fields)
283+
}
284+
285+
/// The change operation in a CDC event.
286+
#[derive(Clone, Copy)]
287+
pub enum ChangeOp {
288+
Insert,
289+
Update,
290+
Delete,
291+
}
292+
293+
impl ChangeOp {
294+
/// Uppercase label shown in the op column.
295+
pub fn label(self) -> &'static str {
296+
match self {
297+
ChangeOp::Insert => "INSERT",
298+
ChangeOp::Update => "UPDATE",
299+
ChangeOp::Delete => "DELETE",
300+
}
301+
}
302+
303+
/// CSS modifier class for the op pill.
304+
pub fn css(self) -> &'static str {
305+
match self {
306+
ChangeOp::Insert => "ins",
307+
ChangeOp::Update => "upd",
308+
ChangeOp::Delete => "del",
309+
}
310+
}
311+
}
312+
313+
/// One row in the Streams · CDC live tail.
314+
pub struct ChangeEvent {
315+
pub time: &'static str,
316+
pub op: ChangeOp,
317+
pub collection: &'static str,
318+
pub payload: MockDoc,
319+
/// Optional annotation appended after the document (e.g. `⤳ +1 field`).
320+
pub note: Option<&'static str>,
321+
}
322+
323+
/// The CDC change feed, newest first.
324+
pub fn cdc_events() -> Vec<ChangeEvent> {
325+
vec![
326+
ChangeEvent {
327+
time: "04:23:18.041",
328+
op: ChangeOp::Insert,
329+
collection: "events",
330+
payload: doc(vec![
331+
("_id", Str("evt_01HMNJ…")),
332+
("type", Str("page_view")),
333+
("user_id", Str("u_44182")),
334+
("props", Nested(doc(vec![("path", Str("/dashboard"))]))),
335+
]),
336+
note: None,
337+
},
338+
ChangeEvent {
339+
time: "04:23:18.039",
340+
op: ChangeOp::Update,
341+
collection: "sessions",
342+
payload: doc(vec![
343+
("_id", Str("s_88209")),
344+
("last_seen", Str("2026-06-13T04:23:18Z")),
345+
]),
346+
note: Some("⤳ +1 field"),
347+
},
348+
ChangeEvent {
349+
time: "04:23:18.037",
350+
op: ChangeOp::Insert,
351+
collection: "events",
352+
payload: doc(vec![
353+
("_id", Str("evt_01HMNJ…")),
354+
("type", Str("click")),
355+
("user_id", Str("u_77103")),
356+
("props", Nested(doc(vec![("el", Str("#cta-buy"))]))),
357+
]),
358+
note: None,
359+
},
360+
ChangeEvent {
361+
time: "04:23:18.035",
362+
op: ChangeOp::Insert,
363+
collection: "orders",
364+
payload: doc(vec![
365+
("id", Int(442004)),
366+
("user_id", Str("u_77103")),
367+
("total", Float(89.40)),
368+
("currency", Str("USD")),
369+
]),
370+
note: None,
371+
},
372+
ChangeEvent {
373+
time: "04:23:18.033",
374+
op: ChangeOp::Delete,
375+
collection: "sessions_cache",
376+
payload: doc(vec![("key", Str("session:u_91002"))]),
377+
note: None,
378+
},
379+
ChangeEvent {
380+
time: "04:23:18.031",
381+
op: ChangeOp::Insert,
382+
collection: "events",
383+
payload: doc(vec![
384+
("_id", Str("evt_01HMNJ…")),
385+
("type", Str("scroll")),
386+
("user_id", Str("u_12998")),
387+
]),
388+
note: None,
389+
},
390+
ChangeEvent {
391+
time: "04:23:18.028",
392+
op: ChangeOp::Update,
393+
collection: "users",
394+
payload: doc(vec![
395+
("_id", Str("u_44182")),
396+
("last_login", Str("2026-06-13T04:23:18Z")),
397+
]),
398+
note: None,
399+
},
400+
ChangeEvent {
401+
time: "04:23:18.025",
402+
op: ChangeOp::Insert,
403+
collection: "events",
404+
payload: doc(vec![
405+
("_id", Str("evt_01HMNJ…")),
406+
("type", Str("page_view")),
407+
("user_id", Str("u_31001")),
408+
("props", Nested(doc(vec![("path", Str("/pricing"))]))),
409+
]),
410+
note: None,
411+
},
412+
ChangeEvent {
413+
time: "04:23:18.022",
414+
op: ChangeOp::Insert,
415+
collection: "events",
416+
payload: doc(vec![
417+
("_id", Str("evt_01HMNJ…")),
418+
("type", Str("form_submit")),
419+
("user_id", Str("u_44182")),
420+
("props", Nested(doc(vec![("form", Str("feedback"))]))),
421+
]),
422+
note: None,
423+
},
424+
]
425+
}
426+
427+
/// A LISTEN/NOTIFY channel in the sidebar.
428+
pub struct NotifyChannel {
429+
pub name: &'static str,
430+
pub listeners: &'static str,
431+
pub active: bool,
432+
}
433+
434+
/// One row in the LISTEN/NOTIFY live tail.
435+
pub struct NotifyMessage {
436+
pub time: &'static str,
437+
pub source: &'static str,
438+
pub payload: MockDoc,
439+
}
440+
441+
/// The notify channel list.
442+
pub fn notify_channels() -> Vec<NotifyChannel> {
443+
let ch = |name, listeners, active| NotifyChannel {
444+
name,
445+
listeners,
446+
active,
447+
};
448+
vec![
449+
ch("user_events", "12", true),
450+
ch("deploy_hooks", "3", false),
451+
ch("cache_invalidate", "5", false),
452+
ch("alerts", "8", false),
453+
ch("jobs_done", "14", false),
454+
ch("presence_room_1", "22", false),
455+
]
456+
}
457+
458+
/// The pub/sub message tail for the active channel.
459+
pub fn notify_messages() -> Vec<NotifyMessage> {
460+
vec![
461+
NotifyMessage {
462+
time: "04:23:18.041",
463+
source: "api-server-2",
464+
payload: doc(vec![("event", Str("login")), ("user", Str("u_44182"))]),
465+
},
466+
NotifyMessage {
467+
time: "04:23:17.812",
468+
source: "webhook-relay",
469+
payload: doc(vec![
470+
("event", Str("signup")),
471+
("user", Str("u_99001")),
472+
("plan", Str("pro")),
473+
]),
474+
},
475+
NotifyMessage {
476+
time: "04:23:17.501",
477+
source: "api-server-1",
478+
payload: doc(vec![
479+
("event", Str("profile_update")),
480+
("user", Str("u_77103")),
481+
]),
482+
},
483+
NotifyMessage {
484+
time: "04:23:16.998",
485+
source: "analytics",
486+
payload: doc(vec![
487+
("event", Str("page_view")),
488+
("user", Str("u_44182")),
489+
("path", Str("/pricing")),
490+
]),
491+
},
492+
NotifyMessage {
493+
time: "04:23:16.422",
494+
source: "api-server-2",
495+
payload: doc(vec![("event", Str("logout")), ("user", Str("u_31001"))]),
496+
},
497+
]
498+
}
499+
500+
#[cfg(test)]
501+
mod tests {
502+
use super::*;
503+
504+
#[test]
505+
fn payload_serializes_with_fields_in_declared_order() {
506+
// The orders INSERT row: keys must come out in insertion order, not
507+
// the alphabetical/HashMap order a `Value::Object` would impose.
508+
let json = sonic_rs::to_string(&cdc_events()[3].payload).unwrap();
509+
assert_eq!(
510+
json,
511+
r#"{"id":442004,"user_id":"u_77103","total":89.4,"currency":"USD"}"#
512+
);
513+
}
514+
515+
#[test]
516+
fn nested_document_serializes() {
517+
let json = sonic_rs::to_string(&cdc_events()[0].payload).unwrap();
518+
assert_eq!(
519+
json,
520+
r#"{"_id":"evt_01HMNJ…","type":"page_view","user_id":"u_44182","props":{"path":"/dashboard"}}"#
521+
);
522+
}
523+
}

nodedb-studio/src/views/streams/cdc.rs

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
1-
//! Streams · CDC: a live tail of change events. Payloads are data strings, so
2-
//! their braces need no rsx escaping.
1+
//! Streams · CDC: a live tail of change events. Payloads are native documents
2+
//! (see `data::mock`); each is serialized to JSON here, at the view, for
3+
//! display — the same seam the real Explorer will use on `Value`s from the
4+
//! client.
35
46
use dioxus::prelude::*;
57

8+
use crate::data::mock;
9+
610
#[component]
711
pub fn StreamsCdc() -> Element {
8-
// (time, op, op-css, collection, payload)
9-
let rows = [
10-
("04:23:18.041", "INSERT", "ins", "events", "{ \"_id\": \"evt_01HMNJ…\", \"type\": \"page_view\", \"user_id\": \"u_44182\", \"props\": { \"path\": \"/dashboard\" } }"),
11-
("04:23:18.039", "UPDATE", "upd", "sessions", "{ \"_id\": \"s_88209\", \"last_seen\": \"2026-06-13T04:23:18Z\" } ⤳ +1 field"),
12-
("04:23:18.037", "INSERT", "ins", "events", "{ \"_id\": \"evt_01HMNJ…\", \"type\": \"click\", \"user_id\": \"u_77103\", \"props\": { \"el\": \"#cta-buy\" } }"),
13-
("04:23:18.035", "INSERT", "ins", "orders", "{ \"id\": 442004, \"user_id\": \"u_77103\", \"total\": 89.40, \"currency\": \"USD\" }"),
14-
("04:23:18.033", "DELETE", "del", "sessions_cache", "{ \"key\": \"session:u_91002\" }"),
15-
("04:23:18.031", "INSERT", "ins", "events", "{ \"_id\": \"evt_01HMNJ…\", \"type\": \"scroll\", \"user_id\": \"u_12998\" }"),
16-
("04:23:18.028", "UPDATE", "upd", "users", "{ \"_id\": \"u_44182\", \"last_login\": \"2026-06-13T04:23:18Z\" }"),
17-
("04:23:18.025", "INSERT", "ins", "events", "{ \"_id\": \"evt_01HMNJ…\", \"type\": \"page_view\", \"user_id\": \"u_31001\", \"props\": { \"path\": \"/pricing\" } }"),
18-
("04:23:18.022", "INSERT", "ins", "events", "{ \"_id\": \"evt_01HMNJ…\", \"type\": \"form_submit\", \"user_id\": \"u_44182\", \"props\": { \"form\": \"feedback\" } }"),
19-
];
12+
// (time, op-label, op-css, collection, payload-json)
13+
let rows: Vec<(&str, &str, &str, &str, String)> = mock::cdc_events()
14+
.into_iter()
15+
.map(|ev| {
16+
let mut payload = sonic_rs::to_string(&ev.payload).unwrap_or_default();
17+
if let Some(note) = ev.note {
18+
payload.push(' ');
19+
payload.push_str(note);
20+
}
21+
(ev.time, ev.op.label(), ev.op.css(), ev.collection, payload)
22+
})
23+
.collect();
2024
rsx! {
2125
div { class: "live-tail",
2226
div { class: "tail-toolbar",

nodedb-studio/src/views/streams/notify.rs

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,33 @@
1-
//! Streams · LISTEN/NOTIFY: channel list + a live pub/sub tail.
1+
//! Streams · LISTEN/NOTIFY: channel list + a live pub/sub tail. Payloads are
2+
//! native documents (see `data::mock`), serialized to JSON here for display.
23
34
use dioxus::prelude::*;
45

6+
use crate::data::mock;
7+
58
#[component]
69
pub fn StreamsNotify() -> Element {
7-
// (channel, listeners, active?)
8-
let channels = [
9-
("user_events", "12", true),
10-
("deploy_hooks", "3", false),
11-
("cache_invalidate", "5", false),
12-
("alerts", "8", false),
13-
("jobs_done", "14", false),
14-
("presence_room_1", "22", false),
15-
];
16-
// (time, source, payload)
17-
let rows = [
18-
("04:23:18.041", "api-server-2", "{\"event\":\"login\",\"user\":\"u_44182\"}"),
19-
("04:23:17.812", "webhook-relay", "{\"event\":\"signup\",\"user\":\"u_99001\",\"plan\":\"pro\"}"),
20-
("04:23:17.501", "api-server-1", "{\"event\":\"profile_update\",\"user\":\"u_77103\"}"),
21-
("04:23:16.998", "analytics", "{\"event\":\"page_view\",\"user\":\"u_44182\",\"path\":\"/pricing\"}"),
22-
("04:23:16.422", "api-server-2", "{\"event\":\"logout\",\"user\":\"u_31001\"}"),
23-
];
10+
let channels = mock::notify_channels();
11+
// (time, source, payload-json)
12+
let rows: Vec<(&str, &str, String)> = mock::notify_messages()
13+
.into_iter()
14+
.map(|m| {
15+
(
16+
m.time,
17+
m.source,
18+
sonic_rs::to_string(&m.payload).unwrap_or_default(),
19+
)
20+
})
21+
.collect();
2422
rsx! {
2523
div { style: "display: grid; grid-template-columns: 260px 1fr; overflow: hidden;",
2624
div { style: "background: var(--bg-secondary); border-right: 0.5px solid var(--border-mid); padding: 10px;",
2725
div { class: "eyebrow", style: "padding: 6px 10px;", "Channels (14)" }
28-
for (name, count, active) in channels {
29-
div { class: if active { "collection active" } else { "collection" },
26+
for c in channels {
27+
div { class: if c.active { "collection active" } else { "collection" },
3028
span { class: "ico", "#" }
31-
" {name} "
32-
span { class: "count", "{count}" }
29+
" {c.name} "
30+
span { class: "count", "{c.listeners}" }
3331
}
3432
}
3533
}

0 commit comments

Comments
 (0)