|
7 | 7 | //! reproduced. NodeDB version numbers are undecided (CLAUDE.md §2), so the |
8 | 8 | //! server stat is a neutral "dev" placeholder rather than an invented version. |
9 | 9 |
|
| 10 | +use serde::ser::{Serialize, SerializeMap, Serializer}; |
| 11 | + |
10 | 12 | use crate::models::collection::{Collection, StorageMode}; |
11 | 13 | use crate::models::notification::{Notification, NotificationTarget, Severity}; |
12 | 14 | use crate::state::connection::{Capabilities, Capability}; |
@@ -231,3 +233,291 @@ pub fn notifications() -> Vec<Notification> { |
231 | 233 | }, |
232 | 234 | ] |
233 | 235 | } |
| 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 | +} |
0 commit comments