Skip to main content

safe_migrate/db/
cache.rs

1use crate::ast::identifiers::ObjectId;
2use crate::model::constraint::ConstraintState;
3use crate::model::function::FunctionState;
4use crate::model::relation::{RelationKind, RelationState};
5use crate::model::replication::{PublicationState, SubscriptionState};
6use crate::model::role::RoleState;
7use crate::model::schema::SchemaState;
8use crate::model::sequence::SequenceState;
9use crate::model::trigger::TriggerEnableMode;
10use crate::model::types::TypeState;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct ForeignKeyCache {
16    pub constraint_name: String,
17    pub from_table: ObjectId,
18    pub to_table: ObjectId,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct IndexCache {
23    pub index_id: ObjectId,
24    pub table_id: ObjectId,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct TriggerCache {
29    pub trigger_id: ObjectId,
30    pub table_id: ObjectId,
31    pub function_id: ObjectId,
32    pub enabled_mode: TriggerEnableMode,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct DependencyCache {
37    pub classid: u32,
38    pub objid: u32,
39    pub objsubid: i32,
40    pub refclassid: u32,
41    pub refobjid: u32,
42    pub refobjsubid: i32,
43    pub deptype: String,
44    pub obj_schema: Option<String>,
45    pub obj_name: Option<String>,
46    pub ref_schema: Option<String>,
47    pub ref_name: Option<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
51pub struct CacheMetadata {
52    /// Seconds since the Unix epoch when `safe-migrate sync` assembled this
53    /// baseline. `None` represents a cache written before provenance support.
54    pub created_at_unix_secs: Option<u64>,
55    /// PostgreSQL database name only; connection credentials and host details
56    /// are deliberately never stored in a cache.
57    pub source_database: Option<String>,
58    /// Session role used when the cache was synchronized. This is needed to
59    /// resolve PostgreSQL's special `$user` search-path entry.
60    pub source_role: Option<String>,
61    /// `SESSION_USER` at synchronization time. This remains distinct from
62    /// `source_role` when the connection has selected another effective role.
63    pub source_session_role: Option<String>,
64    /// Parsed `search_path` setting before PostgreSQL expands `$user`.
65    pub source_search_path: Option<Vec<String>>,
66    /// Effective `lock_timeout` observed on the fresh synchronization
67    /// connection, normalized to milliseconds. PostgreSQL uses zero to mean
68    /// that the timeout is disabled.
69    pub source_lock_timeout_ms: u64,
70    /// Effective `statement_timeout` observed on the fresh synchronization
71    /// connection, normalized to milliseconds. PostgreSQL uses zero to mean
72    /// that the timeout is disabled.
73    pub source_statement_timeout_ms: u64,
74    /// Explicit schema scope passed to sync. `None` means all non-system
75    /// schemas were requested.
76    pub schemas: Option<Vec<String>>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct DbCache {
81    pub pg_version_num: Option<u32>,
82    pub metadata: CacheMetadata,
83    pub search_path: Vec<String>,
84    pub relations: HashMap<ObjectId, RelationState>,
85    pub foreign_keys: Vec<ForeignKeyCache>,
86    pub indexes: Vec<IndexCache>,
87    pub constraints: Vec<ConstraintState>,
88    pub triggers: Vec<TriggerCache>,
89    pub functions: HashMap<ObjectId, FunctionState>,
90    pub types: HashMap<ObjectId, TypeState>,
91    pub roles: HashMap<ObjectId, RoleState>,
92    pub schemas: HashMap<String, SchemaState>,
93    pub sequences: HashMap<ObjectId, SequenceState>,
94    pub dependencies: Vec<DependencyCache>,
95    pub publications: HashMap<String, PublicationState>,
96    pub subscriptions: HashMap<String, SubscriptionState>,
97}
98
99pub const CACHE_FORMAT_VERSION: u32 = 6;
100
101pub const CACHE_V6_MAGIC: &[u8] = b"SMCACHE06";
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub enum DbCacheVersioned {
105    // Unit variants reserve the historic bincode discriminants. The reader
106    // rejects non-V6 headers before decoding, so legacy layouts are not part
107    // of the production model and cannot be converted accidentally.
108    V1,
109    V2,
110    V3,
111    V4,
112    V5(Box<DbCache>),
113    V6(Box<DbCache>),
114}
115
116impl DbCacheVersioned {
117    pub fn format_version(&self) -> u32 {
118        match self {
119            DbCacheVersioned::V1 => 1,
120            DbCacheVersioned::V2 => 2,
121            DbCacheVersioned::V3 => 3,
122            DbCacheVersioned::V4 => 4,
123            DbCacheVersioned::V5(_) => 5,
124            DbCacheVersioned::V6(_) => 6,
125        }
126    }
127
128    pub fn into_cache(self) -> Result<DbCache, String> {
129        match self {
130            DbCacheVersioned::V1
131            | DbCacheVersioned::V2
132            | DbCacheVersioned::V3
133            | DbCacheVersioned::V4
134            | DbCacheVersioned::V5(_) => Err(
135                "This cache format is unsupported. Run `safe-migrate sync` to rebuild it."
136                    .to_string(),
137            ),
138            DbCacheVersioned::V6(c) => {
139                c.validate_semantics()?;
140                Ok(*c)
141            }
142        }
143    }
144}
145
146impl Default for DbCache {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152impl DbCache {
153    pub fn new() -> Self {
154        Self {
155            pg_version_num: None,
156            metadata: CacheMetadata::default(),
157            search_path: vec!["public".to_string()],
158            relations: HashMap::new(),
159            foreign_keys: Vec::new(),
160            indexes: Vec::new(),
161            constraints: Vec::new(),
162            triggers: Vec::new(),
163            functions: HashMap::new(),
164            types: HashMap::new(),
165            roles: HashMap::new(),
166            schemas: HashMap::new(),
167            sequences: HashMap::new(),
168            dependencies: Vec::new(),
169            publications: HashMap::new(),
170            subscriptions: HashMap::new(),
171        }
172    }
173
174    pub fn insert_baseline(&mut self, id: ObjectId, state: RelationState) {
175        self.relations.insert(id, state);
176    }
177
178    pub fn baseline_relations(&self) -> impl Iterator<Item = (&ObjectId, &RelationState)> {
179        self.relations.iter()
180    }
181
182    pub(crate) fn validate_semantics(&self) -> Result<(), String> {
183        for (id, relation) in &self.relations {
184            if id != &relation.id {
185                return Err(format!(
186                    "relation cache key '{}' disagrees with embedded identity '{}'",
187                    id, relation.id
188                ));
189            }
190        }
191        for (id, function) in &self.functions {
192            if id != &function.id {
193                return Err(format!(
194                    "routine cache key '{}' disagrees with embedded identity '{}'",
195                    id, function.id
196                ));
197            }
198        }
199        for (id, ty) in &self.types {
200            if id != &ty.id {
201                return Err(format!(
202                    "type cache key '{}' disagrees with embedded identity '{}'",
203                    id, ty.id
204                ));
205            }
206        }
207        for (id, role) in &self.roles {
208            if id != &role.id {
209                return Err(format!(
210                    "role cache key '{}' disagrees with embedded identity '{}'",
211                    id, role.id
212                ));
213            }
214        }
215        for (name, schema) in &self.schemas {
216            if name != &schema.name {
217                return Err(format!(
218                    "schema cache key '{}' disagrees with embedded identity '{}'",
219                    name, schema.name
220                ));
221            }
222        }
223        for (id, sequence) in &self.sequences {
224            if id != &sequence.id {
225                return Err(format!(
226                    "sequence cache key '{}' disagrees with embedded identity '{}'",
227                    id, sequence.id
228                ));
229            }
230        }
231        for (name, publication) in &self.publications {
232            if name != &publication.name {
233                return Err(format!(
234                    "publication cache key '{}' disagrees with embedded identity '{}'",
235                    name, publication.name
236                ));
237            }
238        }
239        for (name, subscription) in &self.subscriptions {
240            if name != &subscription.name {
241                return Err(format!(
242                    "subscription cache key '{}' disagrees with embedded identity '{}'",
243                    name, subscription.name
244                ));
245            }
246        }
247
248        for schema in &self.search_path {
249            if !self.schemas.is_empty() && !self.schemas.contains_key(schema) {
250                return Err(format!(
251                    "effective search path references missing schema '{}'",
252                    schema
253                ));
254            }
255        }
256
257        for (id, sequence) in &self.sequences {
258            if let Some((table_id, column_name)) = &sequence.owned_by {
259                let Some(relation) = self.relations.get(table_id) else {
260                    let omitted_owner_schema =
261                        self.metadata.schemas.as_ref().is_some_and(|schemas| {
262                            !schemas.iter().any(|schema| schema == &table_id.schema)
263                        });
264                    if omitted_owner_schema {
265                        continue;
266                    }
267                    return Err(format!(
268                        "sequence '{}' ownership references missing relation '{}'",
269                        id, table_id
270                    ));
271                };
272                if !matches!(relation.kind, RelationKind::Table) {
273                    return Err(format!(
274                        "sequence '{}' ownership target '{}' is not a table",
275                        id, table_id
276                    ));
277                }
278                if !relation.has_column(column_name) {
279                    return Err(format!(
280                        "sequence '{}' ownership references missing column '{}.{}'",
281                        id, table_id, column_name
282                    ));
283                }
284            }
285        }
286
287        for (id, role) in &self.roles {
288            for target in role.member_of.iter().chain(&role.can_set_role_to) {
289                if !self.roles.contains_key(target) {
290                    return Err(format!(
291                        "role '{}' membership references missing role '{}'",
292                        id, target
293                    ));
294                }
295            }
296        }
297
298        let mut constraint_keys = HashSet::new();
299        for constraint in &self.constraints {
300            let Some(relation) = self.relations.get(&constraint.table_id) else {
301                return Err(format!(
302                    "constraint '{}.{}' references a missing relation",
303                    constraint.table_id, constraint.name
304                ));
305            };
306            if !matches!(relation.kind, RelationKind::Table) {
307                return Err(format!(
308                    "constraint '{}.{}' targets a non-table relation",
309                    constraint.table_id, constraint.name
310                ));
311            }
312            if !constraint_keys.insert((constraint.table_id.clone(), constraint.name.clone())) {
313                return Err(format!(
314                    "constraint '{}.{}' appears more than once",
315                    constraint.table_id, constraint.name
316                ));
317            }
318        }
319
320        let mut index_ids = HashSet::new();
321        for index in &self.indexes {
322            let Some(relation) = self.relations.get(&index.table_id) else {
323                return Err(format!(
324                    "index '{}' references missing relation '{}'",
325                    index.index_id, index.table_id
326                ));
327            };
328            if !matches!(
329                relation.kind,
330                RelationKind::Table | RelationKind::MaterializedView
331            ) {
332                return Err(format!(
333                    "index '{}' targets a non-indexable relation '{}'",
334                    index.index_id, index.table_id
335                ));
336            }
337            if !index_ids.insert(index.index_id.clone()) {
338                return Err(format!("index '{}' appears more than once", index.index_id));
339            }
340        }
341
342        let mut trigger_ids = HashSet::new();
343        for trigger in &self.triggers {
344            let Some(relation) = self.relations.get(&trigger.table_id) else {
345                return Err(format!(
346                    "trigger '{}' references missing relation '{}'",
347                    trigger.trigger_id, trigger.table_id
348                ));
349            };
350            if !matches!(relation.kind, RelationKind::Table | RelationKind::View) {
351                return Err(format!(
352                    "trigger '{}' targets a relation kind that cannot have triggers",
353                    trigger.trigger_id
354                ));
355            }
356            if !trigger_ids.insert(trigger.trigger_id.clone()) {
357                return Err(format!(
358                    "trigger '{}' appears more than once",
359                    trigger.trigger_id
360                ));
361            }
362        }
363
364        let mut foreign_key_ids = HashSet::new();
365        for foreign_key in &self.foreign_keys {
366            let Some(from_relation) = self.relations.get(&foreign_key.from_table) else {
367                return Err(format!(
368                    "foreign key '{}.{}' references a missing relation",
369                    foreign_key.from_table, foreign_key.constraint_name
370                ));
371            };
372            let Some(to_relation) = self.relations.get(&foreign_key.to_table) else {
373                return Err(format!(
374                    "foreign key '{}.{}' references a missing relation",
375                    foreign_key.from_table, foreign_key.constraint_name
376                ));
377            };
378            if !matches!(from_relation.kind, RelationKind::Table)
379                || !matches!(to_relation.kind, RelationKind::Table)
380            {
381                return Err(format!(
382                    "foreign key '{}.{}' must reference tables",
383                    foreign_key.from_table, foreign_key.constraint_name
384                ));
385            }
386            if !foreign_key_ids.insert((
387                foreign_key.from_table.clone(),
388                foreign_key.constraint_name.clone(),
389            )) {
390                return Err(format!(
391                    "foreign key '{}.{}' appears more than once",
392                    foreign_key.from_table, foreign_key.constraint_name
393                ));
394            }
395            if !self.constraints.iter().any(|constraint| {
396                constraint.table_id == foreign_key.from_table
397                    && constraint.name == foreign_key.constraint_name
398                    && matches!(
399                        constraint.kind,
400                        crate::model::constraint::ConstraintKind::ForeignKey
401                    )
402            }) {
403                return Err(format!(
404                    "foreign key '{}.{}' has no matching constraint",
405                    foreign_key.from_table, foreign_key.constraint_name
406                ));
407            }
408        }
409
410        for dependency in &self.dependencies {
411            if dependency.deptype != "view" {
412                // Early Cache V6 writers included generic pg_depend rows.
413                // Hydration never consumed them, but retaining readability
414                // is part of the V6 byte-compatibility contract.
415                continue;
416            }
417            if dependency
418                .obj_schema
419                .as_deref()
420                .is_some_and(|schema| schema == "information_schema" || schema.starts_with("pg_"))
421                || dependency.ref_schema.as_deref().is_some_and(|schema| {
422                    schema == "information_schema" || schema.starts_with("pg_")
423                })
424            {
425                // Early V6 writers also retained system-catalog view
426                // dependencies while deliberately omitting those relations.
427                continue;
428            }
429            let object_id = dependency
430                .obj_schema
431                .as_deref()
432                .zip(dependency.obj_name.as_deref())
433                .map(|(schema, name)| ObjectId::new(schema, name))
434                .ok_or_else(|| "view dependency is missing its object identity".to_string())?;
435            let referenced_id = dependency
436                .ref_schema
437                .as_deref()
438                .zip(dependency.ref_name.as_deref())
439                .map(|(schema, name)| ObjectId::new(schema, name))
440                .ok_or_else(|| {
441                    format!(
442                        "view dependency for '{}' is missing its referenced identity",
443                        object_id
444                    )
445                })?;
446            let omitted_schema = |schema: Option<&str>| {
447                self.metadata
448                    .schemas
449                    .as_ref()
450                    .zip(schema)
451                    .is_some_and(|(schemas, schema)| !schemas.iter().any(|known| known == schema))
452            };
453            let object_missing = !self.relations.contains_key(&object_id);
454            let referenced_missing = !self.relations.contains_key(&referenced_id);
455            if (object_missing && !omitted_schema(dependency.obj_schema.as_deref()))
456                || (referenced_missing && !omitted_schema(dependency.ref_schema.as_deref()))
457            {
458                return Err(format!(
459                    "view dependency '{} -> {}' references a missing relation",
460                    object_id, referenced_id
461                ));
462            }
463        }
464
465        Ok(())
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn every_legacy_cache_variant_is_rejected_generically() {
475        for (versioned, expected_version) in [
476            (DbCacheVersioned::V1, 1),
477            (DbCacheVersioned::V2, 2),
478            (DbCacheVersioned::V3, 3),
479            (DbCacheVersioned::V4, 4),
480        ] {
481            assert_eq!(versioned.format_version(), expected_version);
482            assert_eq!(
483                versioned.into_cache().unwrap_err(),
484                "This cache format is unsupported. Run `safe-migrate sync` to rebuild it."
485            );
486        }
487        let v5 = DbCacheVersioned::V5(Box::default());
488        assert_eq!(v5.format_version(), 5);
489        assert_eq!(
490            v5.into_cache().unwrap_err(),
491            "This cache format is unsupported. Run `safe-migrate sync` to rebuild it."
492        );
493    }
494
495    #[test]
496    fn current_cache_format_is_v6() {
497        assert_eq!(CACHE_FORMAT_VERSION, 6);
498        assert_eq!(DbCacheVersioned::V6(Box::default()).format_version(), 6);
499        assert_eq!(CACHE_V6_MAGIC, b"SMCACHE06");
500    }
501
502    #[test]
503    fn current_cache_rejects_mismatched_embedded_identity() {
504        let mut cache = DbCache::new();
505        cache.schemas.insert(
506            "app".to_string(),
507            SchemaState {
508                name: "other".to_string(),
509                owner: ObjectId::new("", "postgres"),
510                generation: 0,
511            },
512        );
513
514        let error = DbCacheVersioned::V6(Box::new(cache))
515            .into_cache()
516            .unwrap_err();
517        assert!(error.contains("schema cache key 'app'"));
518    }
519
520    #[test]
521    fn scoped_cache_accepts_a_dependency_to_an_omitted_schema() {
522        let view_id = ObjectId::new("app", "v");
523        let mut cache = DbCache::new();
524        cache.metadata.schemas = Some(vec!["app".to_string()]);
525        cache.insert_baseline(
526            view_id.clone(),
527            RelationState::new(
528                view_id.clone(),
529                ObjectId::new("", "postgres"),
530                0,
531                None,
532                crate::model::relation::RelationKind::View,
533                crate::model::relation::Persistence::Permanent,
534                0,
535            ),
536        );
537        cache.dependencies.push(DependencyCache {
538            classid: 0,
539            objid: 0,
540            objsubid: 0,
541            refclassid: 0,
542            refobjid: 0,
543            refobjsubid: 0,
544            deptype: "view".to_string(),
545            obj_schema: Some("app".to_string()),
546            obj_name: Some("v".to_string()),
547            ref_schema: Some("tenant".to_string()),
548            ref_name: Some("base".to_string()),
549        });
550
551        assert!(cache.validate_semantics().is_ok());
552    }
553
554    #[test]
555    fn current_cache_rejects_dangling_index_relationship() {
556        let mut cache = DbCache::new();
557        cache.indexes.push(IndexCache {
558            index_id: ObjectId::new("public", "items_idx"),
559            table_id: ObjectId::new("public", "items"),
560        });
561
562        let error = DbCacheVersioned::V6(Box::new(cache))
563            .into_cache()
564            .unwrap_err();
565        assert!(error.contains("references missing relation 'public.items'"));
566    }
567
568    #[test]
569    fn current_cache_rejects_cross_catalog_contradictions() {
570        let mut missing_search_schema = DbCache::new();
571        missing_search_schema.schemas.insert(
572            "app".to_string(),
573            SchemaState {
574                name: "app".to_string(),
575                owner: ObjectId::new("", "postgres"),
576                generation: 0,
577            },
578        );
579        assert!(
580            missing_search_schema
581                .validate_semantics()
582                .unwrap_err()
583                .contains("search path references missing schema 'public'")
584        );
585
586        let mut missing_sequence_owner = DbCache::new();
587        let sequence_id = ObjectId::new("public", "items_id_seq");
588        missing_sequence_owner.sequences.insert(
589            sequence_id.clone(),
590            SequenceState {
591                id: sequence_id,
592                owner: ObjectId::new("", "postgres"),
593                owned_by: Some((ObjectId::new("public", "items"), "id".to_string())),
594                kind: crate::model::sequence::SequenceKind::Owned,
595                generation: 0,
596            },
597        );
598        assert!(
599            missing_sequence_owner
600                .validate_semantics()
601                .unwrap_err()
602                .contains("ownership references missing relation 'public.items'")
603        );
604
605        let mut missing_membership_role = DbCache::new();
606        let role_id = ObjectId::new("", "member");
607        missing_membership_role.roles.insert(
608            role_id.clone(),
609            RoleState {
610                id: role_id,
611                can_login: true,
612                is_superuser: false,
613                member_of: vec![ObjectId::new("", "missing")],
614                can_set_role_to: Vec::new(),
615                granted_privileges: Vec::new(),
616            },
617        );
618        assert!(
619            missing_membership_role
620                .validate_semantics()
621                .unwrap_err()
622                .contains("membership references missing role")
623        );
624
625        let mut incomplete_view_dependency = DbCache::new();
626        incomplete_view_dependency
627            .dependencies
628            .push(DependencyCache {
629                classid: 0,
630                objid: 0,
631                objsubid: 0,
632                refclassid: 0,
633                refobjid: 0,
634                refobjsubid: 0,
635                deptype: "view".to_string(),
636                obj_schema: None,
637                obj_name: None,
638                ref_schema: None,
639                ref_name: None,
640            });
641        assert!(
642            incomplete_view_dependency
643                .validate_semantics()
644                .unwrap_err()
645                .contains("view dependency is missing its object identity")
646        );
647    }
648}