1use rusqlite::Connection;
2
3use crate::error::Result;
4
5mod cdc_snapshot_store;
6mod checkpoint;
7mod cursor;
8mod file_log;
9mod journal_store;
10mod load_journal_store;
11mod metrics;
12mod progression;
13mod run_aggregate;
14mod schema;
15mod shape;
16
17#[allow(unused_imports)]
21pub use checkpoint::ChunkTaskInfo;
22#[allow(unused_imports)]
23pub use file_log::FileRecord;
24pub use load_journal_store::LoadRecord;
25#[allow(unused_imports)]
26pub use metrics::ExportMetric;
27pub use metrics::MetricRow;
28#[allow(unused_imports)]
29pub use progression::{Boundary, ExportProgression};
30#[allow(unused_imports)]
31pub use run_aggregate::{RunAggregate, RunAggregateEntry};
32#[allow(unused_imports)]
33pub use schema::{SchemaChange, SchemaColumn, arrow_schema_to_columns, schema_fingerprint};
34#[allow(unused_imports)]
35pub use shape::ShapeWarning;
36
37const STATE_DB_NAME: &str = ".rivet_state.db";
38
39const SCHEMA_VERSION: i64 = MIGRATIONS[MIGRATIONS.len() - 1].0;
41
42const MIGRATIONS: &[(i64, &str)] = &[
44 (
46 1,
47 "CREATE TABLE IF NOT EXISTS export_state (
48 export_name TEXT PRIMARY KEY,
49 last_cursor_value TEXT,
50 last_run_at TEXT
51 );
52 CREATE TABLE IF NOT EXISTS export_metrics (
53 id INTEGER PRIMARY KEY AUTOINCREMENT,
54 export_name TEXT NOT NULL,
55 run_at TEXT NOT NULL,
56 duration_ms INTEGER NOT NULL,
57 total_rows INTEGER NOT NULL,
58 peak_rss_mb INTEGER,
59 status TEXT NOT NULL,
60 error_message TEXT,
61 tuning_profile TEXT,
62 format TEXT,
63 mode TEXT,
64 files_produced INTEGER DEFAULT 0,
65 bytes_written INTEGER DEFAULT 0,
66 retries INTEGER DEFAULT 0,
67 validated INTEGER,
68 schema_changed INTEGER,
69 run_id TEXT
70 );
71 CREATE TABLE IF NOT EXISTS export_schema (
72 export_name TEXT PRIMARY KEY,
73 columns_json TEXT NOT NULL,
74 updated_at TEXT NOT NULL
75 );
76 CREATE TABLE IF NOT EXISTS file_manifest (
77 id INTEGER PRIMARY KEY AUTOINCREMENT,
78 run_id TEXT NOT NULL,
79 export_name TEXT NOT NULL,
80 file_name TEXT NOT NULL,
81 row_count INTEGER NOT NULL,
82 bytes INTEGER NOT NULL,
83 format TEXT NOT NULL,
84 compression TEXT,
85 created_at TEXT NOT NULL
86 );",
87 ),
88 (
90 2,
91 "CREATE TABLE IF NOT EXISTS chunk_run (
92 run_id TEXT PRIMARY KEY,
93 export_name TEXT NOT NULL,
94 plan_hash TEXT NOT NULL,
95 status TEXT NOT NULL,
96 max_chunk_attempts INTEGER NOT NULL DEFAULT 3,
97 created_at TEXT NOT NULL,
98 updated_at TEXT NOT NULL
99 );
100 CREATE INDEX IF NOT EXISTS idx_chunk_run_export_status
101 ON chunk_run(export_name, status);
102 CREATE TABLE IF NOT EXISTS chunk_task (
103 id INTEGER PRIMARY KEY AUTOINCREMENT,
104 run_id TEXT NOT NULL,
105 chunk_index INTEGER NOT NULL,
106 start_key TEXT NOT NULL,
107 end_key TEXT NOT NULL,
108 status TEXT NOT NULL,
109 attempts INTEGER NOT NULL DEFAULT 0,
110 last_error TEXT,
111 rows_written INTEGER,
112 file_name TEXT,
113 updated_at TEXT NOT NULL,
114 UNIQUE(run_id, chunk_index)
115 );
116 CREATE INDEX IF NOT EXISTS idx_chunk_task_run_status ON chunk_task(run_id, status);",
117 ),
118 (
120 3,
121 "CREATE INDEX IF NOT EXISTS idx_file_manifest_export ON file_manifest(export_name, id DESC);",
122 ),
123 (
125 4,
126 "CREATE TABLE IF NOT EXISTS export_progression (
127 export_name TEXT PRIMARY KEY,
128 last_committed_strategy TEXT,
129 last_committed_cursor TEXT,
130 last_committed_chunk_index INTEGER,
131 last_committed_run_id TEXT,
132 last_committed_at TEXT,
133 last_verified_strategy TEXT,
134 last_verified_cursor TEXT,
135 last_verified_chunk_index INTEGER,
136 last_verified_run_id TEXT,
137 last_verified_at TEXT
138 );",
139 ),
140 (
142 5,
143 "CREATE TABLE IF NOT EXISTS run_aggregate (
144 run_aggregate_id TEXT PRIMARY KEY,
145 started_at TEXT NOT NULL,
146 finished_at TEXT NOT NULL,
147 duration_ms INTEGER NOT NULL,
148 config_path TEXT,
149 parallel_mode TEXT NOT NULL,
150 total_exports INTEGER NOT NULL,
151 success_count INTEGER NOT NULL,
152 failed_count INTEGER NOT NULL,
153 skipped_count INTEGER NOT NULL,
154 total_rows INTEGER NOT NULL,
155 total_files INTEGER NOT NULL,
156 total_bytes INTEGER NOT NULL,
157 details_json TEXT NOT NULL
158 );
159 CREATE INDEX IF NOT EXISTS idx_run_aggregate_finished
160 ON run_aggregate(finished_at DESC);",
161 ),
162 (
164 6,
165 "CREATE TABLE IF NOT EXISTS export_shape (
166 export_name TEXT NOT NULL,
167 column_name TEXT NOT NULL,
168 max_byte_len INTEGER NOT NULL,
169 updated_at TEXT NOT NULL,
170 PRIMARY KEY (export_name, column_name)
171 );",
172 ),
173 (
175 7,
176 "CREATE TABLE IF NOT EXISTS run_journal (
177 run_id TEXT PRIMARY KEY,
178 export_name TEXT NOT NULL,
179 finished_at TEXT NOT NULL,
180 journal_json TEXT NOT NULL
181 );
182 CREATE INDEX IF NOT EXISTS idx_run_journal_export
183 ON run_journal(export_name, finished_at DESC);",
184 ),
185 (
189 8,
190 "ALTER TABLE file_manifest RENAME TO file_log;
191 DROP INDEX IF EXISTS idx_file_manifest_export;
192 CREATE INDEX IF NOT EXISTS idx_file_log_export ON file_log(export_name, id DESC);",
193 ),
194 (
201 9,
202 "ALTER TABLE export_metrics ADD COLUMN files_committed INTEGER;
203 ALTER TABLE export_metrics ADD COLUMN reconciled INTEGER;
204 ALTER TABLE export_metrics ADD COLUMN source_count INTEGER;
205 ALTER TABLE export_metrics ADD COLUMN quality_passed INTEGER;
206 ALTER TABLE export_metrics ADD COLUMN pg_temp_bytes_delta INTEGER;
207 ALTER TABLE export_metrics ADD COLUMN batch_size INTEGER;
208 ALTER TABLE export_metrics ADD COLUMN batch_size_memory_mb INTEGER;
209 ALTER TABLE export_metrics ADD COLUMN skip_reason TEXT;
210 ALTER TABLE export_metrics ADD COLUMN schema_fingerprint TEXT;
211 ALTER TABLE export_metrics ADD COLUMN chunk_size INTEGER;
212 ALTER TABLE export_metrics ADD COLUMN parallel INTEGER;
213 ALTER TABLE export_metrics ADD COLUMN source_type TEXT;
214 ALTER TABLE export_metrics ADD COLUMN destination_type TEXT;
215 ALTER TABLE export_metrics ADD COLUMN rivet_version TEXT;",
216 ),
217 (
220 10,
221 "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms INTEGER;",
222 ),
223 (
228 11,
229 "CREATE TABLE IF NOT EXISTS export_harm (
230 id INTEGER PRIMARY KEY AUTOINCREMENT,
231 run_id TEXT NOT NULL,
232 export_name TEXT NOT NULL,
233 metric TEXT NOT NULL,
234 delta INTEGER NOT NULL,
235 recorded_at TEXT NOT NULL
236 );
237 CREATE INDEX IF NOT EXISTS idx_export_harm_run ON export_harm(run_id);",
238 ),
239 (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
247 (
252 13,
253 "CREATE TABLE IF NOT EXISTS load_run (
254 load_id TEXT PRIMARY KEY,
255 export_name TEXT NOT NULL,
256 target_table TEXT NOT NULL,
257 warehouse TEXT NOT NULL,
258 mode TEXT NOT NULL,
259 source_run_ids TEXT NOT NULL,
260 rows_loaded INTEGER NOT NULL,
261 status TEXT NOT NULL,
262 finished_at TEXT NOT NULL
263 );
264 CREATE INDEX IF NOT EXISTS idx_load_run_target
265 ON load_run(target_table, finished_at DESC);
266 CREATE TABLE IF NOT EXISTS loaded_source_run (
267 target_table TEXT NOT NULL,
268 source_run_id TEXT NOT NULL,
269 load_id TEXT NOT NULL,
270 loaded_at TEXT NOT NULL,
271 PRIMARY KEY (target_table, source_run_id)
272 );",
273 ),
274 (
279 14,
280 "CREATE TABLE IF NOT EXISTS cdc_snapshot (
281 export_name TEXT NOT NULL,
282 table_name TEXT NOT NULL,
283 run_id TEXT NOT NULL,
284 completed_at TEXT NOT NULL,
285 PRIMARY KEY (export_name, table_name)
286 );",
287 ),
288];
289
290const PG_MIGRATIONS: &[(i64, &str)] = &[
293 (
294 1,
295 "CREATE TABLE IF NOT EXISTS export_state (
296 export_name TEXT PRIMARY KEY,
297 last_cursor_value TEXT,
298 last_run_at TEXT
299 );
300 CREATE TABLE IF NOT EXISTS export_metrics (
301 id BIGSERIAL PRIMARY KEY,
302 export_name TEXT NOT NULL,
303 run_at TEXT NOT NULL,
304 duration_ms BIGINT NOT NULL,
305 total_rows BIGINT NOT NULL,
306 peak_rss_mb BIGINT,
307 status TEXT NOT NULL,
308 error_message TEXT,
309 tuning_profile TEXT,
310 format TEXT,
311 mode TEXT,
312 files_produced BIGINT DEFAULT 0,
313 bytes_written BIGINT DEFAULT 0,
314 retries BIGINT DEFAULT 0,
315 validated BOOLEAN,
316 schema_changed BOOLEAN,
317 run_id TEXT
318 );
319 CREATE TABLE IF NOT EXISTS export_schema (
320 export_name TEXT PRIMARY KEY,
321 columns_json TEXT NOT NULL,
322 updated_at TEXT NOT NULL
323 );
324 CREATE TABLE IF NOT EXISTS file_manifest (
325 id BIGSERIAL PRIMARY KEY,
326 run_id TEXT NOT NULL,
327 export_name TEXT NOT NULL,
328 file_name TEXT NOT NULL,
329 row_count BIGINT NOT NULL,
330 bytes BIGINT NOT NULL,
331 format TEXT NOT NULL,
332 compression TEXT,
333 created_at TEXT NOT NULL
334 );",
335 ),
336 (
337 2,
338 "CREATE TABLE IF NOT EXISTS chunk_run (
339 run_id TEXT PRIMARY KEY,
340 export_name TEXT NOT NULL,
341 plan_hash TEXT NOT NULL,
342 status TEXT NOT NULL,
343 max_chunk_attempts BIGINT NOT NULL DEFAULT 3,
344 created_at TEXT NOT NULL,
345 updated_at TEXT NOT NULL
346 );
347 CREATE INDEX IF NOT EXISTS idx_chunk_run_export_status
348 ON chunk_run(export_name, status);
349 CREATE TABLE IF NOT EXISTS chunk_task (
350 id BIGSERIAL PRIMARY KEY,
351 run_id TEXT NOT NULL,
352 chunk_index BIGINT NOT NULL,
353 start_key TEXT NOT NULL,
354 end_key TEXT NOT NULL,
355 status TEXT NOT NULL,
356 attempts BIGINT NOT NULL DEFAULT 0,
357 last_error TEXT,
358 rows_written BIGINT,
359 file_name TEXT,
360 updated_at TEXT NOT NULL,
361 UNIQUE(run_id, chunk_index)
362 );
363 CREATE INDEX IF NOT EXISTS idx_chunk_task_run_status ON chunk_task(run_id, status);",
364 ),
365 (
366 3,
367 "CREATE INDEX IF NOT EXISTS idx_file_manifest_export ON file_manifest(export_name, id DESC);",
368 ),
369 (
370 4,
371 "CREATE TABLE IF NOT EXISTS export_progression (
372 export_name TEXT PRIMARY KEY,
373 last_committed_strategy TEXT,
374 last_committed_cursor TEXT,
375 last_committed_chunk_index BIGINT,
376 last_committed_run_id TEXT,
377 last_committed_at TEXT,
378 last_verified_strategy TEXT,
379 last_verified_cursor TEXT,
380 last_verified_chunk_index BIGINT,
381 last_verified_run_id TEXT,
382 last_verified_at TEXT
383 );",
384 ),
385 (
386 5,
387 "CREATE TABLE IF NOT EXISTS run_aggregate (
388 run_aggregate_id TEXT PRIMARY KEY,
389 started_at TEXT NOT NULL,
390 finished_at TEXT NOT NULL,
391 duration_ms BIGINT NOT NULL,
392 config_path TEXT,
393 parallel_mode TEXT NOT NULL,
394 total_exports BIGINT NOT NULL,
395 success_count BIGINT NOT NULL,
396 failed_count BIGINT NOT NULL,
397 skipped_count BIGINT NOT NULL,
398 total_rows BIGINT NOT NULL,
399 total_files BIGINT NOT NULL,
400 total_bytes BIGINT NOT NULL,
401 details_json TEXT NOT NULL
402 );
403 CREATE INDEX IF NOT EXISTS idx_run_aggregate_finished
404 ON run_aggregate(finished_at DESC);",
405 ),
406 (
407 6,
408 "CREATE TABLE IF NOT EXISTS export_shape (
409 export_name TEXT NOT NULL,
410 column_name TEXT NOT NULL,
411 max_byte_len BIGINT NOT NULL,
412 updated_at TEXT NOT NULL,
413 PRIMARY KEY (export_name, column_name)
414 );",
415 ),
416 (
417 7,
418 "CREATE TABLE IF NOT EXISTS run_journal (
419 run_id TEXT PRIMARY KEY,
420 export_name TEXT NOT NULL,
421 finished_at TEXT NOT NULL,
422 journal_json TEXT NOT NULL
423 );
424 CREATE INDEX IF NOT EXISTS idx_run_journal_export
425 ON run_journal(export_name, finished_at DESC);",
426 ),
427 (
430 8,
431 "ALTER TABLE file_manifest RENAME TO file_log;
432 DROP INDEX IF EXISTS idx_file_manifest_export;
433 CREATE INDEX IF NOT EXISTS idx_file_log_export ON file_log(export_name, id DESC);",
434 ),
435 (
438 9,
439 "ALTER TABLE export_metrics ADD COLUMN files_committed BIGINT;
440 ALTER TABLE export_metrics ADD COLUMN reconciled BOOLEAN;
441 ALTER TABLE export_metrics ADD COLUMN source_count BIGINT;
442 ALTER TABLE export_metrics ADD COLUMN quality_passed BOOLEAN;
443 ALTER TABLE export_metrics ADD COLUMN pg_temp_bytes_delta BIGINT;
444 ALTER TABLE export_metrics ADD COLUMN batch_size BIGINT;
445 ALTER TABLE export_metrics ADD COLUMN batch_size_memory_mb BIGINT;
446 ALTER TABLE export_metrics ADD COLUMN skip_reason TEXT;
447 ALTER TABLE export_metrics ADD COLUMN schema_fingerprint TEXT;
448 ALTER TABLE export_metrics ADD COLUMN chunk_size BIGINT;
449 ALTER TABLE export_metrics ADD COLUMN parallel BIGINT;
450 ALTER TABLE export_metrics ADD COLUMN source_type TEXT;
451 ALTER TABLE export_metrics ADD COLUMN destination_type TEXT;
452 ALTER TABLE export_metrics ADD COLUMN rivet_version TEXT;",
453 ),
454 (
456 10,
457 "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms BIGINT;",
458 ),
459 (
461 11,
462 "CREATE TABLE IF NOT EXISTS export_harm (
463 id BIGSERIAL PRIMARY KEY,
464 run_id TEXT NOT NULL,
465 export_name TEXT NOT NULL,
466 metric TEXT NOT NULL,
467 delta BIGINT NOT NULL,
468 recorded_at TEXT NOT NULL
469 );
470 CREATE INDEX IF NOT EXISTS idx_export_harm_run ON export_harm(run_id);",
471 ),
472 (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
474 (
476 13,
477 "CREATE TABLE IF NOT EXISTS load_run (
478 load_id TEXT PRIMARY KEY,
479 export_name TEXT NOT NULL,
480 target_table TEXT NOT NULL,
481 warehouse TEXT NOT NULL,
482 mode TEXT NOT NULL,
483 source_run_ids TEXT NOT NULL,
484 rows_loaded BIGINT NOT NULL,
485 status TEXT NOT NULL,
486 finished_at TEXT NOT NULL
487 );
488 CREATE INDEX IF NOT EXISTS idx_load_run_target
489 ON load_run(target_table, finished_at DESC);
490 CREATE TABLE IF NOT EXISTS loaded_source_run (
491 target_table TEXT NOT NULL,
492 source_run_id TEXT NOT NULL,
493 load_id TEXT NOT NULL,
494 loaded_at TEXT NOT NULL,
495 PRIMARY KEY (target_table, source_run_id)
496 );",
497 ),
498 (
500 14,
501 "CREATE TABLE IF NOT EXISTS cdc_snapshot (
502 export_name TEXT NOT NULL,
503 table_name TEXT NOT NULL,
504 run_id TEXT NOT NULL,
505 completed_at TEXT NOT NULL,
506 PRIMARY KEY (export_name, table_name)
507 );",
508 ),
509];
510
511pub(super) fn pg_sql(sql: &str) -> String {
516 let bytes = sql.as_bytes();
517 let mut out = String::with_capacity(sql.len());
518 let mut i = 0;
519 while i < bytes.len() {
520 if bytes[i] == b'?' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
521 out.push('$');
522 } else {
523 out.push(bytes[i] as char);
524 }
525 i += 1;
526 }
527 out
528}
529
530pub(super) fn connect_pg(url: &str) -> Result<postgres::Client> {
547 let tls = state_tls_mode_from_url(url).map(|mode| crate::config::TlsConfig {
548 mode,
549 ..crate::config::TlsConfig::default()
550 });
551 crate::source::postgres::connect_client(url, tls.as_ref())
552 .map_err(|e| anyhow::anyhow!("state(pg): connect to '{}': {:#}", redact_pg_url(url), e))
553}
554
555fn state_tls_mode_from_url(url: &str) -> Option<crate::config::TlsMode> {
563 use crate::config::TlsMode;
564 let (_, query) = url.split_once('?')?;
565 let mut mode = None;
566 for pair in query.split('&') {
567 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
568 if key != "sslmode" {
569 continue;
570 }
571 mode = match value {
572 "require" => Some(TlsMode::Require),
573 "verify-ca" => Some(TlsMode::VerifyCa),
574 "verify-full" => Some(TlsMode::VerifyFull),
575 _ => None,
576 };
577 }
578 mode
579}
580
581pub(super) enum StateConn {
585 Sqlite(rusqlite::Connection),
586 Postgres(Box<std::cell::RefCell<postgres::Client>>),
591}
592
593#[derive(Clone)]
597pub enum StateRef {
598 Sqlite(std::path::PathBuf),
599 Postgres(String),
600}
601
602fn ensure_schema_version_table(conn: &Connection) {
605 let _ = conn.execute_batch(
606 "CREATE TABLE IF NOT EXISTS schema_version (
607 version INTEGER NOT NULL
608 );",
609 );
610}
611
612fn get_current_version(conn: &Connection) -> i64 {
613 conn.query_row(
614 "SELECT COALESCE(MAX(version), 0) FROM schema_version",
615 [],
616 |row| row.get(0),
617 )
618 .unwrap_or(0)
619}
620
621fn migrate(conn: &Connection) -> Result<()> {
622 ensure_schema_version_table(conn);
623
624 let current = get_current_version(conn);
625
626 if current == 0 {
627 let has_export_state: bool = conn
628 .query_row(
629 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='export_state'",
630 [],
631 |row| row.get(0),
632 )
633 .unwrap_or(false);
634
635 if has_export_state {
636 let metrics_cols = [
637 "files_produced INTEGER DEFAULT 0",
638 "bytes_written INTEGER DEFAULT 0",
639 "retries INTEGER DEFAULT 0",
640 "validated INTEGER",
641 "schema_changed INTEGER",
642 "run_id TEXT",
643 ];
644 for col_def in &metrics_cols {
645 let sql = format!("ALTER TABLE export_metrics ADD COLUMN {}", col_def);
646 let _ = conn.execute(&sql, []);
647 }
648 }
649 }
650
651 for &(ver, sql) in MIGRATIONS {
652 if ver > current {
653 log::debug!("state: applying migration v{}", ver);
654 let atomic_sql = format!(
655 "BEGIN;\n{}\nINSERT INTO schema_version (version) VALUES ({});\nCOMMIT;",
656 sql, ver
657 );
658 conn.execute_batch(&atomic_sql)
659 .map_err(|e| anyhow::anyhow!("state: migration v{} failed: {}", ver, e))?;
660 }
661 }
662
663 let _ = conn.execute(
664 "DELETE FROM schema_version WHERE version < (SELECT MAX(version) FROM schema_version)",
665 [],
666 );
667
668 let final_version = get_current_version(conn);
669 if final_version != SCHEMA_VERSION {
670 anyhow::bail!(
671 "state: migration incomplete — expected schema v{} but reached v{}",
672 SCHEMA_VERSION,
673 final_version
674 );
675 }
676
677 Ok(())
678}
679
680fn migrate_pg(client: &mut postgres::Client) -> Result<()> {
683 client
684 .batch_execute("CREATE TABLE IF NOT EXISTS rivet_schema_version (version BIGINT NOT NULL);")
685 .map_err(|e| anyhow::anyhow!("state(pg): create version table: {:#}", e))?;
686
687 let current: i64 = client
688 .query_one(
689 "SELECT COALESCE(MAX(version), 0) FROM rivet_schema_version",
690 &[],
691 )
692 .map_err(|e| anyhow::anyhow!("state(pg): read schema version: {:#}", e))?
693 .get(0);
694
695 for &(ver, sql) in PG_MIGRATIONS {
696 if ver > current {
697 log::debug!("state(pg): applying migration v{}", ver);
698 let batch = format!(
699 "BEGIN; {} INSERT INTO rivet_schema_version (version) VALUES ({}); COMMIT;",
700 sql, ver
701 );
702 client
703 .batch_execute(&batch)
704 .map_err(|e| anyhow::anyhow!("state(pg): migration v{} failed: {:#}", ver, e))?;
705 }
706 }
707
708 let _ = client.batch_execute(
710 "DELETE FROM rivet_schema_version \
711 WHERE version < (SELECT MAX(version) FROM rivet_schema_version);",
712 );
713
714 let final_version: i64 = client
716 .query_one(
717 "SELECT COALESCE(MAX(version), 0) FROM rivet_schema_version",
718 &[],
719 )
720 .map_err(|e| anyhow::anyhow!("state(pg): read final schema version: {:#}", e))?
721 .get(0);
722 if final_version != SCHEMA_VERSION {
723 anyhow::bail!(
724 "state(pg): migration incomplete — expected schema v{} but reached v{}",
725 SCHEMA_VERSION,
726 final_version
727 );
728 }
729
730 Ok(())
731}
732
733fn redact_pg_url(url: &str) -> String {
737 if let Some(at_pos) = url.rfind('@')
738 && let Some(scheme_end) = url.find("://")
739 {
740 let authority = &url[scheme_end + 3..at_pos];
741 if let Some(colon) = authority.rfind(':') {
742 let user = &authority[..colon];
743 return format!(
744 "{}://{}:***@{}",
745 &url[..scheme_end],
746 user,
747 &url[at_pos + 1..]
748 );
749 }
750 }
751 url.to_string()
752}
753
754pub(crate) const SQLITE_BUSY_TIMEOUT_MS: i64 = 10_000;
757
758pub(crate) fn open_connection(db_path: &std::path::Path) -> Result<Connection> {
759 let conn = Connection::open(db_path)?;
760 if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL;") {
761 log::warn!(
762 "state: WAL journal mode unavailable ({}); \
763 running in default mode — concurrent writes may be slower",
764 e
765 );
766 }
767 if let Err(e) = conn.execute_batch(&format!(
768 "PRAGMA busy_timeout = {};",
769 SQLITE_BUSY_TIMEOUT_MS
770 )) {
771 log::warn!(
772 "state: failed to set busy_timeout ({}); \
773 concurrent writers may surface SQLITE_BUSY immediately",
774 e
775 );
776 }
777 Ok(conn)
778}
779
780pub struct StateStore {
800 pub(super) conn: StateConn,
801 pub(super) state_ref: StateRef,
803}
804
805impl StateStore {
806 pub fn open(config_path: &str) -> Result<Self> {
810 if let Ok(url) = std::env::var("RIVET_STATE_URL")
811 && url.starts_with("postgres")
812 {
813 return Self::open_postgres(&url);
814 }
815 Self::open_sqlite(config_path)
816 }
817
818 fn open_sqlite(config_path: &str) -> Result<Self> {
819 let config_dir = std::path::Path::new(config_path)
820 .parent()
821 .unwrap_or(std::path::Path::new("."));
822 let db_path = config_dir.join(STATE_DB_NAME);
823 let conn = open_connection(&db_path)?;
824 migrate(&conn)?;
825 Ok(Self {
826 conn: StateConn::Sqlite(conn),
827 state_ref: StateRef::Sqlite(db_path),
828 })
829 }
830
831 fn open_postgres(url: &str) -> Result<Self> {
832 let is_local =
833 url.contains("localhost") || url.contains("127.0.0.1") || url.contains("::1");
834 if !is_local && state_tls_mode_from_url(url).is_none() {
835 log::warn!(
836 "state(pg): connecting to a remote host without TLS; \
837 add sslmode=require (or verify-ca / verify-full) to RIVET_STATE_URL \
838 to negotiate TLS for production use"
839 );
840 }
841 let mut client = connect_pg(url)?;
842 migrate_pg(&mut client)?;
843 Ok(Self {
844 conn: StateConn::Postgres(Box::new(std::cell::RefCell::new(client))),
845 state_ref: StateRef::Postgres(url.to_string()),
846 })
847 }
848
849 pub fn state_db_path(config_path: &str) -> std::path::PathBuf {
853 let config_dir = std::path::Path::new(config_path)
854 .parent()
855 .unwrap_or(std::path::Path::new("."));
856 config_dir.join(STATE_DB_NAME)
857 }
858
859 pub fn state_ref(&self) -> &StateRef {
861 &self.state_ref
862 }
863
864 #[allow(dead_code)]
866 pub fn open_in_memory() -> Result<Self> {
867 let conn = Connection::open_in_memory()?;
868 migrate(&conn)?;
869 Ok(Self {
870 conn: StateConn::Sqlite(conn),
871 state_ref: StateRef::Sqlite(std::path::PathBuf::from(":memory:")),
872 })
873 }
874
875 #[allow(dead_code)]
878 pub fn open_at_path(db_path: &std::path::Path) -> Result<Self> {
879 let conn = open_connection(db_path)?;
880 migrate(&conn)?;
881 Ok(Self {
882 conn: StateConn::Sqlite(conn),
883 state_ref: StateRef::Sqlite(db_path.to_path_buf()),
884 })
885 }
886}
887
888#[cfg(test)]
891mod tests {
892 use super::*;
893
894 #[test]
895 fn sqlite_and_postgres_migrations_define_the_same_tables_per_version() {
896 use std::collections::{BTreeSet, HashMap};
902 fn table_names(sql: &str) -> BTreeSet<String> {
903 let lower = sql.to_lowercase();
904 let mut rest = lower.as_str();
905 let mut out = BTreeSet::new();
906 while let Some(i) = rest.find("create table") {
907 rest = &rest[i + "create table".len()..];
908 let after = rest
909 .trim_start()
910 .strip_prefix("if not exists")
911 .unwrap_or_else(|| rest.trim_start())
912 .trim_start();
913 let name: String = after
914 .chars()
915 .take_while(|c| c.is_alphanumeric() || *c == '_')
916 .collect();
917 if !name.is_empty() {
918 out.insert(name);
919 }
920 }
921 out
922 }
923 let mut pg: HashMap<i64, BTreeSet<String>> = HashMap::new();
924 for &(v, sql) in PG_MIGRATIONS {
925 pg.entry(v).or_default().extend(table_names(sql));
926 }
927 for &(v, sql) in MIGRATIONS {
928 if let Some(pg_tables) = pg.get(&v) {
929 assert_eq!(
930 &table_names(sql),
931 pg_tables,
932 "migration v{v}: SQLite and Postgres define different tables"
933 );
934 }
935 }
936 }
937
938 #[test]
939 fn fresh_db_reaches_latest_version() {
940 let s = StateStore::open_in_memory().unwrap();
941 let ver = match &s.conn {
942 StateConn::Sqlite(c) => get_current_version(c),
943 StateConn::Postgres(_) => unreachable!(),
944 };
945 assert_eq!(ver, SCHEMA_VERSION);
946 }
947
948 #[test]
949 fn migration_is_idempotent() {
950 let s = StateStore::open_in_memory().unwrap();
951 match &s.conn {
952 StateConn::Sqlite(c) => {
953 migrate(c).unwrap();
954 migrate(c).unwrap();
955 assert_eq!(get_current_version(c), SCHEMA_VERSION);
956 }
957 StateConn::Postgres(_) => unreachable!(),
958 }
959 }
960
961 #[test]
962 fn legacy_db_gets_upgraded() {
963 let conn = Connection::open_in_memory().unwrap();
964 conn.execute_batch(
965 "CREATE TABLE export_state (
966 export_name TEXT PRIMARY KEY,
967 last_cursor_value TEXT,
968 last_run_at TEXT
969 );
970 CREATE TABLE export_metrics (
971 id INTEGER PRIMARY KEY AUTOINCREMENT,
972 export_name TEXT NOT NULL,
973 run_at TEXT NOT NULL,
974 duration_ms INTEGER NOT NULL,
975 total_rows INTEGER NOT NULL,
976 status TEXT NOT NULL
977 );",
978 )
979 .unwrap();
980
981 migrate(&conn).unwrap();
982 assert_eq!(get_current_version(&conn), SCHEMA_VERSION);
983
984 let has_chunk_run: bool = conn
985 .query_row(
986 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='chunk_run'",
987 [],
988 |row| row.get(0),
989 )
990 .unwrap();
991 assert!(has_chunk_run);
992 }
993
994 #[test]
995 fn upgrading_from_v12_adds_the_ledger_and_snapshot_tables_and_keeps_data() {
996 let conn = Connection::open_in_memory().unwrap();
1000 ensure_schema_version_table(&conn);
1001 for &(ver, sql) in MIGRATIONS {
1002 if ver <= 12 {
1003 conn.execute_batch(&format!(
1004 "BEGIN;\n{sql}\nINSERT INTO schema_version (version) VALUES ({ver});\nCOMMIT;"
1005 ))
1006 .unwrap();
1007 }
1008 }
1009 assert_eq!(get_current_version(&conn), 12, "staged at v12");
1010 conn.execute(
1012 "INSERT INTO export_state (export_name, last_cursor_value, last_run_at) \
1013 VALUES ('orders', '42', '2026-01-01T00:00:00Z')",
1014 [],
1015 )
1016 .unwrap();
1017
1018 migrate(&conn).unwrap();
1020 assert_eq!(get_current_version(&conn), SCHEMA_VERSION);
1021
1022 for t in ["load_run", "loaded_source_run", "cdc_snapshot"] {
1024 let exists: bool = conn
1025 .query_row(
1026 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
1027 [t],
1028 |r| r.get(0),
1029 )
1030 .unwrap();
1031 assert!(
1032 exists,
1033 "{t} missing after the v12→v{SCHEMA_VERSION} upgrade"
1034 );
1035 }
1036 let cursor: String = conn
1038 .query_row(
1039 "SELECT last_cursor_value FROM export_state WHERE export_name = 'orders'",
1040 [],
1041 |r| r.get(0),
1042 )
1043 .unwrap();
1044 assert_eq!(cursor, "42", "pre-upgrade data must survive");
1045 }
1046
1047 #[test]
1048 fn v8_renames_file_manifest_to_file_log() {
1049 let s = StateStore::open_in_memory().unwrap();
1050 let conn = match &s.conn {
1051 StateConn::Sqlite(c) => c,
1052 StateConn::Postgres(_) => unreachable!(),
1053 };
1054 let has_file_log: bool = conn
1055 .query_row(
1056 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='file_log'",
1057 [],
1058 |row| row.get(0),
1059 )
1060 .unwrap();
1061 assert!(has_file_log, "v8 must produce a `file_log` table");
1062 let has_old: bool = conn
1063 .query_row(
1064 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='file_manifest'",
1065 [],
1066 |row| row.get(0),
1067 )
1068 .unwrap();
1069 assert!(!has_old, "v8 must remove the old `file_manifest` table");
1070 let has_new_idx: bool = conn
1071 .query_row(
1072 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name='idx_file_log_export'",
1073 [],
1074 |row| row.get(0),
1075 )
1076 .unwrap();
1077 assert!(has_new_idx, "v8 must create the renamed index");
1078 }
1079
1080 #[test]
1081 fn v8_upgrades_existing_v7_db_with_data() {
1082 let conn = Connection::open_in_memory().unwrap();
1085 migrate(&conn).unwrap();
1089 conn.execute(
1091 "INSERT INTO file_log (run_id, export_name, file_name, row_count, bytes, format, created_at)
1092 VALUES ('r1', 'orders', 'f.parquet', 100, 4096, 'parquet', '2026-05-21T00:00:00Z')",
1093 [],
1094 )
1095 .unwrap();
1096 let count: i64 = conn
1097 .query_row("SELECT COUNT(*) FROM file_log", [], |r| r.get(0))
1098 .unwrap();
1099 assert_eq!(count, 1);
1100 }
1101
1102 #[test]
1103 fn run_aggregate_table_exists_after_migration() {
1104 let s = StateStore::open_in_memory().unwrap();
1105 let conn = match &s.conn {
1106 StateConn::Sqlite(c) => c,
1107 StateConn::Postgres(_) => unreachable!(),
1108 };
1109 let exists: bool = conn
1110 .query_row(
1111 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='run_aggregate'",
1112 [],
1113 |row| row.get(0),
1114 )
1115 .unwrap();
1116 assert!(exists, "v5 migration must create the run_aggregate table");
1117 }
1118
1119 #[test]
1120 fn v13_creates_the_load_ledger_tables() {
1121 let s = StateStore::open_in_memory().unwrap();
1122 let conn = match &s.conn {
1123 StateConn::Sqlite(c) => c,
1124 StateConn::Postgres(_) => unreachable!(),
1125 };
1126 for table in ["load_run", "loaded_source_run"] {
1127 let exists: bool = conn
1128 .query_row(
1129 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
1130 [table],
1131 |row| row.get(0),
1132 )
1133 .unwrap();
1134 assert!(exists, "v13 migration must create `{table}`");
1135 }
1136 }
1137
1138 #[test]
1139 fn v14_creates_the_cdc_snapshot_table() {
1140 let s = StateStore::open_in_memory().unwrap();
1141 let conn = match &s.conn {
1142 StateConn::Sqlite(c) => c,
1143 StateConn::Postgres(_) => unreachable!(),
1144 };
1145 let exists: bool = conn
1146 .query_row(
1147 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='cdc_snapshot'",
1148 [],
1149 |row| row.get(0),
1150 )
1151 .unwrap();
1152 assert!(exists, "v14 migration must create the cdc_snapshot table");
1153 }
1154
1155 #[test]
1156 fn pg_sql_converts_placeholders() {
1157 assert_eq!(
1158 pg_sql("SELECT ?1, ?2 FROM t WHERE x = ?3"),
1159 "SELECT $1, $2 FROM t WHERE x = $3"
1160 );
1161 assert_eq!(
1162 pg_sql("INSERT INTO t VALUES (?1, ?2)"),
1163 "INSERT INTO t VALUES ($1, $2)"
1164 );
1165 assert_eq!(pg_sql("no placeholders"), "no placeholders");
1166 assert_eq!(pg_sql("?10 AND ?11"), "$10 AND $11");
1168 }
1169
1170 #[test]
1171 fn redact_pg_url_removes_password() {
1172 assert_eq!(
1173 redact_pg_url("postgresql://rivet:secret123@localhost:5433/rivet_state"),
1174 "postgresql://rivet:***@localhost:5433/rivet_state"
1175 );
1176 assert_eq!(
1177 redact_pg_url("postgres://admin:p@ssw0rd@db.prod.example.com/state"),
1178 "postgres://admin:***@db.prod.example.com/state"
1179 );
1180 }
1181
1182 #[test]
1183 fn redact_pg_url_no_password_unchanged() {
1184 let url = "postgresql://rivet@localhost/state";
1186 assert_eq!(redact_pg_url(url), url);
1187 }
1188
1189 use crate::config::TlsMode;
1197
1198 #[test]
1199 fn state_sslmode_enforced_values_negotiate_tls() {
1200 for (url, want) in [
1201 (
1202 "postgresql://u:p@db.prod:5432/state?sslmode=require",
1203 TlsMode::Require,
1204 ),
1205 (
1206 "postgresql://u:p@db.prod/state?sslmode=verify-ca",
1207 TlsMode::VerifyCa,
1208 ),
1209 (
1210 "postgresql://u:p@db.prod/state?sslmode=verify-full",
1211 TlsMode::VerifyFull,
1212 ),
1213 ] {
1214 let mode = state_tls_mode_from_url(url);
1215 assert_eq!(mode, Some(want), "url: {url}");
1216 assert!(
1217 mode.unwrap().is_enforced(),
1218 "{want:?} must enforce TLS (not NoTls)"
1219 );
1220 }
1221 }
1222
1223 #[test]
1224 fn state_sslmode_plaintext_values_stay_notls() {
1225 for url in [
1228 "postgresql://u:p@localhost/state",
1229 "postgresql://u:p@localhost/state?sslmode=disable",
1230 "postgresql://u:p@db/state?sslmode=prefer",
1231 "postgresql://u:p@db/state?sslmode=allow",
1232 "postgresql://u:p@db/state?sslmode=REQUIRE",
1233 "postgresql://u:p@db/state?sslmode=garbage",
1234 "postgresql://u:p@db/state?sslmode",
1235 "postgresql://u:p@db/state?sslmode=",
1236 ] {
1237 assert_eq!(state_tls_mode_from_url(url), None, "url: {url}");
1238 }
1239 }
1240
1241 #[test]
1242 fn state_sslmode_exact_key_and_last_occurrence_wins() {
1243 assert_eq!(
1245 state_tls_mode_from_url("postgresql://u:p@db/state?xsslmode=require"),
1246 None
1247 );
1248 assert_eq!(
1250 state_tls_mode_from_url(
1251 "postgresql://u:p@db/state?connect_timeout=10&sslmode=require&application_name=x"
1252 ),
1253 Some(TlsMode::Require)
1254 );
1255 assert_eq!(
1257 state_tls_mode_from_url("postgresql://u:p@db/state?sslmode=disable&sslmode=require"),
1258 Some(TlsMode::Require)
1259 );
1260 assert_eq!(
1261 state_tls_mode_from_url("postgresql://u:p@db/state?sslmode=require&sslmode=disable"),
1262 None
1263 );
1264 }
1265}