Skip to main content

safe_migrate/
sync.rs

1// FILE: src/sync.rs
2
3use crate::ast::identifiers::ObjectId;
4use crate::db::cache::{DbCache, ForeignKeyCache, IndexCache};
5use crate::model::relation::{Persistence, RelationKind, RelationState};
6use anyhow::{Context, Result};
7use postgres::{Client, NoTls};
8use std::fs;
9use std::path::Path;
10
11pub fn sync_cache(out_path: &Path, schemas: Option<&[String]>) -> Result<()> {
12    // Strict env-only credential enforcement
13    let db_url = std::env::var("DATABASE_URL")
14        .context("DATABASE_URL environment variable is required to sync database stats. Do not pass credentials via CLI flags or config files.")?;
15
16    // Destructive cache removal prevents corrupted reads on failures
17    if out_path.exists() {
18        fs::remove_file(out_path).context("Failed to remove old cache file before sync")?;
19    }
20
21    // Warn if connecting to a non-local host without TLS
22    let host = db_url
23        .split('@')
24        .nth(1)
25        .and_then(|h| h.split('/').next())
26        .unwrap_or("localhost");
27    if !host.starts_with("localhost")
28        && !host.starts_with("127.")
29        && !host.starts_with("/")
30        && host != "::1"
31    {
32        eprintln!(
33            "[WARN] Connecting to PostgreSQL at {} without TLS encryption.\n\
34             The database password will be sent in cleartext over the network.\n\
35             Use an SSH tunnel or a local connection for sensitive databases,\n\
36             or add native-tls support (see https://github.com/dsecurity49/safe-migrate).",
37            host
38        );
39    }
40
41    let mut client = Client::connect(&db_url, NoTls).context("Failed to connect to PostgreSQL")?;
42
43    let mut cache = DbCache::new();
44
45    let schema_filter = if let Some(s) = schemas {
46        format!("AND n.nspname = ANY(ARRAY['{}'])", s.join("','"))
47    } else {
48        "".to_string()
49    };
50
51    let schema_filter_with_fk = if let Some(s) = schemas {
52        let arr = format!("ARRAY['{}']", s.join("','"));
53        format!(
54            "AND (
55            n.nspname = ANY({arr})
56            OR c.oid IN (
57                SELECT conrelid FROM pg_constraint cst
58                JOIN pg_class c2 ON c2.oid = cst.confrelid
59                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
60                WHERE n2.nspname = ANY({arr})
61            )
62            OR c.oid IN (
63                SELECT confrelid FROM pg_constraint cst
64                JOIN pg_class c2 ON c2.oid = cst.conrelid
65                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
66                WHERE n2.nspname = ANY({arr})
67            )
68        )"
69        )
70    } else {
71        "".to_string()
72    };
73
74    let schema_filter_n1_or_n2 = if let Some(s) = schemas {
75        let arr = format!("ARRAY['{}']", s.join("','"));
76        format!("AND (n1.nspname = ANY({arr}) OR n2.nspname = ANY({arr}))")
77    } else {
78        "".to_string()
79    };
80
81    let schema_filter_nt = if let Some(s) = schemas {
82        let arr = format!("ARRAY['{}']", s.join("','"));
83        format!(
84            "AND (
85            n_t.nspname = ANY({arr})
86            OR t.oid IN (
87                SELECT conrelid FROM pg_constraint cst
88                JOIN pg_class c2 ON c2.oid = cst.confrelid
89                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
90                WHERE n2.nspname = ANY({arr})
91            )
92            OR t.oid IN (
93                SELECT confrelid FROM pg_constraint cst
94                JOIN pg_class c2 ON c2.oid = cst.conrelid
95                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
96                WHERE n2.nspname = ANY({arr})
97            )
98        )"
99        )
100    } else {
101        "".to_string()
102    };
103
104    // Query 1: Server Version
105    let version_row = client.query_one("SHOW server_version_num;", &[])?;
106    let version_str: String = version_row.get(0);
107    cache.pg_version_num = version_str.parse::<u32>().ok();
108
109    // Query 2: Relations + Staleness
110    let table_query = format!(
111        "
112        SELECT
113            n.nspname AS schema_name,
114            c.relname AS relation_name,
115            c.relkind AS relation_kind,
116            c.relpersistence AS persistence,
117            CASE WHEN c.reltuples < 0 THEN -1 ELSE c.reltuples::bigint END AS estimated_rows,
118            c.relpages::bigint AS relpages,
119            to_char(s.last_analyze, 'YYYY-MM-DD HH24:MI:SS') AS last_analyze,
120            to_char(s.last_autoanalyze, 'YYYY-MM-DD HH24:MI:SS') AS last_autoanalyze,
121            p.partstrat::text AS partition_strategy
122        FROM pg_class c
123        JOIN pg_namespace n ON n.oid = c.relnamespace
124        LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
125        LEFT JOIN pg_partitioned_table p ON p.partrelid = c.oid
126        WHERE c.relkind IN ('r', 'p', 'v', 'm')
127          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
128          {schema_filter_with_fk};
129    "
130    );
131
132    for row in client.query(&table_query, &[])? {
133        let schema_name: String = row.get("schema_name");
134        let relation_name: String = row.get("relation_name");
135        let relkind: i8 = row.get("relation_kind");
136        let persistence_char: i8 = row.get("persistence");
137        let raw_rows: i64 = row.get("estimated_rows");
138        let relpages: i64 = row.get("relpages");
139
140        let last_analyze: Option<String> = row.get("last_analyze");
141        let last_autoanalyze: Option<String> = row.get("last_autoanalyze");
142
143        let object_id = ObjectId::new(&schema_name, &relation_name);
144
145        let kind = match relkind as u8 {
146            b'v' => RelationKind::View,
147            b'm' => RelationKind::MaterializedView,
148            _ => RelationKind::Table,
149        };
150
151        let persistence = match persistence_char as u8 {
152            b't' => Persistence::Temporary,
153            b'u' => Persistence::Unlogged,
154            _ => Persistence::Permanent,
155        };
156
157        let estimated_rows = if raw_rows < 0 {
158            None
159        } else {
160            Some(raw_rows as u64)
161        };
162
163        let mut state = RelationState::new(
164            object_id.clone(),
165            ObjectId::new("public", "postgres"),
166            0,
167            estimated_rows,
168            kind,
169            persistence,
170            0,
171        );
172        state.relpages = Some(relpages as u64);
173        state.last_analyze = last_analyze;
174        state.last_autoanalyze = last_autoanalyze;
175
176        let partition_strategy: Option<String> = row.get("partition_strategy");
177        if let Some(ref strat) = partition_strategy {
178            state.partition_type = Some(match strat.as_str() {
179                "r" => "RANGE".to_string(),
180                "l" => "LIST".to_string(),
181                "h" => "HASH".to_string(),
182                _ => strat.to_uppercase(),
183            });
184        }
185
186        if let Some(s) = schemas
187            && !s.contains(&schema_name)
188        {
189            state.mark_fk_dependency();
190        }
191
192        cache.insert_baseline(object_id, state);
193    }
194
195    // Query 3: Columns + Width
196    let col_query = format!("
197        SELECT
198            n.nspname AS schema_name,
199            c.relname AS relation_name,
200            a.attname AS column_name,
201            pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name,
202            a.attnotnull AS not_null,
203            s.avg_width AS avg_width,
204            pg_get_expr(ad.adbin, ad.adrelid) AS default_expr_text,
205            a.atttypmod AS type_modifier
206        FROM pg_attribute a
207        JOIN pg_class c ON a.attrelid = c.oid
208        JOIN pg_namespace n ON n.oid = c.relnamespace
209        LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname
210        LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
211        WHERE a.attnum > 0 AND NOT a.attisdropped
212          AND c.relkind IN ('r', 'p', 'v', 'm')
213          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
214          {schema_filter_with_fk}
215        ORDER BY n.nspname, c.relname;
216    ");
217
218    let mut current_object_id: Option<ObjectId> = None;
219    let mut current_rel: Option<*mut crate::model::relation::RelationState> = None;
220
221    for row in client.query(&col_query, &[])? {
222        let schema_name: String = row.get("schema_name");
223        let relation_name: String = row.get("relation_name");
224        let column_name: String = row.get("column_name");
225        let type_name: String = row.get("type_name");
226        let not_null: bool = row.get("not_null");
227        let avg_width: Option<i32> = row.get("avg_width");
228        let default_expr_text: Option<String> = row.get("default_expr_text");
229        let type_modifier: Option<i32> = row.get("type_modifier");
230
231        // Fast path: reuse the mutable reference if the relation hasn't changed
232        let is_same_rel = if let Some(ref cur) = current_object_id {
233            cur.schema == schema_name && cur.name == relation_name
234        } else {
235            false
236        };
237
238        if !is_same_rel {
239            let new_oid = ObjectId::new(&schema_name, &relation_name);
240            if let Some(rel) = cache.relations.get_mut(&new_oid) {
241                current_rel = Some(rel as *mut _);
242            } else {
243                current_rel = None;
244            }
245            current_object_id = Some(new_oid);
246        }
247
248        if let Some(rel_ptr) = current_rel {
249            // SAFE: We are strictly single-threaded here, iterating rows sequentially.
250            // We just need a way to bypass the borrow checker for caching the map lookup.
251            let rel = unsafe { &mut *rel_ptr };
252            rel.columns.push(crate::model::column::Column {
253                name: column_name,
254                data_type: Some(type_name),
255                is_nullable: !not_null,
256                default: None,
257                avg_width,
258                default_expr_text,
259                type_modifier,
260            });
261        }
262    }
263
264    // Query 4: Triggers & Policies
265    let tp_query = format!("
266        SELECT 
267            n.nspname AS schema_name,
268            c.relname AS relation_name,
269            COALESCE(array_agg(DISTINCT t.tgname) FILTER (WHERE t.tgname IS NOT NULL AND t.tgisinternal = false), '{{}}') as triggers,
270            COALESCE(array_agg(DISTINCT p.polname) FILTER (WHERE p.polname IS NOT NULL), '{{}}') as policies
271        FROM pg_class c
272        JOIN pg_namespace n ON n.oid = c.relnamespace
273        LEFT JOIN pg_trigger t ON t.tgrelid = c.oid
274        LEFT JOIN pg_policy p ON p.polrelid = c.oid
275        WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
276        {schema_filter_with_fk}
277        GROUP BY n.nspname, c.relname;
278    ");
279
280    for row in client.query(&tp_query, &[])? {
281        let schema_name: String = row.get("schema_name");
282        let relation_name: String = row.get("relation_name");
283        let triggers: Vec<String> = row.get("triggers");
284        let policies: Vec<String> = row.get("policies");
285
286        let object_id = ObjectId::new(&schema_name, &relation_name);
287
288        if let Some(rel) = cache.relations.get_mut(&object_id) {
289            rel.triggers.extend(triggers);
290            rel.policies.extend(policies);
291        }
292    }
293
294    // Query 4.5: Trigger Functions
295    let trig_query = format!(
296        "
297        SELECT 
298            n.nspname AS table_schema,
299            c.relname AS table_name,
300            t.tgname AS trigger_name,
301            fn.nspname AS function_schema,
302            f.proname || '()' AS function_name
303        FROM pg_trigger t
304        JOIN pg_class c ON c.oid = t.tgrelid
305        JOIN pg_namespace n ON n.oid = c.relnamespace
306        JOIN pg_proc f ON f.oid = t.tgfoid
307        JOIN pg_namespace fn ON fn.oid = f.pronamespace
308        WHERE t.tgisinternal = false
309          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
310          {schema_filter_with_fk};
311    "
312    );
313
314    for row in client.query(&trig_query, &[])? {
315        let table_schema: String = row.get("table_schema");
316        let table_name: String = row.get("table_name");
317        let trigger_name: String = row.get("trigger_name");
318        let function_schema: String = row.get("function_schema");
319        let function_name: String = row.get("function_name");
320
321        cache.triggers.push(crate::db::cache::TriggerCache {
322            trigger_id: ObjectId::new(&table_schema, &trigger_name),
323            table_id: ObjectId::new(&table_schema, &table_name),
324            function_id: ObjectId::new(&function_schema, &function_name),
325        });
326    }
327
328    // Query 5: Foreign Keys
329    let fk_query = format!(
330        "
331        SELECT 
332            c.conname AS constraint_name,
333            n1.nspname AS from_schema, t1.relname AS from_table,
334            n2.nspname AS to_schema, t2.relname AS to_table
335        FROM pg_constraint c
336        JOIN pg_class t1 ON t1.oid = c.conrelid
337        JOIN pg_namespace n1 ON n1.oid = t1.relnamespace
338        JOIN pg_class t2 ON t2.oid = c.confrelid
339        JOIN pg_namespace n2 ON n2.oid = t2.relnamespace
340        WHERE c.contype = 'f'
341        {schema_filter_n1_or_n2};
342    "
343    );
344
345    for row in client.query(&fk_query, &[])? {
346        let constraint_name: String = row.get("constraint_name");
347        let from_schema: String = row.get("from_schema");
348        let from_table: String = row.get("from_table");
349        let to_schema: String = row.get("to_schema");
350        let to_table: String = row.get("to_table");
351
352        if let Some(s) = schemas
353            && (!s.contains(&from_schema) || !s.contains(&to_schema))
354        {
355            // Determine which one is out of scope to print a helpful warning
356            let out_of_scope_schema = if !s.contains(&from_schema) {
357                &from_schema
358            } else {
359                &to_schema
360            };
361            let out_of_scope_table = if !s.contains(&from_schema) {
362                &from_table
363            } else {
364                &to_table
365            };
366            eprintln!(
367                "[WARN] Foreign key '{}' crosses schema boundary. Table '{}.{}' was pulled into cache as a dependency to evaluate cross-team locks.",
368                constraint_name, out_of_scope_schema, out_of_scope_table
369            );
370        }
371
372        cache.foreign_keys.push(ForeignKeyCache {
373            constraint_name,
374            from_table: ObjectId::new(&from_schema, &from_table),
375            to_table: ObjectId::new(&to_schema, &to_table),
376        });
377    }
378
379    // Query 6: Indexes
380    let idx_query = format!(
381        "
382        SELECT 
383            n_i.nspname AS index_schema, i.relname AS index_name,
384            n_t.nspname AS table_schema, t.relname AS table_name
385        FROM pg_index x
386        JOIN pg_class i ON i.oid = x.indexrelid
387        JOIN pg_namespace n_i ON n_i.oid = i.relnamespace
388        JOIN pg_class t ON t.oid = x.indrelid
389        JOIN pg_namespace n_t ON n_t.oid = t.relnamespace
390        WHERE x.indisvalid = true
391        {schema_filter_nt};
392    "
393    );
394
395    for row in client.query(&idx_query, &[])? {
396        let index_schema: String = row.get("index_schema");
397        let index_name: String = row.get("index_name");
398        let table_schema: String = row.get("table_schema");
399        let table_name: String = row.get("table_name");
400
401        cache.indexes.push(IndexCache {
402            index_id: ObjectId::new(&index_schema, &index_name),
403            table_id: ObjectId::new(&table_schema, &table_name),
404        });
405    }
406
407    // Query 7: Functions
408    let func_query = format!(
409        "
410        SELECT
411            n.nspname AS schema_name,
412            p.proname AS func_name,
413            COALESCE(
414                (SELECT string_agg(pg_catalog.format_type(t, NULL), ',' ORDER BY n)
415                 FROM unnest(p.proargtypes::int[]) WITH ORDINALITY AS u(t, n)),
416                ''
417            ) AS arg_types,
418            pg_catalog.pg_get_function_result(p.oid) AS return_type,
419            p.provolatile::text AS volatility,
420            l.lanname AS language,
421            p.prosecdef AS security_definer
422        FROM pg_proc p
423        JOIN pg_namespace n ON n.oid = p.pronamespace
424        JOIN pg_language l ON l.oid = p.prolang
425        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
426          AND p.prokind = 'f'
427          {schema_filter};
428    "
429    );
430
431    for row in client.query(&func_query, &[])? {
432        let schema_name: String = row.get("schema_name");
433        let func_name: String = row.get("func_name");
434        let arg_types_str: String = row.get("arg_types");
435        let return_type: Option<String> = row.get("return_type");
436        let volatility_char: String = row.get("volatility");
437        let language: String = row.get("language");
438        let security_definer: bool = row.get("security_definer");
439
440        let volatility = match volatility_char.as_str() {
441            "v" => crate::model::function::Volatility::Volatile,
442            "s" => crate::model::function::Volatility::Stable,
443            "i" => crate::model::function::Volatility::Immutable,
444            _ => crate::model::function::Volatility::Volatile,
445        };
446
447        let security = if security_definer {
448            crate::model::function::SecurityMode::Definer
449        } else {
450            crate::model::function::SecurityMode::Invoker
451        };
452
453        // Normalize argument types in sync just like in resolver
454        let arg_types_str = arg_types_str
455            .split(',')
456            .map(|s| s.trim().to_lowercase())
457            .collect::<Vec<_>>()
458            .join(",");
459
460        let id = ObjectId::new(&schema_name, format!("{}({})", func_name, arg_types_str));
461
462        let arg_types = if arg_types_str.is_empty() {
463            Vec::new()
464        } else {
465            arg_types_str.split(',').map(|s| s.to_string()).collect()
466        };
467
468        cache.functions.insert(
469            id.clone(),
470            crate::model::function::FunctionState {
471                id,
472                arg_types,
473                return_type: return_type.unwrap_or_default(),
474                volatility,
475                language,
476                security,
477            },
478        );
479    }
480
481    // Atomic write via temp file
482    let tmp_path = out_path.with_extension("tmp");
483    let file = std::fs::File::create(&tmp_path).context("Failed to create temporary cache file")?;
484    let writer = std::io::BufWriter::new(file);
485    let mut encoder =
486        zstd::stream::Encoder::new(writer, 3).context("Failed to init zstd compression")?;
487
488    let versioned = crate::db::cache::DbCacheVersioned::V1(cache);
489    let bincode_config = bincode::config::standard().with_variable_int_encoding();
490
491    bincode::serde::encode_into_std_write(&versioned, &mut encoder, bincode_config)
492        .context("Failed binary bincode 2.0 schema compilation and write")?;
493
494    encoder
495        .finish()
496        .context("Failed to flush final zstd stream to disk")?;
497
498    fs::rename(&tmp_path, out_path).context("Failed to atomically rename cache file")?;
499
500    Ok(())
501}