1use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::fmt;
5use std::sync::{Mutex, MutexGuard};
6
7use crate::{
8 BatchAcknowledgement, BatchEventState, ClientRelayFrame, MAX_RELAY_BATCH_EVENTS, RelayFilter,
9 RelayResponseFrame, SignedEvent, SignedNostrEvent, SoftchatError, parse_relay_response_frame,
10};
11use serde::Serialize;
12
13pub const MAX_OUTBOUND_RELAY_FRAMES: usize = 1_024;
15pub const MAX_RELAY_SUBSCRIPTIONS: usize = 128;
17pub const MAX_INFLIGHT_INGESTION: usize = 1_024;
19pub const MAX_PENDING_PUBLISHES: usize = 1_024;
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
24#[serde(rename_all = "camelCase")]
25#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
26pub enum RelayConnectionState {
27 Disconnected,
29 Connecting,
31 Authenticating,
33 Ready,
35 Backoff,
37 Cancelled,
39}
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
43#[serde(rename_all = "camelCase")]
44#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
45pub enum DeliveryState {
46 Pending,
48 InFlight,
50 Accepted,
52 Rejected,
54 Retryable,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
60#[serde(rename_all = "camelCase")]
61#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
62pub struct DeliveryDecision {
63 pub event_id: String,
65 pub state: DeliveryState,
67 pub category: String,
69 pub relay_message: String,
71}
72
73#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
75#[serde(rename_all = "camelCase")]
76#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
77pub enum RelaySessionActionKind {
78 OpenTransport,
80 SendFrame,
82 Authenticate,
84 PersistEvent,
86 SubscriptionReady,
88 DeliveryChanged,
90 CloseTransport,
92 ScheduleReconnect,
94}
95
96#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
98#[serde(rename_all = "camelCase")]
99#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
100pub struct RelaySessionAction {
101 pub kind: RelaySessionActionKind,
103 pub frame: String,
105 pub value: String,
107 pub event: Option<SignedEvent>,
109 pub delay_ms: u64,
111 pub delivery_state: Option<DeliveryState>,
113}
114
115#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
117#[serde(rename_all = "camelCase")]
118#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
119pub struct RelaySessionSnapshot {
120 pub state: RelayConnectionState,
122 pub subscriptions: Vec<String>,
124 pub ready_subscriptions: Vec<String>,
126 pub delivery_event_ids: Vec<String>,
128 pub delivery_states: Vec<DeliveryState>,
130 pub pending_ingestion_ids: Vec<String>,
132 pub reconnect_attempt: u32,
134}
135
136#[derive(Clone, Debug, Eq, PartialEq)]
138#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
139pub struct IngestionBatch {
140 pub account_id: String,
142 pub events: Vec<SignedEvent>,
144}
145
146#[derive(Clone, Debug, Eq, PartialEq)]
148#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
149pub struct IngestionResult {
150 pub inserted_event_ids: Vec<String>,
152 pub duplicate_event_ids: Vec<String>,
154 pub quarantined_event_ids: Vec<String>,
156}
157
158pub trait PersistenceAdapter {
164 fn ingest(&self, batch: &IngestionBatch) -> Result<IngestionResult, SoftchatError>;
171}
172
173#[derive(Debug)]
175pub struct RelaySession {
176 state: RelayConnectionState,
177 subscriptions: BTreeMap<String, Vec<RelayFilter>>,
178 ready_subscriptions: BTreeSet<String>,
179 delivery: BTreeMap<String, DeliveryState>,
180 pending_events: BTreeMap<String, SignedNostrEvent>,
181 pending_ingestion: BTreeSet<String>,
182 outbound: VecDeque<String>,
183 reconnect_attempt: u32,
184}
185
186#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
188pub struct RelaySessionHandle {
189 session: Mutex<RelaySession>,
190}
191
192impl fmt::Debug for RelaySessionHandle {
193 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
194 formatter
195 .debug_struct("RelaySessionHandle")
196 .field("snapshot", &lock(&self.session).snapshot())
197 .finish()
198 }
199}
200
201impl Default for RelaySessionHandle {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207#[cfg_attr(feature = "native-bindings", uniffi::export)]
208impl RelaySessionHandle {
209 #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
211 #[must_use]
212 pub fn new() -> Self {
213 Self {
214 session: Mutex::new(RelaySession::new()),
215 }
216 }
217
218 pub fn connect(&self) -> Result<RelaySessionAction, SoftchatError> {
224 lock(&self.session).connect()
225 }
226
227 pub fn transport_connected(&self) -> Result<(), SoftchatError> {
233 lock(&self.session).transport_connected()
234 }
235
236 pub fn authenticated(&self) -> Result<(), SoftchatError> {
242 lock(&self.session).authenticated()
243 }
244
245 pub fn subscribe(
251 &self,
252 subscription_id: String,
253 filter_json: Vec<String>,
254 ) -> Result<(), SoftchatError> {
255 let filters = filter_json
256 .iter()
257 .map(|json| RelayFilter::from_json(json))
258 .collect::<Result<Vec<_>, _>>()?;
259 lock(&self.session).subscribe(subscription_id, filters)
260 }
261
262 pub fn close_subscription(&self, subscription_id: String) -> Result<(), SoftchatError> {
268 lock(&self.session).close_subscription(&subscription_id)
269 }
270
271 pub fn publish(&self, events: Vec<SignedEvent>) -> Result<(), SoftchatError> {
277 let events = events
278 .into_iter()
279 .map(SignedNostrEvent::try_from)
280 .collect::<Result<Vec<_>, _>>()?;
281 lock(&self.session).publish(events)
282 }
283
284 pub fn receive(&self, frame_json: String) -> Result<Vec<RelaySessionAction>, SoftchatError> {
290 lock(&self.session).receive(&frame_json)
291 }
292
293 pub fn confirm_ingested(&self, event_id: String) -> Result<(), SoftchatError> {
299 lock(&self.session).confirm_ingested(&event_id)
300 }
301
302 pub fn transport_lost(&self) -> Result<Vec<RelaySessionAction>, SoftchatError> {
308 lock(&self.session).transport_lost()
309 }
310
311 pub fn drain_outbound(&self, limit: u32) -> Result<Vec<String>, SoftchatError> {
317 let limit = usize::try_from(limit).map_err(|_| SoftchatError::InvalidRelaySession)?;
318 lock(&self.session).drain_outbound(limit)
319 }
320
321 #[must_use]
323 pub fn snapshot(&self) -> RelaySessionSnapshot {
324 lock(&self.session).snapshot()
325 }
326
327 #[must_use]
329 pub fn cancel(&self) -> RelaySessionAction {
330 lock(&self.session).cancel()
331 }
332}
333
334impl Default for RelaySession {
335 fn default() -> Self {
336 Self::new()
337 }
338}
339
340impl RelaySession {
341 #[must_use]
343 pub const fn new() -> Self {
344 Self {
345 state: RelayConnectionState::Disconnected,
346 subscriptions: BTreeMap::new(),
347 ready_subscriptions: BTreeSet::new(),
348 delivery: BTreeMap::new(),
349 pending_events: BTreeMap::new(),
350 pending_ingestion: BTreeSet::new(),
351 outbound: VecDeque::new(),
352 reconnect_attempt: 0,
353 }
354 }
355
356 pub fn connect(&mut self) -> Result<RelaySessionAction, SoftchatError> {
363 if !matches!(
364 self.state,
365 RelayConnectionState::Disconnected | RelayConnectionState::Backoff
366 ) {
367 return Err(SoftchatError::InvalidRelaySession);
368 }
369 self.state = RelayConnectionState::Connecting;
370 Ok(action(RelaySessionActionKind::OpenTransport))
371 }
372
373 pub fn transport_connected(&mut self) -> Result<(), SoftchatError> {
382 if self.state != RelayConnectionState::Connecting {
383 return Err(SoftchatError::InvalidRelaySession);
384 }
385 self.state = RelayConnectionState::Authenticating;
386 self.ready_subscriptions.clear();
387 Ok(())
388 }
389
390 pub fn authenticated(&mut self) -> Result<(), SoftchatError> {
396 if self.state != RelayConnectionState::Authenticating {
397 return Err(SoftchatError::InvalidRelaySession);
398 }
399 self.state = RelayConnectionState::Ready;
400 self.reconnect_attempt = 0;
401 let subscriptions = self
402 .subscriptions
403 .iter()
404 .map(|(id, filters)| (id.clone(), filters.clone()))
405 .collect::<Vec<_>>();
406 for (subscription_id, filters) in subscriptions {
407 self.enqueue(
408 ClientRelayFrame::Req {
409 subscription_id,
410 filters,
411 }
412 .to_json()?,
413 )?;
414 }
415 let pending = self
416 .pending_events
417 .iter()
418 .filter(|(event_id, _)| {
419 matches!(
420 self.delivery.get(*event_id),
421 Some(DeliveryState::Pending | DeliveryState::Retryable)
422 )
423 })
424 .map(|(_, event)| event.clone())
425 .collect::<Vec<_>>();
426 for chunk in pending.chunks(MAX_RELAY_BATCH_EVENTS) {
427 let frame = if chunk.len() == 1 {
428 ClientRelayFrame::Event(
429 chunk
430 .first()
431 .cloned()
432 .ok_or(SoftchatError::InvalidRelaySession)?,
433 )
434 } else {
435 ClientRelayFrame::Events(chunk.to_vec())
436 }
437 .to_json()?;
438 self.enqueue(frame)?;
439 for event in chunk {
440 self.delivery
441 .insert(event.id().to_hex(), DeliveryState::InFlight);
442 }
443 }
444 Ok(())
445 }
446
447 pub fn subscribe(
456 &mut self,
457 subscription_id: String,
458 filters: Vec<RelayFilter>,
459 ) -> Result<(), SoftchatError> {
460 if self.state == RelayConnectionState::Cancelled
461 || (!self.subscriptions.contains_key(&subscription_id)
462 && self.subscriptions.len() >= MAX_RELAY_SUBSCRIPTIONS)
463 {
464 return Err(SoftchatError::InvalidRelaySession);
465 }
466 let frame = ClientRelayFrame::Req {
467 subscription_id: subscription_id.clone(),
468 filters: filters.clone(),
469 }
470 .to_json()?;
471 self.subscriptions.insert(subscription_id.clone(), filters);
472 self.ready_subscriptions.remove(&subscription_id);
473 if self.state == RelayConnectionState::Ready {
474 self.enqueue(frame)?;
475 }
476 Ok(())
477 }
478
479 pub fn close_subscription(&mut self, subscription_id: &str) -> Result<(), SoftchatError> {
485 if self.subscriptions.remove(subscription_id).is_none() {
486 return Err(SoftchatError::InvalidRelaySession);
487 }
488 self.ready_subscriptions.remove(subscription_id);
489 if self.state == RelayConnectionState::Ready {
490 self.enqueue(
491 ClientRelayFrame::Close {
492 subscription_id: subscription_id.to_owned(),
493 }
494 .to_json()?,
495 )?;
496 }
497 Ok(())
498 }
499
500 pub fn publish(&mut self, events: Vec<SignedNostrEvent>) -> Result<(), SoftchatError> {
506 if self.state == RelayConnectionState::Cancelled
507 || events.is_empty()
508 || events.len() > MAX_RELAY_BATCH_EVENTS
509 || self.delivery.len().saturating_add(events.len()) > MAX_PENDING_PUBLISHES
510 {
511 return Err(SoftchatError::InvalidRelaySession);
512 }
513 for event in &events {
514 if self.delivery.contains_key(&event.id().to_hex()) {
515 return Err(SoftchatError::InvalidDeliveryState);
516 }
517 }
518 for event in &events {
519 self.delivery
520 .insert(event.id().to_hex(), DeliveryState::Pending);
521 self.pending_events
522 .insert(event.id().to_hex(), event.clone());
523 }
524 if self.state == RelayConnectionState::Ready {
525 let frame = if events.len() == 1 {
526 ClientRelayFrame::Event(
527 events
528 .first()
529 .cloned()
530 .ok_or(SoftchatError::InvalidRelaySession)?,
531 )
532 } else {
533 ClientRelayFrame::Events(events.clone())
534 }
535 .to_json()?;
536 self.enqueue(frame)?;
537 for event in events {
538 self.delivery
539 .insert(event.id().to_hex(), DeliveryState::InFlight);
540 }
541 }
542 Ok(())
543 }
544
545 pub fn receive(&mut self, frame_json: &str) -> Result<Vec<RelaySessionAction>, SoftchatError> {
551 if !matches!(
552 self.state,
553 RelayConnectionState::Authenticating | RelayConnectionState::Ready
554 ) {
555 return Err(SoftchatError::InvalidRelaySession);
556 }
557 let frame = parse_relay_response_frame(frame_json)?;
558 match frame {
559 RelayResponseFrame::Auth(challenge) => {
560 self.state = RelayConnectionState::Authenticating;
561 Ok(vec![RelaySessionAction {
562 kind: RelaySessionActionKind::Authenticate,
563 value: challenge,
564 ..action(RelaySessionActionKind::Authenticate)
565 }])
566 }
567 RelayResponseFrame::Event {
568 subscription_id,
569 event,
570 } => {
571 if !self.subscriptions.contains_key(&subscription_id) {
572 return Err(SoftchatError::InvalidRelaySession);
573 }
574 if self.pending_ingestion.len() >= MAX_INFLIGHT_INGESTION {
575 return Err(SoftchatError::RelaySessionQueueFull);
576 }
577 let event_id = event.id().to_hex();
578 self.pending_ingestion.insert(event_id.clone());
579 Ok(vec![RelaySessionAction {
580 kind: RelaySessionActionKind::PersistEvent,
581 value: subscription_id,
582 event: Some(SignedEvent::from(&event)),
583 ..action(RelaySessionActionKind::PersistEvent)
584 }])
585 }
586 RelayResponseFrame::Eose { subscription_id } => {
587 if !self.subscriptions.contains_key(&subscription_id) {
588 return Err(SoftchatError::InvalidRelaySession);
589 }
590 self.ready_subscriptions.insert(subscription_id.clone());
591 Ok(vec![RelaySessionAction {
592 kind: RelaySessionActionKind::SubscriptionReady,
593 value: subscription_id,
594 ..action(RelaySessionActionKind::SubscriptionReady)
595 }])
596 }
597 RelayResponseFrame::Ok(acknowledgement) => {
598 let decision = classify_delivery_acknowledgement(acknowledgement)?;
599 let current = self
600 .delivery
601 .get(&decision.event_id)
602 .copied()
603 .ok_or(SoftchatError::InvalidDeliveryState)?;
604 if matches!(current, DeliveryState::Accepted | DeliveryState::Rejected)
605 && current != decision.state
606 {
607 return Err(SoftchatError::InvalidDeliveryState);
608 }
609 self.delivery
610 .insert(decision.event_id.clone(), decision.state);
611 if matches!(
612 decision.state,
613 DeliveryState::Accepted | DeliveryState::Rejected
614 ) {
615 self.pending_events.remove(&decision.event_id);
616 }
617 Ok(vec![RelaySessionAction {
618 kind: RelaySessionActionKind::DeliveryChanged,
619 value: decision.event_id,
620 delivery_state: Some(decision.state),
621 ..action(RelaySessionActionKind::DeliveryChanged)
622 }])
623 }
624 RelayResponseFrame::Closed {
625 subscription_id,
626 message,
627 } => {
628 self.ready_subscriptions.remove(&subscription_id);
629 Ok(vec![RelaySessionAction {
630 kind: RelaySessionActionKind::SubscriptionReady,
631 value: format!("{subscription_id}:{message}"),
632 ..action(RelaySessionActionKind::SubscriptionReady)
633 }])
634 }
635 RelayResponseFrame::Notice(_) | RelayResponseFrame::Count { .. } => Ok(Vec::new()),
636 }
637 }
638
639 pub fn confirm_ingested(&mut self, event_id: &str) -> Result<(), SoftchatError> {
645 if self.pending_ingestion.remove(event_id) {
646 Ok(())
647 } else {
648 Err(SoftchatError::InvalidPersistenceResult)
649 }
650 }
651
652 pub fn transport_lost(&mut self) -> Result<Vec<RelaySessionAction>, SoftchatError> {
658 if !matches!(
659 self.state,
660 RelayConnectionState::Connecting
661 | RelayConnectionState::Authenticating
662 | RelayConnectionState::Ready
663 ) {
664 return Err(SoftchatError::InvalidRelaySession);
665 }
666 self.outbound.clear();
667 self.ready_subscriptions.clear();
668 for state in self.delivery.values_mut() {
669 if *state == DeliveryState::InFlight {
670 *state = DeliveryState::Retryable;
671 }
672 }
673 self.reconnect_attempt = self.reconnect_attempt.saturating_add(1);
674 self.state = RelayConnectionState::Backoff;
675 let exponent = self.reconnect_attempt.saturating_sub(1).min(6);
676 let delay_ms = 1_000_u64.saturating_mul(1_u64 << exponent);
677 Ok(vec![RelaySessionAction {
678 kind: RelaySessionActionKind::ScheduleReconnect,
679 delay_ms,
680 ..action(RelaySessionActionKind::ScheduleReconnect)
681 }])
682 }
683
684 #[must_use]
686 pub fn cancel(&mut self) -> RelaySessionAction {
687 self.state = RelayConnectionState::Cancelled;
688 self.outbound.clear();
689 self.pending_events.clear();
690 self.pending_ingestion.clear();
691 action(RelaySessionActionKind::CloseTransport)
692 }
693
694 pub fn drain_outbound(&mut self, limit: usize) -> Result<Vec<String>, SoftchatError> {
700 if limit == 0 {
701 return Err(SoftchatError::InvalidRelaySession);
702 }
703 let count = limit.min(self.outbound.len());
704 Ok(self.outbound.drain(..count).collect())
705 }
706
707 #[must_use]
709 pub fn snapshot(&self) -> RelaySessionSnapshot {
710 RelaySessionSnapshot {
711 state: self.state,
712 subscriptions: self.subscriptions.keys().cloned().collect(),
713 ready_subscriptions: self.ready_subscriptions.iter().cloned().collect(),
714 delivery_event_ids: self.delivery.keys().cloned().collect(),
715 delivery_states: self.delivery.values().copied().collect(),
716 pending_ingestion_ids: self.pending_ingestion.iter().cloned().collect(),
717 reconnect_attempt: self.reconnect_attempt,
718 }
719 }
720
721 fn enqueue(&mut self, frame: String) -> Result<(), SoftchatError> {
722 if self.outbound.len() >= MAX_OUTBOUND_RELAY_FRAMES {
723 return Err(SoftchatError::RelaySessionQueueFull);
724 }
725 self.outbound.push_back(frame);
726 Ok(())
727 }
728}
729
730#[cfg_attr(feature = "native-bindings", uniffi::export)]
736pub fn classify_delivery_acknowledgement(
737 acknowledgement: BatchAcknowledgement,
738) -> Result<DeliveryDecision, SoftchatError> {
739 let parsed = crate::track_batch_acknowledgements(
740 vec![acknowledgement.event_id.clone()],
741 vec![acknowledgement.clone()],
742 )
743 .map_err(|_| SoftchatError::InvalidDeliveryState)?;
744 let state = match parsed.states.first() {
745 Some(BatchEventState::Accepted) => DeliveryState::Accepted,
746 Some(BatchEventState::Rejected) => {
747 if is_retryable_message(&acknowledgement.message) {
748 DeliveryState::Retryable
749 } else {
750 DeliveryState::Rejected
751 }
752 }
753 _ => return Err(SoftchatError::InvalidDeliveryState),
754 };
755 let category = if acknowledgement.accepted {
756 if acknowledgement.message.starts_with("duplicate:") {
757 "duplicate"
758 } else {
759 "accepted"
760 }
761 } else if is_retryable_message(&acknowledgement.message) {
762 "retryable"
763 } else if acknowledgement.message.starts_with("auth-required:") {
764 "authentication"
765 } else if acknowledgement.message.starts_with("blocked:") {
766 "blocked"
767 } else if acknowledgement.message.starts_with("invalid:") {
768 "invalid"
769 } else {
770 "rejected"
771 };
772 Ok(DeliveryDecision {
773 event_id: acknowledgement.event_id,
774 state,
775 category: category.to_owned(),
776 relay_message: acknowledgement.message,
777 })
778}
779
780#[cfg_attr(feature = "native-bindings", uniffi::export)]
787pub fn validate_ingestion_result(
788 batch: &IngestionBatch,
789 result: &IngestionResult,
790) -> Result<(), SoftchatError> {
791 if batch.account_id.is_empty() || batch.events.is_empty() {
792 return Err(SoftchatError::InvalidPersistenceResult);
793 }
794 let requested = batch
795 .events
796 .iter()
797 .map(|event| event.id.clone())
798 .collect::<BTreeSet<_>>();
799 if requested.len() != batch.events.len() {
800 return Err(SoftchatError::InvalidPersistenceResult);
801 }
802 let classified = result
803 .inserted_event_ids
804 .iter()
805 .chain(&result.duplicate_event_ids)
806 .chain(&result.quarantined_event_ids)
807 .cloned()
808 .collect::<Vec<_>>();
809 let unique = classified.iter().cloned().collect::<BTreeSet<_>>();
810 if unique.len() != classified.len() || unique != requested {
811 return Err(SoftchatError::InvalidPersistenceResult);
812 }
813 Ok(())
814}
815
816fn is_retryable_message(message: &str) -> bool {
817 ["rate-limited:", "error:", "unavailable:", "timeout:"]
818 .iter()
819 .any(|prefix| message.starts_with(prefix))
820}
821
822fn action(kind: RelaySessionActionKind) -> RelaySessionAction {
823 RelaySessionAction {
824 kind,
825 frame: String::new(),
826 value: String::new(),
827 event: None,
828 delay_ms: 0,
829 delivery_state: None,
830 }
831}
832
833fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
834 match mutex.lock() {
835 Ok(guard) => guard,
836 Err(poisoned) => poisoned.into_inner(),
837 }
838}
839
840#[cfg(test)]
841mod tests {
842 use crate::{LocalIdentity, NostrEventDraft, NostrEventKind};
843
844 use super::*;
845
846 const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
847
848 fn event(created_at: u64) -> Result<SignedNostrEvent, SoftchatError> {
849 LocalIdentity::from_secret_hex(ALICE_SECRET)?.sign_event(NostrEventDraft::new(
850 created_at,
851 NostrEventKind::SHORT_TEXT_NOTE,
852 Vec::new(),
853 "test",
854 )?)
855 }
856
857 #[test]
858 fn session_replays_subscriptions_and_tracks_eose_ingestion_and_delivery()
859 -> Result<(), SoftchatError> {
860 let mut session = RelaySession::new();
861 session.subscribe("messages".to_owned(), vec![RelayFilter::default()])?;
862 assert_eq!(
863 session.connect()?.kind,
864 RelaySessionActionKind::OpenTransport
865 );
866 session.transport_connected()?;
867 session.authenticated()?;
868 assert_eq!(session.drain_outbound(8)?.len(), 1);
869
870 let authored = event(1)?;
871 session.publish(vec![authored.clone()])?;
872 assert_eq!(session.drain_outbound(8)?.len(), 1);
873 let ok = RelayResponseFrame::Ok(BatchAcknowledgement {
874 event_id: authored.id().to_hex(),
875 accepted: true,
876 message: String::new(),
877 })
878 .to_json()?;
879 assert_eq!(
880 session.receive(&ok)?[0].delivery_state,
881 Some(DeliveryState::Accepted)
882 );
883
884 let received = event(2)?;
885 let frame = RelayResponseFrame::Event {
886 subscription_id: "messages".to_owned(),
887 event: received.clone(),
888 }
889 .to_json()?;
890 let action = session.receive(&frame)?;
891 assert_eq!(action[0].kind, RelaySessionActionKind::PersistEvent);
892 session.confirm_ingested(&received.id().to_hex())?;
893 assert!(session.snapshot().pending_ingestion_ids.is_empty());
894
895 let eose = RelayResponseFrame::Eose {
896 subscription_id: "messages".to_owned(),
897 }
898 .to_json()?;
899 assert_eq!(
900 session.receive(&eose)?[0].kind,
901 RelaySessionActionKind::SubscriptionReady
902 );
903 Ok(())
904 }
905
906 #[test]
907 fn disconnect_preserves_terminal_state_and_retries_only_inflight() -> Result<(), SoftchatError>
908 {
909 let mut session = RelaySession::new();
910 session.connect()?;
911 session.transport_connected()?;
912 session.authenticated()?;
913 let first = event(1)?;
914 let second = event(2)?;
915 session.publish(vec![first.clone(), second.clone()])?;
916 let accepted = RelayResponseFrame::Ok(BatchAcknowledgement {
917 event_id: first.id().to_hex(),
918 accepted: true,
919 message: String::new(),
920 })
921 .to_json()?;
922 session.receive(&accepted)?;
923 let reconnect = session.transport_lost()?;
924 assert_eq!(reconnect[0].kind, RelaySessionActionKind::ScheduleReconnect);
925 let snapshot = session.snapshot();
926 let states = snapshot
927 .delivery_event_ids
928 .iter()
929 .cloned()
930 .zip(snapshot.delivery_states)
931 .collect::<BTreeMap<_, _>>();
932 assert_eq!(
933 states.get(&first.id().to_hex()),
934 Some(&DeliveryState::Accepted)
935 );
936 assert_eq!(
937 states.get(&second.id().to_hex()),
938 Some(&DeliveryState::Retryable)
939 );
940 Ok(())
941 }
942
943 #[test]
944 fn persisted_publish_intent_queues_after_authentication() -> Result<(), SoftchatError> {
945 let mut session = RelaySession::new();
946 let authored = event(1)?;
947 session.publish(vec![authored.clone()])?;
948 assert!(session.drain_outbound(8)?.is_empty());
949 session.connect()?;
950 session.transport_connected()?;
951 session.authenticated()?;
952 let outbound = session.drain_outbound(8)?;
953 assert_eq!(outbound.len(), 1);
954 assert!(outbound[0].starts_with("[\"EVENT\","));
955 let snapshot = session.snapshot();
956 let state = snapshot
957 .delivery_event_ids
958 .iter()
959 .position(|event_id| event_id == &authored.id().to_hex())
960 .and_then(|index| snapshot.delivery_states.get(index).copied());
961 assert_eq!(state, Some(DeliveryState::InFlight));
962 Ok(())
963 }
964
965 #[test]
966 fn persistence_result_is_total_unique_and_account_scoped() -> Result<(), SoftchatError> {
967 let first = SignedEvent::from(&event(1)?);
968 let second = SignedEvent::from(&event(2)?);
969 let batch = IngestionBatch {
970 account_id: "account-1".to_owned(),
971 events: vec![first.clone(), second.clone()],
972 };
973 validate_ingestion_result(
974 &batch,
975 &IngestionResult {
976 inserted_event_ids: vec![first.id],
977 duplicate_event_ids: vec![second.id],
978 quarantined_event_ids: Vec::new(),
979 },
980 )?;
981 assert!(
982 validate_ingestion_result(
983 &batch,
984 &IngestionResult {
985 inserted_event_ids: Vec::new(),
986 duplicate_event_ids: Vec::new(),
987 quarantined_event_ids: Vec::new(),
988 },
989 )
990 .is_err()
991 );
992 Ok(())
993 }
994
995 #[test]
996 fn delivery_classifier_separates_retryable_and_permanent_rejection() -> Result<(), SoftchatError>
997 {
998 let event_id = event(1)?.id().to_hex();
999 assert_eq!(
1000 classify_delivery_acknowledgement(BatchAcknowledgement {
1001 event_id: event_id.clone(),
1002 accepted: false,
1003 message: "rate-limited: slow down".to_owned(),
1004 })?
1005 .state,
1006 DeliveryState::Retryable
1007 );
1008 assert_eq!(
1009 classify_delivery_acknowledgement(BatchAcknowledgement {
1010 event_id,
1011 accepted: false,
1012 message: "blocked: policy".to_owned(),
1013 })?
1014 .state,
1015 DeliveryState::Rejected
1016 );
1017 Ok(())
1018 }
1019}