Skip to main content

safe_migrate/
sync.rs

1// FILE: src/sync.rs
2
3use crate::ast::identifiers::ObjectId;
4use crate::db::cache::{CACHE_V3_MAGIC, DbCache, DbCacheVersioned, ForeignKeyCache, IndexCache};
5use crate::db::cache_file::protect_cache_bytes;
6use crate::model::relation::{Persistence, RelationKind, RelationState};
7use anyhow::{Context, Result};
8use postgres::config::Host;
9use postgres::{Client, Config as PostgresConfig, NoTls};
10use std::io::Write;
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13use tempfile::NamedTempFile;
14
15#[cfg(windows)]
16use std::fs;
17
18pub fn sync_cache(
19    out_path: &Path,
20    schemas: Option<&[String]>,
21    cache_encryption: bool,
22) -> Result<()> {
23    // Strict env-only credential enforcement
24    let db_url = std::env::var("DATABASE_URL")
25        .context("DATABASE_URL environment variable is required to sync PostgreSQL schema metadata and statistics. Do not pass credentials via CLI flags or config files.")?;
26
27    let mut client = connect_database(&db_url)?;
28
29    let cache = populate_cache(&mut client, schemas)?;
30
31    write_cache(out_path, cache, cache_encryption)
32}
33
34fn connect_database(db_url: &str) -> Result<Client> {
35    let config: PostgresConfig = db_url
36        .parse()
37        .context("DATABASE_URL is not a valid PostgreSQL connection string")?;
38
39    if config
40        .get_hosts()
41        .iter()
42        .any(|host| matches!(host, Host::Tcp(name) if !is_local_host(name)))
43    {
44        anyhow::bail!(
45            "Remote DATABASE_URL connections are not supported by this build. Use an SSH tunnel and connect through localhost or a Unix socket."
46        );
47    }
48
49    config
50        .connect(NoTls)
51        .context("Failed to connect to PostgreSQL")
52}
53
54pub(crate) fn is_local_host(host: &str) -> bool {
55    if host.starts_with('/') || host.eq_ignore_ascii_case("localhost") {
56        return true;
57    }
58    host.trim_start_matches('[')
59        .trim_end_matches(']')
60        .parse::<std::net::IpAddr>()
61        .is_ok_and(|address| address.is_loopback())
62}
63
64pub(crate) fn cache_search_path(
65    database_search_path: Vec<String>,
66    schemas: Option<&[String]>,
67) -> Vec<String> {
68    let Some(schemas) = schemas else {
69        return database_search_path;
70    };
71
72    let mut scoped_search_path = Vec::new();
73    for schema in database_search_path
74        .into_iter()
75        .filter(|schema| schemas.contains(schema))
76        .chain(schemas.iter().cloned())
77    {
78        if !scoped_search_path.contains(&schema) {
79            scoped_search_path.push(schema);
80        }
81    }
82    scoped_search_path
83}
84
85pub(crate) fn relation_owner_id(owner_name: impl Into<String>) -> ObjectId {
86    ObjectId::new("", owner_name)
87}
88
89pub(crate) fn is_system_schema(schema: &str) -> bool {
90    schema == "information_schema" || schema.starts_with("pg_")
91}
92
93fn write_cache(out_path: &Path, cache: DbCache, cache_encryption: bool) -> Result<()> {
94    write_cache_with_protection(out_path, cache, |compressed| {
95        protect_cache_bytes(compressed, cache_encryption)
96    })
97}
98
99fn write_cache_with_protection(
100    out_path: &Path,
101    cache: DbCache,
102    protect: impl FnOnce(Vec<u8>) -> Result<Vec<u8>>,
103) -> Result<()> {
104    let parent = out_path.parent().unwrap_or_else(|| Path::new("."));
105    let mut temp_file = NamedTempFile::new_in(parent).with_context(|| {
106        format!(
107            "Failed to create temporary cache file beside {}",
108            out_path.display()
109        )
110    })?;
111    let mut compressed = Vec::new();
112    let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3)
113        .context("Failed to init zstd compression")?;
114
115    encoder
116        .write_all(CACHE_V3_MAGIC)
117        .context("Failed to write cache V3 payload header")?;
118
119    let versioned = DbCacheVersioned::V3(cache);
120    let bincode_config = bincode::config::standard().with_variable_int_encoding();
121
122    bincode::serde::encode_into_std_write(&versioned, &mut encoder, bincode_config)
123        .context("Failed bincode schema compilation and write")?;
124
125    encoder
126        .finish()
127        .context("Failed to flush final zstd stream to disk")?;
128
129    let cache_bytes = protect(compressed)?;
130    temp_file
131        .write_all(&cache_bytes)
132        .context("Failed to write cache payload")?;
133    temp_file.flush().context("Failed to flush cache payload")?;
134
135    replace_cache(temp_file, out_path)?;
136
137    Ok(())
138}
139
140#[cfg(not(windows))]
141fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
142    temp_file
143        .persist(out_path)
144        .map_err(|error| error.error)
145        .with_context(|| {
146            format!(
147                "Failed to atomically replace cache file: {}",
148                out_path.display()
149            )
150        })?;
151    Ok(())
152}
153
154#[cfg(windows)]
155fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
156    if !out_path.exists() {
157        temp_file
158            .persist(out_path)
159            .map_err(|error| error.error)
160            .with_context(|| format!("Failed to install cache file: {}", out_path.display()))?;
161        return Ok(());
162    }
163
164    let backup = out_path.with_extension("safe-migrate.backup");
165    fs::rename(out_path, &backup).with_context(|| {
166        format!(
167            "Failed to stage existing cache for replacement: {}",
168            out_path.display()
169        )
170    })?;
171
172    match temp_file.persist(out_path) {
173        Ok(_) => {
174            fs::remove_file(&backup).with_context(|| {
175                format!(
176                    "Installed new cache but failed to remove backup: {}",
177                    backup.display()
178                )
179            })?;
180            Ok(())
181        }
182        Err(error) => {
183            let restore_result = fs::rename(&backup, out_path);
184            let message = if let Err(restore_error) = restore_result {
185                format!(
186                    "Failed to install new cache: {}. The old cache could not be restored: {}",
187                    error.error, restore_error
188                )
189            } else {
190                format!(
191                    "Failed to install new cache; restored the previous cache: {}",
192                    error.error
193                )
194            };
195            Err(anyhow::anyhow!(message))
196        }
197    }
198}
199
200pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result<DbCache> {
201    let mut cache = DbCache::new();
202    let schema_values = schemas.map(|items| items.to_vec());
203    cache.metadata.created_at_unix_secs = Some(
204        SystemTime::now()
205            .duration_since(UNIX_EPOCH)
206            .unwrap_or_default()
207            .as_secs(),
208    );
209    cache.metadata.schemas = schema_values.clone();
210
211    let schema_filter = "AND ($1::text[] IS NULL OR n.nspname = ANY($1))";
212    let schema_filter_with_fk = r#"
213        AND (
214            $1::text[] IS NULL
215            OR n.nspname = ANY($1)
216            OR c.oid IN (
217                SELECT conrelid FROM pg_constraint cst
218                JOIN pg_class c2 ON c2.oid = cst.confrelid
219                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
220                WHERE n2.nspname = ANY($1)
221            )
222            OR c.oid IN (
223                SELECT confrelid FROM pg_constraint cst
224                JOIN pg_class c2 ON c2.oid = cst.conrelid
225                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
226                WHERE n2.nspname = ANY($1)
227            )
228        )
229    "#;
230    let schema_filter_n1_or_n2 =
231        "AND ($1::text[] IS NULL OR n1.nspname = ANY($1) OR n2.nspname = ANY($1))";
232    let schema_filter_nt = r#"
233        AND (
234            $1::text[] IS NULL
235            OR n_t.nspname = ANY($1)
236            OR t.oid IN (
237                SELECT conrelid FROM pg_constraint cst
238                JOIN pg_class c2 ON c2.oid = cst.confrelid
239                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
240                WHERE n2.nspname = ANY($1)
241            )
242            OR t.oid IN (
243                SELECT confrelid FROM pg_constraint cst
244                JOIN pg_class c2 ON c2.oid = cst.conrelid
245                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
246                WHERE n2.nspname = ANY($1)
247            )
248        )
249    "#;
250
251    // Query 1: Server Version
252    let version_row = client.query_one("SHOW server_version_num;", &[])?;
253    let version_str: String = version_row.get(0);
254    cache.pg_version_num = version_str.parse::<u32>().ok();
255
256    let database_row = client.query_one("SELECT current_database();", &[])?;
257    cache.metadata.source_database = Some(database_row.get(0));
258
259    // Resolve role/database defaults and special entries such as "$user" exactly
260    // as PostgreSQL does, while excluding the implicit pg_catalog lookup. An
261    // explicit schema scope remains the resolution boundary, but selected
262    // schemas retain their live PostgreSQL priority.
263    let search_path_row = client.query_one("SELECT current_schemas(false);", &[])?;
264    cache.search_path = cache_search_path(search_path_row.get(0), schemas);
265
266    // Query 2: Relations + Staleness
267    let table_query = format!(
268        "
269        SELECT
270            n.nspname AS schema_name,
271            c.relname AS relation_name,
272            c.relkind AS relation_kind,
273            c.relpersistence AS persistence,
274            pg_catalog.pg_get_userbyid(c.relowner) AS owner_name,
275            CASE WHEN c.reltuples < 0 THEN -1 ELSE c.reltuples::bigint END AS estimated_rows,
276            c.relpages::bigint AS relpages,
277            to_char(s.last_analyze, 'YYYY-MM-DD HH24:MI:SS') AS last_analyze,
278            to_char(s.last_autoanalyze, 'YYYY-MM-DD HH24:MI:SS') AS last_autoanalyze,
279            p.partstrat::text AS partition_strategy
280        FROM pg_class c
281        JOIN pg_namespace n ON n.oid = c.relnamespace
282        LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
283        LEFT JOIN pg_partitioned_table p ON p.partrelid = c.oid
284        WHERE c.relkind IN ('r', 'p', 'v', 'm')
285          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
286          {schema_filter_with_fk};
287    "
288    );
289
290    for row in client.query(&table_query, &[&schema_values])? {
291        let schema_name: String = row.get("schema_name");
292        let relation_name: String = row.get("relation_name");
293        let relkind: i8 = row.get("relation_kind");
294        let persistence_char: i8 = row.get("persistence");
295        let owner_name: String = row.get("owner_name");
296        let raw_rows: i64 = row.get("estimated_rows");
297        let relpages: i64 = row.get("relpages");
298
299        let last_analyze: Option<String> = row.get("last_analyze");
300        let last_autoanalyze: Option<String> = row.get("last_autoanalyze");
301
302        let object_id = ObjectId::new(&schema_name, &relation_name);
303
304        let kind = match relkind as u8 {
305            b'v' => RelationKind::View,
306            b'm' => RelationKind::MaterializedView,
307            _ => RelationKind::Table,
308        };
309
310        let persistence = match persistence_char as u8 {
311            b't' => Persistence::Temporary,
312            b'u' => Persistence::Unlogged,
313            _ => Persistence::Permanent,
314        };
315
316        let estimated_rows = if raw_rows < 0 {
317            None
318        } else {
319            Some(raw_rows as u64)
320        };
321
322        let mut state = RelationState::new(
323            object_id.clone(),
324            relation_owner_id(owner_name),
325            0,
326            estimated_rows,
327            kind,
328            persistence,
329            0,
330        );
331        state.relpages = Some(relpages as u64);
332        state.last_analyze = last_analyze;
333        state.last_autoanalyze = last_autoanalyze;
334
335        let partition_strategy: Option<String> = row.get("partition_strategy");
336        if let Some(ref strat) = partition_strategy {
337            state.partition_type = Some(match strat.as_str() {
338                "r" => "RANGE".to_string(),
339                "l" => "LIST".to_string(),
340                "h" => "HASH".to_string(),
341                _ => strat.to_uppercase(),
342            });
343        }
344
345        if let Some(s) = schemas
346            && !s.contains(&schema_name)
347        {
348            state.mark_fk_dependency();
349        }
350
351        cache.insert_baseline(object_id, state);
352    }
353
354    // Query 3: Columns + Width
355    let col_query = format!("
356        SELECT
357            n.nspname AS schema_name,
358            c.relname AS relation_name,
359            a.attname AS column_name,
360            pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name,
361            a.attnotnull AS not_null,
362            s.avg_width AS avg_width,
363            pg_get_expr(ad.adbin, ad.adrelid) AS default_expr_text,
364            a.atttypmod AS type_modifier
365        FROM pg_attribute a
366        JOIN pg_class c ON a.attrelid = c.oid
367        JOIN pg_namespace n ON n.oid = c.relnamespace
368        LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname
369        LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
370        WHERE a.attnum > 0 AND NOT a.attisdropped
371          AND c.relkind IN ('r', 'p', 'v', 'm')
372          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
373          {schema_filter_with_fk}
374        ORDER BY n.nspname, c.relname;
375    ");
376
377    for row in client.query(&col_query, &[&schema_values])? {
378        let schema_name: String = row.get("schema_name");
379        let relation_name: String = row.get("relation_name");
380        let column_name: String = row.get("column_name");
381        let type_name: String = row.get("type_name");
382        let not_null: bool = row.get("not_null");
383        let avg_width: Option<i32> = row.get("avg_width");
384        let default_expr_text: Option<String> = row.get("default_expr_text");
385        let type_modifier: Option<i32> = row.get("type_modifier");
386
387        let relation_id = ObjectId::new(&schema_name, &relation_name);
388        if let Some(rel) = cache.relations.get_mut(&relation_id) {
389            rel.columns.push(crate::model::column::Column {
390                name: column_name,
391                data_type: Some(type_name),
392                is_nullable: !not_null,
393                default: None,
394                avg_width,
395                default_expr_text,
396                type_modifier,
397            });
398        }
399    }
400
401    // Query 4: Triggers & Policies
402    let tp_query = format!("
403        SELECT 
404            n.nspname AS schema_name,
405            c.relname AS relation_name,
406            COALESCE(array_agg(DISTINCT t.tgname) FILTER (WHERE t.tgname IS NOT NULL AND t.tgisinternal = false), '{{}}') as triggers,
407            COALESCE(array_agg(DISTINCT p.polname) FILTER (WHERE p.polname IS NOT NULL), '{{}}') as policies
408        FROM pg_class c
409        JOIN pg_namespace n ON n.oid = c.relnamespace
410        LEFT JOIN pg_trigger t ON t.tgrelid = c.oid
411        LEFT JOIN pg_policy p ON p.polrelid = c.oid
412        WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
413        {schema_filter_with_fk}
414        GROUP BY n.nspname, c.relname;
415    ");
416
417    for row in client.query(&tp_query, &[&schema_values])? {
418        let schema_name: String = row.get("schema_name");
419        let relation_name: String = row.get("relation_name");
420        let triggers: Vec<String> = row.get("triggers");
421        let policies: Vec<String> = row.get("policies");
422
423        let object_id = ObjectId::new(&schema_name, &relation_name);
424
425        if let Some(rel) = cache.relations.get_mut(&object_id) {
426            rel.triggers.extend(triggers);
427            rel.policies.extend(policies);
428        }
429    }
430
431    // Query 4.25: Explicit non-owner relation privileges.
432    let acl_query = format!(
433        "
434        SELECT
435            n.nspname AS schema_name,
436            c.relname AS relation_name,
437            CASE
438                WHEN acl.grantee = 0 THEN 'public'
439                ELSE pg_catalog.pg_get_userbyid(acl.grantee)
440            END AS grantee,
441            acl.privilege_type
442        FROM pg_class c
443        JOIN pg_namespace n ON n.oid = c.relnamespace
444        CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) acl
445        WHERE c.relkind IN ('r', 'p', 'v', 'm')
446          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
447          AND acl.grantee <> c.relowner
448          {schema_filter_with_fk};
449        "
450    );
451
452    for row in client.query(&acl_query, &[&schema_values])? {
453        let schema_name: String = row.get("schema_name");
454        let relation_name: String = row.get("relation_name");
455        let grantee: String = row.get("grantee");
456        let privilege_type: String = row.get("privilege_type");
457        let privilege = match privilege_type.as_str() {
458            "SELECT" => crate::model::relation::Privilege::Select,
459            "INSERT" => crate::model::relation::Privilege::Insert,
460            "UPDATE" => crate::model::relation::Privilege::Update,
461            "DELETE" => crate::model::relation::Privilege::Delete,
462            "TRUNCATE" => crate::model::relation::Privilege::Truncate,
463            "REFERENCES" => crate::model::relation::Privilege::References,
464            "TRIGGER" => crate::model::relation::Privilege::Trigger,
465            _ => continue,
466        };
467        if let Some(relation) = cache
468            .relations
469            .get_mut(&ObjectId::new(&schema_name, &relation_name))
470        {
471            relation.privileges.grant(
472                ObjectId::new("", grantee),
473                [privilege].into_iter().collect(),
474            );
475        }
476    }
477
478    // Query 4.5: Trigger Functions
479    let trig_query = format!(
480        "
481        SELECT 
482            n.nspname AS table_schema,
483            c.relname AS table_name,
484            t.tgname AS trigger_name,
485            t.tgenabled::text AS enabled_mode,
486            fn.nspname AS function_schema,
487            f.proname || '()' AS function_name
488        FROM pg_trigger t
489        JOIN pg_class c ON c.oid = t.tgrelid
490        JOIN pg_namespace n ON n.oid = c.relnamespace
491        JOIN pg_proc f ON f.oid = t.tgfoid
492        JOIN pg_namespace fn ON fn.oid = f.pronamespace
493        WHERE t.tgisinternal = false
494          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
495          {schema_filter_with_fk};
496    "
497    );
498
499    for row in client.query(&trig_query, &[&schema_values])? {
500        let table_schema: String = row.get("table_schema");
501        let table_name: String = row.get("table_name");
502        let trigger_name: String = row.get("trigger_name");
503        let enabled_mode: String = row.get("enabled_mode");
504        let function_schema: String = row.get("function_schema");
505        let function_name: String = row.get("function_name");
506
507        cache.triggers.push(crate::db::cache::TriggerCache {
508            trigger_id: ObjectId::new(&table_schema, &trigger_name),
509            table_id: ObjectId::new(&table_schema, &table_name),
510            function_id: ObjectId::new(&function_schema, &function_name),
511            enabled_mode: crate::model::trigger::TriggerEnableMode::from_pg_code(&enabled_mode)
512                .ok_or_else(|| {
513                    anyhow::anyhow!("unknown pg_trigger.tgenabled value {enabled_mode}")
514                })?,
515        });
516    }
517
518    // Query 4.75: Table constraints
519    let constraint_query = format!(
520        "
521        SELECT
522            n.nspname AS table_schema,
523            c.relname AS table_name,
524            con.conname AS constraint_name,
525            con.contype::text AS constraint_type,
526            con.convalidated AS validated
527        FROM pg_constraint con
528        JOIN pg_class c ON c.oid = con.conrelid
529        JOIN pg_namespace n ON n.oid = c.relnamespace
530        WHERE con.contype IN ('c', 'f', 'p', 'u', 'x')
531          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
532          {schema_filter};
533        "
534    );
535
536    for row in client.query(&constraint_query, &[&schema_values])? {
537        let table_schema: String = row.get("table_schema");
538        let table_name: String = row.get("table_name");
539        let constraint_name: String = row.get("constraint_name");
540        let constraint_type: String = row.get("constraint_type");
541        let validated: bool = row.get("validated");
542        let kind = match constraint_type.as_str() {
543            "c" => crate::model::constraint::ConstraintKind::Check,
544            "f" => crate::model::constraint::ConstraintKind::ForeignKey,
545            "p" => crate::model::constraint::ConstraintKind::PrimaryKey,
546            "u" => crate::model::constraint::ConstraintKind::Unique,
547            "x" => crate::model::constraint::ConstraintKind::Exclusion,
548            _ => continue,
549        };
550        cache
551            .constraints
552            .push(crate::model::constraint::ConstraintState {
553                table_id: ObjectId::new(&table_schema, &table_name),
554                name: constraint_name,
555                kind,
556                validated,
557            });
558    }
559
560    // Query 5: Foreign Keys
561    let fk_query = format!(
562        "
563        SELECT 
564            c.conname AS constraint_name,
565            n1.nspname AS from_schema, t1.relname AS from_table,
566            n2.nspname AS to_schema, t2.relname AS to_table
567        FROM pg_constraint c
568        JOIN pg_class t1 ON t1.oid = c.conrelid
569        JOIN pg_namespace n1 ON n1.oid = t1.relnamespace
570        JOIN pg_class t2 ON t2.oid = c.confrelid
571        JOIN pg_namespace n2 ON n2.oid = t2.relnamespace
572        WHERE c.contype = 'f'
573        {schema_filter_n1_or_n2};
574    "
575    );
576
577    for row in client.query(&fk_query, &[&schema_values])? {
578        let constraint_name: String = row.get("constraint_name");
579        let from_schema: String = row.get("from_schema");
580        let from_table: String = row.get("from_table");
581        let to_schema: String = row.get("to_schema");
582        let to_table: String = row.get("to_table");
583
584        if let Some(s) = schemas
585            && (!s.contains(&from_schema) || !s.contains(&to_schema))
586        {
587            // Determine which one is out of scope to print a helpful warning
588            let out_of_scope_schema = if !s.contains(&from_schema) {
589                &from_schema
590            } else {
591                &to_schema
592            };
593            let out_of_scope_table = if !s.contains(&from_schema) {
594                &from_table
595            } else {
596                &to_table
597            };
598            eprintln!(
599                "[WARN] Foreign key '{}' crosses schema boundary. Table '{}.{}' was pulled into cache as a dependency to evaluate cross-team locks.",
600                constraint_name, out_of_scope_schema, out_of_scope_table
601            );
602        }
603
604        cache.foreign_keys.push(ForeignKeyCache {
605            constraint_name,
606            from_table: ObjectId::new(&from_schema, &from_table),
607            to_table: ObjectId::new(&to_schema, &to_table),
608        });
609    }
610
611    // Query 6: Indexes
612    let idx_query = format!(
613        "
614        SELECT 
615            n_i.nspname AS index_schema, i.relname AS index_name,
616            n_t.nspname AS table_schema, t.relname AS table_name
617        FROM pg_index x
618        JOIN pg_class i ON i.oid = x.indexrelid
619        JOIN pg_namespace n_i ON n_i.oid = i.relnamespace
620        JOIN pg_class t ON t.oid = x.indrelid
621        JOIN pg_namespace n_t ON n_t.oid = t.relnamespace
622        WHERE x.indisvalid = true
623          AND n_i.nspname !~ '^pg_'
624          AND n_i.nspname <> 'information_schema'
625          AND n_t.nspname !~ '^pg_'
626          AND n_t.nspname <> 'information_schema'
627        {schema_filter_nt};
628    "
629    );
630
631    for row in client.query(&idx_query, &[&schema_values])? {
632        let index_schema: String = row.get("index_schema");
633        let index_name: String = row.get("index_name");
634        let table_schema: String = row.get("table_schema");
635        let table_name: String = row.get("table_name");
636
637        if is_system_schema(&index_schema) || is_system_schema(&table_schema) {
638            continue;
639        }
640
641        cache.indexes.push(IndexCache {
642            index_id: ObjectId::new(&index_schema, &index_name),
643            table_id: ObjectId::new(&table_schema, &table_name),
644        });
645    }
646
647    // Query 7: Functions
648    let func_query = format!(
649        "
650        SELECT
651            n.nspname AS schema_name,
652            p.proname AS func_name,
653            COALESCE(
654                (SELECT string_agg(pg_catalog.format_type(t, NULL), ',' ORDER BY n)
655                 FROM unnest(p.proargtypes::int[]) WITH ORDINALITY AS u(t, n)),
656                ''
657            ) AS arg_types,
658            pg_catalog.pg_get_function_result(p.oid) AS return_type,
659            p.provolatile::text AS volatility,
660            l.lanname AS language,
661            p.prosecdef AS security_definer
662        FROM pg_proc p
663        JOIN pg_namespace n ON n.oid = p.pronamespace
664        JOIN pg_language l ON l.oid = p.prolang
665        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
666          AND p.prokind = 'f'
667          {schema_filter};
668    "
669    );
670
671    for row in client.query(&func_query, &[&schema_values])? {
672        let schema_name: String = row.get("schema_name");
673        let func_name: String = row.get("func_name");
674        let arg_types_str: String = row.get("arg_types");
675        let return_type: Option<String> = row.get("return_type");
676        let volatility_char: String = row.get("volatility");
677        let language: String = row.get("language");
678        let security_definer: bool = row.get("security_definer");
679
680        let volatility = match volatility_char.as_str() {
681            "v" => crate::model::function::Volatility::Volatile,
682            "s" => crate::model::function::Volatility::Stable,
683            "i" => crate::model::function::Volatility::Immutable,
684            _ => crate::model::function::Volatility::Volatile,
685        };
686
687        let security = if security_definer {
688            crate::model::function::SecurityMode::Definer
689        } else {
690            crate::model::function::SecurityMode::Invoker
691        };
692
693        // Normalize argument types in sync just like in resolver
694        let arg_types_str = arg_types_str
695            .split(',')
696            .map(|s| s.trim().to_lowercase())
697            .collect::<Vec<_>>()
698            .join(",");
699
700        let id = ObjectId::new(&schema_name, format!("{}({})", func_name, arg_types_str));
701
702        let arg_types = if arg_types_str.is_empty() {
703            Vec::new()
704        } else {
705            arg_types_str.split(',').map(|s| s.to_string()).collect()
706        };
707
708        cache.functions.insert(
709            id.clone(),
710            crate::model::function::FunctionState {
711                id,
712                arg_types,
713                return_type: return_type.unwrap_or_default(),
714                volatility,
715                language,
716                security,
717            },
718        );
719    }
720
721    // Query 8: User-defined types, including ordered enum labels and domains.
722    let type_query = format!(
723        "
724        SELECT
725            n.nspname AS schema_name,
726            t.typname AS type_name,
727            t.typtype::text AS type_kind,
728            CASE WHEN t.typtype = 'd'
729                THEN pg_catalog.format_type(t.typbasetype, t.typtypmod)
730                ELSE NULL
731            END AS domain_base_type,
732            COALESCE(
733                array_agg(e.enumlabel ORDER BY e.enumsortorder)
734                    FILTER (WHERE e.enumlabel IS NOT NULL),
735                ARRAY[]::text[]
736            ) AS enum_labels
737        FROM pg_type t
738        JOIN pg_namespace n ON n.oid = t.typnamespace
739        LEFT JOIN pg_enum e ON e.enumtypid = t.oid
740        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
741          AND t.typtype IN ('e', 'd')
742          {schema_filter}
743        GROUP BY n.nspname, t.typname, t.typtype, t.typbasetype, t.typtypmod;
744        "
745    );
746
747    for row in client.query(&type_query, &[&schema_values])? {
748        let schema_name: String = row.get("schema_name");
749        let type_name: String = row.get("type_name");
750        let type_kind: String = row.get("type_kind");
751        let domain_base_type: Option<String> = row.get("domain_base_type");
752        let enum_labels: Vec<String> = row.get("enum_labels");
753        let kind = match type_kind.as_str() {
754            "e" => crate::model::types::TypeKind::Enum {
755                variants: enum_labels,
756            },
757            "d" => crate::model::types::TypeKind::Domain {
758                base_type: domain_base_type.unwrap_or_default(),
759            },
760            _ => continue,
761        };
762        let id = ObjectId::new(&schema_name, &type_name);
763        cache.types.insert(
764            id.clone(),
765            crate::model::types::TypeState {
766                id,
767                generation: 0,
768                kind,
769            },
770        );
771    }
772
773    // Query 9: Dependencies (pg_depend)
774    let depend_query = r#"
775        SELECT
776            d.classid, d.objid, d.objsubid,
777            d.refclassid, d.refobjid, d.refobjsubid,
778            d.deptype::text,
779            COALESCE(n1.nspname, n1p.nspname, n1t.nspname) AS obj_schema,
780            COALESCE(c1.relname, p1.proname, t1.typname) AS obj_name,
781            COALESCE(n2.nspname, n2p.nspname, n2t.nspname) AS ref_schema,
782            COALESCE(c2.relname, p2.proname, t2.typname) AS ref_name
783        FROM pg_depend d
784        LEFT JOIN pg_class c1 ON c1.oid = d.objid AND d.classid = 'pg_class'::regclass
785        LEFT JOIN pg_namespace n1 ON n1.oid = c1.relnamespace
786        LEFT JOIN pg_proc p1 ON p1.oid = d.objid AND d.classid = 'pg_proc'::regclass
787        LEFT JOIN pg_namespace n1p ON n1p.oid = p1.pronamespace
788        LEFT JOIN pg_type t1 ON t1.oid = d.objid AND d.classid = 'pg_type'::regclass
789        LEFT JOIN pg_namespace n1t ON n1t.oid = t1.typnamespace
790        LEFT JOIN pg_class c2 ON c2.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
791        LEFT JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
792        LEFT JOIN pg_proc p2 ON p2.oid = d.refobjid AND d.refclassid = 'pg_proc'::regclass
793        LEFT JOIN pg_namespace n2p ON n2p.oid = p2.pronamespace
794        LEFT JOIN pg_type t2 ON t2.oid = d.refobjid AND d.refclassid = 'pg_type'::regclass
795        LEFT JOIN pg_namespace n2t ON n2t.oid = t2.typnamespace
796        WHERE d.deptype IN ('n', 'a', 'i')
797          AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname) IS NOT NULL
798          AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname)
799              NOT IN ('pg_catalog', 'information_schema')
800          AND (
801              $1::text[] IS NULL
802              OR COALESCE(n1.nspname, n1p.nspname, n1t.nspname) = ANY($1)
803          )
804    "#;
805
806    for row in client.query(depend_query, &[&schema_values])? {
807        let classid: u32 = row.get(0);
808        let objid: u32 = row.get(1);
809        let objsubid: i32 = row.get(2);
810        let refclassid: u32 = row.get(3);
811        let refobjid: u32 = row.get(4);
812        let refobjsubid: i32 = row.get(5);
813        let deptype: String = row.get(6);
814        let obj_schema: Option<String> = row.get(7);
815        let obj_name: Option<String> = row.get(8);
816        let ref_schema: Option<String> = row.get(9);
817        let ref_name: Option<String> = row.get(10);
818
819        cache.dependencies.push(crate::db::cache::DependencyCache {
820            classid,
821            objid,
822            objsubid,
823            refclassid,
824            refobjid,
825            refobjsubid,
826            deptype,
827            obj_schema,
828            obj_name,
829            ref_schema,
830            ref_name,
831        });
832    }
833
834    // View dependencies are owned by pg_rewrite entries, so the generic pg_depend
835    // query above cannot recover the dependent view's schema-qualified identity.
836    let view_depend_query = r#"
837        SELECT DISTINCT
838            'pg_class'::regclass::oid AS classid,
839            vc.oid AS objid,
840            0 AS objsubid,
841            'pg_class'::regclass::oid AS refclassid,
842            tc.oid AS refobjid,
843            0 AS refobjsubid,
844            vn.nspname AS obj_schema,
845            vc.relname AS obj_name,
846            tn.nspname AS ref_schema,
847            tc.relname AS ref_name
848        FROM pg_rewrite rw
849        JOIN pg_class vc ON vc.oid = rw.ev_class
850        JOIN pg_namespace vn ON vn.oid = vc.relnamespace
851        JOIN pg_depend d ON d.objid = rw.oid
852        JOIN pg_class tc ON tc.oid = d.refobjid
853        JOIN pg_namespace tn ON tn.oid = tc.relnamespace
854        WHERE vc.relkind IN ('v', 'm')
855          AND d.deptype = 'n'
856          -- PostgreSQL 14/15 expose an internal rewrite-rule self-edge. It is
857          -- not a dependency of the view definition and must not enter the
858          -- modeled dependency graph.
859          AND tc.oid <> vc.oid
860          AND (
861              $1::text[] IS NULL
862              OR (vn.nspname = ANY($1) AND tn.nspname = ANY($1))
863          )
864    "#;
865
866    for row in client.query(view_depend_query, &[&schema_values])? {
867        cache.dependencies.push(crate::db::cache::DependencyCache {
868            classid: row.get(0),
869            objid: row.get(1),
870            objsubid: row.get(2),
871            refclassid: row.get(3),
872            refobjid: row.get(4),
873            refobjsubid: row.get(5),
874            deptype: "view".to_string(),
875            obj_schema: Some(row.get(6)),
876            obj_name: Some(row.get(7)),
877            ref_schema: Some(row.get(8)),
878            ref_name: Some(row.get(9)),
879        });
880    }
881
882    Ok(cache)
883}
884
885#[cfg(test)]
886mod atomic_write_tests {
887    use super::*;
888    use crate::db::cache::DbCacheVersioned;
889    use std::fs;
890    use std::io::Read;
891
892    #[test]
893    fn production_cache_writer_atomically_replaces_and_decodes() {
894        let temp_dir = tempfile::tempdir().unwrap();
895        let cache_path = temp_dir.path().join("baseline.cache");
896        fs::write(&cache_path, b"old-cache").unwrap();
897
898        let mut cache = DbCache::new();
899        cache.pg_version_num = Some(180002);
900        write_cache(&cache_path, cache, false).unwrap();
901
902        let encoded = fs::read(&cache_path).unwrap();
903        assert_ne!(encoded, b"old-cache");
904        let reader = std::io::Cursor::new(encoded);
905        let mut decoder = zstd::stream::Decoder::new(reader).unwrap();
906        let mut payload = Vec::new();
907        decoder.read_to_end(&mut payload).unwrap();
908        let payload = payload
909            .strip_prefix(CACHE_V3_MAGIC)
910            .expect("writer must prefix V3 cache payloads");
911        let config = bincode::config::standard().with_variable_int_encoding();
912        let versioned: DbCacheVersioned = bincode::serde::decode_from_slice(payload, config)
913            .unwrap()
914            .0;
915        assert_eq!(versioned.into_cache().unwrap().pg_version_num, Some(180002));
916        assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
917    }
918
919    #[test]
920    fn production_cache_writer_preserves_old_bytes_after_pre_install_failure() {
921        let temp_dir = tempfile::tempdir().unwrap();
922        let cache_path = temp_dir.path().join("baseline.cache");
923        fs::write(&cache_path, b"known-good-cache").unwrap();
924
925        let error = write_cache_with_protection(&cache_path, DbCache::new(), |_| {
926            Err(anyhow::anyhow!("injected payload-protection failure"))
927        })
928        .unwrap_err();
929
930        assert!(
931            error
932                .to_string()
933                .contains("injected payload-protection failure")
934        );
935        assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
936        assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
937    }
938}