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    // v15: close the chunked-run TOCTOU (round-2 audit #13). ensure_chunk_
289    // checkpoint_plan did check-then-act (find an in_progress run → if None,
290    // create), with no serialization, so two overlapping runs of ONE export both
291    // saw None, both created an in_progress row, and DOUBLED the destination data
292    // (the random part-name nonce made the parts additive, not clobbering). A
293    // partial-unique index makes the second create fail (mapped to the same
294    // 'still in progress' bail). First demote any pre-existing duplicate
295    // in_progress rows — keep the newest (created_at, run_id) per export — so the
296    // index can build on a legacy DB that already raced. Standard SQL: valid for
297    // both SQLite and PostgreSQL (both support partial indexes).
298    (
299        15,
300        "UPDATE chunk_run SET status='interrupted'
301             WHERE status='in_progress' AND run_id NOT IN (
302               SELECT run_id FROM chunk_run c WHERE c.status='in_progress'
303                 AND NOT EXISTS (
304                   SELECT 1 FROM chunk_run c2
305                   WHERE c2.export_name=c.export_name AND c2.status='in_progress'
306                     AND (c2.created_at > c.created_at
307                          OR (c2.created_at = c.created_at AND c2.run_id > c.run_id)))
308             );
309         CREATE UNIQUE INDEX IF NOT EXISTS idx_chunk_run_one_inprogress
310             ON chunk_run(export_name) WHERE status='in_progress';",
311    ),
312    // v16: keyset checkpoint-resume manifest completeness (round-5). export_state
313    // holds only the resume cursor, so a keyset crash+resume couldn't reconstruct the
314    // pre-crash pages into the finalize manifest (silent orphan, the sibling of the
315    // chunked fix). Persist the in-progress run_id here so resume can reuse it and
316    // rehydrate every committed page from file_log; cleared when the run finalizes.
317    (
318        16,
319        "ALTER TABLE export_state ADD COLUMN resume_run_id TEXT;",
320    ),
321];
322
323/// PostgreSQL-compatible DDL.  Column types differ from SQLite (BIGSERIAL,
324/// BOOLEAN); placeholder style is `$N` (handled by callers via `pg_sql()`).
325const PG_MIGRATIONS: &[(i64, &str)] = &[
326    (
327        1,
328        "CREATE TABLE IF NOT EXISTS export_state (
329            export_name TEXT PRIMARY KEY,
330            last_cursor_value TEXT,
331            last_run_at TEXT
332        );
333        CREATE TABLE IF NOT EXISTS export_metrics (
334            id BIGSERIAL PRIMARY KEY,
335            export_name TEXT NOT NULL,
336            run_at TEXT NOT NULL,
337            duration_ms BIGINT NOT NULL,
338            total_rows BIGINT NOT NULL,
339            peak_rss_mb BIGINT,
340            status TEXT NOT NULL,
341            error_message TEXT,
342            tuning_profile TEXT,
343            format TEXT,
344            mode TEXT,
345            files_produced BIGINT DEFAULT 0,
346            bytes_written BIGINT DEFAULT 0,
347            retries BIGINT DEFAULT 0,
348            validated BOOLEAN,
349            schema_changed BOOLEAN,
350            run_id TEXT
351        );
352        CREATE TABLE IF NOT EXISTS export_schema (
353            export_name TEXT PRIMARY KEY,
354            columns_json TEXT NOT NULL,
355            updated_at TEXT NOT NULL
356        );
357        CREATE TABLE IF NOT EXISTS file_manifest (
358            id BIGSERIAL PRIMARY KEY,
359            run_id TEXT NOT NULL,
360            export_name TEXT NOT NULL,
361            file_name TEXT NOT NULL,
362            row_count BIGINT NOT NULL,
363            bytes BIGINT NOT NULL,
364            format TEXT NOT NULL,
365            compression TEXT,
366            created_at TEXT NOT NULL
367        );",
368    ),
369    (
370        2,
371        "CREATE TABLE IF NOT EXISTS chunk_run (
372            run_id TEXT PRIMARY KEY,
373            export_name TEXT NOT NULL,
374            plan_hash TEXT NOT NULL,
375            status TEXT NOT NULL,
376            max_chunk_attempts BIGINT NOT NULL DEFAULT 3,
377            created_at TEXT NOT NULL,
378            updated_at TEXT NOT NULL
379        );
380        CREATE INDEX IF NOT EXISTS idx_chunk_run_export_status
381            ON chunk_run(export_name, status);
382        CREATE TABLE IF NOT EXISTS chunk_task (
383            id BIGSERIAL PRIMARY KEY,
384            run_id TEXT NOT NULL,
385            chunk_index BIGINT NOT NULL,
386            start_key TEXT NOT NULL,
387            end_key TEXT NOT NULL,
388            status TEXT NOT NULL,
389            attempts BIGINT NOT NULL DEFAULT 0,
390            last_error TEXT,
391            rows_written BIGINT,
392            file_name TEXT,
393            updated_at TEXT NOT NULL,
394            UNIQUE(run_id, chunk_index)
395        );
396        CREATE INDEX IF NOT EXISTS idx_chunk_task_run_status ON chunk_task(run_id, status);",
397    ),
398    (
399        3,
400        "CREATE INDEX IF NOT EXISTS idx_file_manifest_export ON file_manifest(export_name, id DESC);",
401    ),
402    (
403        4,
404        "CREATE TABLE IF NOT EXISTS export_progression (
405            export_name TEXT PRIMARY KEY,
406            last_committed_strategy TEXT,
407            last_committed_cursor TEXT,
408            last_committed_chunk_index BIGINT,
409            last_committed_run_id TEXT,
410            last_committed_at TEXT,
411            last_verified_strategy TEXT,
412            last_verified_cursor TEXT,
413            last_verified_chunk_index BIGINT,
414            last_verified_run_id TEXT,
415            last_verified_at TEXT
416        );",
417    ),
418    (
419        5,
420        "CREATE TABLE IF NOT EXISTS run_aggregate (
421            run_aggregate_id TEXT PRIMARY KEY,
422            started_at TEXT NOT NULL,
423            finished_at TEXT NOT NULL,
424            duration_ms BIGINT NOT NULL,
425            config_path TEXT,
426            parallel_mode TEXT NOT NULL,
427            total_exports BIGINT NOT NULL,
428            success_count BIGINT NOT NULL,
429            failed_count BIGINT NOT NULL,
430            skipped_count BIGINT NOT NULL,
431            total_rows BIGINT NOT NULL,
432            total_files BIGINT NOT NULL,
433            total_bytes BIGINT NOT NULL,
434            details_json TEXT NOT NULL
435        );
436        CREATE INDEX IF NOT EXISTS idx_run_aggregate_finished
437            ON run_aggregate(finished_at DESC);",
438    ),
439    (
440        6,
441        "CREATE TABLE IF NOT EXISTS export_shape (
442            export_name TEXT NOT NULL,
443            column_name TEXT NOT NULL,
444            max_byte_len BIGINT NOT NULL,
445            updated_at TEXT NOT NULL,
446            PRIMARY KEY (export_name, column_name)
447        );",
448    ),
449    (
450        7,
451        "CREATE TABLE IF NOT EXISTS run_journal (
452            run_id TEXT PRIMARY KEY,
453            export_name TEXT NOT NULL,
454            finished_at TEXT NOT NULL,
455            journal_json TEXT NOT NULL
456        );
457        CREATE INDEX IF NOT EXISTS idx_run_journal_export
458            ON run_journal(export_name, finished_at DESC);",
459    ),
460    // v8: rename file_manifest → file_log.  Mirrors the SQLite v8 migration;
461    // see the SQLite array for rationale.
462    (
463        8,
464        "ALTER TABLE file_manifest RENAME TO file_log;
465        DROP INDEX IF EXISTS idx_file_manifest_export;
466        CREATE INDEX IF NOT EXISTS idx_file_log_export ON file_log(export_name, id DESC);",
467    ),
468    // v9: extended per-run metrics (see the SQLite array for rationale).
469    // Additive + nullable; BOOLEAN for the bool flags, BIGINT for counts.
470    (
471        9,
472        "ALTER TABLE export_metrics ADD COLUMN files_committed BIGINT;
473        ALTER TABLE export_metrics ADD COLUMN reconciled BOOLEAN;
474        ALTER TABLE export_metrics ADD COLUMN source_count BIGINT;
475        ALTER TABLE export_metrics ADD COLUMN quality_passed BOOLEAN;
476        ALTER TABLE export_metrics ADD COLUMN pg_temp_bytes_delta BIGINT;
477        ALTER TABLE export_metrics ADD COLUMN batch_size BIGINT;
478        ALTER TABLE export_metrics ADD COLUMN batch_size_memory_mb BIGINT;
479        ALTER TABLE export_metrics ADD COLUMN skip_reason TEXT;
480        ALTER TABLE export_metrics ADD COLUMN schema_fingerprint TEXT;
481        ALTER TABLE export_metrics ADD COLUMN chunk_size BIGINT;
482        ALTER TABLE export_metrics ADD COLUMN parallel BIGINT;
483        ALTER TABLE export_metrics ADD COLUMN source_type TEXT;
484        ALTER TABLE export_metrics ADD COLUMN destination_type TEXT;
485        ALTER TABLE export_metrics ADD COLUMN rivet_version TEXT;",
486    ),
487    // v10: longest single-chunk wall time (ms). See the SQLite array.
488    (
489        10,
490        "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms BIGINT;",
491    ),
492    // v11: per-run source-harm deltas (see the SQLite array for rationale).
493    (
494        11,
495        "CREATE TABLE IF NOT EXISTS export_harm (
496            id BIGSERIAL PRIMARY KEY,
497            run_id TEXT NOT NULL,
498            export_name TEXT NOT NULL,
499            metric TEXT NOT NULL,
500            delta BIGINT NOT NULL,
501            recorded_at TEXT NOT NULL
502        );
503        CREATE INDEX IF NOT EXISTS idx_export_harm_run ON export_harm(run_id);",
504    ),
505    // v12: chunking diagnostics (see the SQLite array for rationale).
506    (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
507    // v13: load ledger (see the SQLite array for rationale). rows_loaded is BIGINT.
508    (
509        13,
510        "CREATE TABLE IF NOT EXISTS load_run (
511            load_id TEXT PRIMARY KEY,
512            export_name TEXT NOT NULL,
513            target_table TEXT NOT NULL,
514            warehouse TEXT NOT NULL,
515            mode TEXT NOT NULL,
516            source_run_ids TEXT NOT NULL,
517            rows_loaded BIGINT NOT NULL,
518            status TEXT NOT NULL,
519            finished_at TEXT NOT NULL
520        );
521        CREATE INDEX IF NOT EXISTS idx_load_run_target
522            ON load_run(target_table, finished_at DESC);
523        CREATE TABLE IF NOT EXISTS loaded_source_run (
524            target_table TEXT NOT NULL,
525            source_run_id TEXT NOT NULL,
526            load_id TEXT NOT NULL,
527            loaded_at TEXT NOT NULL,
528            PRIMARY KEY (target_table, source_run_id)
529        );",
530    ),
531    // v14: cdc snapshot completion (see the SQLite array for rationale).
532    (
533        14,
534        "CREATE TABLE IF NOT EXISTS cdc_snapshot (
535            export_name TEXT NOT NULL,
536            table_name TEXT NOT NULL,
537            run_id TEXT NOT NULL,
538            completed_at TEXT NOT NULL,
539            PRIMARY KEY (export_name, table_name)
540        );",
541    ),
542    // v15: close the chunked-run TOCTOU (round-2 audit #13). ensure_chunk_
543    // checkpoint_plan did check-then-act (find an in_progress run → if None,
544    // create), with no serialization, so two overlapping runs of ONE export both
545    // saw None, both created an in_progress row, and DOUBLED the destination data
546    // (the random part-name nonce made the parts additive, not clobbering). A
547    // partial-unique index makes the second create fail (mapped to the same
548    // 'still in progress' bail). First demote any pre-existing duplicate
549    // in_progress rows — keep the newest (created_at, run_id) per export — so the
550    // index can build on a legacy DB that already raced. Standard SQL: valid for
551    // both SQLite and PostgreSQL (both support partial indexes).
552    (
553        15,
554        "UPDATE chunk_run SET status='interrupted'
555             WHERE status='in_progress' AND run_id NOT IN (
556               SELECT run_id FROM chunk_run c WHERE c.status='in_progress'
557                 AND NOT EXISTS (
558                   SELECT 1 FROM chunk_run c2
559                   WHERE c2.export_name=c.export_name AND c2.status='in_progress'
560                     AND (c2.created_at > c.created_at
561                          OR (c2.created_at = c.created_at AND c2.run_id > c.run_id)))
562             );
563         CREATE UNIQUE INDEX IF NOT EXISTS idx_chunk_run_one_inprogress
564             ON chunk_run(export_name) WHERE status='in_progress';",
565    ),
566    // v16: keyset checkpoint-resume manifest completeness (round-5). export_state
567    // holds only the resume cursor, so a keyset crash+resume couldn't reconstruct the
568    // pre-crash pages into the finalize manifest (silent orphan, the sibling of the
569    // chunked fix). Persist the in-progress run_id here so resume can reuse it and
570    // rehydrate every committed page from file_log; cleared when the run finalizes.
571    (
572        16,
573        "ALTER TABLE export_state ADD COLUMN resume_run_id TEXT;",
574    ),
575];
576
577// ─── SQL helpers ──────────────────────────────────────────────────────────────
578
579/// Convert SQLite `?N` placeholders to PostgreSQL `$N` style.
580/// `"WHERE x = ?1 AND y = ?2"` → `"WHERE x = $1 AND y = $2"`.
581pub(super) fn pg_sql(sql: &str) -> String {
582    let bytes = sql.as_bytes();
583    let mut out = String::with_capacity(sql.len());
584    let mut i = 0;
585    while i < bytes.len() {
586        if bytes[i] == b'?' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
587            out.push('$');
588        } else {
589            out.push(bytes[i] as char);
590        }
591        i += 1;
592    }
593    out
594}
595
596/// Open a Postgres client for the state backend, honoring the URL's `sslmode`.
597///
598/// The state backend connects to its store using only a URL (`RIVET_STATE_URL`)
599/// — there is no YAML `tls:` block — so the transport-security policy is derived
600/// from the URL's `sslmode` query parameter, exactly as `rivet init` does for
601/// source connections. The connection itself goes through the shared
602/// [`crate::source::postgres::connect_client`] path so the state backend and
603/// source connections apply identical TLS rules.
604///
605/// - missing / `disable` / `prefer` / `allow` / unrecognized → `NoTls`
606///   (plaintext), keeping local and dev setups working unchanged.
607/// - `require` / `verify-ca` / `verify-full` → negotiate TLS.
608///
609/// Used by both [`StateStore::open_postgres`] and the parallel chunk-worker
610/// reconnection paths in `checkpoint.rs`, so every PG state connection is
611/// TLS-aware.
612pub(super) fn connect_pg(url: &str) -> Result<postgres::Client> {
613    let tls = state_tls_mode_from_url(url).map(|mode| crate::config::TlsConfig {
614        mode,
615        ..crate::config::TlsConfig::default()
616    });
617    crate::source::postgres::connect_client(url, tls.as_ref())
618        .map_err(|e| anyhow::anyhow!("state(pg): connect to '{}': {:#}", redact_pg_url(url), e))
619}
620
621/// Map the state URL's `sslmode` query parameter to a [`crate::config::TlsMode`].
622///
623/// Mirrors the source-side mapping in `crate::init::postgres`: `require` /
624/// `verify-ca` / `verify-full` enforce TLS; everything else — parameter missing,
625/// `disable`, `prefer`, `allow`, or an unrecognized value — returns `None`
626/// (plaintext `NoTls`). [`crate::config::TlsMode`] has no `prefer` variant, so no
627/// try-TLS-then-fallback is attempted. Last occurrence wins, matching libpq.
628fn state_tls_mode_from_url(url: &str) -> Option<crate::config::TlsMode> {
629    use crate::config::TlsMode;
630    let (_, query) = url.split_once('?')?;
631    let mut mode = None;
632    for pair in query.split('&') {
633        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
634        if key != "sslmode" {
635            continue;
636        }
637        mode = match value {
638            "require" => Some(TlsMode::Require),
639            "verify-ca" => Some(TlsMode::VerifyCa),
640            "verify-full" => Some(TlsMode::VerifyFull),
641            _ => None,
642        };
643    }
644    mode
645}
646
647// ─── Backend connection ────────────────────────────────────────────────────────
648
649/// Internal storage for the active database connection.
650pub(super) enum StateConn {
651    Sqlite(rusqlite::Connection),
652    /// postgres::Client requires `&mut self` for queries; RefCell provides
653    /// interior mutability so `StateStore` methods can keep `&self` signatures.
654    /// StateStore is not Sync (neither backend is), so RefCell is safe here.
655    /// Boxed to keep the enum variant sizes balanced (postgres::Client is ~320 B).
656    Postgres(Box<std::cell::RefCell<postgres::Client>>),
657}
658
659/// Serialisable reference that identifies a state database without holding a
660/// live connection.  Passed to parallel chunk workers so they can open their
661/// own connection for atomic `claim_next_chunk_task` operations.
662#[derive(Clone)]
663pub enum StateRef {
664    Sqlite(std::path::PathBuf),
665    Postgres(String),
666}
667
668// ─── SQLite migration ─────────────────────────────────────────────────────────
669
670fn ensure_schema_version_table(conn: &Connection) {
671    let _ = conn.execute_batch(
672        "CREATE TABLE IF NOT EXISTS schema_version (
673            version INTEGER NOT NULL
674        );",
675    );
676}
677
678fn get_current_version(conn: &Connection) -> i64 {
679    conn.query_row(
680        "SELECT COALESCE(MAX(version), 0) FROM schema_version",
681        [],
682        |row| row.get(0),
683    )
684    .unwrap_or(0)
685}
686
687fn migrate(conn: &Connection) -> Result<()> {
688    ensure_schema_version_table(conn);
689
690    let current = get_current_version(conn);
691
692    if current == 0 {
693        let has_export_state: bool = conn
694            .query_row(
695                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='export_state'",
696                [],
697                |row| row.get(0),
698            )
699            .unwrap_or(false);
700
701        if has_export_state {
702            let metrics_cols = [
703                "files_produced INTEGER DEFAULT 0",
704                "bytes_written INTEGER DEFAULT 0",
705                "retries INTEGER DEFAULT 0",
706                "validated INTEGER",
707                "schema_changed INTEGER",
708                "run_id TEXT",
709            ];
710            for col_def in &metrics_cols {
711                let sql = format!("ALTER TABLE export_metrics ADD COLUMN {}", col_def);
712                let _ = conn.execute(&sql, []);
713            }
714        }
715    }
716
717    for &(ver, sql) in MIGRATIONS {
718        if ver > current {
719            log::debug!("state: applying migration v{}", ver);
720            let atomic_sql = format!(
721                "BEGIN;\n{}\nINSERT INTO schema_version (version) VALUES ({});\nCOMMIT;",
722                sql, ver
723            );
724            conn.execute_batch(&atomic_sql)
725                .map_err(|e| anyhow::anyhow!("state: migration v{} failed: {}", ver, e))?;
726        }
727    }
728
729    let _ = conn.execute(
730        "DELETE FROM schema_version WHERE version < (SELECT MAX(version) FROM schema_version)",
731        [],
732    );
733
734    let final_version = get_current_version(conn);
735    if final_version != SCHEMA_VERSION {
736        anyhow::bail!(
737            "state: migration incomplete — expected schema v{} but reached v{}",
738            SCHEMA_VERSION,
739            final_version
740        );
741    }
742
743    Ok(())
744}
745
746// ─── PostgreSQL migration ─────────────────────────────────────────────────────
747
748fn migrate_pg(client: &mut postgres::Client) -> Result<()> {
749    client
750        .batch_execute("CREATE TABLE IF NOT EXISTS rivet_schema_version (version BIGINT NOT NULL);")
751        .map_err(|e| anyhow::anyhow!("state(pg): create version table: {:#}", e))?;
752
753    let current: i64 = client
754        .query_one(
755            "SELECT COALESCE(MAX(version), 0) FROM rivet_schema_version",
756            &[],
757        )
758        .map_err(|e| anyhow::anyhow!("state(pg): read schema version: {:#}", e))?
759        .get(0);
760
761    for &(ver, sql) in PG_MIGRATIONS {
762        if ver > current {
763            log::debug!("state(pg): applying migration v{}", ver);
764            let batch = format!(
765                "BEGIN; {} INSERT INTO rivet_schema_version (version) VALUES ({}); COMMIT;",
766                sql, ver
767            );
768            client
769                .batch_execute(&batch)
770                .map_err(|e| anyhow::anyhow!("state(pg): migration v{} failed: {:#}", ver, e))?;
771        }
772    }
773
774    // Remove superseded version rows so MAX() stays unambiguous (mirrors SQLite behaviour).
775    let _ = client.batch_execute(
776        "DELETE FROM rivet_schema_version \
777         WHERE version < (SELECT MAX(version) FROM rivet_schema_version);",
778    );
779
780    // Verify the DB actually reached the expected version.
781    let final_version: i64 = client
782        .query_one(
783            "SELECT COALESCE(MAX(version), 0) FROM rivet_schema_version",
784            &[],
785        )
786        .map_err(|e| anyhow::anyhow!("state(pg): read final schema version: {:#}", e))?
787        .get(0);
788    if final_version != SCHEMA_VERSION {
789        anyhow::bail!(
790            "state(pg): migration incomplete — expected schema v{} but reached v{}",
791            SCHEMA_VERSION,
792            final_version
793        );
794    }
795
796    Ok(())
797}
798
799/// Redact the password from a PostgreSQL URL for safe use in log/error messages.
800/// `postgresql://user:SECRET@host/db` → `postgresql://user:***@host/db`
801/// Uses `rfind('@')` so passwords containing `@` are handled correctly.
802fn redact_pg_url(url: &str) -> String {
803    // Mask the password in `scheme://user:password@host/...`. RIVET_STATE_URL is
804    // operator-supplied and may be NON-conforming — a raw password can contain any
805    // of `/ ? # @ :` that a well-formed URL would percent-encode. There is no
806    // unambiguous parse of such a URL, so a redactor MUST default-deny: never leak,
807    // even at the cost of over-redacting a pathological host.
808    //
809    // Rule (rounds 2/3/4 converged here after the bounded/two-pass forms each leaked
810    // a different shape): the userinfo ends at the LAST '@' before whitespace (the
811    // URL / log-line terminator), and the user is everything up to the FIRST ':'
812    // (the password separator; a ':' inside the password is masked with the rest).
813    //   * one '@' (the normal case): the real terminator → host preserved.
814    //   * a password with a raw '/','?','#','@' (round-3 `pa/ss`, round-4 `Kp@9x/..`):
815    //     the last '@' is still the true terminator → tail masked, no leak.
816    //   * a ':'-bearing password (`a:b:c:secret`): FIRST ':' splits → prefix masked.
817    //   * a stray '@' in a query (`?opt=a@b`) — vanishingly rare for a connection
818    //     URL — over-redacts the host but never leaks (default-deny).
819    // Residual limitation (round-4 #4/#5, documented): a raw WHITESPACE in the
820    // password terminates the URL scan (whitespace ends the token in a log line), so
821    // a password containing a literal space/tab may not be fully masked. This is
822    // out of reliable scope — a space in a URL is itself non-conforming (must be
823    // %20-encoded), and treating a whitespace-bounded `:`-bearing span as userinfo
824    // would mangle every common credential-free `scheme://host:port/db ...` log line.
825    let Some(scheme_end) = url.find("://") else {
826        return url.to_string();
827    };
828    let after_scheme = &url[scheme_end + 3..];
829    let span_end = after_scheme
830        .find(char::is_whitespace)
831        .unwrap_or(after_scheme.len());
832    let span = &after_scheme[..span_end];
833    // No '@' → no userinfo to redact.
834    let Some(at_rel) = span.rfind('@') else {
835        return url.to_string();
836    };
837    let userinfo = &span[..at_rel];
838    // No ':' before the '@' → user-only, no password to mask.
839    let Some(colon) = userinfo.find(':') else {
840        return url.to_string();
841    };
842    let user = &userinfo[..colon];
843    let at_pos = scheme_end + 3 + at_rel;
844    format!(
845        "{}://{}:***@{}",
846        &url[..scheme_end],
847        user,
848        &url[at_pos + 1..]
849    )
850}
851
852// ─── SQLite connection helper ─────────────────────────────────────────────────
853
854pub(crate) const SQLITE_BUSY_TIMEOUT_MS: i64 = 10_000;
855
856pub(crate) fn open_connection(db_path: &std::path::Path) -> Result<Connection> {
857    let conn = Connection::open(db_path)?;
858    if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL;") {
859        log::warn!(
860            "state: WAL journal mode unavailable ({}); \
861             running in default mode — concurrent writes may be slower",
862            e
863        );
864    }
865    if let Err(e) = conn.execute_batch(&format!(
866        "PRAGMA busy_timeout = {};",
867        SQLITE_BUSY_TIMEOUT_MS
868    )) {
869        log::warn!(
870            "state: failed to set busy_timeout ({}); \
871             concurrent writers may surface SQLITE_BUSY immediately",
872            e
873        );
874    }
875    Ok(conn)
876}
877
878// ─── StateStore ───────────────────────────────────────────────────────────────
879
880/// Entry point for all persistent state.  Supports two backends:
881///
882/// - **SQLite** (default) — a single `.rivet_state.db` file next to the
883///   config.  Good for local / single-node / dev deployments.
884/// - **PostgreSQL** — a shared database addressed by `RIVET_STATE_URL`.
885///   Required for stateless container / Kubernetes deployments where the
886///   rivet pod is ephemeral or replicated.
887///
888/// Set the `RIVET_STATE_URL` environment variable to a PostgreSQL URL to
889/// activate the Postgres backend:
890///
891/// ```text
892/// RIVET_STATE_URL=postgresql://user:pass@host:5432/rivet_state
893/// ```
894///
895/// When the variable is absent or does not start with `postgres`, SQLite is
896/// used and the variable is ignored.
897pub struct StateStore {
898    pub(super) conn: StateConn,
899    /// Serialisable reference for reconnection (parallel chunk workers).
900    pub(super) state_ref: StateRef,
901}
902
903impl StateStore {
904    /// Open the appropriate backend.
905    ///
906    /// Checks `RIVET_STATE_URL`; falls back to SQLite next to `config_path`.
907    pub fn open(config_path: &str) -> Result<Self> {
908        if let Ok(url) = std::env::var("RIVET_STATE_URL")
909            && url.starts_with("postgres")
910        {
911            return Self::open_postgres(&url);
912        }
913        Self::open_sqlite(config_path)
914    }
915
916    fn open_sqlite(config_path: &str) -> Result<Self> {
917        let config_dir = std::path::Path::new(config_path)
918            .parent()
919            .unwrap_or(std::path::Path::new("."));
920        let db_path = config_dir.join(STATE_DB_NAME);
921        let conn = open_connection(&db_path)?;
922        migrate(&conn)?;
923        Ok(Self {
924            conn: StateConn::Sqlite(conn),
925            state_ref: StateRef::Sqlite(db_path),
926        })
927    }
928
929    fn open_postgres(url: &str) -> Result<Self> {
930        let is_local =
931            url.contains("localhost") || url.contains("127.0.0.1") || url.contains("::1");
932        if !is_local && state_tls_mode_from_url(url).is_none() {
933            log::warn!(
934                "state(pg): connecting to a remote host without TLS; \
935                 add sslmode=require (or verify-ca / verify-full) to RIVET_STATE_URL \
936                 to negotiate TLS for production use"
937            );
938        }
939        let mut client = connect_pg(url)?;
940        migrate_pg(&mut client)?;
941        Ok(Self {
942            conn: StateConn::Postgres(Box::new(std::cell::RefCell::new(client))),
943            state_ref: StateRef::Postgres(url.to_string()),
944        })
945    }
946
947    /// Path to `.rivet_state.db` for SQLite deployments.  Returns the config
948    /// directory path for Postgres (not meaningful for connection, only used
949    /// by legacy callers — prefer `state_ref()` for new code).
950    pub fn state_db_path(config_path: &str) -> std::path::PathBuf {
951        let config_dir = std::path::Path::new(config_path)
952            .parent()
953            .unwrap_or(std::path::Path::new("."));
954        config_dir.join(STATE_DB_NAME)
955    }
956
957    /// Serialisable connection reference for parallel chunk workers.
958    pub fn state_ref(&self) -> &StateRef {
959        &self.state_ref
960    }
961
962    /// In-memory SQLite store for unit tests.
963    #[allow(dead_code)]
964    pub fn open_in_memory() -> Result<Self> {
965        let conn = Connection::open_in_memory()?;
966        migrate(&conn)?;
967        Ok(Self {
968            conn: StateConn::Sqlite(conn),
969            state_ref: StateRef::Sqlite(std::path::PathBuf::from(":memory:")),
970        })
971    }
972
973    /// Open a SQLite store at an explicit file path (tests that need
974    /// cross-connection access via `claim_next_chunk_task_at_path`).
975    #[allow(dead_code)]
976    pub fn open_at_path(db_path: &std::path::Path) -> Result<Self> {
977        let conn = open_connection(db_path)?;
978        migrate(&conn)?;
979        Ok(Self {
980            conn: StateConn::Sqlite(conn),
981            state_ref: StateRef::Sqlite(db_path.to_path_buf()),
982        })
983    }
984}
985
986// ─── Migration tests ──────────────────────────────────────────────────────────
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991
992    #[test]
993    fn sqlite_and_postgres_migrations_define_the_same_tables_per_version() {
994        // `migrate`/`migrate_pg` only check the final version NUMBER; nothing
995        // catches a same-version, divergent-DDL edit between the two arrays. This
996        // asserts that for every version present in BOTH, the set of tables each
997        // CREATEs matches — so a table added to one backend but not the other
998        // (a query that works on SQLite and errors on PG) fails loudly here.
999        use std::collections::{BTreeSet, HashMap};
1000        fn table_names(sql: &str) -> BTreeSet<String> {
1001            let lower = sql.to_lowercase();
1002            let mut rest = lower.as_str();
1003            let mut out = BTreeSet::new();
1004            while let Some(i) = rest.find("create table") {
1005                rest = &rest[i + "create table".len()..];
1006                let after = rest
1007                    .trim_start()
1008                    .strip_prefix("if not exists")
1009                    .unwrap_or_else(|| rest.trim_start())
1010                    .trim_start();
1011                let name: String = after
1012                    .chars()
1013                    .take_while(|c| c.is_alphanumeric() || *c == '_')
1014                    .collect();
1015                if !name.is_empty() {
1016                    out.insert(name);
1017                }
1018            }
1019            out
1020        }
1021        let mut pg: HashMap<i64, BTreeSet<String>> = HashMap::new();
1022        for &(v, sql) in PG_MIGRATIONS {
1023            pg.entry(v).or_default().extend(table_names(sql));
1024        }
1025        for &(v, sql) in MIGRATIONS {
1026            if let Some(pg_tables) = pg.get(&v) {
1027                assert_eq!(
1028                    &table_names(sql),
1029                    pg_tables,
1030                    "migration v{v}: SQLite and Postgres define different tables"
1031                );
1032            }
1033        }
1034    }
1035
1036    #[test]
1037    fn fresh_db_reaches_latest_version() {
1038        let s = StateStore::open_in_memory().unwrap();
1039        let ver = match &s.conn {
1040            StateConn::Sqlite(c) => get_current_version(c),
1041            StateConn::Postgres(_) => unreachable!(),
1042        };
1043        assert_eq!(ver, SCHEMA_VERSION);
1044    }
1045
1046    #[test]
1047    fn migration_is_idempotent() {
1048        let s = StateStore::open_in_memory().unwrap();
1049        match &s.conn {
1050            StateConn::Sqlite(c) => {
1051                migrate(c).unwrap();
1052                migrate(c).unwrap();
1053                assert_eq!(get_current_version(c), SCHEMA_VERSION);
1054            }
1055            StateConn::Postgres(_) => unreachable!(),
1056        }
1057    }
1058
1059    #[test]
1060    fn legacy_db_gets_upgraded() {
1061        let conn = Connection::open_in_memory().unwrap();
1062        conn.execute_batch(
1063            "CREATE TABLE export_state (
1064                export_name TEXT PRIMARY KEY,
1065                last_cursor_value TEXT,
1066                last_run_at TEXT
1067            );
1068            CREATE TABLE export_metrics (
1069                id INTEGER PRIMARY KEY AUTOINCREMENT,
1070                export_name TEXT NOT NULL,
1071                run_at TEXT NOT NULL,
1072                duration_ms INTEGER NOT NULL,
1073                total_rows INTEGER NOT NULL,
1074                status TEXT NOT NULL
1075            );",
1076        )
1077        .unwrap();
1078
1079        migrate(&conn).unwrap();
1080        assert_eq!(get_current_version(&conn), SCHEMA_VERSION);
1081
1082        let has_chunk_run: bool = conn
1083            .query_row(
1084                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='chunk_run'",
1085                [],
1086                |row| row.get(0),
1087            )
1088            .unwrap();
1089        assert!(has_chunk_run);
1090    }
1091
1092    #[test]
1093    fn upgrading_from_v12_adds_the_ledger_and_snapshot_tables_and_keeps_data() {
1094        // Stage a database at EXACTLY v12 — a user on the release before the load
1095        // ledger (v13) and cdc_snapshot (v14). Apply only migrations up to v12,
1096        // exactly as the older rivet that wrote their `.rivet_state.db` did.
1097        let conn = Connection::open_in_memory().unwrap();
1098        ensure_schema_version_table(&conn);
1099        for &(ver, sql) in MIGRATIONS {
1100            if ver <= 12 {
1101                conn.execute_batch(&format!(
1102                    "BEGIN;\n{sql}\nINSERT INTO schema_version (version) VALUES ({ver});\nCOMMIT;"
1103                ))
1104                .unwrap();
1105            }
1106        }
1107        assert_eq!(get_current_version(&conn), 12, "staged at v12");
1108        // Pre-existing state that MUST survive the upgrade.
1109        conn.execute(
1110            "INSERT INTO export_state (export_name, last_cursor_value, last_run_at) \
1111             VALUES ('orders', '42', '2026-01-01T00:00:00Z')",
1112            [],
1113        )
1114        .unwrap();
1115
1116        // Upgrade the existing DB to the current schema (the v13 + v14 path).
1117        migrate(&conn).unwrap();
1118        assert_eq!(get_current_version(&conn), SCHEMA_VERSION);
1119
1120        // The v13/v14 tables now exist on the upgraded-in-place DB.
1121        for t in ["load_run", "loaded_source_run", "cdc_snapshot"] {
1122            let exists: bool = conn
1123                .query_row(
1124                    "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
1125                    [t],
1126                    |r| r.get(0),
1127                )
1128                .unwrap();
1129            assert!(
1130                exists,
1131                "{t} missing after the v12→v{SCHEMA_VERSION} upgrade"
1132            );
1133        }
1134        // The v12 data survived the added migrations (not dropped/recreated).
1135        let cursor: String = conn
1136            .query_row(
1137                "SELECT last_cursor_value FROM export_state WHERE export_name = 'orders'",
1138                [],
1139                |r| r.get(0),
1140            )
1141            .unwrap();
1142        assert_eq!(cursor, "42", "pre-upgrade data must survive");
1143    }
1144
1145    #[test]
1146    fn v8_renames_file_manifest_to_file_log() {
1147        let s = StateStore::open_in_memory().unwrap();
1148        let conn = match &s.conn {
1149            StateConn::Sqlite(c) => c,
1150            StateConn::Postgres(_) => unreachable!(),
1151        };
1152        let has_file_log: bool = conn
1153            .query_row(
1154                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='file_log'",
1155                [],
1156                |row| row.get(0),
1157            )
1158            .unwrap();
1159        assert!(has_file_log, "v8 must produce a `file_log` table");
1160        let has_old: bool = conn
1161            .query_row(
1162                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='file_manifest'",
1163                [],
1164                |row| row.get(0),
1165            )
1166            .unwrap();
1167        assert!(!has_old, "v8 must remove the old `file_manifest` table");
1168        let has_new_idx: bool = conn
1169            .query_row(
1170                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name='idx_file_log_export'",
1171                [],
1172                |row| row.get(0),
1173            )
1174            .unwrap();
1175        assert!(has_new_idx, "v8 must create the renamed index");
1176    }
1177
1178    #[test]
1179    fn v8_upgrades_existing_v7_db_with_data() {
1180        // Simulate an existing 0.6.0 database stopped at v7: the table is still
1181        // named `file_manifest` and has rows.  v8 must rename it preserving data.
1182        let conn = Connection::open_in_memory().unwrap();
1183        // Apply v1..=v7 by running the migrator after manually stamping v7.
1184        // Simpler: run the migrator, then manually rename back to v7 state to
1185        // exercise the v7→v8 path.  Here we just verify forward path covers it.
1186        migrate(&conn).unwrap();
1187        // Insert a row using the new name (post-v8); the rename happened transparently.
1188        conn.execute(
1189            "INSERT INTO file_log (run_id, export_name, file_name, row_count, bytes, format, created_at)
1190             VALUES ('r1', 'orders', 'f.parquet', 100, 4096, 'parquet', '2026-05-21T00:00:00Z')",
1191            [],
1192        )
1193        .unwrap();
1194        let count: i64 = conn
1195            .query_row("SELECT COUNT(*) FROM file_log", [], |r| r.get(0))
1196            .unwrap();
1197        assert_eq!(count, 1);
1198    }
1199
1200    #[test]
1201    fn run_aggregate_table_exists_after_migration() {
1202        let s = StateStore::open_in_memory().unwrap();
1203        let conn = match &s.conn {
1204            StateConn::Sqlite(c) => c,
1205            StateConn::Postgres(_) => unreachable!(),
1206        };
1207        let exists: bool = conn
1208            .query_row(
1209                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='run_aggregate'",
1210                [],
1211                |row| row.get(0),
1212            )
1213            .unwrap();
1214        assert!(exists, "v5 migration must create the run_aggregate table");
1215    }
1216
1217    #[test]
1218    fn v13_creates_the_load_ledger_tables() {
1219        let s = StateStore::open_in_memory().unwrap();
1220        let conn = match &s.conn {
1221            StateConn::Sqlite(c) => c,
1222            StateConn::Postgres(_) => unreachable!(),
1223        };
1224        for table in ["load_run", "loaded_source_run"] {
1225            let exists: bool = conn
1226                .query_row(
1227                    "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
1228                    [table],
1229                    |row| row.get(0),
1230                )
1231                .unwrap();
1232            assert!(exists, "v13 migration must create `{table}`");
1233        }
1234    }
1235
1236    #[test]
1237    fn v14_creates_the_cdc_snapshot_table() {
1238        let s = StateStore::open_in_memory().unwrap();
1239        let conn = match &s.conn {
1240            StateConn::Sqlite(c) => c,
1241            StateConn::Postgres(_) => unreachable!(),
1242        };
1243        let exists: bool = conn
1244            .query_row(
1245                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='cdc_snapshot'",
1246                [],
1247                |row| row.get(0),
1248            )
1249            .unwrap();
1250        assert!(exists, "v14 migration must create the cdc_snapshot table");
1251    }
1252
1253    #[test]
1254    fn pg_sql_converts_placeholders() {
1255        assert_eq!(
1256            pg_sql("SELECT ?1, ?2 FROM t WHERE x = ?3"),
1257            "SELECT $1, $2 FROM t WHERE x = $3"
1258        );
1259        assert_eq!(
1260            pg_sql("INSERT INTO t VALUES (?1, ?2)"),
1261            "INSERT INTO t VALUES ($1, $2)"
1262        );
1263        assert_eq!(pg_sql("no placeholders"), "no placeholders");
1264        // ?N with two digits
1265        assert_eq!(pg_sql("?10 AND ?11"), "$10 AND $11");
1266    }
1267
1268    #[test]
1269    fn redact_pg_url_removes_password() {
1270        assert_eq!(
1271            redact_pg_url("postgresql://rivet:secret123@localhost:5433/rivet_state"),
1272            "postgresql://rivet:***@localhost:5433/rivet_state"
1273        );
1274        assert_eq!(
1275            redact_pg_url("postgres://admin:p@ssw0rd@db.prod.example.com/state"),
1276            "postgres://admin:***@db.prod.example.com/state"
1277        );
1278    }
1279
1280    #[test]
1281    fn redact_pg_url_no_password_unchanged() {
1282        // URL without a password should come back as-is.
1283        let url = "postgresql://rivet@localhost/state";
1284        assert_eq!(redact_pg_url(url), url);
1285    }
1286
1287    #[test]
1288    fn redact_pg_url_stray_at_in_query_does_not_leak_password() {
1289        // Round-2 audit #2: an unbounded rfind('@') landed on a '@' in the query and
1290        // echoed `secret`. The SECURITY property is that the secret never survives.
1291        // The round-4 default-deny redactor over-redacts this contrived query-'@'
1292        // shape (masks to the last '@') rather than risk a leak — the secret is gone,
1293        // which is what matters; a '@' in a connection-URL query is vanishingly rare.
1294        let out = redact_pg_url("postgresql://u:secret@host:5432/db?opt=a@b");
1295        assert!(
1296            !out.contains("secret"),
1297            "password must not survive redaction with a stray '@' in the query: {out}"
1298        );
1299        // A normal single-'@' URL keeps the host visible (no over-redaction).
1300        assert_eq!(
1301            redact_pg_url("postgresql://u:secret@host:5432/db"),
1302            "postgresql://u:***@host:5432/db"
1303        );
1304    }
1305
1306    #[test]
1307    fn redact_pg_url_common_hostport_url_is_not_mangled() {
1308        // Round-4 #4/#5 documents that whitespace terminates the scan; the flip side
1309        // this test PINS is that we must NOT aggressively redact a whitespace-bounded
1310        // `:`-bearing span — a common credential-free `scheme://host:port/db` URL has
1311        // exactly that shape and must pass through untouched (no false-positive mangle).
1312        let url = "postgresql://db.internal:5432/orders";
1313        assert_eq!(
1314            redact_pg_url(url),
1315            url,
1316            "a credential-free host:port URL is untouched"
1317        );
1318        assert_eq!(
1319            redact_pg_url("connecting to postgresql://db:5432/x then retry"),
1320            "connecting to postgresql://db:5432/x then retry"
1321        );
1322    }
1323
1324    #[test]
1325    fn redact_pg_url_at_or_colon_in_password_does_not_leak() {
1326        // Round-4: the two-pass redactor leaked when the password held a '@' BEFORE a
1327        // raw '/','?','#' (pass 1 caught the internal '@', skipping the fail-safe), and
1328        // split the user at the LAST ':' (rfind) so a ':'-bearing password leaked its
1329        // prefix. The default-deny form (last '@' before whitespace, FIRST ':') closes
1330        // both. RED before the redesign.
1331        assert_eq!(
1332            redact_pg_url("postgresql://rivet:Kp@9x/Lm2z@db.prod:5432/orders"),
1333            "postgresql://rivet:***@db.prod:5432/orders",
1334            "'@'-before-'/' password tail must not leak"
1335        );
1336        assert_eq!(
1337            redact_pg_url("postgresql://rivet:a:b:c:secret@host:5432/state"),
1338            "postgresql://rivet:***@host:5432/state",
1339            "':'-bearing password prefix must not leak (FIRST-colon split)"
1340        );
1341        for u in [
1342            "postgresql://rivet:Kp@9x/Lm2z@db/orders",
1343            "postgresql://rivet:a:b:c:secret@host/state",
1344            "postgresql://u:p@w?rd@host/db",
1345            "postgresql://u:p@w#rd@host/db",
1346        ] {
1347            let out = redact_pg_url(u);
1348            assert!(
1349                !out.contains("Lm2z")
1350                    && !out.contains("a:b:c")
1351                    && !out.contains("w?rd")
1352                    && !out.contains("w#rd"),
1353                "no password fragment may survive: {out}"
1354            );
1355        }
1356    }
1357
1358    #[test]
1359    fn redact_pg_url_password_with_raw_delimiters_does_not_leak() {
1360        // Round-3 regression: the #2 authority-bound `find(['/','?','#'])` truncated
1361        // BEFORE the real '@' when the password itself contained '/','?', or '#'
1362        // (base64 secrets routinely contain '/'), so rfind('@') missed, the redactor
1363        // fell through, and echoed the cleartext password. RED before the fail-safe
1364        // pass. Each must mask the secret AND keep the user + host visible.
1365        assert_eq!(
1366            redact_pg_url("postgresql://u:pa/ss@host/db"),
1367            "postgresql://u:***@host/db",
1368            "'/' in password must be redacted, not leaked"
1369        );
1370        assert_eq!(
1371            redact_pg_url("postgresql://u:pa?ss@host/db"),
1372            "postgresql://u:***@host/db",
1373            "'?' in password must be redacted"
1374        );
1375        assert_eq!(
1376            redact_pg_url("postgresql://u:pa#ss@host/db"),
1377            "postgresql://u:***@host/db",
1378            "'#' in password must be redacted"
1379        );
1380        // Belt-and-suspenders: the secret string never survives, whatever the shape.
1381        for u in [
1382            "postgresql://rivet:Xy/9Zq@db:5432/state",
1383            "postgres://admin:p/a?s#s@db.example.com/state",
1384        ] {
1385            assert!(
1386                !redact_pg_url(u).contains("Xy/9Zq") && !redact_pg_url(u).contains("p/a?s#s"),
1387                "no raw-delimiter password may survive: {}",
1388                redact_pg_url(u)
1389            );
1390        }
1391    }
1392
1393    // ── state(pg) sslmode → TlsMode mapping ─────────────────────────────────
1394    //
1395    // Pins the decision behind the TLS bug fix: the state backend can no longer
1396    // hard-code NoTls. We can't drive a live TLS handshake in a unit test, so we
1397    // assert the *chosen transport policy* — TLS is enforced for require /
1398    // verify-* and plaintext (NoTls) otherwise — which is what selects the
1399    // connector inside `connect_pg` -> `connect_client`.
1400    use crate::config::TlsMode;
1401
1402    #[test]
1403    fn state_sslmode_enforced_values_negotiate_tls() {
1404        for (url, want) in [
1405            (
1406                "postgresql://u:p@db.prod:5432/state?sslmode=require",
1407                TlsMode::Require,
1408            ),
1409            (
1410                "postgresql://u:p@db.prod/state?sslmode=verify-ca",
1411                TlsMode::VerifyCa,
1412            ),
1413            (
1414                "postgresql://u:p@db.prod/state?sslmode=verify-full",
1415                TlsMode::VerifyFull,
1416            ),
1417        ] {
1418            let mode = state_tls_mode_from_url(url);
1419            assert_eq!(mode, Some(want), "url: {url}");
1420            assert!(
1421                mode.unwrap().is_enforced(),
1422                "{want:?} must enforce TLS (not NoTls)"
1423            );
1424        }
1425    }
1426
1427    #[test]
1428    fn state_sslmode_plaintext_values_stay_notls() {
1429        // Missing / disable / prefer / allow / unrecognized / uppercase all keep
1430        // the original NoTls behavior, so dev + docker setups are unchanged.
1431        for url in [
1432            "postgresql://u:p@localhost/state",
1433            "postgresql://u:p@localhost/state?sslmode=disable",
1434            "postgresql://u:p@db/state?sslmode=prefer",
1435            "postgresql://u:p@db/state?sslmode=allow",
1436            "postgresql://u:p@db/state?sslmode=REQUIRE",
1437            "postgresql://u:p@db/state?sslmode=garbage",
1438            "postgresql://u:p@db/state?sslmode",
1439            "postgresql://u:p@db/state?sslmode=",
1440        ] {
1441            assert_eq!(state_tls_mode_from_url(url), None, "url: {url}");
1442        }
1443    }
1444
1445    #[test]
1446    fn state_sslmode_exact_key_and_last_occurrence_wins() {
1447        // `xsslmode` is a different parameter; the exact `sslmode` key matters.
1448        assert_eq!(
1449            state_tls_mode_from_url("postgresql://u:p@db/state?xsslmode=require"),
1450            None
1451        );
1452        // Found among other params.
1453        assert_eq!(
1454            state_tls_mode_from_url(
1455                "postgresql://u:p@db/state?connect_timeout=10&sslmode=require&application_name=x"
1456            ),
1457            Some(TlsMode::Require)
1458        );
1459        // Last occurrence wins, matching libpq.
1460        assert_eq!(
1461            state_tls_mode_from_url("postgresql://u:p@db/state?sslmode=disable&sslmode=require"),
1462            Some(TlsMode::Require)
1463        );
1464        assert_eq!(
1465            state_tls_mode_from_url("postgresql://u:p@db/state?sslmode=require&sslmode=disable"),
1466            None
1467        );
1468    }
1469}