Skip to main content

eventcore_postgres/
lib.rs

1//! PostgreSQL event store backend for EventCore.
2//!
3//! This crate provides a production-grade [`PostgresEventStore`] backed by PostgreSQL,
4//! with ACID transactions, advisory locks, and connection pooling via `sqlx`.
5//!
6//! Event immutability is enforced by PostgreSQL triggers. Stream pattern matching
7//! follows the conventions described in ADR-0047.
8//!
9//! `PostgresEventStore` implements [`EventStore`], [`EventReader`], [`CheckpointStore`],
10//! and [`ProjectorCoordinator`] from `eventcore-types`. Its normal migration
11//! creates the durable command-state snapshot table used by snapshot-enabled
12//! commands.
13//!
14//! The [`projections`] module adds a transactional read-model runner. It accepts
15//! separate event-source and PostgreSQL destination pools, applies an event and
16//! advances named progress in one destination transaction, and fences one
17//! writer with a session advisory lock. Its exactly-once claim is limited to
18//! acknowledged effect-plus-progress commits in that read-model database; it
19//! does not cover the event store or external side effects. See the crate
20//! README and [`projections::run_transactional_projection`] for setup and
21//! recovery guidance. The pre-existing `eventcore::Projector` API is unchanged.
22//!
23//! # Getting Started
24//!
25//! ```no_run
26//! use eventcore_postgres::PostgresEventStore;
27//!
28//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
29//! let store = PostgresEventStore::new("postgres://user:pass@localhost/mydb").await?;
30//! # Ok(())
31//! # }
32//! ```
33
34use std::{collections::HashMap, time::Duration};
35
36use eventcore_types::{
37    CheckpointStore, CommandStateReplayCheckpoint, CommandStateSnapshot, CommandStateSnapshotId,
38    Event, EventFilter, EventPage, EventReader, EventStore, EventStoreError, EventStream,
39    EventStreamSlice, Operation, ProjectorCoordinator, StreamId, StreamPosition, StreamVersion,
40    StreamWrites,
41};
42use futures::StreamExt;
43use nutype::nutype;
44use serde_json::value::RawValue;
45use serde_json::{Value, json};
46use sqlx::types::Json;
47use sqlx::{Pool, Postgres, QueryBuilder, Row, postgres::PgPoolOptions, query};
48use thiserror::Error;
49use tracing::{error, info, instrument, warn};
50use uuid::Uuid;
51
52pub mod projections;
53pub use projections::{
54    AfterCommit, BoxedProjectionError, NoopAfterCommit, PostgresProjectionConfig,
55    PostgresProjectionMode, PostgresProjectionReset, PostgresProjectionSource,
56    PostgresProjectionSourceError, PostgresProjectionStore, PostgresProjector,
57    ProjectionConfigurationError, ProjectionFailureContext, ProjectionFailureDecision,
58    ProjectionPollSleeper, ProjectionProgress, ProjectionResetAndReplayError, ProjectionResetError,
59    ProjectionRetryPolicy, ProjectionRetrySleeper, ProjectionRunOutcome,
60    TokioProjectionPollSleeper, TokioProjectionRetrySleeper, TransactionalProjectionError,
61    reset_and_replay_transactional_projection, reset_transactional_projection,
62    run_transactional_projection,
63};
64
65/// Errors that can occur when creating a [`PostgresEventStore`].
66#[derive(Debug, Error)]
67pub enum PostgresEventStoreError {
68    /// Failed to create the postgres connection pool.
69    #[error("failed to create postgres connection pool")]
70    ConnectionFailed(#[source] sqlx::Error),
71}
72
73/// Maximum number of database connections in the pool.
74///
75/// MaxConnections represents the connection pool size limit. It must be at least 1,
76/// enforced by using NonZeroU32 as the underlying type.
77///
78/// # Examples
79///
80/// ```no_run
81/// use eventcore_postgres::MaxConnections;
82/// use std::num::NonZeroU32;
83///
84/// let small_pool = MaxConnections::new(NonZeroU32::new(5).expect("5 is non-zero"));
85/// let standard = MaxConnections::new(NonZeroU32::new(10).expect("10 is non-zero"));
86/// let large_pool = MaxConnections::new(NonZeroU32::new(50).expect("50 is non-zero"));
87///
88/// // Zero connections not allowed by type system
89/// // let zero = NonZeroU32::new(0); // Returns None
90/// ```
91#[nutype(derive(Debug, Clone, Copy, PartialEq, Eq, Display, AsRef, Into))]
92pub struct MaxConnections(std::num::NonZeroU32);
93
94/// Configuration for PostgresEventStore connection pool.
95#[derive(Debug, Clone)]
96pub struct PostgresConfig {
97    /// Maximum number of connections in the pool (default: 10)
98    pub max_connections: MaxConnections,
99    /// Timeout for acquiring a connection from the pool (default: 30 seconds)
100    pub acquire_timeout: Duration,
101    /// Idle timeout for connections in the pool (default: 10 minutes)
102    pub idle_timeout: Duration,
103}
104
105impl Default for PostgresConfig {
106    fn default() -> Self {
107        const DEFAULT_MAX_CONNECTIONS: std::num::NonZeroU32 = match std::num::NonZeroU32::new(10) {
108            Some(v) => v,
109            None => unreachable!(),
110        };
111
112        Self {
113            max_connections: MaxConnections::new(DEFAULT_MAX_CONNECTIONS),
114            acquire_timeout: Duration::from_secs(30),
115            idle_timeout: Duration::from_secs(600), // 10 minutes
116        }
117    }
118}
119
120/// PostgreSQL-backed event store implementing EventCore's storage traits.
121///
122/// Provides atomic multi-stream event writes with optimistic concurrency control,
123/// advisory locks for projector coordination, and connection pooling.
124///
125/// Use [`PostgresEventStore::new`] (or [`PostgresEventStore::with_config`]) with a
126/// PostgreSQL connection URL to create an instance.
127#[derive(Debug, Clone)]
128pub struct PostgresEventStore {
129    pool: Pool<Postgres>,
130}
131
132impl PostgresEventStore {
133    /// Create a new PostgresEventStore with default configuration.
134    pub async fn new<S: Into<String>>(
135        connection_string: S,
136    ) -> Result<Self, PostgresEventStoreError> {
137        Self::with_config(connection_string, PostgresConfig::default()).await
138    }
139
140    /// Create a new PostgresEventStore with custom configuration.
141    pub async fn with_config<S: Into<String>>(
142        connection_string: S,
143        config: PostgresConfig,
144    ) -> Result<Self, PostgresEventStoreError> {
145        let connection_string = connection_string.into();
146        let max_connections: std::num::NonZeroU32 = config.max_connections.into();
147        let pool = PgPoolOptions::new()
148            .max_connections(max_connections.get())
149            .acquire_timeout(config.acquire_timeout)
150            .idle_timeout(config.idle_timeout)
151            .connect(&connection_string)
152            .await
153            .map_err(PostgresEventStoreError::ConnectionFailed)?;
154        Ok(Self { pool })
155    }
156
157    /// Create a PostgresEventStore from an existing connection pool.
158    ///
159    /// Use this when you need full control over pool configuration or want to
160    /// share a pool across multiple components.
161    pub fn from_pool(pool: Pool<Postgres>) -> Self {
162        Self { pool }
163    }
164
165    /// Verify connectivity by issuing a trivial `SELECT 1` query.
166    ///
167    /// # Panics
168    ///
169    /// Panics if the database is unreachable or the query fails.
170    #[cfg_attr(test, mutants::skip)] // infallible: panics on failure
171    pub async fn ping(&self) {
172        let _ = query("SELECT 1")
173            .execute(&self.pool)
174            .await
175            .expect("postgres ping failed");
176    }
177
178    /// Apply the bundled schema migrations via `sqlx::migrate!("./migrations")`.
179    ///
180    /// This creates the tables EventCore requires, including the
181    /// `eventcore_subscription_versions` table used by [`PostgresCheckpointStore`].
182    ///
183    /// # Panics
184    ///
185    /// Panics if applying the migrations fails.
186    #[cfg_attr(test, mutants::skip)] // infallible: panics on failure
187    pub async fn migrate(&self) {
188        sqlx::migrate!("./migrations")
189            .run(&self.pool)
190            .await
191            .expect("postgres migration failed");
192    }
193}
194
195impl EventStore for PostgresEventStore {
196    #[instrument(name = "postgres.read_stream", skip(self))]
197    async fn read_stream<E: Event>(
198        &self,
199        stream_id: StreamId,
200    ) -> Result<EventStream<E>, EventStoreError> {
201        info!(
202            stream = %stream_id,
203            "[postgres.read_stream] reading events from postgres"
204        );
205
206        // Clone the pool (an `Arc` internally) so the returned stream owns its
207        // connection handle and can be `'static`. The query uses sqlx's lazy
208        // `fetch`, which pulls rows from the database incrementally rather than
209        // buffering the entire result set with `fetch_all` — the real memory
210        // win behind #364 for large streams.
211        let pool = self.pool.clone();
212
213        let stream = async_stream::stream! {
214            let mut rows = query(
215                "SELECT event_data FROM eventcore_events WHERE stream_id = $1 ORDER BY stream_version ASC",
216            )
217            .bind(stream_id.as_ref())
218            .fetch(&pool);
219
220            while let Some(row) = rows.next().await {
221                let row = match row {
222                    Ok(row) => row,
223                    Err(error) => {
224                        yield Err(map_sqlx_error(error, Operation::ReadStream));
225                        break;
226                    }
227                };
228
229                let payload: Value = match row.try_get("event_data") {
230                    Ok(payload) => payload,
231                    Err(error) => {
232                        yield Err(map_sqlx_error(error, Operation::ReadStream));
233                        break;
234                    }
235                };
236
237                // A per-row decode failure surfaces as an `Err` item, matching
238                // the previous behavior where a type mismatch failed the read
239                // (see the read_stream_errors_on_type_mismatch contract test).
240                match serde_json::from_value::<E>(payload) {
241                    Ok(event) => yield Ok(event),
242                    Err(error) => {
243                        yield Err(EventStoreError::DeserializationFailed {
244                            stream_id: stream_id.clone(),
245                            detail: error.to_string(),
246                        });
247                        break;
248                    }
249                }
250            }
251        };
252
253        Ok(EventStream::new(stream))
254    }
255
256    #[instrument(name = "postgres.read_stream_after", skip(self))]
257    async fn read_stream_after<E: Event>(
258        &self,
259        stream_id: StreamId,
260        exclusive_version: StreamVersion,
261    ) -> Result<EventStream<E>, EventStoreError> {
262        let pool = self.pool.clone();
263        let version: usize = exclusive_version.into();
264
265        let stream = async_stream::stream! {
266            let mut rows = query(
267                "SELECT event_data FROM eventcore_events \
268                 WHERE stream_id = $1 AND stream_version > $2 \
269                 ORDER BY stream_version ASC",
270            )
271            .bind(stream_id.as_ref())
272            .bind(version as i64)
273            .fetch(&pool);
274
275            while let Some(row) = rows.next().await {
276                let row = match row {
277                    Ok(row) => row,
278                    Err(error) => {
279                        yield Err(map_sqlx_error(error, Operation::ReadStream));
280                        break;
281                    }
282                };
283
284                let payload: Value = match row.try_get("event_data") {
285                    Ok(payload) => payload,
286                    Err(error) => {
287                        yield Err(map_sqlx_error(error, Operation::ReadStream));
288                        break;
289                    }
290                };
291
292                match serde_json::from_value::<E>(payload) {
293                    Ok(event) => yield Ok(event),
294                    Err(error) => {
295                        yield Err(EventStoreError::DeserializationFailed {
296                            stream_id: stream_id.clone(),
297                            detail: error.to_string(),
298                        });
299                        break;
300                    }
301                }
302            }
303        };
304
305        Ok(EventStream::new(stream))
306    }
307
308    #[instrument(name = "postgres.append_events", skip(self, writes))]
309    async fn append_events(
310        &self,
311        writes: StreamWrites,
312    ) -> Result<EventStreamSlice, EventStoreError> {
313        let expected_versions = writes.expected_versions().clone();
314        let entries = writes.into_entries();
315
316        if entries.is_empty() {
317            return Ok(EventStreamSlice);
318        }
319
320        info!(
321            stream_count = expected_versions.len(),
322            event_count = entries.len(),
323            "[postgres.append_events] appending events to postgres"
324        );
325
326        // Build expected versions JSON for the trigger
327        let expected_versions_json: Value = expected_versions
328            .iter()
329            .map(|(stream_id, version)| {
330                (stream_id.as_ref().to_string(), json!(version.into_inner()))
331            })
332            .collect();
333
334        let mut tx = self
335            .pool
336            .begin()
337            .await
338            .map_err(|error| map_sqlx_error(error, Operation::BeginTransaction))?;
339
340        validate_expected_versions(&mut tx, &expected_versions).await?;
341
342        // Set expected versions in session config for trigger validation
343        let _ = query("SELECT set_config('eventcore.expected_versions', $1, true)")
344            .bind(expected_versions_json.to_string())
345            .execute(&mut *tx)
346            .await
347            .map_err(|error| map_sqlx_error(error, Operation::SetExpectedVersions))?;
348
349        // Insert all events with a single multi-row INSERT per chunk. The
350        // BEFORE INSERT trigger still assigns gap-free stream versions and
351        // enforces optimistic concurrency for each row in VALUES order,
352        // exactly as it did for the previous per-event loop — replacing N
353        // round-trips with one statement. Chunking keeps the bound-parameter
354        // count well under Postgres' 65535-parameter limit (5 binds/event).
355        // Drop the type-erased event payload (only the in-memory store needs
356        // it) and keep just the Send + Sync fields used for SQL binding, so the
357        // borrows held by the insert loop stay Send across the awaits.
358        let rows: Vec<(StreamId, &'static str, Box<RawValue>)> = entries
359            .into_iter()
360            .map(|entry| (entry.stream_id, entry.event_type, entry.event_data))
361            .collect();
362
363        const MAX_EVENTS_PER_INSERT: usize = 1000;
364        for chunk in rows.chunks(MAX_EVENTS_PER_INSERT) {
365            let mut builder = QueryBuilder::<Postgres>::new(
366                "INSERT INTO eventcore_events (event_id, stream_id, event_type, event_data, metadata) ",
367            );
368            let _ = builder.push_values(chunk, |mut row, (stream_id, event_type, event_data)| {
369                let _ = row
370                    .push_bind(Uuid::now_v7())
371                    .push_bind(stream_id.as_ref())
372                    .push_bind(*event_type)
373                    .push_bind(Json(event_data))
374                    .push_bind(Json(json!({})));
375            });
376            let _ = builder
377                .build()
378                .execute(&mut *tx)
379                .await
380                .map_err(|error| map_sqlx_error(error, Operation::AppendEvents))?;
381        }
382
383        tx.commit()
384            .await
385            .map_err(|error| map_sqlx_error(error, Operation::CommitTransaction))?;
386
387        Ok(EventStreamSlice)
388    }
389
390    async fn load_command_state_snapshot(
391        &self,
392        snapshot_id: CommandStateSnapshotId,
393    ) -> Result<Option<CommandStateSnapshot>, EventStoreError> {
394        let row = query(
395            "SELECT state, stream_versions, replay_checkpoints \
396             FROM eventcore_command_state_snapshots \
397             WHERE snapshot_id = $1",
398        )
399        .bind(snapshot_id.as_ref())
400        .fetch_optional(&self.pool)
401        .await
402        .map_err(|error| map_sqlx_error(error, Operation::ReadStream))?;
403
404        row.map(|row| {
405            let state: Json<Value> = row
406                .try_get("state")
407                .map_err(|error| map_sqlx_error(error, Operation::ReadStream))?;
408            let stream_versions: Json<HashMap<StreamId, StreamVersion>> = row
409                .try_get("stream_versions")
410                .map_err(|error| map_sqlx_error(error, Operation::ReadStream))?;
411            let replay_checkpoints: Json<Vec<CommandStateReplayCheckpoint>> = row
412                .try_get("replay_checkpoints")
413                .map_err(|error| map_sqlx_error(error, Operation::ReadStream))?;
414
415            Ok(CommandStateSnapshot::new(state.0, stream_versions.0)
416                .with_replay_checkpoints(replay_checkpoints.0))
417        })
418        .transpose()
419    }
420
421    async fn save_command_state_snapshot(
422        &self,
423        snapshot_id: CommandStateSnapshotId,
424        snapshot: CommandStateSnapshot,
425    ) -> Result<(), EventStoreError> {
426        // The conflict predicate is evaluated while PostgreSQL holds the
427        // conflicting row lock. This means concurrent refreshes can never let
428        // a stale vector replace one that includes more events.
429        let _ = query(
430            "INSERT INTO eventcore_command_state_snapshots \
431                 (snapshot_id, state, stream_versions, replay_checkpoints, updated_at) \
432             VALUES ($1, $2, $3, $4, NOW()) \
433             ON CONFLICT (snapshot_id) DO UPDATE \
434             SET state = EXCLUDED.state, \
435                 stream_versions = EXCLUDED.stream_versions, \
436                 replay_checkpoints = EXCLUDED.replay_checkpoints, \
437                 updated_at = NOW() \
438             WHERE NOT EXISTS ( \
439                 SELECT 1 \
440                 FROM jsonb_each_text(eventcore_command_state_snapshots.stream_versions) \
441                     AS current(stream_id, stream_version) \
442                 WHERE NOT (EXCLUDED.stream_versions ? current.stream_id) \
443                    OR (EXCLUDED.stream_versions ->> current.stream_id)::BIGINT \
444                       < current.stream_version::BIGINT \
445             )",
446        )
447        .bind(snapshot_id.as_ref())
448        .bind(Json(snapshot.state))
449        .bind(Json(snapshot.stream_versions))
450        .bind(Json(snapshot.replay_checkpoints))
451        .execute(&self.pool)
452        .await
453        .map_err(|error| map_sqlx_error(error, Operation::AppendEvents))?;
454
455        Ok(())
456    }
457}
458
459impl CheckpointStore for PostgresEventStore {
460    type Error = PostgresCheckpointError;
461
462    async fn load(&self, name: &str) -> Result<Option<StreamPosition>, Self::Error> {
463        let row = query("SELECT last_position FROM eventcore_subscription_versions WHERE subscription_name = $1")
464            .bind(name)
465            .fetch_optional(&self.pool)
466            .await
467            .map_err(PostgresCheckpointError::DatabaseError)?;
468
469        match row {
470            Some(row) => {
471                let position: Uuid = row.get("last_position");
472                Ok(Some(StreamPosition::new(position)))
473            }
474            None => Ok(None),
475        }
476    }
477
478    async fn save(&self, name: &str, position: StreamPosition) -> Result<(), Self::Error> {
479        let position_uuid: Uuid = position.into_inner();
480        let _ = query(
481            "INSERT INTO eventcore_subscription_versions (subscription_name, last_position, updated_at)
482             VALUES ($1, $2, NOW())
483             ON CONFLICT (subscription_name) DO UPDATE SET last_position = $2, updated_at = NOW()",
484        )
485        .bind(name)
486        .bind(position_uuid)
487        .execute(&self.pool)
488        .await
489        .map_err(PostgresCheckpointError::DatabaseError)?;
490
491        Ok(())
492    }
493}
494
495/// Locks and validates every stream declared by an append before any event rows
496/// are inserted. This includes read-only command participants and empty streams.
497///
498/// Acquiring transaction-scoped advisory locks in stream-id order means concurrent
499/// appends serialize their checks for every participating stream without creating
500/// a lock-order deadlock. The locks are held until commit or rollback, so a stream
501/// cannot advance after its version has been validated and before this append
502/// commits. The existing trigger remains responsible for gap-free version
503/// assignment within each emitted stream.
504async fn validate_expected_versions(
505    tx: &mut sqlx::Transaction<'_, Postgres>,
506    expected_versions: &HashMap<StreamId, StreamVersion>,
507) -> Result<(), EventStoreError> {
508    let mut expected_streams: Vec<_> = expected_versions.iter().collect();
509    expected_streams.sort_unstable_by(|(left, _), (right, _)| left.as_ref().cmp(right.as_ref()));
510
511    for (stream_id, _) in &expected_streams {
512        let _ = query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
513            .bind(stream_id.as_ref())
514            .execute(&mut **tx)
515            .await
516            .map_err(|error| map_sqlx_error(error, Operation::AppendEvents))?;
517    }
518
519    for (stream_id, expected_version) in expected_streams {
520        let current_version: i64 = sqlx::query_scalar(
521            "SELECT COALESCE(MAX(stream_version), 0) FROM eventcore_events WHERE stream_id = $1",
522        )
523        .bind(stream_id.as_ref())
524        .fetch_one(&mut **tx)
525        .await
526        .map_err(|error| map_sqlx_error(error, Operation::AppendEvents))?;
527        let current_version =
528            usize::try_from(current_version).map_err(|_| EventStoreError::StoreFailure {
529                operation: Operation::AppendEvents,
530            })?;
531        let actual = StreamVersion::new(current_version);
532
533        if actual != *expected_version {
534            warn!(
535                stream = %stream_id,
536                expected = expected_version.into_inner(),
537                actual = actual.into_inner(),
538                "[postgres.version_conflict] optimistic concurrency check failed"
539            );
540            return Err(EventStoreError::VersionConflict {
541                stream_id: stream_id.clone(),
542                expected: *expected_version,
543                actual,
544            });
545        }
546    }
547
548    Ok(())
549}
550
551impl EventReader for PostgresEventStore {
552    type Error = EventStoreError;
553
554    async fn read_events<E: Event>(
555        &self,
556        filter: EventFilter,
557        page: EventPage,
558    ) -> Result<Vec<(E, StreamPosition)>, Self::Error> {
559        // Query events ordered by event_id (UUID7, monotonically increasing).
560        // Use event_id directly as the global position - no need for ROW_NUMBER.
561        let after_event_id: Option<Uuid> = page.after_position().map(|p| p.into_inner());
562        let limit: i64 = page.limit().into_inner() as i64;
563
564        // Filter by event_type in SQL so non-matching types don't consume
565        // batch slots (fixes issue #372). Use explicit filter if set,
566        // otherwise derive from E::event_type_name().
567        let type_filter = filter.event_type().unwrap_or_else(|| E::event_type_name());
568
569        // Glob pattern pushdown translates the pattern to an anchored POSIX
570        // regex matched with the `~` operator (ADR-0047). The translation
571        // escapes all regex metacharacters in literal segments so user input
572        // cannot inject regex syntax.
573        let pattern_regex = filter
574            .stream_pattern()
575            .map(|p| glob_to_anchored_regex(p.as_ref()));
576
577        // Build the query dynamically so prefix XOR pattern, the optional
578        // cursor, and the event_type predicate compose without a combinatorial
579        // explosion of hand-written query strings.
580        let mut builder = QueryBuilder::<Postgres>::new(
581            "SELECT event_id, event_data, stream_id FROM eventcore_events WHERE event_type = ",
582        );
583        let _ = builder.push_bind(type_filter);
584
585        if let Some(after_id) = after_event_id {
586            let _ = builder.push(" AND event_id > ").push_bind(after_id);
587        }
588
589        if let Some(prefix) = filter.stream_prefix() {
590            let _ = builder
591                .push(" AND stream_id LIKE ")
592                .push_bind(prefix.as_ref().to_string())
593                .push(" || '%'");
594        } else if let Some(regex) = pattern_regex {
595            let _ = builder.push(" AND stream_id ~ ").push_bind(regex);
596        }
597
598        let _ = builder.push(" ORDER BY event_id LIMIT ").push_bind(limit);
599
600        let rows = builder
601            .build()
602            .fetch_all(&self.pool)
603            .await
604            .map_err(|error| map_sqlx_error(error, Operation::ReadStream))?;
605
606        let events: Vec<(E, StreamPosition)> = rows
607            .into_iter()
608            .filter_map(|row| {
609                let event_data: Json<Value> = row.get("event_data");
610                let event_id: Uuid = row.get("event_id");
611                serde_json::from_value::<E>(event_data.0)
612                    .ok()
613                    .map(|e| (e, StreamPosition::new(event_id)))
614            })
615            .collect();
616
617        Ok(events)
618    }
619}
620
621/// Translate a POSIX glob pattern into an anchored POSIX regular expression
622/// suitable for PostgreSQL's `~` operator (ADR-0047).
623///
624/// Mapping:
625/// - `*` → `.*` (matches any sequence, including the `/` separator)
626/// - `?` → `.` (matches exactly one character)
627/// - `[...]` / `[!...]` → a regex character class (`[!` is normalized to the
628///   regex negation `[^`); the class contents are copied verbatim so ranges
629///   like `[0-9]` and `[a-z]` work, while a closing `]` ends the class
630/// - every other character is a literal and is regex-escaped
631///
632/// The result is anchored with `^...$` so the whole stream ID must match,
633/// mirroring `glob::Pattern::matches`. Because the pattern has already been
634/// validated as a compilable `glob::Pattern`, brackets are balanced; a stray
635/// `[` (impossible for a valid pattern) is treated as a literal.
636fn glob_to_anchored_regex(glob: &str) -> String {
637    let mut regex = String::with_capacity(glob.len() + 2);
638    regex.push('^');
639
640    let mut chars = glob.chars().peekable();
641    while let Some(c) = chars.next() {
642        match c {
643            '*' => regex.push_str(".*"),
644            '?' => regex.push('.'),
645            '[' => {
646                // Collect the bracket expression up to the closing ']'.
647                let mut class = String::new();
648                let mut closed = false;
649                if matches!(chars.peek(), Some('!')) {
650                    let _ = chars.next();
651                    class.push('^');
652                }
653                for inner in chars.by_ref() {
654                    if inner == ']' {
655                        closed = true;
656                        break;
657                    }
658                    class.push(inner);
659                }
660                if closed {
661                    regex.push('[');
662                    regex.push_str(&class);
663                    regex.push(']');
664                } else {
665                    // Not a real class (a validated glob never reaches here);
666                    // treat the '[' and collected chars as literals.
667                    regex.push_str(&regex_escape("["));
668                    regex.push_str(&regex_escape(&class));
669                }
670            }
671            other => regex.push_str(&regex_escape(&other.to_string())),
672        }
673    }
674
675    regex.push('$');
676    regex
677}
678
679/// Escape every POSIX-regex metacharacter in a literal segment so it matches
680/// itself, preventing regex injection from stream-pattern input.
681fn regex_escape(literal: &str) -> String {
682    const METACHARACTERS: &[char] = &[
683        '.', '^', '$', '*', '+', '?', '(', ')', '[', ']', '{', '}', '|', '\\',
684    ];
685    let mut escaped = String::with_capacity(literal.len());
686    for c in literal.chars() {
687        if METACHARACTERS.contains(&c) {
688            escaped.push('\\');
689        }
690        escaped.push(c);
691    }
692    escaped
693}
694
695fn map_sqlx_error(error: sqlx::Error, operation: Operation) -> EventStoreError {
696    if let sqlx::Error::Database(db_error) = &error {
697        let code = db_error.code();
698        let code_str = code.as_deref();
699        // P0001: Custom error from trigger (version_conflict)
700        // 23505: Unique constraint violation (fallback for version conflict)
701        if code_str == Some("P0001") || code_str == Some("23505") {
702            warn!(
703                error = %db_error,
704                "[postgres.version_conflict] optimistic concurrency check failed"
705            );
706            return parse_version_conflict_from_db_error(db_error.message());
707        }
708    }
709
710    error!(
711        error = %error,
712        operation = %operation,
713        "[postgres.database_error] database operation failed"
714    );
715    EventStoreError::StoreFailure { operation }
716}
717
718/// Parse version conflict details from the PostgreSQL trigger error message.
719///
720/// The trigger produces messages like:
721///   `version_conflict: stream "my-stream" expected version 0, actual 1`
722///
723/// If parsing fails, falls back to a VersionConflict with a sentinel stream_id
724/// indicating the details could not be extracted.
725fn parse_version_conflict_from_db_error(message: &str) -> EventStoreError {
726    // Pattern: version_conflict: stream "STREAM_ID" expected version EXPECTED, actual ACTUAL
727    if let Some(parsed) = try_parse_conflict_message(message) {
728        return parsed;
729    }
730
731    // Fallback: unique constraint violation (23505) or unparseable trigger message.
732    // Use a sentinel stream_id since we don't have the details.
733    let fallback_stream_id =
734        StreamId::try_new("unknown-conflict-stream").expect("static stream id is valid");
735    EventStoreError::VersionConflict {
736        stream_id: fallback_stream_id,
737        expected: StreamVersion::new(0),
738        actual: StreamVersion::new(0),
739    }
740}
741
742fn try_parse_conflict_message(message: &str) -> Option<EventStoreError> {
743    let rest = message.strip_prefix("version_conflict: stream \"")?;
744    let stream_end = rest.find('"')?;
745    let stream_id_str = &rest[..stream_end];
746    let after_stream = &rest[stream_end..];
747
748    let expected_str = after_stream
749        .strip_prefix("\" expected version ")?
750        .split(',')
751        .next()?;
752    let actual_str = after_stream.rsplit("actual ").next()?;
753
754    let expected = expected_str.trim().parse::<usize>().ok()?;
755    let actual = actual_str.trim().parse::<usize>().ok()?;
756    let stream_id = StreamId::try_new(stream_id_str).ok()?;
757
758    Some(EventStoreError::VersionConflict {
759        stream_id,
760        expected: StreamVersion::new(expected),
761        actual: StreamVersion::new(actual),
762    })
763}
764
765/// Error type for PostgresCheckpointStore operations.
766#[derive(Debug, Error)]
767pub enum PostgresCheckpointError {
768    /// Failed to create connection pool.
769    #[error("failed to create postgres connection pool")]
770    ConnectionFailed(#[source] sqlx::Error),
771
772    /// Database operation failed.
773    #[error("database operation failed: {0}")]
774    DatabaseError(#[source] sqlx::Error),
775}
776
777/// Postgres-backed checkpoint store for tracking projection progress.
778///
779/// `PostgresCheckpointStore` stores checkpoint positions in a PostgreSQL table,
780/// providing durability across process restarts. It implements the `CheckpointStore`
781/// trait from eventcore-types.
782///
783/// # Schema
784///
785/// The store uses the `eventcore_subscription_versions` table with:
786/// - `subscription_name`: Unique identifier for each projector/subscription
787/// - `last_position`: UUIDv7-shaped opaque cursor in this adapter's legacy
788///   delivery sequence; it is not a universal cross-stream commit order
789/// - `updated_at`: Timestamp of the last checkpoint update
790#[derive(Debug, Clone)]
791pub struct PostgresCheckpointStore {
792    pool: Pool<Postgres>,
793}
794
795impl PostgresCheckpointStore {
796    /// Create a new PostgresCheckpointStore with default configuration.
797    pub async fn new<S: Into<String>>(
798        connection_string: S,
799    ) -> Result<Self, PostgresCheckpointError> {
800        Self::with_config(connection_string, PostgresConfig::default()).await
801    }
802
803    /// Create a new PostgresCheckpointStore with custom configuration.
804    pub async fn with_config<S: Into<String>>(
805        connection_string: S,
806        config: PostgresConfig,
807    ) -> Result<Self, PostgresCheckpointError> {
808        let connection_string = connection_string.into();
809        let max_connections: std::num::NonZeroU32 = config.max_connections.into();
810        let pool = PgPoolOptions::new()
811            .max_connections(max_connections.get())
812            .acquire_timeout(config.acquire_timeout)
813            .idle_timeout(config.idle_timeout)
814            .connect(&connection_string)
815            .await
816            .map_err(PostgresCheckpointError::ConnectionFailed)?;
817
818        // Run migrations to ensure table exists
819        sqlx::migrate!("./migrations")
820            .run(&pool)
821            .await
822            .map_err(|e| {
823                PostgresCheckpointError::DatabaseError(sqlx::Error::Migrate(Box::new(e)))
824            })?;
825
826        Ok(Self { pool })
827    }
828
829    /// Create a PostgresCheckpointStore from an existing connection pool.
830    ///
831    /// Use this when you need full control over pool configuration or want to
832    /// share a pool across multiple components.
833    pub fn from_pool(pool: Pool<Postgres>) -> Self {
834        Self { pool }
835    }
836}
837
838impl CheckpointStore for PostgresCheckpointStore {
839    type Error = PostgresCheckpointError;
840
841    async fn load(&self, name: &str) -> Result<Option<StreamPosition>, Self::Error> {
842        let row = query("SELECT last_position FROM eventcore_subscription_versions WHERE subscription_name = $1")
843            .bind(name)
844            .fetch_optional(&self.pool)
845            .await
846            .map_err(PostgresCheckpointError::DatabaseError)?;
847
848        match row {
849            Some(row) => {
850                let position: Uuid = row.get("last_position");
851                Ok(Some(StreamPosition::new(position)))
852            }
853            None => Ok(None),
854        }
855    }
856
857    async fn save(&self, name: &str, position: StreamPosition) -> Result<(), Self::Error> {
858        let position_uuid: Uuid = position.into_inner();
859        let _ = query(
860            "INSERT INTO eventcore_subscription_versions (subscription_name, last_position, updated_at)
861             VALUES ($1, $2, NOW())
862             ON CONFLICT (subscription_name) DO UPDATE SET last_position = $2, updated_at = NOW()",
863        )
864        .bind(name)
865        .bind(position_uuid)
866        .execute(&self.pool)
867        .await
868        .map_err(PostgresCheckpointError::DatabaseError)?;
869
870        Ok(())
871    }
872}
873
874// ============================================================================
875// PostgresProjectorCoordinator - Distributed projector coordination via Postgres
876// ============================================================================
877
878/// Error type for projector coordination operations.
879#[derive(Debug, Error)]
880pub enum CoordinationError {
881    /// Leadership could not be acquired (another instance holds the lock).
882    #[error(
883        "leadership not acquired for subscription '{subscription_name}': another instance holds the lock"
884    )]
885    LeadershipNotAcquired { subscription_name: String },
886
887    /// Database operation failed.
888    #[error("database operation failed: {0}")]
889    DatabaseError(#[source] sqlx::Error),
890}
891
892/// Guard type that releases leadership when dropped.
893///
894/// Holds the advisory lock key and the actual database connection that acquired
895/// the lock. This is critical because PostgreSQL advisory locks are session-scoped:
896/// the unlock must happen on the same connection that acquired the lock.
897///
898/// # Lock Release Behavior
899///
900/// The guard attempts to explicitly release the advisory lock when dropped:
901///
902/// - **Multi-threaded runtime**: Uses `block_in_place` to synchronously release
903///   the lock before the guard is fully dropped.
904///
905/// - **Single-threaded runtime**: Spawns a task to release the lock asynchronously.
906///   This task may not execute before process shutdown, in which case the lock is
907///   released when the PostgreSQL session ends (connection closes).
908///
909/// # PostgreSQL Session-Scoped Locks
910///
911/// PostgreSQL advisory locks acquired with `pg_try_advisory_lock` are session-scoped
912/// and automatically released when the database connection closes. This provides a
913/// safety net: even if explicit unlock fails or is skipped, the lock will be released
914/// when:
915/// - The connection is returned to the pool and recycled
916/// - The connection pool is shut down
917/// - The database connection times out
918///
919/// For production deployments, configure appropriate connection pool idle timeouts
920/// to ensure timely lock release on ungraceful shutdown.
921pub struct CoordinationGuard {
922    lock_key: i64,
923    /// The actual connection that holds the advisory lock.
924    /// Must be Option so we can take ownership in Drop.
925    connection: Option<sqlx::pool::PoolConnection<Postgres>>,
926}
927
928impl std::fmt::Debug for CoordinationGuard {
929    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
930        f.debug_struct("CoordinationGuard")
931            .field("lock_key", &self.lock_key)
932            .finish_non_exhaustive()
933    }
934}
935
936impl Drop for CoordinationGuard {
937    fn drop(&mut self) {
938        // Take ownership of the connection - we need the same connection that acquired the lock
939        if let Some(mut connection) = self.connection.take() {
940            let lock_key = self.lock_key;
941
942            // Check runtime flavor to determine the appropriate unlock strategy.
943            // block_in_place panics on single-threaded runtimes, so we must check first.
944            let handle = tokio::runtime::Handle::current();
945            let is_multi_thread =
946                handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread;
947
948            if is_multi_thread {
949                // Multi-threaded runtime: use block_in_place for synchronous unlock
950                tokio::task::block_in_place(|| {
951                    handle.block_on(async {
952                        // Unlock on the SAME connection that acquired the lock
953                        if let Err(e) = query("SELECT pg_advisory_unlock($1)")
954                            .bind(lock_key)
955                            .execute(&mut *connection)
956                            .await
957                        {
958                            warn!(
959                                lock_key = lock_key,
960                                error = %e,
961                                "failed to release advisory lock on drop"
962                            );
963                        }
964                        // Connection is returned to pool when dropped here
965                    });
966                });
967            } else {
968                // Single-threaded runtime: spawn a task for async unlock.
969                // Note: This task may not execute before process shutdown. In that case,
970                // the advisory lock is released when the PostgreSQL session ends (the
971                // connection closes). See struct-level documentation for details.
972                drop(tokio::spawn(async move {
973                    if let Err(e) = query("SELECT pg_advisory_unlock($1)")
974                        .bind(lock_key)
975                        .execute(&mut *connection)
976                        .await
977                    {
978                        warn!(
979                            lock_key = lock_key,
980                            error = %e,
981                            "failed to release advisory lock on drop (async)"
982                        );
983                    }
984                }));
985            }
986        }
987    }
988}
989
990/// Postgres-backed projector coordinator for distributed leadership.
991///
992/// `PostgresProjectorCoordinator` uses PostgreSQL advisory locks to ensure
993/// only one projector instance processes events for a given subscription
994/// at a time, preventing duplicate processing in distributed deployments.
995#[derive(Debug, Clone)]
996pub struct PostgresProjectorCoordinator {
997    pool: Pool<Postgres>,
998}
999
1000impl PostgresProjectorCoordinator {
1001    /// Create a new PostgresProjectorCoordinator with default configuration.
1002    pub async fn new<S: Into<String>>(connection_string: S) -> Result<Self, CoordinationError> {
1003        Self::with_config(connection_string, PostgresConfig::default()).await
1004    }
1005
1006    /// Create a new PostgresProjectorCoordinator with custom configuration.
1007    pub async fn with_config<S: Into<String>>(
1008        connection_string: S,
1009        config: PostgresConfig,
1010    ) -> Result<Self, CoordinationError> {
1011        let connection_string = connection_string.into();
1012        let max_connections: std::num::NonZeroU32 = config.max_connections.into();
1013        let pool = PgPoolOptions::new()
1014            .max_connections(max_connections.get())
1015            .acquire_timeout(config.acquire_timeout)
1016            .idle_timeout(config.idle_timeout)
1017            .connect(&connection_string)
1018            .await
1019            .map_err(CoordinationError::DatabaseError)?;
1020
1021        Ok(Self { pool })
1022    }
1023
1024    /// Create a PostgresProjectorCoordinator from an existing connection pool.
1025    pub fn from_pool(pool: Pool<Postgres>) -> Self {
1026        Self { pool }
1027    }
1028}
1029
1030/// Compute a stable FNV-1a hash of the subscription name to derive an advisory lock key.
1031///
1032/// This uses the FNV-1a algorithm (64-bit) which produces deterministic output
1033/// across Rust versions, unlike `DefaultHasher` which is explicitly not stable.
1034fn advisory_lock_key(subscription_name: &str) -> i64 {
1035    const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
1036    const FNV_PRIME: u64 = 0x00000100000001B3;
1037
1038    let mut hash = FNV_OFFSET_BASIS;
1039    for byte in subscription_name.as_bytes() {
1040        hash ^= *byte as u64;
1041        hash = hash.wrapping_mul(FNV_PRIME);
1042    }
1043    hash as i64
1044}
1045
1046/// Try to acquire a PostgreSQL advisory lock for the given subscription name
1047/// using the provided connection pool.
1048async fn try_acquire_advisory_lock(
1049    pool: &Pool<Postgres>,
1050    subscription_name: &str,
1051) -> Result<CoordinationGuard, CoordinationError> {
1052    let lock_key = advisory_lock_key(subscription_name);
1053
1054    // Acquire a dedicated connection from the pool.
1055    // This connection MUST be kept for the lifetime of the guard because
1056    // PostgreSQL advisory locks are session-scoped.
1057    let mut connection = pool
1058        .acquire()
1059        .await
1060        .map_err(CoordinationError::DatabaseError)?;
1061
1062    // Attempt to acquire advisory lock (non-blocking) on this specific connection
1063    let row = query("SELECT pg_try_advisory_lock($1)")
1064        .bind(lock_key)
1065        .fetch_one(&mut *connection)
1066        .await
1067        .map_err(CoordinationError::DatabaseError)?;
1068
1069    let acquired: bool = row.get(0);
1070
1071    if acquired {
1072        Ok(CoordinationGuard {
1073            lock_key,
1074            connection: Some(connection),
1075        })
1076    } else {
1077        // Lock not acquired - connection will be returned to pool here
1078        Err(CoordinationError::LeadershipNotAcquired {
1079            subscription_name: subscription_name.to_string(),
1080        })
1081    }
1082}
1083
1084impl ProjectorCoordinator for PostgresProjectorCoordinator {
1085    type Error = CoordinationError;
1086    type Guard = CoordinationGuard;
1087
1088    async fn try_acquire(&self, subscription_name: &str) -> Result<Self::Guard, Self::Error> {
1089        try_acquire_advisory_lock(&self.pool, subscription_name).await
1090    }
1091}
1092
1093impl ProjectorCoordinator for PostgresEventStore {
1094    type Error = CoordinationError;
1095    type Guard = CoordinationGuard;
1096
1097    async fn try_acquire(&self, subscription_name: &str) -> Result<Self::Guard, Self::Error> {
1098        try_acquire_advisory_lock(&self.pool, subscription_name).await
1099    }
1100}
1101
1102#[cfg(test)]
1103#[path = "lib.test.rs"]
1104mod tests;