1use rusqlite::Connection;
2
3use crate::error::Result;
4
5mod cdc_snapshot_store;
6mod checkpoint;
7mod cursor;
8mod file_log;
9mod journal_store;
10mod keyset_range;
11mod load_journal_store;
12mod metrics;
13mod progression;
14mod row;
15mod run_aggregate;
16mod run_status_store;
17mod schema;
18mod shape;
19
20#[allow(unused_imports)]
24pub use checkpoint::ChunkTaskInfo;
25#[allow(unused_imports)]
26pub use file_log::FileRecord;
27#[allow(unused_imports)]
28pub use keyset_range::{KeysetRangePart, KeysetRangeRow};
29pub use load_journal_store::LoadRecord;
30#[allow(unused_imports)]
31pub use metrics::ExportMetric;
32pub use metrics::MetricRow;
33#[allow(unused_imports)]
34pub use progression::{Boundary, ExportProgression};
35#[allow(unused_imports)]
36pub use run_aggregate::{RunAggregate, RunAggregateEntry};
37#[allow(unused_imports)]
38pub use schema::{SchemaChange, SchemaColumn, arrow_schema_to_columns, schema_fingerprint};
39#[allow(unused_imports)]
40pub use shape::ShapeWarning;
41
42const STATE_DB_NAME: &str = ".rivet_state.db";
43
44const SCHEMA_VERSION: i64 = MIGRATIONS[MIGRATIONS.len() - 1].0;
46
47const MIGRATIONS: &[(i64, &str)] = &[
49 (
51 1,
52 "CREATE TABLE IF NOT EXISTS export_state (
53 export_name TEXT PRIMARY KEY,
54 last_cursor_value TEXT,
55 last_run_at TEXT
56 );
57 CREATE TABLE IF NOT EXISTS export_metrics (
58 id INTEGER PRIMARY KEY AUTOINCREMENT,
59 export_name TEXT NOT NULL,
60 run_at TEXT NOT NULL,
61 duration_ms INTEGER NOT NULL,
62 total_rows INTEGER NOT NULL,
63 peak_rss_mb INTEGER,
64 status TEXT NOT NULL,
65 error_message TEXT,
66 tuning_profile TEXT,
67 format TEXT,
68 mode TEXT,
69 files_produced INTEGER DEFAULT 0,
70 bytes_written INTEGER DEFAULT 0,
71 retries INTEGER DEFAULT 0,
72 validated INTEGER,
73 schema_changed INTEGER,
74 run_id TEXT
75 );
76 CREATE TABLE IF NOT EXISTS export_schema (
77 export_name TEXT PRIMARY KEY,
78 columns_json TEXT NOT NULL,
79 updated_at TEXT NOT NULL
80 );
81 CREATE TABLE IF NOT EXISTS file_manifest (
82 id INTEGER PRIMARY KEY AUTOINCREMENT,
83 run_id TEXT NOT NULL,
84 export_name TEXT NOT NULL,
85 file_name TEXT NOT NULL,
86 row_count INTEGER NOT NULL,
87 bytes INTEGER NOT NULL,
88 format TEXT NOT NULL,
89 compression TEXT,
90 created_at TEXT NOT NULL
91 );",
92 ),
93 (
95 2,
96 "CREATE TABLE IF NOT EXISTS chunk_run (
97 run_id TEXT PRIMARY KEY,
98 export_name TEXT NOT NULL,
99 plan_hash TEXT NOT NULL,
100 status TEXT NOT NULL,
101 max_chunk_attempts INTEGER NOT NULL DEFAULT 3,
102 created_at TEXT NOT NULL,
103 updated_at TEXT NOT NULL
104 );
105 CREATE INDEX IF NOT EXISTS idx_chunk_run_export_status
106 ON chunk_run(export_name, status);
107 CREATE TABLE IF NOT EXISTS chunk_task (
108 id INTEGER PRIMARY KEY AUTOINCREMENT,
109 run_id TEXT NOT NULL,
110 chunk_index INTEGER NOT NULL,
111 start_key TEXT NOT NULL,
112 end_key TEXT NOT NULL,
113 status TEXT NOT NULL,
114 attempts INTEGER NOT NULL DEFAULT 0,
115 last_error TEXT,
116 rows_written INTEGER,
117 file_name TEXT,
118 updated_at TEXT NOT NULL,
119 UNIQUE(run_id, chunk_index)
120 );
121 CREATE INDEX IF NOT EXISTS idx_chunk_task_run_status ON chunk_task(run_id, status);",
122 ),
123 (
125 3,
126 "CREATE INDEX IF NOT EXISTS idx_file_manifest_export ON file_manifest(export_name, id DESC);",
127 ),
128 (
130 4,
131 "CREATE TABLE IF NOT EXISTS export_progression (
132 export_name TEXT PRIMARY KEY,
133 last_committed_strategy TEXT,
134 last_committed_cursor TEXT,
135 last_committed_chunk_index INTEGER,
136 last_committed_run_id TEXT,
137 last_committed_at TEXT,
138 last_verified_strategy TEXT,
139 last_verified_cursor TEXT,
140 last_verified_chunk_index INTEGER,
141 last_verified_run_id TEXT,
142 last_verified_at TEXT
143 );",
144 ),
145 (
147 5,
148 "CREATE TABLE IF NOT EXISTS run_aggregate (
149 run_aggregate_id TEXT PRIMARY KEY,
150 started_at TEXT NOT NULL,
151 finished_at TEXT NOT NULL,
152 duration_ms INTEGER NOT NULL,
153 config_path TEXT,
154 parallel_mode TEXT NOT NULL,
155 total_exports INTEGER NOT NULL,
156 success_count INTEGER NOT NULL,
157 failed_count INTEGER NOT NULL,
158 skipped_count INTEGER NOT NULL,
159 total_rows INTEGER NOT NULL,
160 total_files INTEGER NOT NULL,
161 total_bytes INTEGER NOT NULL,
162 details_json TEXT NOT NULL
163 );
164 CREATE INDEX IF NOT EXISTS idx_run_aggregate_finished
165 ON run_aggregate(finished_at DESC);",
166 ),
167 (
169 6,
170 "CREATE TABLE IF NOT EXISTS export_shape (
171 export_name TEXT NOT NULL,
172 column_name TEXT NOT NULL,
173 max_byte_len INTEGER NOT NULL,
174 updated_at TEXT NOT NULL,
175 PRIMARY KEY (export_name, column_name)
176 );",
177 ),
178 (
180 7,
181 "CREATE TABLE IF NOT EXISTS run_journal (
182 run_id TEXT PRIMARY KEY,
183 export_name TEXT NOT NULL,
184 finished_at TEXT NOT NULL,
185 journal_json TEXT NOT NULL
186 );
187 CREATE INDEX IF NOT EXISTS idx_run_journal_export
188 ON run_journal(export_name, finished_at DESC);",
189 ),
190 (
194 8,
195 "ALTER TABLE file_manifest RENAME TO file_log;
196 DROP INDEX IF EXISTS idx_file_manifest_export;
197 CREATE INDEX IF NOT EXISTS idx_file_log_export ON file_log(export_name, id DESC);",
198 ),
199 (
206 9,
207 "ALTER TABLE export_metrics ADD COLUMN files_committed INTEGER;
208 ALTER TABLE export_metrics ADD COLUMN reconciled INTEGER;
209 ALTER TABLE export_metrics ADD COLUMN source_count INTEGER;
210 ALTER TABLE export_metrics ADD COLUMN quality_passed INTEGER;
211 ALTER TABLE export_metrics ADD COLUMN pg_temp_bytes_delta INTEGER;
212 ALTER TABLE export_metrics ADD COLUMN batch_size INTEGER;
213 ALTER TABLE export_metrics ADD COLUMN batch_size_memory_mb INTEGER;
214 ALTER TABLE export_metrics ADD COLUMN skip_reason TEXT;
215 ALTER TABLE export_metrics ADD COLUMN schema_fingerprint TEXT;
216 ALTER TABLE export_metrics ADD COLUMN chunk_size INTEGER;
217 ALTER TABLE export_metrics ADD COLUMN parallel INTEGER;
218 ALTER TABLE export_metrics ADD COLUMN source_type TEXT;
219 ALTER TABLE export_metrics ADD COLUMN destination_type TEXT;
220 ALTER TABLE export_metrics ADD COLUMN rivet_version TEXT;",
221 ),
222 (
225 10,
226 "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms INTEGER;",
227 ),
228 (
233 11,
234 "CREATE TABLE IF NOT EXISTS export_harm (
235 id INTEGER PRIMARY KEY AUTOINCREMENT,
236 run_id TEXT NOT NULL,
237 export_name TEXT NOT NULL,
238 metric TEXT NOT NULL,
239 delta INTEGER NOT NULL,
240 recorded_at TEXT NOT NULL
241 );
242 CREATE INDEX IF NOT EXISTS idx_export_harm_run ON export_harm(run_id);",
243 ),
244 (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
252 (
257 13,
258 "CREATE TABLE IF NOT EXISTS load_run (
259 load_id TEXT PRIMARY KEY,
260 export_name TEXT NOT NULL,
261 target_table TEXT NOT NULL,
262 warehouse TEXT NOT NULL,
263 mode TEXT NOT NULL,
264 source_run_ids TEXT NOT NULL,
265 rows_loaded INTEGER NOT NULL,
266 status TEXT NOT NULL,
267 finished_at TEXT NOT NULL
268 );
269 CREATE INDEX IF NOT EXISTS idx_load_run_target
270 ON load_run(target_table, finished_at DESC);
271 CREATE TABLE IF NOT EXISTS loaded_source_run (
272 target_table TEXT NOT NULL,
273 source_run_id TEXT NOT NULL,
274 load_id TEXT NOT NULL,
275 loaded_at TEXT NOT NULL,
276 PRIMARY KEY (target_table, source_run_id)
277 );",
278 ),
279 (
284 14,
285 "CREATE TABLE IF NOT EXISTS cdc_snapshot (
286 export_name TEXT NOT NULL,
287 table_name TEXT NOT NULL,
288 run_id TEXT NOT NULL,
289 completed_at TEXT NOT NULL,
290 PRIMARY KEY (export_name, table_name)
291 );",
292 ),
293 (
304 15,
305 "UPDATE chunk_run SET status='interrupted'
306 WHERE status='in_progress' AND run_id NOT IN (
307 SELECT run_id FROM chunk_run c WHERE c.status='in_progress'
308 AND NOT EXISTS (
309 SELECT 1 FROM chunk_run c2
310 WHERE c2.export_name=c.export_name AND c2.status='in_progress'
311 AND (c2.created_at > c.created_at
312 OR (c2.created_at = c.created_at AND c2.run_id > c.run_id)))
313 );
314 CREATE UNIQUE INDEX IF NOT EXISTS idx_chunk_run_one_inprogress
315 ON chunk_run(export_name) WHERE status='in_progress';",
316 ),
317 (
323 16,
324 "ALTER TABLE export_state ADD COLUMN resume_run_id TEXT;",
325 ),
326 (
334 17,
335 "CREATE TABLE IF NOT EXISTS run_status (
336 run_id TEXT PRIMARY KEY,
337 export_name TEXT NOT NULL,
338 prefix TEXT NOT NULL,
339 status TEXT NOT NULL,
340 started_at TEXT NOT NULL,
341 finished_at TEXT
342 );
343 CREATE INDEX IF NOT EXISTS idx_run_status_prefix ON run_status(prefix);",
344 ),
345 (
352 18,
353 "ALTER TABLE export_metrics ADD COLUMN error_class TEXT;
354 ALTER TABLE export_metrics ADD COLUMN cursor_min TEXT;
355 ALTER TABLE export_metrics ADD COLUMN cursor_max TEXT;
356 ALTER TABLE export_metrics ADD COLUMN key_descriptor_json TEXT;
357 ALTER TABLE export_metrics ADD COLUMN offending_value TEXT;
358 ALTER TABLE export_metrics ADD COLUMN server_context_json TEXT;",
359 ),
360 (
371 19,
372 "CREATE TABLE IF NOT EXISTS keyset_range (
373 export_name TEXT NOT NULL,
374 run_id TEXT NOT NULL,
375 range_index INTEGER NOT NULL,
376 lo TEXT,
377 hi TEXT,
378 done INTEGER NOT NULL DEFAULT 0,
379 updated_at TEXT NOT NULL,
380 PRIMARY KEY (export_name, range_index)
381 );",
382 ),
383];
384
385const PG_MIGRATIONS: &[(i64, &str)] = &[
388 (
389 1,
390 "CREATE TABLE IF NOT EXISTS export_state (
391 export_name TEXT PRIMARY KEY,
392 last_cursor_value TEXT,
393 last_run_at TEXT
394 );
395 CREATE TABLE IF NOT EXISTS export_metrics (
396 id BIGSERIAL PRIMARY KEY,
397 export_name TEXT NOT NULL,
398 run_at TEXT NOT NULL,
399 duration_ms BIGINT NOT NULL,
400 total_rows BIGINT NOT NULL,
401 peak_rss_mb BIGINT,
402 status TEXT NOT NULL,
403 error_message TEXT,
404 tuning_profile TEXT,
405 format TEXT,
406 mode TEXT,
407 files_produced BIGINT DEFAULT 0,
408 bytes_written BIGINT DEFAULT 0,
409 retries BIGINT DEFAULT 0,
410 validated BOOLEAN,
411 schema_changed BOOLEAN,
412 run_id TEXT
413 );
414 CREATE TABLE IF NOT EXISTS export_schema (
415 export_name TEXT PRIMARY KEY,
416 columns_json TEXT NOT NULL,
417 updated_at TEXT NOT NULL
418 );
419 CREATE TABLE IF NOT EXISTS file_manifest (
420 id BIGSERIAL PRIMARY KEY,
421 run_id TEXT NOT NULL,
422 export_name TEXT NOT NULL,
423 file_name TEXT NOT NULL,
424 row_count BIGINT NOT NULL,
425 bytes BIGINT NOT NULL,
426 format TEXT NOT NULL,
427 compression TEXT,
428 created_at TEXT NOT NULL
429 );",
430 ),
431 (
432 2,
433 "CREATE TABLE IF NOT EXISTS chunk_run (
434 run_id TEXT PRIMARY KEY,
435 export_name TEXT NOT NULL,
436 plan_hash TEXT NOT NULL,
437 status TEXT NOT NULL,
438 max_chunk_attempts BIGINT NOT NULL DEFAULT 3,
439 created_at TEXT NOT NULL,
440 updated_at TEXT NOT NULL
441 );
442 CREATE INDEX IF NOT EXISTS idx_chunk_run_export_status
443 ON chunk_run(export_name, status);
444 CREATE TABLE IF NOT EXISTS chunk_task (
445 id BIGSERIAL PRIMARY KEY,
446 run_id TEXT NOT NULL,
447 chunk_index BIGINT NOT NULL,
448 start_key TEXT NOT NULL,
449 end_key TEXT NOT NULL,
450 status TEXT NOT NULL,
451 attempts BIGINT NOT NULL DEFAULT 0,
452 last_error TEXT,
453 rows_written BIGINT,
454 file_name TEXT,
455 updated_at TEXT NOT NULL,
456 UNIQUE(run_id, chunk_index)
457 );
458 CREATE INDEX IF NOT EXISTS idx_chunk_task_run_status ON chunk_task(run_id, status);",
459 ),
460 (
461 3,
462 "CREATE INDEX IF NOT EXISTS idx_file_manifest_export ON file_manifest(export_name, id DESC);",
463 ),
464 (
465 4,
466 "CREATE TABLE IF NOT EXISTS export_progression (
467 export_name TEXT PRIMARY KEY,
468 last_committed_strategy TEXT,
469 last_committed_cursor TEXT,
470 last_committed_chunk_index BIGINT,
471 last_committed_run_id TEXT,
472 last_committed_at TEXT,
473 last_verified_strategy TEXT,
474 last_verified_cursor TEXT,
475 last_verified_chunk_index BIGINT,
476 last_verified_run_id TEXT,
477 last_verified_at TEXT
478 );",
479 ),
480 (
481 5,
482 "CREATE TABLE IF NOT EXISTS run_aggregate (
483 run_aggregate_id TEXT PRIMARY KEY,
484 started_at TEXT NOT NULL,
485 finished_at TEXT NOT NULL,
486 duration_ms BIGINT NOT NULL,
487 config_path TEXT,
488 parallel_mode TEXT NOT NULL,
489 total_exports BIGINT NOT NULL,
490 success_count BIGINT NOT NULL,
491 failed_count BIGINT NOT NULL,
492 skipped_count BIGINT NOT NULL,
493 total_rows BIGINT NOT NULL,
494 total_files BIGINT NOT NULL,
495 total_bytes BIGINT NOT NULL,
496 details_json TEXT NOT NULL
497 );
498 CREATE INDEX IF NOT EXISTS idx_run_aggregate_finished
499 ON run_aggregate(finished_at DESC);",
500 ),
501 (
502 6,
503 "CREATE TABLE IF NOT EXISTS export_shape (
504 export_name TEXT NOT NULL,
505 column_name TEXT NOT NULL,
506 max_byte_len BIGINT NOT NULL,
507 updated_at TEXT NOT NULL,
508 PRIMARY KEY (export_name, column_name)
509 );",
510 ),
511 (
512 7,
513 "CREATE TABLE IF NOT EXISTS run_journal (
514 run_id TEXT PRIMARY KEY,
515 export_name TEXT NOT NULL,
516 finished_at TEXT NOT NULL,
517 journal_json TEXT NOT NULL
518 );
519 CREATE INDEX IF NOT EXISTS idx_run_journal_export
520 ON run_journal(export_name, finished_at DESC);",
521 ),
522 (
525 8,
526 "ALTER TABLE file_manifest RENAME TO file_log;
527 DROP INDEX IF EXISTS idx_file_manifest_export;
528 CREATE INDEX IF NOT EXISTS idx_file_log_export ON file_log(export_name, id DESC);",
529 ),
530 (
533 9,
534 "ALTER TABLE export_metrics ADD COLUMN files_committed BIGINT;
535 ALTER TABLE export_metrics ADD COLUMN reconciled BOOLEAN;
536 ALTER TABLE export_metrics ADD COLUMN source_count BIGINT;
537 ALTER TABLE export_metrics ADD COLUMN quality_passed BOOLEAN;
538 ALTER TABLE export_metrics ADD COLUMN pg_temp_bytes_delta BIGINT;
539 ALTER TABLE export_metrics ADD COLUMN batch_size BIGINT;
540 ALTER TABLE export_metrics ADD COLUMN batch_size_memory_mb BIGINT;
541 ALTER TABLE export_metrics ADD COLUMN skip_reason TEXT;
542 ALTER TABLE export_metrics ADD COLUMN schema_fingerprint TEXT;
543 ALTER TABLE export_metrics ADD COLUMN chunk_size BIGINT;
544 ALTER TABLE export_metrics ADD COLUMN parallel BIGINT;
545 ALTER TABLE export_metrics ADD COLUMN source_type TEXT;
546 ALTER TABLE export_metrics ADD COLUMN destination_type TEXT;
547 ALTER TABLE export_metrics ADD COLUMN rivet_version TEXT;",
548 ),
549 (
551 10,
552 "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms BIGINT;",
553 ),
554 (
556 11,
557 "CREATE TABLE IF NOT EXISTS export_harm (
558 id BIGSERIAL PRIMARY KEY,
559 run_id TEXT NOT NULL,
560 export_name TEXT NOT NULL,
561 metric TEXT NOT NULL,
562 delta BIGINT NOT NULL,
563 recorded_at TEXT NOT NULL
564 );
565 CREATE INDEX IF NOT EXISTS idx_export_harm_run ON export_harm(run_id);",
566 ),
567 (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
569 (
571 13,
572 "CREATE TABLE IF NOT EXISTS load_run (
573 load_id TEXT PRIMARY KEY,
574 export_name TEXT NOT NULL,
575 target_table TEXT NOT NULL,
576 warehouse TEXT NOT NULL,
577 mode TEXT NOT NULL,
578 source_run_ids TEXT NOT NULL,
579 rows_loaded BIGINT NOT NULL,
580 status TEXT NOT NULL,
581 finished_at TEXT NOT NULL
582 );
583 CREATE INDEX IF NOT EXISTS idx_load_run_target
584 ON load_run(target_table, finished_at DESC);
585 CREATE TABLE IF NOT EXISTS loaded_source_run (
586 target_table TEXT NOT NULL,
587 source_run_id TEXT NOT NULL,
588 load_id TEXT NOT NULL,
589 loaded_at TEXT NOT NULL,
590 PRIMARY KEY (target_table, source_run_id)
591 );",
592 ),
593 (
595 14,
596 "CREATE TABLE IF NOT EXISTS cdc_snapshot (
597 export_name TEXT NOT NULL,
598 table_name TEXT NOT NULL,
599 run_id TEXT NOT NULL,
600 completed_at TEXT NOT NULL,
601 PRIMARY KEY (export_name, table_name)
602 );",
603 ),
604 (
615 15,
616 "UPDATE chunk_run SET status='interrupted'
617 WHERE status='in_progress' AND run_id NOT IN (
618 SELECT run_id FROM chunk_run c WHERE c.status='in_progress'
619 AND NOT EXISTS (
620 SELECT 1 FROM chunk_run c2
621 WHERE c2.export_name=c.export_name AND c2.status='in_progress'
622 AND (c2.created_at > c.created_at
623 OR (c2.created_at = c.created_at AND c2.run_id > c.run_id)))
624 );
625 CREATE UNIQUE INDEX IF NOT EXISTS idx_chunk_run_one_inprogress
626 ON chunk_run(export_name) WHERE status='in_progress';",
627 ),
628 (
634 16,
635 "ALTER TABLE export_state ADD COLUMN resume_run_id TEXT;",
636 ),
637 (
645 17,
646 "CREATE TABLE IF NOT EXISTS run_status (
647 run_id TEXT PRIMARY KEY,
648 export_name TEXT NOT NULL,
649 prefix TEXT NOT NULL,
650 status TEXT NOT NULL,
651 started_at TEXT NOT NULL,
652 finished_at TEXT
653 );
654 CREATE INDEX IF NOT EXISTS idx_run_status_prefix ON run_status(prefix);",
655 ),
656 (
659 18,
660 "ALTER TABLE export_metrics ADD COLUMN error_class TEXT;
661 ALTER TABLE export_metrics ADD COLUMN cursor_min TEXT;
662 ALTER TABLE export_metrics ADD COLUMN cursor_max TEXT;
663 ALTER TABLE export_metrics ADD COLUMN key_descriptor_json TEXT;
664 ALTER TABLE export_metrics ADD COLUMN offending_value TEXT;
665 ALTER TABLE export_metrics ADD COLUMN server_context_json TEXT;",
666 ),
667 (
674 19,
675 "CREATE TABLE IF NOT EXISTS keyset_range (
676 export_name TEXT NOT NULL,
677 run_id TEXT NOT NULL,
678 range_index BIGINT NOT NULL,
679 lo TEXT,
680 hi TEXT,
681 done BIGINT NOT NULL DEFAULT 0,
682 updated_at TEXT NOT NULL,
683 PRIMARY KEY (export_name, range_index)
684 );",
685 ),
686];
687
688pub(super) fn pg_sql(sql: &str) -> String {
693 let bytes = sql.as_bytes();
694 let mut out = String::with_capacity(sql.len());
695 let mut i = 0;
696 while i < bytes.len() {
697 if bytes[i] == b'?' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
698 out.push('$');
699 } else {
700 out.push(bytes[i] as char);
701 }
702 i += 1;
703 }
704 out
705}
706
707pub(super) fn connect_pg(url: &str) -> Result<postgres::Client> {
724 let tls = state_tls_mode_from_url(url).map(|mode| crate::config::TlsConfig {
725 mode,
726 ..crate::config::TlsConfig::default()
727 });
728 crate::source::postgres::connect_client(url, tls.as_ref())
729 .map_err(|e| anyhow::anyhow!("state(pg): connect to '{}': {:#}", redact_pg_url(url), e))
730}
731
732fn state_tls_mode_from_url(url: &str) -> Option<crate::config::TlsMode> {
740 use crate::config::TlsMode;
741 let (_, query) = url.split_once('?')?;
742 let mut mode = None;
743 for pair in query.split('&') {
744 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
745 if key != "sslmode" {
746 continue;
747 }
748 mode = match value {
749 "require" => Some(TlsMode::Require),
750 "verify-ca" => Some(TlsMode::VerifyCa),
751 "verify-full" => Some(TlsMode::VerifyFull),
752 _ => None,
753 };
754 }
755 mode
756}
757
758pub(super) enum StateConn {
762 Sqlite(rusqlite::Connection),
763 Postgres(Box<std::cell::RefCell<postgres::Client>>),
768}
769
770#[derive(Clone)]
774pub enum StateRef {
775 Sqlite(std::path::PathBuf),
776 Postgres(String),
777}
778
779fn ensure_schema_version_table(conn: &Connection) {
782 let _ = conn.execute_batch(
783 "CREATE TABLE IF NOT EXISTS schema_version (
784 version INTEGER NOT NULL
785 );",
786 );
787}
788
789fn get_current_version(conn: &Connection) -> i64 {
790 conn.query_row(
791 "SELECT COALESCE(MAX(version), 0) FROM schema_version",
792 [],
793 |row| row.get(0),
794 )
795 .unwrap_or(0)
796}
797
798fn migrate(conn: &Connection) -> Result<()> {
799 ensure_schema_version_table(conn);
800
801 let current = get_current_version(conn);
802
803 if current == 0 {
804 let has_export_state: bool = conn
805 .query_row(
806 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='export_state'",
807 [],
808 |row| row.get(0),
809 )
810 .unwrap_or(false);
811
812 if has_export_state {
813 let metrics_cols = [
814 "files_produced INTEGER DEFAULT 0",
815 "bytes_written INTEGER DEFAULT 0",
816 "retries INTEGER DEFAULT 0",
817 "validated INTEGER",
818 "schema_changed INTEGER",
819 "run_id TEXT",
820 ];
821 for col_def in &metrics_cols {
822 let sql = format!("ALTER TABLE export_metrics ADD COLUMN {}", col_def);
823 let _ = conn.execute(&sql, []);
824 }
825 }
826 }
827
828 for &(ver, sql) in MIGRATIONS {
829 if ver > current {
830 log::debug!("state: applying migration v{}", ver);
831 let atomic_sql = format!(
832 "BEGIN;\n{}\nINSERT INTO schema_version (version) VALUES ({});\nCOMMIT;",
833 sql, ver
834 );
835 conn.execute_batch(&atomic_sql)
836 .map_err(|e| anyhow::anyhow!("state: migration v{} failed: {}", ver, e))?;
837 }
838 }
839
840 let _ = conn.execute(
841 "DELETE FROM schema_version WHERE version < (SELECT MAX(version) FROM schema_version)",
842 [],
843 );
844
845 let final_version = get_current_version(conn);
846 if final_version != SCHEMA_VERSION {
847 anyhow::bail!(
848 "state: migration incomplete — expected schema v{} but reached v{}",
849 SCHEMA_VERSION,
850 final_version
851 );
852 }
853
854 Ok(())
855}
856
857fn migrate_pg(client: &mut postgres::Client) -> Result<()> {
860 client
861 .batch_execute("CREATE TABLE IF NOT EXISTS rivet_schema_version (version BIGINT NOT NULL);")
862 .map_err(|e| anyhow::anyhow!("state(pg): create version table: {:#}", e))?;
863
864 let current: i64 = client
865 .query_one(
866 "SELECT COALESCE(MAX(version), 0) FROM rivet_schema_version",
867 &[],
868 )
869 .map_err(|e| anyhow::anyhow!("state(pg): read schema version: {:#}", e))?
870 .get(0);
871
872 for &(ver, sql) in PG_MIGRATIONS {
873 if ver > current {
874 log::debug!("state(pg): applying migration v{}", ver);
875 let batch = format!(
876 "BEGIN; {} INSERT INTO rivet_schema_version (version) VALUES ({}); COMMIT;",
877 sql, ver
878 );
879 client
880 .batch_execute(&batch)
881 .map_err(|e| anyhow::anyhow!("state(pg): migration v{} failed: {:#}", ver, e))?;
882 }
883 }
884
885 let _ = client.batch_execute(
887 "DELETE FROM rivet_schema_version \
888 WHERE version < (SELECT MAX(version) FROM rivet_schema_version);",
889 );
890
891 let final_version: i64 = client
893 .query_one(
894 "SELECT COALESCE(MAX(version), 0) FROM rivet_schema_version",
895 &[],
896 )
897 .map_err(|e| anyhow::anyhow!("state(pg): read final schema version: {:#}", e))?
898 .get(0);
899 if final_version != SCHEMA_VERSION {
900 anyhow::bail!(
901 "state(pg): migration incomplete — expected schema v{} but reached v{}",
902 SCHEMA_VERSION,
903 final_version
904 );
905 }
906
907 Ok(())
908}
909
910fn redact_pg_url(url: &str) -> String {
914 let Some(scheme_end) = url.find("://") else {
937 return url.to_string();
938 };
939 let after_scheme = &url[scheme_end + 3..];
940 let span_end = after_scheme
941 .find(char::is_whitespace)
942 .unwrap_or(after_scheme.len());
943 let span = &after_scheme[..span_end];
944 let Some(at_rel) = span.rfind('@') else {
946 return url.to_string();
947 };
948 let userinfo = &span[..at_rel];
949 let Some(colon) = userinfo.find(':') else {
951 return url.to_string();
952 };
953 let user = &userinfo[..colon];
954 let at_pos = scheme_end + 3 + at_rel;
955 format!(
956 "{}://{}:***@{}",
957 &url[..scheme_end],
958 user,
959 &url[at_pos + 1..]
960 )
961}
962
963pub(crate) const SQLITE_BUSY_TIMEOUT_MS: i64 = 10_000;
966
967pub(crate) fn open_connection(db_path: &std::path::Path) -> Result<Connection> {
968 let conn = Connection::open(db_path)?;
969 if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL;") {
970 log::warn!(
971 "state: WAL journal mode unavailable ({}); \
972 running in default mode — concurrent writes may be slower",
973 e
974 );
975 }
976 if let Err(e) = conn.execute_batch(&format!(
977 "PRAGMA busy_timeout = {};",
978 SQLITE_BUSY_TIMEOUT_MS
979 )) {
980 log::warn!(
981 "state: failed to set busy_timeout ({}); \
982 concurrent writers may surface SQLITE_BUSY immediately",
983 e
984 );
985 }
986 Ok(conn)
987}
988
989pub struct StateStore {
1009 pub(super) conn: StateConn,
1010 pub(super) state_ref: StateRef,
1012}
1013
1014impl StateStore {
1015 pub fn open(config_path: &str) -> Result<Self> {
1019 if let Ok(url) = std::env::var("RIVET_STATE_URL")
1020 && url.starts_with("postgres")
1021 {
1022 return Self::open_postgres(&url);
1023 }
1024 Self::open_sqlite(config_path)
1025 }
1026
1027 fn open_sqlite(config_path: &str) -> Result<Self> {
1028 let config_dir = std::path::Path::new(config_path)
1029 .parent()
1030 .unwrap_or(std::path::Path::new("."));
1031 let db_path = config_dir.join(STATE_DB_NAME);
1032 let conn = open_connection(&db_path)?;
1033 migrate(&conn)?;
1034 Ok(Self {
1035 conn: StateConn::Sqlite(conn),
1036 state_ref: StateRef::Sqlite(db_path),
1037 })
1038 }
1039
1040 fn open_postgres(url: &str) -> Result<Self> {
1041 let is_local =
1042 url.contains("localhost") || url.contains("127.0.0.1") || url.contains("::1");
1043 if !is_local && state_tls_mode_from_url(url).is_none() {
1044 log::warn!(
1045 "state(pg): connecting to a remote host without TLS; \
1046 add sslmode=require (or verify-ca / verify-full) to RIVET_STATE_URL \
1047 to negotiate TLS for production use"
1048 );
1049 }
1050 let mut client = connect_pg(url)?;
1051 migrate_pg(&mut client)?;
1052 Ok(Self {
1053 conn: StateConn::Postgres(Box::new(std::cell::RefCell::new(client))),
1054 state_ref: StateRef::Postgres(url.to_string()),
1055 })
1056 }
1057
1058 pub fn state_db_path(config_path: &str) -> std::path::PathBuf {
1062 let config_dir = std::path::Path::new(config_path)
1063 .parent()
1064 .unwrap_or(std::path::Path::new("."));
1065 config_dir.join(STATE_DB_NAME)
1066 }
1067
1068 pub fn state_ref(&self) -> &StateRef {
1070 &self.state_ref
1071 }
1072
1073 #[allow(dead_code)]
1075 pub fn open_in_memory() -> Result<Self> {
1076 let conn = Connection::open_in_memory()?;
1077 migrate(&conn)?;
1078 Ok(Self {
1079 conn: StateConn::Sqlite(conn),
1080 state_ref: StateRef::Sqlite(std::path::PathBuf::from(":memory:")),
1081 })
1082 }
1083
1084 #[allow(dead_code)]
1087 pub fn open_at_path(db_path: &std::path::Path) -> Result<Self> {
1088 let conn = open_connection(db_path)?;
1089 migrate(&conn)?;
1090 Ok(Self {
1091 conn: StateConn::Sqlite(conn),
1092 state_ref: StateRef::Sqlite(db_path.to_path_buf()),
1093 })
1094 }
1095}
1096
1097#[cfg(test)]
1100mod tests {
1101 use super::*;
1102
1103 #[test]
1104 fn sqlite_and_postgres_migrations_define_the_same_tables_per_version() {
1105 use std::collections::{BTreeSet, HashMap};
1111 fn table_names(sql: &str) -> BTreeSet<String> {
1112 let lower = sql.to_lowercase();
1113 let mut rest = lower.as_str();
1114 let mut out = BTreeSet::new();
1115 while let Some(i) = rest.find("create table") {
1116 rest = &rest[i + "create table".len()..];
1117 let after = rest
1118 .trim_start()
1119 .strip_prefix("if not exists")
1120 .unwrap_or_else(|| rest.trim_start())
1121 .trim_start();
1122 let name: String = after
1123 .chars()
1124 .take_while(|c| c.is_alphanumeric() || *c == '_')
1125 .collect();
1126 if !name.is_empty() {
1127 out.insert(name);
1128 }
1129 }
1130 out
1131 }
1132 let mut pg: HashMap<i64, BTreeSet<String>> = HashMap::new();
1133 for &(v, sql) in PG_MIGRATIONS {
1134 pg.entry(v).or_default().extend(table_names(sql));
1135 }
1136 for &(v, sql) in MIGRATIONS {
1137 if let Some(pg_tables) = pg.get(&v) {
1138 assert_eq!(
1139 &table_names(sql),
1140 pg_tables,
1141 "migration v{v}: SQLite and Postgres define different tables"
1142 );
1143 }
1144 }
1145 }
1146
1147 #[test]
1148 fn fresh_db_reaches_latest_version() {
1149 let s = StateStore::open_in_memory().unwrap();
1150 let ver = match &s.conn {
1151 StateConn::Sqlite(c) => get_current_version(c),
1152 StateConn::Postgres(_) => unreachable!(),
1153 };
1154 assert_eq!(ver, SCHEMA_VERSION);
1155 }
1156
1157 #[test]
1158 fn migration_is_idempotent() {
1159 let s = StateStore::open_in_memory().unwrap();
1160 match &s.conn {
1161 StateConn::Sqlite(c) => {
1162 migrate(c).unwrap();
1163 migrate(c).unwrap();
1164 assert_eq!(get_current_version(c), SCHEMA_VERSION);
1165 }
1166 StateConn::Postgres(_) => unreachable!(),
1167 }
1168 }
1169
1170 #[test]
1171 fn legacy_db_gets_upgraded() {
1172 let conn = Connection::open_in_memory().unwrap();
1173 conn.execute_batch(
1174 "CREATE TABLE export_state (
1175 export_name TEXT PRIMARY KEY,
1176 last_cursor_value TEXT,
1177 last_run_at TEXT
1178 );
1179 CREATE TABLE export_metrics (
1180 id INTEGER PRIMARY KEY AUTOINCREMENT,
1181 export_name TEXT NOT NULL,
1182 run_at TEXT NOT NULL,
1183 duration_ms INTEGER NOT NULL,
1184 total_rows INTEGER NOT NULL,
1185 status TEXT NOT NULL
1186 );",
1187 )
1188 .unwrap();
1189
1190 migrate(&conn).unwrap();
1191 assert_eq!(get_current_version(&conn), SCHEMA_VERSION);
1192
1193 let has_chunk_run: bool = conn
1194 .query_row(
1195 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='chunk_run'",
1196 [],
1197 |row| row.get(0),
1198 )
1199 .unwrap();
1200 assert!(has_chunk_run);
1201 }
1202
1203 #[test]
1204 fn upgrading_from_v12_adds_the_ledger_and_snapshot_tables_and_keeps_data() {
1205 let conn = Connection::open_in_memory().unwrap();
1209 ensure_schema_version_table(&conn);
1210 for &(ver, sql) in MIGRATIONS {
1211 if ver <= 12 {
1212 conn.execute_batch(&format!(
1213 "BEGIN;\n{sql}\nINSERT INTO schema_version (version) VALUES ({ver});\nCOMMIT;"
1214 ))
1215 .unwrap();
1216 }
1217 }
1218 assert_eq!(get_current_version(&conn), 12, "staged at v12");
1219 conn.execute(
1221 "INSERT INTO export_state (export_name, last_cursor_value, last_run_at) \
1222 VALUES ('orders', '42', '2026-01-01T00:00:00Z')",
1223 [],
1224 )
1225 .unwrap();
1226
1227 migrate(&conn).unwrap();
1229 assert_eq!(get_current_version(&conn), SCHEMA_VERSION);
1230
1231 for t in ["load_run", "loaded_source_run", "cdc_snapshot"] {
1233 let exists: bool = conn
1234 .query_row(
1235 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
1236 [t],
1237 |r| r.get(0),
1238 )
1239 .unwrap();
1240 assert!(
1241 exists,
1242 "{t} missing after the v12→v{SCHEMA_VERSION} upgrade"
1243 );
1244 }
1245 let cursor: String = conn
1247 .query_row(
1248 "SELECT last_cursor_value FROM export_state WHERE export_name = 'orders'",
1249 [],
1250 |r| r.get(0),
1251 )
1252 .unwrap();
1253 assert_eq!(cursor, "42", "pre-upgrade data must survive");
1254 }
1255
1256 #[test]
1264 fn pg_upgrade_from_v18_lands_keyset_range_as_bigint_and_keeps_data() {
1265 let Ok(url) = std::env::var("RIVET_TEST_STATE_URL") else {
1266 return;
1267 };
1268 if !url.starts_with("postgres") {
1269 return;
1270 }
1271 let mut client = connect_pg(&url).expect("connect pg state");
1272 client
1275 .batch_execute(
1276 "DROP SCHEMA IF EXISTS rivet_upgrade_test CASCADE; \
1277 CREATE SCHEMA rivet_upgrade_test; SET search_path TO rivet_upgrade_test;",
1278 )
1279 .unwrap();
1280
1281 client
1284 .batch_execute(
1285 "CREATE TABLE IF NOT EXISTS rivet_schema_version (version BIGINT NOT NULL);",
1286 )
1287 .unwrap();
1288 for &(ver, sql) in PG_MIGRATIONS {
1289 if ver <= 18 {
1290 client
1291 .batch_execute(&format!(
1292 "BEGIN; {sql} INSERT INTO rivet_schema_version (version) VALUES ({ver}); COMMIT;"
1293 ))
1294 .unwrap();
1295 }
1296 }
1297 client
1299 .batch_execute(
1300 "INSERT INTO export_state (export_name, last_cursor_value, last_run_at) \
1301 VALUES ('orders', '42', '2026-01-01T00:00:00Z')",
1302 )
1303 .unwrap();
1304
1305 migrate_pg(&mut client).expect("v18 -> HEAD upgrade must apply cleanly on a populated db");
1307
1308 let dtype: String = client
1310 .query_one(
1311 "SELECT data_type FROM information_schema.columns \
1312 WHERE table_schema = 'rivet_upgrade_test' AND table_name = 'keyset_range' \
1313 AND column_name = 'range_index'",
1314 &[],
1315 )
1316 .unwrap()
1317 .get(0);
1318 assert_eq!(
1319 dtype, "bigint",
1320 "v19 keyset_range.range_index must upgrade to BIGINT, not int4 (bug #1)"
1321 );
1322 let cursor: String = client
1324 .query_one(
1325 "SELECT last_cursor_value FROM export_state WHERE export_name = 'orders'",
1326 &[],
1327 )
1328 .unwrap()
1329 .get(0);
1330 assert_eq!(
1331 cursor, "42",
1332 "pre-upgrade cursor must survive the migration"
1333 );
1334
1335 client
1336 .batch_execute("DROP SCHEMA IF EXISTS rivet_upgrade_test CASCADE;")
1337 .unwrap();
1338 }
1339
1340 #[test]
1341 fn v8_renames_file_manifest_to_file_log() {
1342 let s = StateStore::open_in_memory().unwrap();
1343 let conn = match &s.conn {
1344 StateConn::Sqlite(c) => c,
1345 StateConn::Postgres(_) => unreachable!(),
1346 };
1347 let has_file_log: bool = conn
1348 .query_row(
1349 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='file_log'",
1350 [],
1351 |row| row.get(0),
1352 )
1353 .unwrap();
1354 assert!(has_file_log, "v8 must produce a `file_log` table");
1355 let has_old: bool = conn
1356 .query_row(
1357 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='file_manifest'",
1358 [],
1359 |row| row.get(0),
1360 )
1361 .unwrap();
1362 assert!(!has_old, "v8 must remove the old `file_manifest` table");
1363 let has_new_idx: bool = conn
1364 .query_row(
1365 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name='idx_file_log_export'",
1366 [],
1367 |row| row.get(0),
1368 )
1369 .unwrap();
1370 assert!(has_new_idx, "v8 must create the renamed index");
1371 }
1372
1373 #[test]
1374 fn v8_upgrades_existing_v7_db_with_data() {
1375 let conn = Connection::open_in_memory().unwrap();
1378 migrate(&conn).unwrap();
1382 conn.execute(
1384 "INSERT INTO file_log (run_id, export_name, file_name, row_count, bytes, format, created_at)
1385 VALUES ('r1', 'orders', 'f.parquet', 100, 4096, 'parquet', '2026-05-21T00:00:00Z')",
1386 [],
1387 )
1388 .unwrap();
1389 let count: i64 = conn
1390 .query_row("SELECT COUNT(*) FROM file_log", [], |r| r.get(0))
1391 .unwrap();
1392 assert_eq!(count, 1);
1393 }
1394
1395 #[test]
1396 fn run_aggregate_table_exists_after_migration() {
1397 let s = StateStore::open_in_memory().unwrap();
1398 let conn = match &s.conn {
1399 StateConn::Sqlite(c) => c,
1400 StateConn::Postgres(_) => unreachable!(),
1401 };
1402 let exists: bool = conn
1403 .query_row(
1404 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='run_aggregate'",
1405 [],
1406 |row| row.get(0),
1407 )
1408 .unwrap();
1409 assert!(exists, "v5 migration must create the run_aggregate table");
1410 }
1411
1412 #[test]
1413 fn v13_creates_the_load_ledger_tables() {
1414 let s = StateStore::open_in_memory().unwrap();
1415 let conn = match &s.conn {
1416 StateConn::Sqlite(c) => c,
1417 StateConn::Postgres(_) => unreachable!(),
1418 };
1419 for table in ["load_run", "loaded_source_run"] {
1420 let exists: bool = conn
1421 .query_row(
1422 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
1423 [table],
1424 |row| row.get(0),
1425 )
1426 .unwrap();
1427 assert!(exists, "v13 migration must create `{table}`");
1428 }
1429 }
1430
1431 #[test]
1432 fn v14_creates_the_cdc_snapshot_table() {
1433 let s = StateStore::open_in_memory().unwrap();
1434 let conn = match &s.conn {
1435 StateConn::Sqlite(c) => c,
1436 StateConn::Postgres(_) => unreachable!(),
1437 };
1438 let exists: bool = conn
1439 .query_row(
1440 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='cdc_snapshot'",
1441 [],
1442 |row| row.get(0),
1443 )
1444 .unwrap();
1445 assert!(exists, "v14 migration must create the cdc_snapshot table");
1446 }
1447
1448 #[test]
1449 fn pg_sql_converts_placeholders() {
1450 assert_eq!(
1451 pg_sql("SELECT ?1, ?2 FROM t WHERE x = ?3"),
1452 "SELECT $1, $2 FROM t WHERE x = $3"
1453 );
1454 assert_eq!(
1455 pg_sql("INSERT INTO t VALUES (?1, ?2)"),
1456 "INSERT INTO t VALUES ($1, $2)"
1457 );
1458 assert_eq!(pg_sql("no placeholders"), "no placeholders");
1459 assert_eq!(pg_sql("?10 AND ?11"), "$10 AND $11");
1461 }
1462
1463 #[test]
1464 fn redact_pg_url_removes_password() {
1465 assert_eq!(
1466 redact_pg_url("postgresql://rivet:secret123@localhost:5433/rivet_state"),
1467 "postgresql://rivet:***@localhost:5433/rivet_state"
1468 );
1469 assert_eq!(
1470 redact_pg_url("postgres://admin:p@ssw0rd@db.prod.example.com/state"),
1471 "postgres://admin:***@db.prod.example.com/state"
1472 );
1473 }
1474
1475 #[test]
1476 fn redact_pg_url_no_password_unchanged() {
1477 let url = "postgresql://rivet@localhost/state";
1479 assert_eq!(redact_pg_url(url), url);
1480 }
1481
1482 #[test]
1483 fn redact_pg_url_stray_at_in_query_does_not_leak_password() {
1484 let out = redact_pg_url("postgresql://u:secret@host:5432/db?opt=a@b");
1490 assert!(
1491 !out.contains("secret"),
1492 "password must not survive redaction with a stray '@' in the query: {out}"
1493 );
1494 assert_eq!(
1496 redact_pg_url("postgresql://u:secret@host:5432/db"),
1497 "postgresql://u:***@host:5432/db"
1498 );
1499 }
1500
1501 #[test]
1502 fn redact_pg_url_common_hostport_url_is_not_mangled() {
1503 let url = "postgresql://db.internal:5432/orders";
1508 assert_eq!(
1509 redact_pg_url(url),
1510 url,
1511 "a credential-free host:port URL is untouched"
1512 );
1513 assert_eq!(
1514 redact_pg_url("connecting to postgresql://db:5432/x then retry"),
1515 "connecting to postgresql://db:5432/x then retry"
1516 );
1517 }
1518
1519 #[test]
1520 fn redact_pg_url_at_or_colon_in_password_does_not_leak() {
1521 assert_eq!(
1527 redact_pg_url("postgresql://rivet:Kp@9x/Lm2z@db.prod:5432/orders"),
1528 "postgresql://rivet:***@db.prod:5432/orders",
1529 "'@'-before-'/' password tail must not leak"
1530 );
1531 assert_eq!(
1532 redact_pg_url("postgresql://rivet:a:b:c:secret@host:5432/state"),
1533 "postgresql://rivet:***@host:5432/state",
1534 "':'-bearing password prefix must not leak (FIRST-colon split)"
1535 );
1536 for u in [
1537 "postgresql://rivet:Kp@9x/Lm2z@db/orders",
1538 "postgresql://rivet:a:b:c:secret@host/state",
1539 "postgresql://u:p@w?rd@host/db",
1540 "postgresql://u:p@w#rd@host/db",
1541 ] {
1542 let out = redact_pg_url(u);
1543 assert!(
1544 !out.contains("Lm2z")
1545 && !out.contains("a:b:c")
1546 && !out.contains("w?rd")
1547 && !out.contains("w#rd"),
1548 "no password fragment may survive: {out}"
1549 );
1550 }
1551 }
1552
1553 #[test]
1554 fn redact_pg_url_password_with_raw_delimiters_does_not_leak() {
1555 assert_eq!(
1561 redact_pg_url("postgresql://u:pa/ss@host/db"),
1562 "postgresql://u:***@host/db",
1563 "'/' in password must be redacted, not leaked"
1564 );
1565 assert_eq!(
1566 redact_pg_url("postgresql://u:pa?ss@host/db"),
1567 "postgresql://u:***@host/db",
1568 "'?' in password must be redacted"
1569 );
1570 assert_eq!(
1571 redact_pg_url("postgresql://u:pa#ss@host/db"),
1572 "postgresql://u:***@host/db",
1573 "'#' in password must be redacted"
1574 );
1575 for u in [
1577 "postgresql://rivet:Xy/9Zq@db:5432/state",
1578 "postgres://admin:p/a?s#s@db.example.com/state",
1579 ] {
1580 assert!(
1581 !redact_pg_url(u).contains("Xy/9Zq") && !redact_pg_url(u).contains("p/a?s#s"),
1582 "no raw-delimiter password may survive: {}",
1583 redact_pg_url(u)
1584 );
1585 }
1586 }
1587
1588 use crate::config::TlsMode;
1596
1597 #[test]
1598 fn state_sslmode_enforced_values_negotiate_tls() {
1599 for (url, want) in [
1600 (
1601 "postgresql://u:p@db.prod:5432/state?sslmode=require",
1602 TlsMode::Require,
1603 ),
1604 (
1605 "postgresql://u:p@db.prod/state?sslmode=verify-ca",
1606 TlsMode::VerifyCa,
1607 ),
1608 (
1609 "postgresql://u:p@db.prod/state?sslmode=verify-full",
1610 TlsMode::VerifyFull,
1611 ),
1612 ] {
1613 let mode = state_tls_mode_from_url(url);
1614 assert_eq!(mode, Some(want), "url: {url}");
1615 assert!(
1616 mode.unwrap().is_enforced(),
1617 "{want:?} must enforce TLS (not NoTls)"
1618 );
1619 }
1620 }
1621
1622 #[test]
1623 fn state_sslmode_plaintext_values_stay_notls() {
1624 for url in [
1627 "postgresql://u:p@localhost/state",
1628 "postgresql://u:p@localhost/state?sslmode=disable",
1629 "postgresql://u:p@db/state?sslmode=prefer",
1630 "postgresql://u:p@db/state?sslmode=allow",
1631 "postgresql://u:p@db/state?sslmode=REQUIRE",
1632 "postgresql://u:p@db/state?sslmode=garbage",
1633 "postgresql://u:p@db/state?sslmode",
1634 "postgresql://u:p@db/state?sslmode=",
1635 ] {
1636 assert_eq!(state_tls_mode_from_url(url), None, "url: {url}");
1637 }
1638 }
1639
1640 #[test]
1641 fn state_sslmode_exact_key_and_last_occurrence_wins() {
1642 assert_eq!(
1644 state_tls_mode_from_url("postgresql://u:p@db/state?xsslmode=require"),
1645 None
1646 );
1647 assert_eq!(
1649 state_tls_mode_from_url(
1650 "postgresql://u:p@db/state?connect_timeout=10&sslmode=require&application_name=x"
1651 ),
1652 Some(TlsMode::Require)
1653 );
1654 assert_eq!(
1656 state_tls_mode_from_url("postgresql://u:p@db/state?sslmode=disable&sslmode=require"),
1657 Some(TlsMode::Require)
1658 );
1659 assert_eq!(
1660 state_tls_mode_from_url("postgresql://u:p@db/state?sslmode=require&sslmode=disable"),
1661 None
1662 );
1663 }
1664}