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::lsn_to_position_bytes(earliest_available)),
896                }
897                .into());
898            }
899
900            let read_lsn = self.replay_state.current_read_lsn();
901            let is_running = self.base.get_status().await == ComponentStatus::Running;
902
903            if !is_running || read_lsn == 0 || resume_lsn < read_lsn {
904                restart_from = Some(resume_lsn);
905                pause_before_subscribe = is_running;
906            }
907        }
908
909        if let Some(start_lsn) = restart_from.filter(|_| pause_before_subscribe) {
910            // Quiesce the current replication task before attaching the resumed
911            // receiver so it cannot observe newer live events ahead of replayed
912            // older WAL entries.
913            self.pause_replication_for_restart(start_lsn).await;
914        }
915
916        let response = match self
917            .base
918            .subscribe_with_bootstrap(&settings, "PostgreSQL")
919            .await
920        {
921            Ok(response) => response,
922            Err(err) => {
923                if pause_before_subscribe {
924                    self.base
925                        .set_status(
926                            ComponentStatus::Error,
927                            Some(format!("Failed to register replay subscription: {err}")),
928                        )
929                        .await;
930                }
931                return Err(err);
932            }
933        };
934
935        if let Some(start_lsn) = restart_from {
936            if pause_before_subscribe {
937                self.resume_replication_from(start_lsn).await?;
938            } else {
939                self.restart_replication_from(start_lsn).await?;
940            }
941        }
942
943        Ok(response)
944    }
945
946    fn as_any(&self) -> &dyn std::any::Any {
947        self
948    }
949
950    async fn initialize(&self, context: drasi_lib::context::SourceRuntimeContext) {
951        self.base.initialize(context).await;
952        // Postgres WAL LSN is an 8-byte big-endian u64 — byte-lexicographic comparison is correct.
953        self.base
954            .set_position_comparator(drasi_lib::sources::ByteLexPositionComparator)
955            .await;
956    }
957
958    async fn remove_position_handle(&self, query_id: &str) {
959        self.base.remove_position_handle(query_id).await;
960    }
961
962    async fn on_subscriptions_complete(&self) {
963        // Release the flush fence so that send_feedback() can advance flush_lsn
964        // based on the natural min-watermark of all registered position handles.
965        self.replay_state.clear_flush_fence();
966    }
967
968    async fn set_bootstrap_provider(
969        &self,
970        provider: Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>,
971    ) {
972        self.base.set_bootstrap_provider(provider).await;
973    }
974}
975
976/// Builder for PostgreSQL source configuration.
977///
978/// Provides a fluent API for constructing PostgreSQL source configurations
979/// with sensible defaults.
980///
981/// # Example
982///
983/// ```rust,no_run
984/// use drasi_source_postgres::PostgresReplicationSource;
985///
986/// # fn main() -> anyhow::Result<()> {
987/// let source = PostgresReplicationSource::builder("pg-source")
988///     .with_host("db.example.com")
989///     .with_database("production")
990///     .with_user("replication_user")
991///     .with_password("secret")
992///     .with_tables(vec!["users".to_string(), "orders".to_string()])
993///     .with_slot_name("my_slot")
994///     .build()?;
995/// # Ok(())
996/// # }
997/// ```
998pub struct PostgresSourceBuilder {
999    id: String,
1000    host: String,
1001    port: u16,
1002    database: String,
1003    user: String,
1004    password: String,
1005    tables: Vec<String>,
1006    slot_name: String,
1007    publication_name: String,
1008    ssl_mode: SslMode,
1009    table_keys: Vec<TableKeyConfig>,
1010    dispatch_mode: Option<DispatchMode>,
1011    dispatch_buffer_capacity: Option<usize>,
1012    bootstrap_provider: Option<Box<dyn drasi_lib::bootstrap::BootstrapProvider + 'static>>,
1013    auto_start: bool,
1014}
1015
1016impl PostgresSourceBuilder {
1017    /// Create a new PostgreSQL source builder with the given ID and default values
1018    pub fn new(id: impl Into<String>) -> Self {
1019        Self {
1020            id: id.into(),
1021            host: "localhost".to_string(),
1022            port: 5432,
1023            database: String::new(),
1024            user: String::new(),
1025            password: String::new(),
1026            tables: Vec::new(),
1027            slot_name: "drasi_slot".to_string(),
1028            publication_name: "drasi_publication".to_string(),
1029            ssl_mode: SslMode::default(),
1030            table_keys: Vec::new(),
1031            dispatch_mode: None,
1032            dispatch_buffer_capacity: None,
1033            bootstrap_provider: None,
1034            auto_start: true,
1035        }
1036    }
1037
1038    /// Set the PostgreSQL host
1039    pub fn with_host(mut self, host: impl Into<String>) -> Self {
1040        self.host = host.into();
1041        self
1042    }
1043
1044    /// Set the PostgreSQL port
1045    pub fn with_port(mut self, port: u16) -> Self {
1046        self.port = port;
1047        self
1048    }
1049
1050    /// Set the database name
1051    pub fn with_database(mut self, database: impl Into<String>) -> Self {
1052        self.database = database.into();
1053        self
1054    }
1055
1056    /// Set the database user
1057    pub fn with_user(mut self, user: impl Into<String>) -> Self {
1058        self.user = user.into();
1059        self
1060    }
1061
1062    /// Set the database password
1063    pub fn with_password(mut self, password: impl Into<String>) -> Self {
1064        self.password = password.into();
1065        self
1066    }
1067
1068    /// Set the tables to replicate
1069    pub fn with_tables(mut self, tables: Vec<String>) -> Self {
1070        self.tables = tables;
1071        self
1072    }
1073
1074    /// Add a table to replicate
1075    pub fn add_table(mut self, table: impl Into<String>) -> Self {
1076        self.tables.push(table.into());
1077        self
1078    }
1079
1080    /// Set the replication slot name
1081    pub fn with_slot_name(mut self, slot_name: impl Into<String>) -> Self {
1082        self.slot_name = slot_name.into();
1083        self
1084    }
1085
1086    /// Set the publication name
1087    pub fn with_publication_name(mut self, publication_name: impl Into<String>) -> Self {
1088        self.publication_name = publication_name.into();
1089        self
1090    }
1091
1092    /// Set the SSL mode
1093    pub fn with_ssl_mode(mut self, ssl_mode: SslMode) -> Self {
1094        self.ssl_mode = ssl_mode;
1095        self
1096    }
1097
1098    /// Set the table key configurations
1099    pub fn with_table_keys(mut self, table_keys: Vec<TableKeyConfig>) -> Self {
1100        self.table_keys = table_keys;
1101        self
1102    }
1103
1104    /// Add a table key configuration
1105    pub fn add_table_key(mut self, table_key: TableKeyConfig) -> Self {
1106        self.table_keys.push(table_key);
1107        self
1108    }
1109
1110    /// Set the dispatch mode for this source
1111    pub fn with_dispatch_mode(mut self, mode: DispatchMode) -> Self {
1112        self.dispatch_mode = Some(mode);
1113        self
1114    }
1115
1116    /// Set the dispatch buffer capacity for this source
1117    pub fn with_dispatch_buffer_capacity(mut self, capacity: usize) -> Self {
1118        self.dispatch_buffer_capacity = Some(capacity);
1119        self
1120    }
1121
1122    /// Set the bootstrap provider for this source
1123    pub fn with_bootstrap_provider(
1124        mut self,
1125        provider: impl drasi_lib::bootstrap::BootstrapProvider + 'static,
1126    ) -> Self {
1127        self.bootstrap_provider = Some(Box::new(provider));
1128        self
1129    }
1130
1131    /// Set whether this source should auto-start when DrasiLib starts.
1132    ///
1133    /// Default is `true`. Set to `false` if this source should only be
1134    /// started manually via `start_source()`.
1135    pub fn with_auto_start(mut self, auto_start: bool) -> Self {
1136        self.auto_start = auto_start;
1137        self
1138    }
1139
1140    /// Set the full configuration at once
1141    pub fn with_config(mut self, config: PostgresSourceConfig) -> Self {
1142        self.host = config.host;
1143        self.port = config.port;
1144        self.database = config.database;
1145        self.user = config.user;
1146        self.password = config.password;
1147        self.tables = config.tables;
1148        self.slot_name = config.slot_name;
1149        self.publication_name = config.publication_name;
1150        self.ssl_mode = config.ssl_mode;
1151        self.table_keys = config.table_keys;
1152        self
1153    }
1154
1155    /// Build the PostgreSQL replication source
1156    ///
1157    /// # Errors
1158    ///
1159    /// Returns an error if the source cannot be constructed.
1160    pub fn build(self) -> Result<PostgresReplicationSource> {
1161        let config = PostgresSourceConfig {
1162            host: self.host,
1163            port: self.port,
1164            database: self.database,
1165            user: self.user,
1166            password: self.password,
1167            tables: self.tables,
1168            slot_name: self.slot_name,
1169            publication_name: self.publication_name,
1170            ssl_mode: self.ssl_mode,
1171            table_keys: self.table_keys,
1172        };
1173
1174        let mut params = SourceBaseParams::new(&self.id).with_auto_start(self.auto_start);
1175        if let Some(mode) = self.dispatch_mode {
1176            params = params.with_dispatch_mode(mode);
1177        }
1178        if let Some(capacity) = self.dispatch_buffer_capacity {
1179            params = params.with_dispatch_buffer_capacity(capacity);
1180        }
1181        if let Some(provider) = self.bootstrap_provider {
1182            params = params.with_bootstrap_provider(provider);
1183        }
1184
1185        Ok(PostgresReplicationSource {
1186            base: SourceBase::new(params)?,
1187            config,
1188            cached_schema: Arc::new(std::sync::RwLock::new(None)),
1189            replay_state: Arc::new(ReplayState::default()),
1190            subscribe_lock: Mutex::new(()),
1191        })
1192    }
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197    use super::*;
1198
1199    mod construction {
1200        use super::*;
1201
1202        #[test]
1203        fn test_builder_with_valid_config() {
1204            let source = PostgresSourceBuilder::new("test-source")
1205                .with_database("testdb")
1206                .with_user("testuser")
1207                .build();
1208            assert!(source.is_ok());
1209        }
1210
1211        #[test]
1212        fn test_builder_with_custom_config() {
1213            let source = PostgresSourceBuilder::new("pg-source")
1214                .with_host("192.168.1.100")
1215                .with_port(5433)
1216                .with_database("production")
1217                .with_user("admin")
1218                .with_password("secret")
1219                .build()
1220                .unwrap();
1221            assert_eq!(source.id(), "pg-source");
1222        }
1223
1224        #[test]
1225        fn test_with_dispatch_creates_source() {
1226            let config = PostgresSourceConfig {
1227                host: "localhost".to_string(),
1228                port: 5432,
1229                database: "testdb".to_string(),
1230                user: "testuser".to_string(),
1231                password: String::new(),
1232                tables: Vec::new(),
1233                slot_name: "drasi_slot".to_string(),
1234                publication_name: "drasi_publication".to_string(),
1235                ssl_mode: SslMode::default(),
1236                table_keys: Vec::new(),
1237            };
1238            let source = PostgresReplicationSource::with_dispatch(
1239                "dispatch-source",
1240                config,
1241                Some(DispatchMode::Channel),
1242                Some(2000),
1243            );
1244            assert!(source.is_ok());
1245            assert_eq!(source.unwrap().id(), "dispatch-source");
1246        }
1247    }
1248
1249    mod properties {
1250        use super::*;
1251
1252        #[test]
1253        fn test_id_returns_correct_value() {
1254            let source = PostgresSourceBuilder::new("my-pg-source")
1255                .with_database("db")
1256                .with_user("user")
1257                .build()
1258                .unwrap();
1259            assert_eq!(source.id(), "my-pg-source");
1260        }
1261
1262        #[test]
1263        fn test_type_name_returns_postgres() {
1264            let source = PostgresSourceBuilder::new("test")
1265                .with_database("db")
1266                .with_user("user")
1267                .build()
1268                .unwrap();
1269            assert_eq!(source.type_name(), "postgres");
1270        }
1271
1272        #[test]
1273        fn test_properties_contains_connection_info() {
1274            let source = PostgresSourceBuilder::new("test")
1275                .with_host("db.example.com")
1276                .with_port(5433)
1277                .with_database("mydb")
1278                .with_user("app_user")
1279                .with_password("secret")
1280                .with_tables(vec!["users".to_string()])
1281                .build()
1282                .unwrap();
1283            let props = source.properties();
1284
1285            assert_eq!(
1286                props.get("host"),
1287                Some(&serde_json::Value::String("db.example.com".to_string()))
1288            );
1289            assert_eq!(
1290                props.get("port"),
1291                Some(&serde_json::Value::Number(5433.into()))
1292            );
1293            assert_eq!(
1294                props.get("database"),
1295                Some(&serde_json::Value::String("mydb".to_string()))
1296            );
1297            assert_eq!(
1298                props.get("user"),
1299                Some(&serde_json::Value::String("app_user".to_string()))
1300            );
1301        }
1302
1303        #[test]
1304        fn test_properties_includes_password() {
1305            let source = PostgresSourceBuilder::new("test")
1306                .with_database("db")
1307                .with_user("user")
1308                .with_password("super_secret_password")
1309                .build()
1310                .unwrap();
1311            let props = source.properties();
1312
1313            // Password must be preserved for config persistence roundtrip
1314            assert_eq!(
1315                props.get("password"),
1316                Some(&serde_json::Value::String(
1317                    "super_secret_password".to_string()
1318                ))
1319            );
1320        }
1321
1322        #[test]
1323        fn test_properties_includes_tables() {
1324            let source = PostgresSourceBuilder::new("test")
1325                .with_database("db")
1326                .with_user("user")
1327                .with_tables(vec!["users".to_string(), "orders".to_string()])
1328                .build()
1329                .unwrap();
1330            let props = source.properties();
1331
1332            let tables = props.get("tables").unwrap().as_array().unwrap();
1333            assert_eq!(tables.len(), 2);
1334            assert_eq!(tables[0], "users");
1335            assert_eq!(tables[1], "orders");
1336        }
1337
1338        #[test]
1339        fn test_describe_schema_falls_back_to_configured_tables() {
1340            let source = PostgresSourceBuilder::new("test")
1341                .with_database("db")
1342                .with_user("user")
1343                .with_tables(vec!["public.users".to_string(), "orders".to_string()])
1344                .build()
1345                .unwrap();
1346
1347            let schema = source
1348                .describe_schema()
1349                .expect("configured postgres tables should produce fallback schema");
1350
1351            assert_eq!(schema.nodes.len(), 2);
1352            assert!(schema.nodes.iter().any(|node| node.label == "users"));
1353            assert!(schema.nodes.iter().any(|node| node.label == "orders"));
1354        }
1355
1356        #[test]
1357        fn test_postgres_type_to_property_type_integer() {
1358            assert_eq!(
1359                postgres_type_to_property_type("integer"),
1360                Some(PropertyType::Integer)
1361            );
1362            assert_eq!(
1363                postgres_type_to_property_type("bigint"),
1364                Some(PropertyType::Integer)
1365            );
1366            assert_eq!(
1367                postgres_type_to_property_type("smallint"),
1368                Some(PropertyType::Integer)
1369            );
1370        }
1371
1372        #[test]
1373        fn test_postgres_type_to_property_type_float() {
1374            assert_eq!(
1375                postgres_type_to_property_type("double precision"),
1376                Some(PropertyType::Float)
1377            );
1378            assert_eq!(
1379                postgres_type_to_property_type("real"),
1380                Some(PropertyType::Float)
1381            );
1382            assert_eq!(
1383                postgres_type_to_property_type("numeric"),
1384                Some(PropertyType::Float)
1385            );
1386            assert_eq!(
1387                postgres_type_to_property_type("decimal"),
1388                Some(PropertyType::Float)
1389            );
1390        }
1391
1392        #[test]
1393        fn test_postgres_type_to_property_type_boolean() {
1394            assert_eq!(
1395                postgres_type_to_property_type("boolean"),
1396                Some(PropertyType::Boolean)
1397            );
1398        }
1399
1400        #[test]
1401        fn test_postgres_type_to_property_type_timestamp() {
1402            assert_eq!(
1403                postgres_type_to_property_type("timestamp with time zone"),
1404                Some(PropertyType::Timestamp)
1405            );
1406            assert_eq!(
1407                postgres_type_to_property_type("timestamp without time zone"),
1408                Some(PropertyType::Timestamp)
1409            );
1410            assert_eq!(
1411                postgres_type_to_property_type("date"),
1412                Some(PropertyType::Timestamp)
1413            );
1414        }
1415
1416        #[test]
1417        fn test_postgres_type_to_property_type_json() {
1418            assert_eq!(
1419                postgres_type_to_property_type("json"),
1420                Some(PropertyType::Json)
1421            );
1422            assert_eq!(
1423                postgres_type_to_property_type("jsonb"),
1424                Some(PropertyType::Json)
1425            );
1426        }
1427
1428        #[test]
1429        fn test_postgres_type_to_property_type_string() {
1430            assert_eq!(
1431                postgres_type_to_property_type("character varying"),
1432                Some(PropertyType::String)
1433            );
1434            assert_eq!(
1435                postgres_type_to_property_type("text"),
1436                Some(PropertyType::String)
1437            );
1438            assert_eq!(
1439                postgres_type_to_property_type("uuid"),
1440                Some(PropertyType::String)
1441            );
1442        }
1443
1444        #[test]
1445        fn test_postgres_type_to_property_type_unknown_returns_none() {
1446            assert_eq!(postgres_type_to_property_type("point"), None);
1447            assert_eq!(postgres_type_to_property_type("polygon"), None);
1448            assert_eq!(postgres_type_to_property_type("cidr"), None);
1449        }
1450    }
1451
1452    mod lifecycle {
1453        use super::*;
1454
1455        /// A test secret resolver that returns a fixed value for any secret name.
1456        struct TestSecretResolver;
1457
1458        #[async_trait::async_trait]
1459        impl drasi_plugin_sdk::resolver::ValueResolver for TestSecretResolver {
1460            async fn resolve_to_string(
1461                &self,
1462                value: &drasi_plugin_sdk::ConfigValue<String>,
1463            ) -> Result<String, drasi_plugin_sdk::resolver::ResolverError> {
1464                match value {
1465                    drasi_plugin_sdk::ConfigValue::Secret { name } => {
1466                        Ok(format!("resolved-secret-{name}"))
1467                    }
1468                    _ => Err(drasi_plugin_sdk::resolver::ResolverError::WrongResolverType),
1469                }
1470            }
1471        }
1472
1473        fn ensure_test_secret_resolver() {
1474            drasi_plugin_sdk::resolver::register_secret_resolver(std::sync::Arc::new(
1475                TestSecretResolver,
1476            ));
1477        }
1478
1479        #[tokio::test]
1480        async fn test_descriptor_preserves_secret_envelope() {
1481            use crate::descriptor::PostgresSourceDescriptor;
1482            use drasi_lib::sources::Source;
1483            use drasi_plugin_sdk::descriptor::SourcePluginDescriptor;
1484
1485            ensure_test_secret_resolver();
1486
1487            let config_json = serde_json::json!({
1488                "host": "db.example.com",
1489                "port": 5432,
1490                "database": "mydb",
1491                "user": "app_user",
1492                "password": {
1493                    "kind": "Secret",
1494                    "name": "db-password"
1495                },
1496                "tables": ["users"],
1497                "slotName": "drasi_slot",
1498                "publicationName": "drasi_pub"
1499            });
1500
1501            let descriptor = PostgresSourceDescriptor;
1502            let source = descriptor
1503                .create_source("pg-secret-test", &config_json, true)
1504                .await
1505                .expect("descriptor should create source");
1506
1507            let props = source.properties();
1508
1509            // Password must be the Secret envelope, NOT the resolved value
1510            let password = props.get("password").expect("password must be present");
1511            assert!(
1512                password.is_object(),
1513                "password should be Secret envelope, got: {password}"
1514            );
1515            assert_eq!(
1516                password.get("kind").and_then(|v| v.as_str()),
1517                Some("Secret"),
1518                "envelope kind must be Secret"
1519            );
1520            assert_eq!(
1521                password.get("name").and_then(|v| v.as_str()),
1522                Some("db-password"),
1523                "secret name must be preserved"
1524            );
1525
1526            // Resolved value must NOT leak into persisted properties
1527            let props_str = serde_json::to_string(&props).unwrap();
1528            assert!(
1529                !props_str.contains("resolved-secret-db-password"),
1530                "resolved secret must not appear in properties"
1531            );
1532
1533            // Keys must be camelCase (from raw_config)
1534            assert!(
1535                props.contains_key("slotName"),
1536                "expected camelCase 'slotName', got keys: {:?}",
1537                props.keys().collect::<Vec<_>>()
1538            );
1539            assert!(
1540                props.contains_key("publicationName"),
1541                "expected camelCase 'publicationName'"
1542            );
1543        }
1544
1545        #[tokio::test]
1546        async fn test_initial_status_is_stopped() {
1547            let source = PostgresSourceBuilder::new("test")
1548                .with_database("db")
1549                .with_user("user")
1550                .build()
1551                .unwrap();
1552            assert_eq!(source.status().await, ComponentStatus::Stopped);
1553        }
1554
1555        #[test]
1556        fn test_builder_fallback_produces_camel_case() {
1557            use drasi_lib::sources::Source;
1558
1559            let source = PostgresSourceBuilder::new("pg-fallback")
1560                .with_host("myhost.example.com")
1561                .with_port(5433)
1562                .with_database("mydb")
1563                .with_user("admin")
1564                .with_password("secret123")
1565                .with_ssl_mode(SslMode::Require)
1566                .with_slot_name("custom_slot")
1567                .with_publication_name("custom_pub")
1568                .build()
1569                .unwrap();
1570
1571            let props = source.properties();
1572
1573            // Must use camelCase keys (DTO serialization)
1574            assert!(
1575                props.contains_key("slotName"),
1576                "expected camelCase 'slotName', got keys: {:?}",
1577                props.keys().collect::<Vec<_>>()
1578            );
1579            assert!(
1580                props.contains_key("publicationName"),
1581                "expected camelCase 'publicationName'"
1582            );
1583            assert!(
1584                props.contains_key("sslMode"),
1585                "expected camelCase 'sslMode'"
1586            );
1587
1588            // Must NOT have snake_case keys
1589            assert!(
1590                !props.contains_key("slot_name"),
1591                "should not have snake_case 'slot_name'"
1592            );
1593            assert!(
1594                !props.contains_key("publication_name"),
1595                "should not have snake_case 'publication_name'"
1596            );
1597
1598            // Values should be correct
1599            assert_eq!(
1600                props.get("host").and_then(|v| v.as_str()),
1601                Some("myhost.example.com")
1602            );
1603            assert_eq!(props.get("port").and_then(|v| v.as_u64()), Some(5433));
1604            assert_eq!(props.get("database").and_then(|v| v.as_str()), Some("mydb"));
1605            assert_eq!(
1606                props.get("password").and_then(|v| v.as_str()),
1607                Some("secret123")
1608            );
1609        }
1610
1611        #[tokio::test]
1612        async fn test_pause_replication_for_restart_aborts_existing_task() {
1613            let source = PostgresSourceBuilder::new("test")
1614                .with_database("db")
1615                .with_user("user")
1616                .build()
1617                .unwrap();
1618
1619            source.base.set_status(ComponentStatus::Running, None).await;
1620
1621            let task = tokio::spawn(async {
1622                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1623            });
1624            *source.base.task_handle.write().await = Some(task);
1625
1626            source.pause_replication_for_restart(42).await;
1627
1628            assert!(source.base.task_handle.read().await.is_none());
1629            assert_eq!(source.status().await, ComponentStatus::Starting);
1630        }
1631
1632        #[test]
1633        fn test_supports_replay_returns_true() {
1634            let source = PostgresSourceBuilder::new("test")
1635                .with_database("db")
1636                .with_user("user")
1637                .build()
1638                .unwrap();
1639            assert!(source.supports_replay());
1640        }
1641    }
1642
1643    mod subscribe {
1644        use super::*;
1645        use drasi_lib::config::SourceSubscriptionSettings;
1646        use std::collections::HashSet;
1647
1648        #[tokio::test]
1649        async fn test_malformed_resume_from_rejected() {
1650            let source = PostgresSourceBuilder::new("test-source")
1651                .with_database("testdb")
1652                .with_user("testuser")
1653                .build()
1654                .unwrap();
1655
1656            // 4 bytes instead of expected 8
1657            let bad_position = bytes::Bytes::from(vec![0u8; 4]);
1658            let settings = SourceSubscriptionSettings {
1659                source_id: "test-source".to_string(),
1660                query_id: "q-bad-position".to_string(),
1661                enable_bootstrap: false,
1662                nodes: HashSet::new(),
1663                relations: HashSet::new(),
1664                resume_from: Some(bad_position),
1665                request_position_handle: false,
1666            };
1667
1668            let result = source.subscribe(settings).await;
1669            assert!(result.is_err());
1670            let err_msg = format!("{}", result.err().unwrap());
1671            assert!(
1672                err_msg.contains("expected 8 bytes"),
1673                "Error should mention expected byte length, got: {err_msg}"
1674            );
1675        }
1676    }
1677
1678    mod builder {
1679        use super::*;
1680
1681        #[test]
1682        fn test_postgres_builder_defaults() {
1683            let source = PostgresSourceBuilder::new("test").build().unwrap();
1684            assert_eq!(source.config.host, "localhost");
1685            assert_eq!(source.config.port, 5432);
1686            assert_eq!(source.config.slot_name, "drasi_slot");
1687            assert_eq!(source.config.publication_name, "drasi_publication");
1688        }
1689
1690        #[test]
1691        fn test_postgres_builder_custom_values() {
1692            let source = PostgresSourceBuilder::new("test")
1693                .with_host("db.example.com")
1694                .with_port(5433)
1695                .with_database("production")
1696                .with_user("app_user")
1697                .with_password("secret")
1698                .with_tables(vec!["users".to_string(), "orders".to_string()])
1699                .build()
1700                .unwrap();
1701
1702            assert_eq!(source.config.host, "db.example.com");
1703            assert_eq!(source.config.port, 5433);
1704            assert_eq!(source.config.database, "production");
1705            assert_eq!(source.config.user, "app_user");
1706            assert_eq!(source.config.password, "secret");
1707            assert_eq!(source.config.tables.len(), 2);
1708            assert_eq!(source.config.tables[0], "users");
1709            assert_eq!(source.config.tables[1], "orders");
1710        }
1711
1712        #[test]
1713        fn test_builder_add_table() {
1714            let source = PostgresSourceBuilder::new("test")
1715                .add_table("table1")
1716                .add_table("table2")
1717                .add_table("table3")
1718                .build()
1719                .unwrap();
1720
1721            assert_eq!(source.config.tables.len(), 3);
1722            assert_eq!(source.config.tables[0], "table1");
1723            assert_eq!(source.config.tables[1], "table2");
1724            assert_eq!(source.config.tables[2], "table3");
1725        }
1726
1727        #[test]
1728        fn test_builder_slot_and_publication() {
1729            let source = PostgresSourceBuilder::new("test")
1730                .with_slot_name("custom_slot")
1731                .with_publication_name("custom_pub")
1732                .build()
1733                .unwrap();
1734
1735            assert_eq!(source.config.slot_name, "custom_slot");
1736            assert_eq!(source.config.publication_name, "custom_pub");
1737        }
1738
1739        #[test]
1740        fn test_builder_id() {
1741            let source = PostgresReplicationSource::builder("my-pg-source")
1742                .with_database("db")
1743                .with_user("user")
1744                .build()
1745                .unwrap();
1746
1747            assert_eq!(source.base.id, "my-pg-source");
1748        }
1749    }
1750
1751    mod config {
1752        use super::*;
1753
1754        #[test]
1755        fn test_config_serialization() {
1756            let config = PostgresSourceConfig {
1757                host: "localhost".to_string(),
1758                port: 5432,
1759                database: "testdb".to_string(),
1760                user: "testuser".to_string(),
1761                password: String::new(),
1762                tables: Vec::new(),
1763                slot_name: "drasi_slot".to_string(),
1764                publication_name: "drasi_publication".to_string(),
1765                ssl_mode: SslMode::default(),
1766                table_keys: Vec::new(),
1767            };
1768
1769            let json = serde_json::to_string(&config).unwrap();
1770            let deserialized: PostgresSourceConfig = serde_json::from_str(&json).unwrap();
1771
1772            assert_eq!(config, deserialized);
1773        }
1774
1775        #[test]
1776        fn test_config_deserialization_with_required_fields() {
1777            let json = r#"{
1778                "database": "mydb",
1779                "user": "myuser"
1780            }"#;
1781            let config: PostgresSourceConfig = serde_json::from_str(json).unwrap();
1782
1783            assert_eq!(config.database, "mydb");
1784            assert_eq!(config.user, "myuser");
1785            assert_eq!(config.host, "localhost"); // default
1786            assert_eq!(config.port, 5432); // default
1787            assert_eq!(config.slot_name, "drasi_slot"); // default
1788        }
1789
1790        #[test]
1791        fn test_config_deserialization_full() {
1792            let json = r#"{
1793                "host": "db.prod.internal",
1794                "port": 5433,
1795                "database": "production",
1796                "user": "replication_user",
1797                "password": "secret",
1798                "tables": ["accounts", "transactions"],
1799                "slot_name": "prod_slot",
1800                "publication_name": "prod_publication"
1801            }"#;
1802            let config: PostgresSourceConfig = serde_json::from_str(json).unwrap();
1803
1804            assert_eq!(config.host, "db.prod.internal");
1805            assert_eq!(config.port, 5433);
1806            assert_eq!(config.database, "production");
1807            assert_eq!(config.user, "replication_user");
1808            assert_eq!(config.password, "secret");
1809            assert_eq!(config.tables, vec!["accounts", "transactions"]);
1810            assert_eq!(config.slot_name, "prod_slot");
1811            assert_eq!(config.publication_name, "prod_publication");
1812        }
1813    }
1814}
1815
1816/// Dynamic plugin entry point.
1817///
1818/// Dynamic plugin entry point.
1819#[cfg(feature = "dynamic-plugin")]
1820drasi_plugin_sdk::export_plugin!(
1821    plugin_id = "postgres-source",
1822    core_version = env!("CARGO_PKG_VERSION"),
1823    lib_version = env!("CARGO_PKG_VERSION"),
1824    plugin_version = env!("CARGO_PKG_VERSION"),
1825    source_descriptors = [descriptor::PostgresSourceDescriptor],
1826    reaction_descriptors = [],
1827    bootstrap_descriptors = [],
1828);