Skip to main content

eventcore_postgres/projections/
source.rs

1use std::num::NonZeroU64;
2
3use eventcore_types::{
4    BatchSize, DeliveryPosition, DeliverySourceId, DeliveryUpperBound, EventTypeName,
5    PersistedEventEnvelope, PersistedEventId, ProjectionSelection, ProjectionSource,
6    ProjectionStreamFilter, StreamId, StreamVersion,
7};
8use serde_json::value::RawValue;
9use sqlx::{Pool, Postgres, QueryBuilder, Row, postgres::PgPoolOptions, query_scalar};
10use thiserror::Error;
11use uuid::Uuid;
12
13use super::migration::run_component_migration;
14use crate::PostgresConfig;
15
16const DELIVERY_SOURCE_COMPONENT: &str = "delivery-source";
17const DELIVERY_SOURCE_VERSION: i64 = 1;
18const DELIVERY_SOURCE_MIGRATION: &str =
19    include_str!("../../projection-migrations/0001_delivery_source.sql");
20
21/// Errors returned by [`PostgresProjectionSource`].
22#[derive(Debug, Error)]
23#[non_exhaustive]
24pub enum PostgresProjectionSourceError {
25    /// Connecting the source's PostgreSQL pool failed.
26    #[error("failed to connect projection source")]
27    ConnectionFailed(#[source] sqlx::Error),
28    /// Applying the projection source's component-scoped migration failed.
29    #[error("failed to migrate projection source")]
30    MigrationFailed(#[source] sqlx::Error),
31    /// Reading the committed delivery sequence failed.
32    #[error("failed to read projection delivery source")]
33    ReadFailed(#[source] sqlx::Error),
34    /// A persisted delivery value cannot satisfy the public delivery contract.
35    #[error("invalid persisted {field}: {detail}")]
36    InvalidPersistedValue {
37        /// Name of the persisted field that could not be represented.
38        field: &'static str,
39        /// Conversion failure detail.
40        detail: String,
41    },
42}
43
44/// PostgreSQL implementation of the strict, lossless projection delivery source.
45#[derive(Debug, Clone)]
46pub struct PostgresProjectionSource {
47    pool: Pool<Postgres>,
48    source_id: DeliverySourceId,
49}
50
51impl PostgresProjectionSource {
52    /// Creates a source with the default PostgreSQL pool configuration.
53    pub async fn new<S: Into<String>>(
54        connection_string: S,
55        source_id: DeliverySourceId,
56    ) -> Result<Self, PostgresProjectionSourceError> {
57        Self::with_config(connection_string, PostgresConfig::default(), source_id).await
58    }
59
60    /// Creates a source with an explicit PostgreSQL pool configuration.
61    pub async fn with_config<S: Into<String>>(
62        connection_string: S,
63        config: PostgresConfig,
64        source_id: DeliverySourceId,
65    ) -> Result<Self, PostgresProjectionSourceError> {
66        let max_connections: std::num::NonZeroU32 = config.max_connections.into();
67        let pool = PgPoolOptions::new()
68            .max_connections(max_connections.get())
69            .acquire_timeout(config.acquire_timeout)
70            .idle_timeout(config.idle_timeout)
71            .connect(&connection_string.into())
72            .await
73            .map_err(PostgresProjectionSourceError::ConnectionFailed)?;
74        Ok(Self::from_pool(pool, source_id))
75    }
76
77    /// Creates a source from an existing pool and a stable application-supplied source identity.
78    pub fn from_pool(pool: Pool<Postgres>, source_id: DeliverySourceId) -> Self {
79        Self { pool, source_id }
80    }
81
82    /// Applies the source-owned delivery migration without using SQLx's shared migration ledger.
83    pub async fn migrate(&self) -> Result<(), PostgresProjectionSourceError> {
84        run_component_migration(
85            &self.pool,
86            DELIVERY_SOURCE_COMPONENT,
87            DELIVERY_SOURCE_VERSION,
88            DELIVERY_SOURCE_MIGRATION,
89        )
90        .await
91        .map_err(PostgresProjectionSourceError::MigrationFailed)
92    }
93}
94
95impl ProjectionSource for PostgresProjectionSource {
96    type Error = PostgresProjectionSourceError;
97
98    fn source_id(&self) -> &DeliverySourceId {
99        &self.source_id
100    }
101
102    async fn high_watermark(&self) -> Result<Option<DeliveryPosition>, Self::Error> {
103        let watermark: Option<i64> = query_scalar(
104            "SELECT NULLIF(last_position, 0) FROM eventcore_projection_delivery_frontier WHERE singleton = TRUE",
105        )
106        .fetch_one(&self.pool)
107        .await
108        .map_err(PostgresProjectionSourceError::ReadFailed)?;
109
110        watermark.map(delivery_position).transpose()
111    }
112
113    async fn read_envelopes(
114        &self,
115        selection: &ProjectionSelection,
116        after: Option<DeliveryPosition>,
117        through: DeliveryUpperBound,
118        limit: BatchSize,
119    ) -> Result<Vec<PersistedEventEnvelope>, Self::Error> {
120        let mut query = QueryBuilder::<Postgres>::new(
121            "SELECT delivery.delivery_position, events.event_id, events.stream_id, \
122             events.stream_version, events.event_type, events.event_data::TEXT AS event_data, \
123             events.metadata::TEXT AS metadata \
124             FROM eventcore_projection_delivery AS delivery \
125             INNER JOIN eventcore_events AS events ON events.event_id = delivery.event_id \
126             WHERE TRUE",
127        );
128
129        if let Some(after) = after {
130            let _ = query
131                .push(" AND delivery.delivery_position > ")
132                .push_bind(i64_from_position(after)?);
133        }
134        if let DeliveryUpperBound::Inclusive(through) = through {
135            let _ = query
136                .push(" AND delivery.delivery_position <= ")
137                .push_bind(i64_from_position(through)?);
138        }
139
140        match selection.stream_filter() {
141            ProjectionStreamFilter::All => {}
142            ProjectionStreamFilter::Prefix(prefix) => {
143                let _ = query
144                    .push(" AND LEFT(events.stream_id, char_length(")
145                    .push_bind(prefix.as_ref().to_string())
146                    .push(")) = ")
147                    .push_bind(prefix.as_ref().to_string());
148            }
149            ProjectionStreamFilter::Pattern(pattern) => {
150                let _ = query
151                    .push(" AND events.stream_id ~ ")
152                    .push_bind(glob_to_anchored_regex(pattern.as_ref()));
153            }
154        }
155
156        let _ = query.push(" AND events.event_type IN (");
157        let mut separated = query.separated(", ");
158        for event_type in selection.event_types() {
159            let _ = separated.push_bind(event_type.as_ref());
160        }
161        let _ = separated.push_unseparated(")");
162        let _ = query
163            .push(" ORDER BY delivery.delivery_position ASC LIMIT ")
164            .push_bind(i64::try_from(usize::from(limit)).map_err(|error| {
165                PostgresProjectionSourceError::InvalidPersistedValue {
166                    field: "batch limit",
167                    detail: error.to_string(),
168                }
169            })?);
170
171        let rows = query
172            .build()
173            .fetch_all(&self.pool)
174            .await
175            .map_err(PostgresProjectionSourceError::ReadFailed)?;
176
177        rows.into_iter()
178            .map(|row| envelope_from_row(row, self.source_id.clone()))
179            .collect()
180    }
181}
182
183fn envelope_from_row(
184    row: sqlx::postgres::PgRow,
185    source_id: DeliverySourceId,
186) -> Result<PersistedEventEnvelope, PostgresProjectionSourceError> {
187    let position: i64 = row
188        .try_get("delivery_position")
189        .map_err(PostgresProjectionSourceError::ReadFailed)?;
190    let event_id: Uuid = row
191        .try_get("event_id")
192        .map_err(PostgresProjectionSourceError::ReadFailed)?;
193    let stream_id: String = row
194        .try_get("stream_id")
195        .map_err(PostgresProjectionSourceError::ReadFailed)?;
196    let stream_version: i64 = row
197        .try_get("stream_version")
198        .map_err(PostgresProjectionSourceError::ReadFailed)?;
199    let event_type: String = row
200        .try_get("event_type")
201        .map_err(PostgresProjectionSourceError::ReadFailed)?;
202    let payload: String = row
203        .try_get("event_data")
204        .map_err(PostgresProjectionSourceError::ReadFailed)?;
205    let metadata: String = row
206        .try_get("metadata")
207        .map_err(PostgresProjectionSourceError::ReadFailed)?;
208
209    let stream_id = StreamId::try_new(stream_id).map_err(|error| {
210        PostgresProjectionSourceError::InvalidPersistedValue {
211            field: "stream_id",
212            detail: error.to_string(),
213        }
214    })?;
215    let stream_version = usize::try_from(stream_version).map_err(|error| {
216        PostgresProjectionSourceError::InvalidPersistedValue {
217            field: "stream_version",
218            detail: error.to_string(),
219        }
220    })?;
221    let event_type = EventTypeName::try_new(event_type).map_err(|error| {
222        PostgresProjectionSourceError::InvalidPersistedValue {
223            field: "event_type",
224            detail: error.to_string(),
225        }
226    })?;
227    let payload = RawValue::from_string(payload).map_err(|error| {
228        PostgresProjectionSourceError::InvalidPersistedValue {
229            field: "event_data",
230            detail: error.to_string(),
231        }
232    })?;
233    let metadata = RawValue::from_string(metadata).map_err(|error| {
234        PostgresProjectionSourceError::InvalidPersistedValue {
235            field: "metadata",
236            detail: error.to_string(),
237        }
238    })?;
239    Ok(PersistedEventEnvelope::new(
240        source_id,
241        delivery_position(position)?,
242        PersistedEventId::new(event_id),
243        stream_id,
244        StreamVersion::new(stream_version),
245        event_type,
246        payload,
247        metadata,
248    ))
249}
250
251fn delivery_position(value: i64) -> Result<DeliveryPosition, PostgresProjectionSourceError> {
252    let value = u64::try_from(value).map_err(|error| {
253        PostgresProjectionSourceError::InvalidPersistedValue {
254            field: "delivery_position",
255            detail: error.to_string(),
256        }
257    })?;
258    let value = NonZeroU64::new(value).ok_or_else(|| {
259        PostgresProjectionSourceError::InvalidPersistedValue {
260            field: "delivery_position",
261            detail: "must be positive".to_string(),
262        }
263    })?;
264    Ok(DeliveryPosition::new(value))
265}
266
267fn i64_from_position(value: DeliveryPosition) -> Result<i64, PostgresProjectionSourceError> {
268    i64::try_from(value.get()).map_err(|error| {
269        PostgresProjectionSourceError::InvalidPersistedValue {
270            field: "delivery_position",
271            detail: error.to_string(),
272        }
273    })
274}
275
276fn glob_to_anchored_regex(glob: &str) -> String {
277    let mut regex = String::with_capacity(glob.len() + 2);
278    regex.push('^');
279
280    let mut chars = glob.chars().peekable();
281    while let Some(character) = chars.next() {
282        match character {
283            '*' => regex.push_str(".*"),
284            '?' => regex.push('.'),
285            '[' => {
286                let mut class = String::new();
287                let mut closed = false;
288                let negated = matches!(chars.peek(), Some('!'));
289                if negated {
290                    let _ = chars.next();
291                    class.push('^');
292                }
293                for class_character in chars.by_ref() {
294                    if class_character == ']' {
295                        closed = true;
296                        break;
297                    }
298                    class.push(class_character);
299                }
300                if closed {
301                    regex.push('[');
302                    for (index, class_character) in class.chars().enumerate() {
303                        if !negated && index == 0 && class_character == '^' {
304                            regex.push_str("\\^");
305                        } else if class_character == '\\' {
306                            regex.push_str("\\\\");
307                        } else {
308                            regex.push(class_character);
309                        }
310                    }
311                    regex.push(']');
312                } else {
313                    regex.push_str("\\[");
314                    regex.push_str(&class);
315                }
316            }
317            '\\' | '^' | '$' | '.' | '+' | '(' | ')' | '|' | '{' | '}' => {
318                regex.push('\\');
319                regex.push(character);
320            }
321            other => regex.push(other),
322        }
323    }
324
325    regex.push('$');
326    regex
327}