1use 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
56pub 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
71pub struct PostgresBootstrapProvider {
76 config: PostgresConfig,
77}
78
79impl PostgresBootstrapProvider {
80 pub fn new(postgres_config: PostgresBootstrapConfig) -> Self {
82 Self {
83 config: PostgresConfig::from_bootstrap_config(postgres_config),
84 }
85 }
86
87 pub fn builder() -> PostgresBootstrapProviderBuilder {
89 PostgresBootstrapProviderBuilder::new()
90 }
91}
92
93pub 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 pub fn new() -> Self {
125 Self {
126 host: "localhost".to_string(), 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 pub fn with_host(mut self, host: impl Into<String>) -> Self {
141 self.host = host.into();
142 self
143 }
144
145 pub fn with_port(mut self, port: u16) -> Self {
147 self.port = port;
148 self
149 }
150
151 pub fn with_database(mut self, database: impl Into<String>) -> Self {
153 self.database = database.into();
154 self
155 }
156
157 pub fn with_user(mut self, user: impl Into<String>) -> Self {
159 self.user = user.into();
160 self
161 }
162
163 pub fn with_password(mut self, password: impl Into<String>) -> Self {
165 self.password = password.into();
166 self
167 }
168
169 pub fn with_tables(mut self, tables: Vec<String>) -> Self {
171 self.tables = tables;
172 self
173 }
174
175 pub fn with_table(mut self, table: impl Into<String>) -> Self {
177 self.tables.push(table.into());
178 self
179 }
180
181 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 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 pub fn with_ssl_mode(mut self, ssl_mode: SslMode) -> Self {
195 self.ssl_mode = ssl_mode;
196 self
197 }
198
199 pub fn with_table_keys(mut self, table_keys: Vec<TableKeyConfig>) -> Self {
201 self.table_keys = table_keys;
202 self
203 }
204
205 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 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 let mut handler =
256 PostgresBootstrapHandler::new(self.config.clone(), context.source_id.clone());
257
258 let query_id = request.query_id.clone();
260
261 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#[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
309struct PostgresBootstrapHandler {
311 config: PostgresConfig,
312 source_id: String,
313 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 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 let mut client = self.connect().await?;
340
341 self.query_primary_keys(&client).await?;
343
344 info!("Bootstrap: Connected, creating snapshot transaction...");
345 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 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 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 transaction.commit().await?;
371
372 info!("Bootstrap completed: {total_count} total elements sent");
373 Ok((total_count, source_position))
374 }
375
376 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 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 async fn create_snapshot<'a>(&self, client: &'a mut Client) -> Result<(Transaction<'a>, u64)> {
401 let transaction = client
403 .build_transaction()
404 .isolation_level(tokio_postgres::IsolationLevel::RepeatableRead)
405 .start()
406 .await?;
407
408 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 async fn resolve_tables(
422 &self,
423 request: &BootstrapRequest,
424 transaction: &Transaction<'_>,
425 ) -> Result<Vec<String>> {
426 let mut tables = Vec::new();
427
428 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 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 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 let columns = self.get_table_columns(transaction, table).await?;
475
476 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 async fn get_table_columns(
508 &self,
509 transaction: &Transaction<'_>,
510 table_name: &str,
511 ) -> Result<Vec<ColumnInfo>> {
512 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 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 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 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 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 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 let pk_columns = self.table_primary_keys.get(table);
648
649 let mut pk_values = Vec::new();
651
652 for (idx, column) in columns.iter().enumerate() {
653 let is_pk = pk_columns
655 .map(|pks| pks.contains(&column.name))
656 .unwrap_or(false);
657
658 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 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 let elem_id = if !pk_values.is_empty() {
675 format!("{}:{}", table, pk_values.join("_"))
677 } else if pk_columns.is_none() || pk_columns.map(|pks| pks.is_empty()).unwrap_or(true) {
678 warn!(
680 "No primary key found for table '{table}'. Consider adding 'table_keys' configuration."
681 );
682 format!("{}:{}", table, uuid::Uuid::new_v4())
684 } else {
685 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
710fn row_column_to_postgres_value(row: &Row, idx: usize, type_oid: u32) -> PostgresValue {
712 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 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 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 1007 => 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 => 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 => 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 => 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 => 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 match row.try_get::<_, Option<String>>(idx) {
825 Ok(Some(s)) => {
826 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 #[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 #[test]
879 fn effective_from_rejects_nanoseconds_pattern() {
880 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 #[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}