1use rusqlite::Connection;
2
3use crate::error::Result;
4
5mod checkpoint;
6mod cursor;
7mod file_log;
8mod journal_store;
9mod metrics;
10mod progression;
11mod run_aggregate;
12mod schema;
13mod shape;
14
15#[allow(unused_imports)]
19pub use checkpoint::ChunkTaskInfo;
20#[allow(unused_imports)]
21pub use file_log::FileRecord;
22#[allow(unused_imports)]
23pub use metrics::ExportMetric;
24pub use metrics::MetricRow;
25#[allow(unused_imports)]
26pub use progression::{Boundary, ExportProgression};
27#[allow(unused_imports)]
28pub use run_aggregate::{RunAggregate, RunAggregateEntry};
29#[allow(unused_imports)]
30pub use schema::{SchemaChange, SchemaColumn, arrow_schema_to_columns, schema_fingerprint};
31#[allow(unused_imports)]
32pub use shape::ShapeWarning;
33
34const STATE_DB_NAME: &str = ".rivet_state.db";
35
36const SCHEMA_VERSION: i64 = MIGRATIONS[MIGRATIONS.len() - 1].0;
38
39const MIGRATIONS: &[(i64, &str)] = &[
41 (
43 1,
44 "CREATE TABLE IF NOT EXISTS export_state (
45 export_name TEXT PRIMARY KEY,
46 last_cursor_value TEXT,
47 last_run_at TEXT
48 );
49 CREATE TABLE IF NOT EXISTS export_metrics (
50 id INTEGER PRIMARY KEY AUTOINCREMENT,
51 export_name TEXT NOT NULL,
52 run_at TEXT NOT NULL,
53 duration_ms INTEGER NOT NULL,
54 total_rows INTEGER NOT NULL,
55 peak_rss_mb INTEGER,
56 status TEXT NOT NULL,
57 error_message TEXT,
58 tuning_profile TEXT,
59 format TEXT,
60 mode TEXT,
61 files_produced INTEGER DEFAULT 0,
62 bytes_written INTEGER DEFAULT 0,
63 retries INTEGER DEFAULT 0,
64 validated INTEGER,
65 schema_changed INTEGER,
66 run_id TEXT
67 );
68 CREATE TABLE IF NOT EXISTS export_schema (
69 export_name TEXT PRIMARY KEY,
70 columns_json TEXT NOT NULL,
71 updated_at TEXT NOT NULL
72 );
73 CREATE TABLE IF NOT EXISTS file_manifest (
74 id INTEGER PRIMARY KEY AUTOINCREMENT,
75 run_id TEXT NOT NULL,
76 export_name TEXT NOT NULL,
77 file_name TEXT NOT NULL,
78 row_count INTEGER NOT NULL,
79 bytes INTEGER NOT NULL,
80 format TEXT NOT NULL,
81 compression TEXT,
82 created_at TEXT NOT NULL
83 );",
84 ),
85 (
87 2,
88 "CREATE TABLE IF NOT EXISTS chunk_run (
89 run_id TEXT PRIMARY KEY,
90 export_name TEXT NOT NULL,
91 plan_hash TEXT NOT NULL,
92 status TEXT NOT NULL,
93 max_chunk_attempts INTEGER NOT NULL DEFAULT 3,
94 created_at TEXT NOT NULL,
95 updated_at TEXT NOT NULL
96 );
97 CREATE INDEX IF NOT EXISTS idx_chunk_run_export_status
98 ON chunk_run(export_name, status);
99 CREATE TABLE IF NOT EXISTS chunk_task (
100 id INTEGER PRIMARY KEY AUTOINCREMENT,
101 run_id TEXT NOT NULL,
102 chunk_index INTEGER NOT NULL,
103 start_key TEXT NOT NULL,
104 end_key TEXT NOT NULL,
105 status TEXT NOT NULL,
106 attempts INTEGER NOT NULL DEFAULT 0,
107 last_error TEXT,
108 rows_written INTEGER,
109 file_name TEXT,
110 updated_at TEXT NOT NULL,
111 UNIQUE(run_id, chunk_index)
112 );
113 CREATE INDEX IF NOT EXISTS idx_chunk_task_run_status ON chunk_task(run_id, status);",
114 ),
115 (
117 3,
118 "CREATE INDEX IF NOT EXISTS idx_file_manifest_export ON file_manifest(export_name, id DESC);",
119 ),
120 (
122 4,
123 "CREATE TABLE IF NOT EXISTS export_progression (
124 export_name TEXT PRIMARY KEY,
125 last_committed_strategy TEXT,
126 last_committed_cursor TEXT,
127 last_committed_chunk_index INTEGER,
128 last_committed_run_id TEXT,
129 last_committed_at TEXT,
130 last_verified_strategy TEXT,
131 last_verified_cursor TEXT,
132 last_verified_chunk_index INTEGER,
133 last_verified_run_id TEXT,
134 last_verified_at TEXT
135 );",
136 ),
137 (
139 5,
140 "CREATE TABLE IF NOT EXISTS run_aggregate (
141 run_aggregate_id TEXT PRIMARY KEY,
142 started_at TEXT NOT NULL,
143 finished_at TEXT NOT NULL,
144 duration_ms INTEGER NOT NULL,
145 config_path TEXT,
146 parallel_mode TEXT NOT NULL,
147 total_exports INTEGER NOT NULL,
148 success_count INTEGER NOT NULL,
149 failed_count INTEGER NOT NULL,
150 skipped_count INTEGER NOT NULL,
151 total_rows INTEGER NOT NULL,
152 total_files INTEGER NOT NULL,
153 total_bytes INTEGER NOT NULL,
154 details_json TEXT NOT NULL
155 );
156 CREATE INDEX IF NOT EXISTS idx_run_aggregate_finished
157 ON run_aggregate(finished_at DESC);",
158 ),
159 (
161 6,
162 "CREATE TABLE IF NOT EXISTS export_shape (
163 export_name TEXT NOT NULL,
164 column_name TEXT NOT NULL,
165 max_byte_len INTEGER NOT NULL,
166 updated_at TEXT NOT NULL,
167 PRIMARY KEY (export_name, column_name)
168 );",
169 ),
170 (
172 7,
173 "CREATE TABLE IF NOT EXISTS run_journal (
174 run_id TEXT PRIMARY KEY,
175 export_name TEXT NOT NULL,
176 finished_at TEXT NOT NULL,
177 journal_json TEXT NOT NULL
178 );
179 CREATE INDEX IF NOT EXISTS idx_run_journal_export
180 ON run_journal(export_name, finished_at DESC);",
181 ),
182 (
186 8,
187 "ALTER TABLE file_manifest RENAME TO file_log;
188 DROP INDEX IF EXISTS idx_file_manifest_export;
189 CREATE INDEX IF NOT EXISTS idx_file_log_export ON file_log(export_name, id DESC);",
190 ),
191 (
198 9,
199 "ALTER TABLE export_metrics ADD COLUMN files_committed INTEGER;
200 ALTER TABLE export_metrics ADD COLUMN reconciled INTEGER;
201 ALTER TABLE export_metrics ADD COLUMN source_count INTEGER;
202 ALTER TABLE export_metrics ADD COLUMN quality_passed INTEGER;
203 ALTER TABLE export_metrics ADD COLUMN pg_temp_bytes_delta INTEGER;
204 ALTER TABLE export_metrics ADD COLUMN batch_size INTEGER;
205 ALTER TABLE export_metrics ADD COLUMN batch_size_memory_mb INTEGER;
206 ALTER TABLE export_metrics ADD COLUMN skip_reason TEXT;
207 ALTER TABLE export_metrics ADD COLUMN schema_fingerprint TEXT;
208 ALTER TABLE export_metrics ADD COLUMN chunk_size INTEGER;
209 ALTER TABLE export_metrics ADD COLUMN parallel INTEGER;
210 ALTER TABLE export_metrics ADD COLUMN source_type TEXT;
211 ALTER TABLE export_metrics ADD COLUMN destination_type TEXT;
212 ALTER TABLE export_metrics ADD COLUMN rivet_version TEXT;",
213 ),
214 (
217 10,
218 "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms INTEGER;",
219 ),
220 (
225 11,
226 "CREATE TABLE IF NOT EXISTS export_harm (
227 id INTEGER PRIMARY KEY AUTOINCREMENT,
228 run_id TEXT NOT NULL,
229 export_name TEXT NOT NULL,
230 metric TEXT NOT NULL,
231 delta INTEGER NOT NULL,
232 recorded_at TEXT NOT NULL
233 );
234 CREATE INDEX IF NOT EXISTS idx_export_harm_run ON export_harm(run_id);",
235 ),
236 (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
244];
245
246const PG_MIGRATIONS: &[(i64, &str)] = &[
249 (
250 1,
251 "CREATE TABLE IF NOT EXISTS export_state (
252 export_name TEXT PRIMARY KEY,
253 last_cursor_value TEXT,
254 last_run_at TEXT
255 );
256 CREATE TABLE IF NOT EXISTS export_metrics (
257 id BIGSERIAL PRIMARY KEY,
258 export_name TEXT NOT NULL,
259 run_at TEXT NOT NULL,
260 duration_ms BIGINT NOT NULL,
261 total_rows BIGINT NOT NULL,
262 peak_rss_mb BIGINT,
263 status TEXT NOT NULL,
264 error_message TEXT,
265 tuning_profile TEXT,
266 format TEXT,
267 mode TEXT,
268 files_produced BIGINT DEFAULT 0,
269 bytes_written BIGINT DEFAULT 0,
270 retries BIGINT DEFAULT 0,
271 validated BOOLEAN,
272 schema_changed BOOLEAN,
273 run_id TEXT
274 );
275 CREATE TABLE IF NOT EXISTS export_schema (
276 export_name TEXT PRIMARY KEY,
277 columns_json TEXT NOT NULL,
278 updated_at TEXT NOT NULL
279 );
280 CREATE TABLE IF NOT EXISTS file_manifest (
281 id BIGSERIAL PRIMARY KEY,
282 run_id TEXT NOT NULL,
283 export_name TEXT NOT NULL,
284 file_name TEXT NOT NULL,
285 row_count BIGINT NOT NULL,
286 bytes BIGINT NOT NULL,
287 format TEXT NOT NULL,
288 compression TEXT,
289 created_at TEXT NOT NULL
290 );",
291 ),
292 (
293 2,
294 "CREATE TABLE IF NOT EXISTS chunk_run (
295 run_id TEXT PRIMARY KEY,
296 export_name TEXT NOT NULL,
297 plan_hash TEXT NOT NULL,
298 status TEXT NOT NULL,
299 max_chunk_attempts BIGINT NOT NULL DEFAULT 3,
300 created_at TEXT NOT NULL,
301 updated_at TEXT NOT NULL
302 );
303 CREATE INDEX IF NOT EXISTS idx_chunk_run_export_status
304 ON chunk_run(export_name, status);
305 CREATE TABLE IF NOT EXISTS chunk_task (
306 id BIGSERIAL PRIMARY KEY,
307 run_id TEXT NOT NULL,
308 chunk_index BIGINT NOT NULL,
309 start_key TEXT NOT NULL,
310 end_key TEXT NOT NULL,
311 status TEXT NOT NULL,
312 attempts BIGINT NOT NULL DEFAULT 0,
313 last_error TEXT,
314 rows_written BIGINT,
315 file_name TEXT,
316 updated_at TEXT NOT NULL,
317 UNIQUE(run_id, chunk_index)
318 );
319 CREATE INDEX IF NOT EXISTS idx_chunk_task_run_status ON chunk_task(run_id, status);",
320 ),
321 (
322 3,
323 "CREATE INDEX IF NOT EXISTS idx_file_manifest_export ON file_manifest(export_name, id DESC);",
324 ),
325 (
326 4,
327 "CREATE TABLE IF NOT EXISTS export_progression (
328 export_name TEXT PRIMARY KEY,
329 last_committed_strategy TEXT,
330 last_committed_cursor TEXT,
331 last_committed_chunk_index BIGINT,
332 last_committed_run_id TEXT,
333 last_committed_at TEXT,
334 last_verified_strategy TEXT,
335 last_verified_cursor TEXT,
336 last_verified_chunk_index BIGINT,
337 last_verified_run_id TEXT,
338 last_verified_at TEXT
339 );",
340 ),
341 (
342 5,
343 "CREATE TABLE IF NOT EXISTS run_aggregate (
344 run_aggregate_id TEXT PRIMARY KEY,
345 started_at TEXT NOT NULL,
346 finished_at TEXT NOT NULL,
347 duration_ms BIGINT NOT NULL,
348 config_path TEXT,
349 parallel_mode TEXT NOT NULL,
350 total_exports BIGINT NOT NULL,
351 success_count BIGINT NOT NULL,
352 failed_count BIGINT NOT NULL,
353 skipped_count BIGINT NOT NULL,
354 total_rows BIGINT NOT NULL,
355 total_files BIGINT NOT NULL,
356 total_bytes BIGINT NOT NULL,
357 details_json TEXT NOT NULL
358 );
359 CREATE INDEX IF NOT EXISTS idx_run_aggregate_finished
360 ON run_aggregate(finished_at DESC);",
361 ),
362 (
363 6,
364 "CREATE TABLE IF NOT EXISTS export_shape (
365 export_name TEXT NOT NULL,
366 column_name TEXT NOT NULL,
367 max_byte_len BIGINT NOT NULL,
368 updated_at TEXT NOT NULL,
369 PRIMARY KEY (export_name, column_name)
370 );",
371 ),
372 (
373 7,
374 "CREATE TABLE IF NOT EXISTS run_journal (
375 run_id TEXT PRIMARY KEY,
376 export_name TEXT NOT NULL,
377 finished_at TEXT NOT NULL,
378 journal_json TEXT NOT NULL
379 );
380 CREATE INDEX IF NOT EXISTS idx_run_journal_export
381 ON run_journal(export_name, finished_at DESC);",
382 ),
383 (
386 8,
387 "ALTER TABLE file_manifest RENAME TO file_log;
388 DROP INDEX IF EXISTS idx_file_manifest_export;
389 CREATE INDEX IF NOT EXISTS idx_file_log_export ON file_log(export_name, id DESC);",
390 ),
391 (
394 9,
395 "ALTER TABLE export_metrics ADD COLUMN files_committed BIGINT;
396 ALTER TABLE export_metrics ADD COLUMN reconciled BOOLEAN;
397 ALTER TABLE export_metrics ADD COLUMN source_count BIGINT;
398 ALTER TABLE export_metrics ADD COLUMN quality_passed BOOLEAN;
399 ALTER TABLE export_metrics ADD COLUMN pg_temp_bytes_delta BIGINT;
400 ALTER TABLE export_metrics ADD COLUMN batch_size BIGINT;
401 ALTER TABLE export_metrics ADD COLUMN batch_size_memory_mb BIGINT;
402 ALTER TABLE export_metrics ADD COLUMN skip_reason TEXT;
403 ALTER TABLE export_metrics ADD COLUMN schema_fingerprint TEXT;
404 ALTER TABLE export_metrics ADD COLUMN chunk_size BIGINT;
405 ALTER TABLE export_metrics ADD COLUMN parallel BIGINT;
406 ALTER TABLE export_metrics ADD COLUMN source_type TEXT;
407 ALTER TABLE export_metrics ADD COLUMN destination_type TEXT;
408 ALTER TABLE export_metrics ADD COLUMN rivet_version TEXT;",
409 ),
410 (
412 10,
413 "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms BIGINT;",
414 ),
415 (
417 11,
418 "CREATE TABLE IF NOT EXISTS export_harm (
419 id BIGSERIAL PRIMARY KEY,
420 run_id TEXT NOT NULL,
421 export_name TEXT NOT NULL,
422 metric TEXT NOT NULL,
423 delta BIGINT NOT NULL,
424 recorded_at TEXT NOT NULL
425 );
426 CREATE INDEX IF NOT EXISTS idx_export_harm_run ON export_harm(run_id);",
427 ),
428 (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
430];
431
432pub(super) fn pg_sql(sql: &str) -> String {
437 let bytes = sql.as_bytes();
438 let mut out = String::with_capacity(sql.len());
439 let mut i = 0;
440 while i < bytes.len() {
441 if bytes[i] == b'?' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
442 out.push('$');
443 } else {
444 out.push(bytes[i] as char);
445 }
446 i += 1;
447 }
448 out
449}
450
451pub(super) fn connect_pg(url: &str) -> Result<postgres::Client> {
468 let tls = state_tls_mode_from_url(url).map(|mode| crate::config::TlsConfig {
469 mode,
470 ..crate::config::TlsConfig::default()
471 });
472 crate::source::postgres::connect_client(url, tls.as_ref())
473 .map_err(|e| anyhow::anyhow!("state(pg): connect to '{}': {:#}", redact_pg_url(url), e))
474}
475
476fn state_tls_mode_from_url(url: &str) -> Option<crate::config::TlsMode> {
484 use crate::config::TlsMode;
485 let (_, query) = url.split_once('?')?;
486 let mut mode = None;
487 for pair in query.split('&') {
488 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
489 if key != "sslmode" {
490 continue;
491 }
492 mode = match value {
493 "require" => Some(TlsMode::Require),
494 "verify-ca" => Some(TlsMode::VerifyCa),
495 "verify-full" => Some(TlsMode::VerifyFull),
496 _ => None,
497 };
498 }
499 mode
500}
501
502pub(super) enum StateConn {
506 Sqlite(rusqlite::Connection),
507 Postgres(Box<std::cell::RefCell<postgres::Client>>),
512}
513
514#[derive(Clone)]
518pub enum StateRef {
519 Sqlite(std::path::PathBuf),
520 Postgres(String),
521}
522
523fn ensure_schema_version_table(conn: &Connection) {
526 let _ = conn.execute_batch(
527 "CREATE TABLE IF NOT EXISTS schema_version (
528 version INTEGER NOT NULL
529 );",
530 );
531}
532
533fn get_current_version(conn: &Connection) -> i64 {
534 conn.query_row(
535 "SELECT COALESCE(MAX(version), 0) FROM schema_version",
536 [],
537 |row| row.get(0),
538 )
539 .unwrap_or(0)
540}
541
542fn migrate(conn: &Connection) -> Result<()> {
543 ensure_schema_version_table(conn);
544
545 let current = get_current_version(conn);
546
547 if current == 0 {
548 let has_export_state: bool = conn
549 .query_row(
550 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='export_state'",
551 [],
552 |row| row.get(0),
553 )
554 .unwrap_or(false);
555
556 if has_export_state {
557 let metrics_cols = [
558 "files_produced INTEGER DEFAULT 0",
559 "bytes_written INTEGER DEFAULT 0",
560 "retries INTEGER DEFAULT 0",
561 "validated INTEGER",
562 "schema_changed INTEGER",
563 "run_id TEXT",
564 ];
565 for col_def in &metrics_cols {
566 let sql = format!("ALTER TABLE export_metrics ADD COLUMN {}", col_def);
567 let _ = conn.execute(&sql, []);
568 }
569 }
570 }
571
572 for &(ver, sql) in MIGRATIONS {
573 if ver > current {
574 log::debug!("state: applying migration v{}", ver);
575 let atomic_sql = format!(
576 "BEGIN;\n{}\nINSERT INTO schema_version (version) VALUES ({});\nCOMMIT;",
577 sql, ver
578 );
579 conn.execute_batch(&atomic_sql)
580 .map_err(|e| anyhow::anyhow!("state: migration v{} failed: {}", ver, e))?;
581 }
582 }
583
584 let _ = conn.execute(
585 "DELETE FROM schema_version WHERE version < (SELECT MAX(version) FROM schema_version)",
586 [],
587 );
588
589 let final_version = get_current_version(conn);
590 if final_version != SCHEMA_VERSION {
591 anyhow::bail!(
592 "state: migration incomplete — expected schema v{} but reached v{}",
593 SCHEMA_VERSION,
594 final_version
595 );
596 }
597
598 Ok(())
599}
600
601fn migrate_pg(client: &mut postgres::Client) -> Result<()> {
604 client
605 .batch_execute("CREATE TABLE IF NOT EXISTS rivet_schema_version (version BIGINT NOT NULL);")
606 .map_err(|e| anyhow::anyhow!("state(pg): create version table: {:#}", e))?;
607
608 let current: i64 = client
609 .query_one(
610 "SELECT COALESCE(MAX(version), 0) FROM rivet_schema_version",
611 &[],
612 )
613 .map_err(|e| anyhow::anyhow!("state(pg): read schema version: {:#}", e))?
614 .get(0);
615
616 for &(ver, sql) in PG_MIGRATIONS {
617 if ver > current {
618 log::debug!("state(pg): applying migration v{}", ver);
619 let batch = format!(
620 "BEGIN; {} INSERT INTO rivet_schema_version (version) VALUES ({}); COMMIT;",
621 sql, ver
622 );
623 client
624 .batch_execute(&batch)
625 .map_err(|e| anyhow::anyhow!("state(pg): migration v{} failed: {:#}", ver, e))?;
626 }
627 }
628
629 let _ = client.batch_execute(
631 "DELETE FROM rivet_schema_version \
632 WHERE version < (SELECT MAX(version) FROM rivet_schema_version);",
633 );
634
635 let final_version: i64 = client
637 .query_one(
638 "SELECT COALESCE(MAX(version), 0) FROM rivet_schema_version",
639 &[],
640 )
641 .map_err(|e| anyhow::anyhow!("state(pg): read final schema version: {:#}", e))?
642 .get(0);
643 if final_version != SCHEMA_VERSION {
644 anyhow::bail!(
645 "state(pg): migration incomplete — expected schema v{} but reached v{}",
646 SCHEMA_VERSION,
647 final_version
648 );
649 }
650
651 Ok(())
652}
653
654fn redact_pg_url(url: &str) -> String {
658 if let Some(at_pos) = url.rfind('@')
659 && let Some(scheme_end) = url.find("://")
660 {
661 let authority = &url[scheme_end + 3..at_pos];
662 if let Some(colon) = authority.rfind(':') {
663 let user = &authority[..colon];
664 return format!(
665 "{}://{}:***@{}",
666 &url[..scheme_end],
667 user,
668 &url[at_pos + 1..]
669 );
670 }
671 }
672 url.to_string()
673}
674
675pub(crate) const SQLITE_BUSY_TIMEOUT_MS: i64 = 10_000;
678
679pub(crate) fn open_connection(db_path: &std::path::Path) -> Result<Connection> {
680 let conn = Connection::open(db_path)?;
681 if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL;") {
682 log::warn!(
683 "state: WAL journal mode unavailable ({}); \
684 running in default mode — concurrent writes may be slower",
685 e
686 );
687 }
688 if let Err(e) = conn.execute_batch(&format!(
689 "PRAGMA busy_timeout = {};",
690 SQLITE_BUSY_TIMEOUT_MS
691 )) {
692 log::warn!(
693 "state: failed to set busy_timeout ({}); \
694 concurrent writers may surface SQLITE_BUSY immediately",
695 e
696 );
697 }
698 Ok(conn)
699}
700
701pub struct StateStore {
721 pub(super) conn: StateConn,
722 pub(super) state_ref: StateRef,
724}
725
726impl StateStore {
727 pub fn open(config_path: &str) -> Result<Self> {
731 if let Ok(url) = std::env::var("RIVET_STATE_URL")
732 && url.starts_with("postgres")
733 {
734 return Self::open_postgres(&url);
735 }
736 Self::open_sqlite(config_path)
737 }
738
739 fn open_sqlite(config_path: &str) -> Result<Self> {
740 let config_dir = std::path::Path::new(config_path)
741 .parent()
742 .unwrap_or(std::path::Path::new("."));
743 let db_path = config_dir.join(STATE_DB_NAME);
744 let conn = open_connection(&db_path)?;
745 migrate(&conn)?;
746 Ok(Self {
747 conn: StateConn::Sqlite(conn),
748 state_ref: StateRef::Sqlite(db_path),
749 })
750 }
751
752 fn open_postgres(url: &str) -> Result<Self> {
753 let is_local =
754 url.contains("localhost") || url.contains("127.0.0.1") || url.contains("::1");
755 if !is_local && state_tls_mode_from_url(url).is_none() {
756 log::warn!(
757 "state(pg): connecting to a remote host without TLS; \
758 add sslmode=require (or verify-ca / verify-full) to RIVET_STATE_URL \
759 to negotiate TLS for production use"
760 );
761 }
762 let mut client = connect_pg(url)?;
763 migrate_pg(&mut client)?;
764 Ok(Self {
765 conn: StateConn::Postgres(Box::new(std::cell::RefCell::new(client))),
766 state_ref: StateRef::Postgres(url.to_string()),
767 })
768 }
769
770 pub fn state_db_path(config_path: &str) -> std::path::PathBuf {
774 let config_dir = std::path::Path::new(config_path)
775 .parent()
776 .unwrap_or(std::path::Path::new("."));
777 config_dir.join(STATE_DB_NAME)
778 }
779
780 pub fn state_ref(&self) -> &StateRef {
782 &self.state_ref
783 }
784
785 #[allow(dead_code)]
787 pub fn open_in_memory() -> Result<Self> {
788 let conn = Connection::open_in_memory()?;
789 migrate(&conn)?;
790 Ok(Self {
791 conn: StateConn::Sqlite(conn),
792 state_ref: StateRef::Sqlite(std::path::PathBuf::from(":memory:")),
793 })
794 }
795
796 #[allow(dead_code)]
799 pub fn open_at_path(db_path: &std::path::Path) -> Result<Self> {
800 let conn = open_connection(db_path)?;
801 migrate(&conn)?;
802 Ok(Self {
803 conn: StateConn::Sqlite(conn),
804 state_ref: StateRef::Sqlite(db_path.to_path_buf()),
805 })
806 }
807}
808
809#[cfg(test)]
812mod tests {
813 use super::*;
814
815 #[test]
816 fn fresh_db_reaches_latest_version() {
817 let s = StateStore::open_in_memory().unwrap();
818 let ver = match &s.conn {
819 StateConn::Sqlite(c) => get_current_version(c),
820 StateConn::Postgres(_) => unreachable!(),
821 };
822 assert_eq!(ver, SCHEMA_VERSION);
823 }
824
825 #[test]
826 fn migration_is_idempotent() {
827 let s = StateStore::open_in_memory().unwrap();
828 match &s.conn {
829 StateConn::Sqlite(c) => {
830 migrate(c).unwrap();
831 migrate(c).unwrap();
832 assert_eq!(get_current_version(c), SCHEMA_VERSION);
833 }
834 StateConn::Postgres(_) => unreachable!(),
835 }
836 }
837
838 #[test]
839 fn legacy_db_gets_upgraded() {
840 let conn = Connection::open_in_memory().unwrap();
841 conn.execute_batch(
842 "CREATE TABLE export_state (
843 export_name TEXT PRIMARY KEY,
844 last_cursor_value TEXT,
845 last_run_at TEXT
846 );
847 CREATE TABLE export_metrics (
848 id INTEGER PRIMARY KEY AUTOINCREMENT,
849 export_name TEXT NOT NULL,
850 run_at TEXT NOT NULL,
851 duration_ms INTEGER NOT NULL,
852 total_rows INTEGER NOT NULL,
853 status TEXT NOT NULL
854 );",
855 )
856 .unwrap();
857
858 migrate(&conn).unwrap();
859 assert_eq!(get_current_version(&conn), SCHEMA_VERSION);
860
861 let has_chunk_run: bool = conn
862 .query_row(
863 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='chunk_run'",
864 [],
865 |row| row.get(0),
866 )
867 .unwrap();
868 assert!(has_chunk_run);
869 }
870
871 #[test]
872 fn v8_renames_file_manifest_to_file_log() {
873 let s = StateStore::open_in_memory().unwrap();
874 let conn = match &s.conn {
875 StateConn::Sqlite(c) => c,
876 StateConn::Postgres(_) => unreachable!(),
877 };
878 let has_file_log: bool = conn
879 .query_row(
880 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='file_log'",
881 [],
882 |row| row.get(0),
883 )
884 .unwrap();
885 assert!(has_file_log, "v8 must produce a `file_log` table");
886 let has_old: bool = conn
887 .query_row(
888 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='file_manifest'",
889 [],
890 |row| row.get(0),
891 )
892 .unwrap();
893 assert!(!has_old, "v8 must remove the old `file_manifest` table");
894 let has_new_idx: bool = conn
895 .query_row(
896 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name='idx_file_log_export'",
897 [],
898 |row| row.get(0),
899 )
900 .unwrap();
901 assert!(has_new_idx, "v8 must create the renamed index");
902 }
903
904 #[test]
905 fn v8_upgrades_existing_v7_db_with_data() {
906 let conn = Connection::open_in_memory().unwrap();
909 migrate(&conn).unwrap();
913 conn.execute(
915 "INSERT INTO file_log (run_id, export_name, file_name, row_count, bytes, format, created_at)
916 VALUES ('r1', 'orders', 'f.parquet', 100, 4096, 'parquet', '2026-05-21T00:00:00Z')",
917 [],
918 )
919 .unwrap();
920 let count: i64 = conn
921 .query_row("SELECT COUNT(*) FROM file_log", [], |r| r.get(0))
922 .unwrap();
923 assert_eq!(count, 1);
924 }
925
926 #[test]
927 fn run_aggregate_table_exists_after_migration() {
928 let s = StateStore::open_in_memory().unwrap();
929 let conn = match &s.conn {
930 StateConn::Sqlite(c) => c,
931 StateConn::Postgres(_) => unreachable!(),
932 };
933 let exists: bool = conn
934 .query_row(
935 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='run_aggregate'",
936 [],
937 |row| row.get(0),
938 )
939 .unwrap();
940 assert!(exists, "v5 migration must create the run_aggregate table");
941 }
942
943 #[test]
944 fn pg_sql_converts_placeholders() {
945 assert_eq!(
946 pg_sql("SELECT ?1, ?2 FROM t WHERE x = ?3"),
947 "SELECT $1, $2 FROM t WHERE x = $3"
948 );
949 assert_eq!(
950 pg_sql("INSERT INTO t VALUES (?1, ?2)"),
951 "INSERT INTO t VALUES ($1, $2)"
952 );
953 assert_eq!(pg_sql("no placeholders"), "no placeholders");
954 assert_eq!(pg_sql("?10 AND ?11"), "$10 AND $11");
956 }
957
958 #[test]
959 fn redact_pg_url_removes_password() {
960 assert_eq!(
961 redact_pg_url("postgresql://rivet:secret123@localhost:5433/rivet_state"),
962 "postgresql://rivet:***@localhost:5433/rivet_state"
963 );
964 assert_eq!(
965 redact_pg_url("postgres://admin:p@ssw0rd@db.prod.example.com/state"),
966 "postgres://admin:***@db.prod.example.com/state"
967 );
968 }
969
970 #[test]
971 fn redact_pg_url_no_password_unchanged() {
972 let url = "postgresql://rivet@localhost/state";
974 assert_eq!(redact_pg_url(url), url);
975 }
976
977 use crate::config::TlsMode;
985
986 #[test]
987 fn state_sslmode_enforced_values_negotiate_tls() {
988 for (url, want) in [
989 (
990 "postgresql://u:p@db.prod:5432/state?sslmode=require",
991 TlsMode::Require,
992 ),
993 (
994 "postgresql://u:p@db.prod/state?sslmode=verify-ca",
995 TlsMode::VerifyCa,
996 ),
997 (
998 "postgresql://u:p@db.prod/state?sslmode=verify-full",
999 TlsMode::VerifyFull,
1000 ),
1001 ] {
1002 let mode = state_tls_mode_from_url(url);
1003 assert_eq!(mode, Some(want), "url: {url}");
1004 assert!(
1005 mode.unwrap().is_enforced(),
1006 "{want:?} must enforce TLS (not NoTls)"
1007 );
1008 }
1009 }
1010
1011 #[test]
1012 fn state_sslmode_plaintext_values_stay_notls() {
1013 for url in [
1016 "postgresql://u:p@localhost/state",
1017 "postgresql://u:p@localhost/state?sslmode=disable",
1018 "postgresql://u:p@db/state?sslmode=prefer",
1019 "postgresql://u:p@db/state?sslmode=allow",
1020 "postgresql://u:p@db/state?sslmode=REQUIRE",
1021 "postgresql://u:p@db/state?sslmode=garbage",
1022 "postgresql://u:p@db/state?sslmode",
1023 "postgresql://u:p@db/state?sslmode=",
1024 ] {
1025 assert_eq!(state_tls_mode_from_url(url), None, "url: {url}");
1026 }
1027 }
1028
1029 #[test]
1030 fn state_sslmode_exact_key_and_last_occurrence_wins() {
1031 assert_eq!(
1033 state_tls_mode_from_url("postgresql://u:p@db/state?xsslmode=require"),
1034 None
1035 );
1036 assert_eq!(
1038 state_tls_mode_from_url(
1039 "postgresql://u:p@db/state?connect_timeout=10&sslmode=require&application_name=x"
1040 ),
1041 Some(TlsMode::Require)
1042 );
1043 assert_eq!(
1045 state_tls_mode_from_url("postgresql://u:p@db/state?sslmode=disable&sslmode=require"),
1046 Some(TlsMode::Require)
1047 );
1048 assert_eq!(
1049 state_tls_mode_from_url("postgresql://u:p@db/state?sslmode=require&sslmode=disable"),
1050 None
1051 );
1052 }
1053}