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