1use 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
73pub 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 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 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 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 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 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}