Skip to main content

coven_database/
routing_contract.rs

1//! Canonical signed contract for the schema shape that decides sync routing.
2
3use crate::query_mapped_rows;
4use std::collections::{BTreeMap, BTreeSet};
5
6use serde::{Deserialize, Serialize};
7
8use crate::foreign_key_edges;
9use coven_protocol::store_commit::ObjectHash;
10use coven_protocol::synced_schema::{GateRole, RowIdentity, SyncedTable};
11
12const SYNC_ROUTING_CONTRACT_VERSION: u32 = 1;
13
14#[derive(Debug, thiserror::Error)]
15pub enum SyncRoutingContractError {
16    #[error(transparent)]
17    Sqlite(#[from] rusqlite::Error),
18    #[error(transparent)]
19    ForeignKey(#[from] crate::ForeignKeySchemaError),
20    #[error("parse sync-routing contract: {0}")]
21    Json(#[from] serde_json::Error),
22    #[error("unsupported sync-routing contract version {0}")]
23    UnsupportedVersion(u32),
24    #[error("sync-routing contract bytes are not canonical")]
25    Noncanonical,
26    #[error("synced table {child_table:?} has a foreign key to undeclared table {parent_table:?}")]
27    UndeclaredForeignKeyTarget {
28        child_table: String,
29        parent_table: String,
30    },
31    #[error(
32        "foreign key from {child_table:?} to {parent_table:?} targets non-primary columns {columns:?} without a matching non-partial UNIQUE key and collation"
33    )]
34    MissingUniqueParentKey {
35        child_table: String,
36        parent_table: String,
37        columns: Vec<String>,
38    },
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct SyncRoutingContract {
43    bytes: Vec<u8>,
44    hash: ObjectHash,
45    has_scoped_graph: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50struct CanonicalContract {
51    version: u32,
52    tables: Vec<CanonicalTable>,
53    foreign_keys: Vec<CanonicalForeignKey>,
54    parent_unique_keys: Vec<CanonicalUniqueKey>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59struct CanonicalTable {
60    name: String,
61    row_identity: CanonicalRowIdentity,
62    role: CanonicalRole,
63    audience_parent_column: Option<String>,
64    asset: bool,
65    blob: Option<CanonicalBlob>,
66    required_columns: Vec<CanonicalColumn>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71enum CanonicalRowIdentity {
72    IndependentUuid,
73    SharedKey,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case", tag = "kind")]
78enum CanonicalRole {
79    Plain,
80    RemoteRoot,
81    GatedRoot { column: String },
82    ScopedRoot { column: String },
83    GatedByDescendants,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88struct CanonicalBlob {
89    id_column: String,
90    size_column: String,
91    hash_column: String,
92    namespace: String,
93    cloud_path_column: Option<String>,
94    scope: CanonicalBlobScope,
95    provenance: CanonicalProvenance,
96    fill: CanonicalCacheFill,
97    replacement: CanonicalBlobReplacement,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case", tag = "kind", content = "name")]
102enum CanonicalBlobScope {
103    Master,
104    Derived(String),
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109enum CanonicalProvenance {
110    UserProvided,
111    HostProvided,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116enum CanonicalCacheFill {
117    CacheEager,
118    CacheLazy,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123enum CanonicalBlobReplacement {
124    Replaceable,
125    WriteOnce,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130struct CanonicalColumn {
131    ordinal: i64,
132    name: String,
133    declared_type: String,
134    not_null: bool,
135    primary_key_rank: i64,
136    collation: String,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141struct CanonicalForeignKey {
142    child_table: String,
143    parent_table: String,
144    columns: Vec<CanonicalForeignKeyColumn>,
145    on_update: String,
146    on_delete: String,
147    match_clause: String,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152struct CanonicalForeignKeyColumn {
153    child: CanonicalColumn,
154    parent: CanonicalColumn,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159struct CanonicalUniqueKey {
160    parent_table: String,
161    columns: Vec<CanonicalUniqueKeyColumn>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166struct CanonicalUniqueKeyColumn {
167    column: CanonicalColumn,
168    index_collation: String,
169    descending: bool,
170}
171
172impl SyncRoutingContract {
173    pub(crate) fn from_connection(
174        conn: &rusqlite::Connection,
175        declarations: &[SyncedTable],
176    ) -> Result<Self, SyncRoutingContractError> {
177        let mut declarations = declarations.to_vec();
178        declarations.sort_by(|left, right| left.name().cmp(right.name()));
179        let synced_names = declarations
180            .iter()
181            .map(|table| table.name().to_string())
182            .collect::<BTreeSet<_>>();
183        let mut tables = Vec::with_capacity(declarations.len());
184        let mut foreign_keys = Vec::new();
185        let mut parent_unique_keys = BTreeSet::new();
186        for declaration in declarations {
187            tables.push(canonical_table(conn, &declaration)?);
188            let (table_foreign_keys, table_unique_keys) =
189                canonical_foreign_keys(conn, declaration.name(), &synced_names)?;
190            foreign_keys.extend(table_foreign_keys);
191            parent_unique_keys.extend(table_unique_keys);
192        }
193        foreign_keys.sort();
194        Ok(Self::from_canonical(CanonicalContract {
195            version: SYNC_ROUTING_CONTRACT_VERSION,
196            tables,
197            foreign_keys,
198            parent_unique_keys: parent_unique_keys.into_iter().collect(),
199        }))
200    }
201
202    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SyncRoutingContractError> {
203        let canonical: CanonicalContract = serde_json::from_slice(bytes)?;
204        if canonical.version != SYNC_ROUTING_CONTRACT_VERSION {
205            return Err(SyncRoutingContractError::UnsupportedVersion(
206                canonical.version,
207            ));
208        }
209        let parsed = Self::from_canonical(canonical);
210        if parsed.bytes != bytes {
211            return Err(SyncRoutingContractError::Noncanonical);
212        }
213        Ok(parsed)
214    }
215
216    fn from_canonical(canonical: CanonicalContract) -> Self {
217        let has_scoped_graph = canonical
218            .tables
219            .iter()
220            .any(|table| matches!(table.role, CanonicalRole::ScopedRoot { .. }));
221        let bytes = serde_json::to_vec(&canonical)
222            .expect("SyncRoutingContract canonical serialization cannot fail");
223        Self {
224            hash: ObjectHash::digest(&bytes),
225            bytes,
226            has_scoped_graph,
227        }
228    }
229
230    pub fn bytes(&self) -> &[u8] {
231        &self.bytes
232    }
233
234    pub fn hash(&self) -> ObjectHash {
235        self.hash
236    }
237
238    pub fn has_scoped_graph(&self) -> bool {
239        self.has_scoped_graph
240    }
241}
242
243fn canonical_table(
244    conn: &rusqlite::Connection,
245    declaration: &SyncedTable,
246) -> Result<CanonicalTable, SyncRoutingContractError> {
247    let columns = live_columns(conn, declaration.name())?;
248    let role = match declaration.gate_role() {
249        GateRole::Plain => CanonicalRole::Plain,
250        GateRole::RemoteRoot => CanonicalRole::RemoteRoot,
251        GateRole::GatedRoot { gate_column } => CanonicalRole::GatedRoot {
252            column: gate_column.clone(),
253        },
254        GateRole::ScopedRoot { audience_column } => CanonicalRole::ScopedRoot {
255            column: audience_column.clone(),
256        },
257        GateRole::GatedByDescendants => CanonicalRole::GatedByDescendants,
258    };
259    let blob = declaration.blob().map(|blob| CanonicalBlob {
260        id_column: blob.id_column.clone(),
261        size_column: blob.size_column.clone(),
262        hash_column: blob.hash_column.clone(),
263        namespace: blob.namespace.clone(),
264        cloud_path_column: blob.cloud_path_column.clone(),
265        scope: match &blob.scope {
266            coven_protocol::blob::BlobScope::Master => CanonicalBlobScope::Master,
267            coven_protocol::blob::BlobScope::Derived(name) => {
268                CanonicalBlobScope::Derived(name.clone())
269            }
270        },
271        provenance: match blob.provenance {
272            coven_protocol::blob::Provenance::UserProvided => CanonicalProvenance::UserProvided,
273            coven_protocol::blob::Provenance::HostProvided => CanonicalProvenance::HostProvided,
274        },
275        fill: match blob.fill {
276            coven_protocol::blob::CacheFill::CacheEager => CanonicalCacheFill::CacheEager,
277            coven_protocol::blob::CacheFill::CacheLazy => CanonicalCacheFill::CacheLazy,
278        },
279        replacement: match blob.replacement {
280            coven_protocol::blob::BlobReplacement::Replaceable => {
281                CanonicalBlobReplacement::Replaceable
282            }
283            coven_protocol::blob::BlobReplacement::WriteOnce => CanonicalBlobReplacement::WriteOnce,
284        },
285    });
286    let mut required_names = BTreeSet::from(["id".to_string(), "_updated_at".to_string()]);
287    if let Some(column) = declaration.gate_column() {
288        required_names.insert(column.to_string());
289    }
290    if let Some(column) = declaration.audience_column() {
291        required_names.insert(column.to_string());
292    }
293    if let Some(column) = declaration.audience_parent_column() {
294        required_names.insert(column.to_string());
295    }
296    if let Some(blob) = declaration.blob() {
297        required_names.insert(blob.id_column.clone());
298        required_names.insert(blob.size_column.clone());
299        required_names.insert(blob.hash_column.clone());
300        if let Some(column) = &blob.cloud_path_column {
301            required_names.insert(column.clone());
302        }
303    }
304    let required_columns = required_names
305        .into_iter()
306        .map(|name| {
307            columns
308                .get(&name)
309                .cloned()
310                .ok_or_else(|| rusqlite::Error::InvalidColumnName(name).into())
311        })
312        .collect::<Result<Vec<_>, SyncRoutingContractError>>()?;
313    Ok(CanonicalTable {
314        name: declaration.name().to_string(),
315        row_identity: match declaration.row_identity() {
316            RowIdentity::IndependentUuid => CanonicalRowIdentity::IndependentUuid,
317            RowIdentity::SharedKey => CanonicalRowIdentity::SharedKey,
318        },
319        role,
320        audience_parent_column: declaration.audience_parent_column().map(str::to_string),
321        asset: declaration.is_asset(),
322        blob,
323        required_columns,
324    })
325}
326
327fn canonical_foreign_keys(
328    conn: &rusqlite::Connection,
329    child_table: &str,
330    synced_names: &BTreeSet<String>,
331) -> Result<(Vec<CanonicalForeignKey>, Vec<CanonicalUniqueKey>), SyncRoutingContractError> {
332    let child_columns = live_columns(conn, child_table)?;
333    let mut foreign_keys = Vec::new();
334    let mut parent_unique_keys = BTreeSet::new();
335    for edge in foreign_key_edges(conn, child_table)? {
336        if !synced_names.contains(&edge.parent_table) {
337            return Err(SyncRoutingContractError::UndeclaredForeignKeyTarget {
338                child_table: child_table.to_string(),
339                parent_table: edge.parent_table,
340            });
341        }
342        let parent_columns = live_columns(conn, &edge.parent_table)?;
343        let columns = edge
344            .columns
345            .iter()
346            .map(|column| {
347                let child = child_columns
348                    .get(&column.child)
349                    .cloned()
350                    .ok_or_else(|| rusqlite::Error::InvalidColumnName(column.child.clone()))?;
351                let parent = parent_columns
352                    .get(&column.parent)
353                    .cloned()
354                    .ok_or_else(|| rusqlite::Error::InvalidColumnName(column.parent.clone()))?;
355                Ok(CanonicalForeignKeyColumn { child, parent })
356            })
357            .collect::<rusqlite::Result<Vec<_>>>()?;
358        let target_columns = edge
359            .columns
360            .iter()
361            .map(|column| column.parent.clone())
362            .collect::<Vec<_>>();
363        let mut primary_key = parent_columns
364            .values()
365            .filter(|column| column.primary_key_rank > 0)
366            .collect::<Vec<_>>();
367        primary_key.sort_by_key(|column| column.primary_key_rank);
368        let primary_key = primary_key
369            .into_iter()
370            .map(|column| column.name.clone())
371            .collect::<Vec<_>>();
372        if target_columns != primary_key {
373            let unique_keys = canonical_unique_parent_keys(
374                conn,
375                &edge.parent_table,
376                &target_columns,
377                &parent_columns,
378            )?;
379            if unique_keys.is_empty() {
380                return Err(SyncRoutingContractError::MissingUniqueParentKey {
381                    child_table: child_table.to_string(),
382                    parent_table: edge.parent_table,
383                    columns: target_columns,
384                });
385            }
386            parent_unique_keys.extend(unique_keys);
387        }
388        foreign_keys.push(CanonicalForeignKey {
389            child_table: child_table.to_string(),
390            parent_table: edge.parent_table,
391            columns,
392            on_update: edge.on_update,
393            on_delete: edge.on_delete,
394            match_clause: edge.match_clause,
395        });
396    }
397    foreign_keys.sort();
398    Ok((foreign_keys, parent_unique_keys.into_iter().collect()))
399}
400
401fn live_columns(
402    conn: &rusqlite::Connection,
403    table: &str,
404) -> Result<BTreeMap<String, CanonicalColumn>, SyncRoutingContractError> {
405    let sql = format!("PRAGMA table_info({})", crate::quote_ident(table));
406    let mut statement = conn.prepare(&sql)?;
407    let rows = statement.query_map([], |row| {
408        Ok((
409            row.get::<_, i64>(0)?,
410            row.get::<_, String>(1)?,
411            row.get::<_, String>(2)?,
412            row.get::<_, i64>(3)? != 0,
413            row.get::<_, i64>(5)?,
414        ))
415    })?;
416    let mut columns = BTreeMap::new();
417    for row in rows {
418        let (ordinal, name, declared_type, not_null, primary_key_rank) = row?;
419        let (_, collation, _, _, _) = conn.column_metadata(None::<&str>, table, name.as_str())?;
420        let collation = collation
421            .ok_or_else(|| rusqlite::Error::InvalidColumnName(name.clone()))?
422            .to_str()
423            .map_err(|error| rusqlite::Error::Utf8Error(0, error))?
424            .to_ascii_uppercase();
425        columns.insert(
426            name.clone(),
427            CanonicalColumn {
428                ordinal,
429                name,
430                declared_type: declared_type.to_ascii_uppercase(),
431                not_null,
432                primary_key_rank,
433                collation,
434            },
435        );
436    }
437    Ok(columns)
438}
439
440fn canonical_unique_parent_keys(
441    conn: &rusqlite::Connection,
442    parent_table: &str,
443    target_columns: &[String],
444    parent_columns: &BTreeMap<String, CanonicalColumn>,
445) -> Result<Vec<CanonicalUniqueKey>, SyncRoutingContractError> {
446    let sql = format!("PRAGMA index_list({})", crate::quote_ident(parent_table));
447    let indexes = query_mapped_rows(conn, &sql, [], |row| {
448        Ok((
449            row.get::<_, String>(1)?,
450            row.get::<_, i64>(2)? != 0,
451            row.get::<_, i64>(4)? != 0,
452        ))
453    })?;
454    let mut keys = BTreeSet::new();
455    for (index_name, unique, partial) in indexes {
456        if !unique || partial {
457            continue;
458        }
459        let sql = format!("PRAGMA index_xinfo({})", crate::quote_ident(&index_name));
460        let rows = query_mapped_rows(conn, &sql, [], |row| {
461            Ok((
462                row.get::<_, i64>(0)?,
463                row.get::<_, Option<String>>(2)?,
464                row.get::<_, i64>(3)? != 0,
465                row.get::<_, Option<String>>(4)?,
466                row.get::<_, i64>(5)? != 0,
467            ))
468        })?;
469        let mut index_columns = rows
470            .into_iter()
471            .filter(|(_, _, _, _, key)| *key)
472            .collect::<Vec<_>>();
473        index_columns.sort_by_key(|(sequence, _, _, _, _)| *sequence);
474        let Some(names) = index_columns
475            .iter()
476            .map(|(_, name, _, _, _)| name.clone())
477            .collect::<Option<Vec<_>>>()
478        else {
479            continue;
480        };
481        if names != target_columns {
482            continue;
483        }
484        let mut columns = Vec::with_capacity(index_columns.len());
485        let mut usable = true;
486        for ((_, _, descending, index_collation, _), name) in index_columns.into_iter().zip(names) {
487            let column = parent_columns
488                .get(&name)
489                .cloned()
490                .ok_or_else(|| rusqlite::Error::InvalidColumnName(name.clone()))?;
491            let index_collation = index_collation
492                .ok_or_else(|| rusqlite::Error::InvalidColumnName(name.clone()))?
493                .to_ascii_uppercase();
494            if index_collation != column.collation {
495                usable = false;
496            }
497            columns.push(CanonicalUniqueKeyColumn {
498                column,
499                index_collation,
500                descending,
501            });
502        }
503        if usable {
504            keys.insert(CanonicalUniqueKey {
505                parent_table: parent_table.to_string(),
506                columns,
507            });
508        }
509    }
510    Ok(keys.into_iter().collect())
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    fn schema() -> rusqlite::Connection {
518        let conn = rusqlite::Connection::open_in_memory().expect("open");
519        conn.execute_batch(
520            "PRAGMA foreign_keys = ON;
521             CREATE TABLE parents (
522                id TEXT PRIMARY KEY,
523                ordinary TEXT DEFAULT 'first',
524                audience TEXT,
525                _updated_at TEXT NOT NULL
526             ) STRICT;
527             CREATE INDEX parents_ordinary ON parents(ordinary);
528             CREATE TABLE children (
529                id TEXT PRIMARY KEY,
530                parent_id TEXT NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
531                ordinary INTEGER,
532                _updated_at TEXT NOT NULL
533             ) STRICT;",
534        )
535        .expect("schema");
536        conn
537    }
538
539    fn declarations() -> Vec<SyncedTable> {
540        vec![
541            SyncedTable::new("children", RowIdentity::IndependentUuid)
542                .inherits_audience_through("parent_id"),
543            SyncedTable::new("parents", RowIdentity::IndependentUuid).scoped_by("audience"),
544        ]
545    }
546
547    #[test]
548    fn canonical_hash_binds_routing_declarations_and_synced_foreign_keys() {
549        let conn = schema();
550        let declarations = declarations();
551        let contract =
552            SyncRoutingContract::from_connection(&conn, &declarations).expect("routing contract");
553        let reversed = SyncRoutingContract::from_connection(
554            &conn,
555            &declarations.iter().cloned().rev().collect::<Vec<_>>(),
556        )
557        .expect("reordered contract");
558        assert_eq!(contract.bytes(), reversed.bytes());
559        assert_eq!(contract.hash(), reversed.hash());
560        assert!(contract.has_scoped_graph());
561        assert_eq!(
562            SyncRoutingContract::from_bytes(contract.bytes()).expect("parse exact contract"),
563            contract,
564        );
565
566        let changed = vec![
567            SyncedTable::new("children", RowIdentity::SharedKey)
568                .inherits_audience_through("parent_id"),
569            declarations[1].clone(),
570        ];
571        assert_ne!(
572            contract.hash(),
573            SyncRoutingContract::from_connection(&conn, &changed)
574                .expect("changed contract")
575                .hash()
576        );
577    }
578
579    #[test]
580    fn ordinary_columns_indexes_defaults_and_local_tables_do_not_change_the_hash() {
581        let conn = schema();
582        let declarations = declarations();
583        let before = SyncRoutingContract::from_connection(&conn, &declarations)
584            .expect("routing contract before ordinary migration");
585        conn.execute_batch(
586            "ALTER TABLE parents ADD COLUMN later TEXT DEFAULT 'second';
587             CREATE INDEX children_ordinary ON children(ordinary);
588             CREATE TABLE local_notes (id TEXT PRIMARY KEY) STRICT;",
589        )
590        .expect("ordinary migration");
591        let after = SyncRoutingContract::from_connection(&conn, &declarations)
592            .expect("routing contract after ordinary migration");
593        assert_eq!(before.bytes(), after.bytes());
594        assert_eq!(before.hash(), after.hash());
595    }
596
597    #[test]
598    fn noncanonical_or_unknown_contract_bytes_are_rejected() {
599        let contract = SyncRoutingContract::from_connection(&schema(), &declarations())
600            .expect("routing contract");
601        let mut value: serde_json::Value =
602            serde_json::from_slice(contract.bytes()).expect("parse contract json");
603        value
604            .as_object_mut()
605            .expect("contract object")
606            .insert("unknown".to_string(), serde_json::Value::Bool(true));
607        assert!(SyncRoutingContract::from_bytes(&serde_json::to_vec(&value).unwrap()).is_err());
608
609        let pretty = serde_json::to_vec_pretty(
610            &serde_json::from_slice::<serde_json::Value>(contract.bytes()).unwrap(),
611        )
612        .unwrap();
613        assert!(SyncRoutingContract::from_bytes(&pretty).is_err());
614    }
615
616    #[test]
617    fn required_column_ordinal_changes_the_contract() {
618        fn contract(parent_columns: &str) -> SyncRoutingContract {
619            let conn = rusqlite::Connection::open_in_memory().expect("open");
620            conn.execute_batch(&format!(
621                "PRAGMA foreign_keys = ON;
622                 CREATE TABLE parents ({parent_columns}) STRICT;
623                 CREATE TABLE children (
624                    id TEXT PRIMARY KEY,
625                    parent_id TEXT NOT NULL REFERENCES parents(id),
626                    _updated_at TEXT NOT NULL
627                 ) STRICT;"
628            ))
629            .expect("schema");
630            SyncRoutingContract::from_connection(&conn, &declarations()).expect("contract")
631        }
632
633        let before = contract(
634            "id TEXT PRIMARY KEY,
635             ordinary TEXT,
636             audience TEXT,
637             _updated_at TEXT NOT NULL",
638        );
639        let after = contract(
640            "ordinary TEXT,
641             id TEXT PRIMARY KEY,
642             audience TEXT,
643             _updated_at TEXT NOT NULL",
644        );
645        assert_ne!(before.hash(), after.hash());
646    }
647
648    #[test]
649    fn routing_column_collation_changes_the_contract() {
650        fn contract(audience: &str) -> SyncRoutingContract {
651            let conn = rusqlite::Connection::open_in_memory().expect("open");
652            conn.execute_batch(&format!(
653                "PRAGMA foreign_keys = ON;
654                 CREATE TABLE parents (
655                    id TEXT PRIMARY KEY,
656                    {audience},
657                    _updated_at TEXT NOT NULL
658                 ) STRICT;
659                 CREATE TABLE children (
660                    id TEXT PRIMARY KEY,
661                    parent_id TEXT NOT NULL REFERENCES parents(id),
662                    _updated_at TEXT NOT NULL
663                 ) STRICT;"
664            ))
665            .expect("schema");
666            SyncRoutingContract::from_connection(&conn, &declarations()).expect("contract")
667        }
668
669        let binary = contract("audience TEXT COLLATE BINARY");
670        let no_case = contract("audience TEXT COLLATE NOCASE");
671        assert_ne!(binary.hash(), no_case.hash());
672    }
673
674    #[test]
675    fn synced_foreign_key_to_local_table_is_rejected() {
676        let conn = rusqlite::Connection::open_in_memory().expect("open");
677        conn.execute_batch(
678            "CREATE TABLE local_parents (id TEXT PRIMARY KEY) STRICT;
679             CREATE TABLE children (
680                id TEXT PRIMARY KEY,
681                local_parent_id TEXT NOT NULL REFERENCES local_parents(id),
682                _updated_at TEXT NOT NULL
683             ) STRICT;",
684        )
685        .expect("schema");
686
687        let error = SyncRoutingContract::from_connection(
688            &conn,
689            &[SyncedTable::new("children", RowIdentity::IndependentUuid)],
690        )
691        .expect_err("synced-to-local foreign key must be rejected");
692        assert!(matches!(
693            error,
694            SyncRoutingContractError::UndeclaredForeignKeyTarget {
695                child_table,
696                parent_table,
697            } if child_table == "children" && parent_table == "local_parents"
698        ));
699    }
700
701    #[test]
702    fn non_primary_foreign_key_requires_matching_unique_parent_key() {
703        fn connection(index: &str) -> rusqlite::Connection {
704            let conn = rusqlite::Connection::open_in_memory().expect("open");
705            conn.execute_batch(&format!(
706                "PRAGMA foreign_keys = ON;
707                 CREATE TABLE parents (
708                    id TEXT PRIMARY KEY,
709                    code TEXT COLLATE NOCASE NOT NULL,
710                    _updated_at TEXT NOT NULL
711                 ) STRICT;
712                 {index}
713                 CREATE TABLE children (
714                    id TEXT PRIMARY KEY,
715                    parent_code TEXT NOT NULL,
716                    _updated_at TEXT NOT NULL,
717                    FOREIGN KEY (parent_code) REFERENCES parents(code)
718                 ) STRICT;"
719            ))
720            .expect("schema");
721            conn
722        }
723        let declarations = vec![
724            SyncedTable::new("parents", RowIdentity::IndependentUuid),
725            SyncedTable::new("children", RowIdentity::IndependentUuid),
726        ];
727
728        SyncRoutingContract::from_connection(
729            &connection("CREATE UNIQUE INDEX parents_code ON parents(code COLLATE NOCASE);"),
730            &declarations,
731        )
732        .expect("matching structural unique key");
733        for index in [
734            "",
735            "CREATE UNIQUE INDEX parents_code ON parents(code COLLATE BINARY);",
736        ] {
737            let error = SyncRoutingContract::from_connection(&connection(index), &declarations)
738                .expect_err("missing or changed unique key must be rejected");
739            assert!(matches!(
740                error,
741                SyncRoutingContractError::MissingUniqueParentKey { .. }
742            ));
743        }
744    }
745
746    #[test]
747    fn foreign_key_declaration_order_does_not_change_the_contract() {
748        fn contract(foreign_keys: &str) -> SyncRoutingContract {
749            let conn = rusqlite::Connection::open_in_memory().expect("open");
750            conn.execute_batch(&format!(
751                "CREATE TABLE left_parents (
752                    id TEXT PRIMARY KEY,
753                    _updated_at TEXT NOT NULL
754                 ) STRICT;
755                 CREATE TABLE right_parents (
756                    id TEXT PRIMARY KEY,
757                    _updated_at TEXT NOT NULL
758                 ) STRICT;
759                 CREATE TABLE children (
760                    id TEXT PRIMARY KEY,
761                    left_id TEXT NOT NULL,
762                    right_id TEXT NOT NULL,
763                    _updated_at TEXT NOT NULL,
764                    {foreign_keys}
765                 ) STRICT;"
766            ))
767            .expect("schema");
768            SyncRoutingContract::from_connection(
769                &conn,
770                &[
771                    SyncedTable::new("left_parents", RowIdentity::IndependentUuid),
772                    SyncedTable::new("right_parents", RowIdentity::IndependentUuid),
773                    SyncedTable::new("children", RowIdentity::IndependentUuid),
774                ],
775            )
776            .expect("contract")
777        }
778
779        let left_then_right = contract(
780            "FOREIGN KEY (left_id) REFERENCES left_parents(id) ON DELETE CASCADE,
781             FOREIGN KEY (right_id) REFERENCES right_parents(id) ON UPDATE CASCADE",
782        );
783        let right_then_left = contract(
784            "FOREIGN KEY (right_id) REFERENCES right_parents(id) ON UPDATE CASCADE,
785             FOREIGN KEY (left_id) REFERENCES left_parents(id) ON DELETE CASCADE",
786        );
787        assert_eq!(left_then_right.bytes(), right_then_left.bytes());
788    }
789}