Skip to main content

krabka_connect_postgres/
source.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2
3use async_trait::async_trait;
4use bytes::Bytes;
5use indexmap::IndexSet;
6use krabka_connect::{ConnectError, ConnectRecord, OffsetValue, Source, SourceOffset};
7
8use crate::{
9    PgLsn, PostgresSourceConfig,
10    catalog::{PgCatalog, TokioPgCatalog},
11    ids::{CommitLsn, EndLsn, TransactionId},
12    model::Operation,
13    pgoutput::{DecodedMessage, RelationCache, RelationEvent, RowEvent, decode_pgoutput_message},
14    schema::PostgresProtoEncoder,
15};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum LogicalEvent {
19    Begin {
20        final_lsn: PgLsn,
21        xid: TransactionId,
22    },
23    Commit {
24        commit_lsn: CommitLsn,
25        end_lsn: EndLsn,
26        commit_timestamp_ms: i64,
27    },
28    Relation(RelationEvent),
29    Row(RowEvent),
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33struct TransactionState {
34    xid: TransactionId,
35}
36
37#[derive(Debug)]
38pub struct PostgresWalSource {
39    config: PostgresSourceConfig,
40    database_name: String,
41    catalog: Option<Box<dyn PgCatalog>>,
42    relation_cache: RelationCache,
43    encoder: PostgresProtoEncoder,
44    pending: VecDeque<LogicalEvent>,
45    remaining_transaction_rows: HashMap<PgLsn, usize>,
46    transaction: Option<TransactionState>,
47    transaction_rows: IndexSet<RowEvent>,
48    checkpoint: Option<PgLsn>,
49    resume_lsn: Option<PgLsn>,
50    #[cfg(test)]
51    transaction_boundary_checks: usize,
52}
53
54impl PostgresWalSource {
55    fn build(
56        config: PostgresSourceConfig,
57        database_name: String,
58        catalog: Option<Box<dyn PgCatalog>>,
59        pending: VecDeque<LogicalEvent>,
60        encoder: PostgresProtoEncoder,
61    ) -> Self {
62        let remaining_transaction_rows =
63            pending
64                .iter()
65                .fold(HashMap::<PgLsn, usize>::new(), |mut remaining, event| {
66                    if let LogicalEvent::Row(RowEvent {
67                        commit_lsn: Some(commit_lsn),
68                        ..
69                    }) = event
70                    {
71                        *remaining.entry(*commit_lsn).or_default() += 1;
72                    }
73                    remaining
74                });
75        Self {
76            config,
77            database_name,
78            catalog,
79            relation_cache: RelationCache::default(),
80            encoder,
81            pending,
82            remaining_transaction_rows,
83            transaction: None,
84            transaction_rows: IndexSet::new(),
85            checkpoint: None,
86            resume_lsn: None,
87            #[cfg(test)]
88            transaction_boundary_checks: 0,
89        }
90    }
91
92    /// # Errors
93    /// Returns an error when configuration is invalid, protocol encoding fails, the broker rejects the request, or transport I/O fails.
94    #[cfg(test)]
95    pub fn scripted(
96        config: PostgresSourceConfig,
97        database_name: impl Into<String>,
98        events: impl IntoIterator<Item = LogicalEvent>,
99    ) -> Result<Self, ConnectError> {
100        Ok(Self::build(
101            config,
102            database_name.into(),
103            None,
104            events.into_iter().collect(),
105            PostgresProtoEncoder::for_test()?,
106        ))
107    }
108
109    // cargo-mutants: real DB connection; not exercised under unit tests.
110    #[cfg_attr(test, mutants::skip)]
111    #[tracing::instrument(
112        level = "info",
113        skip_all,
114        fields(slot = %config.slot_name, publication = %config.publication_name, schema = %config.schema),
115        err,
116    )]
117    /// # Errors
118    /// Returns an error when configuration is invalid, protocol encoding fails, the broker rejects the request, or transport I/O fails.
119    pub async fn connect(config: PostgresSourceConfig) -> Result<Self, ConnectError> {
120        let encoder = PostgresProtoEncoder::from_registry(&config.schema_registry_url).await?;
121        let catalog = TokioPgCatalog::connect(config.database_url.expose_secret()).await?;
122        let database_name = initialize(&catalog, &config).await?;
123        Ok(Self::build(
124            config,
125            database_name,
126            Some(Box::new(catalog)),
127            VecDeque::new(),
128            encoder,
129        ))
130    }
131
132    #[cfg(test)]
133    fn with_catalog(
134        config: PostgresSourceConfig,
135        database_name: impl Into<String>,
136        catalog: Box<dyn PgCatalog>,
137    ) -> Result<Self, ConnectError> {
138        Ok(Self::build(
139            config,
140            database_name.into(),
141            Some(catalog),
142            VecDeque::new(),
143            PostgresProtoEncoder::for_test()?,
144        ))
145    }
146
147    #[cfg(test)]
148    fn pending_len(&self) -> usize {
149        self.pending.len()
150    }
151
152    #[tracing::instrument(
153        level = "debug",
154        skip_all,
155        fields(slot = %self.config.slot_name, changes = tracing::field::Empty),
156        err,
157    )]
158    async fn fill_pending_from_slot(&mut self) -> Result<(), ConnectError> {
159        let Some(catalog) = &self.catalog else {
160            return Ok(());
161        };
162
163        // This live seam intentionally peeks instead of getting changes. Peeking
164        // avoids advancing the Postgres replication slot before downstream sink
165        // durability is known; acknowledge advances the slot only after the
166        // runtime has committed the sink and saved the checkpoint.
167        let changes = catalog
168            .peek_changes(
169                &self.config.slot_name,
170                self.config.max_messages_per_poll,
171                &self.config.publication_name,
172            )
173            .await?;
174
175        tracing::Span::current().record("changes", changes.len());
176        for change in changes {
177            let lsn = change.lsn.parse::<PgLsn>()?;
178
179            self.apply_decoded_message(decode_pgoutput_message(&change.data, lsn, None)?);
180        }
181
182        Ok(())
183    }
184
185    fn apply_decoded_message(&mut self, message: DecodedMessage) {
186        match message {
187            DecodedMessage::Begin { final_lsn, xid } => {
188                self.apply_logical_event(LogicalEvent::Begin { final_lsn, xid });
189            }
190            DecodedMessage::Commit {
191                commit_lsn,
192                end_lsn,
193                commit_timestamp_ms,
194            } => self.apply_logical_event(LogicalEvent::Commit {
195                commit_lsn,
196                end_lsn,
197                commit_timestamp_ms,
198            }),
199            DecodedMessage::Relation(relation) => {
200                self.apply_logical_event(LogicalEvent::Relation(relation));
201            }
202            DecodedMessage::Row(row) => self.apply_logical_event(LogicalEvent::Row(row)),
203            DecodedMessage::Keepalive => {}
204        }
205    }
206
207    fn apply_logical_event(&mut self, event: LogicalEvent) {
208        match event {
209            LogicalEvent::Begin { final_lsn: _, xid } => {
210                self.transaction = Some(TransactionState { xid });
211                self.transaction_rows.clear();
212            }
213            LogicalEvent::Commit {
214                commit_lsn: _,
215                end_lsn,
216                commit_timestamp_ms,
217            } => {
218                self.commit_transaction(end_lsn.0, commit_timestamp_ms);
219            }
220            LogicalEvent::Relation(relation) => {
221                self.pending.push_back(LogicalEvent::Relation(relation));
222            }
223            LogicalEvent::Row(row) => {
224                self.enqueue_row(row);
225            }
226        }
227    }
228
229    fn enqueue_row(&mut self, row: RowEvent) {
230        if self.should_skip_row(&row) {
231            return;
232        }
233
234        if self.transaction.is_some() {
235            self.transaction_rows.insert(row);
236        } else {
237            self.pending.push_back(LogicalEvent::Row(row));
238        }
239    }
240
241    fn commit_transaction(&mut self, end_lsn: PgLsn, commit_timestamp_ms: i64) {
242        let Some(transaction) = self.transaction.take() else {
243            return;
244        };
245        if self.should_skip_lsn(end_lsn) {
246            self.transaction_rows.clear();
247            return;
248        }
249
250        let row_count = self.transaction_rows.len();
251        if row_count > 0 {
252            *self.remaining_transaction_rows.entry(end_lsn).or_default() += row_count;
253        }
254
255        for mut row in self.transaction_rows.drain(..) {
256            row.commit_lsn = Some(end_lsn);
257            row.txid = Some(transaction.xid);
258            row.commit_timestamp_ms = Some(commit_timestamp_ms);
259            self.pending.push_back(LogicalEvent::Row(row));
260        }
261    }
262
263    fn should_skip_row(&self, row: &RowEvent) -> bool {
264        let checkpoint_lsn = row.commit_lsn.unwrap_or(row.lsn);
265        if self.should_skip_resume_lsn(checkpoint_lsn) {
266            return true;
267        }
268        if row.commit_lsn.is_some() {
269            return false;
270        }
271
272        self.should_skip_checkpoint_lsn(row.lsn)
273    }
274
275    fn should_skip_lsn(&self, lsn: PgLsn) -> bool {
276        self.should_skip_resume_lsn(lsn) || self.should_skip_checkpoint_lsn(lsn)
277    }
278
279    fn should_skip_resume_lsn(&self, lsn: PgLsn) -> bool {
280        self.resume_lsn.is_some_and(|resume_lsn| lsn <= resume_lsn)
281    }
282
283    fn should_skip_checkpoint_lsn(&self, lsn: PgLsn) -> bool {
284        // Because the live path peeks, Postgres can return rows that were
285        // already emitted and checkpointed in this process. Skip them here so
286        // duplicate peek results do not re-emit before the runtime calls
287        // acknowledge, which advances the replication slot after checkpoint
288        // persistence.
289        self.checkpoint
290            .is_some_and(|checkpoint_lsn| lsn <= checkpoint_lsn)
291    }
292
293    fn finish_pending_transaction_row(&mut self, commit_lsn: Option<PgLsn>) -> bool {
294        let Some(commit_lsn) = commit_lsn else {
295            return true;
296        };
297        #[cfg(test)]
298        {
299            self.transaction_boundary_checks += 1;
300        }
301
302        let Some(remaining) = self.remaining_transaction_rows.get_mut(&commit_lsn) else {
303            return true;
304        };
305        *remaining -= 1;
306        if *remaining == 0 {
307            self.remaining_transaction_rows.remove(&commit_lsn);
308            true
309        } else {
310            false
311        }
312    }
313}
314
315#[async_trait]
316impl Source<Bytes, Bytes> for PostgresWalSource {
317    #[tracing::instrument(
318        level = "debug",
319        skip_all,
320        fields(slot = %self.config.slot_name, table = tracing::field::Empty, op = tracing::field::Empty),
321        err,
322    )]
323    async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
324        if self.pending.is_empty() {
325            self.fill_pending_from_slot().await?;
326        }
327
328        while let Some(event) = self.pending.front().cloned() {
329            match event {
330                LogicalEvent::Begin { .. } | LogicalEvent::Commit { .. } => {
331                    self.pending.pop_front();
332                    self.apply_logical_event(event);
333                }
334                LogicalEvent::Relation(relation) => {
335                    self.pending.pop_front();
336                    self.relation_cache.apply_relation(relation);
337                }
338                LogicalEvent::Row(row) => {
339                    if self.transaction.is_some() && row.commit_lsn.is_none() {
340                        self.pending.pop_front();
341                        self.apply_logical_event(LogicalEvent::Row(row));
342                        continue;
343                    }
344
345                    if self.should_skip_row(&row) {
346                        self.pending.pop_front();
347                        self.finish_pending_transaction_row(row.commit_lsn);
348                        continue;
349                    }
350
351                    let transaction_lsn = row.commit_lsn;
352                    let diff = self.relation_cache.translate(row)?;
353                    let span = tracing::Span::current();
354                    span.record("table", diff.table.as_str());
355                    span.record("op", operation_header(diff.op));
356                    let key = self.encoder.encode_key(&diff.key)?;
357                    let value = if diff.op == Operation::Delete {
358                        None
359                    } else {
360                        Some(self.encoder.encode_value(&diff)?)
361                    };
362                    let mut record = ConnectRecord::new(Some(key), value)
363                        .with_topic(diff.table.clone())
364                        .with_header("krabka.pg.table", Some(Bytes::from(diff.table.clone())))
365                        .with_header("krabka.pg.lsn", Some(Bytes::from(diff.lsn.to_string())))
366                        .with_header(
367                            "krabka.pg.operation",
368                            Some(Bytes::from_static(operation_header(diff.op).as_bytes())),
369                        );
370                    if let Some(commit_timestamp_ms) = diff.commit_timestamp_ms {
371                        record = record.with_timestamp(commit_timestamp_ms);
372                    }
373
374                    self.pending.pop_front();
375                    if self.finish_pending_transaction_row(transaction_lsn) {
376                        self.checkpoint = Some(diff.lsn);
377                    }
378                    return Ok(Some(record));
379                }
380            }
381        }
382
383        Ok(None)
384    }
385
386    fn checkpoint(&self) -> Option<SourceOffset> {
387        self.checkpoint
388            .map(|lsn| lsn.to_source_offset(&self.database_name, &self.config.slot_name))
389    }
390
391    #[tracing::instrument(level = "debug", skip_all, fields(slot = %self.config.slot_name), err)]
392    async fn seek(&mut self, offset: SourceOffset) -> Result<(), ConnectError> {
393        validate_database(&offset, &self.database_name)?;
394        let lsn = PgLsn::from_source_offset(&offset, &self.config.slot_name)?;
395        self.checkpoint = Some(lsn);
396        self.resume_lsn = Some(lsn);
397        Ok(())
398    }
399
400    #[tracing::instrument(level = "debug", skip_all, fields(slot = %self.config.slot_name), err)]
401    async fn acknowledge(&mut self, offset: &SourceOffset) -> Result<(), ConnectError> {
402        validate_database(offset, &self.database_name)?;
403        let lsn = PgLsn::from_source_offset(offset, &self.config.slot_name)?;
404
405        if let Some(catalog) = &self.catalog {
406            let lsn_text = lsn.to_string();
407            catalog
408                .advance_slot(&self.config.slot_name, &lsn_text)
409                .await?;
410        }
411
412        self.resume_lsn = Some(lsn);
413        Ok(())
414    }
415}
416
417fn quote_ident(value: &str) -> String {
418    format!("\"{}\"", value.replace('"', "\"\""))
419}
420
421fn sql_string(value: &str) -> String {
422    format!("'{}'", value.replace('\'', "''"))
423}
424
425fn create_publication_sql(publication: &str, schema: &str, tables: &[String]) -> String {
426    let table_list = tables
427        .iter()
428        .map(|table| format!("{}.{}", quote_ident(schema), quote_ident(table)))
429        .collect::<Vec<_>>()
430        .join(", ");
431    let create_sql = format!(
432        "CREATE PUBLICATION {} FOR TABLE {} WITH (publish = 'insert, update, delete')",
433        quote_ident(publication),
434        table_list
435    );
436
437    format!(
438        "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = {}) THEN EXECUTE {}; END IF; END $$",
439        sql_string(publication),
440        sql_string(&create_sql)
441    )
442}
443
444pub(crate) fn peek_binary_changes_sql(slot: &str, max_messages: u32, publication: &str) -> String {
445    format!(
446        "SELECT lsn::text AS lsn, data FROM pg_logical_slot_peek_binary_changes({}, NULL, {}, 'proto_version', '1', 'publication_names', {})",
447        sql_string(slot),
448        max_messages,
449        sql_string(publication)
450    )
451}
452
453pub(crate) fn publication_tables_sql() -> &'static str {
454    "SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2"
455}
456
457pub(crate) fn publication_settings_sql() -> &'static str {
458    "SELECT pubinsert, pubupdate, pubdelete, pubtruncate FROM pg_publication WHERE pubname = $1"
459}
460
461pub(crate) fn replication_slot_sql() -> &'static str {
462    "SELECT slot_name, plugin, slot_type, database FROM pg_replication_slots WHERE slot_name = $1"
463}
464
465pub(crate) fn create_logical_slot_sql() -> &'static str {
466    "SELECT * FROM pg_create_logical_replication_slot($1, 'pgoutput')"
467}
468
469pub(crate) fn advance_slot_sql() -> &'static str {
470    // `tokio-postgres` can serialize `&str` as text, but not directly as the
471    // PostgreSQL-specific `pg_lsn` type inferred by a single cast.
472    "SELECT pg_replication_slot_advance($1, $2::text::pg_lsn)"
473}
474
475/// Run the one-time connection setup against `catalog`.
476///
477/// The function resolves the database name, creates and validates the
478/// publication when tables are configured, and makes sure that the replication
479/// slot exists. It is separate from [`PostgresWalSource::connect`], so the
480/// orchestration is unit-testable against a [`PgCatalog`] mock.
481#[tracing::instrument(
482    level = "info",
483    skip_all,
484    fields(slot = %config.slot_name, publication = %config.publication_name, tables = config.table_names.len()),
485    err,
486)]
487async fn initialize(
488    catalog: &dyn PgCatalog,
489    config: &PostgresSourceConfig,
490) -> Result<String, ConnectError> {
491    let database_name = catalog.current_database().await?;
492
493    if !config.table_names.is_empty() {
494        catalog
495            .ensure_publication(&create_publication_sql(
496                &config.publication_name,
497                &config.schema,
498                &config.table_names,
499            ))
500            .await?;
501        validate_publication_tables(
502            catalog,
503            &config.publication_name,
504            &config.schema,
505            &config.table_names,
506        )
507        .await?;
508        validate_publication_settings(catalog, &config.publication_name).await?;
509    }
510
511    ensure_slot(catalog, &config.slot_name, &database_name).await?;
512
513    Ok(database_name)
514}
515
516async fn validate_publication_tables(
517    catalog: &dyn PgCatalog,
518    publication: &str,
519    schema: &str,
520    tables: &[String],
521) -> Result<(), ConnectError> {
522    let published_tables = catalog.published_tables(publication, schema).await?;
523    let missing_tables = missing_publication_tables(tables, published_tables);
524
525    if missing_tables.is_empty() {
526        Ok(())
527    } else {
528        Err(ConnectError::Backend(format!(
529            "publication {publication:?} does not cover configured tables: {}",
530            missing_tables.join(", ")
531        )))
532    }
533}
534
535fn missing_publication_tables(
536    tables: &[String],
537    published_tables: impl IntoIterator<Item = String>,
538) -> Vec<String> {
539    let published_tables = published_tables.into_iter().collect::<HashSet<_>>();
540    tables
541        .iter()
542        .filter(|table| !published_tables.contains(*table))
543        .cloned()
544        .collect()
545}
546
547async fn validate_publication_settings(
548    catalog: &dyn PgCatalog,
549    publication: &str,
550) -> Result<(), ConnectError> {
551    let Some(flags) = catalog.publication_settings(publication).await? else {
552        return Ok(());
553    };
554
555    if publication_settings_are_compatible(flags) {
556        Ok(())
557    } else {
558        Err(ConnectError::Backend(format!(
559            "publication {publication:?} must publish insert, update, and delete, and must not publish truncate"
560        )))
561    }
562}
563
564fn publication_settings_are_compatible([insert, update, delete, truncate]: [bool; 4]) -> bool {
565    insert && update && delete && !truncate
566}
567
568async fn ensure_slot(
569    catalog: &dyn PgCatalog,
570    slot_name: &str,
571    database_name: &str,
572) -> Result<(), ConnectError> {
573    let Some(slot) = catalog.replication_slot(slot_name).await? else {
574        catalog.create_logical_slot(slot_name).await?;
575        return Ok(());
576    };
577
578    validate_slot_metadata(
579        slot_name,
580        slot.plugin.as_deref(),
581        &slot.slot_type,
582        slot.database.as_deref(),
583        database_name,
584    )
585}
586
587fn validate_slot_metadata(
588    slot_name: &str,
589    plugin: Option<&str>,
590    slot_type: &str,
591    database: Option<&str>,
592    database_name: &str,
593) -> Result<(), ConnectError> {
594    let mut mismatches = Vec::new();
595
596    if plugin != Some("pgoutput") {
597        mismatches.push(format!("plugin is {plugin:?}"));
598    }
599    if slot_type != "logical" {
600        mismatches.push(format!("slot_type is {slot_type:?}"));
601    }
602    if database != Some(database_name) {
603        mismatches.push(format!("database is {database:?}"));
604    }
605
606    if mismatches.is_empty() {
607        Ok(())
608    } else {
609        Err(ConnectError::Backend(format!(
610            "replication slot {slot_name:?} is not compatible: {}",
611            mismatches.join(", ")
612        )))
613    }
614}
615
616fn validate_database(offset: &SourceOffset, expected_database: &str) -> Result<(), ConnectError> {
617    match offset.partition.get("database") {
618        Some(OffsetValue::String(database)) if database == expected_database => Ok(()),
619        Some(OffsetValue::String(database)) => Err(ConnectError::Offset(format!(
620            "source offset database {database:?} does not match expected database {expected_database:?}"
621        ))),
622        _ => Err(ConnectError::Offset(
623            "source offset missing string database".to_owned(),
624        )),
625    }
626}
627
628fn operation_header(operation: Operation) -> &'static str {
629    match operation {
630        Operation::Insert => "insert",
631        Operation::Update => "update",
632        Operation::Delete => "delete",
633    }
634}
635
636#[cfg(test)]
637mod sql_tests {
638    use assert2::check;
639
640    use super::{
641        advance_slot_sql, create_logical_slot_sql, create_publication_sql,
642        missing_publication_tables, peek_binary_changes_sql, publication_settings_are_compatible,
643        publication_settings_sql, publication_tables_sql, replication_slot_sql,
644        validate_slot_metadata,
645    };
646
647    #[test]
648    fn create_logical_slot_sql_uses_pgoutput_plugin() {
649        check!(
650            create_logical_slot_sql()
651                == "SELECT * FROM pg_create_logical_replication_slot($1, 'pgoutput')"
652        );
653    }
654
655    #[test]
656    fn publication_sql_quotes_identifiers() {
657        let sql = create_publication_sql(
658            "pub\"name",
659            "sch\"ema",
660            &["orders".to_owned(), "line\"items".to_owned()],
661        );
662
663        check!(sql.contains("CREATE PUBLICATION \"pub\"\"name\""));
664        check!(
665            sql.contains("FOR TABLE \"sch\"\"ema\".\"orders\", \"sch\"\"ema\".\"line\"\"items\"")
666        );
667        check!(sql.contains("WHERE pubname = 'pub\"name'"));
668    }
669
670    #[test]
671    fn peek_binary_changes_sql_escapes_string_literals_without_advancing_slot() {
672        let sql = peek_binary_changes_sql("slot'name", 25, "pub'name");
673
674        check!(
675            sql == "SELECT lsn::text AS lsn, data FROM pg_logical_slot_peek_binary_changes('slot''name', NULL, 25, 'proto_version', '1', 'publication_names', 'pub''name')"
676        );
677    }
678
679    #[test]
680    fn publication_sql_excludes_truncate_events() {
681        let sql = create_publication_sql("pub", "public", &["orders".to_owned()]);
682
683        check!(sql.contains("WITH (publish = ''insert, update, delete'')"));
684    }
685
686    #[test]
687    fn validation_sql_cases() {
688        for (_name, actual, expected) in [
689            (
690                "publication_tables",
691                publication_tables_sql(),
692                "SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2",
693            ),
694            (
695                "publication_settings",
696                publication_settings_sql(),
697                "SELECT pubinsert, pubupdate, pubdelete, pubtruncate FROM pg_publication WHERE pubname = $1",
698            ),
699            (
700                "replication_slot",
701                replication_slot_sql(),
702                "SELECT slot_name, plugin, slot_type, database FROM pg_replication_slots WHERE slot_name = $1",
703            ),
704        ] {
705            assert2::assert!(actual == expected);
706        }
707    }
708
709    #[test]
710    fn advance_slot_binds_lsn_as_text_before_server_cast() {
711        check!(advance_slot_sql() == "SELECT pg_replication_slot_advance($1, $2::text::pg_lsn)");
712    }
713
714    #[test]
715    fn publication_table_validation_reports_only_missing_configured_tables() {
716        let missing = missing_publication_tables(
717            &["orders".to_owned(), "accounts".to_owned()],
718            ["orders".to_owned(), "ignored".to_owned()],
719        );
720
721        check!(missing == vec!["accounts".to_owned()]);
722    }
723
724    #[test]
725    fn publication_settings_require_insert_update_delete_without_truncate() {
726        for (name, flags, expected) in [
727            ("required-flags", [true, true, true, false], true),
728            ("missing-insert", [false, true, true, false], false),
729            ("missing-update", [true, false, true, false], false),
730            ("missing-delete", [true, true, false, false], false),
731            ("includes-truncate", [true, true, true, true], false),
732        ] {
733            check!(
734                publication_settings_are_compatible(flags) == expected,
735                "case {name}"
736            );
737        }
738    }
739
740    #[test]
741    fn slot_metadata_accepts_pgoutput_logical_slot_for_current_database() {
742        validate_slot_metadata("slot_a", Some("pgoutput"), "logical", Some("app"), "app")
743            .expect("matching slot should validate");
744    }
745
746    #[test]
747    fn slot_metadata_reports_all_incompatible_fields() {
748        let error =
749            validate_slot_metadata("slot_a", Some("test_decoding"), "physical", None, "app")
750                .expect_err("incompatible slot should fail");
751
752        match error {
753            krabka_connect::ConnectError::Backend(message) => {
754                check!(
755                    (
756                        message.contains("replication slot \"slot_a\" is not compatible"),
757                        message.contains("plugin is Some(\"test_decoding\")"),
758                        message.contains("slot_type is \"physical\""),
759                        message.contains("database is None"),
760                    ) == (true, true, true, true)
761                );
762            }
763            error => panic!("expected backend error, got {error:?}"),
764        }
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use std::collections::VecDeque;
771
772    use assert2::check;
773    use bytes::Bytes;
774    use krabka_connect::{SecretString, Source as _};
775    use krabka_schema_serde::wire::MAGIC;
776
777    use super::{LogicalEvent, PostgresWalSource, TransactionState, validate_database};
778    use crate::{
779        PgLsn, PostgresSourceConfig,
780        ids::{CommitLsn, EndLsn, RelationId, TransactionId},
781        model::{ColumnSchema, ColumnValue, ScalarValue},
782        pgoutput::{RelationEvent, RowEvent, RowEventKind, RowTupleKind},
783    };
784
785    fn header_value(
786        record: &krabka_connect::ConnectRecord<bytes::Bytes, bytes::Bytes>,
787        key: &str,
788    ) -> bytes::Bytes {
789        record
790            .headers
791            .iter()
792            .find(|header| header.key == key)
793            .and_then(|header| header.value.clone())
794            .unwrap_or_else(|| panic!("missing header {key}"))
795    }
796
797    fn config(slot_name: &str) -> PostgresSourceConfig {
798        PostgresSourceConfig {
799            schema_registry_url: "http://localhost:8081".to_owned(),
800            database_url: SecretString::new("postgres://localhost/app"),
801            slot_name: slot_name.to_owned(),
802            publication_name: "krabka_connect".to_owned(),
803            schema: "public".to_owned(),
804            table_names: vec!["orders".to_owned()],
805            max_messages_per_poll: 1000,
806        }
807    }
808
809    fn orders_relation() -> RelationEvent {
810        RelationEvent {
811            relation_id: RelationId(7),
812            schema: "public".to_owned(),
813            table: "orders".to_owned(),
814            columns: vec![
815                ColumnSchema {
816                    name: "id".to_owned(),
817                    type_name: "int8".to_owned(),
818                    key: true,
819                },
820                ColumnSchema {
821                    name: "status".to_owned(),
822                    type_name: "text".to_owned(),
823                    key: false,
824                },
825            ],
826        }
827    }
828
829    fn id(value: i64) -> ColumnValue {
830        ColumnValue {
831            name: "id".to_owned(),
832            value: ScalarValue::Int(value),
833        }
834    }
835
836    fn status(value: &str) -> ColumnValue {
837        ColumnValue {
838            name: "status".to_owned(),
839            value: ScalarValue::Text(value.to_owned()),
840        }
841    }
842
843    fn insert_event(lsn: PgLsn) -> RowEvent {
844        RowEvent {
845            relation_id: RelationId(7),
846            lsn,
847            commit_lsn: None,
848            txid: Some(TransactionId(99)),
849            commit_timestamp_ms: Some(1_700_000_000_000),
850            kind: RowEventKind::Insert,
851            values: vec![id(42), status("paid")],
852        }
853    }
854
855    fn delete_event(lsn: PgLsn) -> RowEvent {
856        RowEvent {
857            relation_id: RelationId(7),
858            lsn,
859            commit_lsn: None,
860            txid: None,
861            commit_timestamp_ms: None,
862            kind: RowEventKind::Delete {
863                tuple_kind: RowTupleKind::Full,
864            },
865            values: vec![id(42), status("cancelled")],
866        }
867    }
868
869    #[test]
870    fn transaction_rows_keep_order_and_distinct_payloads_while_deduplicating_replays() {
871        let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
872        source.transaction = Some(TransactionState {
873            xid: TransactionId(99),
874        });
875        let first = insert_event(PgLsn(42));
876        let mut changed = first.clone();
877        changed.values = vec![id(42), status("shipped")];
878        let mut later = first.clone();
879        later.lsn = PgLsn(43);
880        let deleted = delete_event(PgLsn(44));
881        let expected = vec![
882            first.clone(),
883            changed.clone(),
884            later.clone(),
885            deleted.clone(),
886        ];
887        for row in [first.clone(), changed, first, later, deleted] {
888            source.enqueue_row(row);
889        }
890        assert2::assert!(source.transaction_rows.iter().cloned().collect::<Vec<_>>() == expected);
891        source.commit_transaction(PgLsn(100), 123);
892        let expected = expected
893            .into_iter()
894            .map(|mut row| {
895                row.commit_lsn = Some(PgLsn(100));
896                row.txid = Some(TransactionId(99));
897                row.commit_timestamp_ms = Some(123);
898                LogicalEvent::Row(row)
899            })
900            .collect::<VecDeque<_>>();
901        assert2::assert!(source.pending == expected);
902        assert2::assert!(source.transaction_rows.is_empty());
903
904        source.transaction = Some(TransactionState {
905            xid: TransactionId(100),
906        });
907        source.enqueue_row(insert_event(PgLsn(42)));
908        assert2::assert!(source.transaction_rows.len() == 1);
909    }
910
911    #[tokio::test]
912    async fn insert_poll_emits_framed_record_and_checkpoint() {
913        let mut source = PostgresWalSource::scripted(
914            config("slot_a"),
915            "app",
916            [
917                LogicalEvent::Relation(orders_relation()),
918                LogicalEvent::Row(insert_event(PgLsn(0x2a))),
919            ],
920        )
921        .expect("source builds");
922
923        let record = source
924            .poll()
925            .await
926            .expect("poll succeeds")
927            .expect("row emits");
928
929        check!(
930            (
931                record.key.as_ref().map(|key| key[0]),
932                record.value.as_ref().map(|value| value[0]),
933                record.topic.as_deref(),
934                record.partition,
935                record.timestamp,
936                header_value(&record, "krabka.pg.table"),
937                header_value(&record, "krabka.pg.lsn"),
938                header_value(&record, "krabka.pg.operation"),
939                source.checkpoint(),
940            ) == (
941                Some(MAGIC),
942                Some(MAGIC),
943                Some("public.orders"),
944                None,
945                Some(1_700_000_000_000),
946                Bytes::from_static(b"public.orders"),
947                Bytes::from_static(b"0/2A"),
948                Bytes::from_static(b"insert"),
949                Some(PgLsn(0x2a).to_source_offset("app", "slot_a")),
950            )
951        );
952    }
953
954    #[tokio::test]
955    async fn delete_poll_emits_tombstone() {
956        let mut source = PostgresWalSource::scripted(
957            config("slot_a"),
958            "app",
959            [
960                LogicalEvent::Relation(orders_relation()),
961                LogicalEvent::Row(delete_event(PgLsn(0x2b))),
962            ],
963        )
964        .expect("source builds");
965
966        let record = source
967            .poll()
968            .await
969            .expect("poll succeeds")
970            .expect("row emits");
971
972        check!(
973            (
974                record.key.is_some(),
975                record.value.is_some(),
976                record.topic.as_deref(),
977                record.partition,
978                record.timestamp,
979                header_value(&record, "krabka.pg.table"),
980                header_value(&record, "krabka.pg.lsn"),
981                header_value(&record, "krabka.pg.operation"),
982            ) == (
983                true,
984                false,
985                Some("public.orders"),
986                None,
987                None,
988                Bytes::from_static(b"public.orders"),
989                Bytes::from_static(b"0/2B"),
990                Bytes::from_static(b"delete"),
991            )
992        );
993    }
994
995    #[tokio::test]
996    async fn seek_restores_lsn_checkpoint() {
997        let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
998        let offset = PgLsn(0x2a).to_source_offset("app", "slot_a");
999
1000        source.seek(offset).await.expect("seek succeeds");
1001
1002        check!(source.checkpoint() == Some(PgLsn(0x2a).to_source_offset("app", "slot_a")));
1003    }
1004
1005    #[test]
1006    fn skip_lsn_checks_resume_and_checkpoint_offsets_independently() {
1007        let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
1008        source.resume_lsn = Some(PgLsn(0x20));
1009
1010        check!(source.should_skip_lsn(PgLsn(0x20)));
1011        check!(!source.should_skip_lsn(PgLsn(0x21)));
1012
1013        source.resume_lsn = None;
1014        source.checkpoint = Some(PgLsn(0x30));
1015
1016        check!(source.should_skip_lsn(PgLsn(0x30)));
1017        check!(!source.should_skip_lsn(PgLsn(0x31)));
1018
1019        source.resume_lsn = Some(PgLsn(0x20));
1020        source.checkpoint = Some(PgLsn(0x30));
1021
1022        check!(source.should_skip_lsn(PgLsn(0x20)));
1023        check!(source.should_skip_lsn(PgLsn(0x30)));
1024        check!(!source.should_skip_lsn(PgLsn(0x31)));
1025    }
1026
1027    #[tokio::test]
1028    async fn seek_past_first_row_poll_emits_only_later_row_without_regressing_checkpoint() {
1029        let mut source = PostgresWalSource::scripted(
1030            config("slot_a"),
1031            "app",
1032            [
1033                LogicalEvent::Relation(orders_relation()),
1034                LogicalEvent::Row(insert_event(PgLsn(0x2a))),
1035                LogicalEvent::Row(insert_event(PgLsn(0x2b))),
1036            ],
1037        )
1038        .expect("source builds");
1039
1040        source
1041            .seek(PgLsn(0x2a).to_source_offset("app", "slot_a"))
1042            .await
1043            .expect("seek succeeds");
1044
1045        let record = source
1046            .poll()
1047            .await
1048            .expect("poll succeeds")
1049            .expect("later row emits");
1050
1051        check!(header_value(&record, "krabka.pg.lsn").as_ref() == b"0/2B");
1052        check!(source.checkpoint() == Some(PgLsn(0x2b).to_source_offset("app", "slot_a")));
1053        check!(source.poll().await.expect("poll succeeds").is_none());
1054    }
1055
1056    #[tokio::test]
1057    async fn checkpointed_duplicate_row_is_skipped_without_regressing_checkpoint() {
1058        let mut source = PostgresWalSource::scripted(
1059            config("slot_a"),
1060            "app",
1061            [
1062                LogicalEvent::Relation(orders_relation()),
1063                LogicalEvent::Row(insert_event(PgLsn(0x2a))),
1064                LogicalEvent::Row(insert_event(PgLsn(0x2a))),
1065            ],
1066        )
1067        .expect("source builds");
1068
1069        let record = source
1070            .poll()
1071            .await
1072            .expect("poll succeeds")
1073            .expect("first row emits");
1074
1075        check!(header_value(&record, "krabka.pg.lsn").as_ref() == b"0/2A");
1076        check!(source.poll().await.expect("poll succeeds").is_none());
1077        check!(source.checkpoint() == Some(PgLsn(0x2a).to_source_offset("app", "slot_a")));
1078    }
1079
1080    #[tokio::test]
1081    async fn translation_failure_does_not_drop_pending_row() {
1082        let mut source = PostgresWalSource::scripted(
1083            config("slot_a"),
1084            "app",
1085            [LogicalEvent::Row(insert_event(PgLsn(0x2a)))],
1086        )
1087        .expect("source builds");
1088
1089        check!(source.poll().await.is_err());
1090
1091        check!(source.pending_len() == 1);
1092        check!(source.checkpoint().is_none());
1093    }
1094
1095    #[tokio::test]
1096    async fn seek_rejects_offset_for_different_database() {
1097        let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
1098        let offset = PgLsn(0x2a).to_source_offset("other_app", "slot_a");
1099
1100        let error = source.seek(offset).await.expect_err("database mismatch");
1101
1102        check!(matches!(error, krabka_connect::ConnectError::Offset(_)));
1103        check!(source.checkpoint().is_none());
1104    }
1105
1106    #[test]
1107    fn validate_database_rejects_missing_or_non_string_database_partition() {
1108        let missing = krabka_connect::SourceOffset::default();
1109        let mut non_string = krabka_connect::SourceOffset::default();
1110        non_string
1111            .partition
1112            .0
1113            .insert("database".to_owned(), krabka_connect::OffsetValue::Long(7));
1114
1115        for (_name, offset) in [("missing", missing), ("non_string", non_string)] {
1116            assert2::assert!(matches!(
1117                validate_database(&offset, "app"),
1118                Err(krabka_connect::ConnectError::Offset(_))
1119            ));
1120        }
1121    }
1122
1123    #[test]
1124    fn validate_database_reports_database_mismatch() {
1125        let offset = PgLsn(0x2a).to_source_offset("other_app", "slot_a");
1126
1127        let error = validate_database(&offset, "app").expect_err("database mismatch should fail");
1128
1129        match error {
1130            krabka_connect::ConnectError::Offset(message) => {
1131                check!(message.contains("does not match expected database"));
1132                check!(message.contains("other_app"));
1133                check!(message.contains("app"));
1134            }
1135            error => panic!("expected offset error, got {error:?}"),
1136        }
1137    }
1138
1139    #[tokio::test]
1140    async fn scripted_acknowledge_accepts_matching_offset_and_rejects_database_mismatch() {
1141        let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
1142
1143        source
1144            .acknowledge(&PgLsn(0x2a).to_source_offset("app", "slot_a"))
1145            .await
1146            .expect("matching offset acknowledged");
1147
1148        let error = source
1149            .acknowledge(&PgLsn(0x2b).to_source_offset("other_app", "slot_a"))
1150            .await
1151            .expect_err("database mismatch rejected");
1152
1153        check!(matches!(error, krabka_connect::ConnectError::Offset(_)));
1154    }
1155
1156    #[tokio::test]
1157    async fn relation_only_poll_returns_none_without_checkpoint() {
1158        let mut source = PostgresWalSource::scripted(
1159            config("slot_a"),
1160            "app",
1161            [LogicalEvent::Relation(orders_relation())],
1162        )
1163        .expect("source builds");
1164
1165        check!(source.poll().await.expect("poll succeeds").is_none());
1166        check!(source.checkpoint().is_none());
1167    }
1168
1169    #[tokio::test]
1170    async fn transaction_commit_metadata_is_applied_to_all_rows() {
1171        let mut second = insert_event(PgLsn(0x2b));
1172        second.values = vec![id(43), status("pending")];
1173        let mut source = PostgresWalSource::scripted(
1174            config("slot_a"),
1175            "app",
1176            [
1177                LogicalEvent::Relation(orders_relation()),
1178                LogicalEvent::Begin {
1179                    final_lsn: PgLsn(0x40),
1180                    xid: TransactionId(123),
1181                },
1182                LogicalEvent::Row(insert_event(PgLsn(0x2a))),
1183                LogicalEvent::Row(second),
1184                LogicalEvent::Commit {
1185                    commit_lsn: CommitLsn(PgLsn(0x41)),
1186                    end_lsn: EndLsn(PgLsn(0x42)),
1187                    commit_timestamp_ms: 1_700_000_000_123,
1188                },
1189            ],
1190        )
1191        .expect("source builds");
1192
1193        let first = source
1194            .poll()
1195            .await
1196            .expect("first poll succeeds")
1197            .expect("first row emits");
1198        check!(source.checkpoint().is_none());
1199        let second = source
1200            .poll()
1201            .await
1202            .expect("second poll succeeds")
1203            .expect("second row emits");
1204
1205        check!(
1206            (
1207                header_value(&first, "krabka.pg.lsn"),
1208                header_value(&second, "krabka.pg.lsn"),
1209                first.timestamp,
1210                second.timestamp,
1211                source.checkpoint(),
1212            ) == (
1213                Bytes::from_static(b"0/42"),
1214                Bytes::from_static(b"0/42"),
1215                Some(1_700_000_000_123),
1216                Some(1_700_000_000_123),
1217                Some(PgLsn(0x42).to_source_offset("app", "slot_a")),
1218            )
1219        );
1220        check!(source.poll().await.expect("poll succeeds").is_none());
1221    }
1222
1223    #[tokio::test]
1224    async fn mixed_transactions_preserve_order_metadata_and_checkpoint_boundaries() {
1225        let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
1226        source.apply_logical_event(LogicalEvent::Relation(orders_relation()));
1227        source.apply_logical_event(LogicalEvent::Begin {
1228            final_lsn: PgLsn(0x40),
1229            xid: TransactionId(123),
1230        });
1231        source.apply_logical_event(LogicalEvent::Row(insert_event(PgLsn(0x2a))));
1232        source.apply_logical_event(LogicalEvent::Row(delete_event(PgLsn(0x2b))));
1233        source.apply_logical_event(LogicalEvent::Commit {
1234            commit_lsn: CommitLsn(PgLsn(0x41)),
1235            end_lsn: EndLsn(PgLsn(0x42)),
1236            commit_timestamp_ms: 1_700_000_000_123,
1237        });
1238        source.apply_logical_event(LogicalEvent::Relation(orders_relation()));
1239        source.apply_logical_event(LogicalEvent::Begin {
1240            final_lsn: PgLsn(0x50),
1241            xid: TransactionId(124),
1242        });
1243        source.apply_logical_event(LogicalEvent::Row(insert_event(PgLsn(0x2c))));
1244        source.apply_logical_event(LogicalEvent::Commit {
1245            commit_lsn: CommitLsn(PgLsn(0x51)),
1246            end_lsn: EndLsn(PgLsn(0x52)),
1247            commit_timestamp_ms: 1_700_000_000_456,
1248        });
1249
1250        let first = source.poll().await.unwrap().unwrap();
1251        check!(source.checkpoint().is_none());
1252        let second = source.poll().await.unwrap().unwrap();
1253        check!(source.checkpoint() == Some(PgLsn(0x42).to_source_offset("app", "slot_a")));
1254        let third = source.poll().await.unwrap().unwrap();
1255
1256        check!(
1257            [
1258                (
1259                    header_value(&first, "krabka.pg.operation"),
1260                    header_value(&first, "krabka.pg.lsn"),
1261                    first.timestamp,
1262                ),
1263                (
1264                    header_value(&second, "krabka.pg.operation"),
1265                    header_value(&second, "krabka.pg.lsn"),
1266                    second.timestamp,
1267                ),
1268                (
1269                    header_value(&third, "krabka.pg.operation"),
1270                    header_value(&third, "krabka.pg.lsn"),
1271                    third.timestamp,
1272                ),
1273            ] == [
1274                (
1275                    Bytes::from_static(b"insert"),
1276                    Bytes::from_static(b"0/42"),
1277                    Some(1_700_000_000_123),
1278                ),
1279                (
1280                    Bytes::from_static(b"delete"),
1281                    Bytes::from_static(b"0/42"),
1282                    Some(1_700_000_000_123),
1283                ),
1284                (
1285                    Bytes::from_static(b"insert"),
1286                    Bytes::from_static(b"0/52"),
1287                    Some(1_700_000_000_456),
1288                ),
1289            ]
1290        );
1291        check!(source.checkpoint() == Some(PgLsn(0x52).to_source_offset("app", "slot_a")));
1292    }
1293
1294    #[tokio::test]
1295    async fn transaction_boundary_bookkeeping_scales_with_committed_rows() {
1296        for transaction_count in [1_000_u32, 2_000, 4_000] {
1297            let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
1298            source.apply_logical_event(LogicalEvent::Relation(orders_relation()));
1299            for index in 0..transaction_count {
1300                source.apply_logical_event(LogicalEvent::Begin {
1301                    final_lsn: PgLsn(u64::from(index + 1)),
1302                    xid: TransactionId(i64::from(index + 1)),
1303                });
1304                source.apply_logical_event(LogicalEvent::Row(insert_event(PgLsn(u64::from(
1305                    index + 1,
1306                )))));
1307                source.apply_logical_event(LogicalEvent::Commit {
1308                    commit_lsn: CommitLsn(PgLsn(u64::from(index + 1))),
1309                    end_lsn: EndLsn(PgLsn(u64::from(10_000 + index))),
1310                    commit_timestamp_ms: i64::from(index),
1311                });
1312            }
1313
1314            let mut emitted = 0;
1315            while source.poll().await.unwrap().is_some() {
1316                emitted += 1;
1317            }
1318
1319            check!(emitted == transaction_count);
1320            check!(
1321                source.transaction_boundary_checks
1322                    == usize::try_from(transaction_count).expect("test count fits usize")
1323            );
1324            check!(source.remaining_transaction_rows.is_empty());
1325        }
1326    }
1327
1328    #[tokio::test]
1329    async fn failed_transaction_final_row_keeps_boundary_for_retry() {
1330        let mut source = PostgresWalSource::scripted(config("slot_a"), "app", []).unwrap();
1331        source.apply_logical_event(LogicalEvent::Begin {
1332            final_lsn: PgLsn(0x40),
1333            xid: TransactionId(123),
1334        });
1335        source.apply_logical_event(LogicalEvent::Row(insert_event(PgLsn(0x2a))));
1336        source.apply_logical_event(LogicalEvent::Commit {
1337            commit_lsn: CommitLsn(PgLsn(0x41)),
1338            end_lsn: EndLsn(PgLsn(0x42)),
1339            commit_timestamp_ms: 1_700_000_000_123,
1340        });
1341
1342        check!(source.poll().await.is_err());
1343        check!(source.pending_len() == 1);
1344        check!(source.checkpoint().is_none());
1345        check!(source.remaining_transaction_rows.get(&PgLsn(0x42)) == Some(&1));
1346
1347        source.relation_cache.apply_relation(orders_relation());
1348        check!(source.poll().await.unwrap().is_some());
1349        check!(source.checkpoint() == Some(PgLsn(0x42).to_source_offset("app", "slot_a")));
1350        check!(source.remaining_transaction_rows.is_empty());
1351    }
1352
1353    #[test]
1354    fn decoded_transaction_messages_stage_rows_with_commit_metadata() {
1355        let mut source =
1356            PostgresWalSource::scripted(config("slot_a"), "app", []).expect("source builds");
1357        let mut row = insert_event(PgLsn(0x2a));
1358        row.txid = None;
1359        row.commit_timestamp_ms = None;
1360
1361        source.apply_decoded_message(crate::pgoutput::DecodedMessage::Begin {
1362            final_lsn: PgLsn(0x40),
1363            xid: TransactionId(123),
1364        });
1365        source.apply_decoded_message(crate::pgoutput::DecodedMessage::Row(row));
1366        source.apply_decoded_message(crate::pgoutput::DecodedMessage::Commit {
1367            commit_lsn: CommitLsn(PgLsn(0x41)),
1368            end_lsn: EndLsn(PgLsn(0x42)),
1369            commit_timestamp_ms: 1_700_000_000_123,
1370        });
1371
1372        let Some(LogicalEvent::Row(row)) = source.pending.pop_front() else {
1373            panic!("committed row should be pending");
1374        };
1375        let mut expected = insert_event(PgLsn(0x2a));
1376        expected.commit_lsn = Some(PgLsn(0x42));
1377        expected.txid = Some(TransactionId(123));
1378        expected.commit_timestamp_ms = Some(1_700_000_000_123);
1379        check!(row == expected);
1380    }
1381}
1382
1383/// Mock-driven coverage for the connection-setup decision logic.
1384///
1385/// The `PgCatalog` seam lets these tests run offline. A `mockall` expectation
1386/// replaces every live query that the tests must exclude, so the validation and
1387/// orchestration branches carry real mutation signal without a running
1388/// `PostgreSQL`.
1389#[cfg(test)]
1390mod catalog_tests {
1391    use assert2::check;
1392    use krabka_connect::{ConnectError, SecretString, Source as _};
1393
1394    use super::{
1395        PostgresWalSource, ensure_slot, initialize, validate_publication_settings,
1396        validate_publication_tables,
1397    };
1398    use crate::{
1399        PgLsn, PostgresSourceConfig,
1400        catalog::{MockPgCatalog, SlotChange, SlotMetadata},
1401    };
1402
1403    fn config_with_tables(tables: Vec<String>) -> PostgresSourceConfig {
1404        PostgresSourceConfig {
1405            schema_registry_url: "http://localhost:8081".to_owned(),
1406            database_url: SecretString::new("postgres://localhost/app"),
1407            slot_name: "slot_a".to_owned(),
1408            publication_name: "krabka_connect".to_owned(),
1409            schema: "public".to_owned(),
1410            table_names: tables,
1411            max_messages_per_poll: 1000,
1412        }
1413    }
1414
1415    fn valid_slot() -> SlotMetadata {
1416        SlotMetadata {
1417            plugin: Some("pgoutput".to_owned()),
1418            slot_type: "logical".to_owned(),
1419            database: Some("app".to_owned()),
1420        }
1421    }
1422
1423    /// A minimal valid pgoutput `Begin` frame: the tag, then the final-LSN,
1424    /// commit-time, and xid fields. A decode of this frame stages a transaction
1425    /// without real WAL bytes.
1426    fn begin_frame() -> Vec<u8> {
1427        let mut data = vec![b'B'];
1428        data.extend_from_slice(&0u64.to_be_bytes()); // final_lsn
1429        data.extend_from_slice(&0i64.to_be_bytes()); // commit_time
1430        data.extend_from_slice(&0u32.to_be_bytes()); // xid
1431        data
1432    }
1433
1434    #[tokio::test]
1435    async fn validate_publication_tables_accepts_full_coverage_and_reports_gaps() {
1436        let mut catalog = MockPgCatalog::new();
1437        catalog
1438            .expect_published_tables()
1439            .returning(|_, _| Ok(vec!["orders".to_owned()]));
1440
1441        validate_publication_tables(&catalog, "krabka_connect", "public", &["orders".to_owned()])
1442            .await
1443            .expect("full coverage validates");
1444
1445        let mut missing = MockPgCatalog::new();
1446        missing
1447            .expect_published_tables()
1448            .returning(|_, _| Ok(Vec::new()));
1449
1450        let error = validate_publication_tables(
1451            &missing,
1452            "krabka_connect",
1453            "public",
1454            &["orders".to_owned()],
1455        )
1456        .await
1457        .expect_err("uncovered table fails");
1458        match error {
1459            ConnectError::Backend(message) => {
1460                check!(message.contains("does not cover configured tables"));
1461                check!(message.contains("orders"));
1462            }
1463            error => panic!("expected backend error, got {error:?}"),
1464        }
1465    }
1466
1467    #[tokio::test]
1468    async fn validate_publication_settings_requires_insert_update_delete_without_truncate() {
1469        let mut compatible = MockPgCatalog::new();
1470        compatible
1471            .expect_publication_settings()
1472            .returning(|_| Ok(Some([true, true, true, false])));
1473        validate_publication_settings(&compatible, "krabka_connect")
1474            .await
1475            .expect("compatible flags validate");
1476
1477        let mut truncating = MockPgCatalog::new();
1478        truncating
1479            .expect_publication_settings()
1480            .returning(|_| Ok(Some([true, true, true, true])));
1481        let error = validate_publication_settings(&truncating, "krabka_connect")
1482            .await
1483            .expect_err("publishing truncate fails");
1484        check!(matches!(error, ConnectError::Backend(_)));
1485
1486        // An absent publication row is tolerated (the create path handles it).
1487        let mut absent = MockPgCatalog::new();
1488        absent.expect_publication_settings().returning(|_| Ok(None));
1489        validate_publication_settings(&absent, "krabka_connect")
1490            .await
1491            .expect("missing publication row is tolerated");
1492    }
1493
1494    #[tokio::test]
1495    async fn ensure_slot_creates_when_absent_and_validates_when_present() {
1496        let mut absent = MockPgCatalog::new();
1497        absent.expect_replication_slot().returning(|_| Ok(None));
1498        absent
1499            .expect_create_logical_slot()
1500            .times(1)
1501            .returning(|_| Ok(()));
1502        ensure_slot(&absent, "slot_a", "app")
1503            .await
1504            .expect("absent slot is created");
1505
1506        // A present, compatible slot must not be recreated.
1507        let mut present = MockPgCatalog::new();
1508        present
1509            .expect_replication_slot()
1510            .returning(|_| Ok(Some(valid_slot())));
1511        ensure_slot(&present, "slot_a", "app")
1512            .await
1513            .expect("compatible slot validates");
1514
1515        let mut mismatched = MockPgCatalog::new();
1516        mismatched.expect_replication_slot().returning(|_| {
1517            Ok(Some(SlotMetadata {
1518                plugin: Some("test_decoding".to_owned()),
1519                slot_type: "physical".to_owned(),
1520                database: Some("other".to_owned()),
1521            }))
1522        });
1523        let error = ensure_slot(&mismatched, "slot_a", "app")
1524            .await
1525            .expect_err("incompatible slot fails");
1526        check!(matches!(error, ConnectError::Backend(_)));
1527    }
1528
1529    #[tokio::test]
1530    async fn initialize_runs_publication_setup_only_when_tables_configured() {
1531        let mut with_tables = MockPgCatalog::new();
1532        with_tables
1533            .expect_current_database()
1534            .returning(|| Ok("app".to_owned()));
1535        with_tables
1536            .expect_ensure_publication()
1537            .times(1)
1538            .returning(|_| Ok(()));
1539        with_tables
1540            .expect_published_tables()
1541            .returning(|_, _| Ok(vec!["orders".to_owned()]));
1542        with_tables
1543            .expect_publication_settings()
1544            .returning(|_| Ok(Some([true, true, true, false])));
1545        with_tables
1546            .expect_replication_slot()
1547            .returning(|_| Ok(Some(valid_slot())));
1548
1549        let database = initialize(&with_tables, &config_with_tables(vec!["orders".to_owned()]))
1550            .await
1551            .expect("initialize succeeds");
1552        check!(database == "app");
1553
1554        // With no tables configured, the publication path is skipped entirely
1555        // (no `ensure_publication` expectation set — calling it would panic).
1556        let mut no_tables = MockPgCatalog::new();
1557        no_tables
1558            .expect_current_database()
1559            .returning(|| Ok("app".to_owned()));
1560        no_tables
1561            .expect_replication_slot()
1562            .returning(|_| Ok(Some(valid_slot())));
1563        initialize(&no_tables, &config_with_tables(Vec::new()))
1564            .await
1565            .expect("initialize without tables succeeds");
1566    }
1567
1568    #[tokio::test]
1569    async fn fill_pending_applies_peeked_changes_and_propagates_decode_input() {
1570        let mut catalog = MockPgCatalog::new();
1571        catalog.expect_peek_changes().returning(|_, _, _| {
1572            Ok(vec![SlotChange {
1573                lsn: "0/2A".to_owned(),
1574                data: begin_frame(),
1575            }])
1576        });
1577
1578        let mut source = PostgresWalSource::with_catalog(
1579            config_with_tables(Vec::new()),
1580            "app",
1581            Box::new(catalog),
1582        )
1583        .expect("source builds");
1584        source
1585            .fill_pending_from_slot()
1586            .await
1587            .expect("fill succeeds");
1588        // The Begin frame opened a transaction — proof the peeked change was
1589        // decoded and applied rather than dropped.
1590        check!(source.transaction.is_some());
1591    }
1592
1593    #[tokio::test]
1594    async fn fill_pending_surfaces_unparsable_lsn() {
1595        let mut catalog = MockPgCatalog::new();
1596        catalog.expect_peek_changes().returning(|_, _, _| {
1597            Ok(vec![SlotChange {
1598                lsn: "not-a-lsn".to_owned(),
1599                data: begin_frame(),
1600            }])
1601        });
1602
1603        let mut source = PostgresWalSource::with_catalog(
1604            config_with_tables(Vec::new()),
1605            "app",
1606            Box::new(catalog),
1607        )
1608        .expect("source builds");
1609        check!(source.fill_pending_from_slot().await.is_err());
1610    }
1611
1612    #[tokio::test]
1613    async fn fill_pending_without_catalog_is_a_noop() {
1614        let mut source =
1615            PostgresWalSource::scripted(config_with_tables(Vec::new()), "app", []).unwrap();
1616        source
1617            .fill_pending_from_slot()
1618            .await
1619            .expect("noop succeeds");
1620        check!(source.pending_len() == 0);
1621    }
1622
1623    #[tokio::test]
1624    async fn acknowledge_advances_slot_through_catalog() {
1625        let mut catalog = MockPgCatalog::new();
1626        catalog
1627            .expect_advance_slot()
1628            .times(1)
1629            .returning(|_, _| Ok(()));
1630
1631        let mut source = PostgresWalSource::with_catalog(
1632            config_with_tables(Vec::new()),
1633            "app",
1634            Box::new(catalog),
1635        )
1636        .expect("source builds");
1637        source
1638            .acknowledge(&PgLsn(0x2a).to_source_offset("app", "slot_a"))
1639            .await
1640            .expect("acknowledge advances the slot");
1641    }
1642}