Rindle docs and package mapSkip to main content

rindle_replica/
initial_snapshot.rs

1//! One-connection, rollback-journal construction of an initial dark CDC snapshot.
2//!
3//! This is deliberately not a `Cluster` mode. It owns no graph, capture hook, worker, reader, or
4//! query surface. The only durable states it can produce are an exact owned BUILDING database or a
5//! complete snapshot plus its whole-run source checkpoint.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::marker::PhantomData;
9use std::path::{Path, PathBuf};
10use std::rc::Rc;
11
12use rindle::value::OwnedValue;
13use rusqlite::{params, params_from_iter, Connection, OptionalExtension};
14
15use crate::apply::{ensure_source_offsets_table_on_conn, source_offsets_table_ddl};
16use crate::sql::to_value;
17use crate::table_shape::ColType;
18use crate::ReplicaError;
19use crate::{set_foreign_keys, ForeignKeys};
20use crate::{SOURCE_OFFSETS_TABLE, SOURCE_OFFSET_WHOLE_RUN};
21
22pub const CDC_BOOTSTRAP_TABLE: &str = "_rindle_cdc_bootstrap";
23const BOOTSTRAP_FORMAT: i64 = 1;
24const BOOTSTRAP_STATE: &str = "snapshot-building";
25
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct InitialSnapshotIdentity {
28    pub source: String,
29    pub generation: String,
30    pub descriptor_hash: String,
31    pub profile_fingerprint: String,
32    pub schema_fingerprint: String,
33    pub topic_id: String,
34    pub created_at_ms: i64,
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct InitialSnapshotColumn {
39    pub name: String,
40    pub ty: ColType,
41    pub nullable: bool,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct InitialSnapshotTable {
46    pub name: String,
47    pub columns: Vec<InitialSnapshotColumn>,
48    pub primary_key: Vec<String>,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct StoredSourceCheckpoint {
53    pub offset: String,
54    pub chunk_seq: i64,
55    pub run_id: Option<String>,
56    pub run_rows: Option<u64>,
57}
58
59pub enum InitialSnapshotOpen {
60    Building(Box<InitialSnapshotStore>),
61    Committed(StoredSourceCheckpoint),
62}
63
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct InitialSnapshotCommit {
66    pub source: String,
67    pub snapshot_id: String,
68    pub offset: String,
69    pub locator: String,
70    pub rows: u64,
71}
72
73/// The single SQLite owner for an initial snapshot. `PhantomData<Rc<()>>` makes the lifecycle
74/// explicitly thread-confined even on platforms where rusqlite's connection is movable.
75pub struct InitialSnapshotStore {
76    path: PathBuf,
77    conn: Connection,
78    identity: InitialSnapshotIdentity,
79    tables: BTreeMap<String, InitialSnapshotTable>,
80    open_snapshot_id: Option<String>,
81    rows: u64,
82    _not_send: PhantomData<Rc<()>>,
83}
84
85impl InitialSnapshotStore {
86    /// Classify and open a generation-owned build. An unrelated data-bearing database is never
87    /// cleared or adopted. SQLite performs hot-journal recovery while the existing schema is read.
88    pub fn open_or_create(
89        path: impl AsRef<Path>,
90        identity: InitialSnapshotIdentity,
91        tables: Vec<InitialSnapshotTable>,
92    ) -> Result<InitialSnapshotOpen, ReplicaError> {
93        validate_identity(&identity)?;
94        let tables = validate_tables(tables)?;
95        let path = path.as_ref().to_path_buf();
96        let conn = Connection::open(&path)
97            .map_err(|error| ReplicaError::sqlite("initial snapshot open", error))?;
98        // An apply-plane store, so foreign keys are not enforced here — stated explicitly
99        // rather than inherited from the vendored build's `SQLITE_DEFAULT_FOREIGN_KEYS=1`.
100        // A dark snapshot is a bulk copy of an upstream's rows: they land table by table in
101        // whatever order the source hands them over, so a child row routinely precedes its
102        // parent and only the COMPLETED snapshot is referentially whole. Enforcing per row
103        // would fail a snapshot that is correct the moment it commits. `PRAGMA
104        // foreign_key_check` on the finished file is how a caller wanting proof gets it
105        // (`rindle_writeplane::foreign_key_check`).
106        set_foreign_keys(&conn, ForeignKeys::Unenforced)?;
107        conn.busy_timeout(std::time::Duration::ZERO)
108            .map_err(|error| ReplicaError::sqlite("initial snapshot busy timeout", error))?;
109
110        let existing_tables = user_tables(&conn)?;
111        let unexpected_objects = user_non_table_objects(&conn)?;
112        if !unexpected_objects.is_empty() {
113            return Err(state_error(format!(
114                "initial snapshot database contains unexpected schema objects: {}",
115                unexpected_objects.join(", ")
116            )));
117        }
118        let marker_table_present = existing_tables.contains(CDC_BOOTSTRAP_TABLE);
119        let marker_present = if marker_table_present {
120            let rows: i64 = conn
121                .query_row(
122                    &format!("SELECT COUNT(*) FROM {CDC_BOOTSTRAP_TABLE}"),
123                    [],
124                    |row| row.get(0),
125                )
126                .map_err(|error| {
127                    ReplicaError::sqlite("initial snapshot marker classification", error)
128                })?;
129            match rows {
130                0 => false,
131                1 => true,
132                _ => return Err(state_error("bootstrap marker has more than one row")),
133            }
134        } else {
135            false
136        };
137        let checkpoint = if existing_tables.contains(SOURCE_OFFSETS_TABLE) {
138            verify_create_sql(
139                &conn,
140                SOURCE_OFFSETS_TABLE,
141                &source_offsets_table_ddl(),
142                "source-offset",
143            )?;
144            read_checkpoint(&conn, &identity.source)?
145        } else {
146            None
147        };
148
149        if !marker_present {
150            if let Some(checkpoint) = checkpoint {
151                if table_row_count(&conn, SOURCE_OFFSETS_TABLE, "checkpoint classification")? != 1 {
152                    return Err(state_error(
153                        "committed initial snapshot has more than one source checkpoint",
154                    ));
155                }
156                verify_committed_build(&conn, &tables, &existing_tables, &checkpoint)?;
157                return Ok(InitialSnapshotOpen::Committed(checkpoint));
158            }
159            if !existing_tables.is_empty() {
160                return Err(state_error(
161                    "database has tables but neither an owned bootstrap marker nor a source checkpoint",
162                ));
163            }
164            configure_delete_exclusive(&conn)?;
165            initialize_schema(&conn, &identity, &tables)?;
166        } else {
167            if checkpoint.is_some() {
168                return Err(state_error(
169                    "database contains both a bootstrap marker and a source checkpoint",
170                ));
171            }
172            verify_owned_build(&conn, &identity, &tables, &existing_tables)?;
173            configure_delete_exclusive(&conn)?;
174        }
175
176        Ok(InitialSnapshotOpen::Building(Box::new(Self {
177            path,
178            conn,
179            identity,
180            tables,
181            open_snapshot_id: None,
182            rows: 0,
183            _not_send: PhantomData,
184        })))
185    }
186
187    pub fn begin(&mut self, snapshot_id: &str) -> Result<(), ReplicaError> {
188        if snapshot_id.is_empty() {
189            return Err(state_error("snapshot ID must not be empty"));
190        }
191        if self.open_snapshot_id.is_some() {
192            return Err(state_error(
193                "an initial snapshot transaction is already open",
194            ));
195        }
196        self.conn
197            .execute_batch("BEGIN EXCLUSIVE")
198            .map_err(|error| ReplicaError::sqlite("initial snapshot BEGIN EXCLUSIVE", error))?;
199        self.open_snapshot_id = Some(snapshot_id.to_owned());
200        self.rows = 0;
201        Ok(())
202    }
203
204    /// Execute one strict positional INSERT immediately. The connection's prepared-statement cache
205    /// retains the generated SQL per mapped table; no UPSERT/replace/ignore form is permitted.
206    pub fn apply_add(&mut self, table: &str, row: &[OwnedValue]) -> Result<(), ReplicaError> {
207        if self.open_snapshot_id.is_none() {
208            return Err(state_error("snapshot add arrived without BEGIN"));
209        }
210        let table = self
211            .tables
212            .get(table)
213            .ok_or_else(|| state_error("snapshot add names an unknown mapped table"))?;
214        if row.len() != table.columns.len() {
215            return Err(state_error(
216                "snapshot add width differs from the mapped table",
217            ));
218        }
219        let sql = insert_sql(table);
220        let mut statement = self
221            .conn
222            .prepare_cached(&sql)
223            .map_err(|error| ReplicaError::sqlite("initial snapshot prepare INSERT", error))?;
224        statement
225            .execute(params_from_iter(row.iter().map(to_value)))
226            .map_err(|error| ReplicaError::sqlite("initial snapshot strict INSERT", error))?;
227        self.rows = self
228            .rows
229            .checked_add(1)
230            .ok_or_else(|| state_error("initial snapshot row count overflow"))?;
231        Ok(())
232    }
233
234    /// Commit data, signed locator/checkpoint, accounting, and marker removal as one SQLite atom.
235    pub fn commit(
236        mut self,
237        checkpoint: InitialSnapshotCommit,
238    ) -> Result<StoredSourceCheckpoint, ReplicaError> {
239        let open_id = self
240            .open_snapshot_id
241            .as_deref()
242            .ok_or_else(|| state_error("initial snapshot commit arrived without BEGIN"))?;
243        if checkpoint.source != self.identity.source
244            || checkpoint.snapshot_id != open_id
245            || checkpoint.rows != self.rows
246            || checkpoint.offset.is_empty()
247            || checkpoint.locator.is_empty()
248        {
249            return Err(state_error(
250                "initial snapshot commit identity/count/checkpoint differs from the open build",
251            ));
252        }
253        verify_marker(&self.conn, &self.identity)?;
254        let rows = i64::try_from(checkpoint.rows)
255            .map_err(|_| state_error("initial snapshot row count exceeds SQLite INTEGER"))?;
256        self.conn
257            .execute(
258                &format!(
259                    "INSERT INTO {SOURCE_OFFSETS_TABLE} \
260                     (source, offset, chunk_seq, run_id, batch_hash, run_rows, rows_cum, committed_at) \
261                     VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5, NULL)"
262                ),
263                params![
264                    checkpoint.source,
265                    checkpoint.offset,
266                    SOURCE_OFFSET_WHOLE_RUN,
267                    checkpoint.locator,
268                    rows,
269                ],
270            )
271            .map_err(|error| ReplicaError::sqlite("initial snapshot checkpoint INSERT", error))?;
272        let removed = self
273            .conn
274            .execute(
275                &format!("DELETE FROM {CDC_BOOTSTRAP_TABLE} WHERE singleton = 1"),
276                [],
277            )
278            .map_err(|error| ReplicaError::sqlite("initial snapshot marker removal", error))?;
279        if removed != 1 {
280            return Err(state_error("initial snapshot BUILDING marker disappeared"));
281        }
282        self.conn
283            .execute_batch("COMMIT")
284            .map_err(|error| ReplicaError::sqlite("initial snapshot COMMIT", error))?;
285        self.open_snapshot_id = None;
286        Ok(StoredSourceCheckpoint {
287            offset: checkpoint.offset,
288            chunk_seq: SOURCE_OFFSET_WHOLE_RUN,
289            run_id: Some(checkpoint.locator),
290            run_rows: Some(checkpoint.rows),
291        })
292    }
293
294    pub fn rollback(&mut self) {
295        if self.open_snapshot_id.take().is_some() {
296            let _ = self.conn.execute_batch("ROLLBACK");
297            self.rows = 0;
298        }
299    }
300
301    pub fn rows(&self) -> u64 {
302        self.rows
303    }
304
305    pub fn path(&self) -> &Path {
306        &self.path
307    }
308
309    pub fn journal_mode(&self) -> Result<String, ReplicaError> {
310        pragma_text(&self.conn, "journal_mode")
311    }
312
313    pub fn locking_mode(&self) -> Result<String, ReplicaError> {
314        pragma_text(&self.conn, "locking_mode")
315    }
316
317    pub fn synchronous(&self) -> Result<i64, ReplicaError> {
318        self.conn
319            .query_row("PRAGMA synchronous", [], |row| row.get(0))
320            .map_err(|error| ReplicaError::sqlite("initial snapshot PRAGMA synchronous", error))
321    }
322}
323
324impl Drop for InitialSnapshotStore {
325    fn drop(&mut self) {
326        self.rollback();
327    }
328}
329
330fn validate_identity(identity: &InitialSnapshotIdentity) -> Result<(), ReplicaError> {
331    if identity.created_at_ms < 0
332        || [
333            &identity.source,
334            &identity.generation,
335            &identity.descriptor_hash,
336            &identity.profile_fingerprint,
337            &identity.schema_fingerprint,
338            &identity.topic_id,
339        ]
340        .into_iter()
341        .any(|value| value.is_empty())
342    {
343        return Err(state_error("initial snapshot identity is incomplete"));
344    }
345    Ok(())
346}
347
348fn validate_tables(
349    tables: Vec<InitialSnapshotTable>,
350) -> Result<BTreeMap<String, InitialSnapshotTable>, ReplicaError> {
351    let mut result = BTreeMap::new();
352    for table in tables {
353        if table.name.is_empty() || table.columns.is_empty() || table.primary_key.is_empty() {
354            return Err(state_error("initial snapshot table identity is incomplete"));
355        }
356        let names = table
357            .columns
358            .iter()
359            .map(|column| column.name.as_str())
360            .collect::<BTreeSet<_>>();
361        if names.len() != table.columns.len()
362            || table
363                .primary_key
364                .iter()
365                .any(|key| !names.contains(key.as_str()))
366            || table
367                .columns
368                .iter()
369                .any(|column| table.primary_key.contains(&column.name) && column.nullable)
370        {
371            return Err(state_error(
372                "initial snapshot table has duplicate/unknown/nullable primary-key columns",
373            ));
374        }
375        if result.insert(table.name.clone(), table).is_some() {
376            return Err(state_error("initial snapshot maps a duplicate table name"));
377        }
378    }
379    if result.is_empty() {
380        return Err(state_error("initial snapshot has no mapped tables"));
381    }
382    Ok(result)
383}
384
385fn configure_delete_exclusive(conn: &Connection) -> Result<(), ReplicaError> {
386    let journal = conn
387        .query_row("PRAGMA journal_mode=DELETE", [], |row| {
388            row.get::<_, String>(0)
389        })
390        .map_err(|error| ReplicaError::sqlite("initial snapshot journal_mode=DELETE", error))?;
391    let locking = conn
392        .query_row("PRAGMA locking_mode=EXCLUSIVE", [], |row| {
393            row.get::<_, String>(0)
394        })
395        .map_err(|error| ReplicaError::sqlite("initial snapshot locking_mode=EXCLUSIVE", error))?;
396    conn.execute_batch("PRAGMA synchronous=FULL")
397        .map_err(|error| ReplicaError::sqlite("initial snapshot synchronous=FULL", error))?;
398    if !journal.eq_ignore_ascii_case("delete")
399        || !locking.eq_ignore_ascii_case("exclusive")
400        || conn
401            .query_row("PRAGMA synchronous", [], |row| row.get::<_, i64>(0))
402            .map_err(|error| ReplicaError::sqlite("initial snapshot verify synchronous", error))?
403            != 2
404    {
405        return Err(ReplicaError::Open(
406            "initial snapshot requires journal_mode=delete, locking_mode=exclusive, synchronous=FULL"
407                .into(),
408        ));
409    }
410    Ok(())
411}
412
413fn initialize_schema(
414    conn: &Connection,
415    identity: &InitialSnapshotIdentity,
416    tables: &BTreeMap<String, InitialSnapshotTable>,
417) -> Result<(), ReplicaError> {
418    conn.execute_batch("BEGIN EXCLUSIVE")
419        .map_err(|error| ReplicaError::sqlite("initial snapshot schema BEGIN", error))?;
420    let result = (|| {
421        for table in tables.values() {
422            conn.execute_batch(&create_table_sql(table))
423                .map_err(|error| ReplicaError::sqlite("initial snapshot mapped schema", error))?;
424        }
425        ensure_source_offsets_table_on_conn(conn)?;
426        conn.execute_batch(&bootstrap_table_ddl())
427            .map_err(|error| ReplicaError::sqlite("initial snapshot marker schema", error))?;
428        conn.execute(
429            &format!(
430                "INSERT INTO {CDC_BOOTSTRAP_TABLE} \
431                 (singleton, format, state, source, generation, descriptor_hash, \
432                  profile_fingerprint, schema_fingerprint, topic_id, created_at_ms) \
433                 VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"
434            ),
435            params![
436                BOOTSTRAP_FORMAT,
437                BOOTSTRAP_STATE,
438                identity.source,
439                identity.generation,
440                identity.descriptor_hash,
441                identity.profile_fingerprint,
442                identity.schema_fingerprint,
443                identity.topic_id,
444                identity.created_at_ms,
445            ],
446        )
447        .map_err(|error| ReplicaError::sqlite("initial snapshot marker INSERT", error))?;
448        conn.execute_batch("COMMIT")
449            .map_err(|error| ReplicaError::sqlite("initial snapshot schema COMMIT", error))
450    })();
451    if result.is_err() {
452        let _ = conn.execute_batch("ROLLBACK");
453    }
454    result
455}
456
457fn verify_owned_build(
458    conn: &Connection,
459    identity: &InitialSnapshotIdentity,
460    tables: &BTreeMap<String, InitialSnapshotTable>,
461    existing_tables: &BTreeSet<String>,
462) -> Result<(), ReplicaError> {
463    let mut expected = tables.keys().cloned().collect::<BTreeSet<_>>();
464    expected.insert(SOURCE_OFFSETS_TABLE.to_owned());
465    expected.insert(CDC_BOOTSTRAP_TABLE.to_owned());
466    if &expected != existing_tables {
467        return Err(state_error(
468            "owned bootstrap database contains unexpected or missing tables",
469        ));
470    }
471    verify_marker(conn, identity)?;
472    verify_create_sql(
473        conn,
474        SOURCE_OFFSETS_TABLE,
475        &source_offsets_table_ddl(),
476        "source-offset",
477    )?;
478    verify_create_sql(
479        conn,
480        CDC_BOOTSTRAP_TABLE,
481        &bootstrap_table_ddl(),
482        "bootstrap-marker",
483    )?;
484    let checkpoints: i64 = conn
485        .query_row(
486            &format!("SELECT COUNT(*) FROM {SOURCE_OFFSETS_TABLE}"),
487            [],
488            |row| row.get(0),
489        )
490        .map_err(|error| {
491            ReplicaError::sqlite("initial snapshot checkpoint classification", error)
492        })?;
493    if checkpoints != 0 {
494        return Err(state_error("owned BUILDING database contains a checkpoint"));
495    }
496    for table in tables.values() {
497        verify_table_schema(conn, table)?;
498        verify_create_sql(conn, &table.name, &create_table_sql(table), "mapped-table")?;
499        let row = conn
500            .query_row(
501                &format!("SELECT 1 FROM {} LIMIT 1", quote_ident(&table.name)),
502                [],
503                |_| Ok(()),
504            )
505            .optional()
506            .map_err(|error| ReplicaError::sqlite("initial snapshot empty-table check", error))?;
507        if row.is_some() {
508            return Err(state_error("owned BUILDING database contains mapped rows"));
509        }
510    }
511    Ok(())
512}
513
514fn verify_committed_build(
515    conn: &Connection,
516    tables: &BTreeMap<String, InitialSnapshotTable>,
517    existing_tables: &BTreeSet<String>,
518    checkpoint: &StoredSourceCheckpoint,
519) -> Result<(), ReplicaError> {
520    let mut expected = tables.keys().cloned().collect::<BTreeSet<_>>();
521    expected.insert(SOURCE_OFFSETS_TABLE.to_owned());
522    expected.insert(CDC_BOOTSTRAP_TABLE.to_owned());
523    if &expected != existing_tables {
524        return Err(state_error(
525            "committed initial snapshot contains unexpected or missing tables",
526        ));
527    }
528    verify_create_sql(
529        conn,
530        SOURCE_OFFSETS_TABLE,
531        &source_offsets_table_ddl(),
532        "source-offset",
533    )?;
534    verify_create_sql(
535        conn,
536        CDC_BOOTSTRAP_TABLE,
537        &bootstrap_table_ddl(),
538        "bootstrap-marker",
539    )?;
540    if table_row_count(conn, CDC_BOOTSTRAP_TABLE, "committed marker classification")? != 0 {
541        return Err(state_error(
542            "committed initial snapshot still contains a BUILDING marker",
543        ));
544    }
545    if checkpoint.chunk_seq != SOURCE_OFFSET_WHOLE_RUN
546        || checkpoint.offset.is_empty()
547        || checkpoint.run_id.as_deref().is_none_or(str::is_empty)
548    {
549        return Err(state_error(
550            "committed initial snapshot checkpoint is not a complete signed run",
551        ));
552    }
553
554    let mut rows = 0_u64;
555    for table in tables.values() {
556        verify_table_schema(conn, table)?;
557        verify_create_sql(conn, &table.name, &create_table_sql(table), "mapped-table")?;
558        let table_rows = table_row_count(conn, &table.name, "committed mapped-row accounting")?;
559        rows = rows
560            .checked_add(
561                u64::try_from(table_rows)
562                    .map_err(|_| state_error("committed mapped-table row count is negative"))?,
563            )
564            .ok_or_else(|| state_error("committed mapped-table row count overflow"))?;
565    }
566    if checkpoint.run_rows != Some(rows) {
567        return Err(state_error(
568            "committed mapped rows differ from the source-checkpoint accounting",
569        ));
570    }
571    Ok(())
572}
573
574fn verify_marker(
575    conn: &Connection,
576    identity: &InitialSnapshotIdentity,
577) -> Result<(), ReplicaError> {
578    let marker = conn
579        .query_row(
580            &format!(
581                "SELECT format, state, source, generation, descriptor_hash, profile_fingerprint, \
582                        schema_fingerprint, topic_id, created_at_ms \
583                 FROM {CDC_BOOTSTRAP_TABLE} WHERE singleton = 1"
584            ),
585            [],
586            |row| {
587                Ok((
588                    row.get::<_, i64>(0)?,
589                    row.get::<_, String>(1)?,
590                    row.get::<_, String>(2)?,
591                    row.get::<_, String>(3)?,
592                    row.get::<_, String>(4)?,
593                    row.get::<_, String>(5)?,
594                    row.get::<_, String>(6)?,
595                    row.get::<_, String>(7)?,
596                    row.get::<_, i64>(8)?,
597                ))
598            },
599        )
600        .optional()
601        .map_err(|error| ReplicaError::sqlite("initial snapshot marker read", error))?;
602    let expected = (
603        BOOTSTRAP_FORMAT,
604        BOOTSTRAP_STATE,
605        identity.source.as_str(),
606        identity.generation.as_str(),
607        identity.descriptor_hash.as_str(),
608        identity.profile_fingerprint.as_str(),
609        identity.schema_fingerprint.as_str(),
610        identity.topic_id.as_str(),
611        identity.created_at_ms,
612    );
613    match marker {
614        Some((
615            format,
616            state,
617            source,
618            generation,
619            descriptor,
620            profile,
621            schema,
622            topic,
623            created_at_ms,
624        )) if (
625            format,
626            state.as_str(),
627            source.as_str(),
628            generation.as_str(),
629            descriptor.as_str(),
630            profile.as_str(),
631            schema.as_str(),
632            topic.as_str(),
633            created_at_ms,
634        ) == expected =>
635        {
636            Ok(())
637        }
638        _ => Err(state_error(
639            "bootstrap marker does not exactly match the configured descriptor identity",
640        )),
641    }
642}
643
644fn read_checkpoint(
645    conn: &Connection,
646    source: &str,
647) -> Result<Option<StoredSourceCheckpoint>, ReplicaError> {
648    let stored = conn
649        .query_row(
650            &format!(
651                "SELECT offset, chunk_seq, run_id, run_rows, rows_cum FROM {SOURCE_OFFSETS_TABLE} \
652                 WHERE source = ?1"
653            ),
654            [source],
655            |row| {
656                Ok((
657                    row.get::<_, String>(0)?,
658                    row.get::<_, i64>(1)?,
659                    row.get::<_, Option<String>>(2)?,
660                    row.get::<_, Option<i64>>(3)?,
661                    row.get::<_, Option<i64>>(4)?,
662                ))
663            },
664        )
665        .optional()
666        .map_err(|error| ReplicaError::sqlite("initial snapshot checkpoint read", error))?;
667    stored
668        .map(|(offset, chunk_seq, run_id, run_rows, rows_cum)| {
669            let run_rows = run_rows
670                .map(|rows| {
671                    u64::try_from(rows)
672                        .map_err(|_| state_error("stored initial snapshot row count is negative"))
673                })
674                .transpose()?;
675            let rows_cum = rows_cum
676                .map(|rows| {
677                    u64::try_from(rows).map_err(|_| {
678                        state_error("stored cumulative snapshot row count is negative")
679                    })
680                })
681                .transpose()?;
682            if run_rows != rows_cum {
683                return Err(state_error(
684                    "stored snapshot run and cumulative row accounting differ",
685                ));
686            }
687            Ok(StoredSourceCheckpoint {
688                offset,
689                chunk_seq,
690                run_id,
691                run_rows,
692            })
693        })
694        .transpose()
695}
696
697fn user_tables(conn: &Connection) -> Result<BTreeSet<String>, ReplicaError> {
698    conn.prepare(
699        "SELECT name FROM sqlite_schema \
700         WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
701    )
702    .and_then(|mut statement| {
703        statement
704            .query_map([], |row| row.get::<_, String>(0))?
705            .collect::<rusqlite::Result<BTreeSet<_>>>()
706    })
707    .map_err(|error| ReplicaError::sqlite("initial snapshot schema classification", error))
708}
709
710fn user_non_table_objects(conn: &Connection) -> Result<Vec<String>, ReplicaError> {
711    conn.prepare(
712        "SELECT type || ':' || name FROM sqlite_schema \
713         WHERE type <> 'table' AND name NOT LIKE 'sqlite_%' ORDER BY type, name",
714    )
715    .and_then(|mut statement| {
716        statement
717            .query_map([], |row| row.get::<_, String>(0))?
718            .collect::<rusqlite::Result<Vec<_>>>()
719    })
720    .map_err(|error| ReplicaError::sqlite("initial snapshot schema-object classification", error))
721}
722
723fn table_row_count(
724    conn: &Connection,
725    table: &str,
726    context: &'static str,
727) -> Result<i64, ReplicaError> {
728    conn.query_row(
729        &format!("SELECT COUNT(*) FROM {}", quote_ident(table)),
730        [],
731        |row| row.get(0),
732    )
733    .map_err(|error| ReplicaError::sqlite(context, error))
734}
735
736fn verify_create_sql(
737    conn: &Connection,
738    table: &str,
739    expected: &str,
740    kind: &str,
741) -> Result<(), ReplicaError> {
742    let actual = conn
743        .query_row(
744            "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?1",
745            [table],
746            |row| row.get::<_, String>(0),
747        )
748        .optional()
749        .map_err(|error| ReplicaError::sqlite("initial snapshot CREATE SQL validation", error))?;
750    // SQLite intentionally omits the idempotence clause from the canonical SQL it stores in
751    // `sqlite_schema`; compare against that representation while keeping every column, constraint,
752    // and ordering byte exact.
753    let expected = expected.replacen("CREATE TABLE IF NOT EXISTS ", "CREATE TABLE ", 1);
754    if actual.as_deref() != Some(expected.as_str()) {
755        return Err(state_error(format!(
756            "owned BUILDING database {kind} schema differs from the current bootstrap format"
757        )));
758    }
759    Ok(())
760}
761
762fn verify_table_schema(
763    conn: &Connection,
764    table: &InitialSnapshotTable,
765) -> Result<(), ReplicaError> {
766    let sql = format!("PRAGMA table_info({})", quote_ident(&table.name));
767    let mut actual = conn
768        .prepare(&sql)
769        .and_then(|mut statement| {
770            statement
771                .query_map([], |row| {
772                    Ok((
773                        row.get::<_, String>(1)?,
774                        row.get::<_, String>(2)?,
775                        row.get::<_, i64>(3)? != 0,
776                        row.get::<_, i64>(5)?,
777                    ))
778                })?
779                .collect::<rusqlite::Result<Vec<_>>>()
780        })
781        .map_err(|error| {
782            ReplicaError::sqlite("initial snapshot mapped schema validation", error)
783        })?;
784    let mut actual_pk = actual
785        .iter()
786        .filter(|(_, _, _, ordinal)| *ordinal > 0)
787        .map(|(name, _, _, ordinal)| (*ordinal, name.clone()))
788        .collect::<Vec<_>>();
789    actual_pk.sort_by_key(|(ordinal, _)| *ordinal);
790    let actual_pk = actual_pk
791        .into_iter()
792        .map(|(_, name)| name)
793        .collect::<Vec<_>>();
794    if actual.len() != table.columns.len()
795        || actual
796            .iter_mut()
797            .zip(&table.columns)
798            .any(|((name, ty, not_null, _), expected)| {
799                name != &expected.name
800                    || !ty.eq_ignore_ascii_case(sql_type(expected.ty))
801                    || *not_null == expected.nullable
802            })
803        || actual_pk != table.primary_key
804    {
805        return Err(state_error(
806            "owned BUILDING database mapped schema differs from the descriptor",
807        ));
808    }
809    Ok(())
810}
811
812fn create_table_sql(table: &InitialSnapshotTable) -> String {
813    let columns = table
814        .columns
815        .iter()
816        .map(|column| {
817            format!(
818                "{} {}{}",
819                quote_ident(&column.name),
820                sql_type(column.ty),
821                if column.nullable { "" } else { " NOT NULL" }
822            )
823        })
824        .collect::<Vec<_>>()
825        .join(", ");
826    let primary_key = table
827        .primary_key
828        .iter()
829        .map(|name| quote_ident(name))
830        .collect::<Vec<_>>()
831        .join(", ");
832    format!(
833        "CREATE TABLE {} ({columns}, PRIMARY KEY ({primary_key}))",
834        quote_ident(&table.name)
835    )
836}
837
838fn bootstrap_table_ddl() -> String {
839    format!(
840        "CREATE TABLE {CDC_BOOTSTRAP_TABLE} (\
841             singleton INTEGER PRIMARY KEY CHECK (singleton = 1),\
842             format INTEGER NOT NULL CHECK (format = {BOOTSTRAP_FORMAT}),\
843             state TEXT NOT NULL CHECK (state = '{BOOTSTRAP_STATE}'),\
844             source TEXT NOT NULL, generation TEXT NOT NULL, descriptor_hash TEXT NOT NULL,\
845             profile_fingerprint TEXT NOT NULL, schema_fingerprint TEXT NOT NULL,\
846             topic_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL\
847         )"
848    )
849}
850
851fn insert_sql(table: &InitialSnapshotTable) -> String {
852    let columns = table
853        .columns
854        .iter()
855        .map(|column| quote_ident(&column.name))
856        .collect::<Vec<_>>()
857        .join(", ");
858    let parameters = (1..=table.columns.len())
859        .map(|index| format!("?{index}"))
860        .collect::<Vec<_>>()
861        .join(", ");
862    format!(
863        "INSERT INTO {} ({columns}) VALUES ({parameters})",
864        quote_ident(&table.name)
865    )
866}
867
868fn sql_type(ty: ColType) -> &'static str {
869    match ty {
870        ColType::String => "TEXT",
871        ColType::Number => "REAL",
872        ColType::Boolean => "BOOLEAN",
873        ColType::Json => "JSON",
874        ColType::Int => "BIGINT",
875    }
876}
877
878fn quote_ident(name: &str) -> String {
879    format!("\"{}\"", name.replace('"', "\"\""))
880}
881
882fn pragma_text(conn: &Connection, name: &'static str) -> Result<String, ReplicaError> {
883    conn.query_row(&format!("PRAGMA {name}"), [], |row| row.get(0))
884        .map_err(|error| ReplicaError::sqlite("initial snapshot PRAGMA read", error))
885}
886
887fn state_error(message: impl Into<String>) -> ReplicaError {
888    ReplicaError::Open(message.into())
889}