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::{PhotonError, Result, StorageCapabilities, StoragePort};
10use serde_json::Value;
11use uuid::Uuid;
12
13use crate::checkpoint::CheckpointStore;
14use crate::config::{NatsConfig, NatsStoragePortBuilder, ReplayCursor};
15use crate::connect::connect_nats;
16use crate::consumer::subscribe_push;
17use crate::message::encode_event;
18use crate::publish::PublishPipeline;
19use crate::stream::ensure_streams;
20use crate::stream_shard::{
21    composite_seq, photon_subject_for, pick_shard, publish_routing_key,
22};
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(&config.url).await?;
84        let jetstream = async_nats::jetstream::new(client);
85        ensure_streams(&jetstream, &config).await?;
86        let checkpoint_store = CheckpointStore::connect(&jetstream, &config).await?;
87        let pipeline = PublishPipeline::new(&config);
88        Ok(Self {
89            jetstream,
90            config,
91            pipeline,
92            checkpoint_store,
93        })
94    }
95}
96
97impl NatsStoragePortBuilder {
98    /// Connect and return a configured [`NatsStoragePort`].
99    ///
100    /// # Errors
101    ///
102    /// Returns an error when configuration or connection fails.
103    pub async fn build(self) -> Result<NatsStoragePort> {
104        let config = self.resolve()?;
105        NatsStoragePort::connect_with_config(config).await
106    }
107}
108
109#[async_trait]
110impl StoragePort for NatsStoragePort {
111    fn capabilities(&self) -> StorageCapabilities {
112        StorageCapabilities::broker("nats")
113    }
114
115    async fn append(
116        &self,
117        topic_name: &str,
118        topic_key: Option<&str>,
119        actor_json: Value,
120        payload_json: Value,
121    ) -> Result<Event> {
122        let _ = self.config.crypto.encrypt(&actor_json, &payload_json)?;
123        let mut event = Event {
124            event_id: Uuid::new_v4().to_string(),
125            topic_name: topic_name.to_string(),
126            topic_key: topic_key.map(String::from),
127            seq: 0,
128            actor_json,
129            payload_json,
130            created_at: Utc::now(),
131        };
132
133        let routing = publish_routing_key(topic_key, &event.event_id);
134        let shard = pick_shard(&routing, self.config.stream_shards);
135        let subject = photon_subject_for(shard, self.config.stream_shards, topic_name);
136        let (headers, body) = encode_event(&event)?;
137        let stream_seq = self
138            .pipeline
139            .publish(&self.jetstream, subject, Some(headers), body)
140            .await?;
141
142        if self.config.replay_cursor == ReplayCursor::StreamSeq {
143            if let Some(seq) = stream_seq {
144                let local = i64::try_from(seq).unwrap_or(i64::MAX);
145                event.seq = if self.config.is_sharded() {
146                    composite_seq(shard, seq)
147                } else {
148                    local
149                };
150            }
151        }
152
153        Ok(event)
154    }
155
156    fn subscribe(
157        &self,
158        topic_name: String,
159        topic_key_filter: Option<String>,
160        after_seq: Option<i64>,
161    ) -> Pin<Box<dyn Stream<Item = Result<Event>> + Send>> {
162        let effective_after = if self.config.replay_cursor == ReplayCursor::TailOnly {
163            None
164        } else {
165            after_seq
166        };
167        subscribe_push(
168            self.jetstream.clone(),
169            self.config.clone(),
170            self.checkpoint_store.clone(),
171            topic_name,
172            topic_key_filter,
173            effective_after,
174        )
175    }
176
177    async fn get_event(&self, _event_id: &str) -> Result<Option<Event>> {
178        Ok(None)
179    }
180
181    async fn load_checkpoint(
182        &self,
183        subscription_name: &str,
184        topic_name: &str,
185        topic_key: Option<&str>,
186    ) -> Result<Option<i64>> {
187        if self.config.replay_cursor == ReplayCursor::TailOnly {
188            return Ok(None);
189        }
190        self.checkpoint_store
191            .load(subscription_name, topic_name, topic_key)
192            .await
193    }
194
195    async fn commit_checkpoint(
196        &self,
197        subscription_name: &str,
198        topic_name: &str,
199        topic_key: Option<&str>,
200        last_seq: i64,
201    ) -> Result<()> {
202        if self.config.replay_cursor == ReplayCursor::TailOnly {
203            return Ok(());
204        }
205        self.checkpoint_store
206            .commit(subscription_name, topic_name, topic_key, last_seq)
207            .await
208    }
209}