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