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