Skip to main content

photon_backend_nats/
port.rs

1//! NATS `JetStream` [`StoragePort`] — live adapter when `PHOTON_NATS_URL` is set.
2
3use std::pin::Pin;
4
5use async_trait::async_trait;
6use chrono::Utc;
7use futures::stream::Stream;
8use photon_backend::models::Event;
9use photon_backend::{
10    seal_event_for_storage, PhotonError, Result, StorageCapabilities, StoragePort,
11};
12use serde_json::Value;
13use uuid::Uuid;
14
15use crate::checkpoint::CheckpointStore;
16use crate::config::{NatsConfig, NatsStoragePortBuilder, ReplayCursor};
17use crate::connect::connect_nats;
18use crate::consumer::subscribe_push;
19use crate::message::encode_event;
20use crate::publish::PublishPipeline;
21use crate::stream::ensure_streams;
22use crate::stream_shard::{composite_seq, photon_subject_for, pick_shard, publish_routing_key};
23
24/// Read NATS URL from the environment.
25///
26/// # Errors
27///
28/// Returns an error when `PHOTON_NATS_URL` is unset.
29pub fn nats_url_from_env() -> Result<String> {
30    std::env::var(crate::config::URL_ENV).map_err(|_| {
31        PhotonError::Internal(format!(
32            "{} not set for nats storage adapter",
33            crate::config::URL_ENV
34        ))
35    })
36}
37
38/// NATS JetStream-backed storage port.
39pub struct NatsStoragePort {
40    jetstream: async_nats::jetstream::Context,
41    config: NatsConfig,
42    pipeline: PublishPipeline,
43    checkpoint_store: CheckpointStore,
44}
45
46impl NatsStoragePort {
47    /// Start a builder for explicit host wiring.
48    #[must_use]
49    pub fn builder() -> NatsStoragePortBuilder {
50        NatsStoragePortBuilder::new()
51    }
52
53    /// Connect using env (`PHOTON_NATS_*` defaults via builder).
54    ///
55    /// # Errors
56    ///
57    /// Returns an error when env is missing or connection fails.
58    pub async fn from_env() -> Result<Self> {
59        Self::builder().from_env_defaults().build().await
60    }
61
62    /// Connect to NATS with explicit URL and stream name (legacy; uses env defaults for firehose options).
63    ///
64    /// # Errors
65    ///
66    /// Returns an error when connection or stream setup fails.
67    pub async fn connect(url: &str, stream_name: &str) -> Result<Self> {
68        Self::builder()
69            .url(url)
70            .stream_name(stream_name)
71            .from_env_defaults()
72            .build()
73            .await
74    }
75
76    /// Resolved adapter configuration.
77    #[must_use]
78    pub const fn config(&self) -> &NatsConfig {
79        &self.config
80    }
81
82    async fn connect_with_config(config: NatsConfig) -> Result<Self> {
83        let client = connect_nats(
84            &config.url,
85            config.transport_security,
86            config.credentials_file.as_deref(),
87        )
88        .await?;
89        let jetstream = async_nats::jetstream::new(client);
90        ensure_streams(&jetstream, &config).await?;
91        let checkpoint_store = CheckpointStore::connect(&jetstream, &config).await?;
92        let pipeline = PublishPipeline::new(&config);
93        Ok(Self {
94            jetstream,
95            config,
96            pipeline,
97            checkpoint_store,
98        })
99    }
100}
101
102impl NatsStoragePortBuilder {
103    /// Connect and return a configured [`NatsStoragePort`].
104    ///
105    /// # Errors
106    ///
107    /// Returns an error when configuration or connection fails.
108    pub async fn build(self) -> Result<NatsStoragePort> {
109        let config = self.resolve()?;
110        NatsStoragePort::connect_with_config(config).await
111    }
112}
113
114#[async_trait]
115impl StoragePort for NatsStoragePort {
116    fn capabilities(&self) -> StorageCapabilities {
117        StorageCapabilities::broker("nats")
118    }
119
120    async fn append(
121        &self,
122        topic_name: &str,
123        topic_key: Option<&str>,
124        actor_json: Value,
125        payload_json: Value,
126    ) -> Result<Event> {
127        let event = Event {
128            event_id: Uuid::new_v4().to_string(),
129            topic_name: topic_name.to_string(),
130            topic_key: topic_key.map(String::from),
131            seq: 0,
132            actor_json,
133            payload_json,
134            created_at: Utc::now(),
135        };
136        let (mut plain, sealed) = seal_event_for_storage(&self.config.crypto, event)?;
137
138        let routing = publish_routing_key(topic_key, &plain.event_id);
139        let shard = pick_shard(&routing, self.config.stream_shards);
140        let subject = photon_subject_for(shard, self.config.stream_shards, topic_name);
141        let (headers, body) = encode_event(&sealed)?;
142        let stream_seq = self
143            .pipeline
144            .publish(&self.jetstream, subject, Some(headers), body)
145            .await?;
146
147        if self.config.replay_cursor == ReplayCursor::StreamSeq {
148            if let Some(seq) = stream_seq {
149                let local = i64::try_from(seq).unwrap_or(i64::MAX);
150                plain.seq = if self.config.is_sharded() {
151                    composite_seq(shard, seq)
152                } else {
153                    local
154                };
155            }
156        }
157
158        Ok(plain)
159    }
160
161    fn subscribe(
162        &self,
163        topic_name: String,
164        topic_key_filter: Option<String>,
165        after_seq: Option<i64>,
166    ) -> Pin<Box<dyn Stream<Item = Result<Event>> + Send>> {
167        let effective_after = if self.config.replay_cursor == ReplayCursor::TailOnly {
168            None
169        } else {
170            after_seq
171        };
172        subscribe_push(
173            self.jetstream.clone(),
174            self.config.clone(),
175            self.checkpoint_store.clone(),
176            topic_name,
177            topic_key_filter,
178            effective_after,
179        )
180    }
181
182    async fn get_event(&self, _event_id: &str) -> Result<Option<Event>> {
183        Ok(None)
184    }
185
186    async fn list_by_topic(
187        &self,
188        _topic_name: &str,
189        _topic_key: Option<&str>,
190        _after_seq: Option<i64>,
191        _limit: usize,
192    ) -> Result<Vec<Event>> {
193        Ok(Vec::new())
194    }
195
196    async fn list_recent(&self, _limit: usize) -> Result<Vec<Event>> {
197        Ok(Vec::new())
198    }
199
200    async fn load_checkpoint(
201        &self,
202        subscription_name: &str,
203        topic_name: &str,
204        topic_key: Option<&str>,
205    ) -> Result<Option<i64>> {
206        if self.config.replay_cursor == ReplayCursor::TailOnly {
207            return Ok(None);
208        }
209        self.checkpoint_store
210            .load(subscription_name, topic_name, topic_key)
211            .await
212    }
213
214    async fn commit_checkpoint(
215        &self,
216        subscription_name: &str,
217        topic_name: &str,
218        topic_key: Option<&str>,
219        last_seq: i64,
220    ) -> Result<()> {
221        if self.config.replay_cursor == ReplayCursor::TailOnly {
222            return Ok(());
223        }
224        self.checkpoint_store
225            .commit(subscription_name, topic_name, topic_key, last_seq)
226            .await
227    }
228}