Skip to main content

drasi_bootstrap_postgres/
postgres.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//! PostgreSQL bootstrap provider for reading initial data from PostgreSQL databases
16
17use anyhow::{anyhow, Context, Result};
18use async_trait::async_trait;
19use bytes::Bytes;
20use drasi_core::models::{
21    Element, ElementMetadata, ElementPropertyMap, ElementReference, SourceChange,
22};
23use drasi_postgres_common::{
24    oid_from_information_schema, parse_bytea_text, PostgresValue, BOOL, BYTEA, CHAR, DATE, FLOAT4,
25    FLOAT8, INT2, INT4, INT8, JSON, JSONB, NAME, NUMERIC, TEXT, TIME, TIMESTAMP, TIMESTAMPTZ, UUID,
26    VARCHAR,
27};
28use log::{debug, error, info, warn};
29use std::collections::HashMap;
30use std::sync::Arc;
31use tokio_postgres::{Client, NoTls, Row, Transaction};
32
33use drasi_lib::bootstrap::{
34    BootstrapContext, BootstrapProvider, BootstrapRequest, BootstrapResult,
35};
36use drasi_lib::channels::SourceChangeEvent;
37
38pub use crate::config::{PostgresBootstrapConfig, SslMode, TableKeyConfig};
39
40fn parse_lsn(lsn_str: &str) -> Result<u64> {
41    let parts: Vec<&str> = lsn_str.split('/').collect();
42    if parts.len() != 2 {
43        return Err(anyhow!("Invalid LSN format: {lsn_str}"));
44    }
45
46    let high = u64::from_str_radix(parts[0], 16)
47        .with_context(|| format!("Invalid high bits in LSN: {lsn_str}"))?;
48    let low = u64::from_str_radix(parts[1], 16)
49        .with_context(|| format!("Invalid low bits in LSN: {lsn_str}"))?;
50
51    Ok((high << 32) | low)
52}
53
54/// Encodes the bootstrap snapshot boundary as a 16-byte source-position
55/// `[ snapshot_lsn (8 BE) | u64::MAX (8 BE) ]`, matching the CDC source's
56/// `[ commit_lsn | in-transaction offset ]` encoding.
57///
58/// Padding the offset with `u64::MAX` preserves the exact handover boundary:
59/// a CDC transaction whose `commit_lsn == snapshot_lsn` (already contained in
60/// the snapshot) stays suppressed, while every transaction committing after the
61/// snapshot is delivered. See issue #599.
62pub fn snapshot_position_bytes(snapshot_lsn: u64) -> Bytes {
63    let mut buf = Vec::with_capacity(16);
64    buf.extend_from_slice(&snapshot_lsn.to_be_bytes());
65    buf.extend_from_slice(&u64::MAX.to_be_bytes());
66    Bytes::from(buf)
67}
68
69/// Bootstrap provider for PostgreSQL sources
70///
71/// This provider takes its configuration directly at construction time,
72/// following the instance-based plugin architecture.
73pub struct PostgresBootstrapProvider {
74    config: PostgresConfig,
75}
76
77impl PostgresBootstrapProvider {
78    /// Create a new PostgreSQL bootstrap provider with the given configuration
79    pub fn new(postgres_config: PostgresBootstrapConfig) -> Self {
80        Self {
81            config: PostgresConfig::from_bootstrap_config(postgres_config),
82        }
83    }
84
85    /// Create a builder for PostgresBootstrapProvider
86    pub fn builder() -> PostgresBootstrapProviderBuilder {
87        PostgresBootstrapProviderBuilder::new()
88    }
89}
90
91/// Builder for PostgresBootstrapProvider
92///
93/// # Example
94///
95/// ```no_run
96/// use drasi_bootstrap_postgres::PostgresBootstrapProvider;
97///
98/// let provider = PostgresBootstrapProvider::builder()
99///     .with_host("localhost")
100///     .with_port(5432)
101///     .with_database("mydb")
102///     .with_user("postgres")
103///     .with_password("secret")
104///     .with_tables(vec!["users".to_string()])
105///     .build();
106/// ```
107pub struct PostgresBootstrapProviderBuilder {
108    host: String,
109    port: u16,
110    database: String,
111    user: String,
112    password: String,
113    tables: Vec<String>,
114    slot_name: String,
115    publication_name: String,
116    ssl_mode: SslMode,
117    table_keys: Vec<TableKeyConfig>,
118}
119
120impl PostgresBootstrapProviderBuilder {
121    /// Create a new builder with default values
122    pub fn new() -> Self {
123        Self {
124            host: "localhost".to_string(), // DevSkim: ignore DS137138
125            port: 5432,
126            database: String::new(),
127            user: String::new(),
128            password: String::new(),
129            tables: Vec::new(),
130            slot_name: "drasi_slot".to_string(),
131            publication_name: "drasi_pub".to_string(),
132            ssl_mode: SslMode::Disable,
133            table_keys: Vec::new(),
134        }
135    }
136
137    /// Set the PostgreSQL host
138    pub fn with_host(mut self, host: impl Into<String>) -> Self {
139        self.host = host.into();
140        self
141    }
142
143    /// Set the PostgreSQL port
144    pub fn with_port(mut self, port: u16) -> Self {
145        self.port = port;
146        self
147    }
148
149    /// Set the database name
150    pub fn with_database(mut self, database: impl Into<String>) -> Self {
151        self.database = database.into();
152        self
153    }
154
155    /// Set the username
156    pub fn with_user(mut self, user: impl Into<String>) -> Self {
157        self.user = user.into();
158        self
159    }
160
161    /// Set the password
162    pub fn with_password(mut self, password: impl Into<String>) -> Self {
163        self.password = password.into();
164        self
165    }
166
167    /// Set the tables to bootstrap
168    pub fn with_tables(mut self, tables: Vec<String>) -> Self {
169        self.tables = tables;
170        self
171    }
172
173    /// Add a table to bootstrap
174    pub fn with_table(mut self, table: impl Into<String>) -> Self {
175        self.tables.push(table.into());
176        self
177    }
178
179    /// Set the replication slot name
180    pub fn with_slot_name(mut self, slot_name: impl Into<String>) -> Self {
181        self.slot_name = slot_name.into();
182        self
183    }
184
185    /// Set the publication name
186    pub fn with_publication_name(mut self, publication_name: impl Into<String>) -> Self {
187        self.publication_name = publication_name.into();
188        self
189    }
190
191    /// Set the SSL mode
192    pub fn with_ssl_mode(mut self, ssl_mode: SslMode) -> Self {
193        self.ssl_mode = ssl_mode;
194        self
195    }
196
197    /// Set the table key configurations
198    pub fn with_table_keys(mut self, table_keys: Vec<TableKeyConfig>) -> Self {
199        self.table_keys = table_keys;
200        self
201    }
202
203    /// Add a table key configuration
204    pub fn with_table_key(mut self, table: impl Into<String>, key_columns: Vec<String>) -> Self {
205        self.table_keys.push(TableKeyConfig {
206            table: table.into(),
207            key_columns,
208        });
209        self
210    }
211
212    /// Build the PostgresBootstrapProvider
213    pub fn build(self) -> PostgresBootstrapProvider {
214        let config = PostgresBootstrapConfig {
215            host: self.host,
216            port: self.port,
217            database: self.database,
218            user: self.user,
219            password: self.password,
220            tables: self.tables,
221            slot_name: self.slot_name,
222            publication_name: self.publication_name,
223            ssl_mode: self.ssl_mode,
224            table_keys: self.table_keys,
225        };
226        PostgresBootstrapProvider::new(config)
227    }
228}
229
230impl Default for PostgresBootstrapProviderBuilder {
231    fn default() -> Self {
232        Self::new()
233    }
234}
235
236#[async_trait]
237impl BootstrapProvider for PostgresBootstrapProvider {
238    async fn bootstrap(
239        &self,
240        request: BootstrapRequest,
241        context: &BootstrapContext,
242        event_tx: drasi_lib::channels::BootstrapEventSender,
243        _settings: Option<&drasi_lib::config::SourceSubscriptionSettings>,
244    ) -> Result<BootstrapResult> {
245        info!(
246            "Starting PostgreSQL bootstrap for query '{}' with {} node labels and {} relation labels",
247            request.query_id,
248            request.node_labels.len(),
249            request.relation_labels.len()
250        );
251
252        // Create bootstrap handler with pre-configured settings
253        let mut handler =
254            PostgresBootstrapHandler::new(self.config.clone(), context.source_id.clone());
255
256        // Store query_id before moving request
257        let query_id = request.query_id.clone();
258
259        // Execute bootstrap
260        let (count, source_position) = handler.execute(request, context, event_tx).await?;
261
262        info!("Completed PostgreSQL bootstrap for query {query_id}: sent {count} records");
263
264        Ok(BootstrapResult {
265            event_count: count,
266            source_position: Some(source_position),
267        })
268    }
269}
270
271/// PostgreSQL configuration extracted from source properties
272#[derive(Debug, Clone)]
273struct PostgresConfig {
274    pub host: String,
275    pub port: u16,
276    pub database: String,
277    pub user: String,
278    pub password: String,
279    #[allow(dead_code)]
280    pub tables: Vec<String>,
281    #[allow(dead_code)]
282    pub slot_name: String,
283    #[allow(dead_code)]
284    pub publication_name: String,
285    #[allow(dead_code)]
286    pub ssl_mode: SslMode,
287    pub table_keys: Vec<TableKeyConfig>,
288}
289
290impl PostgresConfig {
291    fn from_bootstrap_config(postgres_config: PostgresBootstrapConfig) -> Self {
292        PostgresConfig {
293            host: postgres_config.host.clone(),
294            port: postgres_config.port,
295            database: postgres_config.database.clone(),
296            user: postgres_config.user.clone(),
297            password: postgres_config.password.clone(),
298            tables: postgres_config.tables.clone(),
299            slot_name: postgres_config.slot_name.clone(),
300            publication_name: postgres_config.publication_name.clone(),
301            ssl_mode: postgres_config.ssl_mode,
302            table_keys: postgres_config.table_keys.clone(),
303        }
304    }
305}
306
307/// Handles bootstrap operations for PostgreSQL source
308struct PostgresBootstrapHandler {
309    config: PostgresConfig,
310    source_id: String,
311    /// Stores primary key information for each table
312    table_primary_keys: HashMap<String, Vec<String>>,
313}
314
315impl PostgresBootstrapHandler {
316    fn new(config: PostgresConfig, source_id: String) -> Self {
317        Self {
318            config,
319            source_id,
320            table_primary_keys: HashMap::new(),
321        }
322    }
323
324    /// Execute bootstrap for the given request
325    async fn execute(
326        &mut self,
327        request: BootstrapRequest,
328        context: &BootstrapContext,
329        event_tx: drasi_lib::channels::BootstrapEventSender,
330    ) -> Result<(usize, Bytes)> {
331        info!(
332            "Bootstrap: Connecting to PostgreSQL at {}:{}",
333            self.config.host, self.config.port
334        );
335
336        // Connect to PostgreSQL
337        let mut client = self.connect().await?;
338
339        // Query and cache primary key information
340        self.query_primary_keys(&client).await?;
341
342        info!("Bootstrap: Connected, creating snapshot transaction...");
343        // Start snapshot transaction and capture LSN
344        let (transaction, snapshot_lsn) = self.create_snapshot(&mut client).await?;
345        let source_position = snapshot_position_bytes(snapshot_lsn);
346
347        info!("Bootstrap snapshot created at LSN: {snapshot_lsn:x}");
348
349        // Resolve labels to verified table names
350        let tables = self.resolve_tables(&request, &transaction).await?;
351        info!(
352            "Resolved {} labels to {} tables",
353            request.node_labels.len() + request.relation_labels.len(),
354            tables.len()
355        );
356
357        // Fetch and stream data from each table
358        let mut total_count = 0;
359        for table in &tables {
360            let count = self
361                .bootstrap_table(&transaction, table, context, &event_tx)
362                .await?;
363            info!("Bootstrapped {count} rows from table '{table}'");
364            total_count += count;
365        }
366
367        // Commit transaction to release snapshot
368        transaction.commit().await?;
369
370        info!("Bootstrap completed: {total_count} total elements sent");
371        Ok((total_count, source_position))
372    }
373
374    /// Create a regular PostgreSQL connection
375    async fn connect(&self) -> Result<Client> {
376        let connection_string = format!(
377            "host={} port={} user={} password={} dbname={}",
378            self.config.host,
379            self.config.port,
380            self.config.user,
381            self.config.password,
382            self.config.database
383        );
384
385        let (client, connection) = tokio_postgres::connect(&connection_string, NoTls).await?;
386
387        // Spawn connection handler
388        tokio::spawn(async move {
389            if let Err(e) = connection.await {
390                error!("PostgreSQL connection error: {e}");
391            }
392        });
393
394        Ok(client)
395    }
396
397    /// Create a consistent snapshot and capture current LSN
398    async fn create_snapshot<'a>(&self, client: &'a mut Client) -> Result<(Transaction<'a>, u64)> {
399        // Start transaction with repeatable read isolation
400        let transaction = client
401            .build_transaction()
402            .isolation_level(tokio_postgres::IsolationLevel::RepeatableRead)
403            .start()
404            .await?;
405
406        // Capture current LSN for replication coordination
407        let row = transaction
408            .query_one("SELECT pg_current_wal_lsn()::text", &[])
409            .await?;
410        let lsn: String = row.get(0);
411        let lsn = parse_lsn(&lsn).context("Failed to parse PostgreSQL snapshot LSN")?;
412
413        Ok((transaction, lsn))
414    }
415
416    /// Resolve requested labels to verified table names.
417    /// Labels are used as-is (case-sensitive) to match PostgreSQL table names,
418    /// ensuring consistency with the CDC stream which also uses the actual table name.
419    async fn resolve_tables(
420        &self,
421        request: &BootstrapRequest,
422        transaction: &Transaction<'_>,
423    ) -> Result<Vec<String>> {
424        let mut tables = Vec::new();
425
426        // Combine all labels (treating nodes and relations the same)
427        let all_labels: Vec<String> = request
428            .node_labels
429            .iter()
430            .chain(request.relation_labels.iter())
431            .cloned()
432            .collect();
433
434        for label in all_labels {
435            if self.table_exists(transaction, &label).await? {
436                tables.push(label);
437            } else {
438                warn!("Table '{label}' does not exist, skipping");
439            }
440        }
441
442        Ok(tables)
443    }
444
445    /// Check if a table exists in the database
446    async fn table_exists(&self, transaction: &Transaction<'_>, table_name: &str) -> Result<bool> {
447        let row = transaction
448            .query_one(
449                "SELECT EXISTS (
450                    SELECT 1 FROM information_schema.tables
451                    WHERE table_schema = 'public'
452                    AND table_name = $1
453                )",
454                &[&table_name],
455            )
456            .await?;
457
458        Ok(row.get(0))
459    }
460
461    /// Bootstrap all data from a single table
462    async fn bootstrap_table(
463        &self,
464        transaction: &Transaction<'_>,
465        table: &str,
466        context: &BootstrapContext,
467        event_tx: &drasi_lib::channels::BootstrapEventSender,
468    ) -> Result<usize> {
469        debug!("Starting bootstrap of table '{table}'");
470
471        // Get table columns for proper type handling
472        let columns = self.get_table_columns(transaction, table).await?;
473
474        // Quote table name to preserve case
475        let query = format!("SELECT * FROM \"{}\"", table.replace('"', "\"\""));
476        let rows = transaction.query(&query, &[]).await?;
477
478        let mut count = 0;
479        let mut batch = Vec::new();
480        let batch_size = 1000;
481
482        for row in rows {
483            let source_change = self.row_to_source_change(&row, table, &columns).await?;
484
485            batch.push(SourceChangeEvent {
486                source_id: self.source_id.clone(),
487                change: source_change,
488                timestamp: chrono::Utc::now(),
489                sequence: None,
490            });
491
492            if batch.len() >= batch_size {
493                self.send_batch(&mut batch, context, event_tx).await?;
494                count += batch_size;
495            }
496        }
497
498        // Send remaining batch
499        if !batch.is_empty() {
500            count += batch.len();
501            self.send_batch(&mut batch, context, event_tx).await?;
502        }
503
504        Ok(count)
505    }
506
507    /// Get column information for a table
508    async fn get_table_columns(
509        &self,
510        transaction: &Transaction<'_>,
511        table_name: &str,
512    ) -> Result<Vec<ColumnInfo>> {
513        // Prefer pg_attribute + pg_type: use atttypid, but resolve domains to typbasetype
514        // so CREATE DOMAIN ... AS integer reads as int4 (not an unknown domain OID).
515        // Fall back to information_schema mapping if the catalog query fails.
516        let catalog_result = transaction
517            .query(
518                "SELECT a.attname AS column_name,
519                        CASE
520                          WHEN t.typtype = 'd' THEN t.typbasetype
521                          ELSE a.atttypid
522                        END::int4 AS type_oid
523                 FROM pg_attribute a
524                 JOIN pg_class c ON a.attrelid = c.oid
525                 JOIN pg_namespace n ON c.relnamespace = n.oid
526                 JOIN pg_type t ON a.atttypid = t.oid
527                 WHERE n.nspname = 'public'
528                   AND c.relname = $1
529                   AND a.attnum > 0
530                   AND NOT a.attisdropped
531                 ORDER BY a.attnum",
532                &[&table_name],
533            )
534            .await;
535
536        if let Ok(rows) = catalog_result {
537            if !rows.is_empty() {
538                let mut columns = Vec::new();
539                for row in rows {
540                    columns.push(ColumnInfo {
541                        name: row.get(0),
542                        type_oid: row.get::<_, i32>(1),
543                    });
544                }
545                return Ok(columns);
546            }
547        }
548
549        let rows = transaction
550            .query(
551                "SELECT column_name, data_type, udt_name
552                 FROM information_schema.columns
553                 WHERE table_schema = 'public' AND table_name = $1
554                 ORDER BY ordinal_position",
555                &[&table_name],
556            )
557            .await?;
558
559        let mut columns = Vec::new();
560        for row in rows {
561            let name: String = row.get(0);
562            let data_type: String = row.get(1);
563            let udt_name: String = row.get(2);
564            let type_oid = oid_from_information_schema(&data_type, Some(&udt_name)) as i32;
565            columns.push(ColumnInfo { name, type_oid });
566        }
567
568        Ok(columns)
569    }
570
571    /// Query primary key information for all tables in the database.
572    async fn query_primary_keys(&mut self, client: &Client) -> Result<()> {
573        info!("Querying primary key information from PostgreSQL system catalogs");
574
575        let query = r#"
576            SELECT
577                n.nspname as schema_name,
578                c.relname as table_name,
579                a.attname as column_name
580            FROM pg_constraint con
581            JOIN pg_class c ON con.conrelid = c.oid
582            JOIN pg_namespace n ON c.relnamespace = n.oid
583            JOIN pg_attribute a ON a.attrelid = c.oid
584            WHERE con.contype = 'p'  -- Primary key constraint
585                AND a.attnum = ANY(con.conkey)
586                AND n.nspname NOT IN ('pg_catalog', 'information_schema')
587            ORDER BY n.nspname, c.relname, array_position(con.conkey, a.attnum)
588        "#;
589
590        let rows = client.query(query, &[]).await?;
591
592        let mut primary_keys: HashMap<String, Vec<String>> = HashMap::new();
593
594        for row in rows {
595            let schema: &str = row.get(0);
596            let table: &str = row.get(1);
597            let column: &str = row.get(2);
598
599            // Use fully qualified table name if not in public schema
600            let table_key = if schema == "public" {
601                table.to_string()
602            } else {
603                format!("{schema}.{table}")
604            };
605
606            primary_keys
607                .entry(table_key.clone())
608                .or_default()
609                .push(column.to_string());
610
611            debug!("Found primary key column '{column}' for table '{table_key}'");
612        }
613
614        // Add user-configured key columns (these override detected ones)
615        for table_key_config in &self.config.table_keys {
616            let table_name = &table_key_config.table;
617            let key_columns = &table_key_config.key_columns;
618
619            if !key_columns.is_empty() {
620                info!(
621                    "Using user-configured key columns for table '{table_name}': {key_columns:?}"
622                );
623                primary_keys.insert(table_name.clone(), key_columns.clone());
624            }
625        }
626
627        // Store the primary keys
628        self.table_primary_keys = primary_keys.clone();
629
630        info!("Found primary keys for {} tables", primary_keys.len());
631        for (table, keys) in &primary_keys {
632            info!("Table '{table}' primary key columns: {keys:?}");
633        }
634
635        Ok(())
636    }
637
638    /// Convert a PostgreSQL row to a SourceChange
639    async fn row_to_source_change(
640        &self,
641        row: &Row,
642        table: &str,
643        columns: &[ColumnInfo],
644    ) -> Result<SourceChange> {
645        let mut properties = ElementPropertyMap::new();
646
647        // Get primary key columns for this table
648        let pk_columns = self.table_primary_keys.get(table);
649
650        // Collect values for element ID generation
651        let mut pk_values = Vec::new();
652
653        for (idx, column) in columns.iter().enumerate() {
654            // Check if this column is a primary key
655            let is_pk = pk_columns
656                .map(|pks| pks.contains(&column.name))
657                .unwrap_or(false);
658
659            // Convert via shared PostgresValue so bootstrap ≡ CDC (#670/#672).
660            let pg_value = row_column_to_postgres_value(row, idx, column.type_oid as u32);
661            let element_value = pg_value.to_element_value();
662
663            // If this is a primary key column, collect its value for the element ID
664            if is_pk {
665                if let Some(value_str) = pg_value.to_key_string() {
666                    pk_values.push(value_str);
667                }
668            }
669
670            properties.insert(&column.name, element_value);
671        }
672
673        // Generate element ID based on primary key values
674        // Always include table name as prefix to ensure uniqueness across tables
675        let elem_id = if !pk_values.is_empty() {
676            // Use table name prefix with primary key values
677            format!("{}:{}", table, pk_values.join("_"))
678        } else if pk_columns.is_none() || pk_columns.map(|pks| pks.is_empty()).unwrap_or(true) {
679            // No primary key defined and none configured - require user configuration
680            warn!(
681                "No primary key found for table '{table}'. Consider adding 'table_keys' configuration."
682            );
683            // Generate a UUID as fallback with table prefix
684            format!("{}:{}", table, uuid::Uuid::new_v4())
685        } else {
686            // Primary key columns defined but all values are NULL - use UUID with table prefix
687            format!("{}:{}", table, uuid::Uuid::new_v4())
688        };
689
690        let metadata = ElementMetadata {
691            reference: ElementReference::new(&self.source_id, &elem_id),
692            labels: Arc::from(vec![Arc::from(table)]),
693            effective_from: chrono::Utc::now().timestamp_millis() as u64,
694        };
695
696        let element = Element::Node {
697            metadata,
698            properties,
699        };
700
701        Ok(SourceChange::Insert { element })
702    }
703
704    /// Send a batch of changes through the channel
705    async fn send_batch(
706        &self,
707        batch: &mut Vec<SourceChangeEvent>,
708        context: &BootstrapContext,
709        event_tx: &drasi_lib::channels::BootstrapEventSender,
710    ) -> Result<()> {
711        for event in batch.drain(..) {
712            // Get next sequence number for this bootstrap event
713            let sequence = context.next_sequence();
714
715            let bootstrap_event = drasi_lib::channels::BootstrapEvent {
716                source_id: event.source_id,
717                change: event.change,
718                timestamp: event.timestamp,
719                sequence,
720            };
721            event_tx.send(bootstrap_event).await.map_err(|e| {
722                anyhow!("Failed to send bootstrap event to channel (channel may be closed): {e}")
723            })?;
724        }
725        Ok(())
726    }
727}
728
729#[derive(Debug)]
730struct ColumnInfo {
731    name: String,
732    type_oid: i32,
733}
734
735/// Read a single column from a tokio-postgres `Row` into the shared `PostgresValue`.
736fn row_column_to_postgres_value(row: &Row, idx: usize, type_oid: u32) -> PostgresValue {
737    // Helper: Option try_get that maps None → Null
738    macro_rules! opt {
739        ($t:ty, $map:expr) => {
740            match row.try_get::<_, Option<$t>>(idx) {
741                Ok(Some(v)) => $map(v),
742                Ok(None) => PostgresValue::Null,
743                Err(e) => {
744                    warn!(
745                        "Failed to read column idx={idx} oid={type_oid} as {}: {e}",
746                        stringify!($t)
747                    );
748                    // Last resort: string
749                    match row.try_get::<_, Option<String>>(idx) {
750                        Ok(Some(s)) => PostgresValue::Text(s),
751                        Ok(None) => PostgresValue::Null,
752                        Err(_) => PostgresValue::Null,
753                    }
754                }
755            }
756        };
757    }
758
759    match type_oid {
760        BOOL => opt!(bool, PostgresValue::Bool),
761        INT2 => opt!(i16, PostgresValue::Int2),
762        INT4 => opt!(i32, PostgresValue::Int4),
763        INT8 => opt!(i64, PostgresValue::Int8),
764        FLOAT4 => opt!(f32, PostgresValue::Float4),
765        FLOAT8 => opt!(f64, PostgresValue::Float8),
766        NUMERIC => opt!(rust_decimal::Decimal, PostgresValue::Numeric),
767        TEXT | NAME => opt!(String, PostgresValue::Text),
768        VARCHAR => opt!(String, PostgresValue::Varchar),
769        CHAR => match row.try_get::<_, Option<String>>(idx) {
770            Ok(Some(s)) => PostgresValue::Char(s.trim_end().to_string()),
771            Ok(None) => PostgresValue::Null,
772            Err(e) => {
773                warn!("Failed to read char column idx={idx}: {e}");
774                PostgresValue::Null
775            }
776        },
777        UUID => opt!(uuid::Uuid, PostgresValue::Uuid),
778        TIMESTAMP => opt!(chrono::NaiveDateTime, PostgresValue::Timestamp),
779        TIMESTAMPTZ => opt!(chrono::DateTime<chrono::Utc>, PostgresValue::TimestampTz),
780        DATE => opt!(chrono::NaiveDate, PostgresValue::Date),
781        TIME => opt!(chrono::NaiveTime, PostgresValue::Time),
782        JSON => opt!(serde_json::Value, PostgresValue::Json),
783        JSONB => opt!(serde_json::Value, PostgresValue::Jsonb),
784        BYTEA => match row.try_get::<_, Option<Vec<u8>>>(idx) {
785            Ok(Some(bytes)) => PostgresValue::Bytea(bytes),
786            Ok(None) => PostgresValue::Null,
787            Err(e) => {
788                // Sometimes delivered as text \x...
789                warn!("bytea try_get failed idx={idx}: {e}; trying text");
790                match row.try_get::<_, Option<String>>(idx) {
791                    Ok(Some(s)) => match parse_bytea_text(&s) {
792                        Ok(b) => PostgresValue::Bytea(b),
793                        Err(_) => PostgresValue::Text(s),
794                    },
795                    Ok(None) => PostgresValue::Null,
796                    Err(_) => PostgresValue::Null,
797                }
798            }
799        },
800        // Common 1-D arrays. Use Vec<Option<T>> so NULL elements stay Null
801        // (Vec<T> cannot represent '{1,NULL,3}' and would fail try_get).
802        1007 /* int4[] */ => match row.try_get::<_, Option<Vec<Option<i32>>>>(idx) {
803            Ok(Some(v)) => PostgresValue::Array(
804                v.into_iter()
805                    .map(|o| o.map(PostgresValue::Int4).unwrap_or(PostgresValue::Null))
806                    .collect(),
807            ),
808            Ok(None) => PostgresValue::Null,
809            Err(_) => try_array_as_text(row, idx, type_oid),
810        },
811        1009 /* text[] */ => match row.try_get::<_, Option<Vec<Option<String>>>>(idx) {
812            Ok(Some(v)) => PostgresValue::Array(
813                v.into_iter()
814                    .map(|o| o.map(PostgresValue::Text).unwrap_or(PostgresValue::Null))
815                    .collect(),
816            ),
817            Ok(None) => PostgresValue::Null,
818            Err(_) => try_array_as_text(row, idx, type_oid),
819        },
820        1016 /* int8[] */ => match row.try_get::<_, Option<Vec<Option<i64>>>>(idx) {
821            Ok(Some(v)) => PostgresValue::Array(
822                v.into_iter()
823                    .map(|o| o.map(PostgresValue::Int8).unwrap_or(PostgresValue::Null))
824                    .collect(),
825            ),
826            Ok(None) => PostgresValue::Null,
827            Err(_) => try_array_as_text(row, idx, type_oid),
828        },
829        1005 /* int2[] */ => match row.try_get::<_, Option<Vec<Option<i16>>>>(idx) {
830            Ok(Some(v)) => PostgresValue::Array(
831                v.into_iter()
832                    .map(|o| o.map(PostgresValue::Int2).unwrap_or(PostgresValue::Null))
833                    .collect(),
834            ),
835            Ok(None) => PostgresValue::Null,
836            Err(_) => try_array_as_text(row, idx, type_oid),
837        },
838        1000 /* bool[] */ => match row.try_get::<_, Option<Vec<Option<bool>>>>(idx) {
839            Ok(Some(v)) => PostgresValue::Array(
840                v.into_iter()
841                    .map(|o| o.map(PostgresValue::Bool).unwrap_or(PostgresValue::Null))
842                    .collect(),
843            ),
844            Ok(None) => PostgresValue::Null,
845            Err(_) => try_array_as_text(row, idx, type_oid),
846        },
847        _ => {
848            // Unknown / other arrays: try string, else Null
849            match row.try_get::<_, Option<String>>(idx) {
850                Ok(Some(s)) => {
851                    // If it looks like an array literal, try shared text decode
852                    if s.starts_with('{') {
853                        match drasi_postgres_common::decode_text_to_postgres_value(&s, type_oid) {
854                            Ok(v) => v,
855                            Err(_) => PostgresValue::Text(s),
856                        }
857                    } else {
858                        PostgresValue::Text(s)
859                    }
860                }
861                Ok(None) => PostgresValue::Null,
862                Err(e) => {
863                    warn!(
864                        "Unsupported PG type oid={type_oid} at idx={idx}: {e}; emitting Null"
865                    );
866                    PostgresValue::Null
867                }
868            }
869        }
870    }
871}
872
873fn try_array_as_text(row: &Row, idx: usize, array_oid: u32) -> PostgresValue {
874    match row.try_get::<_, Option<String>>(idx) {
875        Ok(Some(s)) => match drasi_postgres_common::decode_text_to_postgres_value(&s, array_oid) {
876            Ok(v) => v,
877            Err(_) => PostgresValue::Text(s),
878        },
879        Ok(None) => PostgresValue::Null,
880        Err(_) => PostgresValue::Null,
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use drasi_core::models::validate_effective_from;
887
888    /// Validates that the timestamp pattern used in convert_row_to_source_change
889    /// produces a value in the millisecond range, not nanoseconds.
890    ///
891    /// This test would have caught the original bug where timestamp_nanos_opt()
892    /// was used instead of timestamp_millis().
893    #[test]
894    fn effective_from_uses_milliseconds() {
895        let effective_from = chrono::Utc::now().timestamp_millis() as u64;
896        assert!(
897            validate_effective_from(effective_from).is_ok(),
898            "Postgres bootstrapper effective_from ({effective_from}) should be in millisecond range"
899        );
900    }
901
902    /// Verifies that using nanoseconds would be caught by the validator.
903    #[test]
904    fn effective_from_rejects_nanoseconds_pattern() {
905        // This is the OLD buggy pattern — should fail validation
906        let bad_effective_from = chrono::Utc::now().timestamp_nanos_opt().unwrap() as u64;
907        assert!(
908            validate_effective_from(bad_effective_from).is_err(),
909            "Nanosecond timestamp ({bad_effective_from}) should be rejected"
910        );
911    }
912
913    /// The bootstrap snapshot boundary must be the 16-byte
914    /// `[ snapshot_lsn (8 BE) | u64::MAX (8 BE) ]` encoding so it lines up with
915    /// the CDC source's `[ commit_lsn | offset ]` positions and suppresses every
916    /// change of a transaction whose commit_lsn equals the snapshot LSN.
917    #[test]
918    fn snapshot_position_bytes_is_16_byte_max_padded() {
919        let lsn = 0x0000_0000_0152_00b0u64;
920        let pos = super::snapshot_position_bytes(lsn);
921        assert_eq!(pos.len(), 16, "snapshot boundary must be 16 bytes");
922
923        let mut expected = Vec::with_capacity(16);
924        expected.extend_from_slice(&lsn.to_be_bytes());
925        expected.extend_from_slice(&u64::MAX.to_be_bytes());
926        assert_eq!(pos.as_ref(), expected.as_slice());
927    }
928}