1use std::collections::BTreeMap;
11
12use fallible_streaming_iterator::FallibleStreamingIterator;
13use rusqlite::hooks::Action;
14use rusqlite::session::{ChangesetItem, ChangesetIter};
15use rusqlite::types::ValueRef;
16use rusqlite::Connection;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum RowIdentity {
26 IndependentUuid,
29 SharedKey,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
35pub(crate) enum RowIdentityError {
36 #[error(
37 "synced table {table:?} id {value:?} is invalid for IndependentUuid; expected a canonical lowercase hyphenated RFC UUID version 4 or 7"
38 )]
39 InvalidIndependentUuid { table: String, value: String },
40 #[error("synced table {table:?} changeset has no {side} primary-key value")]
41 MissingPrimaryKey { table: String, side: &'static str },
42 #[error("synced table {table:?} changeset has a non-TEXT {side} primary-key value")]
43 NonTextPrimaryKey { table: String, side: &'static str },
44 #[error("synced table {table:?} changeset primary key is not UTF-8: {reason}")]
45 NonUtf8PrimaryKey { table: String, reason: String },
46}
47
48impl RowIdentityError {
49 pub(crate) fn table(&self) -> &str {
50 match self {
51 Self::InvalidIndependentUuid { table, .. }
52 | Self::MissingPrimaryKey { table, .. }
53 | Self::NonTextPrimaryKey { table, .. }
54 | Self::NonUtf8PrimaryKey { table, .. } => table,
55 }
56 }
57}
58
59#[derive(Debug, thiserror::Error)]
60pub(crate) enum ChangesetIdentityError {
61 #[error("changeset row identity validation failed: {0}")]
62 Parse(String),
63 #[error("changeset contains undeclared table {0:?}")]
64 UndeclaredTable(String),
65 #[error(transparent)]
66 Row(#[from] RowIdentityError),
67}
68
69pub(crate) fn validate_row_identity(
70 table: &str,
71 identity: RowIdentity,
72 value: &str,
73) -> Result<(), RowIdentityError> {
74 if identity == RowIdentity::SharedKey {
75 return Ok(());
76 }
77
78 let valid = uuid::Uuid::parse_str(value).is_ok_and(|parsed| {
79 parsed.get_variant() == uuid::Variant::RFC4122
80 && matches!(
81 parsed.get_version(),
82 Some(uuid::Version::Random | uuid::Version::SortRand)
83 )
84 && parsed.hyphenated().to_string() == value
85 });
86 if valid {
87 Ok(())
88 } else {
89 Err(RowIdentityError::InvalidIndependentUuid {
90 table: table.to_string(),
91 value: value.to_string(),
92 })
93 }
94}
95
96pub(crate) fn validate_changeset_row_identities(
97 bytes: &[u8],
98 tables: &[SyncedTable],
99) -> Result<(), ChangesetIdentityError> {
100 if bytes.is_empty() {
101 return Ok(());
102 }
103
104 let input: &mut dyn std::io::Read = &mut &bytes[..];
105 let mut iter = ChangesetIter::start_strm(&input)
106 .map_err(|error| ChangesetIdentityError::Parse(error.to_string()))?;
107 while let Some(item) = iter
108 .next()
109 .map_err(|error| ChangesetIdentityError::Parse(error.to_string()))?
110 {
111 let op = item
112 .op()
113 .map_err(|error| ChangesetIdentityError::Parse(error.to_string()))?;
114 let table_name = op.table_name();
115 let table = tables
116 .iter()
117 .find(|table| table.name() == table_name)
118 .ok_or_else(|| ChangesetIdentityError::UndeclaredTable(table_name.to_string()))?;
119 match op.code() {
120 Action::SQLITE_INSERT => {
121 let id = required_changeset_id(item, table_name, "new", ChangesetSide::New)?;
122 validate_row_identity(table_name, table.row_identity(), &id)?;
123 }
124 Action::SQLITE_DELETE => {
125 let id = required_changeset_id(item, table_name, "old", ChangesetSide::Old)?;
126 validate_row_identity(table_name, table.row_identity(), &id)?;
127 }
128 Action::SQLITE_UPDATE => {
129 let old = required_changeset_id(item, table_name, "old", ChangesetSide::Old)?;
130 let id = optional_changeset_id(item, table_name, "new", ChangesetSide::New)?
131 .unwrap_or(old);
132 validate_row_identity(table_name, table.row_identity(), &id)?;
133 }
134 _ => {}
135 }
136 }
137 Ok(())
138}
139
140#[derive(Clone, Copy)]
141enum ChangesetSide {
142 Old,
143 New,
144}
145
146fn required_changeset_id(
147 item: &ChangesetItem,
148 table: &str,
149 side_name: &'static str,
150 side: ChangesetSide,
151) -> Result<String, RowIdentityError> {
152 optional_changeset_id(item, table, side_name, side)?.ok_or_else(|| {
153 RowIdentityError::MissingPrimaryKey {
154 table: table.to_string(),
155 side: side_name,
156 }
157 })
158}
159
160fn optional_changeset_id(
161 item: &ChangesetItem,
162 table: &str,
163 side_name: &'static str,
164 side: ChangesetSide,
165) -> Result<Option<String>, RowIdentityError> {
166 let value = match side {
167 ChangesetSide::Old => item.old_value(0),
168 ChangesetSide::New => item.new_value(0),
169 };
170 let value = match value {
171 Ok(value) => value,
172 Err(rusqlite::Error::InvalidColumnIndex(_)) => return Ok(None),
173 Err(error) => {
174 return Err(RowIdentityError::NonUtf8PrimaryKey {
175 table: table.to_string(),
176 reason: error.to_string(),
177 })
178 }
179 };
180 let ValueRef::Text(bytes) = value else {
181 return Err(RowIdentityError::NonTextPrimaryKey {
182 table: table.to_string(),
183 side: side_name,
184 });
185 };
186 std::str::from_utf8(bytes)
187 .map(str::to_owned)
188 .map(Some)
189 .map_err(|error| RowIdentityError::NonUtf8PrimaryKey {
190 table: table.to_string(),
191 reason: error.to_string(),
192 })
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct SyncedTable {
241 name: String,
242 row_identity: RowIdentity,
243 role: GateRole,
244 audience_parent_column: Option<String>,
245 blob: Option<BlobDecl>,
246 asset: bool,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum GateRole {
257 Plain,
259 RemoteRoot,
262 GatedRoot { gate_column: String },
265 ScopedRoot { audience_column: String },
268 GatedByDescendants,
273}
274
275impl SyncedTable {
276 pub fn new(name: impl Into<String>, row_identity: RowIdentity) -> Self {
279 SyncedTable {
280 name: name.into(),
281 row_identity,
282 role: GateRole::Plain,
283 audience_parent_column: None,
284 blob: None,
285 asset: false,
286 }
287 }
288
289 pub fn gated_by(mut self, column: impl Into<String>) -> Self {
291 self.role = GateRole::GatedRoot {
292 gate_column: column.into(),
293 };
294 self
295 }
296
297 pub fn scoped_by(mut self, column: impl Into<String>) -> Self {
301 self.role = GateRole::ScopedRoot {
302 audience_column: column.into(),
303 };
304 self
305 }
306
307 pub fn inherits_audience_through(mut self, column: impl Into<String>) -> Self {
312 self.audience_parent_column = Some(column.into());
313 self
314 }
315
316 pub fn remote_root(mut self) -> Self {
321 self.role = GateRole::RemoteRoot;
322 self
323 }
324
325 pub fn gated_by_descendants(mut self) -> Self {
330 self.role = GateRole::GatedByDescendants;
331 self
332 }
333
334 pub fn carries_blob(mut self, decl: BlobDecl) -> Self {
339 self.blob = Some(decl);
340 self
341 }
342
343 pub fn asset(mut self) -> Self {
352 self.asset = true;
353 self
354 }
355
356 pub fn name(&self) -> &str {
358 &self.name
359 }
360
361 pub fn row_identity(&self) -> RowIdentity {
363 self.row_identity
364 }
365
366 pub fn gate_column(&self) -> Option<&str> {
368 match &self.role {
369 GateRole::GatedRoot { gate_column } => Some(gate_column),
370 GateRole::Plain
371 | GateRole::RemoteRoot
372 | GateRole::ScopedRoot { .. }
373 | GateRole::GatedByDescendants => None,
374 }
375 }
376
377 pub fn audience_column(&self) -> Option<&str> {
379 match &self.role {
380 GateRole::ScopedRoot { audience_column } => Some(audience_column),
381 GateRole::Plain
382 | GateRole::RemoteRoot
383 | GateRole::GatedRoot { .. }
384 | GateRole::GatedByDescendants => None,
385 }
386 }
387
388 pub fn gate_role(&self) -> &GateRole {
390 &self.role
391 }
392
393 pub fn audience_parent_column(&self) -> Option<&str> {
395 self.audience_parent_column.as_deref()
396 }
397
398 pub fn is_remote_root(&self) -> bool {
401 matches!(self.role, GateRole::RemoteRoot)
402 }
403
404 pub fn is_gated_by_descendants(&self) -> bool {
407 matches!(self.role, GateRole::GatedByDescendants)
408 }
409
410 pub fn blob(&self) -> Option<&BlobDecl> {
412 self.blob.as_ref()
413 }
414
415 pub fn is_asset(&self) -> bool {
418 self.asset
419 }
420}
421
422#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct BlobDecl {
430 pub id_column: String,
433 pub size_column: String,
435 pub hash_column: String,
442 pub namespace: String,
444 pub cloud_path_column: Option<String>,
448 pub scope: crate::blob::BlobScope,
450 pub provenance: crate::blob::Provenance,
454 pub fill: crate::blob::CacheFill,
458 pub replacement: crate::blob::BlobReplacement,
463}
464
465impl BlobDecl {
466 pub fn new(
472 namespace: impl Into<String>,
473 provenance: crate::blob::Provenance,
474 fill: crate::blob::CacheFill,
475 ) -> Self {
476 BlobDecl {
477 id_column: "id".to_string(),
478 size_column: "size".to_string(),
479 hash_column: "hash".to_string(),
480 namespace: namespace.into(),
481 cloud_path_column: None,
482 scope: crate::blob::BlobScope::Master,
483 provenance,
484 fill,
485 replacement: crate::blob::BlobReplacement::Replaceable,
486 }
487 }
488
489 pub fn write_once(mut self) -> Self {
496 self.replacement = crate::blob::BlobReplacement::WriteOnce;
497 self
498 }
499
500 pub fn with_id_column(mut self, column: impl Into<String>) -> Self {
502 self.id_column = column.into();
503 self
504 }
505
506 pub fn with_size_column(mut self, column: impl Into<String>) -> Self {
508 self.size_column = column.into();
509 self
510 }
511
512 pub fn with_hash_column(mut self, column: impl Into<String>) -> Self {
514 self.hash_column = column.into();
515 self
516 }
517
518 pub fn with_cloud_path_column(mut self, column: impl Into<String>) -> Self {
520 self.cloud_path_column = Some(column.into());
521 self
522 }
523
524 pub fn with_scope(mut self, scope: crate::blob::BlobScope) -> Self {
526 self.scope = scope;
527 self
528 }
529}
530
531pub(crate) fn table_columns(conn: &Connection, table: &str) -> rusqlite::Result<Vec<String>> {
535 let sql = format!("PRAGMA table_info({})", quote_ident(table));
536 let mut stmt = conn.prepare(&sql)?;
537 let columns = stmt
538 .query_map([], |row| row.get::<_, String>(1))?
539 .collect::<Result<Vec<_>, _>>()?;
540 Ok(columns)
541}
542
543pub(crate) fn quote_ident(ident: &str) -> String {
547 format!("\"{}\"", ident.replace('"', "\"\""))
548}
549
550#[derive(Debug, thiserror::Error)]
551pub(crate) enum ForeignKeySchemaError {
552 #[error(transparent)]
553 Sqlite(#[from] rusqlite::Error),
554 #[error("foreign key on {child_table:?} is malformed: {reason}")]
555 Malformed { child_table: String, reason: String },
556}
557
558#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
559pub(crate) struct ForeignKeyColumn {
560 pub(crate) child: String,
561 pub(crate) parent: String,
562}
563
564#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
565pub(crate) struct ForeignKeyEdge {
566 pub(crate) parent_table: String,
567 pub(crate) columns: Vec<ForeignKeyColumn>,
568 pub(crate) on_update: String,
569 pub(crate) on_delete: String,
570 pub(crate) match_clause: String,
571}
572
573struct ForeignKeyRow {
574 sequence: i64,
575 parent_table: String,
576 child_column: String,
577 parent_column: Option<String>,
578 on_update: String,
579 on_delete: String,
580 match_clause: String,
581}
582
583pub(crate) fn foreign_key_edges(
587 conn: &Connection,
588 child_table: &str,
589) -> Result<Vec<ForeignKeyEdge>, ForeignKeySchemaError> {
590 let sql = format!("PRAGMA foreign_key_list({})", quote_ident(child_table));
591 let mut statement = conn.prepare(&sql)?;
592 let rows = statement.query_map([], |row| {
593 Ok((
594 row.get::<_, i64>(0)?,
595 ForeignKeyRow {
596 sequence: row.get(1)?,
597 parent_table: row.get(2)?,
598 child_column: row.get(3)?,
599 parent_column: row.get(4)?,
600 on_update: row.get::<_, String>(5)?.to_ascii_uppercase(),
601 on_delete: row.get::<_, String>(6)?.to_ascii_uppercase(),
602 match_clause: row.get::<_, String>(7)?.to_ascii_uppercase(),
603 },
604 ))
605 })?;
606 let mut grouped: BTreeMap<i64, Vec<ForeignKeyRow>> = BTreeMap::new();
607 for row in rows {
608 let (id, row) = row?;
609 grouped.entry(id).or_default().push(row);
610 }
611
612 let mut edges = Vec::with_capacity(grouped.len());
613 for mut rows in grouped.into_values() {
614 rows.sort_by_key(|row| row.sequence);
615 let first = rows
616 .first()
617 .ok_or_else(|| ForeignKeySchemaError::Malformed {
618 child_table: child_table.to_string(),
619 reason: "constraint has no columns".to_string(),
620 })?;
621 if rows.iter().any(|row| {
622 row.parent_table != first.parent_table
623 || row.on_update != first.on_update
624 || row.on_delete != first.on_delete
625 || row.match_clause != first.match_clause
626 }) {
627 return Err(ForeignKeySchemaError::Malformed {
628 child_table: child_table.to_string(),
629 reason: "one constraint reports inconsistent parent or actions".to_string(),
630 });
631 }
632 let omitted_parent_columns = rows.iter().all(|row| row.parent_column.is_none());
633 if !omitted_parent_columns && rows.iter().any(|row| row.parent_column.is_none()) {
634 return Err(ForeignKeySchemaError::Malformed {
635 child_table: child_table.to_string(),
636 reason: "one constraint mixes named and omitted parent columns".to_string(),
637 });
638 }
639 let inferred_parent_columns = if omitted_parent_columns {
640 primary_key_columns(conn, &first.parent_table)?
641 } else {
642 Vec::new()
643 };
644 if omitted_parent_columns && inferred_parent_columns.len() != rows.len() {
645 return Err(ForeignKeySchemaError::Malformed {
646 child_table: child_table.to_string(),
647 reason: format!(
648 "{} child columns reference {} primary-key columns",
649 rows.len(),
650 inferred_parent_columns.len(),
651 ),
652 });
653 }
654 let columns = rows
655 .iter()
656 .enumerate()
657 .map(|(position, row)| ForeignKeyColumn {
658 child: row.child_column.clone(),
659 parent: row
660 .parent_column
661 .clone()
662 .unwrap_or_else(|| inferred_parent_columns[position].clone()),
663 })
664 .collect();
665 edges.push(ForeignKeyEdge {
666 parent_table: first.parent_table.clone(),
667 columns,
668 on_update: first.on_update.clone(),
669 on_delete: first.on_delete.clone(),
670 match_clause: first.match_clause.clone(),
671 });
672 }
673 edges.sort();
674 Ok(edges)
675}
676
677fn primary_key_columns(
678 conn: &Connection,
679 table: &str,
680) -> Result<Vec<String>, ForeignKeySchemaError> {
681 let sql = format!("PRAGMA table_info({})", quote_ident(table));
682 let mut statement = conn.prepare(&sql)?;
683 let rows = statement.query_map([], |row| {
684 Ok((row.get::<_, i64>(5)?, row.get::<_, String>(1)?))
685 })?;
686 let mut columns = rows
687 .collect::<rusqlite::Result<Vec<_>>>()?
688 .into_iter()
689 .filter(|(rank, _)| *rank > 0)
690 .collect::<Vec<_>>();
691 columns.sort_by_key(|(rank, _)| *rank);
692 Ok(columns.into_iter().map(|(_, name)| name).collect())
693}
694
695#[cfg(test)]
696mod row_identity_tests {
697 use super::*;
698
699 #[test]
700 fn independent_uuid_accepts_only_canonical_rfc_uuid_v4_or_v7() {
701 for valid in [
702 "f47ac10b-58cc-4372-a567-0e02b2c3d479",
703 "01890a5d-ac96-774b-bcce-b302099c3f74",
704 ] {
705 validate_row_identity("things", RowIdentity::IndependentUuid, valid)
706 .unwrap_or_else(|error| panic!("{valid} must be accepted: {error}"));
707 }
708
709 for invalid in [
710 "F47AC10B-58CC-4372-A567-0E02B2C3D479",
711 "f47ac10b58cc4372a5670e02b2c3d479",
712 "{f47ac10b-58cc-4372-a567-0e02b2c3d479}",
713 "urn:uuid:f47ac10b-58cc-4372-a567-0e02b2c3d479",
714 "00000000-0000-0000-0000-000000000000",
715 "f47ac10b-58cc-1372-a567-0e02b2c3d479",
716 "f47ac10b-58cc-2372-a567-0e02b2c3d479",
717 "f47ac10b-58cc-3372-a567-0e02b2c3d479",
718 "f47ac10b-58cc-5372-a567-0e02b2c3d479",
719 "f47ac10b-58cc-6372-a567-0e02b2c3d479",
720 "f47ac10b-58cc-8372-a567-0e02b2c3d479",
721 "f47ac10b-58cc-4372-0567-0e02b2c3d479",
722 "not-a-uuid",
723 ] {
724 assert!(
725 validate_row_identity("things", RowIdentity::IndependentUuid, invalid).is_err(),
726 "{invalid} must be rejected",
727 );
728 }
729
730 validate_row_identity("settings", RowIdentity::SharedKey, "preferences")
731 .expect("shared keys accept application-assigned ids");
732 }
733}