Skip to main content

rivet/state/
mod.rs

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// Re-export domain types so callers use `rivet::state::*` unchanged.
18// Items below may not be explicitly named by all internal callers (often used
19// as inferred return types), but are part of the public integration-test API.
20#[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
39/// Current schema version — always the last entry in `MIGRATIONS`.
40const SCHEMA_VERSION: i64 = MIGRATIONS[MIGRATIONS.len() - 1].0;
41
42/// Each entry is `(version, sql)`.  Applied in order when the DB is behind.
43const MIGRATIONS: &[(i64, &str)] = &[
44    // v1: core tables
45    (
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    // v2: chunk checkpoint tables
89    (
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    // v3: index on file_manifest for faster per-export lookups
119    (
120        3,
121        "CREATE INDEX IF NOT EXISTS idx_file_manifest_export ON file_manifest(export_name, id DESC);",
122    ),
123    // v4: committed / verified boundary tracking (ADR-0008, Epic G)
124    (
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    // v5: aggregate run summary
141    (
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    // v6: per-column data shape stats
163    (
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    // v7: structured run journal
174    (
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    // v8: rename file_manifest → file_log.  The 0.7.0 cloud-output contract
186    // reclaims the "manifest" name for the public JSON artifact; the internal
187    // SQLite log of written files becomes `file_log` to remove the overload.
188    (
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    // v9: extended per-run metrics for post-pilot analysis — source harm
195    // (pg_temp_bytes_delta), completeness (reconciled, source_count,
196    // quality_passed), memory (batch_size[_memory_mb]), and config dimensions
197    // (chunk_size, parallel, source/destination type, rivet_version). All
198    // additive + nullable: old rows read NULL, no backfill, reads stay forward-
199    // compatible.
200    (
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    // v10: longest single-chunk wall time (ms) — the #5 source-harm lever,
218    // aggregated at finalize from the run journal's per-chunk timings.
219    (
220        10,
221        "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms INTEGER;",
222    ),
223    // v11: per-run source-harm deltas (locks, rows read, buffer misses, temp
224    // files) — one row per counter, keyed on run_id. Engine-neutral key/value so
225    // each engine's counter set lands without schema churn. Written from
226    // pipeline::job::harm_snapshot via source::{postgres,mysql,mssql}.
227    (
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    // v12: chunking diagnostics — the chunk KEY column. (The resolved strategy is
240    // already the `mode` column — `summary.mode` is `strategy.mode_label()`,
241    // "keyset"/"chunked"/etc. — and the span/window count are derivable from
242    // chunk_task.) A sparse-key post-mortem: mode='chunked' + chunk_key='id' →
243    // "which column was range-chunked". Whether that key is a PK (the "should have
244    // keyset-paged" signal) needs a run-time PK probe — a follow-up, so no field
245    // that would merely restate mode='keyset'.
246    (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
247    // v13: load ledger. `rivet load` is now stateful — `load_run` is the audit
248    // log (one row per invocation-table), `loaded_source_run` the skip ledger
249    // (which extraction run_ids have landed in which target) that makes loads
250    // incremental + idempotent instead of re-loading whatever sits in the bucket.
251    (
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    // v14: cdc snapshot completion. `cdc.initial: snapshot` records that an
275    // export/table's backfill finished HERE, not only as a GCS `snapshot/_SUCCESS`
276    // marker — so `cleanup_source: true` wiping the bucket no longer looks like an
277    // un-snapshotted table and re-snapshots the whole thing on every run.
278    (
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
290/// PostgreSQL-compatible DDL.  Column types differ from SQLite (BIGSERIAL,
291/// BOOLEAN); placeholder style is `$N` (handled by callers via `pg_sql()`).
292const 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    // v8: rename file_manifest → file_log.  Mirrors the SQLite v8 migration;
428    // see the SQLite array for rationale.
429    (
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    // v9: extended per-run metrics (see the SQLite array for rationale).
436    // Additive + nullable; BOOLEAN for the bool flags, BIGINT for counts.
437    (
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    // v10: longest single-chunk wall time (ms). See the SQLite array.
455    (
456        10,
457        "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms BIGINT;",
458    ),
459    // v11: per-run source-harm deltas (see the SQLite array for rationale).
460    (
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    // v12: chunking diagnostics (see the SQLite array for rationale).
473    (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
474    // v13: load ledger (see the SQLite array for rationale). rows_loaded is BIGINT.
475    (
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    // v14: cdc snapshot completion (see the SQLite array for rationale).
499    (
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
511// ─── SQL helpers ──────────────────────────────────────────────────────────────
512
513/// Convert SQLite `?N` placeholders to PostgreSQL `$N` style.
514/// `"WHERE x = ?1 AND y = ?2"` → `"WHERE x = $1 AND y = $2"`.
515pub(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
530/// Open a Postgres client for the state backend, honoring the URL's `sslmode`.
531///
532/// The state backend connects to its store using only a URL (`RIVET_STATE_URL`)
533/// — there is no YAML `tls:` block — so the transport-security policy is derived
534/// from the URL's `sslmode` query parameter, exactly as `rivet init` does for
535/// source connections. The connection itself goes through the shared
536/// [`crate::source::postgres::connect_client`] path so the state backend and
537/// source connections apply identical TLS rules.
538///
539/// - missing / `disable` / `prefer` / `allow` / unrecognized → `NoTls`
540///   (plaintext), keeping local and dev setups working unchanged.
541/// - `require` / `verify-ca` / `verify-full` → negotiate TLS.
542///
543/// Used by both [`StateStore::open_postgres`] and the parallel chunk-worker
544/// reconnection paths in `checkpoint.rs`, so every PG state connection is
545/// TLS-aware.
546pub(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
555/// Map the state URL's `sslmode` query parameter to a [`crate::config::TlsMode`].
556///
557/// Mirrors the source-side mapping in `crate::init::postgres`: `require` /
558/// `verify-ca` / `verify-full` enforce TLS; everything else — parameter missing,
559/// `disable`, `prefer`, `allow`, or an unrecognized value — returns `None`
560/// (plaintext `NoTls`). [`crate::config::TlsMode`] has no `prefer` variant, so no
561/// try-TLS-then-fallback is attempted. Last occurrence wins, matching libpq.
562fn 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
581// ─── Backend connection ────────────────────────────────────────────────────────
582
583/// Internal storage for the active database connection.
584pub(super) enum StateConn {
585    Sqlite(rusqlite::Connection),
586    /// postgres::Client requires `&mut self` for queries; RefCell provides
587    /// interior mutability so `StateStore` methods can keep `&self` signatures.
588    /// StateStore is not Sync (neither backend is), so RefCell is safe here.
589    /// Boxed to keep the enum variant sizes balanced (postgres::Client is ~320 B).
590    Postgres(Box<std::cell::RefCell<postgres::Client>>),
591}
592
593/// Serialisable reference that identifies a state database without holding a
594/// live connection.  Passed to parallel chunk workers so they can open their
595/// own connection for atomic `claim_next_chunk_task` operations.
596#[derive(Clone)]
597pub enum StateRef {
598    Sqlite(std::path::PathBuf),
599    Postgres(String),
600}
601
602// ─── SQLite migration ─────────────────────────────────────────────────────────
603
604fn 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
680// ─── PostgreSQL migration ─────────────────────────────────────────────────────
681
682fn 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    // Remove superseded version rows so MAX() stays unambiguous (mirrors SQLite behaviour).
709    let _ = client.batch_execute(
710        "DELETE FROM rivet_schema_version \
711         WHERE version < (SELECT MAX(version) FROM rivet_schema_version);",
712    );
713
714    // Verify the DB actually reached the expected version.
715    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
733/// Redact the password from a PostgreSQL URL for safe use in log/error messages.
734/// `postgresql://user:SECRET@host/db` → `postgresql://user:***@host/db`
735/// Uses `rfind('@')` so passwords containing `@` are handled correctly.
736fn 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
754// ─── SQLite connection helper ─────────────────────────────────────────────────
755
756pub(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
780// ─── StateStore ───────────────────────────────────────────────────────────────
781
782/// Entry point for all persistent state.  Supports two backends:
783///
784/// - **SQLite** (default) — a single `.rivet_state.db` file next to the
785///   config.  Good for local / single-node / dev deployments.
786/// - **PostgreSQL** — a shared database addressed by `RIVET_STATE_URL`.
787///   Required for stateless container / Kubernetes deployments where the
788///   rivet pod is ephemeral or replicated.
789///
790/// Set the `RIVET_STATE_URL` environment variable to a PostgreSQL URL to
791/// activate the Postgres backend:
792///
793/// ```text
794/// RIVET_STATE_URL=postgresql://user:pass@host:5432/rivet_state
795/// ```
796///
797/// When the variable is absent or does not start with `postgres`, SQLite is
798/// used and the variable is ignored.
799pub struct StateStore {
800    pub(super) conn: StateConn,
801    /// Serialisable reference for reconnection (parallel chunk workers).
802    pub(super) state_ref: StateRef,
803}
804
805impl StateStore {
806    /// Open the appropriate backend.
807    ///
808    /// Checks `RIVET_STATE_URL`; falls back to SQLite next to `config_path`.
809    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    /// Path to `.rivet_state.db` for SQLite deployments.  Returns the config
850    /// directory path for Postgres (not meaningful for connection, only used
851    /// by legacy callers — prefer `state_ref()` for new code).
852    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    /// Serialisable connection reference for parallel chunk workers.
860    pub fn state_ref(&self) -> &StateRef {
861        &self.state_ref
862    }
863
864    /// In-memory SQLite store for unit tests.
865    #[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    /// Open a SQLite store at an explicit file path (tests that need
876    /// cross-connection access via `claim_next_chunk_task_at_path`).
877    #[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// ─── Migration tests ──────────────────────────────────────────────────────────
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893
894    #[test]
895    fn sqlite_and_postgres_migrations_define_the_same_tables_per_version() {
896        // `migrate`/`migrate_pg` only check the final version NUMBER; nothing
897        // catches a same-version, divergent-DDL edit between the two arrays. This
898        // asserts that for every version present in BOTH, the set of tables each
899        // CREATEs matches — so a table added to one backend but not the other
900        // (a query that works on SQLite and errors on PG) fails loudly here.
901        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        // Stage a database at EXACTLY v12 — a user on the release before the load
997        // ledger (v13) and cdc_snapshot (v14). Apply only migrations up to v12,
998        // exactly as the older rivet that wrote their `.rivet_state.db` did.
999        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        // Pre-existing state that MUST survive the upgrade.
1011        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        // Upgrade the existing DB to the current schema (the v13 + v14 path).
1019        migrate(&conn).unwrap();
1020        assert_eq!(get_current_version(&conn), SCHEMA_VERSION);
1021
1022        // The v13/v14 tables now exist on the upgraded-in-place DB.
1023        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        // The v12 data survived the added migrations (not dropped/recreated).
1037        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        // Simulate an existing 0.6.0 database stopped at v7: the table is still
1083        // named `file_manifest` and has rows.  v8 must rename it preserving data.
1084        let conn = Connection::open_in_memory().unwrap();
1085        // Apply v1..=v7 by running the migrator after manually stamping v7.
1086        // Simpler: run the migrator, then manually rename back to v7 state to
1087        // exercise the v7→v8 path.  Here we just verify forward path covers it.
1088        migrate(&conn).unwrap();
1089        // Insert a row using the new name (post-v8); the rename happened transparently.
1090        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        // ?N with two digits
1167        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        // URL without a password should come back as-is.
1185        let url = "postgresql://rivet@localhost/state";
1186        assert_eq!(redact_pg_url(url), url);
1187    }
1188
1189    // ── state(pg) sslmode → TlsMode mapping ─────────────────────────────────
1190    //
1191    // Pins the decision behind the TLS bug fix: the state backend can no longer
1192    // hard-code NoTls. We can't drive a live TLS handshake in a unit test, so we
1193    // assert the *chosen transport policy* — TLS is enforced for require /
1194    // verify-* and plaintext (NoTls) otherwise — which is what selects the
1195    // connector inside `connect_pg` -> `connect_client`.
1196    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        // Missing / disable / prefer / allow / unrecognized / uppercase all keep
1226        // the original NoTls behavior, so dev + docker setups are unchanged.
1227        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        // `xsslmode` is a different parameter; the exact `sslmode` key matters.
1244        assert_eq!(
1245            state_tls_mode_from_url("postgresql://u:p@db/state?xsslmode=require"),
1246            None
1247        );
1248        // Found among other params.
1249        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        // Last occurrence wins, matching libpq.
1256        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}