Skip to main content

safe_migrate/db/
cache.rs

1// FILE: src/db/cache.rs
2use crate::ast::identifiers::ObjectId;
3use crate::model::constraint::ConstraintState;
4use crate::model::function::FunctionState;
5use crate::model::relation::RelationState;
6use crate::model::role::RoleState;
7use crate::model::trigger::TriggerEnableMode;
8use crate::model::types::TypeState;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct ForeignKeyCache {
14    pub constraint_name: String,
15    pub from_table: ObjectId,
16    pub to_table: ObjectId,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct IndexCache {
21    pub index_id: ObjectId,
22    pub table_id: ObjectId,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TriggerCache {
27    pub trigger_id: ObjectId,
28    pub table_id: ObjectId,
29    pub function_id: ObjectId,
30    pub enabled_mode: TriggerEnableMode,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct LegacyTriggerCache {
35    pub trigger_id: ObjectId,
36    pub table_id: ObjectId,
37    pub function_id: ObjectId,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct DependencyCache {
42    pub classid: u32,
43    pub objid: u32,
44    pub objsubid: i32,
45    pub refclassid: u32,
46    pub refobjid: u32,
47    pub refobjsubid: i32,
48    pub deptype: String,
49    pub obj_schema: Option<String>,
50    pub obj_name: Option<String>,
51    pub ref_schema: Option<String>,
52    pub ref_name: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct CacheMetadata {
57    /// Seconds since the Unix epoch when `safe-migrate sync` assembled this
58    /// baseline. `None` represents a cache written before provenance support.
59    pub created_at_unix_secs: Option<u64>,
60    /// PostgreSQL database name only; connection credentials and host details
61    /// are deliberately never stored in a cache.
62    pub source_database: Option<String>,
63    /// Session role used when the cache was synchronized. This is needed to
64    /// resolve PostgreSQL's special `$user` search-path entry.
65    pub source_role: Option<String>,
66    /// `SESSION_USER` at synchronization time. This remains distinct from
67    /// `source_role` when the connection has selected another effective role.
68    pub source_session_role: Option<String>,
69    /// Parsed `search_path` setting before PostgreSQL expands `$user`.
70    pub source_search_path: Option<Vec<String>>,
71    /// Explicit schema scope passed to sync. `None` means all non-system
72    /// schemas were requested.
73    pub schemas: Option<Vec<String>>,
74}
75
76/// Metadata layout written by cache V3. Keep this byte-for-byte stable so V3
77/// remains readable after the current cache schema evolves.
78#[derive(Debug, Clone, Serialize, Deserialize, Default)]
79pub struct CacheMetadataV3 {
80    pub created_at_unix_secs: Option<u64>,
81    pub source_database: Option<String>,
82    pub schemas: Option<Vec<String>>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct DbCacheV1 {
87    pub pg_version_num: Option<u32>,
88    pub relations: HashMap<ObjectId, RelationState>,
89    #[serde(default)]
90    pub foreign_keys: Vec<ForeignKeyCache>,
91    #[serde(default)]
92    pub indexes: Vec<IndexCache>,
93    #[serde(default)]
94    pub triggers: Vec<LegacyTriggerCache>,
95    #[serde(default)]
96    pub functions: HashMap<ObjectId, FunctionState>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct DbCacheV2 {
101    pub pg_version_num: Option<u32>,
102    pub relations: HashMap<ObjectId, RelationState>,
103    pub foreign_keys: Vec<ForeignKeyCache>,
104    pub indexes: Vec<IndexCache>,
105    pub triggers: Vec<LegacyTriggerCache>,
106    pub functions: HashMap<ObjectId, FunctionState>,
107    pub dependencies: Vec<DependencyCache>,
108}
109
110/// Cache layout written by safe-migrate v0.4.3.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct DbCacheV3 {
113    pub pg_version_num: Option<u32>,
114    pub metadata: CacheMetadataV3,
115    pub search_path: Vec<String>,
116    pub relations: HashMap<ObjectId, RelationState>,
117    pub foreign_keys: Vec<ForeignKeyCache>,
118    pub indexes: Vec<IndexCache>,
119    pub constraints: Vec<ConstraintState>,
120    pub triggers: Vec<TriggerCache>,
121    pub functions: HashMap<ObjectId, FunctionState>,
122    pub types: HashMap<ObjectId, TypeState>,
123    pub dependencies: Vec<DependencyCache>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct DbCache {
128    pub pg_version_num: Option<u32>,
129    pub metadata: CacheMetadata,
130    pub search_path: Vec<String>,
131    pub relations: HashMap<ObjectId, RelationState>,
132    pub foreign_keys: Vec<ForeignKeyCache>,
133    pub indexes: Vec<IndexCache>,
134    pub constraints: Vec<ConstraintState>,
135    pub triggers: Vec<TriggerCache>,
136    pub functions: HashMap<ObjectId, FunctionState>,
137    pub types: HashMap<ObjectId, TypeState>,
138    pub roles: HashMap<ObjectId, RoleState>,
139    pub dependencies: Vec<DependencyCache>,
140}
141
142pub const CACHE_FORMAT_VERSION: u32 = 4;
143
144/// Prefixes every V3 payload after zstd decompression. Older caches did not
145/// have a payload header, so this prevents their bincode V3 discriminator from
146/// being mistaken for the redesigned V3 schema.
147pub const CACHE_V3_MAGIC: &[u8] = b"SMCACHE03";
148pub const CACHE_V4_MAGIC: &[u8] = b"SMCACHE04";
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub enum DbCacheVersioned {
152    V1(DbCacheV1),
153    V2(DbCacheV2),
154    V3(DbCacheV3),
155    V4(DbCache),
156}
157
158impl DbCacheVersioned {
159    pub fn format_version(&self) -> u32 {
160        match self {
161            DbCacheVersioned::V1(_) => 1,
162            DbCacheVersioned::V2(_) => 2,
163            DbCacheVersioned::V3(_) => 3,
164            DbCacheVersioned::V4(_) => 4,
165        }
166    }
167
168    pub fn into_cache(self) -> Result<DbCache, String> {
169        match self {
170            DbCacheVersioned::V1(_) | DbCacheVersioned::V2(_) => Err(
171                "This cache format is unsupported. Run `safe-migrate sync` to rebuild it."
172                    .to_string(),
173            ),
174            DbCacheVersioned::V3(c) => Ok(c.into()),
175            DbCacheVersioned::V4(c) => Ok(c),
176        }
177    }
178}
179
180impl From<DbCacheV3> for DbCache {
181    fn from(cache: DbCacheV3) -> Self {
182        Self {
183            pg_version_num: cache.pg_version_num,
184            metadata: CacheMetadata {
185                created_at_unix_secs: cache.metadata.created_at_unix_secs,
186                source_database: cache.metadata.source_database,
187                source_role: None,
188                source_session_role: None,
189                source_search_path: None,
190                schemas: cache.metadata.schemas,
191            },
192            search_path: cache.search_path,
193            relations: cache.relations,
194            foreign_keys: cache.foreign_keys,
195            indexes: cache.indexes,
196            constraints: cache.constraints,
197            triggers: cache.triggers,
198            functions: cache.functions,
199            types: cache.types,
200            roles: HashMap::new(),
201            dependencies: cache.dependencies,
202        }
203    }
204}
205
206impl From<DbCache> for DbCacheV3 {
207    fn from(cache: DbCache) -> Self {
208        Self {
209            pg_version_num: cache.pg_version_num,
210            metadata: CacheMetadataV3 {
211                created_at_unix_secs: cache.metadata.created_at_unix_secs,
212                source_database: cache.metadata.source_database,
213                schemas: cache.metadata.schemas,
214            },
215            search_path: cache.search_path,
216            relations: cache.relations,
217            foreign_keys: cache.foreign_keys,
218            indexes: cache.indexes,
219            constraints: cache.constraints,
220            triggers: cache.triggers,
221            functions: cache.functions,
222            types: cache.types,
223            dependencies: cache.dependencies,
224        }
225    }
226}
227
228impl Default for DbCache {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234impl DbCache {
235    pub fn new() -> Self {
236        Self {
237            pg_version_num: None,
238            metadata: CacheMetadata::default(),
239            search_path: vec!["public".to_string()],
240            relations: HashMap::new(),
241            foreign_keys: Vec::new(),
242            indexes: Vec::new(),
243            constraints: Vec::new(),
244            triggers: Vec::new(),
245            functions: HashMap::new(),
246            types: HashMap::new(),
247            roles: HashMap::new(),
248            dependencies: Vec::new(),
249        }
250    }
251
252    pub fn insert_baseline(&mut self, id: ObjectId, state: RelationState) {
253        self.relations.insert(id, state);
254    }
255
256    pub fn baseline_relations(&self) -> impl Iterator<Item = (&ObjectId, &RelationState)> {
257        self.relations.iter()
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn legacy_v1_cache_is_rejected() {
267        let cache = DbCacheV1 {
268            pg_version_num: None,
269            relations: HashMap::new(),
270            foreign_keys: Vec::new(),
271            indexes: Vec::new(),
272            triggers: Vec::new(),
273            functions: HashMap::new(),
274        };
275        let versioned = DbCacheVersioned::V1(cache);
276        assert_eq!(versioned.format_version(), 1);
277        let result = versioned.into_cache();
278        assert_eq!(
279            result.unwrap_err(),
280            "This cache format is unsupported. Run `safe-migrate sync` to rebuild it."
281        );
282    }
283
284    #[test]
285    fn current_cache_format_is_v4() {
286        assert_eq!(CACHE_FORMAT_VERSION, 4);
287        assert_eq!(DbCacheVersioned::V4(DbCache::new()).format_version(), 4);
288        assert_eq!(CACHE_V3_MAGIC, b"SMCACHE03");
289        assert_eq!(CACHE_V4_MAGIC, b"SMCACHE04");
290    }
291
292    #[test]
293    fn v3_cache_remains_readable_without_inventing_a_source_role() {
294        let v3 = DbCacheV3::from(DbCache::new());
295        let cache = DbCacheVersioned::V3(v3).into_cache().unwrap();
296        assert_eq!(cache.metadata.source_role, None);
297    }
298}