photon_backend_nats/
port.rs1use 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
24pub 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
38pub struct NatsStoragePort {
40 jetstream: async_nats::jetstream::Context,
41 config: NatsConfig,
42 pipeline: PublishPipeline,
43 checkpoint_store: CheckpointStore,
44}
45
46impl NatsStoragePort {
47 #[must_use]
49 pub fn builder() -> NatsStoragePortBuilder {
50 NatsStoragePortBuilder::new()
51 }
52
53 pub async fn from_env() -> Result<Self> {
59 Self::builder().from_env_defaults().build().await
60 }
61
62 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 #[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 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}