Skip to main content

drasi_source_postgres/
lib.rs

1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15#![allow(unexpected_cfgs)]
16
17//! PostgreSQL Replication Source Plugin for Drasi
18//!
19//! This plugin captures data changes from PostgreSQL databases using logical replication.
20//! It connects to PostgreSQL as a replication client and decodes Write-Ahead Log (WAL)
21//! messages in real-time, converting them to Drasi source change events.
22//!
23//! # Prerequisites
24//!
25//! Before using this source, you must configure PostgreSQL for logical replication:
26//!
27//! 1. **Enable logical replication** in `postgresql.conf`:
28//!    ```text
29//!    wal_level = logical
30//!    max_replication_slots = 10
31//!    max_wal_senders = 10
32//!    ```
33//!
34//! 2. **Create a publication** for the tables you want to monitor:
35//!    ```sql
36//!    CREATE PUBLICATION drasi_publication FOR TABLE users, orders;
37//!    ```
38//!
39//! 3. **Create a replication slot** (optional - the source can create one automatically):
40//!    ```sql
41//!    SELECT pg_create_logical_replication_slot('drasi_slot', 'pgoutput');
42//!    ```
43//!
44//! 4. **Grant replication permissions** to the database user:
45//!    ```sql
46//!    ALTER ROLE drasi_user REPLICATION;
47//!    GRANT SELECT ON TABLE users, orders TO drasi_user;
48//!    ```
49//!
50//! # Architecture
51//!
52//! The source has two main components:
53//!
54//! - **Bootstrap Handler**: Performs an initial snapshot of table data when a query
55//!   subscribes with bootstrap enabled. Uses the replication slot's snapshot LSN to
56//!   ensure consistency.
57//!
58//! - **Streaming Handler**: Continuously reads WAL messages and decodes them using
59//!   the `pgoutput` protocol. Handles INSERT, UPDATE, and DELETE operations.
60//!
61//! # Configuration
62//!
63//! | Field | Type | Default | Description |
64//! |-------|------|---------|-------------|
65//! | `host` | string | `"localhost"` | PostgreSQL host |
66//! | `port` | u16 | `5432` | PostgreSQL port |
67//! | `database` | string | *required* | Database name |
68//! | `user` | string | *required* | Database user (must have replication permission) |
69//! | `password` | string | `""` | Database password |
70//! | `tables` | string[] | `[]` | Tables to replicate |
71//! | `slot_name` | string | `"drasi_slot"` | Replication slot name |
72//! | `publication_name` | string | `"drasi_publication"` | Publication name |
73//! | `ssl_mode` | string | `"prefer"` | SSL mode: disable, prefer, require |
74//! | `table_keys` | TableKeyConfig[] | `[]` | Primary key configuration for tables |
75//!
76//! # Example Configuration (YAML)
77//!
78//! ```yaml
79//! source_type: postgres
80//! properties:
81//!   host: db.example.com
82//!   port: 5432
83//!   database: production
84//!   user: replication_user
85//!   password: secret
86//!   tables:
87//!     - users
88//!     - orders
89//!   slot_name: drasi_slot
90//!   publication_name: drasi_publication
91//!   table_keys:
92//!     - table: users
93//!       key_columns: [id]
94//!     - table: orders
95//!       key_columns: [order_id]
96//! ```
97//!
98//! # Data Format
99//!
100//! The PostgreSQL source decodes WAL messages and converts them to Drasi source changes.
101//! Each row change is mapped as follows:
102//!
103//! ## Node Mapping
104//!
105//! - **Element ID**: `{schema}:{table}:{primary_key_value}` (e.g., `public:users:123`)
106//! - **Labels**: `[{table_name}]` (e.g., `["users"]`)
107//! - **Properties**: All columns from the row (column names become property keys)
108//!
109//! ## WAL Message to SourceChange
110//!
111//! | WAL Operation | SourceChange |
112//! |---------------|--------------|
113//! | INSERT | `SourceChange::Insert { element: Node }` |
114//! | UPDATE | `SourceChange::Update { element: Node }` |
115//! | DELETE | `SourceChange::Delete { metadata }` |
116//!
117//! ## Example Mapping
118//!
119//! Given a PostgreSQL table:
120//!
121//! ```sql
122//! CREATE TABLE users (
123//!     id SERIAL PRIMARY KEY,
124//!     name VARCHAR(100),
125//!     email VARCHAR(255),
126//!     age INTEGER
127//! );
128//!
129//! INSERT INTO users (name, email, age) VALUES ('Alice', 'alice@example.com', 30);
130//! ```
131//!
132//! Produces a SourceChange equivalent to:
133//!
134//! ```json
135//! {
136//!     "type": "Insert",
137//!     "element": {
138//!         "metadata": {
139//!             "element_id": "public:users:1",
140//!             "source_id": "pg-source",
141//!             "labels": ["users"],
142//!             "effective_from": 1699900000000000
143//!         },
144//!         "properties": {
145//!             "id": 1,
146//!             "name": "Alice",
147//!             "email": "alice@example.com",
148//!             "age": 30
149//!         }
150//!     }
151//! }
152//! ```
153//!
154//! # Usage Example
155//!
156//! ```rust,no_run
157//! use drasi_source_postgres::{PostgresReplicationSource, PostgresSourceBuilder};
158//! use std::sync::Arc;
159//!
160//! # fn main() -> anyhow::Result<()> {
161//! let source = PostgresReplicationSource::builder("pg-source")
162//!     .with_host("db.example.com")
163//!     .with_database("production")
164//!     .with_user("replication_user")
165//!     .with_password("secret")
166//!     .with_tables(vec!["users".to_string(), "orders".to_string()])
167//!     .build()?;
168//! # Ok(())
169//! # }
170//! ```
171
172pub mod config;
173pub mod connection;
174pub mod decoder;
175pub mod descriptor;
176pub mod protocol;
177pub mod scram;
178pub mod stream;
179pub mod types;
180
181pub use config::{PostgresSourceConfig, SslMode, TableKeyConfig};
182
183use anyhow::{anyhow, Context, Result};
184use async_trait::async_trait;
185use drasi_lib::schema::{
186    normalize_table_label, NodeSchema, PropertySchema, PropertyType, SourceSchema,
187};
188use log::{debug, error, info};
189use postgres_native_tls::MakeTlsConnector;
190use std::collections::HashMap;
191use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
192use std::sync::Arc;
193use tokio::sync::{oneshot, Mutex};
194
195use drasi_lib::channels::{ComponentStatus, DispatchMode, *};
196use drasi_lib::sources::base::{SourceBase, SourceBaseParams};
197use drasi_lib::{Source, SourceError};
198use tracing::Instrument;
199
200/// Shared state tracking WAL progress for subscribe/rewind decisions.
201///
202/// The replication stream atomically publishes `read_lsn` (the highest WAL LSN
203/// received so far). The `subscribe()` method reads this to decide whether a
204/// new subscriber can be served from the current stream position or if a rewind
205/// is needed.
206pub(crate) struct ReplayState {
207    pub(crate) read_lsn: AtomicU64,
208    /// Maximum `flush_lsn` that `send_feedback()` may report to PostgreSQL.
209    ///
210    /// Set to the replay start LSN during `pause_replication_for_restart()` to
211    /// prevent the slot's `restart_lsn` from advancing past the position that
212    /// other (not-yet-subscribed) queries will need.  `u64::MAX` means "no
213    /// fence" (normal operation).
214    pub(crate) flush_fence_lsn: AtomicU64,
215    /// UNIX epoch seconds when the fence was last set. Used for auto-clearing
216    /// the fence after [`FENCE_TIMEOUT_SECS`] as a safety fallback.
217    pub(crate) fence_set_epoch_secs: AtomicU64,
218}
219
220/// Duration (in seconds) after which the flush fence auto-clears.
221///
222/// This is a safety fallback for deployments where no explicit
223/// `on_subscriptions_complete()` signal arrives (e.g., FFI plugin usage).
224/// During normal startup all queries subscribe within a few seconds; the
225/// timeout must be generous enough to cover slow-starting deployments.
226const FENCE_TIMEOUT_SECS: u64 = 60;
227
228impl Default for ReplayState {
229    fn default() -> Self {
230        Self {
231            read_lsn: AtomicU64::new(0),
232            flush_fence_lsn: AtomicU64::new(u64::MAX),
233            fence_set_epoch_secs: AtomicU64::new(0),
234        }
235    }
236}
237
238impl ReplayState {
239    fn current_read_lsn(&self) -> u64 {
240        self.read_lsn.load(Ordering::Acquire)
241    }
242
243    /// Set the flush fence to prevent `send_feedback()` from advancing
244    /// `flush_lsn` past `lsn`.
245    fn set_flush_fence(&self, lsn: u64) {
246        use std::time::{SystemTime, UNIX_EPOCH};
247        let now_secs = SystemTime::now()
248            .duration_since(UNIX_EPOCH)
249            .unwrap_or_default()
250            .as_secs();
251        self.flush_fence_lsn.store(lsn, Ordering::Release);
252        self.fence_set_epoch_secs.store(now_secs, Ordering::Release);
253    }
254
255    /// Clear the flush fence, allowing normal `flush_lsn` advancement.
256    fn clear_flush_fence(&self) {
257        self.flush_fence_lsn.store(u64::MAX, Ordering::Release);
258    }
259
260    /// Returns the effective fence value, auto-clearing if the timeout has
261    /// elapsed. Returns `u64::MAX` if no fence is active.
262    fn effective_flush_fence(&self) -> u64 {
263        let fence = self.flush_fence_lsn.load(Ordering::Acquire);
264        if fence == u64::MAX {
265            return u64::MAX;
266        }
267        use std::time::{SystemTime, UNIX_EPOCH};
268        let now_secs = SystemTime::now()
269            .duration_since(UNIX_EPOCH)
270            .unwrap_or_default()
271            .as_secs();
272        let set_secs = self.fence_set_epoch_secs.load(Ordering::Acquire);
273        if now_secs.saturating_sub(set_secs) > FENCE_TIMEOUT_SECS {
274            // Timeout expired — auto-clear
275            self.flush_fence_lsn.store(u64::MAX, Ordering::Release);
276            u64::MAX
277        } else {
278            fence
279        }
280    }
281}
282
283/// PostgreSQL replication source that captures changes via logical replication.
284///
285/// This source connects to PostgreSQL using the replication protocol and decodes
286/// WAL messages in real-time, converting them to Drasi source change events.
287///
288/// # Fields
289///
290/// - `base`: Common source functionality (dispatchers, status, lifecycle)
291/// - `config`: PostgreSQL connection and replication configuration
292/// - `replay_state`: Shared WAL progress for subscribe/rewind decisions
293pub struct PostgresReplicationSource {
294    /// Base source implementation providing common functionality
295    base: SourceBase,
296    /// PostgreSQL source configuration
297    config: PostgresSourceConfig,
298    /// Best-effort cached schema populated from information_schema on start.
299    cached_schema: Arc<std::sync::RwLock<Option<SourceSchema>>>,
300    /// Shared replay progress for subscribe()/rewind decisions and WAL feedback.
301    replay_state: Arc<ReplayState>,
302    /// Serializes subscribe() calls that may restart the replication task.
303    subscribe_lock: Mutex<()>,
304}
305
306fn postgres_type_to_property_type(data_type: &str) -> Option<PropertyType> {
307    match data_type {
308        "smallint" | "integer" | "bigint" => Some(PropertyType::Integer),
309        "real" | "double precision" | "numeric" | "decimal" => Some(PropertyType::Float),
310        "boolean" => Some(PropertyType::Boolean),
311        "timestamp without time zone"
312        | "timestamp with time zone"
313        | "date"
314        | "time without time zone"
315        | "time with time zone" => Some(PropertyType::Timestamp),
316        "json" | "jsonb" => Some(PropertyType::Json),
317        "character" | "character varying" | "text" | "uuid" | "bytea" => Some(PropertyType::String),
318        _ => None,
319    }
320}
321
322async fn introspect_postgres_schema(config: &PostgresSourceConfig) -> Result<Option<SourceSchema>> {
323    if config.tables.is_empty() {
324        return Ok(None);
325    }
326
327    let mut pg_config = tokio_postgres::Config::new();
328    pg_config.host(&config.host);
329    pg_config.port(config.port);
330    pg_config.dbname(&config.database);
331    pg_config.user(&config.user);
332    if !config.password.is_empty() {
333        pg_config.password(&config.password);
334    }
335
336    let client = match config.ssl_mode {
337        SslMode::Require => {
338            pg_config.ssl_mode(tokio_postgres::config::SslMode::Require);
339            let tls_connector = native_tls::TlsConnector::builder()
340                .danger_accept_invalid_hostnames(false)
341                .danger_accept_invalid_certs(false)
342                .build()
343                .map_err(|e| anyhow!("Failed to create TLS connector: {e}"))?;
344            let connector = MakeTlsConnector::new(tls_connector);
345
346            debug!("Schema introspection: connecting with SSL (require)");
347            let (client, connection) = pg_config.connect(connector).await?;
348            tokio::spawn(async move {
349                if let Err(e) = connection.await {
350                    log::warn!("PostgreSQL schema introspection connection closed: {e}");
351                }
352            });
353            client
354        }
355        SslMode::Prefer => {
356            // Try TLS first, fall back to plaintext
357            let tls_connector = native_tls::TlsConnector::builder()
358                .danger_accept_invalid_hostnames(false)
359                .danger_accept_invalid_certs(false)
360                .build()
361                .map_err(|e| anyhow!("Failed to create TLS connector: {e}"))?;
362            let connector = MakeTlsConnector::new(tls_connector);
363
364            pg_config.ssl_mode(tokio_postgres::config::SslMode::Prefer);
365            debug!("Schema introspection: connecting with SSL (prefer)");
366            let (client, connection) = pg_config.connect(connector).await?;
367            tokio::spawn(async move {
368                if let Err(e) = connection.await {
369                    log::warn!("PostgreSQL schema introspection connection closed: {e}");
370                }
371            });
372            client
373        }
374        SslMode::Disable => {
375            debug!("Schema introspection: connecting without SSL");
376            let (client, connection) = pg_config.connect(tokio_postgres::NoTls).await?;
377            tokio::spawn(async move {
378                if let Err(e) = connection.await {
379                    log::warn!("PostgreSQL schema introspection connection closed: {e}");
380                }
381            });
382            client
383        }
384    };
385
386    let mut nodes = Vec::new();
387
388    for table in &config.tables {
389        let (schema_name, table_name) = table
390            .split_once('.')
391            .map(|(schema, name)| (schema.to_string(), name.to_string()))
392            .unwrap_or_else(|| ("public".to_string(), table.to_string()));
393
394        let rows = client
395            .query(
396                "SELECT column_name, data_type \
397                 FROM information_schema.columns \
398                 WHERE table_schema = $1 AND table_name = $2 \
399                 ORDER BY ordinal_position",
400                &[&schema_name, &table_name],
401            )
402            .await?;
403
404        let properties = rows
405            .into_iter()
406            .map(|row| PropertySchema {
407                name: row.get::<_, String>(0),
408                data_type: postgres_type_to_property_type(&row.get::<_, String>(1)),
409                description: None,
410            })
411            .collect();
412
413        nodes.push(NodeSchema {
414            label: normalize_table_label(&table_name),
415            properties,
416        });
417    }
418
419    Ok(Some(SourceSchema {
420        nodes,
421        relations: Vec::new(),
422    }))
423}
424
425impl PostgresReplicationSource {
426    /// Create a builder for PostgresReplicationSource
427    ///
428    /// # Example
429    ///
430    /// ```rust,no_run
431    /// use drasi_source_postgres::PostgresReplicationSource;
432    ///
433    /// # fn main() -> anyhow::Result<()> {
434    /// let source = PostgresReplicationSource::builder("pg-source")
435    ///     .with_host("db.example.com")
436    ///     .with_database("production")
437    ///     .with_user("replication_user")
438    ///     .with_password("secret")
439    ///     .with_tables(vec!["users".to_string(), "orders".to_string()])
440    ///     .build()?;
441    /// # Ok(())
442    /// # }
443    /// ```
444    pub fn builder(id: impl Into<String>) -> PostgresSourceBuilder {
445        PostgresSourceBuilder::new(id)
446    }
447
448    /// Create a new PostgreSQL replication source.
449    ///
450    /// The event channel is automatically injected when the source is added
451    /// to DrasiLib via `add_source()`.
452    ///
453    /// # Arguments
454    ///
455    /// * `id` - Unique identifier for this source instance
456    /// * `config` - PostgreSQL source configuration
457    ///
458    /// # Returns
459    ///
460    /// A new `PostgresReplicationSource` instance, or an error if construction fails.
461    ///
462    /// # Errors
463    ///
464    /// Returns an error if the base source cannot be initialized.
465    ///
466    /// # Example
467    ///
468    /// ```rust,no_run
469    /// use drasi_source_postgres::{PostgresReplicationSource, PostgresSourceConfig, SslMode};
470    ///
471    /// # fn main() -> anyhow::Result<()> {
472    /// let config = PostgresSourceConfig {
473    ///     host: "db.example.com".to_string(),
474    ///     port: 5432,
475    ///     database: "mydb".to_string(),
476    ///     user: "replication_user".to_string(),
477    ///     password: "secret".to_string(),
478    ///     tables: vec!["users".to_string()],
479    ///     slot_name: "drasi_slot".to_string(),
480    ///     publication_name: "drasi_pub".to_string(),
481    ///     ssl_mode: SslMode::Disable,
482    ///     table_keys: vec![],
483    /// };
484    ///
485    /// let source = PostgresReplicationSource::new("my-pg-source", config)?;
486    /// # Ok(())
487    /// # }
488    /// ```
489    pub fn new(id: impl Into<String>, config: PostgresSourceConfig) -> Result<Self> {
490        let id = id.into();
491        let params = SourceBaseParams::new(id);
492        Ok(Self {
493            base: SourceBase::new(params)?,
494            config,
495            cached_schema: Arc::new(std::sync::RwLock::new(None)),
496            replay_state: Arc::new(ReplayState::default()),
497            subscribe_lock: Mutex::new(()),
498        })
499    }
500
501    /// Create a new PostgreSQL replication source with custom dispatch settings
502    ///
503    /// The event channel is automatically injected when the source is added
504    /// to DrasiLib via `add_source()`.
505    pub fn with_dispatch(
506        id: impl Into<String>,
507        config: PostgresSourceConfig,
508        dispatch_mode: Option<DispatchMode>,
509        dispatch_buffer_capacity: Option<usize>,
510    ) -> Result<Self> {
511        let id = id.into();
512        let mut params = SourceBaseParams::new(id);
513        if let Some(mode) = dispatch_mode {
514            params = params.with_dispatch_mode(mode);
515        }
516        if let Some(capacity) = dispatch_buffer_capacity {
517            params = params.with_dispatch_buffer_capacity(capacity);
518        }
519        Ok(Self {
520            base: SourceBase::new(params)?,
521            config,
522            cached_schema: Arc::new(std::sync::RwLock::new(None)),
523            replay_state: Arc::new(ReplayState::default()),
524            subscribe_lock: Mutex::new(()),
525        })
526    }
527}
528
529impl PostgresReplicationSource {
530    /// Spawn the background replication task, optionally starting from a specific LSN.
531    ///
532    /// Waits for the task to confirm successful connection before returning, except for the
533    /// initial bootstrap handoff path where the task must wait for the snapshot boundary first.
534    async fn spawn_replication_task(
535        &self,
536        start_lsn: Option<u64>,
537        wait_for_bootstrap_boundary: bool,
538    ) -> Result<()> {
539        let config = self.config.clone();
540        let source_id = self.base.id.clone();
541        let reporter = self.base.status_handle();
542        let base = self.base.clone_shared();
543        let replay_state = self.replay_state.clone();
544        // `startup_tx` signals that `start()` may return. In the non-bootstrap path
545        // it is sent once replication is connected (see `stream.run`). In the bootstrap
546        // path it is sent as soon as the task is spawned, because the task must then
547        // block on `wait_for_subscribers()` and the bootstrap boundary — work that
548        // happens only after `start()` returns and a subscriber registers. It therefore
549        // does NOT imply a live CDC connection in that path.
550        let (startup_tx, startup_rx) = oneshot::channel::<std::result::Result<(), String>>();
551
552        let instance_id = self
553            .base
554            .context()
555            .await
556            .map(|c| c.instance_id)
557            .unwrap_or_default();
558
559        let source_id_for_span = source_id.clone();
560        let span = tracing::info_span!(
561            "postgres_replication_task",
562            instance_id = %instance_id,
563            component_id = %source_id_for_span,
564            component_type = "source",
565            start_lsn = ?start_lsn
566        );
567
568        let task = tokio::spawn(
569            async move {
570                info!("Starting replication for source {source_id}");
571                let mut startup_tx = Some(startup_tx);
572
573                let effective_start_lsn = if wait_for_bootstrap_boundary {
574                    // Release start() now: the task must wait for a subscriber and the
575                    // bootstrap boundary, neither of which can happen until start() returns.
576                    if let Some(tx) = startup_tx.take() {
577                        let _ = tx.send(Ok(()));
578                    }
579
580                    // Wait for the first subscriber so we know whether an
581                    // initial bootstrap was actually requested before choosing
582                    // the CDC start position. A streaming-only subscription
583                    // (enable_bootstrap=false) never publishes a boundary, so
584                    // without this gate the task would wait forever.
585                    base.wait_for_subscribers().await;
586
587                    if base.take_pending_initial_bootstrap() {
588                        info!(
589                            "PostgreSQL source '{source_id}' waiting for bootstrap snapshot boundary"
590                        );
591                        match base.wait_for_bootstrap_boundary().await {
592                            Some(boundary) => match connection::position_bytes_to_lsn(&boundary)
593                                .context("invalid PostgreSQL bootstrap boundary position")
594                            {
595                                Ok(lsn) => {
596                                    info!(
597                                        "PostgreSQL source '{source_id}' starting CDC from bootstrap boundary LSN {lsn:x}"
598                                    );
599                                    Some(lsn)
600                                }
601                                Err(e) => {
602                                    error!("Replication task failed for {source_id}: {e}");
603                                    reporter
604                                        .set_status(
605                                            ComponentStatus::Error,
606                                            Some(format!("Replication failed: {e}")),
607                                        )
608                                        .await;
609                                    return;
610                                }
611                            },
612                            None => {
613                                info!(
614                                    "PostgreSQL source '{source_id}' stopped before bootstrap boundary was published"
615                                );
616                                return;
617                            }
618                        }
619                    } else {
620                        info!(
621                            "PostgreSQL source '{source_id}' starting CDC from current position (no initial bootstrap requested)"
622                        );
623                        start_lsn
624                    }
625                } else {
626                    start_lsn
627                };
628
629                let mut stream = stream::ReplicationStream::new(
630                    config,
631                    source_id.clone(),
632                    reporter.clone(),
633                    base,
634                    replay_state,
635                    effective_start_lsn,
636                );
637
638                if let Err(e) = stream.run(startup_tx).await {
639                    error!("Replication task failed for {source_id}: {e}");
640                    reporter
641                        .set_status(
642                            ComponentStatus::Error,
643                            Some(format!("Replication failed: {e}")),
644                        )
645                        .await;
646                }
647            }
648            .instrument(span),
649        );
650
651        *self.base.task_handle.write().await = Some(task);
652
653        match startup_rx.await {
654            Ok(Ok(())) => Ok(()),
655            Ok(Err(message)) => {
656                let _ = self.base.task_handle.write().await.take();
657                Err(anyhow!(
658                    "Failed to establish PostgreSQL replication: {message}"
659                ))
660            }
661            Err(_) => {
662                let _ = self.base.task_handle.write().await.take();
663                Err(anyhow!(
664                    "PostgreSQL replication task exited before confirming startup"
665                ))
666            }
667        }
668    }
669
670    async fn abort_replication_task(&self) {
671        if let Some(task) = self.base.task_handle.write().await.take() {
672            task.abort();
673            let _ = task.await;
674        }
675    }
676
677    async fn pause_replication_for_restart(&self, start_lsn: u64) {
678        info!(
679            "Pausing PostgreSQL source '{}' before replay from requested LSN {:x}",
680            self.base.id, start_lsn
681        );
682
683        self.base
684            .set_status(
685                ComponentStatus::Starting,
686                Some(format!(
687                    "Rewinding PostgreSQL replication to LSN {start_lsn:x}"
688                )),
689            )
690            .await;
691
692        self.abort_replication_task().await;
693
694        // Clear stale sequence→position mappings from the pre-replay stream.
695        // Without this, compute_confirmed_source_position() could map a
696        // position handle (seeded from the recovery checkpoint) to a WAL position
697        // from the old stream, causing flush_lsn feedback to advance the
698        // slot's restart_lsn past other queries' checkpoints.
699        self.base.clear_sequence_position_map().await;
700
701        // Set the flush fence to prevent send_feedback() from advancing
702        // flush_lsn past start_lsn. This protects subscribers that haven't
703        // connected yet — their checkpoint is at (or near) start_lsn, so the
704        // slot must retain WAL from that point.
705        self.replay_state.set_flush_fence(start_lsn);
706    }
707
708    async fn resume_replication_from(&self, start_lsn: u64) -> Result<()> {
709        self.spawn_replication_task(Some(start_lsn), false).await?;
710
711        self.base
712            .set_status(
713                ComponentStatus::Running,
714                Some(format!(
715                    "PostgreSQL replication resumed from LSN {start_lsn:x}"
716                )),
717            )
718            .await;
719
720        Ok(())
721    }
722
723    async fn restart_replication_from(&self, start_lsn: u64) -> Result<()> {
724        info!(
725            "Restarting PostgreSQL source '{}' from requested LSN {:x}",
726            self.base.id, start_lsn
727        );
728
729        self.pause_replication_for_restart(start_lsn).await;
730        self.resume_replication_from(start_lsn).await
731    }
732
733    /// Query the replication slot's consistent_point to determine the earliest
734    /// WAL position that can be replayed.
735    async fn get_earliest_available_lsn(&self) -> Result<u64> {
736        let mut conn = connection::ReplicationConnection::connect(
737            &self.config.host,
738            self.config.port,
739            &self.config.database,
740            &self.config.user,
741            &self.config.password,
742        )
743        .await?;
744
745        let _ = conn.identify_system().await?;
746        let slot_info = conn
747            .get_replication_slot_info(&self.config.slot_name)
748            .await?;
749        let _ = conn.close().await;
750
751        // Use restart_lsn (the earliest WAL the slot retains) when available,
752        // falling back to consistent_point for newly created slots.
753        let lsn_str = slot_info
754            .restart_lsn
755            .as_deref()
756            .unwrap_or(&slot_info.consistent_point);
757
758        if lsn_str.is_empty() || lsn_str == "0/0" {
759            Ok(0)
760        } else {
761            connection::parse_lsn(lsn_str)
762        }
763    }
764}
765
766#[async_trait]
767impl Source for PostgresReplicationSource {
768    fn id(&self) -> &str {
769        &self.base.id
770    }
771
772    fn type_name(&self) -> &str {
773        "postgres"
774    }
775
776    fn properties(&self) -> HashMap<String, serde_json::Value> {
777        use crate::descriptor::PostgresSourceConfigDto;
778
779        self.base
780            .properties_or_serialize(&PostgresSourceConfigDto::from(&self.config))
781    }
782
783    fn auto_start(&self) -> bool {
784        self.base.get_auto_start()
785    }
786
787    fn describe_schema(&self) -> Option<SourceSchema> {
788        self.cached_schema
789            .read()
790            .ok()
791            .and_then(|schema| schema.clone())
792            .or_else(|| {
793                if self.config.tables.is_empty() {
794                    None
795                } else {
796                    Some(SourceSchema {
797                        nodes: self
798                            .config
799                            .tables
800                            .iter()
801                            .map(|table| NodeSchema::new(normalize_table_label(table)))
802                            .collect(),
803                        relations: Vec::new(),
804                    })
805                }
806            })
807    }
808
809    async fn start(&self) -> Result<()> {
810        if self.base.get_status().await == ComponentStatus::Running {
811            return Ok(());
812        }
813
814        self.base.set_status(ComponentStatus::Starting, None).await;
815        self.base.reset_bootstrap_boundary();
816        info!("Starting PostgreSQL replication source: {}", self.base.id);
817
818        match introspect_postgres_schema(&self.config).await {
819            Ok(Some(schema)) => {
820                if let Ok(mut cached) = self.cached_schema.write() {
821                    *cached = Some(schema);
822                }
823            }
824            Ok(None) => {}
825            Err(e) => {
826                log::warn!(
827                    "Failed to introspect PostgreSQL schema for '{}': {e}",
828                    self.base.id
829                );
830            }
831        }
832
833        let wait_for_bootstrap_boundary = self.base.has_bootstrap_provider();
834        self.spawn_replication_task(None, wait_for_bootstrap_boundary)
835            .await?;
836        self.base
837            .set_status(
838                ComponentStatus::Running,
839                Some("PostgreSQL replication started".to_string()),
840            )
841            .await;
842
843        Ok(())
844    }
845
846    async fn stop(&self) -> Result<()> {
847        if self.base.get_status().await != ComponentStatus::Running {
848            return Ok(());
849        }
850
851        info!("Stopping PostgreSQL replication source: {}", self.base.id);
852
853        self.base.set_status(ComponentStatus::Stopping, None).await;
854
855        self.abort_replication_task().await;
856
857        // Clear cached schema so a subsequent start() re-introspects
858        if let Ok(mut cached) = self.cached_schema.write() {
859            *cached = None;
860        }
861
862        self.base
863            .set_status(
864                ComponentStatus::Stopped,
865                Some("PostgreSQL replication stopped".to_string()),
866            )
867            .await;
868
869        Ok(())
870    }
871
872    async fn status(&self) -> ComponentStatus {
873        self.base.get_status().await
874    }
875
876    async fn subscribe(
877        &self,
878        settings: drasi_lib::config::SourceSubscriptionSettings,
879    ) -> Result<SubscriptionResponse> {
880        // Serialize subscribe calls that may restart the replication task to
881        // prevent TOCTOU races between concurrent callers.
882        let _guard = self.subscribe_lock.lock().await;
883
884        let mut restart_from = None;
885        let mut pause_before_subscribe = false;
886
887        if let Some(ref resume_bytes) = settings.resume_from {
888            let resume_lsn = connection::position_bytes_to_lsn(resume_bytes)?;
889
890            let earliest_available = self.get_earliest_available_lsn().await?;
891            if resume_lsn < earliest_available {
892                return Err(SourceError::PositionUnavailable {
893                    source_id: self.base.id.clone(),
894                    requested: resume_bytes.clone(),
895                    earliest_available: Some(connection::commit_position_bytes(
896                        earliest_available,
897                        0,
898                    )),
899                }
900                .into());
901            }
902
903            let read_lsn = self.replay_state.current_read_lsn();
904            let is_running = self.base.get_status().await == ComponentStatus::Running;
905
906            if !is_running || read_lsn == 0 || resume_lsn < read_lsn {
907                restart_from = Some(resume_lsn);
908                pause_before_subscribe = is_running;
909            }
910        }
911
912        if let Some(start_lsn) = restart_from.filter(|_| pause_before_subscribe) {
913            // Quiesce the current replication task before attaching the resumed
914            // receiver so it cannot observe newer live events ahead of replayed
915            // older WAL entries.
916            self.pause_replication_for_restart(start_lsn).await;
917        }
918
919        let response = match self
920            .base
921            .subscribe_with_bootstrap(&settings, "PostgreSQL")
922            .await
923        {
924            Ok(response) => response,
925            Err(err) => {
926                if pause_before_subscribe {
927                    self.base
928                        .set_status(
929                            ComponentStatus::Error,
930                            Some(format!("Failed to register replay subscription: {err}")),
931                        )
932                        .await;
933                }
934                return Err(err);
935            }
936        };
937
938        if let Some(start_lsn) = restart_from {
939            if pause_before_subscribe {
940                self.resume_replication_from(start_lsn).await?;
941            } else {
942                self.restart_replication_from(start_lsn).await?;
943            }
944        }
945
946        Ok(response)
947    }
948
949    fn as_any(&self) -> &dyn std::any::Any {
950        self
951    }
952
953    async fn initialize(&self, context: drasi_lib::context::SourceRuntimeContext) {
954        self.base.initialize(context).await;
955        // Source positions are uniformly 16-byte big-endian
956        // `[ commit_lsn (8) | offset (8) ]` (CDC events and the
957        // `[snapshot_lsn | u64::MAX]` bootstrap boundary), so byte-lexicographic
958        // comparison equals tuple ordering on (commit_lsn, offset).
959        self.base
960            .set_position_comparator(drasi_lib::sources::ByteLexPositionComparator)
961            .await;
962    }
963
964    async fn remove_position_handle(&self, query_id: &str) {
965        self.base.remove_position_handle(query_id).await;
966    }
967
968    async fn on_subscriptions_complete(&self) {
969        // Release the flush fence so that send_feedback() can advance flush_lsn
970        // based on the natural min-watermark of all registered position handles.
971        self.replay_state.clear_flush_fence();
972    }
973
974    async fn set_bootstrap_provider(
975        &self,
976        provider: Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>,
977    ) {
978        self.base.set_bootstrap_provider(provider).await;
979    }
980}
981
982/// Builder for PostgreSQL source configuration.
983///
984/// Provides a fluent API for constructing PostgreSQL source configurations
985/// with sensible defaults.
986///
987/// # Example
988///
989/// ```rust,no_run
990/// use drasi_source_postgres::PostgresReplicationSource;
991///
992/// # fn main() -> anyhow::Result<()> {
993/// let source = PostgresReplicationSource::builder("pg-source")
994///     .with_host("db.example.com")
995///     .with_database("production")
996///     .with_user("replication_user")
997///     .with_password("secret")
998///     .with_tables(vec!["users".to_string(), "orders".to_string()])
999///     .with_slot_name("my_slot")
1000///     .build()?;
1001/// # Ok(())
1002/// # }
1003/// ```
1004pub struct PostgresSourceBuilder {
1005    id: String,
1006    host: String,
1007    port: u16,
1008    database: String,
1009    user: String,
1010    password: String,
1011    tables: Vec<String>,
1012    slot_name: String,
1013    publication_name: String,
1014    ssl_mode: SslMode,
1015    table_keys: Vec<TableKeyConfig>,
1016    dispatch_mode: Option<DispatchMode>,
1017    dispatch_buffer_capacity: Option<usize>,
1018    bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
1019    auto_start: bool,
1020}
1021
1022impl PostgresSourceBuilder {
1023    /// Create a new PostgreSQL source builder with the given ID and default values
1024    pub fn new(id: impl Into<String>) -> Self {
1025        Self {
1026            id: id.into(),
1027            host: "localhost".to_string(),
1028            port: 5432,
1029            database: String::new(),
1030            user: String::new(),
1031            password: String::new(),
1032            tables: Vec::new(),
1033            slot_name: "drasi_slot".to_string(),
1034            publication_name: "drasi_publication".to_string(),
1035            ssl_mode: SslMode::default(),
1036            table_keys: Vec::new(),
1037            dispatch_mode: None,
1038            dispatch_buffer_capacity: None,
1039            bootstrap_provider: None,
1040            auto_start: true,
1041        }
1042    }
1043
1044    /// Set the PostgreSQL host
1045    pub fn with_host(mut self, host: impl Into<String>) -> Self {
1046        self.host = host.into();
1047        self
1048    }
1049
1050    /// Set the PostgreSQL port
1051    pub fn with_port(mut self, port: u16) -> Self {
1052        self.port = port;
1053        self
1054    }
1055
1056    /// Set the database name
1057    pub fn with_database(mut self, database: impl Into<String>) -> Self {
1058        self.database = database.into();
1059        self
1060    }
1061
1062    /// Set the database user
1063    pub fn with_user(mut self, user: impl Into<String>) -> Self {
1064        self.user = user.into();
1065        self
1066    }
1067
1068    /// Set the database password
1069    pub fn with_password(mut self, password: impl Into<String>) -> Self {
1070        self.password = password.into();
1071        self
1072    }
1073
1074    /// Set the tables to replicate
1075    pub fn with_tables(mut self, tables: Vec<String>) -> Self {
1076        self.tables = tables;
1077        self
1078    }
1079
1080    /// Add a table to replicate
1081    pub fn add_table(mut self, table: impl Into<String>) -> Self {
1082        self.tables.push(table.into());
1083        self
1084    }
1085
1086    /// Set the replication slot name
1087    pub fn with_slot_name(mut self, slot_name: impl Into<String>) -> Self {
1088        self.slot_name = slot_name.into();
1089        self
1090    }
1091
1092    /// Set the publication name
1093    pub fn with_publication_name(mut self, publication_name: impl Into<String>) -> Self {
1094        self.publication_name = publication_name.into();
1095        self
1096    }
1097
1098    /// Set the SSL mode
1099    pub fn with_ssl_mode(mut self, ssl_mode: SslMode) -> Self {
1100        self.ssl_mode = ssl_mode;
1101        self
1102    }
1103
1104    /// Set the table key configurations
1105    pub fn with_table_keys(mut self, table_keys: Vec<TableKeyConfig>) -> Self {
1106        self.table_keys = table_keys;
1107        self
1108    }
1109
1110    /// Add a table key configuration
1111    pub fn add_table_key(mut self, table_key: TableKeyConfig) -> Self {
1112        self.table_keys.push(table_key);
1113        self
1114    }
1115
1116    /// Set the dispatch mode for this source
1117    pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1118        self.dispatch_mode = Some(mode);
1119        self
1120    }
1121
1122    /// Set the dispatch buffer capacity for this source
1123    pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1124        self.dispatch_buffer_capacity = Some(capacity);
1125        self
1126    }
1127
1128    /// Set the bootstrap provider for this source
1129    pub fn with_bootstrap_provider(
1130        mut self,
1131        provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1132    ) -> Self {
1133        self.bootstrap_provider = Some(Box::new(provider));
1134        self
1135    }
1136
1137    /// Set whether this source should auto-start when DrasiLib starts.
1138    ///
1139    /// Default is `true`. Set to `false` if this source should only be
1140    /// started manually via `start_source()`.
1141    pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1142        self.auto_start = auto_start;
1143        self
1144    }
1145
1146    /// Set the full configuration at once
1147    pub fn with_config(mut self, config: PostgresSourceConfig) -> Self {
1148        self.host = config.host;
1149        self.port = config.port;
1150        self.database = config.database;
1151        self.user = config.user;
1152        self.password = config.password;
1153        self.tables = config.tables;
1154        self.slot_name = config.slot_name;
1155        self.publication_name = config.publication_name;
1156        self.ssl_mode = config.ssl_mode;
1157        self.table_keys = config.table_keys;
1158        self
1159    }
1160
1161    /// Build the PostgreSQL replication source
1162    ///
1163    /// # Errors
1164    ///
1165    /// Returns an error if the source cannot be constructed.
1166    pub fn build(self) -> Result<PostgresReplicationSource> {
1167        let config = PostgresSourceConfig {
1168            host: self.host,
1169            port: self.port,
1170            database: self.database,
1171            user: self.user,
1172            password: self.password,
1173            tables: self.tables,
1174            slot_name: self.slot_name,
1175            publication_name: self.publication_name,
1176            ssl_mode: self.ssl_mode,
1177            table_keys: self.table_keys,
1178        };
1179
1180        let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1181        if let Some(mode) = self.dispatch_mode {
1182            params = params.with_dispatch_mode(mode);
1183        }
1184        if let Some(capacity) = self.dispatch_buffer_capacity {
1185            params = params.with_dispatch_buffer_capacity(capacity);
1186        }
1187        if let Some(provider) = self.bootstrap_provider {
1188            params = params.with_bootstrap_provider(provider);
1189        }
1190
1191        Ok(PostgresReplicationSource {
1192            base: SourceBase::new(params)?,
1193            config,
1194            cached_schema: Arc::new(std::sync::RwLock::new(None)),
1195            replay_state: Arc::new(ReplayState::default()),
1196            subscribe_lock: Mutex::new(()),
1197        })
1198    }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204
1205    mod construction {
1206        use super::*;
1207
1208        #[test]
1209        fn test_builder_with_valid_config() {
1210            let source = PostgresSourceBuilder::new("test-source")
1211                .with_database("testdb")
1212                .with_user("testuser")
1213                .build();
1214            assert!(source.is_ok());
1215        }
1216
1217        #[test]
1218        fn test_builder_with_custom_config() {
1219            let source = PostgresSourceBuilder::new("pg-source")
1220                .with_host("192.168.1.100")
1221                .with_port(5433)
1222                .with_database("production")
1223                .with_user("admin")
1224                .with_password("secret")
1225                .build()
1226                .unwrap();
1227            assert_eq!(source.id(), "pg-source");
1228        }
1229
1230        #[test]
1231        fn test_with_dispatch_creates_source() {
1232            let config = PostgresSourceConfig {
1233                host: "localhost".to_string(),
1234                port: 5432,
1235                database: "testdb".to_string(),
1236                user: "testuser".to_string(),
1237                password: String::new(),
1238                tables: Vec::new(),
1239                slot_name: "drasi_slot".to_string(),
1240                publication_name: "drasi_publication".to_string(),
1241                ssl_mode: SslMode::default(),
1242                table_keys: Vec::new(),
1243            };
1244            let source = PostgresReplicationSource::with_dispatch(
1245                "dispatch-source",
1246                config,
1247                Some(DispatchMode::Channel),
1248                Some(2000),
1249            );
1250            assert!(source.is_ok());
1251            assert_eq!(source.unwrap().id(), "dispatch-source");
1252        }
1253    }
1254
1255    mod properties {
1256        use super::*;
1257
1258        #[test]
1259        fn test_id_returns_correct_value() {
1260            let source = PostgresSourceBuilder::new("my-pg-source")
1261                .with_database("db")
1262                .with_user("user")
1263                .build()
1264                .unwrap();
1265            assert_eq!(source.id(), "my-pg-source");
1266        }
1267
1268        #[test]
1269        fn test_type_name_returns_postgres() {
1270            let source = PostgresSourceBuilder::new("test")
1271                .with_database("db")
1272                .with_user("user")
1273                .build()
1274                .unwrap();
1275            assert_eq!(source.type_name(), "postgres");
1276        }
1277
1278        #[test]
1279        fn test_properties_contains_connection_info() {
1280            let source = PostgresSourceBuilder::new("test")
1281                .with_host("db.example.com")
1282                .with_port(5433)
1283                .with_database("mydb")
1284                .with_user("app_user")
1285                .with_password("secret")
1286                .with_tables(vec!["users".to_string()])
1287                .build()
1288                .unwrap();
1289            let props = source.properties();
1290
1291            assert_eq!(
1292                props.get("host"),
1293                Some(&serde_json::Value::String("db.example.com".to_string()))
1294            );
1295            assert_eq!(
1296                props.get("port"),
1297                Some(&serde_json::Value::Number(5433.into()))
1298            );
1299            assert_eq!(
1300                props.get("database"),
1301                Some(&serde_json::Value::String("mydb".to_string()))
1302            );
1303            assert_eq!(
1304                props.get("user"),
1305                Some(&serde_json::Value::String("app_user".to_string()))
1306            );
1307        }
1308
1309        #[test]
1310        fn test_properties_includes_password() {
1311            let source = PostgresSourceBuilder::new("test")
1312                .with_database("db")
1313                .with_user("user")
1314                .with_password("super_secret_password")
1315                .build()
1316                .unwrap();
1317            let props = source.properties();
1318
1319            // Password must be preserved for config persistence roundtrip
1320            assert_eq!(
1321                props.get("password"),
1322                Some(&serde_json::Value::String(
1323                    "super_secret_password".to_string()
1324                ))
1325            );
1326        }
1327
1328        #[test]
1329        fn test_properties_includes_tables() {
1330            let source = PostgresSourceBuilder::new("test")
1331                .with_database("db")
1332                .with_user("user")
1333                .with_tables(vec!["users".to_string(), "orders".to_string()])
1334                .build()
1335                .unwrap();
1336            let props = source.properties();
1337
1338            let tables = props.get("tables").unwrap().as_array().unwrap();
1339            assert_eq!(tables.len(), 2);
1340            assert_eq!(tables[0], "users");
1341            assert_eq!(tables[1], "orders");
1342        }
1343
1344        #[test]
1345        fn test_describe_schema_falls_back_to_configured_tables() {
1346            let source = PostgresSourceBuilder::new("test")
1347                .with_database("db")
1348                .with_user("user")
1349                .with_tables(vec!["public.users".to_string(), "orders".to_string()])
1350                .build()
1351                .unwrap();
1352
1353            let schema = source
1354                .describe_schema()
1355                .expect("configured postgres tables should produce fallback schema");
1356
1357            assert_eq!(schema.nodes.len(), 2);
1358            assert!(schema.nodes.iter().any(|node| node.label == "users"));
1359            assert!(schema.nodes.iter().any(|node| node.label == "orders"));
1360        }
1361
1362        #[test]
1363        fn test_postgres_type_to_property_type_integer() {
1364            assert_eq!(
1365                postgres_type_to_property_type("integer"),
1366                Some(PropertyType::Integer)
1367            );
1368            assert_eq!(
1369                postgres_type_to_property_type("bigint"),
1370                Some(PropertyType::Integer)
1371            );
1372            assert_eq!(
1373                postgres_type_to_property_type("smallint"),
1374                Some(PropertyType::Integer)
1375            );
1376        }
1377
1378        #[test]
1379        fn test_postgres_type_to_property_type_float() {
1380            assert_eq!(
1381                postgres_type_to_property_type("double precision"),
1382                Some(PropertyType::Float)
1383            );
1384            assert_eq!(
1385                postgres_type_to_property_type("real"),
1386                Some(PropertyType::Float)
1387            );
1388            assert_eq!(
1389                postgres_type_to_property_type("numeric"),
1390                Some(PropertyType::Float)
1391            );
1392            assert_eq!(
1393                postgres_type_to_property_type("decimal"),
1394                Some(PropertyType::Float)
1395            );
1396        }
1397
1398        #[test]
1399        fn test_postgres_type_to_property_type_boolean() {
1400            assert_eq!(
1401                postgres_type_to_property_type("boolean"),
1402                Some(PropertyType::Boolean)
1403            );
1404        }
1405
1406        #[test]
1407        fn test_postgres_type_to_property_type_timestamp() {
1408            assert_eq!(
1409                postgres_type_to_property_type("timestamp with time zone"),
1410                Some(PropertyType::Timestamp)
1411            );
1412            assert_eq!(
1413                postgres_type_to_property_type("timestamp without time zone"),
1414                Some(PropertyType::Timestamp)
1415            );
1416            assert_eq!(
1417                postgres_type_to_property_type("date"),
1418                Some(PropertyType::Timestamp)
1419            );
1420        }
1421
1422        #[test]
1423        fn test_postgres_type_to_property_type_json() {
1424            assert_eq!(
1425                postgres_type_to_property_type("json"),
1426                Some(PropertyType::Json)
1427            );
1428            assert_eq!(
1429                postgres_type_to_property_type("jsonb"),
1430                Some(PropertyType::Json)
1431            );
1432        }
1433
1434        #[test]
1435        fn test_postgres_type_to_property_type_string() {
1436            assert_eq!(
1437                postgres_type_to_property_type("character varying"),
1438                Some(PropertyType::String)
1439            );
1440            assert_eq!(
1441                postgres_type_to_property_type("text"),
1442                Some(PropertyType::String)
1443            );
1444            assert_eq!(
1445                postgres_type_to_property_type("uuid"),
1446                Some(PropertyType::String)
1447            );
1448        }
1449
1450        #[test]
1451        fn test_postgres_type_to_property_type_unknown_returns_none() {
1452            assert_eq!(postgres_type_to_property_type("point"), None);
1453            assert_eq!(postgres_type_to_property_type("polygon"), None);
1454            assert_eq!(postgres_type_to_property_type("cidr"), None);
1455        }
1456    }
1457
1458    mod lifecycle {
1459        use super::*;
1460
1461        /// A test secret resolver that returns a fixed value for any secret name.
1462        struct TestSecretResolver;
1463
1464        #[async_trait::async_trait]
1465        impl drasi_plugin_sdk::resolver::ValueResolver for TestSecretResolver {
1466            async fn resolve_to_string(
1467                &self,
1468                value: &drasi_plugin_sdk::ConfigValue<String>,
1469            ) -> Result<String, drasi_plugin_sdk::resolver::ResolverError> {
1470                match value {
1471                    drasi_plugin_sdk::ConfigValue::Secret { name } => {
1472                        Ok(format!("resolved-secret-{name}"))
1473                    }
1474                    _ => Err(drasi_plugin_sdk::resolver::ResolverError::WrongResolverType),
1475                }
1476            }
1477        }
1478
1479        fn ensure_test_secret_resolver() {
1480            drasi_plugin_sdk::resolver::register_secret_resolver(std::sync::Arc::new(
1481                TestSecretResolver,
1482            ));
1483        }
1484
1485        #[tokio::test]
1486        async fn test_descriptor_preserves_secret_envelope() {
1487            use crate::descriptor::PostgresSourceDescriptor;
1488            use drasi_lib::sources::Source;
1489            use drasi_plugin_sdk::descriptor::SourcePluginDescriptor;
1490
1491            ensure_test_secret_resolver();
1492
1493            let config_json = serde_json::json!({
1494                "host": "db.example.com",
1495                "port": 5432,
1496                "database": "mydb",
1497                "user": "app_user",
1498                "password": {
1499                    "kind": "Secret",
1500                    "name": "db-password"
1501                },
1502                "tables": ["users"],
1503                "slotName": "drasi_slot",
1504                "publicationName": "drasi_pub"
1505            });
1506
1507            let descriptor = PostgresSourceDescriptor;
1508            let source = descriptor
1509                .create_source("pg-secret-test", &config_json, true)
1510                .await
1511                .expect("descriptor should create source");
1512
1513            let props = source.properties();
1514
1515            // Password must be the Secret envelope, NOT the resolved value
1516            let password = props.get("password").expect("password must be present");
1517            assert!(
1518                password.is_object(),
1519                "password should be Secret envelope, got: {password}"
1520            );
1521            assert_eq!(
1522                password.get("kind").and_then(|v| v.as_str()),
1523                Some("Secret"),
1524                "envelope kind must be Secret"
1525            );
1526            assert_eq!(
1527                password.get("name").and_then(|v| v.as_str()),
1528                Some("db-password"),
1529                "secret name must be preserved"
1530            );
1531
1532            // Resolved value must NOT leak into persisted properties
1533            let props_str = serde_json::to_string(&props).unwrap();
1534            assert!(
1535                !props_str.contains("resolved-secret-db-password"),
1536                "resolved secret must not appear in properties"
1537            );
1538
1539            // Keys must be camelCase (from raw_config)
1540            assert!(
1541                props.contains_key("slotName"),
1542                "expected camelCase 'slotName', got keys: {:?}",
1543                props.keys().collect::<Vec<_>>()
1544            );
1545            assert!(
1546                props.contains_key("publicationName"),
1547                "expected camelCase 'publicationName'"
1548            );
1549        }
1550
1551        #[tokio::test]
1552        async fn test_initial_status_is_stopped() {
1553            let source = PostgresSourceBuilder::new("test")
1554                .with_database("db")
1555                .with_user("user")
1556                .build()
1557                .unwrap();
1558            assert_eq!(source.status().await, ComponentStatus::Stopped);
1559        }
1560
1561        #[test]
1562        fn test_builder_fallback_produces_camel_case() {
1563            use drasi_lib::sources::Source;
1564
1565            let source = PostgresSourceBuilder::new("pg-fallback")
1566                .with_host("myhost.example.com")
1567                .with_port(5433)
1568                .with_database("mydb")
1569                .with_user("admin")
1570                .with_password("secret123")
1571                .with_ssl_mode(SslMode::Require)
1572                .with_slot_name("custom_slot")
1573                .with_publication_name("custom_pub")
1574                .build()
1575                .unwrap();
1576
1577            let props = source.properties();
1578
1579            // Must use camelCase keys (DTO serialization)
1580            assert!(
1581                props.contains_key("slotName"),
1582                "expected camelCase 'slotName', got keys: {:?}",
1583                props.keys().collect::<Vec<_>>()
1584            );
1585            assert!(
1586                props.contains_key("publicationName"),
1587                "expected camelCase 'publicationName'"
1588            );
1589            assert!(
1590                props.contains_key("sslMode"),
1591                "expected camelCase 'sslMode'"
1592            );
1593
1594            // Must NOT have snake_case keys
1595            assert!(
1596                !props.contains_key("slot_name"),
1597                "should not have snake_case 'slot_name'"
1598            );
1599            assert!(
1600                !props.contains_key("publication_name"),
1601                "should not have snake_case 'publication_name'"
1602            );
1603
1604            // Values should be correct
1605            assert_eq!(
1606                props.get("host").and_then(|v| v.as_str()),
1607                Some("myhost.example.com")
1608            );
1609            assert_eq!(props.get("port").and_then(|v| v.as_u64()), Some(5433));
1610            assert_eq!(props.get("database").and_then(|v| v.as_str()), Some("mydb"));
1611            assert_eq!(
1612                props.get("password").and_then(|v| v.as_str()),
1613                Some("secret123")
1614            );
1615        }
1616
1617        #[tokio::test]
1618        async fn test_pause_replication_for_restart_aborts_existing_task() {
1619            let source = PostgresSourceBuilder::new("test")
1620                .with_database("db")
1621                .with_user("user")
1622                .build()
1623                .unwrap();
1624
1625            source.base.set_status(ComponentStatus::Running, None).await;
1626
1627            let task = tokio::spawn(async {
1628                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1629            });
1630            *source.base.task_handle.write().await = Some(task);
1631
1632            source.pause_replication_for_restart(42).await;
1633
1634            assert!(source.base.task_handle.read().await.is_none());
1635            assert_eq!(source.status().await, ComponentStatus::Starting);
1636        }
1637
1638        #[test]
1639        fn test_supports_replay_returns_true() {
1640            let source = PostgresSourceBuilder::new("test")
1641                .with_database("db")
1642                .with_user("user")
1643                .build()
1644                .unwrap();
1645            assert!(source.supports_replay());
1646        }
1647    }
1648
1649    mod subscribe {
1650        use super::*;
1651        use drasi_lib::config::SourceSubscriptionSettings;
1652        use std::collections::HashSet;
1653
1654        #[tokio::test]
1655        async fn test_malformed_resume_from_rejected() {
1656            let source = PostgresSourceBuilder::new("test-source")
1657                .with_database("testdb")
1658                .with_user("testuser")
1659                .build()
1660                .unwrap();
1661
1662            // 8 bytes — a bare LSN is no longer a valid position (only the
1663            // 16-byte `[commit_lsn | offset]` encoding is accepted).
1664            let bad_position = bytes::Bytes::from(vec![0u8; 8]);
1665            let settings = SourceSubscriptionSettings {
1666                source_id: "test-source".to_string(),
1667                query_id: "q-bad-position".to_string(),
1668                enable_bootstrap: false,
1669                nodes: HashSet::new(),
1670                relations: HashSet::new(),
1671                resume_from: Some(bad_position),
1672                request_position_handle: false,
1673            };
1674
1675            let result = source.subscribe(settings).await;
1676            assert!(result.is_err());
1677            let err_msg = format!("{}", result.err().unwrap());
1678            assert!(
1679                err_msg.contains("expected 16 bytes"),
1680                "Error should mention expected byte length, got: {err_msg}"
1681            );
1682        }
1683    }
1684
1685    mod builder {
1686        use super::*;
1687
1688        #[test]
1689        fn test_postgres_builder_defaults() {
1690            let source = PostgresSourceBuilder::new("test").build().unwrap();
1691            assert_eq!(source.config.host, "localhost");
1692            assert_eq!(source.config.port, 5432);
1693            assert_eq!(source.config.slot_name, "drasi_slot");
1694            assert_eq!(source.config.publication_name, "drasi_publication");
1695        }
1696
1697        #[test]
1698        fn test_postgres_builder_custom_values() {
1699            let source = PostgresSourceBuilder::new("test")
1700                .with_host("db.example.com")
1701                .with_port(5433)
1702                .with_database("production")
1703                .with_user("app_user")
1704                .with_password("secret")
1705                .with_tables(vec!["users".to_string(), "orders".to_string()])
1706                .build()
1707                .unwrap();
1708
1709            assert_eq!(source.config.host, "db.example.com");
1710            assert_eq!(source.config.port, 5433);
1711            assert_eq!(source.config.database, "production");
1712            assert_eq!(source.config.user, "app_user");
1713            assert_eq!(source.config.password, "secret");
1714            assert_eq!(source.config.tables.len(), 2);
1715            assert_eq!(source.config.tables[0], "users");
1716            assert_eq!(source.config.tables[1], "orders");
1717        }
1718
1719        #[test]
1720        fn test_builder_add_table() {
1721            let source = PostgresSourceBuilder::new("test")
1722                .add_table("table1")
1723                .add_table("table2")
1724                .add_table("table3")
1725                .build()
1726                .unwrap();
1727
1728            assert_eq!(source.config.tables.len(), 3);
1729            assert_eq!(source.config.tables[0], "table1");
1730            assert_eq!(source.config.tables[1], "table2");
1731            assert_eq!(source.config.tables[2], "table3");
1732        }
1733
1734        #[test]
1735        fn test_builder_slot_and_publication() {
1736            let source = PostgresSourceBuilder::new("test")
1737                .with_slot_name("custom_slot")
1738                .with_publication_name("custom_pub")
1739                .build()
1740                .unwrap();
1741
1742            assert_eq!(source.config.slot_name, "custom_slot");
1743            assert_eq!(source.config.publication_name, "custom_pub");
1744        }
1745
1746        #[test]
1747        fn test_builder_id() {
1748            let source = PostgresReplicationSource::builder("my-pg-source")
1749                .with_database("db")
1750                .with_user("user")
1751                .build()
1752                .unwrap();
1753
1754            assert_eq!(source.base.id, "my-pg-source");
1755        }
1756    }
1757
1758    mod config {
1759        use super::*;
1760
1761        #[test]
1762        fn test_config_serialization() {
1763            let config = PostgresSourceConfig {
1764                host: "localhost".to_string(),
1765                port: 5432,
1766                database: "testdb".to_string(),
1767                user: "testuser".to_string(),
1768                password: String::new(),
1769                tables: Vec::new(),
1770                slot_name: "drasi_slot".to_string(),
1771                publication_name: "drasi_publication".to_string(),
1772                ssl_mode: SslMode::default(),
1773                table_keys: Vec::new(),
1774            };
1775
1776            let json = serde_json::to_string(&config).unwrap();
1777            let deserialized: PostgresSourceConfig = serde_json::from_str(&json).unwrap();
1778
1779            assert_eq!(config, deserialized);
1780        }
1781
1782        #[test]
1783        fn test_config_deserialization_with_required_fields() {
1784            let json = r#"{
1785                "database": "mydb",
1786                "user": "myuser"
1787            }"#;
1788            let config: PostgresSourceConfig = serde_json::from_str(json).unwrap();
1789
1790            assert_eq!(config.database, "mydb");
1791            assert_eq!(config.user, "myuser");
1792            assert_eq!(config.host, "localhost"); // default
1793            assert_eq!(config.port, 5432); // default
1794            assert_eq!(config.slot_name, "drasi_slot"); // default
1795        }
1796
1797        #[test]
1798        fn test_config_deserialization_full() {
1799            let json = r#"{
1800                "host": "db.prod.internal",
1801                "port": 5433,
1802                "database": "production",
1803                "user": "replication_user",
1804                "password": "secret",
1805                "tables": ["accounts", "transactions"],
1806                "slot_name": "prod_slot",
1807                "publication_name": "prod_publication"
1808            }"#;
1809            let config: PostgresSourceConfig = serde_json::from_str(json).unwrap();
1810
1811            assert_eq!(config.host, "db.prod.internal");
1812            assert_eq!(config.port, 5433);
1813            assert_eq!(config.database, "production");
1814            assert_eq!(config.user, "replication_user");
1815            assert_eq!(config.password, "secret");
1816            assert_eq!(config.tables, vec!["accounts", "transactions"]);
1817            assert_eq!(config.slot_name, "prod_slot");
1818            assert_eq!(config.publication_name, "prod_publication");
1819        }
1820    }
1821}
1822
1823/// Dynamic plugin entry point.
1824///
1825/// Dynamic plugin entry point.
1826#[cfg(feature = "dynamic-plugin")]
1827drasi_plugin_sdk::export_plugin!(
1828    plugin_id = "postgres-source",
1829    core_version = env!("CARGO_PKG_VERSION"),
1830    lib_version = env!("CARGO_PKG_VERSION"),
1831    plugin_version = env!("CARGO_PKG_VERSION"),
1832    source_descriptors = [descriptor::PostgresSourceDescriptor],
1833    reaction_descriptors = [],
1834    bootstrap_descriptors = [],
1835);