Skip to main content

softchat/
session.rs

1//! Serialized relay session, delivery decisions, and platform persistence contract.
2
3#![allow(
4    unreachable_pub,
5    reason = "UniFFI handle methods stay inside this private core module"
6)]
7
8use std::collections::{BTreeMap, BTreeSet, VecDeque};
9#[cfg(feature = "native-bindings")]
10use std::fmt;
11#[cfg(feature = "native-bindings")]
12use std::sync::{Mutex, MutexGuard};
13
14use crate::event::SignedEvent;
15use crate::{
16    BatchAcknowledgement, BatchEventState, ClientRelayFrame, MAX_RELAY_BATCH_EVENTS, RelayFilter,
17    RelayResponseFrame, SignedNostrEvent, SoftchatError, parse_relay_response_frame,
18};
19use serde::{Deserialize, Serialize};
20
21/// Maximum queued outbound frames in one session.
22pub const MAX_OUTBOUND_RELAY_FRAMES: usize = 1_024;
23/// Maximum simultaneous subscription identifiers.
24pub const MAX_RELAY_SUBSCRIPTIONS: usize = 128;
25/// Maximum received events awaiting authoritative platform persistence.
26pub const MAX_INFLIGHT_INGESTION: usize = 1_024;
27/// Maximum authored events retained for durable delivery intent.
28pub const MAX_PENDING_PUBLISHES: usize = 1_024;
29const MAX_TERMINAL_DELIVERY_RECORDS: usize = 1_024;
30
31/// Transport-independent relay connection state.
32#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(rename_all = "camelCase")]
34#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
35pub enum RelayConnectionState {
36    /// No transport exists.
37    Disconnected,
38    /// The platform is opening a WebSocket.
39    Connecting,
40    /// A transport exists and authentication is pending.
41    Authenticating,
42    /// The session can send subscriptions and events.
43    Ready,
44    /// The host should delay before another connection attempt.
45    Backoff,
46    /// The caller permanently cancelled this session.
47    Cancelled,
48}
49
50/// Durable delivery state for one authored event.
51#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
52#[serde(rename_all = "camelCase")]
53#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
54pub enum DeliveryState {
55    /// Persisted locally and not currently in flight.
56    Pending,
57    /// Sent on the current connection and awaiting `OK`.
58    InFlight,
59    /// Relay accepted the event, including an idempotent duplicate.
60    Accepted,
61    /// Relay rejected the event permanently.
62    Rejected,
63    /// The event can be retried according to host policy.
64    Retryable,
65}
66
67/// Stable host-facing delivery decision.
68#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
69#[serde(rename_all = "camelCase")]
70#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
71pub struct DeliveryDecision {
72    /// Canonical event ID.
73    pub event_id: String,
74    /// New durable state.
75    pub state: DeliveryState,
76    /// Stable category independent of arbitrary relay prose.
77    pub category: String,
78    /// Bounded original relay message for redacted diagnostics.
79    pub relay_message: String,
80}
81
82/// One ordered text frame removed from the session's bounded outbound queue.
83///
84/// The host sends [`Self::frame`] through its platform WebSocket. Only after
85/// that write completes successfully may it pass [`Self::delivery_event_ids`]
86/// to [`RelaySession::confirm_sent`]. An empty ID list means the frame carries
87/// no authored delivery intent.
88#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
89#[serde(rename_all = "camelCase")]
90#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
91pub struct OutboundRelayFrame {
92    /// Complete text frame for the host-owned WebSocket.
93    pub frame: String,
94    /// Authored event IDs whose socket-write handoff this frame represents.
95    pub delivery_event_ids: Vec<String>,
96}
97
98const EVENT_FRAME_OVERHEAD_BYTES: usize = "[\"EVENT\",]".len();
99pub(crate) const MAX_ACCOUNT_EVENT_JSON_BYTES: usize =
100    crate::MAX_RELAY_FRAME_BYTES - EVENT_FRAME_OVERHEAD_BYTES;
101
102/// Account-profile events must also fit their minimum publish envelope.
103pub(crate) fn validate_event_frame_json(event_json: &str) -> Result<(), SoftchatError> {
104    if event_json.len() > MAX_ACCOUNT_EVENT_JSON_BYTES {
105        Err(SoftchatError::InvalidRelayFrame)
106    } else {
107        Ok(())
108    }
109}
110
111/// Frame already authenticated, canonical event JSON from the typed event API
112/// or the account store. Keep event order and exact JSON while enforcing both
113/// wire limits; framing does not authenticate or publish the events again.
114pub(crate) fn batch_event_frames(
115    events: impl IntoIterator<Item = (String, String)>,
116) -> Result<Vec<OutboundRelayFrame>, SoftchatError> {
117    let mut frames = Vec::new();
118    let mut ids = Vec::new();
119    let mut jsons = Vec::new();
120    let mut event_bytes = 0_usize;
121    for (event_id, event_json) in events {
122        // A single EVENT has ten framing bytes. EVENTS has ten fixed bytes
123        // plus one comma per event, so adding a second event changes overhead.
124        validate_event_frame_json(&event_json)?;
125        let next_bytes = event_bytes
126            .saturating_add(event_json.len())
127            .saturating_add(ids.len())
128            .saturating_add(EVENT_FRAME_OVERHEAD_BYTES + 1);
129        if !ids.is_empty()
130            && (ids.len() == MAX_RELAY_BATCH_EVENTS || next_bytes > crate::MAX_RELAY_FRAME_BYTES)
131        {
132            frames.push(finish_event_frame(
133                std::mem::take(&mut ids),
134                std::mem::take(&mut jsons),
135            ));
136            event_bytes = 0;
137        }
138        event_bytes += event_json.len();
139        ids.push(event_id);
140        jsons.push(event_json);
141    }
142    if !ids.is_empty() {
143        frames.push(finish_event_frame(ids, jsons));
144    }
145    Ok(frames)
146}
147
148fn finish_event_frame(ids: Vec<String>, jsons: Vec<String>) -> OutboundRelayFrame {
149    let prefix = if ids.len() == 1 {
150        "[\"EVENT\","
151    } else {
152        "[\"EVENTS\","
153    };
154    OutboundRelayFrame {
155        frame: format!("{prefix}{}]", jsons.join(",")),
156        delivery_event_ids: ids,
157    }
158}
159
160/// Action kind emitted by the pure session.
161#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
162#[serde(rename_all = "camelCase")]
163#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
164pub enum RelaySessionActionKind {
165    /// Platform should open a WebSocket.
166    OpenTransport,
167    /// Platform should send `frame`.
168    SendFrame,
169    /// Host should build and enqueue a NIP-42 AUTH frame for `value`.
170    Authenticate,
171    /// Platform must transactionally ingest `event`.
172    PersistEvent,
173    /// Subscription reached EOSE.
174    SubscriptionReady,
175    /// Relay ended the subscription named by `value`.
176    SubscriptionClosed,
177    /// One delivery reached a terminal or retry state.
178    DeliveryChanged,
179    /// Platform should close the current transport.
180    CloseTransport,
181    /// Host should schedule another connection attempt after `delay_ms`.
182    ScheduleReconnect,
183}
184
185/// One transport/platform action emitted by [`RelaySession`].
186#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
187#[serde(rename_all = "camelCase")]
188#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
189pub struct RelaySessionAction {
190    /// Action type.
191    pub kind: RelaySessionActionKind,
192    /// Wire frame for `SendFrame`, otherwise empty.
193    pub frame: String,
194    /// Subscription ID, challenge, event ID, or reason depending on `kind`.
195    pub value: String,
196    /// Bounded diagnostic reason for `SubscriptionClosed`, otherwise empty.
197    ///
198    /// This relay-provided text must never be parsed for control flow.
199    pub reason: String,
200    /// Signed event for `PersistEvent`, otherwise absent.
201    pub event: Option<SignedEvent>,
202    /// Delay for `ScheduleReconnect`, otherwise zero.
203    pub delay_ms: u64,
204    /// Complete delivery decision for `DeliveryChanged`, otherwise absent.
205    pub delivery_decision: Option<DeliveryDecision>,
206}
207
208/// Serializable non-secret session state for diagnostics only.
209#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
210#[serde(rename_all = "camelCase")]
211#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
212pub struct RelaySessionSnapshot {
213    /// Current connection state.
214    pub state: RelayConnectionState,
215    /// Active subscription IDs.
216    pub subscriptions: Vec<String>,
217    /// Subscriptions that observed EOSE on this connection.
218    pub ready_subscriptions: Vec<String>,
219    /// Event IDs and delivery states at matching indices.
220    pub delivery_event_ids: Vec<String>,
221    /// Event states at matching indices.
222    pub delivery_states: Vec<DeliveryState>,
223    /// Verified inbound IDs waiting for a platform transaction.
224    pub pending_ingestion_ids: Vec<String>,
225    /// Current reconnect attempt.
226    pub reconnect_attempt: u32,
227}
228
229/// One bounded atomic ingestion request to a platform-owned store.
230#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
231#[serde(rename_all = "camelCase")]
232#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
233pub struct IngestionBatch {
234    /// Stable account partition identifier; never a secret key.
235    pub account_id: String,
236    /// Verified events to insert idempotently in one transaction.
237    pub events: Vec<SignedEvent>,
238}
239
240/// Result returned by an authoritative Room, GRDB, IndexedDB, or host adapter.
241#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
242#[serde(rename_all = "camelCase")]
243#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
244pub struct IngestionResult {
245    /// IDs inserted for the first time.
246    pub inserted_event_ids: Vec<String>,
247    /// IDs already present with identical canonical data.
248    pub duplicate_event_ids: Vec<String>,
249    /// IDs quarantined instead of projected.
250    pub quarantined_event_ids: Vec<String>,
251}
252
253/// One deterministic transport-independent relay session.
254#[derive(Debug)]
255pub struct RelaySession {
256    state: RelayConnectionState,
257    subscriptions: BTreeMap<String, Vec<RelayFilter>>,
258    ready_subscriptions: BTreeSet<String>,
259    delivery: BTreeMap<String, DeliveryState>,
260    terminal_delivery_order: VecDeque<String>,
261    pending_events: BTreeMap<String, SignedNostrEvent>,
262    pending_authentication_event_id: Option<String>,
263    pending_ingestion: BTreeSet<String>,
264    outbound: VecDeque<OutboundRelayFrame>,
265    reconnect_attempt: u32,
266}
267
268/// Thread-safe generated-binding owner for one serialized relay session.
269#[cfg(feature = "native-bindings")]
270#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
271pub struct RelaySessionHandle {
272    session: Mutex<RelaySession>,
273}
274
275#[cfg(feature = "native-bindings")]
276impl fmt::Debug for RelaySessionHandle {
277    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
278        formatter
279            .debug_struct("RelaySessionHandle")
280            .field("snapshot", &lock(&self.session).snapshot())
281            .finish()
282    }
283}
284
285#[cfg(feature = "native-bindings")]
286impl Default for RelaySessionHandle {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292#[cfg(feature = "native-bindings")]
293#[cfg_attr(feature = "native-bindings", uniffi::export)]
294impl RelaySessionHandle {
295    /// Construct an empty disconnected session.
296    #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
297    #[must_use]
298    pub fn new() -> Self {
299        Self {
300            session: Mutex::new(RelaySession::new()),
301        }
302    }
303
304    /// Begin a platform-owned WebSocket connection attempt.
305    ///
306    /// # Errors
307    ///
308    /// Returns a stable invalid-transition error.
309    pub fn connect(&self) -> Result<RelaySessionAction, SoftchatError> {
310        lock(&self.session).connect()
311    }
312
313    /// Notify the session that the native transport is connected.
314    ///
315    /// # Errors
316    ///
317    /// Returns a stable invalid-transition error.
318    pub fn transport_connected(&self) -> Result<(), SoftchatError> {
319        lock(&self.session).transport_connected()
320    }
321
322    /// Mark relay authentication complete and replay retained intent.
323    ///
324    /// # Errors
325    ///
326    /// Returns a stable transition or bounded-queue error.
327    pub fn authenticated(&self) -> Result<(), SoftchatError> {
328        lock(&self.session).authenticated()
329    }
330
331    /// Queue one validated NIP-42 response while authentication is pending.
332    ///
333    /// # Errors
334    ///
335    /// Returns a stable authentication, state, or queue error.
336    pub fn authenticate(&self, event: SignedEvent) -> Result<(), SoftchatError> {
337        lock(&self.session).authenticate(SignedNostrEvent::try_from(event)?)
338    }
339
340    /// Add or replace a subscription using strict filter JSON objects.
341    ///
342    /// # Errors
343    ///
344    /// Returns a stable filter, transition, or bounded-queue error.
345    pub fn subscribe(
346        &self,
347        subscription_id: String,
348        filter_json: Vec<String>,
349    ) -> Result<(), SoftchatError> {
350        let filters = filter_json
351            .iter()
352            .map(|json| RelayFilter::from_json(json))
353            .collect::<Result<Vec<_>, _>>()?;
354        lock(&self.session).subscribe(subscription_id, filters)
355    }
356
357    /// Close one retained subscription.
358    ///
359    /// # Errors
360    ///
361    /// Returns a stable state or bounded-queue error.
362    pub fn close_subscription(&self, subscription_id: String) -> Result<(), SoftchatError> {
363        lock(&self.session).close_subscription(&subscription_id)
364    }
365
366    /// Queue already-persisted authored events for delivery.
367    ///
368    /// # Errors
369    ///
370    /// Returns a stable validation, state, or queue error.
371    pub fn publish(&self, events: Vec<SignedEvent>) -> Result<(), SoftchatError> {
372        let events = events
373            .into_iter()
374            .map(SignedNostrEvent::try_from)
375            .collect::<Result<Vec<_>, _>>()?;
376        lock(&self.session).publish(events)
377    }
378
379    /// Confirm that one drained authored-event frame was written to the socket.
380    ///
381    /// An empty list is accepted so a host may call this uniformly for every
382    /// [`OutboundRelayFrame`].
383    ///
384    /// # Errors
385    ///
386    /// Returns a stable delivery-state error for duplicate, unknown, or
387    /// already in-flight IDs. A terminal ID is an idempotent no-op because a
388    /// relay acknowledgement may race the local socket-write completion.
389    pub fn confirm_sent(&self, event_ids: Vec<String>) -> Result<(), SoftchatError> {
390        lock(&self.session).confirm_sent(&event_ids)
391    }
392
393    /// Consume one complete native WebSocket text frame.
394    ///
395    /// # Errors
396    ///
397    /// Returns a stable frame, correlation, state, or backpressure error.
398    pub fn receive(&self, frame_json: String) -> Result<Vec<RelaySessionAction>, SoftchatError> {
399        lock(&self.session).receive(&frame_json)
400    }
401
402    /// Confirm an authoritative platform transaction handled an event.
403    ///
404    /// # Errors
405    ///
406    /// Returns a stable persistence-result error for unknown IDs.
407    pub fn confirm_ingested(&self, event_id: String) -> Result<(), SoftchatError> {
408        lock(&self.session).confirm_ingested(&event_id)
409    }
410
411    /// Handle transport loss and return the reconnect plan.
412    ///
413    /// # Errors
414    ///
415    /// Returns a stable invalid-transition error.
416    pub fn transport_lost(&self) -> Result<Vec<RelaySessionAction>, SoftchatError> {
417        lock(&self.session).transport_lost()
418    }
419
420    /// Drain at most `limit` ordered outbound text frames.
421    ///
422    /// # Errors
423    ///
424    /// Returns a stable error for a zero limit.
425    pub fn drain_outbound(&self, limit: u32) -> Result<Vec<OutboundRelayFrame>, SoftchatError> {
426        let limit = usize::try_from(limit).map_err(|_| SoftchatError::InvalidRelaySession)?;
427        lock(&self.session).drain_outbound(limit)
428    }
429
430    /// Return one redacted serializable snapshot.
431    #[must_use]
432    pub fn snapshot(&self) -> RelaySessionSnapshot {
433        lock(&self.session).snapshot()
434    }
435
436    /// Permanently cancel the session and clear process-local queues.
437    #[must_use]
438    pub fn cancel(&self) -> RelaySessionAction {
439        lock(&self.session).cancel()
440    }
441}
442
443impl Default for RelaySession {
444    fn default() -> Self {
445        Self::new()
446    }
447}
448
449impl RelaySession {
450    /// Create one disconnected session with empty bounded queues.
451    #[must_use]
452    pub const fn new() -> Self {
453        Self {
454            state: RelayConnectionState::Disconnected,
455            subscriptions: BTreeMap::new(),
456            ready_subscriptions: BTreeSet::new(),
457            delivery: BTreeMap::new(),
458            terminal_delivery_order: VecDeque::new(),
459            pending_events: BTreeMap::new(),
460            pending_authentication_event_id: None,
461            pending_ingestion: BTreeSet::new(),
462            outbound: VecDeque::new(),
463            reconnect_attempt: 0,
464        }
465    }
466
467    /// Begin a platform-owned connection attempt.
468    ///
469    /// # Errors
470    ///
471    /// Returns [`SoftchatError::InvalidRelaySession`] unless disconnected or
472    /// waiting in backoff.
473    pub fn connect(&mut self) -> Result<RelaySessionAction, SoftchatError> {
474        if !matches!(
475            self.state,
476            RelayConnectionState::Disconnected | RelayConnectionState::Backoff
477        ) {
478            return Err(SoftchatError::InvalidRelaySession);
479        }
480        self.state = RelayConnectionState::Connecting;
481        Ok(action(RelaySessionActionKind::OpenTransport))
482    }
483
484    /// Notify the session that the native WebSocket is open.
485    ///
486    /// Existing subscriptions are replayed only after the host indicates
487    /// authentication is complete.
488    ///
489    /// # Errors
490    ///
491    /// Returns a stable transition error from any state except `Connecting`.
492    pub fn transport_connected(&mut self) -> Result<(), SoftchatError> {
493        if self.state != RelayConnectionState::Connecting {
494            return Err(SoftchatError::InvalidRelaySession);
495        }
496        self.state = RelayConnectionState::Authenticating;
497        self.ready_subscriptions.clear();
498        Ok(())
499    }
500
501    /// Mark NIP-42 authentication complete and replay session intent.
502    ///
503    /// # Errors
504    ///
505    /// Returns a transition or bounded-queue failure.
506    pub fn authenticated(&mut self) -> Result<(), SoftchatError> {
507        if self.state != RelayConnectionState::Authenticating {
508            return Err(SoftchatError::InvalidRelaySession);
509        }
510        self.pending_authentication_event_id = None;
511        let mut replay = self
512            .subscriptions
513            .iter()
514            .map(|(subscription_id, filters)| {
515                Ok(OutboundRelayFrame {
516                    frame: ClientRelayFrame::Req {
517                        subscription_id: subscription_id.clone(),
518                        filters: filters.clone(),
519                    }
520                    .to_json()?,
521                    delivery_event_ids: Vec::new(),
522                })
523            })
524            .collect::<Result<Vec<_>, SoftchatError>>()?;
525        let pending = self
526            .pending_events
527            .iter()
528            .filter(|(event_id, _)| {
529                matches!(
530                    self.delivery.get(*event_id),
531                    Some(DeliveryState::Pending | DeliveryState::Retryable)
532                )
533            })
534            .map(|(_, event)| event.clone())
535            .collect::<Vec<_>>();
536        replay.extend(batch_event_frames(
537            pending
538                .iter()
539                .map(|event| Ok((event.id().to_hex(), event.to_json()?)))
540                .collect::<Result<Vec<_>, SoftchatError>>()?,
541        )?);
542        self.ensure_outbound_capacity(replay.len())?;
543        self.state = RelayConnectionState::Ready;
544        self.reconnect_attempt = 0;
545        self.ready_subscriptions.clear();
546        self.outbound.extend(replay);
547        Ok(())
548    }
549
550    /// Queue one NIP-42 `AUTH` response for the platform WebSocket.
551    ///
552    /// The event must already be verified, kind 22242, and bound to the
553    /// challenge/relay by the identity writer. Authentication becomes ready
554    /// only after the host writes this frame and calls [`Self::authenticated`].
555    ///
556    /// # Errors
557    ///
558    /// Returns a stable authentication, state, or bounded-queue error.
559    pub fn authenticate(&mut self, event: SignedNostrEvent) -> Result<(), SoftchatError> {
560        if self.state != RelayConnectionState::Authenticating {
561            return Err(SoftchatError::InvalidRelaySession);
562        }
563        let event_id = event.id().to_hex();
564        let frame = ClientRelayFrame::Auth(event).to_json()?;
565        self.ensure_outbound_capacity(1)?;
566        self.enqueue(frame, Vec::new())?;
567        self.pending_authentication_event_id = Some(event_id);
568        Ok(())
569    }
570
571    /// Add or replace one subscription intent.
572    ///
573    /// The request is queued immediately when ready and retained across
574    /// reconnects.
575    ///
576    /// # Errors
577    ///
578    /// Returns a validation, state, or bounded-queue failure.
579    pub fn subscribe(
580        &mut self,
581        subscription_id: String,
582        filters: Vec<RelayFilter>,
583    ) -> Result<(), SoftchatError> {
584        self.subscribe_many(vec![(subscription_id, filters)])
585    }
586
587    /// Atomically add or replace multiple subscription intents.
588    ///
589    /// This internal operation validates every identifier, filter, encoded
590    /// frame, subscription slot, and outbound queue slot before changing the
591    /// session. It lets the account synchronizer shard a large exact-ID fetch
592    /// without leaving a partially installed request set.
593    pub(crate) fn subscribe_many(
594        &mut self,
595        subscriptions: Vec<(String, Vec<RelayFilter>)>,
596    ) -> Result<(), SoftchatError> {
597        if self.state == RelayConnectionState::Cancelled || subscriptions.is_empty() {
598            return Err(SoftchatError::InvalidRelaySession);
599        }
600        let unique_ids = subscriptions
601            .iter()
602            .map(|(subscription_id, _)| subscription_id)
603            .collect::<BTreeSet<_>>();
604        if unique_ids.len() != subscriptions.len() {
605            return Err(SoftchatError::InvalidRelaySession);
606        }
607        let additional_subscriptions = unique_ids
608            .iter()
609            .filter(|subscription_id| !self.subscriptions.contains_key(subscription_id.as_str()))
610            .count();
611        if self
612            .subscriptions
613            .len()
614            .saturating_add(additional_subscriptions)
615            > MAX_RELAY_SUBSCRIPTIONS
616        {
617            return Err(SoftchatError::InvalidRelaySession);
618        }
619        let frames = subscriptions
620            .iter()
621            .map(|(subscription_id, filters)| {
622                ClientRelayFrame::Req {
623                    subscription_id: subscription_id.clone(),
624                    filters: filters.clone(),
625                }
626                .to_json()
627            })
628            .collect::<Result<Vec<_>, _>>()?;
629        if self.state == RelayConnectionState::Ready {
630            self.ensure_outbound_capacity(frames.len())?;
631        }
632        for (subscription_id, filters) in subscriptions {
633            self.subscriptions.insert(subscription_id.clone(), filters);
634            self.ready_subscriptions.remove(&subscription_id);
635        }
636        if self.state == RelayConnectionState::Ready {
637            self.outbound
638                .extend(frames.into_iter().map(|frame| OutboundRelayFrame {
639                    frame,
640                    delivery_event_ids: Vec::new(),
641                }));
642        }
643        Ok(())
644    }
645
646    /// Close one retained subscription.
647    ///
648    /// # Errors
649    ///
650    /// Returns a state or bounded-queue failure.
651    pub fn close_subscription(&mut self, subscription_id: &str) -> Result<(), SoftchatError> {
652        if !self.subscriptions.contains_key(subscription_id) {
653            return Err(SoftchatError::InvalidRelaySession);
654        }
655        let frame = if self.state == RelayConnectionState::Ready {
656            self.ensure_outbound_capacity(1)?;
657            Some(
658                ClientRelayFrame::Close {
659                    subscription_id: subscription_id.to_owned(),
660                }
661                .to_json()?,
662            )
663        } else {
664            None
665        };
666        self.subscriptions.remove(subscription_id);
667        self.ready_subscriptions.remove(subscription_id);
668        if let Some(frame) = frame {
669            self.enqueue(frame, Vec::new())?;
670        }
671        Ok(())
672    }
673
674    /// Queue one or more already persisted authored events for publication.
675    ///
676    /// # Errors
677    ///
678    /// Returns a state, duplicate-ID, event-validation, or queue failure.
679    pub fn publish(&mut self, events: Vec<SignedNostrEvent>) -> Result<(), SoftchatError> {
680        if self.state == RelayConnectionState::Cancelled
681            || events.is_empty()
682            || events.len() > MAX_RELAY_BATCH_EVENTS
683            || self.pending_events.len().saturating_add(events.len()) > MAX_PENDING_PUBLISHES
684        {
685            return Err(SoftchatError::InvalidRelaySession);
686        }
687        let event_ids = events
688            .iter()
689            .map(|event| event.id().to_hex())
690            .collect::<Vec<_>>();
691        if event_ids.iter().collect::<BTreeSet<_>>().len() != event_ids.len() {
692            return Err(SoftchatError::InvalidDeliveryState);
693        }
694        for event_id in &event_ids {
695            if self.delivery.contains_key(event_id) {
696                return Err(SoftchatError::InvalidDeliveryState);
697            }
698        }
699        let frames = batch_event_frames(
700            events
701                .iter()
702                .map(|event| Ok((event.id().to_hex(), event.to_json()?)))
703                .collect::<Result<Vec<_>, SoftchatError>>()?,
704        )?;
705        if self.state == RelayConnectionState::Ready {
706            self.ensure_outbound_capacity(frames.len())?;
707        }
708        for (event, event_id) in events.iter().zip(&event_ids) {
709            self.delivery
710                .insert(event_id.clone(), DeliveryState::Pending);
711            self.pending_events.insert(event_id.clone(), event.clone());
712        }
713        if self.state == RelayConnectionState::Ready {
714            self.outbound.extend(frames);
715        }
716        Ok(())
717    }
718
719    /// Confirm that the host successfully wrote one authored-event frame.
720    ///
721    /// This is deliberately separate from [`Self::drain_outbound`]: removal
722    /// from a process-local queue is not evidence that a platform socket
723    /// accepted the frame. An empty list is a no-op for subscription and
724    /// control frames.
725    ///
726    /// # Errors
727    ///
728    /// Returns a stable delivery-state error for duplicate, unknown, or
729    /// already in-flight IDs. A terminal ID is an idempotent no-op because a
730    /// relay acknowledgement may race the local socket-write completion.
731    pub fn confirm_sent(&mut self, event_ids: &[String]) -> Result<(), SoftchatError> {
732        if event_ids.is_empty() {
733            return Ok(());
734        }
735        if self.state != RelayConnectionState::Ready || event_ids.len() > MAX_RELAY_BATCH_EVENTS {
736            return Err(SoftchatError::InvalidDeliveryState);
737        }
738        let unique = event_ids.iter().collect::<BTreeSet<_>>();
739        if unique.len() != event_ids.len()
740            || event_ids.iter().any(|event_id| {
741                !matches!(
742                    self.delivery.get(event_id),
743                    Some(
744                        DeliveryState::Pending
745                            | DeliveryState::Retryable
746                            | DeliveryState::Accepted
747                            | DeliveryState::Rejected
748                    )
749                )
750            })
751        {
752            return Err(SoftchatError::InvalidDeliveryState);
753        }
754        for event_id in event_ids {
755            if matches!(
756                self.delivery.get(event_id),
757                Some(DeliveryState::Pending | DeliveryState::Retryable)
758            ) {
759                self.delivery
760                    .insert(event_id.clone(), DeliveryState::InFlight);
761            }
762        }
763        Ok(())
764    }
765
766    /// Consume one complete relay WebSocket text frame.
767    ///
768    /// # Errors
769    ///
770    /// Returns a frame, state, correlation, or backpressure failure.
771    pub fn receive(&mut self, frame_json: &str) -> Result<Vec<RelaySessionAction>, SoftchatError> {
772        if !matches!(
773            self.state,
774            RelayConnectionState::Authenticating | RelayConnectionState::Ready
775        ) {
776            return Err(SoftchatError::InvalidRelaySession);
777        }
778        let frame = parse_relay_response_frame(frame_json)?;
779        if self.state == RelayConnectionState::Authenticating
780            && matches!(
781                &frame,
782                RelayResponseFrame::Event { .. }
783                    | RelayResponseFrame::Eose { .. }
784                    | RelayResponseFrame::Closed { .. }
785            )
786        {
787            return Ok(Vec::new());
788        }
789        match frame {
790            RelayResponseFrame::Auth(challenge) => {
791                self.state = RelayConnectionState::Authenticating;
792                self.outbound.clear();
793                self.pending_authentication_event_id = None;
794                self.ready_subscriptions.clear();
795                for state in self.delivery.values_mut() {
796                    if *state == DeliveryState::InFlight {
797                        *state = DeliveryState::Retryable;
798                    }
799                }
800                Ok(vec![RelaySessionAction {
801                    kind: RelaySessionActionKind::Authenticate,
802                    value: challenge,
803                    ..action(RelaySessionActionKind::Authenticate)
804                }])
805            }
806            RelayResponseFrame::Event {
807                subscription_id,
808                event,
809            } => {
810                if !self.subscriptions.contains_key(&subscription_id) {
811                    return Err(SoftchatError::InvalidRelaySession);
812                }
813                let event_id = event.id().to_hex();
814                if self.pending_ingestion.contains(&event_id) {
815                    return Ok(Vec::new());
816                }
817                if self.pending_ingestion.len() >= MAX_INFLIGHT_INGESTION {
818                    return Err(SoftchatError::RelaySessionQueueFull);
819                }
820                self.pending_ingestion.insert(event_id.clone());
821                Ok(vec![RelaySessionAction {
822                    kind: RelaySessionActionKind::PersistEvent,
823                    value: subscription_id,
824                    event: Some(SignedEvent::from(&event)),
825                    ..action(RelaySessionActionKind::PersistEvent)
826                }])
827            }
828            RelayResponseFrame::Eose { subscription_id } => {
829                if !self.subscriptions.contains_key(&subscription_id) {
830                    return Err(SoftchatError::InvalidRelaySession);
831                }
832                self.ready_subscriptions.insert(subscription_id.clone());
833                Ok(vec![RelaySessionAction {
834                    kind: RelaySessionActionKind::SubscriptionReady,
835                    value: subscription_id,
836                    ..action(RelaySessionActionKind::SubscriptionReady)
837                }])
838            }
839            RelayResponseFrame::Ok(acknowledgement) => {
840                if self.pending_authentication_event_id.as_deref()
841                    == Some(acknowledgement.event_id.as_str())
842                {
843                    if !acknowledgement.accepted {
844                        self.pending_authentication_event_id = None;
845                        return Err(SoftchatError::InvalidRelayAuthentication);
846                    }
847                    self.pending_authentication_event_id = None;
848                    self.authenticated()?;
849                    return Ok(Vec::new());
850                }
851                let decision = classify_delivery_acknowledgement(acknowledgement)?;
852                let current = self
853                    .delivery
854                    .get(&decision.event_id)
855                    .copied()
856                    .ok_or(SoftchatError::InvalidDeliveryState)?;
857                let is_idempotent_terminal =
858                    matches!(current, DeliveryState::Accepted | DeliveryState::Rejected)
859                        && current == decision.state;
860                let is_ack_before_write =
861                    matches!(current, DeliveryState::Pending | DeliveryState::Retryable);
862                if current != DeliveryState::InFlight
863                    && !is_ack_before_write
864                    && !is_idempotent_terminal
865                {
866                    return Err(SoftchatError::InvalidDeliveryState);
867                }
868                self.delivery
869                    .insert(decision.event_id.clone(), decision.state);
870                if matches!(
871                    decision.state,
872                    DeliveryState::Accepted | DeliveryState::Rejected
873                ) {
874                    self.pending_events.remove(&decision.event_id);
875                    if !is_idempotent_terminal {
876                        self.remember_terminal_delivery(decision.event_id.clone());
877                    }
878                }
879                Ok(vec![RelaySessionAction {
880                    kind: RelaySessionActionKind::DeliveryChanged,
881                    value: decision.event_id.clone(),
882                    delivery_decision: Some(decision),
883                    ..action(RelaySessionActionKind::DeliveryChanged)
884                }])
885            }
886            RelayResponseFrame::Closed {
887                subscription_id,
888                message,
889            } => {
890                if !self.subscriptions.contains_key(&subscription_id) {
891                    return Ok(Vec::new());
892                }
893                self.subscriptions.remove(&subscription_id);
894                self.ready_subscriptions.remove(&subscription_id);
895                Ok(vec![RelaySessionAction {
896                    kind: RelaySessionActionKind::SubscriptionClosed,
897                    value: subscription_id,
898                    reason: message,
899                    ..action(RelaySessionActionKind::SubscriptionClosed)
900                }])
901            }
902            RelayResponseFrame::Notice(_) | RelayResponseFrame::Count { .. } => Ok(Vec::new()),
903        }
904    }
905
906    /// Confirm that an authoritative platform transaction handled an event.
907    ///
908    /// # Errors
909    ///
910    /// Returns a stable state error for an unknown or repeated confirmation.
911    pub fn confirm_ingested(&mut self, event_id: &str) -> Result<(), SoftchatError> {
912        if self.pending_ingestion.remove(event_id) {
913            Ok(())
914        } else {
915            Err(SoftchatError::InvalidPersistenceResult)
916        }
917    }
918
919    /// Retire a rejected input without claiming that it was durably ingested.
920    #[cfg(feature = "sqlite-storage")]
921    pub(crate) fn discard_ingestion(&mut self, event_id: &str) -> Result<(), SoftchatError> {
922        if self.pending_ingestion.remove(event_id) {
923            Ok(())
924        } else {
925            Err(SoftchatError::InvalidPersistenceResult)
926        }
927    }
928
929    /// Handle transport loss and classify only nonterminal in-flight events for retry.
930    ///
931    /// # Errors
932    ///
933    /// Returns a transition error when no live transport existed.
934    pub fn transport_lost(&mut self) -> Result<Vec<RelaySessionAction>, SoftchatError> {
935        if !matches!(
936            self.state,
937            RelayConnectionState::Connecting
938                | RelayConnectionState::Authenticating
939                | RelayConnectionState::Ready
940        ) {
941            return Err(SoftchatError::InvalidRelaySession);
942        }
943        self.outbound.clear();
944        self.pending_authentication_event_id = None;
945        self.ready_subscriptions.clear();
946        for state in self.delivery.values_mut() {
947            if *state == DeliveryState::InFlight {
948                *state = DeliveryState::Retryable;
949            }
950        }
951        self.reconnect_attempt = self.reconnect_attempt.saturating_add(1);
952        self.state = RelayConnectionState::Backoff;
953        let exponent = self.reconnect_attempt.saturating_sub(1).min(4);
954        let delay_ms = 500_u64.saturating_mul(1_u64 << exponent);
955        Ok(vec![RelaySessionAction {
956            kind: RelaySessionActionKind::ScheduleReconnect,
957            delay_ms,
958            ..action(RelaySessionActionKind::ScheduleReconnect)
959        }])
960    }
961
962    /// Permanently cancel and clear process-local queues.
963    #[must_use]
964    pub fn cancel(&mut self) -> RelaySessionAction {
965        self.state = RelayConnectionState::Cancelled;
966        self.subscriptions.clear();
967        self.ready_subscriptions.clear();
968        self.delivery.clear();
969        self.terminal_delivery_order.clear();
970        self.outbound.clear();
971        self.pending_events.clear();
972        self.pending_authentication_event_id = None;
973        self.pending_ingestion.clear();
974        action(RelaySessionActionKind::CloseTransport)
975    }
976
977    /// Drain at most `limit` queued frames for the platform WebSocket.
978    ///
979    /// # Errors
980    ///
981    /// Returns a stable state error for zero limits.
982    pub fn drain_outbound(
983        &mut self,
984        limit: usize,
985    ) -> Result<Vec<OutboundRelayFrame>, SoftchatError> {
986        if limit == 0 {
987            return Err(SoftchatError::InvalidRelaySession);
988        }
989        let count = limit.min(self.outbound.len());
990        Ok(self.outbound.drain(..count).collect())
991    }
992
993    /// Return a stable non-secret snapshot.
994    #[must_use]
995    pub fn snapshot(&self) -> RelaySessionSnapshot {
996        RelaySessionSnapshot {
997            state: self.state,
998            subscriptions: self.subscriptions.keys().cloned().collect(),
999            ready_subscriptions: self.ready_subscriptions.iter().cloned().collect(),
1000            delivery_event_ids: self.delivery.keys().cloned().collect(),
1001            delivery_states: self.delivery.values().copied().collect(),
1002            pending_ingestion_ids: self.pending_ingestion.iter().cloned().collect(),
1003            reconnect_attempt: self.reconnect_attempt,
1004        }
1005    }
1006
1007    fn enqueue(
1008        &mut self,
1009        frame: String,
1010        delivery_event_ids: Vec<String>,
1011    ) -> Result<(), SoftchatError> {
1012        self.ensure_outbound_capacity(1)?;
1013        self.outbound.push_back(OutboundRelayFrame {
1014            frame,
1015            delivery_event_ids,
1016        });
1017        Ok(())
1018    }
1019
1020    fn ensure_outbound_capacity(&self, additional: usize) -> Result<(), SoftchatError> {
1021        if self.outbound.len().saturating_add(additional) > MAX_OUTBOUND_RELAY_FRAMES {
1022            Err(SoftchatError::RelaySessionQueueFull)
1023        } else {
1024            Ok(())
1025        }
1026    }
1027
1028    fn remember_terminal_delivery(&mut self, event_id: String) {
1029        self.terminal_delivery_order.push_back(event_id);
1030        while self.terminal_delivery_order.len() > MAX_TERMINAL_DELIVERY_RECORDS {
1031            if let Some(expired) = self.terminal_delivery_order.pop_front()
1032                && matches!(
1033                    self.delivery.get(&expired),
1034                    Some(DeliveryState::Accepted | DeliveryState::Rejected)
1035                )
1036            {
1037                self.delivery.remove(&expired);
1038            }
1039        }
1040    }
1041}
1042
1043/// Classify one standard `OK` without trusting its human-readable message as identity.
1044///
1045/// # Errors
1046///
1047/// Returns [`SoftchatError::InvalidDeliveryState`] for malformed values.
1048#[cfg_attr(feature = "native-bindings", uniffi::export)]
1049pub fn classify_delivery_acknowledgement(
1050    acknowledgement: BatchAcknowledgement,
1051) -> Result<DeliveryDecision, SoftchatError> {
1052    let parsed = crate::track_batch_acknowledgements(
1053        vec![acknowledgement.event_id.clone()],
1054        vec![acknowledgement.clone()],
1055    )
1056    .map_err(|_| SoftchatError::InvalidDeliveryState)?;
1057    let state = match parsed.states.first() {
1058        Some(BatchEventState::Accepted) => DeliveryState::Accepted,
1059        Some(BatchEventState::Rejected) => {
1060            if is_retryable_message(&acknowledgement.message) {
1061                DeliveryState::Retryable
1062            } else {
1063                DeliveryState::Rejected
1064            }
1065        }
1066        _ => return Err(SoftchatError::InvalidDeliveryState),
1067    };
1068    let category = if acknowledgement.accepted {
1069        if acknowledgement.message.starts_with("duplicate:") {
1070            "duplicate"
1071        } else {
1072            "accepted"
1073        }
1074    } else if is_retryable_message(&acknowledgement.message) {
1075        "retryable"
1076    } else if acknowledgement.message.starts_with("auth-required:") {
1077        "authentication"
1078    } else if acknowledgement.message.starts_with("blocked:") {
1079        "blocked"
1080    } else if acknowledgement.message.starts_with("invalid:") {
1081        "invalid"
1082    } else {
1083        "rejected"
1084    };
1085    Ok(DeliveryDecision {
1086        event_id: acknowledgement.event_id,
1087        state,
1088        category: category.to_owned(),
1089        relay_message: acknowledgement.message,
1090    })
1091}
1092
1093/// Verify that a platform transaction classified every requested event exactly once.
1094///
1095/// # Errors
1096///
1097/// Returns [`SoftchatError::InvalidPersistenceResult`] for missing, duplicate,
1098/// or unknown IDs and accountless batches.
1099#[cfg_attr(feature = "native-bindings", uniffi::export)]
1100pub fn validate_ingestion_result(
1101    batch: &IngestionBatch,
1102    result: &IngestionResult,
1103) -> Result<(), SoftchatError> {
1104    if batch.account_id.is_empty() || batch.events.is_empty() {
1105        return Err(SoftchatError::InvalidPersistenceResult);
1106    }
1107    let requested = batch
1108        .events
1109        .iter()
1110        .map(|event| event.id.clone())
1111        .collect::<BTreeSet<_>>();
1112    if requested.len() != batch.events.len() {
1113        return Err(SoftchatError::InvalidPersistenceResult);
1114    }
1115    let classified = result
1116        .inserted_event_ids
1117        .iter()
1118        .chain(&result.duplicate_event_ids)
1119        .chain(&result.quarantined_event_ids)
1120        .cloned()
1121        .collect::<Vec<_>>();
1122    let unique = classified.iter().cloned().collect::<BTreeSet<_>>();
1123    if unique.len() != classified.len() || unique != requested {
1124        return Err(SoftchatError::InvalidPersistenceResult);
1125    }
1126    Ok(())
1127}
1128
1129fn is_retryable_message(message: &str) -> bool {
1130    ["rate-limited:", "error:", "unavailable:", "timeout:"]
1131        .iter()
1132        .any(|prefix| message.starts_with(prefix))
1133}
1134
1135fn action(kind: RelaySessionActionKind) -> RelaySessionAction {
1136    RelaySessionAction {
1137        kind,
1138        frame: String::new(),
1139        value: String::new(),
1140        reason: String::new(),
1141        event: None,
1142        delay_ms: 0,
1143        delivery_decision: None,
1144    }
1145}
1146
1147#[cfg(feature = "native-bindings")]
1148fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
1149    match mutex.lock() {
1150        Ok(guard) => guard,
1151        Err(poisoned) => poisoned.into_inner(),
1152    }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157    use crate::{LocalIdentity, NostrEventDraft, NostrEventKind};
1158
1159    use super::*;
1160
1161    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
1162
1163    fn event(created_at: u64) -> Result<SignedNostrEvent, SoftchatError> {
1164        LocalIdentity::from_secret_hex(ALICE_SECRET)?.sign_event(NostrEventDraft::new(
1165            created_at,
1166            NostrEventKind::SHORT_TEXT_NOTE,
1167            Vec::new(),
1168            "test",
1169        )?)
1170    }
1171
1172    #[test]
1173    fn session_publish_and_authentication_replay_split_large_frames() -> Result<(), SoftchatError> {
1174        let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
1175        let events = (0..3)
1176            .map(|timestamp| {
1177                identity.sign_event(NostrEventDraft::new(
1178                    timestamp,
1179                    NostrEventKind::SHORT_TEXT_NOTE,
1180                    Vec::new(),
1181                    "large 🦀".repeat(28_000),
1182                )?)
1183            })
1184            .collect::<Result<Vec<_>, SoftchatError>>()?;
1185        let expected = events
1186            .iter()
1187            .map(|event| event.id().to_hex())
1188            .collect::<Vec<_>>();
1189        for authenticated in [false, true] {
1190            let mut session = RelaySession::new();
1191            session.connect()?;
1192            session.transport_connected()?;
1193            if authenticated {
1194                session.authenticated()?;
1195            }
1196            session.publish(events.clone())?;
1197            if !authenticated {
1198                session.authenticated()?;
1199            }
1200            let frames = session.drain_outbound(8)?;
1201            assert_eq!(frames.len(), 3);
1202            let actual = frames
1203                .iter()
1204                .flat_map(|frame| frame.delivery_event_ids.clone())
1205                .collect::<Vec<_>>();
1206            let expected_order = if authenticated {
1207                expected.clone()
1208            } else {
1209                expected
1210                    .iter()
1211                    .cloned()
1212                    .collect::<BTreeSet<_>>()
1213                    .into_iter()
1214                    .collect()
1215            };
1216            assert_eq!(actual, expected_order);
1217            for frame in &frames {
1218                let event = events
1219                    .iter()
1220                    .find(|event| frame.delivery_event_ids.contains(&event.id().to_hex()))
1221                    .ok_or(SoftchatError::InvalidRelaySession)?;
1222                assert!(frame.frame.len() <= crate::MAX_RELAY_FRAME_BYTES);
1223                assert_eq!(
1224                    crate::parse_client_relay_frame(&frame.frame)?,
1225                    ClientRelayFrame::Event(event.clone())
1226                );
1227                session.confirm_sent(&frame.delivery_event_ids)?;
1228            }
1229            assert!(
1230                session
1231                    .snapshot()
1232                    .delivery_states
1233                    .iter()
1234                    .all(|state| *state == DeliveryState::InFlight)
1235            );
1236        }
1237        Ok(())
1238    }
1239
1240    #[test]
1241    fn session_authentication_replay_preserves_the_event_count_limit() -> Result<(), SoftchatError>
1242    {
1243        let events = (0..=MAX_RELAY_BATCH_EVENTS)
1244            .map(|timestamp| event(timestamp as u64))
1245            .collect::<Result<Vec<_>, _>>()?;
1246        let mut session = RelaySession::new();
1247        for chunk in events.chunks(MAX_RELAY_BATCH_EVENTS) {
1248            session.publish(chunk.to_vec())?;
1249        }
1250        session.connect()?;
1251        session.transport_connected()?;
1252        session.authenticated()?;
1253        let frames = session.drain_outbound(8)?;
1254        assert_eq!(frames.len(), 2);
1255        assert_eq!(frames[0].delivery_event_ids.len(), MAX_RELAY_BATCH_EVENTS);
1256        assert_eq!(frames[1].delivery_event_ids.len(), 1);
1257        assert_eq!(
1258            frames
1259                .iter()
1260                .flat_map(|frame| frame.delivery_event_ids.clone())
1261                .collect::<Vec<_>>(),
1262            events
1263                .iter()
1264                .map(|event| event.id().to_hex())
1265                .collect::<BTreeSet<_>>()
1266                .into_iter()
1267                .collect::<Vec<_>>()
1268        );
1269        Ok(())
1270    }
1271
1272    #[test]
1273    fn session_replays_subscriptions_and_tracks_eose_ingestion_and_delivery()
1274    -> Result<(), SoftchatError> {
1275        let mut session = RelaySession::new();
1276        session.subscribe("messages".to_owned(), vec![RelayFilter::default()])?;
1277        assert_eq!(
1278            session.connect()?.kind,
1279            RelaySessionActionKind::OpenTransport
1280        );
1281        session.transport_connected()?;
1282        session.authenticated()?;
1283        assert_eq!(session.drain_outbound(8)?.len(), 1);
1284
1285        let authored = event(1)?;
1286        session.publish(vec![authored.clone()])?;
1287        let outbound = session.drain_outbound(8)?;
1288        assert_eq!(outbound.len(), 1);
1289        assert_eq!(outbound[0].delivery_event_ids, vec![authored.id().to_hex()]);
1290        session.confirm_sent(&outbound[0].delivery_event_ids)?;
1291        let ok = RelayResponseFrame::Ok(BatchAcknowledgement {
1292            event_id: authored.id().to_hex(),
1293            accepted: true,
1294            message: String::new(),
1295        })
1296        .to_json()?;
1297        assert_eq!(
1298            session.receive(&ok)?[0]
1299                .delivery_decision
1300                .as_ref()
1301                .map(|decision| decision.state),
1302            Some(DeliveryState::Accepted),
1303        );
1304
1305        let received = event(2)?;
1306        let frame = RelayResponseFrame::Event {
1307            subscription_id: "messages".to_owned(),
1308            event: received.clone(),
1309        }
1310        .to_json()?;
1311        let action = session.receive(&frame)?;
1312        assert_eq!(action[0].kind, RelaySessionActionKind::PersistEvent);
1313        session.confirm_ingested(&received.id().to_hex())?;
1314        assert!(session.snapshot().pending_ingestion_ids.is_empty());
1315
1316        let eose = RelayResponseFrame::Eose {
1317            subscription_id: "messages".to_owned(),
1318        }
1319        .to_json()?;
1320        assert_eq!(
1321            session.receive(&eose)?[0].kind,
1322            RelaySessionActionKind::SubscriptionReady
1323        );
1324        Ok(())
1325    }
1326
1327    #[test]
1328    fn publish_rejects_duplicates_in_one_call_without_mutating_state() -> Result<(), SoftchatError>
1329    {
1330        let authored = event(1)?;
1331        for stage in 0..4 {
1332            let mut session = RelaySession::new();
1333            if stage >= 1 {
1334                session.connect()?;
1335            }
1336            if stage >= 2 {
1337                session.transport_connected()?;
1338            }
1339            if stage >= 3 {
1340                session.authenticated()?;
1341            }
1342            assert_eq!(
1343                session.publish(vec![authored.clone(), authored.clone()]),
1344                Err(SoftchatError::InvalidDeliveryState),
1345            );
1346            assert!(session.snapshot().delivery_event_ids.is_empty());
1347            assert!(session.drain_outbound(8)?.is_empty());
1348        }
1349        Ok(())
1350    }
1351
1352    #[test]
1353    fn duplicate_inflight_event_emits_one_persistence_action() -> Result<(), SoftchatError> {
1354        let mut session = RelaySession::new();
1355        session.subscribe("messages".to_owned(), vec![RelayFilter::default()])?;
1356        session.connect()?;
1357        session.transport_connected()?;
1358        session.authenticated()?;
1359        session.drain_outbound(8)?;
1360        let received = event(2)?;
1361        let frame = RelayResponseFrame::Event {
1362            subscription_id: "messages".to_owned(),
1363            event: received,
1364        }
1365        .to_json()?;
1366
1367        assert_eq!(session.receive(&frame)?.len(), 1);
1368        assert!(session.receive(&frame)?.is_empty());
1369        assert_eq!(session.snapshot().pending_ingestion_ids.len(), 1);
1370        Ok(())
1371    }
1372
1373    #[test]
1374    fn closed_ends_subscription_and_reports_bounded_reason() -> Result<(), SoftchatError> {
1375        let mut session = RelaySession::new();
1376        session.subscribe("messages".to_owned(), vec![RelayFilter::default()])?;
1377        session.connect()?;
1378        session.transport_connected()?;
1379        session.authenticated()?;
1380        session.drain_outbound(8)?;
1381        let frame = RelayResponseFrame::Closed {
1382            subscription_id: "messages".to_owned(),
1383            message: "rate-limited".to_owned(),
1384        }
1385        .to_json()?;
1386
1387        let actions = session.receive(&frame)?;
1388        assert_eq!(actions.len(), 1);
1389        assert_eq!(actions[0].kind, RelaySessionActionKind::SubscriptionClosed);
1390        assert_eq!(actions[0].value, "messages");
1391        assert_eq!(actions[0].reason, "rate-limited");
1392        assert!(session.snapshot().subscriptions.is_empty());
1393        assert!(session.receive(&frame)?.is_empty());
1394        let unknown = RelayResponseFrame::Closed {
1395            subscription_id: "unknown".to_owned(),
1396            message: "not ours".to_owned(),
1397        }
1398        .to_json()?;
1399        assert!(session.receive(&unknown)?.is_empty());
1400
1401        let mut authenticating = RelaySession::new();
1402        authenticating.subscribe("retained".to_owned(), vec![RelayFilter::default()])?;
1403        authenticating.connect()?;
1404        authenticating.transport_connected()?;
1405        let stale = RelayResponseFrame::Closed {
1406            subscription_id: "retained".to_owned(),
1407            message: "authentication required".to_owned(),
1408        }
1409        .to_json()?;
1410        assert!(authenticating.receive(&stale)?.is_empty());
1411        assert_eq!(
1412            authenticating.snapshot().subscriptions,
1413            vec!["retained".to_owned()],
1414        );
1415        Ok(())
1416    }
1417
1418    #[test]
1419    fn disconnect_preserves_terminal_state_and_retries_only_inflight() -> Result<(), SoftchatError>
1420    {
1421        let mut session = RelaySession::new();
1422        session.connect()?;
1423        session.transport_connected()?;
1424        session.authenticated()?;
1425        let first = event(1)?;
1426        let second = event(2)?;
1427        session.publish(vec![first.clone(), second.clone()])?;
1428        let outbound = session.drain_outbound(8)?;
1429        session.confirm_sent(&outbound[0].delivery_event_ids)?;
1430        let accepted = RelayResponseFrame::Ok(BatchAcknowledgement {
1431            event_id: first.id().to_hex(),
1432            accepted: true,
1433            message: String::new(),
1434        })
1435        .to_json()?;
1436        session.receive(&accepted)?;
1437        let reconnect = session.transport_lost()?;
1438        assert_eq!(reconnect[0].kind, RelaySessionActionKind::ScheduleReconnect);
1439        let snapshot = session.snapshot();
1440        let states = snapshot
1441            .delivery_event_ids
1442            .iter()
1443            .cloned()
1444            .zip(snapshot.delivery_states)
1445            .collect::<BTreeMap<_, _>>();
1446        assert_eq!(
1447            states.get(&first.id().to_hex()),
1448            Some(&DeliveryState::Accepted)
1449        );
1450        assert_eq!(
1451            states.get(&second.id().to_hex()),
1452            Some(&DeliveryState::Retryable)
1453        );
1454        Ok(())
1455    }
1456
1457    #[test]
1458    fn persisted_publish_intent_queues_after_authentication() -> Result<(), SoftchatError> {
1459        let mut session = RelaySession::new();
1460        let authored = event(1)?;
1461        session.publish(vec![authored.clone()])?;
1462        assert!(session.drain_outbound(8)?.is_empty());
1463        session.connect()?;
1464        session.transport_connected()?;
1465        session.authenticated()?;
1466        let outbound = session.drain_outbound(8)?;
1467        assert_eq!(outbound.len(), 1);
1468        assert!(outbound[0].frame.starts_with("[\"EVENT\","));
1469        let snapshot = session.snapshot();
1470        let state = snapshot
1471            .delivery_event_ids
1472            .iter()
1473            .position(|event_id| event_id == &authored.id().to_hex())
1474            .and_then(|index| snapshot.delivery_states.get(index).copied());
1475        assert_eq!(state, Some(DeliveryState::Pending));
1476        session.confirm_sent(&outbound[0].delivery_event_ids)?;
1477        let snapshot = session.snapshot();
1478        let state = snapshot
1479            .delivery_event_ids
1480            .iter()
1481            .position(|event_id| event_id == &authored.id().to_hex())
1482            .and_then(|index| snapshot.delivery_states.get(index).copied());
1483        assert_eq!(state, Some(DeliveryState::InFlight));
1484        Ok(())
1485    }
1486
1487    #[test]
1488    fn acknowledgement_may_race_socket_handoff_and_confirmation_is_idempotent()
1489    -> Result<(), SoftchatError> {
1490        let mut session = RelaySession::new();
1491        session.connect()?;
1492        session.transport_connected()?;
1493        session.authenticated()?;
1494        let authored = event(1)?;
1495        session.publish(vec![authored.clone()])?;
1496        let outbound = session.drain_outbound(8)?;
1497        assert_eq!(
1498            session.snapshot().delivery_states,
1499            vec![DeliveryState::Pending]
1500        );
1501        let acknowledgement = RelayResponseFrame::Ok(BatchAcknowledgement {
1502            event_id: authored.id().to_hex(),
1503            accepted: true,
1504            message: String::new(),
1505        })
1506        .to_json()?;
1507        let decisions = session.receive(&acknowledgement)?;
1508        assert_eq!(
1509            decisions[0]
1510                .delivery_decision
1511                .as_ref()
1512                .map(|decision| decision.state),
1513            Some(DeliveryState::Accepted)
1514        );
1515        session.confirm_sent(&outbound[0].delivery_event_ids)?;
1516        assert_eq!(
1517            session.snapshot().delivery_states,
1518            vec![DeliveryState::Accepted]
1519        );
1520        let duplicate = session.receive(&acknowledgement)?;
1521        assert_eq!(
1522            duplicate[0]
1523                .delivery_decision
1524                .as_ref()
1525                .map(|decision| decision.state),
1526            Some(DeliveryState::Accepted)
1527        );
1528        Ok(())
1529    }
1530
1531    #[test]
1532    fn queue_backpressure_does_not_partially_mutate_intent() -> Result<(), SoftchatError> {
1533        let mut session = RelaySession::new();
1534        session.connect()?;
1535        session.transport_connected()?;
1536        session.authenticated()?;
1537        for _ in 0..MAX_OUTBOUND_RELAY_FRAMES {
1538            session.enqueue("[]".to_owned(), Vec::new())?;
1539        }
1540
1541        let authored = event(1)?;
1542        assert_eq!(
1543            session.publish(vec![authored.clone()]),
1544            Err(SoftchatError::RelaySessionQueueFull),
1545        );
1546        assert!(
1547            !session
1548                .snapshot()
1549                .delivery_event_ids
1550                .contains(&authored.id().to_hex())
1551        );
1552        assert_eq!(
1553            session.subscribe("blocked".to_owned(), vec![RelayFilter::default()]),
1554            Err(SoftchatError::RelaySessionQueueFull),
1555        );
1556        assert!(
1557            !session
1558                .snapshot()
1559                .subscriptions
1560                .contains(&"blocked".to_owned())
1561        );
1562
1563        let mut closing = RelaySession::new();
1564        closing.subscribe("retained".to_owned(), vec![RelayFilter::default()])?;
1565        closing.connect()?;
1566        closing.transport_connected()?;
1567        closing.authenticated()?;
1568        closing.drain_outbound(MAX_OUTBOUND_RELAY_FRAMES)?;
1569        for _ in 0..MAX_OUTBOUND_RELAY_FRAMES {
1570            closing.enqueue("[]".to_owned(), Vec::new())?;
1571        }
1572        assert_eq!(
1573            closing.close_subscription("retained"),
1574            Err(SoftchatError::RelaySessionQueueFull),
1575        );
1576        assert_eq!(
1577            closing.snapshot().subscriptions,
1578            vec!["retained".to_owned()]
1579        );
1580
1581        let mut replay = RelaySession::new();
1582        replay.subscribe("retained".to_owned(), vec![RelayFilter::default()])?;
1583        replay.publish(vec![event(2)?])?;
1584        replay.connect()?;
1585        replay.transport_connected()?;
1586        for _ in 0..MAX_OUTBOUND_RELAY_FRAMES {
1587            replay.enqueue("[]".to_owned(), Vec::new())?;
1588        }
1589        assert_eq!(
1590            replay.authenticated(),
1591            Err(SoftchatError::RelaySessionQueueFull),
1592        );
1593        assert_eq!(
1594            replay.snapshot().state,
1595            RelayConnectionState::Authenticating,
1596        );
1597        assert_eq!(replay.outbound.len(), MAX_OUTBOUND_RELAY_FRAMES);
1598        Ok(())
1599    }
1600
1601    #[test]
1602    fn terminal_delivery_history_is_bounded_and_does_not_consume_pending_capacity()
1603    -> Result<(), SoftchatError> {
1604        let mut session = RelaySession::new();
1605        for index in 0..=MAX_TERMINAL_DELIVERY_RECORDS {
1606            let event_id = format!("{index:064x}");
1607            session
1608                .delivery
1609                .insert(event_id.clone(), DeliveryState::Accepted);
1610            session.remember_terminal_delivery(event_id);
1611        }
1612        assert_eq!(session.delivery.len(), MAX_TERMINAL_DELIVERY_RECORDS);
1613        assert_eq!(
1614            session.terminal_delivery_order.len(),
1615            MAX_TERMINAL_DELIVERY_RECORDS
1616        );
1617        assert!(!session.delivery.contains_key(&format!("{:064x}", 0)));
1618        session.publish(vec![event(1)?])?;
1619        assert_eq!(session.pending_events.len(), 1);
1620        Ok(())
1621    }
1622
1623    #[test]
1624    fn authentication_challenge_discards_stale_frames_and_replays_pending_intent()
1625    -> Result<(), SoftchatError> {
1626        let mut session = RelaySession::new();
1627        session.connect()?;
1628        session.transport_connected()?;
1629        session.authenticated()?;
1630        let authored = event(1)?;
1631        session.publish(vec![authored.clone()])?;
1632        assert_eq!(session.outbound.len(), 1);
1633
1634        let actions =
1635            session.receive(&RelayResponseFrame::Auth("challenge".to_owned()).to_json()?)?;
1636        assert_eq!(actions[0].kind, RelaySessionActionKind::Authenticate);
1637        assert!(session.outbound.is_empty());
1638        let identity = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
1639        let authentication =
1640            crate::plan_nip42_authentication(&identity, "challenge", "wss://relay.example", 1_000)?;
1641        let authentication_id = authentication.id().to_hex();
1642        session.authenticate(authentication)?;
1643        let auth_frame = session.drain_outbound(1)?;
1644        assert!(auth_frame[0].delivery_event_ids.is_empty());
1645        assert!(matches!(
1646            crate::parse_client_relay_frame(&auth_frame[0].frame)?,
1647            ClientRelayFrame::Auth(_)
1648        ));
1649        session.receive(
1650            &RelayResponseFrame::Ok(BatchAcknowledgement {
1651                event_id: authentication_id,
1652                accepted: true,
1653                message: String::new(),
1654            })
1655            .to_json()?,
1656        )?;
1657        let replay = session.drain_outbound(1)?;
1658        assert_eq!(replay[0].delivery_event_ids, vec![authored.id().to_hex()]);
1659        Ok(())
1660    }
1661
1662    #[test]
1663    fn persistence_result_is_total_unique_and_account_scoped() -> Result<(), SoftchatError> {
1664        let first = SignedEvent::from(&event(1)?);
1665        let second = SignedEvent::from(&event(2)?);
1666        let batch = IngestionBatch {
1667            account_id: "account-1".to_owned(),
1668            events: vec![first.clone(), second.clone()],
1669        };
1670        validate_ingestion_result(
1671            &batch,
1672            &IngestionResult {
1673                inserted_event_ids: vec![first.id],
1674                duplicate_event_ids: vec![second.id],
1675                quarantined_event_ids: Vec::new(),
1676            },
1677        )?;
1678        assert!(
1679            validate_ingestion_result(
1680                &batch,
1681                &IngestionResult {
1682                    inserted_event_ids: Vec::new(),
1683                    duplicate_event_ids: Vec::new(),
1684                    quarantined_event_ids: Vec::new(),
1685                },
1686            )
1687            .is_err()
1688        );
1689        Ok(())
1690    }
1691
1692    #[test]
1693    fn delivery_classifier_separates_retryable_and_permanent_rejection() -> Result<(), SoftchatError>
1694    {
1695        let event_id = event(1)?.id().to_hex();
1696        assert_eq!(
1697            classify_delivery_acknowledgement(BatchAcknowledgement {
1698                event_id: event_id.clone(),
1699                accepted: false,
1700                message: "rate-limited: slow down".to_owned(),
1701            })?
1702            .state,
1703            DeliveryState::Retryable
1704        );
1705        assert_eq!(
1706            classify_delivery_acknowledgement(BatchAcknowledgement {
1707                event_id,
1708                accepted: false,
1709                message: "blocked: policy".to_owned(),
1710            })?
1711            .state,
1712            DeliveryState::Rejected
1713        );
1714        Ok(())
1715    }
1716}