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