Skip to main content

safe_migrate/
sync.rs

1// FILE: src/sync.rs
2
3use crate::ast::identifiers::ObjectId;
4use crate::db::cache::{CACHE_V5_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
85/// Parse PostgreSQL's canonical `SHOW search_path` representation while
86/// preserving the special `$user` placeholder and quoted identifier casing.
87pub(crate) fn parse_search_path_setting(setting: &str) -> Vec<String> {
88    let mut entries = Vec::new();
89    let mut current = String::new();
90    let mut chars = setting.chars().peekable();
91    let mut quoted = false;
92
93    while let Some(ch) = chars.next() {
94        match ch {
95            '"' if quoted && chars.peek() == Some(&'"') => {
96                current.push('"');
97                chars.next();
98            }
99            '"' => quoted = !quoted,
100            ',' if !quoted => {
101                let entry = current.trim();
102                if !entry.is_empty() {
103                    entries.push(entry.to_string());
104                }
105                current.clear();
106            }
107            _ => current.push(ch),
108        }
109    }
110
111    let entry = current.trim();
112    if !entry.is_empty() {
113        entries.push(entry.to_string());
114    }
115    entries
116}
117
118pub(crate) fn relation_owner_id(owner_name: impl Into<String>) -> ObjectId {
119    ObjectId::new("", owner_name)
120}
121
122pub(crate) fn is_system_schema(schema: &str) -> bool {
123    schema == "information_schema" || schema.starts_with("pg_")
124}
125
126fn write_cache(out_path: &Path, cache: DbCache, cache_encryption: bool) -> Result<()> {
127    write_cache_with_protection(out_path, cache, |compressed| {
128        protect_cache_bytes(compressed, cache_encryption)
129    })
130}
131
132fn write_cache_with_protection(
133    out_path: &Path,
134    cache: DbCache,
135    protect: impl FnOnce(Vec<u8>) -> Result<Vec<u8>>,
136) -> Result<()> {
137    let parent = out_path.parent().unwrap_or_else(|| Path::new("."));
138    let mut temp_file = NamedTempFile::new_in(parent).with_context(|| {
139        format!(
140            "Failed to create temporary cache file beside {}",
141            out_path.display()
142        )
143    })?;
144    let mut compressed = Vec::new();
145    let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3)
146        .context("Failed to init zstd compression")?;
147
148    encoder
149        .write_all(CACHE_V5_MAGIC)
150        .context("Failed to write cache V5 payload header")?;
151
152    let versioned = DbCacheVersioned::V5(Box::new(cache));
153    let bincode_config = bincode::config::standard().with_variable_int_encoding();
154
155    bincode::serde::encode_into_std_write(&versioned, &mut encoder, bincode_config)
156        .context("Failed bincode schema compilation and write")?;
157
158    encoder
159        .finish()
160        .context("Failed to flush final zstd stream to disk")?;
161
162    let cache_bytes = protect(compressed)?;
163    temp_file
164        .write_all(&cache_bytes)
165        .context("Failed to write cache payload")?;
166    temp_file.flush().context("Failed to flush cache payload")?;
167
168    replace_cache(temp_file, out_path)?;
169
170    Ok(())
171}
172
173#[cfg(not(windows))]
174fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
175    temp_file
176        .persist(out_path)
177        .map_err(|error| error.error)
178        .with_context(|| {
179            format!(
180                "Failed to atomically replace cache file: {}",
181                out_path.display()
182            )
183        })?;
184    Ok(())
185}
186
187#[cfg(windows)]
188fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
189    if !out_path.exists() {
190        temp_file
191            .persist(out_path)
192            .map_err(|error| error.error)
193            .with_context(|| format!("Failed to install cache file: {}", out_path.display()))?;
194        return Ok(());
195    }
196
197    let backup = out_path.with_extension("safe-migrate.backup");
198    fs::rename(out_path, &backup).with_context(|| {
199        format!(
200            "Failed to stage existing cache for replacement: {}",
201            out_path.display()
202        )
203    })?;
204
205    match temp_file.persist(out_path) {
206        Ok(_) => {
207            fs::remove_file(&backup).with_context(|| {
208                format!(
209                    "Installed new cache but failed to remove backup: {}",
210                    backup.display()
211                )
212            })?;
213            Ok(())
214        }
215        Err(error) => {
216            let restore_result = fs::rename(&backup, out_path);
217            let message = if let Err(restore_error) = restore_result {
218                format!(
219                    "Failed to install new cache: {}. The old cache could not be restored: {}",
220                    error.error, restore_error
221                )
222            } else {
223                format!(
224                    "Failed to install new cache; restored the previous cache: {}",
225                    error.error
226                )
227            };
228            Err(anyhow::anyhow!(message))
229        }
230    }
231}
232
233pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result<DbCache> {
234    let mut cache = DbCache::new();
235    let schema_values = schemas.map(|items| items.to_vec());
236    cache.metadata.created_at_unix_secs = Some(
237        SystemTime::now()
238            .duration_since(UNIX_EPOCH)
239            .unwrap_or_default()
240            .as_secs(),
241    );
242    cache.metadata.schemas = schema_values.clone();
243
244    let schema_filter = "AND ($1::text[] IS NULL OR n.nspname = ANY($1))";
245    let schema_filter_with_fk = r#"
246        AND (
247            $1::text[] IS NULL
248            OR n.nspname = ANY($1)
249            OR c.oid IN (
250                SELECT conrelid FROM pg_constraint cst
251                JOIN pg_class c2 ON c2.oid = cst.confrelid
252                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
253                WHERE n2.nspname = ANY($1)
254            )
255            OR c.oid IN (
256                SELECT confrelid FROM pg_constraint cst
257                JOIN pg_class c2 ON c2.oid = cst.conrelid
258                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
259                WHERE n2.nspname = ANY($1)
260            )
261        )
262    "#;
263    let schema_filter_n1_or_n2 =
264        "AND ($1::text[] IS NULL OR n1.nspname = ANY($1) OR n2.nspname = ANY($1))";
265    let schema_filter_nt = r#"
266        AND (
267            $1::text[] IS NULL
268            OR n_t.nspname = ANY($1)
269            OR t.oid IN (
270                SELECT conrelid FROM pg_constraint cst
271                JOIN pg_class c2 ON c2.oid = cst.confrelid
272                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
273                WHERE n2.nspname = ANY($1)
274            )
275            OR t.oid IN (
276                SELECT confrelid FROM pg_constraint cst
277                JOIN pg_class c2 ON c2.oid = cst.conrelid
278                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
279                WHERE n2.nspname = ANY($1)
280            )
281        )
282    "#;
283
284    // Query 1: Server Version
285    let version_row = client.query_one("SHOW server_version_num;", &[])?;
286    let version_str: String = version_row.get(0);
287    cache.pg_version_num = version_str.parse::<u32>().ok();
288
289    let provenance_row = client.query_one(
290        "SELECT current_database(), current_user, session_user, current_setting('search_path');",
291        &[],
292    )?;
293    cache.metadata.source_database = Some(provenance_row.get(0));
294    cache.metadata.source_role = Some(provenance_row.get(1));
295    cache.metadata.source_session_role = Some(provenance_row.get(2));
296    let search_path_setting: String = provenance_row.get(3);
297    cache.metadata.source_search_path = Some(parse_search_path_setting(&search_path_setting));
298
299    // Resolve role/database defaults and special entries such as "$user" exactly
300    // as PostgreSQL does, while excluding the implicit pg_catalog lookup. An
301    // explicit schema scope remains the resolution boundary, but selected
302    // schemas retain their live PostgreSQL priority.
303    let search_path_row = client.query_one("SELECT current_schemas(false);", &[])?;
304    cache.search_path = cache_search_path(search_path_row.get(0), schemas);
305
306    // Schemas are an authoritative catalog only for the requested sync scope.
307    // FK-only external schemas pulled in below deliberately do not enter it.
308    let schema_query = format!(
309        "SELECT n.nspname, pg_catalog.pg_get_userbyid(n.nspowner)
310         FROM pg_namespace n
311         WHERE n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
312           AND n.nspname <> 'information_schema'
313           {schema_filter}
314         ORDER BY n.nspname;"
315    );
316    for row in client.query(&schema_query, &[&schema_values])? {
317        let name: String = row.get(0);
318        let owner: String = row.get(1);
319        cache.schemas.insert(
320            name.clone(),
321            crate::model::schema::SchemaState {
322                name,
323                owner: relation_owner_id(owner),
324                generation: 0,
325            },
326        );
327    }
328    // A scoped request can name schemas that do not exist yet. PostgreSQL's
329    // effective search path skips those entries, so do not let them become
330    // inferred-present namespaces when the cache is hydrated.
331    cache
332        .search_path
333        .retain(|schema| cache.schemas.contains_key(schema));
334
335    // A sequence can have at most one pg_depend ownership relationship. The
336    // dependency flavor distinguishes identity's internal dependency from an
337    // ordinary OWNED BY relationship. An auto dependency is serial-like only
338    // when the owning column also has the sequence-backed nextval default.
339    let sequence_query = format!(
340        "SELECT
341             n.nspname AS sequence_schema,
342             s.relname AS sequence_name,
343             pg_catalog.pg_get_userbyid(s.relowner) AS owner_name,
344             tn.nspname AS table_schema,
345             t.relname AS table_name,
346             a.attname AS column_name,
347             d.deptype::text AS dependency_type,
348             CASE WHEN ad.adbin IS NULL THEN false
349                  ELSE pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) LIKE '%nextval(%'
350             END AS has_nextval_default
351         FROM pg_class s
352         JOIN pg_namespace n ON n.oid = s.relnamespace
353         LEFT JOIN pg_depend d
354           ON d.classid = 'pg_class'::regclass
355          AND d.objid = s.oid
356          AND d.objsubid = 0
357          AND d.refclassid = 'pg_class'::regclass
358          AND d.deptype IN ('a', 'i')
359         LEFT JOIN pg_class t ON t.oid = d.refobjid
360         LEFT JOIN pg_namespace tn ON tn.oid = t.relnamespace
361         LEFT JOIN pg_attribute a
362           ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
363         LEFT JOIN pg_attrdef ad
364           ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
365         WHERE s.relkind = 'S'
366           AND n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
367           AND n.nspname <> 'information_schema'
368           {schema_filter}
369         ORDER BY n.nspname, s.relname;"
370    );
371    for row in client.query(&sequence_query, &[&schema_values])? {
372        let id = ObjectId::new(row.get::<_, String>(0), row.get::<_, String>(1));
373        let owner = relation_owner_id(row.get::<_, String>(2));
374        let table_schema: Option<String> = row.get(3);
375        let table_name: Option<String> = row.get(4);
376        let column_name: Option<String> = row.get(5);
377        let dependency_type: Option<String> = row.get(6);
378        let has_nextval_default: bool = row.get(7);
379        let owned_by = table_schema
380            .zip(table_name)
381            .zip(column_name)
382            .map(|((schema, table), column)| (ObjectId::new(schema, table), column));
383        let kind = match dependency_type.as_deref() {
384            Some("i") => crate::model::sequence::SequenceKind::Identity,
385            Some("a") if has_nextval_default => crate::model::sequence::SequenceKind::SerialLike,
386            Some("a") => crate::model::sequence::SequenceKind::Owned,
387            _ => crate::model::sequence::SequenceKind::Standalone,
388        };
389        cache.sequences.insert(
390            id.clone(),
391            crate::model::sequence::SequenceState {
392                id,
393                owner,
394                owned_by,
395                kind,
396                generation: 0,
397            },
398        );
399    }
400
401    // Query 2: Relations + Staleness
402    let table_query = format!(
403        "
404        SELECT
405            n.nspname AS schema_name,
406            c.relname AS relation_name,
407            c.relkind AS relation_kind,
408            c.relpersistence AS persistence,
409            pg_catalog.pg_get_userbyid(c.relowner) AS owner_name,
410            CASE WHEN c.reltuples < 0 THEN -1 ELSE c.reltuples::bigint END AS estimated_rows,
411            c.relpages::bigint AS relpages,
412            to_char(s.last_analyze, 'YYYY-MM-DD HH24:MI:SS') AS last_analyze,
413            to_char(s.last_autoanalyze, 'YYYY-MM-DD HH24:MI:SS') AS last_autoanalyze,
414            p.partstrat::text AS partition_strategy
415        FROM pg_class c
416        JOIN pg_namespace n ON n.oid = c.relnamespace
417        LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
418        LEFT JOIN pg_partitioned_table p ON p.partrelid = c.oid
419        WHERE c.relkind IN ('r', 'p', 'v', 'm')
420          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
421          {schema_filter_with_fk};
422    "
423    );
424
425    for row in client.query(&table_query, &[&schema_values])? {
426        let schema_name: String = row.get("schema_name");
427        let relation_name: String = row.get("relation_name");
428        let relkind: i8 = row.get("relation_kind");
429        let persistence_char: i8 = row.get("persistence");
430        let owner_name: String = row.get("owner_name");
431        let raw_rows: i64 = row.get("estimated_rows");
432        let relpages: i64 = row.get("relpages");
433
434        let last_analyze: Option<String> = row.get("last_analyze");
435        let last_autoanalyze: Option<String> = row.get("last_autoanalyze");
436
437        let object_id = ObjectId::new(&schema_name, &relation_name);
438
439        let kind = match relkind as u8 {
440            b'v' => RelationKind::View,
441            b'm' => RelationKind::MaterializedView,
442            _ => RelationKind::Table,
443        };
444
445        let persistence = match persistence_char as u8 {
446            b't' => Persistence::Temporary,
447            b'u' => Persistence::Unlogged,
448            _ => Persistence::Permanent,
449        };
450
451        let estimated_rows = if raw_rows < 0 {
452            None
453        } else {
454            Some(raw_rows as u64)
455        };
456
457        let mut state = RelationState::new(
458            object_id.clone(),
459            relation_owner_id(owner_name),
460            0,
461            estimated_rows,
462            kind,
463            persistence,
464            0,
465        );
466        state.relpages = Some(relpages as u64);
467        state.last_analyze = last_analyze;
468        state.last_autoanalyze = last_autoanalyze;
469
470        let partition_strategy: Option<String> = row.get("partition_strategy");
471        if let Some(ref strat) = partition_strategy {
472            state.partition_type = Some(match strat.as_str() {
473                "r" => "RANGE".to_string(),
474                "l" => "LIST".to_string(),
475                "h" => "HASH".to_string(),
476                _ => strat.to_uppercase(),
477            });
478        }
479
480        if let Some(s) = schemas
481            && !s.contains(&schema_name)
482        {
483            state.mark_fk_dependency();
484        }
485
486        cache.insert_baseline(object_id, state);
487    }
488
489    // Query 3: Columns + Width
490    let col_query = format!("
491        SELECT
492            n.nspname AS schema_name,
493            c.relname AS relation_name,
494            a.attname AS column_name,
495            pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name,
496            a.attnotnull AS not_null,
497            s.avg_width AS avg_width,
498            pg_get_expr(ad.adbin, ad.adrelid) AS default_expr_text,
499            a.atttypmod AS type_modifier
500        FROM pg_attribute a
501        JOIN pg_class c ON a.attrelid = c.oid
502        JOIN pg_namespace n ON n.oid = c.relnamespace
503        LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname
504        LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
505        WHERE a.attnum > 0 AND NOT a.attisdropped
506          AND c.relkind IN ('r', 'p', 'v', 'm')
507          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
508          {schema_filter_with_fk}
509        ORDER BY n.nspname, c.relname;
510    ");
511
512    for row in client.query(&col_query, &[&schema_values])? {
513        let schema_name: String = row.get("schema_name");
514        let relation_name: String = row.get("relation_name");
515        let column_name: String = row.get("column_name");
516        let type_name: String = row.get("type_name");
517        let not_null: bool = row.get("not_null");
518        let avg_width: Option<i32> = row.get("avg_width");
519        let default_expr_text: Option<String> = row.get("default_expr_text");
520        let type_modifier: Option<i32> = row.get("type_modifier");
521
522        let relation_id = ObjectId::new(&schema_name, &relation_name);
523        if let Some(rel) = cache.relations.get_mut(&relation_id) {
524            rel.columns.push(crate::model::column::Column {
525                name: column_name,
526                data_type: Some(type_name),
527                type_id: None,
528                is_nullable: !not_null,
529                default: None,
530                avg_width,
531                default_expr_text,
532                type_modifier,
533            });
534        }
535    }
536
537    // Query 4: Triggers & Policies
538    let tp_query = format!("
539        SELECT 
540            n.nspname AS schema_name,
541            c.relname AS relation_name,
542            COALESCE(array_agg(DISTINCT t.tgname) FILTER (WHERE t.tgname IS NOT NULL AND t.tgisinternal = false), '{{}}') as triggers,
543            COALESCE(array_agg(DISTINCT p.polname) FILTER (WHERE p.polname IS NOT NULL), '{{}}') as policies
544        FROM pg_class c
545        JOIN pg_namespace n ON n.oid = c.relnamespace
546        LEFT JOIN pg_trigger t ON t.tgrelid = c.oid
547        LEFT JOIN pg_policy p ON p.polrelid = c.oid
548        WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
549        {schema_filter_with_fk}
550        GROUP BY n.nspname, c.relname;
551    ");
552
553    for row in client.query(&tp_query, &[&schema_values])? {
554        let schema_name: String = row.get("schema_name");
555        let relation_name: String = row.get("relation_name");
556        let triggers: Vec<String> = row.get("triggers");
557        let policies: Vec<String> = row.get("policies");
558
559        let object_id = ObjectId::new(&schema_name, &relation_name);
560
561        if let Some(rel) = cache.relations.get_mut(&object_id) {
562            rel.triggers.extend(triggers);
563            rel.policies.extend(policies);
564        }
565    }
566
567    // Query 4.25: Explicit non-owner relation privileges.
568    let acl_query = format!(
569        "
570        SELECT
571            n.nspname AS schema_name,
572            c.relname AS relation_name,
573            CASE
574                WHEN acl.grantee = 0 THEN 'public'
575                ELSE pg_catalog.pg_get_userbyid(acl.grantee)
576            END AS grantee,
577            acl.privilege_type
578        FROM pg_class c
579        JOIN pg_namespace n ON n.oid = c.relnamespace
580        CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) acl
581        WHERE c.relkind IN ('r', 'p', 'v', 'm')
582          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
583          AND acl.grantee <> c.relowner
584          {schema_filter_with_fk};
585        "
586    );
587
588    for row in client.query(&acl_query, &[&schema_values])? {
589        let schema_name: String = row.get("schema_name");
590        let relation_name: String = row.get("relation_name");
591        let grantee: String = row.get("grantee");
592        let privilege_type: String = row.get("privilege_type");
593        let privilege = match privilege_type.as_str() {
594            "SELECT" => crate::model::relation::Privilege::Select,
595            "INSERT" => crate::model::relation::Privilege::Insert,
596            "UPDATE" => crate::model::relation::Privilege::Update,
597            "DELETE" => crate::model::relation::Privilege::Delete,
598            "TRUNCATE" => crate::model::relation::Privilege::Truncate,
599            "REFERENCES" => crate::model::relation::Privilege::References,
600            "TRIGGER" => crate::model::relation::Privilege::Trigger,
601            _ => continue,
602        };
603        if let Some(relation) = cache
604            .relations
605            .get_mut(&ObjectId::new(&schema_name, &relation_name))
606        {
607            relation.privileges.grant(
608                ObjectId::new("", grantee),
609                [privilege].into_iter().collect(),
610            );
611        }
612    }
613
614    // Query 4.5: Trigger Functions
615    let trig_query = format!(
616        "
617        SELECT 
618            n.nspname AS table_schema,
619            c.relname AS table_name,
620            t.tgname AS trigger_name,
621            t.tgenabled::text AS enabled_mode,
622            fn.nspname AS function_schema,
623            f.proname || '()' AS function_name
624        FROM pg_trigger t
625        JOIN pg_class c ON c.oid = t.tgrelid
626        JOIN pg_namespace n ON n.oid = c.relnamespace
627        JOIN pg_proc f ON f.oid = t.tgfoid
628        JOIN pg_namespace fn ON fn.oid = f.pronamespace
629        WHERE t.tgisinternal = false
630          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
631          {schema_filter_with_fk};
632    "
633    );
634
635    for row in client.query(&trig_query, &[&schema_values])? {
636        let table_schema: String = row.get("table_schema");
637        let table_name: String = row.get("table_name");
638        let trigger_name: String = row.get("trigger_name");
639        let enabled_mode: String = row.get("enabled_mode");
640        let function_schema: String = row.get("function_schema");
641        let function_name: String = row.get("function_name");
642
643        cache.triggers.push(crate::db::cache::TriggerCache {
644            trigger_id: ObjectId::new(&table_schema, &trigger_name),
645            table_id: ObjectId::new(&table_schema, &table_name),
646            function_id: ObjectId::new(&function_schema, &function_name),
647            enabled_mode: crate::model::trigger::TriggerEnableMode::from_pg_code(&enabled_mode)
648                .ok_or_else(|| {
649                    anyhow::anyhow!("unknown pg_trigger.tgenabled value {enabled_mode}")
650                })?,
651        });
652    }
653
654    // Query 4.75: Table constraints
655    let constraint_query = format!(
656        "
657        SELECT
658            n.nspname AS table_schema,
659            c.relname AS table_name,
660            con.conname AS constraint_name,
661            con.contype::text AS constraint_type,
662            con.convalidated AS validated
663        FROM pg_constraint con
664        JOIN pg_class c ON c.oid = con.conrelid
665        JOIN pg_namespace n ON n.oid = c.relnamespace
666        WHERE con.contype IN ('c', 'f', 'p', 'u', 'x')
667          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
668          {schema_filter};
669        "
670    );
671
672    for row in client.query(&constraint_query, &[&schema_values])? {
673        let table_schema: String = row.get("table_schema");
674        let table_name: String = row.get("table_name");
675        let constraint_name: String = row.get("constraint_name");
676        let constraint_type: String = row.get("constraint_type");
677        let validated: bool = row.get("validated");
678        let kind = match constraint_type.as_str() {
679            "c" => crate::model::constraint::ConstraintKind::Check,
680            "f" => crate::model::constraint::ConstraintKind::ForeignKey,
681            "p" => crate::model::constraint::ConstraintKind::PrimaryKey,
682            "u" => crate::model::constraint::ConstraintKind::Unique,
683            "x" => crate::model::constraint::ConstraintKind::Exclusion,
684            _ => continue,
685        };
686        cache
687            .constraints
688            .push(crate::model::constraint::ConstraintState {
689                table_id: ObjectId::new(&table_schema, &table_name),
690                name: constraint_name,
691                kind,
692                validated,
693            });
694    }
695
696    // Query 5: Foreign Keys
697    let fk_query = format!(
698        "
699        SELECT 
700            c.conname AS constraint_name,
701            n1.nspname AS from_schema, t1.relname AS from_table,
702            n2.nspname AS to_schema, t2.relname AS to_table
703        FROM pg_constraint c
704        JOIN pg_class t1 ON t1.oid = c.conrelid
705        JOIN pg_namespace n1 ON n1.oid = t1.relnamespace
706        JOIN pg_class t2 ON t2.oid = c.confrelid
707        JOIN pg_namespace n2 ON n2.oid = t2.relnamespace
708        WHERE c.contype = 'f'
709        {schema_filter_n1_or_n2};
710    "
711    );
712
713    for row in client.query(&fk_query, &[&schema_values])? {
714        let constraint_name: String = row.get("constraint_name");
715        let from_schema: String = row.get("from_schema");
716        let from_table: String = row.get("from_table");
717        let to_schema: String = row.get("to_schema");
718        let to_table: String = row.get("to_table");
719
720        if let Some(s) = schemas
721            && (!s.contains(&from_schema) || !s.contains(&to_schema))
722        {
723            // Determine which one is out of scope to print a helpful warning
724            let out_of_scope_schema = if !s.contains(&from_schema) {
725                &from_schema
726            } else {
727                &to_schema
728            };
729            let out_of_scope_table = if !s.contains(&from_schema) {
730                &from_table
731            } else {
732                &to_table
733            };
734            eprintln!(
735                "[WARN] Foreign key '{}' crosses schema boundary. Table '{}.{}' was pulled into cache as a dependency to evaluate cross-team locks.",
736                constraint_name, out_of_scope_schema, out_of_scope_table
737            );
738        }
739
740        cache.foreign_keys.push(ForeignKeyCache {
741            constraint_name,
742            from_table: ObjectId::new(&from_schema, &from_table),
743            to_table: ObjectId::new(&to_schema, &to_table),
744        });
745    }
746
747    // Query 6: Indexes
748    let idx_query = format!(
749        "
750        SELECT 
751            n_i.nspname AS index_schema, i.relname AS index_name,
752            n_t.nspname AS table_schema, t.relname AS table_name
753        FROM pg_index x
754        JOIN pg_class i ON i.oid = x.indexrelid
755        JOIN pg_namespace n_i ON n_i.oid = i.relnamespace
756        JOIN pg_class t ON t.oid = x.indrelid
757        JOIN pg_namespace n_t ON n_t.oid = t.relnamespace
758        WHERE x.indisvalid = true
759          AND n_i.nspname !~ '^pg_'
760          AND n_i.nspname <> 'information_schema'
761          AND n_t.nspname !~ '^pg_'
762          AND n_t.nspname <> 'information_schema'
763        {schema_filter_nt};
764    "
765    );
766
767    for row in client.query(&idx_query, &[&schema_values])? {
768        let index_schema: String = row.get("index_schema");
769        let index_name: String = row.get("index_name");
770        let table_schema: String = row.get("table_schema");
771        let table_name: String = row.get("table_name");
772
773        if is_system_schema(&index_schema) || is_system_schema(&table_schema) {
774            continue;
775        }
776
777        cache.indexes.push(IndexCache {
778            index_id: ObjectId::new(&index_schema, &index_name),
779            table_id: ObjectId::new(&table_schema, &table_name),
780        });
781    }
782
783    // Query 7: Functions
784    let func_query = format!(
785        "
786        SELECT
787            n.nspname AS schema_name,
788            p.proname AS func_name,
789            COALESCE(
790                (SELECT string_agg(pg_catalog.format_type(t, NULL), ',' ORDER BY n)
791                 FROM unnest(p.proargtypes::int[]) WITH ORDINALITY AS u(t, n)),
792                ''
793            ) AS arg_types,
794            pg_catalog.pg_get_function_result(p.oid) AS return_type,
795            p.provolatile::text AS volatility,
796            l.lanname AS language,
797            p.prosecdef AS security_definer
798        FROM pg_proc p
799        JOIN pg_namespace n ON n.oid = p.pronamespace
800        JOIN pg_language l ON l.oid = p.prolang
801        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
802          AND p.prokind = 'f'
803          {schema_filter};
804    "
805    );
806
807    for row in client.query(&func_query, &[&schema_values])? {
808        let schema_name: String = row.get("schema_name");
809        let func_name: String = row.get("func_name");
810        let arg_types_str: String = row.get("arg_types");
811        let return_type: Option<String> = row.get("return_type");
812        let volatility_char: String = row.get("volatility");
813        let language: String = row.get("language");
814        let security_definer: bool = row.get("security_definer");
815
816        let volatility = match volatility_char.as_str() {
817            "v" => crate::model::function::Volatility::Volatile,
818            "s" => crate::model::function::Volatility::Stable,
819            "i" => crate::model::function::Volatility::Immutable,
820            _ => crate::model::function::Volatility::Volatile,
821        };
822
823        let security = if security_definer {
824            crate::model::function::SecurityMode::Definer
825        } else {
826            crate::model::function::SecurityMode::Invoker
827        };
828
829        // Normalize argument types in sync just like in resolver
830        let arg_types_str = arg_types_str
831            .split(',')
832            .map(crate::analysis::resolver::Resolver::normalize_function_arg_type)
833            .collect::<Vec<_>>()
834            .join(",");
835
836        let id = ObjectId::new(&schema_name, format!("{}({})", func_name, arg_types_str));
837
838        let arg_types = if arg_types_str.is_empty() {
839            Vec::new()
840        } else {
841            arg_types_str.split(',').map(|s| s.to_string()).collect()
842        };
843
844        cache.functions.insert(
845            id.clone(),
846            crate::model::function::FunctionState {
847                id,
848                arg_types,
849                arg_type_ids: Vec::new(),
850                return_type: return_type.unwrap_or_default(),
851                return_type_id: None,
852                volatility,
853                language,
854                security,
855            },
856        );
857    }
858
859    // Query 8: User-defined types, including ordered enum labels and domains.
860    let type_query = format!(
861        "
862        SELECT
863            n.nspname AS schema_name,
864            t.typname AS type_name,
865            t.typtype::text AS type_kind,
866            CASE WHEN t.typtype = 'd'
867                THEN pg_catalog.format_type(t.typbasetype, t.typtypmod)
868                ELSE NULL
869            END AS domain_base_type,
870            COALESCE(
871                array_agg(e.enumlabel ORDER BY e.enumsortorder)
872                    FILTER (WHERE e.enumlabel IS NOT NULL),
873                ARRAY[]::text[]
874            ) AS enum_labels
875        FROM pg_type t
876        JOIN pg_namespace n ON n.oid = t.typnamespace
877        LEFT JOIN pg_enum e ON e.enumtypid = t.oid
878        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
879          AND t.typtype IN ('e', 'd')
880          {schema_filter}
881        GROUP BY n.nspname, t.typname, t.typtype, t.typbasetype, t.typtypmod;
882        "
883    );
884
885    for row in client.query(&type_query, &[&schema_values])? {
886        let schema_name: String = row.get("schema_name");
887        let type_name: String = row.get("type_name");
888        let type_kind: String = row.get("type_kind");
889        let domain_base_type: Option<String> = row.get("domain_base_type");
890        let enum_labels: Vec<String> = row.get("enum_labels");
891        let kind = match type_kind.as_str() {
892            "e" => crate::model::types::TypeKind::Enum {
893                variants: enum_labels,
894            },
895            "d" => crate::model::types::TypeKind::Domain {
896                base_type: domain_base_type.unwrap_or_default(),
897                base_type_id: None,
898            },
899            _ => continue,
900        };
901        let id = ObjectId::new(&schema_name, &type_name);
902        cache.types.insert(
903            id.clone(),
904            crate::model::types::TypeState {
905                id,
906                generation: 0,
907                kind,
908            },
909        );
910    }
911
912    // Query 9: Dependencies (pg_depend)
913    let depend_query = r#"
914        SELECT
915            d.classid, d.objid, d.objsubid,
916            d.refclassid, d.refobjid, d.refobjsubid,
917            d.deptype::text,
918            COALESCE(n1.nspname, n1p.nspname, n1t.nspname) AS obj_schema,
919            COALESCE(c1.relname, p1.proname, t1.typname) AS obj_name,
920            COALESCE(n2.nspname, n2p.nspname, n2t.nspname) AS ref_schema,
921            COALESCE(c2.relname, p2.proname, t2.typname) AS ref_name
922        FROM pg_depend d
923        LEFT JOIN pg_class c1 ON c1.oid = d.objid AND d.classid = 'pg_class'::regclass
924        LEFT JOIN pg_namespace n1 ON n1.oid = c1.relnamespace
925        LEFT JOIN pg_proc p1 ON p1.oid = d.objid AND d.classid = 'pg_proc'::regclass
926        LEFT JOIN pg_namespace n1p ON n1p.oid = p1.pronamespace
927        LEFT JOIN pg_type t1 ON t1.oid = d.objid AND d.classid = 'pg_type'::regclass
928        LEFT JOIN pg_namespace n1t ON n1t.oid = t1.typnamespace
929        LEFT JOIN pg_class c2 ON c2.oid = d.refobjid AND d.refclassid = 'pg_class'::regclass
930        LEFT JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
931        LEFT JOIN pg_proc p2 ON p2.oid = d.refobjid AND d.refclassid = 'pg_proc'::regclass
932        LEFT JOIN pg_namespace n2p ON n2p.oid = p2.pronamespace
933        LEFT JOIN pg_type t2 ON t2.oid = d.refobjid AND d.refclassid = 'pg_type'::regclass
934        LEFT JOIN pg_namespace n2t ON n2t.oid = t2.typnamespace
935        WHERE d.deptype IN ('n', 'a', 'i')
936          AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname) IS NOT NULL
937          AND COALESCE(n1.nspname, n1p.nspname, n1t.nspname)
938              NOT IN ('pg_catalog', 'information_schema')
939          AND (
940              $1::text[] IS NULL
941              OR COALESCE(n1.nspname, n1p.nspname, n1t.nspname) = ANY($1)
942          )
943    "#;
944
945    for row in client.query(depend_query, &[&schema_values])? {
946        let classid: u32 = row.get(0);
947        let objid: u32 = row.get(1);
948        let objsubid: i32 = row.get(2);
949        let refclassid: u32 = row.get(3);
950        let refobjid: u32 = row.get(4);
951        let refobjsubid: i32 = row.get(5);
952        let deptype: String = row.get(6);
953        let obj_schema: Option<String> = row.get(7);
954        let obj_name: Option<String> = row.get(8);
955        let ref_schema: Option<String> = row.get(9);
956        let ref_name: Option<String> = row.get(10);
957
958        cache.dependencies.push(crate::db::cache::DependencyCache {
959            classid,
960            objid,
961            objsubid,
962            refclassid,
963            refobjid,
964            refobjsubid,
965            deptype,
966            obj_schema,
967            obj_name,
968            ref_schema,
969            ref_name,
970        });
971    }
972
973    // View dependencies are owned by pg_rewrite entries, so the generic pg_depend
974    // query above cannot recover the dependent view's schema-qualified identity.
975    let view_depend_query = r#"
976        SELECT DISTINCT
977            'pg_class'::regclass::oid AS classid,
978            vc.oid AS objid,
979            0 AS objsubid,
980            'pg_class'::regclass::oid AS refclassid,
981            tc.oid AS refobjid,
982            0 AS refobjsubid,
983            vn.nspname AS obj_schema,
984            vc.relname AS obj_name,
985            tn.nspname AS ref_schema,
986            tc.relname AS ref_name
987        FROM pg_rewrite rw
988        JOIN pg_class vc ON vc.oid = rw.ev_class
989        JOIN pg_namespace vn ON vn.oid = vc.relnamespace
990        JOIN pg_depend d ON d.objid = rw.oid
991        JOIN pg_class tc ON tc.oid = d.refobjid
992        JOIN pg_namespace tn ON tn.oid = tc.relnamespace
993        WHERE vc.relkind IN ('v', 'm')
994          AND d.deptype = 'n'
995          -- PostgreSQL 14/15 expose an internal rewrite-rule self-edge. It is
996          -- not a dependency of the view definition and must not enter the
997          -- modeled dependency graph.
998          AND tc.oid <> vc.oid
999          AND (
1000              $1::text[] IS NULL
1001              OR (vn.nspname = ANY($1) AND tn.nspname = ANY($1))
1002          )
1003    "#;
1004
1005    for row in client.query(view_depend_query, &[&schema_values])? {
1006        cache.dependencies.push(crate::db::cache::DependencyCache {
1007            classid: row.get(0),
1008            objid: row.get(1),
1009            objsubid: row.get(2),
1010            refclassid: row.get(3),
1011            refobjid: row.get(4),
1012            refobjsubid: row.get(5),
1013            deptype: "view".to_string(),
1014            obj_schema: Some(row.get(6)),
1015            obj_name: Some(row.get(7)),
1016            ref_schema: Some(row.get(8)),
1017            ref_name: Some(row.get(9)),
1018        });
1019    }
1020
1021    // Role identity and membership are required to distinguish a valid
1022    // `SET ROLE` from a migration that PostgreSQL would reject. pg_roles does
1023    // not expose password hashes or other credentials.
1024    for row in client.query(
1025        "SELECT rolname, rolcanlogin, rolsuper FROM pg_roles ORDER BY rolname;",
1026        &[],
1027    )? {
1028        let name: String = row.get(0);
1029        let id = ObjectId::new("", &name);
1030        cache.roles.insert(
1031            id.clone(),
1032            crate::model::role::RoleState {
1033                id,
1034                can_login: row.get(1),
1035                is_superuser: row.get(2),
1036                member_of: Vec::new(),
1037                can_set_role_to: Vec::new(),
1038                granted_privileges: Vec::new(),
1039            },
1040        );
1041    }
1042
1043    let membership_query = if cache.pg_version_num.unwrap_or_default() >= 160_000 {
1044        "SELECT member.rolname, parent.rolname, membership.set_option
1045         FROM pg_auth_members membership
1046         JOIN pg_roles member ON member.oid = membership.member
1047         JOIN pg_roles parent ON parent.oid = membership.roleid;"
1048    } else {
1049        "SELECT member.rolname, parent.rolname, true AS set_option
1050         FROM pg_auth_members membership
1051         JOIN pg_roles member ON member.oid = membership.member
1052         JOIN pg_roles parent ON parent.oid = membership.roleid;"
1053    };
1054    for row in client.query(membership_query, &[])? {
1055        let member = ObjectId::new("", row.get::<_, String>(0));
1056        let parent = ObjectId::new("", row.get::<_, String>(1));
1057        let set_option: bool = row.get(2);
1058        if let Some(role) = cache.roles.get_mut(&member) {
1059            role.member_of.push(parent.clone());
1060            if set_option {
1061                role.can_set_role_to.push(parent);
1062            }
1063        }
1064    }
1065
1066    Ok(cache)
1067}
1068
1069#[cfg(test)]
1070mod atomic_write_tests {
1071    use super::*;
1072    use crate::db::cache::DbCacheVersioned;
1073    use std::fs;
1074    use std::io::Read;
1075
1076    #[test]
1077    fn production_cache_writer_atomically_replaces_and_decodes() {
1078        let temp_dir = tempfile::tempdir().unwrap();
1079        let cache_path = temp_dir.path().join("baseline.cache");
1080        fs::write(&cache_path, b"old-cache").unwrap();
1081
1082        let mut cache = DbCache::new();
1083        cache.pg_version_num = Some(180002);
1084        write_cache(&cache_path, cache, false).unwrap();
1085
1086        let encoded = fs::read(&cache_path).unwrap();
1087        assert_ne!(encoded, b"old-cache");
1088        let reader = std::io::Cursor::new(encoded);
1089        let mut decoder = zstd::stream::Decoder::new(reader).unwrap();
1090        let mut payload = Vec::new();
1091        decoder.read_to_end(&mut payload).unwrap();
1092        let payload = payload
1093            .strip_prefix(CACHE_V5_MAGIC)
1094            .expect("writer must prefix V5 cache payloads");
1095        let config = bincode::config::standard().with_variable_int_encoding();
1096        let versioned: DbCacheVersioned = bincode::serde::decode_from_slice(payload, config)
1097            .unwrap()
1098            .0;
1099        assert_eq!(versioned.into_cache().unwrap().pg_version_num, Some(180002));
1100        assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
1101    }
1102
1103    #[test]
1104    fn production_cache_writer_preserves_old_bytes_after_pre_install_failure() {
1105        let temp_dir = tempfile::tempdir().unwrap();
1106        let cache_path = temp_dir.path().join("baseline.cache");
1107        fs::write(&cache_path, b"known-good-cache").unwrap();
1108
1109        let error = write_cache_with_protection(&cache_path, DbCache::new(), |_| {
1110            Err(anyhow::anyhow!("injected payload-protection failure"))
1111        })
1112        .unwrap_err();
1113
1114        assert!(
1115            error
1116                .to_string()
1117                .contains("injected payload-protection failure")
1118        );
1119        assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
1120        assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
1121    }
1122}