Skip to main content

safe_migrate/
sync.rs

1use crate::ast::identifiers::ObjectId;
2use crate::db::cache::{CACHE_V6_MAGIC, DbCache, DbCacheVersioned, ForeignKeyCache, IndexCache};
3use crate::db::cache_file::{
4    MAX_CACHE_DECODE_BYTES, MAX_CACHE_FILE_BYTES, protect_cache_bytes,
5    validate_cache_encryption_configuration,
6};
7use crate::model::relation::{Persistence, RelationKind, RelationState};
8use anyhow::{Context, Result};
9use postgres::config::Host;
10use postgres::{Client, Config as PostgresConfig, GenericClient, IsolationLevel, NoTls};
11use std::io::{self, Write};
12use std::path::Path;
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14use tempfile::NamedTempFile;
15
16#[cfg(windows)]
17use std::fs;
18
19const MIN_POSTGRES_VERSION_NUM: u32 = 140_000;
20const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
21
22pub fn sync_cache(
23    out_path: &Path,
24    schemas: Option<&[String]>,
25    cache_encryption: bool,
26) -> Result<()> {
27    validate_cache_encryption_configuration(cache_encryption)
28        .context("Invalid cache encryption configuration")?;
29    // Strict env-only credential enforcement
30    let db_url = std::env::var("DATABASE_URL")
31        .context("DATABASE_URL environment variable is required to sync PostgreSQL schema metadata and statistics. Do not pass credentials via CLI flags or config files.")?;
32    if db_url.trim().is_empty() {
33        anyhow::bail!("DATABASE_URL must not be empty or whitespace");
34    }
35
36    let mut client = connect_database(&db_url)?;
37
38    let cache = populate_cache(&mut client, schemas)?;
39
40    write_cache(out_path, cache, cache_encryption)
41}
42
43fn connect_database(db_url: &str) -> Result<Client> {
44    let mut config: PostgresConfig = db_url
45        .parse()
46        .context("DATABASE_URL is not a valid PostgreSQL connection string")?;
47
48    if !database_config_is_local(&config) {
49        anyhow::bail!(
50            "Remote DATABASE_URL connections are not supported by this build. Use an SSH tunnel and connect through localhost or a Unix socket."
51        );
52    }
53
54    apply_connection_safety_defaults(&mut config);
55
56    config
57        .connect(NoTls)
58        .context("Failed to connect to PostgreSQL")
59}
60
61fn apply_connection_safety_defaults(config: &mut PostgresConfig) {
62    if config.get_connect_timeout().is_none() {
63        config.connect_timeout(DEFAULT_CONNECT_TIMEOUT);
64    }
65}
66
67pub(crate) fn database_config_is_local(config: &PostgresConfig) -> bool {
68    config
69        .get_hostaddrs()
70        .iter()
71        .all(|address| address.is_loopback())
72        && config.get_hosts().iter().all(|host| match host {
73            #[cfg(unix)]
74            Host::Unix(_) => true,
75            Host::Tcp(name) => is_local_host(name),
76        })
77}
78
79pub(crate) fn ensure_supported_postgres_version(version: u32) -> Result<()> {
80    if version < MIN_POSTGRES_VERSION_NUM {
81        anyhow::bail!(
82            "PostgreSQL {} is unsupported; safe-migrate sync requires PostgreSQL 14 or newer",
83            version / 10_000
84        );
85    }
86    Ok(())
87}
88
89pub(crate) fn is_local_host(host: &str) -> bool {
90    if host.starts_with('/') || host.eq_ignore_ascii_case("localhost") {
91        return true;
92    }
93    host.trim_start_matches('[')
94        .trim_end_matches(']')
95        .parse::<std::net::IpAddr>()
96        .is_ok_and(|address| address.is_loopback())
97}
98
99pub(crate) fn cache_search_path(
100    database_search_path: Vec<String>,
101    schemas: Option<&[String]>,
102) -> Vec<String> {
103    let Some(schemas) = schemas else {
104        return database_search_path;
105    };
106
107    let mut scoped_search_path = Vec::new();
108    for schema in database_search_path
109        .into_iter()
110        .filter(|schema| schemas.contains(schema))
111        .chain(schemas.iter().cloned())
112    {
113        if !scoped_search_path.contains(&schema) {
114            scoped_search_path.push(schema);
115        }
116    }
117    scoped_search_path
118}
119
120/// Parse PostgreSQL's canonical `SHOW search_path` representation while
121/// preserving the special `$user` placeholder and quoted identifier casing.
122pub(crate) fn parse_search_path_setting(setting: &str) -> Vec<String> {
123    let mut entries = Vec::new();
124    let mut current = String::new();
125    let mut chars = setting.chars().peekable();
126    let mut quoted = false;
127
128    while let Some(ch) = chars.next() {
129        match ch {
130            '"' if quoted && chars.peek() == Some(&'"') => {
131                current.push('"');
132                chars.next();
133            }
134            '"' => quoted = !quoted,
135            ',' if !quoted => {
136                let entry = current.trim();
137                if !entry.is_empty() {
138                    entries.push(entry.to_string());
139                }
140                current.clear();
141            }
142            _ => current.push(ch),
143        }
144    }
145
146    let entry = current.trim();
147    if !entry.is_empty() {
148        entries.push(entry.to_string());
149    }
150    entries
151}
152
153pub(crate) fn relation_owner_id(owner_name: impl Into<String>) -> ObjectId {
154    ObjectId::new("", owner_name)
155}
156
157pub(crate) fn is_system_schema(schema: &str) -> bool {
158    schema == "information_schema" || schema.starts_with("pg_")
159}
160
161fn sequence_kind_from_pg(
162    dependency_type: Option<&str>,
163    has_nextval_default: bool,
164) -> Result<crate::model::sequence::SequenceKind> {
165    match dependency_type {
166        Some("i") => Ok(crate::model::sequence::SequenceKind::Identity),
167        Some("a") if has_nextval_default => Ok(crate::model::sequence::SequenceKind::SerialLike),
168        Some("a") => Ok(crate::model::sequence::SequenceKind::Owned),
169        None => Ok(crate::model::sequence::SequenceKind::Standalone),
170        Some(other) => anyhow::bail!("unsupported pg_depend type '{other}'"),
171    }
172}
173
174fn relation_kind_from_pg(code: u8) -> Result<RelationKind> {
175    match code {
176        b'r' | b'p' => Ok(RelationKind::Table),
177        b'v' => Ok(RelationKind::View),
178        b'm' => Ok(RelationKind::MaterializedView),
179        other => anyhow::bail!("unsupported pg_class.relkind byte {other}"),
180    }
181}
182
183fn persistence_from_pg(code: u8) -> Result<Persistence> {
184    match code {
185        b'p' => Ok(Persistence::Permanent),
186        b't' => Ok(Persistence::Temporary),
187        b'u' => Ok(Persistence::Unlogged),
188        other => anyhow::bail!("unsupported pg_class.relpersistence byte {other}"),
189    }
190}
191
192fn partition_strategy_from_pg(code: Option<&str>) -> Result<Option<String>> {
193    match code {
194        None => Ok(None),
195        Some("r") => Ok(Some("RANGE".to_string())),
196        Some("l") => Ok(Some("LIST".to_string())),
197        Some("h") => Ok(Some("HASH".to_string())),
198        Some(other) => anyhow::bail!("unsupported partition strategy '{other}'"),
199    }
200}
201
202fn routine_volatility_from_pg(code: &str) -> Result<crate::model::function::Volatility> {
203    match code {
204        "v" => Ok(crate::model::function::Volatility::Volatile),
205        "s" => Ok(crate::model::function::Volatility::Stable),
206        "i" => Ok(crate::model::function::Volatility::Immutable),
207        other => anyhow::bail!("unknown pg_proc.provolatile value '{other}'"),
208    }
209}
210
211fn routine_kind_from_pg(code: &str) -> Result<crate::model::function::RoutineKind> {
212    match code {
213        "f" => Ok(crate::model::function::RoutineKind::Function),
214        "p" => Ok(crate::model::function::RoutineKind::Procedure),
215        "a" => Ok(crate::model::function::RoutineKind::Aggregate),
216        "w" => Ok(crate::model::function::RoutineKind::Window),
217        other => anyhow::bail!("unknown pg_proc.prokind value '{other}'"),
218    }
219}
220
221fn subscription_streaming_from_pg(code: &str) -> Result<&'static str> {
222    match code {
223        "t" | "true" => Ok("true"),
224        "f" | "false" => Ok("false"),
225        "p" => Ok("parallel"),
226        other => anyhow::bail!("unknown subscription streaming mode '{other}'"),
227    }
228}
229
230fn subscription_two_phase_from_pg(code: &str) -> Result<&'static str> {
231    match code {
232        "d" => Ok("false"),
233        "e" => Ok("true"),
234        "p" => Ok("pending"),
235        other => anyhow::bail!("unknown subscription two-phase state '{other}'"),
236    }
237}
238
239fn write_cache(out_path: &Path, cache: DbCache, cache_encryption: bool) -> Result<()> {
240    write_cache_with_protection(out_path, cache, |compressed| {
241        protect_cache_bytes(compressed, cache_encryption)
242    })
243}
244
245fn write_cache_with_protection(
246    out_path: &Path,
247    cache: DbCache,
248    protect: impl FnOnce(Vec<u8>) -> Result<Vec<u8>>,
249) -> Result<()> {
250    write_cache_with_protection_and_limits(
251        out_path,
252        cache,
253        protect,
254        MAX_CACHE_FILE_BYTES,
255        MAX_CACHE_DECODE_BYTES,
256    )
257}
258
259fn write_cache_with_protection_and_limits(
260    out_path: &Path,
261    cache: DbCache,
262    protect: impl FnOnce(Vec<u8>) -> Result<Vec<u8>>,
263    max_file_bytes: u64,
264    max_decode_bytes: usize,
265) -> Result<()> {
266    cache
267        .validate_semantics()
268        .map_err(anyhow::Error::msg)
269        .context("Refusing to write a semantically invalid Cache V6 baseline")?;
270    let parent = cache_parent(out_path);
271    let mut temp_file = NamedTempFile::new_in(parent).with_context(|| {
272        format!(
273            "Failed to create temporary cache file beside {}",
274            out_path.display()
275        )
276    })?;
277    let mut compressed = Vec::new();
278    let encoder = zstd::stream::Encoder::new(&mut compressed, 3)
279        .context("Failed to init zstd compression")?;
280    let mut encoder = SizeLimitedWriter::new(encoder, max_decode_bytes);
281
282    if let Err(error) = encoder.write_all(CACHE_V6_MAGIC) {
283        if encoder.limit_exceeded() {
284            anyhow::bail!(
285                "Cache payload exceeds the {} MiB decoded-size limit",
286                max_decode_bytes / (1024 * 1024)
287            );
288        }
289        return Err(error).context("Failed to write cache V6 payload header");
290    }
291
292    let versioned = DbCacheVersioned::V6(Box::new(cache));
293    let bincode_config = bincode::config::standard().with_variable_int_encoding();
294
295    let encode_result =
296        bincode::serde::encode_into_std_write(&versioned, &mut encoder, bincode_config);
297    if encoder.limit_exceeded() {
298        anyhow::bail!(
299            "Cache payload exceeds the {} MiB decoded-size limit",
300            max_decode_bytes / (1024 * 1024)
301        );
302    }
303    encode_result.context("Failed bincode schema compilation and write")?;
304
305    let encoder = encoder.into_inner();
306    encoder
307        .finish()
308        .context("Failed to flush final zstd stream to disk")?;
309
310    let cache_bytes = protect(compressed)?;
311    let cache_file_bytes = u64::try_from(cache_bytes.len()).unwrap_or(u64::MAX);
312    if cache_file_bytes > max_file_bytes {
313        anyhow::bail!(
314            "Cache payload exceeds the {} MiB encoded-size limit",
315            max_file_bytes / (1024 * 1024)
316        );
317    }
318    temp_file
319        .write_all(&cache_bytes)
320        .context("Failed to write cache payload")?;
321    temp_file.flush().context("Failed to flush cache payload")?;
322    temp_file
323        .as_file()
324        .sync_all()
325        .context("Failed to synchronize cache payload before installation")?;
326
327    replace_cache(temp_file, out_path)?;
328
329    Ok(())
330}
331
332fn cache_parent(out_path: &Path) -> &Path {
333    out_path
334        .parent()
335        .filter(|parent| !parent.as_os_str().is_empty())
336        .unwrap_or_else(|| Path::new("."))
337}
338
339// This bounds decoded bytes entering zstd, not the compressed output size.
340struct SizeLimitedWriter<W> {
341    inner: W,
342    bytes_written: usize,
343    max_bytes: usize,
344    limit_exceeded: bool,
345}
346
347impl<W> SizeLimitedWriter<W> {
348    fn new(inner: W, max_bytes: usize) -> Self {
349        Self {
350            inner,
351            bytes_written: 0,
352            max_bytes,
353            limit_exceeded: false,
354        }
355    }
356
357    fn limit_exceeded(&self) -> bool {
358        self.limit_exceeded
359    }
360
361    fn into_inner(self) -> W {
362        self.inner
363    }
364}
365
366impl<W: Write> Write for SizeLimitedWriter<W> {
367    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
368        if bytes.len() > self.max_bytes.saturating_sub(self.bytes_written) {
369            self.limit_exceeded = true;
370            return Err(io::Error::new(
371                io::ErrorKind::InvalidData,
372                "cache decoded-size limit exceeded",
373            ));
374        }
375
376        let written = self.inner.write(bytes)?;
377        self.bytes_written = self.bytes_written.saturating_add(written);
378        Ok(written)
379    }
380
381    fn flush(&mut self) -> io::Result<()> {
382        self.inner.flush()
383    }
384}
385
386#[cfg(not(windows))]
387fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
388    temp_file
389        .persist(out_path)
390        .map_err(|error| error.error)
391        .with_context(|| {
392            format!(
393                "Failed to atomically replace cache file: {}",
394                out_path.display()
395            )
396        })?;
397    let parent = cache_parent(out_path);
398    std::fs::File::open(parent)
399        .and_then(|directory| directory.sync_all())
400        .with_context(|| {
401            format!(
402                "Installed cache but failed to synchronize its parent directory: {}",
403                parent.display()
404            )
405        })?;
406    Ok(())
407}
408
409#[cfg(windows)]
410fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> {
411    let backup = out_path.with_extension("safe-migrate.backup");
412    if backup.exists() {
413        if out_path.exists() {
414            fs::remove_file(&backup).with_context(|| {
415                format!(
416                    "Failed to remove stale cache backup before replacement: {}",
417                    backup.display()
418                )
419            })?;
420        } else {
421            fs::rename(&backup, out_path).with_context(|| {
422                format!(
423                    "Failed to restore interrupted cache replacement from backup: {}",
424                    backup.display()
425                )
426            })?;
427        }
428    }
429
430    if !out_path.exists() {
431        temp_file
432            .persist(out_path)
433            .map_err(|error| error.error)
434            .with_context(|| format!("Failed to install cache file: {}", out_path.display()))?;
435        return Ok(());
436    }
437
438    fs::rename(out_path, &backup).with_context(|| {
439        format!(
440            "Failed to stage existing cache for replacement: {}",
441            out_path.display()
442        )
443    })?;
444
445    match temp_file.persist(out_path) {
446        Ok(_) => {
447            fs::remove_file(&backup).with_context(|| {
448                format!(
449                    "Installed new cache but failed to remove backup: {}",
450                    backup.display()
451                )
452            })?;
453            Ok(())
454        }
455        Err(error) => {
456            let restore_result = fs::rename(&backup, out_path);
457            let message = if let Err(restore_error) = restore_result {
458                format!(
459                    "Failed to install new cache: {}. The old cache could not be restored: {}",
460                    error.error, restore_error
461                )
462            } else {
463                format!(
464                    "Failed to install new cache; restored the previous cache: {}",
465                    error.error
466                )
467            };
468            Err(anyhow::anyhow!(message))
469        }
470    }
471}
472
473pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result<DbCache> {
474    let mut transaction = client
475        .build_transaction()
476        .isolation_level(IsolationLevel::RepeatableRead)
477        .read_only(true)
478        .start()
479        .context("Failed to start read-only cache synchronization transaction")?;
480    let cache = populate_cache_from_client(&mut transaction, schemas)?;
481    transaction
482        .commit()
483        .context("Failed to commit cache synchronization transaction")?;
484    Ok(cache)
485}
486
487#[doc(hidden)]
488pub fn populate_cache_in_current_transaction(
489    client: &mut Client,
490    schemas: Option<&[String]>,
491) -> Result<DbCache> {
492    populate_cache_from_client(client, schemas)
493}
494
495fn load_view_dependencies(
496    client: &mut impl GenericClient,
497    schema_values: &Option<Vec<String>>,
498) -> Result<Vec<crate::db::cache::DependencyCache>> {
499    let query = r#"
500        SELECT DISTINCT
501            'pg_class'::regclass::oid AS classid,
502            vc.oid AS objid,
503            0 AS objsubid,
504            'pg_class'::regclass::oid AS refclassid,
505            tc.oid AS refobjid,
506            0 AS refobjsubid,
507            vn.nspname AS obj_schema,
508            vc.relname AS obj_name,
509            tn.nspname AS ref_schema,
510            tc.relname AS ref_name
511        FROM pg_rewrite rw
512        JOIN pg_class vc ON vc.oid = rw.ev_class
513        JOIN pg_namespace vn ON vn.oid = vc.relnamespace
514        JOIN pg_depend d ON d.objid = rw.oid
515        JOIN pg_class tc ON tc.oid = d.refobjid
516        JOIN pg_namespace tn ON tn.oid = tc.relnamespace
517            WHERE vc.relkind IN ('v', 'm')
518              AND d.classid = 'pg_rewrite'::regclass
519              AND d.refclassid = 'pg_class'::regclass
520              AND d.deptype = 'n'
521              AND tc.oid <> vc.oid
522              AND tc.relkind IN ('r', 'p', 'v', 'm')
523              AND vn.nspname NOT LIKE 'pg\_%' ESCAPE '\'
524              AND vn.nspname <> 'information_schema'
525              AND tn.nspname NOT LIKE 'pg\_%' ESCAPE '\'
526              AND tn.nspname <> 'information_schema'
527              AND (
528              $1::text[] IS NULL
529              OR vn.nspname = ANY($1)
530              OR tn.nspname = ANY($1)
531          )
532    "#;
533
534    let rows = client
535        .query(query, &[schema_values])
536        .context("Failed to load view dependencies from pg_rewrite/pg_depend")?;
537    rows.into_iter()
538        .map(|row| {
539            Ok(crate::db::cache::DependencyCache {
540                classid: row.try_get(0).context("view dependency classid")?,
541                objid: row.try_get(1).context("view dependency object oid")?,
542                objsubid: row.try_get(2).context("view dependency object sub-id")?,
543                refclassid: row
544                    .try_get(3)
545                    .context("view dependency referenced classid")?,
546                refobjid: row
547                    .try_get(4)
548                    .context("view dependency referenced object oid")?,
549                refobjsubid: row
550                    .try_get(5)
551                    .context("view dependency referenced object sub-id")?,
552                deptype: "view".to_string(),
553                obj_schema: Some(row.try_get(6).context("view dependency schema")?),
554                obj_name: Some(row.try_get(7).context("view dependency name")?),
555                ref_schema: Some(
556                    row.try_get(8)
557                        .context("view dependency referenced schema")?,
558                ),
559                ref_name: Some(row.try_get(9).context("view dependency referenced name")?),
560            })
561        })
562        .collect()
563}
564
565fn load_roles(
566    client: &mut impl GenericClient,
567    pg_version_num: u32,
568) -> Result<std::collections::HashMap<ObjectId, crate::model::role::RoleState>> {
569    let mut roles = std::collections::HashMap::new();
570    let rows = client
571        .query(
572            "SELECT rolname, rolcanlogin, rolsuper FROM pg_roles ORDER BY rolname;",
573            &[],
574        )
575        .context("Failed to load role identities from pg_roles")?;
576    for row in rows {
577        let name: String = row.try_get(0).context("role name")?;
578        let id = ObjectId::new("", &name);
579        roles.insert(
580            id.clone(),
581            crate::model::role::RoleState {
582                id,
583                can_login: row.try_get(1).context("role login capability")?,
584                is_superuser: row.try_get(2).context("role superuser capability")?,
585                member_of: Vec::new(),
586                can_set_role_to: Vec::new(),
587                granted_privileges: Vec::new(),
588            },
589        );
590    }
591
592    let membership_query = if pg_version_num >= 160_000 {
593        "SELECT member.rolname, parent.rolname, membership.set_option
594         FROM pg_auth_members membership
595         JOIN pg_roles member ON member.oid = membership.member
596         JOIN pg_roles parent ON parent.oid = membership.roleid;"
597    } else {
598        "SELECT member.rolname, parent.rolname, true AS set_option
599         FROM pg_auth_members membership
600         JOIN pg_roles member ON member.oid = membership.member
601         JOIN pg_roles parent ON parent.oid = membership.roleid;"
602    };
603    let memberships = client
604        .query(membership_query, &[])
605        .context("Failed to load role memberships from pg_auth_members")?;
606    for row in memberships {
607        let member = ObjectId::new("", row.try_get::<_, String>(0).context("member role")?);
608        let parent = ObjectId::new("", row.try_get::<_, String>(1).context("parent role")?);
609        let set_option: bool = row.try_get(2).context("role membership SET option")?;
610        if let Some(role) = roles.get_mut(&member) {
611            role.member_of.push(parent.clone());
612            if set_option {
613                role.can_set_role_to.push(parent);
614            }
615        }
616    }
617    Ok(roles)
618}
619
620struct ProvenanceCatalog {
621    pg_version_num: u32,
622    metadata: crate::db::cache::CacheMetadata,
623    search_path: Vec<String>,
624}
625
626fn load_provenance(
627    client: &mut impl GenericClient,
628    schemas: Option<&[String]>,
629) -> Result<ProvenanceCatalog> {
630    let version_row = client
631        .query_one("SHOW server_version_num;", &[])
632        .context("Failed to load PostgreSQL server version")?;
633    let version_str: String = version_row
634        .try_get(0)
635        .context("PostgreSQL server version field")?;
636    let pg_version_num = version_str
637        .parse::<u32>()
638        .context("PostgreSQL returned an invalid server_version_num")?;
639    ensure_supported_postgres_version(pg_version_num)?;
640
641    let row = client
642        .query_one(
643            "SELECT current_database(), current_user, session_user, current_setting('search_path'),
644                    (SELECT setting::bigint FROM pg_settings WHERE name = 'lock_timeout'),
645                    (SELECT setting::bigint FROM pg_settings WHERE name = 'statement_timeout');",
646            &[],
647        )
648        .context("Failed to load synchronization provenance and timeout settings")?;
649    let search_path_setting: String = row
650        .try_get(3)
651        .context("synchronization provenance search_path")?;
652    let lock_timeout_ms = row
653        .try_get::<_, Option<i64>>(4)
654        .context("synchronization provenance lock_timeout field")?
655        .context("PostgreSQL did not report lock_timeout")?;
656    let statement_timeout_ms = row
657        .try_get::<_, Option<i64>>(5)
658        .context("synchronization provenance statement_timeout field")?
659        .context("PostgreSQL did not report statement_timeout")?;
660
661    let search_path_row = client
662        .query_one("SELECT current_schemas(false);", &[])
663        .context("Failed to load the effective PostgreSQL search path")?;
664    let effective_search_path = search_path_row
665        .try_get(0)
666        .context("effective PostgreSQL search path field")?;
667
668    Ok(ProvenanceCatalog {
669        pg_version_num,
670        metadata: crate::db::cache::CacheMetadata {
671            created_at_unix_secs: Some(
672                SystemTime::now()
673                    .duration_since(UNIX_EPOCH)
674                    .unwrap_or_default()
675                    .as_secs(),
676            ),
677            source_database: Some(
678                row.try_get(0)
679                    .context("synchronization provenance database field")?,
680            ),
681            source_role: Some(
682                row.try_get(1)
683                    .context("synchronization provenance current-role field")?,
684            ),
685            source_session_role: Some(
686                row.try_get(2)
687                    .context("synchronization provenance session-role field")?,
688            ),
689            source_search_path: Some(parse_search_path_setting(&search_path_setting)),
690            source_lock_timeout_ms: lock_timeout_ms
691                .try_into()
692                .context("PostgreSQL returned a negative lock_timeout")?,
693            source_statement_timeout_ms: statement_timeout_ms
694                .try_into()
695                .context("PostgreSQL returned a negative statement_timeout")?,
696            schemas: schemas.map(<[String]>::to_vec),
697        },
698        search_path: cache_search_path(effective_search_path, schemas),
699    })
700}
701
702fn load_schemas(
703    client: &mut impl GenericClient,
704    schema_values: &Option<Vec<String>>,
705    schema_filter: &str,
706) -> Result<std::collections::HashMap<String, crate::model::schema::SchemaState>> {
707    let query = format!(
708        "SELECT n.nspname, pg_catalog.pg_get_userbyid(n.nspowner)
709         FROM pg_namespace n
710         WHERE n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
711           AND n.nspname <> 'information_schema'
712           {schema_filter}
713         ORDER BY n.nspname;"
714    );
715    let rows = client
716        .query(&query, &[schema_values])
717        .context("Failed to load schemas from pg_namespace")?;
718    rows.into_iter()
719        .map(|row| {
720            let name: String = row.try_get(0).context("schema name")?;
721            let owner: String = row.try_get(1).context("schema owner")?;
722            Ok((
723                name.clone(),
724                crate::model::schema::SchemaState {
725                    name,
726                    owner: relation_owner_id(owner),
727                    generation: 0,
728                },
729            ))
730        })
731        .collect()
732}
733
734fn load_sequences(
735    client: &mut impl GenericClient,
736    schema_values: &Option<Vec<String>>,
737) -> Result<std::collections::HashMap<ObjectId, crate::model::sequence::SequenceState>> {
738    // Keep a sequence when either side of OWNED BY is in the requested
739    // scope. A sequence can live in a different schema from its owning
740    // table, and dropping it without that edge would make a later migration
741    // look exact while missing PostgreSQL's ownership dependency.
742    let schema_filter = "AND ($1::text[] IS NULL OR n.nspname = ANY($1) OR tn.nspname = ANY($1))";
743    let query = format!(
744        "SELECT
745             n.nspname AS sequence_schema,
746             s.relname AS sequence_name,
747             pg_catalog.pg_get_userbyid(s.relowner) AS owner_name,
748             tn.nspname AS table_schema,
749             t.relname AS table_name,
750             a.attname AS column_name,
751             d.deptype::text AS dependency_type,
752             CASE WHEN ad.adbin IS NULL THEN false
753                  ELSE pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) LIKE '%nextval(%'
754             END AS has_nextval_default
755         FROM pg_class s
756         JOIN pg_namespace n ON n.oid = s.relnamespace
757         LEFT JOIN pg_depend d
758           ON d.classid = 'pg_class'::regclass
759          AND d.objid = s.oid
760          AND d.objsubid = 0
761          AND d.refclassid = 'pg_class'::regclass
762          AND d.deptype IN ('a', 'i')
763         LEFT JOIN pg_class t ON t.oid = d.refobjid
764         LEFT JOIN pg_namespace tn ON tn.oid = t.relnamespace
765         LEFT JOIN pg_attribute a
766           ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
767         LEFT JOIN pg_attrdef ad
768           ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
769         WHERE s.relkind = 'S'
770           AND n.nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
771           AND n.nspname <> 'information_schema'
772           {schema_filter}
773         ORDER BY n.nspname, s.relname;"
774    );
775    let rows = client
776        .query(&query, &[schema_values])
777        .context("Failed to load sequences and ownership from pg_class/pg_depend")?;
778    rows.into_iter()
779        .map(|row| {
780            let id = ObjectId::new(
781                row.try_get::<_, String>(0).context("sequence schema")?,
782                row.try_get::<_, String>(1).context("sequence name")?,
783            );
784            let owner = relation_owner_id(row.try_get::<_, String>(2).context("sequence owner")?);
785            let table_schema: Option<String> =
786                row.try_get(3).context("sequence owner table schema")?;
787            let table_name: Option<String> = row.try_get(4).context("sequence owner table name")?;
788            let column_name: Option<String> =
789                row.try_get(5).context("sequence owner column name")?;
790            let dependency_type: Option<String> =
791                row.try_get(6).context("sequence dependency type")?;
792            let has_nextval_default: bool =
793                row.try_get(7).context("sequence-backed default marker")?;
794            let owned_by = table_schema
795                .zip(table_name)
796                .zip(column_name)
797                .map(|((schema, table), column)| (ObjectId::new(schema, table), column));
798            let kind = sequence_kind_from_pg(dependency_type.as_deref(), has_nextval_default)
799                .with_context(|| format!("sequence '{}' dependency kind", id))?;
800            Ok((
801                id.clone(),
802                crate::model::sequence::SequenceState {
803                    id,
804                    owner,
805                    owned_by,
806                    kind,
807                    generation: 0,
808                },
809            ))
810        })
811        .collect()
812}
813
814fn load_relations_and_columns(
815    client: &mut impl GenericClient,
816    schemas: Option<&[String]>,
817    schema_values: &Option<Vec<String>>,
818    schema_filter_with_fk: &str,
819) -> Result<std::collections::HashMap<ObjectId, RelationState>> {
820    let relation_query = format!(
821        "
822        SELECT
823            n.nspname AS schema_name,
824            c.relname AS relation_name,
825            c.relkind AS relation_kind,
826            c.relpersistence AS persistence,
827            pg_catalog.pg_get_userbyid(c.relowner) AS owner_name,
828            CASE WHEN c.reltuples < 0 THEN -1 ELSE c.reltuples::bigint END AS estimated_rows,
829            c.relpages::bigint AS relpages,
830            to_char(s.last_analyze, 'YYYY-MM-DD HH24:MI:SS') AS last_analyze,
831            to_char(s.last_autoanalyze, 'YYYY-MM-DD HH24:MI:SS') AS last_autoanalyze,
832            p.partstrat::text AS partition_strategy
833        FROM pg_class c
834        JOIN pg_namespace n ON n.oid = c.relnamespace
835        LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
836        LEFT JOIN pg_partitioned_table p ON p.partrelid = c.oid
837        WHERE c.relkind IN ('r', 'p', 'v', 'm')
838          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
839          {schema_filter_with_fk};
840    "
841    );
842    let rows = client
843        .query(&relation_query, &[schema_values])
844        .context("Failed to load relations and statistics from pg_class")?;
845    let mut relations = std::collections::HashMap::new();
846    for row in rows {
847        let schema_name: String = row.try_get("schema_name").context("relation schema")?;
848        let relation_name: String = row.try_get("relation_name").context("relation name")?;
849        let relkind: i8 = row.try_get("relation_kind").context("relation kind")?;
850        let persistence_char: i8 = row.try_get("persistence").context("relation persistence")?;
851        let owner_name: String = row.try_get("owner_name").context("relation owner")?;
852        let raw_rows: i64 = row
853            .try_get("estimated_rows")
854            .context("relation estimated row count")?;
855        let relpages: i64 = row.try_get("relpages").context("relation page count")?;
856        let last_analyze: Option<String> = row
857            .try_get("last_analyze")
858            .context("relation last-analyze timestamp")?;
859        let last_autoanalyze: Option<String> = row
860            .try_get("last_autoanalyze")
861            .context("relation last-autoanalyze timestamp")?;
862
863        let object_id = ObjectId::new(&schema_name, &relation_name);
864        let kind = relation_kind_from_pg(relkind as u8)
865            .with_context(|| format!("relation '{}' kind", object_id))?;
866        let persistence = persistence_from_pg(persistence_char as u8)
867            .with_context(|| format!("relation '{}' persistence", object_id))?;
868        let estimated_rows = if raw_rows < 0 {
869            None
870        } else {
871            Some(raw_rows as u64)
872        };
873        let mut state = RelationState::new(
874            object_id.clone(),
875            relation_owner_id(owner_name),
876            0,
877            estimated_rows,
878            kind,
879            persistence,
880            0,
881        );
882        state.relpages = Some(
883            relpages
884                .try_into()
885                .with_context(|| format!("relation '{}' has a negative page count", object_id))?,
886        );
887        state.last_analyze = last_analyze;
888        state.last_autoanalyze = last_autoanalyze;
889        let partition_strategy: Option<String> = row
890            .try_get("partition_strategy")
891            .context("relation partition strategy")?;
892        state.partition_type = partition_strategy_from_pg(partition_strategy.as_deref())
893            .with_context(|| format!("relation '{}' partition strategy", object_id))?;
894        if let Some(scoped_schemas) = schemas
895            && !scoped_schemas.contains(&schema_name)
896        {
897            state.mark_fk_dependency();
898        }
899        relations.insert(object_id, state);
900    }
901
902    let column_query = format!(
903        "
904        SELECT
905            n.nspname AS schema_name,
906            c.relname AS relation_name,
907            a.attname AS column_name,
908            pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name,
909            a.attnotnull AS not_null,
910            s.avg_width AS avg_width,
911            pg_get_expr(ad.adbin, ad.adrelid) AS default_expr_text,
912            a.atttypmod AS type_modifier
913        FROM pg_attribute a
914        JOIN pg_class c ON a.attrelid = c.oid
915        JOIN pg_namespace n ON n.oid = c.relnamespace
916        LEFT JOIN pg_stats s ON s.schemaname = n.nspname AND s.tablename = c.relname AND s.attname = a.attname
917        LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
918        WHERE a.attnum > 0 AND NOT a.attisdropped
919          AND c.relkind IN ('r', 'p', 'v', 'm')
920          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
921          {schema_filter_with_fk}
922        ORDER BY n.nspname, c.relname;
923    "
924    );
925    let rows = client
926        .query(&column_query, &[schema_values])
927        .context("Failed to load relation columns from pg_attribute")?;
928    for row in rows {
929        let relation_id = ObjectId::new(
930            row.try_get::<_, String>("schema_name")
931                .context("column relation schema")?,
932            row.try_get::<_, String>("relation_name")
933                .context("column relation name")?,
934        );
935        let relation = relations.get_mut(&relation_id).with_context(|| {
936            format!(
937                "column catalog row references relation '{}' omitted by the relation loader",
938                relation_id
939            )
940        })?;
941        relation.columns.push(crate::model::column::Column {
942            name: row.try_get("column_name").context("column name")?,
943            data_type: Some(row.try_get("type_name").context("column type")?),
944            type_id: None,
945            is_nullable: !row
946                .try_get::<_, bool>("not_null")
947                .context("column nullability")?,
948            default: None,
949            avg_width: row.try_get("avg_width").context("column average width")?,
950            default_expr_text: row
951                .try_get("default_expr_text")
952                .context("column default expression")?,
953            type_modifier: row
954                .try_get("type_modifier")
955                .context("column type modifier")?,
956        });
957    }
958    Ok(relations)
959}
960
961struct RelationDecoration {
962    relation_id: ObjectId,
963    triggers: Vec<String>,
964    policies: Vec<String>,
965}
966
967struct RelationGrant {
968    relation_id: ObjectId,
969    grantee: ObjectId,
970    privilege: crate::model::relation::Privilege,
971}
972
973fn load_relation_decorations(
974    client: &mut impl GenericClient,
975    schema_values: &Option<Vec<String>>,
976    schema_filter_with_fk: &str,
977) -> Result<(Vec<RelationDecoration>, Vec<RelationGrant>)> {
978    let topology_query = format!(
979        "
980        SELECT
981            n.nspname AS schema_name,
982            c.relname AS relation_name,
983            COALESCE(array_agg(DISTINCT t.tgname) FILTER (WHERE t.tgname IS NOT NULL AND t.tgisinternal = false), '{{}}') as triggers,
984            COALESCE(array_agg(DISTINCT p.polname) FILTER (WHERE p.polname IS NOT NULL), '{{}}') as policies
985        FROM pg_class c
986        JOIN pg_namespace n ON n.oid = c.relnamespace
987        LEFT JOIN pg_trigger t ON t.tgrelid = c.oid
988        LEFT JOIN pg_policy p ON p.polrelid = c.oid
989        WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
990        {schema_filter_with_fk}
991        GROUP BY n.nspname, c.relname;
992    "
993    );
994    let decorations = client
995        .query(&topology_query, &[schema_values])
996        .context("Failed to load relation triggers and policies")?
997        .into_iter()
998        .map(|row| {
999            Ok(RelationDecoration {
1000                relation_id: ObjectId::new(
1001                    row.try_get::<_, String>("schema_name")
1002                        .context("decorated relation schema")?,
1003                    row.try_get::<_, String>("relation_name")
1004                        .context("decorated relation name")?,
1005                ),
1006                triggers: row.try_get("triggers").context("relation trigger names")?,
1007                policies: row.try_get("policies").context("relation policy names")?,
1008            })
1009        })
1010        .collect::<Result<Vec<_>>>()?;
1011
1012    let acl_query = format!(
1013        "
1014        SELECT
1015            n.nspname AS schema_name,
1016            c.relname AS relation_name,
1017            CASE
1018                WHEN acl.grantee = 0 THEN 'public'
1019                ELSE pg_catalog.pg_get_userbyid(acl.grantee)
1020            END AS grantee,
1021            acl.privilege_type
1022        FROM pg_class c
1023        JOIN pg_namespace n ON n.oid = c.relnamespace
1024        CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) acl
1025        WHERE c.relkind IN ('r', 'p', 'v', 'm')
1026          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
1027          AND acl.grantee <> c.relowner
1028          {schema_filter_with_fk};
1029        "
1030    );
1031    let grants = client
1032        .query(&acl_query, &[schema_values])
1033        .context("Failed to load explicit relation privileges")?
1034        .into_iter()
1035        .map(|row| {
1036            let privilege_type: String = row
1037                .try_get("privilege_type")
1038                .context("relation privilege type")?;
1039            let privilege = match privilege_type.as_str() {
1040                "SELECT" => crate::model::relation::Privilege::Select,
1041                "INSERT" => crate::model::relation::Privilege::Insert,
1042                "UPDATE" => crate::model::relation::Privilege::Update,
1043                "DELETE" => crate::model::relation::Privilege::Delete,
1044                "TRUNCATE" => crate::model::relation::Privilege::Truncate,
1045                "REFERENCES" => crate::model::relation::Privilege::References,
1046                "TRIGGER" => crate::model::relation::Privilege::Trigger,
1047                "MAINTAIN" => crate::model::relation::Privilege::Maintain,
1048                other => anyhow::bail!("unsupported relation privilege type '{other}'"),
1049            };
1050            Ok(RelationGrant {
1051                relation_id: ObjectId::new(
1052                    row.try_get::<_, String>("schema_name")
1053                        .context("privileged relation schema")?,
1054                    row.try_get::<_, String>("relation_name")
1055                        .context("privileged relation name")?,
1056                ),
1057                grantee: ObjectId::new(
1058                    "",
1059                    row.try_get::<_, String>("grantee")
1060                        .context("relation privilege grantee")?,
1061                ),
1062                privilege,
1063            })
1064        })
1065        .collect::<Result<Vec<_>>>()?;
1066    Ok((decorations, grants))
1067}
1068
1069fn load_triggers(
1070    client: &mut impl GenericClient,
1071    schema_values: &Option<Vec<String>>,
1072    schema_filter_with_fk: &str,
1073) -> Result<Vec<crate::db::cache::TriggerCache>> {
1074    let query = format!(
1075        "
1076        SELECT
1077            n.nspname AS table_schema,
1078            c.relname AS table_name,
1079            t.tgname AS trigger_name,
1080            t.tgenabled::text AS enabled_mode,
1081            fn.nspname AS function_schema,
1082            f.proname || '()' AS function_name
1083        FROM pg_trigger t
1084        JOIN pg_class c ON c.oid = t.tgrelid
1085        JOIN pg_namespace n ON n.oid = c.relnamespace
1086        JOIN pg_proc f ON f.oid = t.tgfoid
1087        JOIN pg_namespace fn ON fn.oid = f.pronamespace
1088        WHERE t.tgisinternal = false
1089          AND c.relkind IN ('r', 'p', 'v', 'm')
1090          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
1091          {schema_filter_with_fk};
1092    "
1093    );
1094    client
1095        .query(&query, &[schema_values])
1096        .context("Failed to load triggers and trigger functions")?
1097        .into_iter()
1098        .map(|row| {
1099            let table_schema: String = row.try_get("table_schema").context("trigger schema")?;
1100            let enabled_mode: String = row
1101                .try_get("enabled_mode")
1102                .context("trigger enabled mode")?;
1103            Ok(crate::db::cache::TriggerCache {
1104                trigger_id: ObjectId::new(
1105                    &table_schema,
1106                    row.try_get::<_, String>("trigger_name")
1107                        .context("trigger name")?,
1108                ),
1109                table_id: ObjectId::new(
1110                    &table_schema,
1111                    row.try_get::<_, String>("table_name")
1112                        .context("trigger table name")?,
1113                ),
1114                function_id: ObjectId::new(
1115                    row.try_get::<_, String>("function_schema")
1116                        .context("trigger function schema")?,
1117                    row.try_get::<_, String>("function_name")
1118                        .context("trigger function name")?,
1119                ),
1120                enabled_mode: crate::model::trigger::TriggerEnableMode::from_pg_code(&enabled_mode)
1121                    .ok_or_else(|| {
1122                        anyhow::anyhow!("unknown pg_trigger.tgenabled value {enabled_mode}")
1123                    })?,
1124            })
1125        })
1126        .collect()
1127}
1128
1129fn load_constraints(
1130    client: &mut impl GenericClient,
1131    schema_values: &Option<Vec<String>>,
1132    schema_filter_with_fk: &str,
1133) -> Result<Vec<crate::model::constraint::ConstraintState>> {
1134    let query = format!(
1135        "
1136        SELECT
1137            n.nspname AS table_schema,
1138            c.relname AS table_name,
1139            con.conname AS constraint_name,
1140            con.contype::text AS constraint_type,
1141            con.convalidated AS validated
1142        FROM pg_constraint con
1143        JOIN pg_class c ON c.oid = con.conrelid
1144        JOIN pg_namespace n ON n.oid = c.relnamespace
1145        WHERE con.contype IN ('c', 'f', 'p', 'u', 'x')
1146          AND c.relkind IN ('r', 'p', 'v', 'm')
1147          AND n.nspname NOT IN ('pg_catalog', 'information_schema')
1148          {schema_filter_with_fk};
1149        "
1150    );
1151    client
1152        .query(&query, &[schema_values])
1153        .context("Failed to load table constraints from pg_constraint")?
1154        .into_iter()
1155        .map(|row| {
1156            let constraint_type: String =
1157                row.try_get("constraint_type").context("constraint type")?;
1158            let kind = match constraint_type.as_str() {
1159                "c" => crate::model::constraint::ConstraintKind::Check,
1160                "f" => crate::model::constraint::ConstraintKind::ForeignKey,
1161                "p" => crate::model::constraint::ConstraintKind::PrimaryKey,
1162                "u" => crate::model::constraint::ConstraintKind::Unique,
1163                "x" => crate::model::constraint::ConstraintKind::Exclusion,
1164                other => anyhow::bail!("unsupported pg_constraint.contype '{other}'"),
1165            };
1166            Ok(crate::model::constraint::ConstraintState {
1167                table_id: ObjectId::new(
1168                    row.try_get::<_, String>("table_schema")
1169                        .context("constraint table schema")?,
1170                    row.try_get::<_, String>("table_name")
1171                        .context("constraint table name")?,
1172                ),
1173                name: row.try_get("constraint_name").context("constraint name")?,
1174                kind,
1175                validated: row
1176                    .try_get("validated")
1177                    .context("constraint validation state")?,
1178            })
1179        })
1180        .collect()
1181}
1182
1183fn load_foreign_keys(
1184    client: &mut impl GenericClient,
1185    schemas: Option<&[String]>,
1186    schema_values: &Option<Vec<String>>,
1187    schema_filter_n1_or_n2: &str,
1188) -> Result<Vec<ForeignKeyCache>> {
1189    let query = format!(
1190        "
1191        SELECT
1192            c.conname AS constraint_name,
1193            n1.nspname AS from_schema, t1.relname AS from_table,
1194            n2.nspname AS to_schema, t2.relname AS to_table
1195        FROM pg_constraint c
1196        JOIN pg_class t1 ON t1.oid = c.conrelid
1197        JOIN pg_namespace n1 ON n1.oid = t1.relnamespace
1198        JOIN pg_class t2 ON t2.oid = c.confrelid
1199        JOIN pg_namespace n2 ON n2.oid = t2.relnamespace
1200        WHERE c.contype = 'f'
1201        {schema_filter_n1_or_n2};
1202    "
1203    );
1204    client
1205        .query(&query, &[schema_values])
1206        .context("Failed to load foreign keys from pg_constraint")?
1207        .into_iter()
1208        .map(|row| {
1209            let constraint_name: String = row
1210                .try_get("constraint_name")
1211                .context("foreign-key constraint name")?;
1212            let from_schema: String = row
1213                .try_get("from_schema")
1214                .context("foreign-key source schema")?;
1215            let from_table: String = row
1216                .try_get("from_table")
1217                .context("foreign-key source table")?;
1218            let to_schema: String = row
1219                .try_get("to_schema")
1220                .context("foreign-key target schema")?;
1221            let to_table: String = row
1222                .try_get("to_table")
1223                .context("foreign-key target table")?;
1224            if let Some(scoped_schemas) = schemas
1225                && (!scoped_schemas.contains(&from_schema)
1226                    || !scoped_schemas.contains(&to_schema))
1227            {
1228                let (out_of_scope_schema, out_of_scope_table) =
1229                    if !scoped_schemas.contains(&from_schema) {
1230                        (&from_schema, &from_table)
1231                    } else {
1232                        (&to_schema, &to_table)
1233                    };
1234                eprintln!(
1235                    "[WARN] Foreign key '{}' crosses schema boundary. Table '{}.{}' was pulled into cache as a dependency to evaluate cross-team locks.",
1236                    constraint_name, out_of_scope_schema, out_of_scope_table
1237                );
1238            }
1239            Ok(ForeignKeyCache {
1240                constraint_name,
1241                from_table: ObjectId::new(from_schema, from_table),
1242                to_table: ObjectId::new(to_schema, to_table),
1243            })
1244        })
1245        .collect()
1246}
1247
1248fn load_indexes(
1249    client: &mut impl GenericClient,
1250    schema_values: &Option<Vec<String>>,
1251    schema_filter_nt: &str,
1252) -> Result<Vec<IndexCache>> {
1253    let query = format!(
1254        "
1255        SELECT
1256            n_i.nspname AS index_schema, i.relname AS index_name,
1257            n_t.nspname AS table_schema, t.relname AS table_name
1258        FROM pg_index x
1259        JOIN pg_class i ON i.oid = x.indexrelid
1260        JOIN pg_namespace n_i ON n_i.oid = i.relnamespace
1261        JOIN pg_class t ON t.oid = x.indrelid
1262        JOIN pg_namespace n_t ON n_t.oid = t.relnamespace
1263        WHERE x.indisvalid = true
1264          AND n_i.nspname !~ '^pg_'
1265          AND n_i.nspname <> 'information_schema'
1266          AND n_t.nspname !~ '^pg_'
1267          AND n_t.nspname <> 'information_schema'
1268        {schema_filter_nt};
1269    "
1270    );
1271    let rows = client
1272        .query(&query, &[schema_values])
1273        .context("Failed to load valid indexes from pg_index")?;
1274    let mut indexes = Vec::with_capacity(rows.len());
1275    for row in rows {
1276        let index_schema: String = row.try_get("index_schema").context("index schema")?;
1277        let table_schema: String = row
1278            .try_get("table_schema")
1279            .context("indexed table schema")?;
1280        if is_system_schema(&index_schema) || is_system_schema(&table_schema) {
1281            continue;
1282        }
1283        indexes.push(IndexCache {
1284            index_id: ObjectId::new(
1285                index_schema,
1286                row.try_get::<_, String>("index_name")
1287                    .context("index name")?,
1288            ),
1289            table_id: ObjectId::new(
1290                table_schema,
1291                row.try_get::<_, String>("table_name")
1292                    .context("indexed table name")?,
1293            ),
1294        });
1295    }
1296    Ok(indexes)
1297}
1298
1299fn load_routines(
1300    client: &mut impl GenericClient,
1301    schema_values: &Option<Vec<String>>,
1302    schema_filter: &str,
1303) -> Result<std::collections::HashMap<ObjectId, crate::model::function::FunctionState>> {
1304    let query = format!(
1305        "
1306        SELECT
1307            n.nspname AS schema_name,
1308            p.proname AS func_name,
1309            ARRAY(
1310                SELECT pg_catalog.format_type(t, NULL)
1311                FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY AS u(t, n)
1312                ORDER BY n
1313            )::text[] AS arg_types,
1314            pg_catalog.pg_get_function_result(p.oid) AS return_type,
1315            p.provolatile::text AS volatility,
1316            p.prokind::text AS routine_kind,
1317            l.lanname AS language,
1318            p.prosecdef AS security_definer
1319        FROM pg_proc p
1320        JOIN pg_namespace n ON n.oid = p.pronamespace
1321        JOIN pg_language l ON l.oid = p.prolang
1322        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
1323          AND p.prokind IN ('f', 'p', 'a', 'w')
1324          {schema_filter};
1325    "
1326    );
1327    client
1328        .query(&query, &[schema_values])
1329        .context("Failed to load routines from pg_proc")?
1330        .into_iter()
1331        .map(|row| {
1332            let schema_name: String = row.try_get("schema_name").context("routine schema")?;
1333            let function_name: String = row.try_get("func_name").context("routine name")?;
1334            let raw_arg_types: Vec<String> =
1335                row.try_get("arg_types").context("routine argument types")?;
1336            let volatility_code: String =
1337                row.try_get("volatility").context("routine volatility")?;
1338            let volatility = routine_volatility_from_pg(&volatility_code)?;
1339            let routine_kind_code: String = row.try_get("routine_kind").context("routine kind")?;
1340            let routine_kind = routine_kind_from_pg(&routine_kind_code)?;
1341            let arg_types = raw_arg_types
1342                .iter()
1343                .map(|arg_type| {
1344                    crate::analysis::resolver::Resolver::normalize_function_arg_type(arg_type)
1345                })
1346                .collect::<Vec<_>>();
1347            let id = ObjectId::new(
1348                schema_name,
1349                format!("{}({})", function_name, arg_types.join(",")),
1350            );
1351            let security_definer: bool = row
1352                .try_get("security_definer")
1353                .context("routine security mode")?;
1354            Ok((
1355                id.clone(),
1356                crate::model::function::FunctionState {
1357                    id,
1358                    routine_kind,
1359                    arg_types,
1360                    arg_type_ids: Vec::new(),
1361                    return_type: row
1362                        .try_get::<_, Option<String>>("return_type")
1363                        .context("routine return type")?
1364                        .unwrap_or_default(),
1365                    return_type_id: None,
1366                    volatility,
1367                    language: row.try_get("language").context("routine language")?,
1368                    security: if security_definer {
1369                        crate::model::function::SecurityMode::Definer
1370                    } else {
1371                        crate::model::function::SecurityMode::Invoker
1372                    },
1373                },
1374            ))
1375        })
1376        .collect()
1377}
1378
1379fn load_types(
1380    client: &mut impl GenericClient,
1381    schema_values: &Option<Vec<String>>,
1382    schema_filter: &str,
1383) -> Result<std::collections::HashMap<ObjectId, crate::model::types::TypeState>> {
1384    let query = format!(
1385        "
1386        SELECT
1387            n.nspname AS schema_name,
1388            t.typname AS type_name,
1389            t.typtype::text AS type_kind,
1390            CASE WHEN t.typtype = 'd'
1391                THEN pg_catalog.format_type(t.typbasetype, t.typtypmod)
1392                ELSE NULL
1393            END AS domain_base_type,
1394            COALESCE(
1395                array_agg(e.enumlabel ORDER BY e.enumsortorder)
1396                    FILTER (WHERE e.enumlabel IS NOT NULL),
1397                ARRAY[]::text[]
1398            ) AS enum_labels
1399        FROM pg_type t
1400        JOIN pg_namespace n ON n.oid = t.typnamespace
1401        LEFT JOIN pg_enum e ON e.enumtypid = t.oid
1402        WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
1403          AND t.typtype IN ('e', 'd')
1404          {schema_filter}
1405        GROUP BY n.nspname, t.typname, t.typtype, t.typbasetype, t.typtypmod;
1406        "
1407    );
1408    client
1409        .query(&query, &[schema_values])
1410        .context("Failed to load user-defined enum and domain types")?
1411        .into_iter()
1412        .map(|row| {
1413            let type_kind: String = row.try_get("type_kind").context("type kind")?;
1414            let kind = match type_kind.as_str() {
1415                "e" => crate::model::types::TypeKind::Enum {
1416                    variants: row.try_get("enum_labels").context("enum labels")?,
1417                },
1418                "d" => crate::model::types::TypeKind::Domain {
1419                    base_type: row
1420                        .try_get::<_, Option<String>>("domain_base_type")
1421                        .context("domain base type")?
1422                        .context("PostgreSQL omitted the base type for a domain")?,
1423                    base_type_id: None,
1424                },
1425                other => anyhow::bail!("unsupported pg_type.typtype '{other}'"),
1426            };
1427            let id = ObjectId::new(
1428                row.try_get::<_, String>("schema_name")
1429                    .context("type schema")?,
1430                row.try_get::<_, String>("type_name").context("type name")?,
1431            );
1432            Ok((
1433                id.clone(),
1434                crate::model::types::TypeState {
1435                    id,
1436                    generation: 0,
1437                    kind,
1438                },
1439            ))
1440        })
1441        .collect()
1442}
1443
1444fn load_publications(
1445    client: &mut impl GenericClient,
1446    pg_version_num: u32,
1447) -> Result<std::collections::HashMap<String, crate::model::replication::PublicationState>> {
1448    let publication_query = if pg_version_num >= 180_000 {
1449        r#"
1450            SELECT p.oid, p.pubname::text AS publication_name,
1451                   pg_catalog.pg_get_userbyid(p.pubowner) AS owner_name,
1452                   p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete,
1453                   p.pubtruncate, p.pubviaroot, p.pubgencols::text AS generated_columns
1454            FROM pg_publication p
1455            ORDER BY p.oid
1456        "#
1457    } else {
1458        r#"
1459            SELECT p.oid, p.pubname::text AS publication_name,
1460                   pg_catalog.pg_get_userbyid(p.pubowner) AS owner_name,
1461                   p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete,
1462                   p.pubtruncate, p.pubviaroot, NULL::text AS generated_columns
1463            FROM pg_publication p
1464            ORDER BY p.oid
1465        "#
1466    };
1467    let rows = client
1468        .query(publication_query, &[])
1469        .context("Failed to load publications from pg_publication")?;
1470    let mut names_by_oid = std::collections::HashMap::<u32, String>::new();
1471    let mut publications = std::collections::HashMap::new();
1472    for row in rows {
1473        let oid: u32 = row.try_get("oid").context("publication OID")?;
1474        let name: String = row
1475            .try_get("publication_name")
1476            .context("publication name")?;
1477        let mut operations = Vec::new();
1478        for (field, operation) in [
1479            ("pubinsert", "insert"),
1480            ("pubupdate", "update"),
1481            ("pubdelete", "delete"),
1482            ("pubtruncate", "truncate"),
1483        ] {
1484            if row
1485                .try_get::<_, bool>(field)
1486                .with_context(|| format!("publication '{name}' {field}"))?
1487            {
1488                operations.push(operation);
1489            }
1490        }
1491        let mut params = vec![
1492            crate::analysis::facts::AttributeFact {
1493                name: "publish".to_string(),
1494                value: operations.join(", "),
1495            },
1496            crate::analysis::facts::AttributeFact {
1497                name: "publish_via_partition_root".to_string(),
1498                value: row
1499                    .try_get::<_, bool>("pubviaroot")
1500                    .context("publication partition-root mode")?
1501                    .to_string(),
1502            },
1503        ];
1504        if let Some(generated_columns) = row
1505            .try_get::<_, Option<String>>("generated_columns")
1506            .context("publication generated-column mode")?
1507        {
1508            let value = match generated_columns.as_str() {
1509                "n" => "none",
1510                "s" => "stored",
1511                other => anyhow::bail!(
1512                    "publication '{name}' has unknown generated-column mode '{other}'"
1513                ),
1514            };
1515            params.push(crate::analysis::facts::AttributeFact {
1516                name: "publish_generated_columns".to_string(),
1517                value: value.to_string(),
1518            });
1519        }
1520        let scope = if row
1521            .try_get::<_, bool>("puballtables")
1522            .context("publication all-tables mode")?
1523        {
1524            crate::analysis::facts::PublicationScope::AllTables { except: Vec::new() }
1525        } else {
1526            crate::analysis::facts::PublicationScope::Explicit(Vec::new())
1527        };
1528        names_by_oid.insert(oid, name.clone());
1529        publications.insert(
1530            name.clone(),
1531            crate::model::replication::PublicationState {
1532                name,
1533                owner: Some(row.try_get("owner_name").context("publication owner")?),
1534                scope,
1535                params,
1536                generation: 0,
1537            },
1538        );
1539    }
1540
1541    let relation_query = if pg_version_num >= 150_000 {
1542        r#"
1543            SELECT pr.prpubid, n.nspname::text AS schema_name,
1544                   c.relname::text AS relation_name,
1545                   pg_catalog.pg_get_expr(pr.prqual, pr.prrelid) AS row_filter,
1546                   CASE WHEN pr.prattrs IS NULL THEN NULL ELSE ARRAY(
1547                       SELECT a.attname::text
1548                       FROM pg_attribute a
1549                       WHERE a.attrelid = pr.prrelid
1550                         AND a.attnum = ANY(pr.prattrs::smallint[])
1551                       ORDER BY array_position(pr.prattrs::smallint[], a.attnum)
1552                   ) END AS columns
1553            FROM pg_publication_rel pr
1554            JOIN pg_class c ON c.oid = pr.prrelid
1555            JOIN pg_namespace n ON n.oid = c.relnamespace
1556            ORDER BY pr.prpubid, pr.oid
1557        "#
1558    } else {
1559        r#"
1560            SELECT pr.prpubid, n.nspname::text AS schema_name,
1561                   c.relname::text AS relation_name,
1562                   NULL::text AS row_filter, NULL::text[] AS columns
1563            FROM pg_publication_rel pr
1564            JOIN pg_class c ON c.oid = pr.prrelid
1565            JOIN pg_namespace n ON n.oid = c.relnamespace
1566            ORDER BY pr.prpubid, pr.oid
1567        "#
1568    };
1569    for row in client
1570        .query(relation_query, &[])
1571        .context("Failed to load publication relation membership")?
1572    {
1573        let oid: u32 = row.try_get("prpubid").context("publication relation OID")?;
1574        let name = names_by_oid.get(&oid).with_context(|| {
1575            format!("publication relation membership references unknown publication OID {oid}")
1576        })?;
1577        let publication = publications
1578            .get_mut(name)
1579            .with_context(|| format!("publication '{name}' disappeared during assembly"))?;
1580        let crate::analysis::facts::PublicationScope::Explicit(objects) = &mut publication.scope
1581        else {
1582            continue;
1583        };
1584        objects.push(crate::analysis::facts::PublicationObjectFact::Table {
1585            name: crate::ast::identifiers::QualifiedName::new(
1586                Some(crate::ast::identifiers::Ident::new(
1587                    row.try_get::<_, String>("schema_name")
1588                        .context("publication relation schema")?,
1589                    true,
1590                )),
1591                crate::ast::identifiers::Ident::new(
1592                    row.try_get::<_, String>("relation_name")
1593                        .context("publication relation name")?,
1594                    true,
1595                ),
1596            ),
1597            only: true,
1598            include_partitions: false,
1599            columns: row
1600                .try_get("columns")
1601                .context("publication relation column list")?,
1602            row_filter: row
1603                .try_get::<_, Option<String>>("row_filter")
1604                .context("publication relation row filter")?
1605                .map(crate::analysis::facts::PublicationRowFilter::CatalogSql),
1606        });
1607    }
1608
1609    if pg_version_num >= 150_000 {
1610        for row in client
1611            .query(
1612                r#"
1613                    SELECT pn.pnpubid, n.nspname::text AS schema_name
1614                    FROM pg_publication_namespace pn
1615                    JOIN pg_namespace n ON n.oid = pn.pnnspid
1616                    ORDER BY pn.pnpubid, pn.oid
1617                "#,
1618                &[],
1619            )
1620            .context("Failed to load publication schema membership")?
1621        {
1622            let oid: u32 = row.try_get("pnpubid").context("publication schema OID")?;
1623            let name = names_by_oid.get(&oid).with_context(|| {
1624                format!("publication schema membership references unknown publication OID {oid}")
1625            })?;
1626            let publication = publications
1627                .get_mut(name)
1628                .with_context(|| format!("publication '{name}' disappeared during assembly"))?;
1629            let crate::analysis::facts::PublicationScope::Explicit(objects) =
1630                &mut publication.scope
1631            else {
1632                continue;
1633            };
1634            objects.push(
1635                crate::analysis::facts::PublicationObjectFact::SchemaTables {
1636                    schema: row
1637                        .try_get("schema_name")
1638                        .context("publication member schema name")?,
1639                    row_filter: None,
1640                },
1641            );
1642        }
1643    }
1644    Ok(publications)
1645}
1646
1647fn load_subscriptions(
1648    client: &mut impl GenericClient,
1649    pg_version_num: u32,
1650) -> Result<std::collections::HashMap<String, crate::model::replication::SubscriptionState>> {
1651    // Every version-specific query deliberately omits pg_subscription.subconninfo.
1652    let query = match pg_version_num {
1653        170_000.. => {
1654            r#"
1655            SELECT s.subname::text AS subscription_name,
1656                   pg_catalog.pg_get_userbyid(s.subowner) AS owner_name,
1657                   s.subenabled, s.subbinary, s.subslotname::text,
1658                   s.subsynccommit, s.subpublications,
1659                   s.substream::text AS streaming,
1660                   s.subtwophasestate::text AS two_phase_state,
1661                   s.subdisableonerr AS disable_on_error,
1662                   s.subpasswordrequired AS password_required,
1663                   s.subrunasowner AS run_as_owner,
1664                   s.subfailover AS failover,
1665                   s.suborigin AS origin,
1666                   s.subskiplsn::text AS skip_lsn
1667            FROM pg_subscription s
1668            WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())
1669            ORDER BY s.oid
1670        "#
1671        }
1672        160_000.. => {
1673            r#"
1674            SELECT s.subname::text AS subscription_name,
1675                   pg_catalog.pg_get_userbyid(s.subowner) AS owner_name,
1676                   s.subenabled, s.subbinary, s.subslotname::text,
1677                   s.subsynccommit, s.subpublications,
1678                   s.substream::text AS streaming,
1679                   s.subtwophasestate::text AS two_phase_state,
1680                   s.subdisableonerr AS disable_on_error,
1681                   s.subpasswordrequired AS password_required,
1682                   s.subrunasowner AS run_as_owner,
1683                   NULL::bool AS failover,
1684                   s.suborigin AS origin,
1685                   s.subskiplsn::text AS skip_lsn
1686            FROM pg_subscription s
1687            WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())
1688            ORDER BY s.oid
1689        "#
1690        }
1691        150_000.. => {
1692            r#"
1693            SELECT s.subname::text AS subscription_name,
1694                   pg_catalog.pg_get_userbyid(s.subowner) AS owner_name,
1695                   s.subenabled, s.subbinary, s.subslotname::text,
1696                   s.subsynccommit, s.subpublications,
1697                   s.substream::text AS streaming,
1698                   s.subtwophasestate::text AS two_phase_state,
1699                   s.subdisableonerr AS disable_on_error,
1700                   NULL::bool AS password_required,
1701                   NULL::bool AS run_as_owner,
1702                   NULL::bool AS failover,
1703                   NULL::text AS origin,
1704                   s.subskiplsn::text AS skip_lsn
1705            FROM pg_subscription s
1706            WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())
1707            ORDER BY s.oid
1708        "#
1709        }
1710        _ => {
1711            r#"
1712            SELECT s.subname::text AS subscription_name,
1713                   pg_catalog.pg_get_userbyid(s.subowner) AS owner_name,
1714                   s.subenabled, s.subbinary, s.subslotname::text,
1715                   s.subsynccommit, s.subpublications,
1716                   s.substream::text AS streaming,
1717                   NULL::text AS two_phase_state,
1718                   NULL::bool AS disable_on_error,
1719                   NULL::bool AS password_required,
1720                   NULL::bool AS run_as_owner,
1721                   NULL::bool AS failover,
1722                   NULL::text AS origin,
1723                   NULL::text AS skip_lsn
1724            FROM pg_subscription s
1725            WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database())
1726            ORDER BY s.oid
1727        "#
1728        }
1729    };
1730    client
1731        .query(query, &[])
1732        .context("Failed to load non-secret subscription metadata")?
1733        .into_iter()
1734        .map(|row| {
1735            let name: String = row
1736                .try_get("subscription_name")
1737                .context("subscription name")?;
1738            let streaming_code: String = row
1739                .try_get("streaming")
1740                .with_context(|| format!("subscription '{name}' streaming mode"))?;
1741            let streaming = subscription_streaming_from_pg(&streaming_code)
1742                .with_context(|| format!("subscription '{name}' streaming mode"))?;
1743            let mut params = vec![
1744                crate::analysis::facts::AttributeFact {
1745                    name: "binary".to_string(),
1746                    value: row
1747                        .try_get::<_, bool>("subbinary")
1748                        .with_context(|| format!("subscription '{name}' binary mode"))?
1749                        .to_string(),
1750                },
1751                crate::analysis::facts::AttributeFact {
1752                    name: "streaming".to_string(),
1753                    value: streaming.to_string(),
1754                },
1755                crate::analysis::facts::AttributeFact {
1756                    name: "synchronous_commit".to_string(),
1757                    value: row
1758                        .try_get("subsynccommit")
1759                        .with_context(|| format!("subscription '{name}' synchronous_commit"))?,
1760                },
1761            ];
1762            let two_phase = row
1763                .try_get::<_, Option<String>>("two_phase_state")
1764                .with_context(|| format!("subscription '{name}' two-phase state"))?
1765                .map(|state| subscription_two_phase_from_pg(&state).map(str::to_string))
1766                .transpose()?;
1767            let mut push_param = |param_name: &str, value: Option<String>| {
1768                if let Some(value) = value {
1769                    params.push(crate::analysis::facts::AttributeFact {
1770                        name: param_name.to_string(),
1771                        value,
1772                    });
1773                }
1774            };
1775            push_param("two_phase", two_phase);
1776            for (field, param_name) in [
1777                ("disable_on_error", "disable_on_error"),
1778                ("password_required", "password_required"),
1779                ("run_as_owner", "run_as_owner"),
1780                ("failover", "failover"),
1781            ] {
1782                push_param(
1783                    param_name,
1784                    row.try_get::<_, Option<bool>>(field)
1785                        .with_context(|| format!("subscription '{name}' {field}"))?
1786                        .map(|value| value.to_string()),
1787                );
1788            }
1789            push_param(
1790                "origin",
1791                row.try_get("origin")
1792                    .with_context(|| format!("subscription '{name}' origin"))?,
1793            );
1794            push_param(
1795                "skip_lsn",
1796                row.try_get::<_, Option<String>>("skip_lsn")
1797                    .with_context(|| format!("subscription '{name}' skip LSN"))?
1798                    .filter(|lsn| lsn != "0/0"),
1799            );
1800            Ok((
1801                name.clone(),
1802                crate::model::replication::SubscriptionState {
1803                    name,
1804                    owner: Some(row.try_get("owner_name").context("subscription owner")?),
1805                    connection: crate::analysis::facts::ConnectionTarget::Redacted,
1806                    publications: row
1807                        .try_get("subpublications")
1808                        .context("subscription publication names")?,
1809                    params: Some(params),
1810                    enabled: row
1811                        .try_get("subenabled")
1812                        .context("subscription enabled state")?,
1813                    slot_name: row
1814                        .try_get("subslotname")
1815                        .context("subscription slot name")?,
1816                    generation: 0,
1817                },
1818            ))
1819        })
1820        .collect()
1821}
1822
1823fn populate_cache_from_client(
1824    client: &mut impl GenericClient,
1825    schemas: Option<&[String]>,
1826) -> Result<DbCache> {
1827    let mut cache = DbCache::new();
1828    let schema_values = schemas.map(|items| items.to_vec());
1829    let provenance = load_provenance(client, schemas)?;
1830    cache.pg_version_num = Some(provenance.pg_version_num);
1831    cache.metadata = provenance.metadata;
1832    cache.search_path = provenance.search_path;
1833
1834    let schema_filter = "AND ($1::text[] IS NULL OR n.nspname = ANY($1))";
1835    let schema_filter_with_fk = r#"
1836        AND (
1837            $1::text[] IS NULL
1838            OR n.nspname = ANY($1)
1839            OR c.oid IN (
1840                SELECT conrelid FROM pg_constraint cst
1841                JOIN pg_class c2 ON c2.oid = cst.confrelid
1842                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
1843                WHERE n2.nspname = ANY($1)
1844            )
1845            OR c.oid IN (
1846                SELECT confrelid FROM pg_constraint cst
1847                JOIN pg_class c2 ON c2.oid = cst.conrelid
1848                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
1849                WHERE n2.nspname = ANY($1)
1850            )
1851        )
1852    "#;
1853    let schema_filter_n1_or_n2 =
1854        "AND ($1::text[] IS NULL OR n1.nspname = ANY($1) OR n2.nspname = ANY($1))";
1855    let schema_filter_nt = r#"
1856        AND (
1857            $1::text[] IS NULL
1858            OR n_t.nspname = ANY($1)
1859            OR t.oid IN (
1860                SELECT conrelid FROM pg_constraint cst
1861                JOIN pg_class c2 ON c2.oid = cst.confrelid
1862                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
1863                WHERE n2.nspname = ANY($1)
1864            )
1865            OR t.oid IN (
1866                SELECT confrelid FROM pg_constraint cst
1867                JOIN pg_class c2 ON c2.oid = cst.conrelid
1868                JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
1869                WHERE n2.nspname = ANY($1)
1870            )
1871        )
1872    "#;
1873
1874    // Schemas are an authoritative catalog only for the requested sync scope.
1875    // FK-only external schemas pulled in below deliberately do not enter it.
1876    cache.schemas = load_schemas(client, &schema_values, schema_filter)?;
1877    // A scoped request can name schemas that do not exist yet. PostgreSQL's
1878    // effective search path skips those entries, so do not let them become
1879    // inferred-present namespaces when the cache is hydrated.
1880    cache
1881        .search_path
1882        .retain(|schema| cache.schemas.contains_key(schema));
1883
1884    cache.sequences = load_sequences(client, &schema_values)?;
1885
1886    cache.relations =
1887        load_relations_and_columns(client, schemas, &schema_values, schema_filter_with_fk)?;
1888
1889    let (relation_decorations, relation_grants) =
1890        load_relation_decorations(client, &schema_values, schema_filter_with_fk)?;
1891    for decoration in relation_decorations {
1892        let relation = cache
1893            .relations
1894            .get_mut(&decoration.relation_id)
1895            .with_context(|| {
1896                format!(
1897                    "relation decoration references omitted relation '{}'",
1898                    decoration.relation_id
1899                )
1900            })?;
1901        relation.triggers.extend(decoration.triggers);
1902        relation.policies.extend(decoration.policies);
1903    }
1904    for grant in relation_grants {
1905        let relation = cache
1906            .relations
1907            .get_mut(&grant.relation_id)
1908            .with_context(|| {
1909                format!(
1910                    "relation privilege references omitted relation '{}'",
1911                    grant.relation_id
1912                )
1913            })?;
1914        relation
1915            .privileges
1916            .grant(grant.grantee, [grant.privilege].into_iter().collect());
1917    }
1918
1919    cache.triggers = load_triggers(client, &schema_values, schema_filter_with_fk)?;
1920
1921    cache.constraints = load_constraints(client, &schema_values, schema_filter_with_fk)?;
1922
1923    cache.foreign_keys =
1924        load_foreign_keys(client, schemas, &schema_values, schema_filter_n1_or_n2)?;
1925
1926    cache.indexes = load_indexes(client, &schema_values, schema_filter_nt)?;
1927
1928    cache.functions = load_routines(client, &schema_values, schema_filter)?;
1929
1930    cache.publications = load_publications(client, cache.pg_version_num.unwrap_or_default())?;
1931
1932    cache.subscriptions = load_subscriptions(client, cache.pg_version_num.unwrap_or_default())?;
1933
1934    cache.types = load_types(client, &schema_values, schema_filter)?;
1935
1936    // Only view dependencies are consumed by cache hydration. Generic
1937    // pg_depend rows use PostgreSQL dependency codes (n/a/i) and were ignored
1938    // after synchronization, so avoid loading them into Cache V6.
1939    cache.dependencies = load_view_dependencies(client, &schema_values)?;
1940
1941    // Role identity and membership are required to distinguish a valid
1942    // `SET ROLE` from a migration that PostgreSQL would reject. pg_roles does
1943    // not expose password hashes or other credentials.
1944    cache.roles = load_roles(client, cache.pg_version_num.unwrap_or_default())?;
1945
1946    cache
1947        .validate_semantics()
1948        .map_err(anyhow::Error::msg)
1949        .context("PostgreSQL catalogs produced a semantically invalid Cache V6 baseline")?;
1950    Ok(cache)
1951}
1952
1953#[cfg(test)]
1954mod catalog_conversion_tests {
1955    use super::*;
1956
1957    #[test]
1958    fn known_catalog_codes_convert_without_fallbacks() {
1959        assert!(matches!(
1960            sequence_kind_from_pg(Some("i"), false).unwrap(),
1961            crate::model::sequence::SequenceKind::Identity
1962        ));
1963        assert!(matches!(
1964            sequence_kind_from_pg(Some("a"), true).unwrap(),
1965            crate::model::sequence::SequenceKind::SerialLike
1966        ));
1967        assert!(matches!(
1968            relation_kind_from_pg(b'm').unwrap(),
1969            RelationKind::MaterializedView
1970        ));
1971        assert!(matches!(
1972            persistence_from_pg(b'u').unwrap(),
1973            Persistence::Unlogged
1974        ));
1975        assert_eq!(
1976            partition_strategy_from_pg(Some("h")).unwrap(),
1977            Some("HASH".to_string())
1978        );
1979        assert!(matches!(
1980            routine_volatility_from_pg("i").unwrap(),
1981            crate::model::function::Volatility::Immutable
1982        ));
1983        assert!(matches!(
1984            routine_kind_from_pg("a").unwrap(),
1985            crate::model::function::RoutineKind::Aggregate
1986        ));
1987        assert_eq!(subscription_streaming_from_pg("p").unwrap(), "parallel");
1988        assert_eq!(subscription_two_phase_from_pg("e").unwrap(), "true");
1989    }
1990
1991    #[test]
1992    fn unknown_catalog_codes_are_actionable_errors() {
1993        for error in [
1994            sequence_kind_from_pg(Some("x"), false).unwrap_err(),
1995            relation_kind_from_pg(b'x').unwrap_err(),
1996            persistence_from_pg(b'x').unwrap_err(),
1997            partition_strategy_from_pg(Some("x")).unwrap_err(),
1998            routine_volatility_from_pg("x").unwrap_err(),
1999            routine_kind_from_pg("x").unwrap_err(),
2000            subscription_streaming_from_pg("x").unwrap_err(),
2001            subscription_two_phase_from_pg("x").unwrap_err(),
2002        ] {
2003            assert!(!error.to_string().is_empty());
2004        }
2005    }
2006
2007    #[test]
2008    fn connection_timeout_default_preserves_an_explicit_value() {
2009        let mut defaulted = PostgresConfig::new();
2010        apply_connection_safety_defaults(&mut defaulted);
2011        assert_eq!(
2012            defaulted.get_connect_timeout(),
2013            Some(&DEFAULT_CONNECT_TIMEOUT)
2014        );
2015
2016        let explicit = Duration::from_secs(3);
2017        let mut configured = PostgresConfig::new();
2018        configured.connect_timeout(explicit);
2019        apply_connection_safety_defaults(&mut configured);
2020        assert_eq!(configured.get_connect_timeout(), Some(&explicit));
2021    }
2022}
2023
2024#[cfg(test)]
2025mod atomic_write_tests {
2026    use super::*;
2027    use crate::db::cache::DbCacheVersioned;
2028    use std::fs;
2029    use std::io::Read;
2030
2031    fn decode_written_cache(path: &Path) -> DbCache {
2032        let encoded = fs::read(path).unwrap();
2033        let reader = std::io::Cursor::new(encoded);
2034        let mut decoder = zstd::stream::Decoder::new(reader).unwrap();
2035        let mut payload = Vec::new();
2036        decoder.read_to_end(&mut payload).unwrap();
2037        let payload = payload
2038            .strip_prefix(CACHE_V6_MAGIC)
2039            .expect("writer must prefix V6 cache payloads");
2040        let config = bincode::config::standard().with_variable_int_encoding();
2041        let versioned: DbCacheVersioned = bincode::serde::decode_from_slice(payload, config)
2042            .unwrap()
2043            .0;
2044        versioned.into_cache().unwrap()
2045    }
2046
2047    #[test]
2048    fn bare_cache_filenames_use_the_current_directory_as_parent() {
2049        assert_eq!(cache_parent(Path::new("baseline.cache")), Path::new("."));
2050        assert_eq!(
2051            cache_parent(Path::new("cache/baseline.cache")),
2052            Path::new("cache")
2053        );
2054    }
2055
2056    #[test]
2057    fn production_cache_writer_atomically_replaces_and_decodes() {
2058        let temp_dir = tempfile::tempdir().unwrap();
2059        let cache_path = temp_dir.path().join("baseline.cache");
2060        fs::write(&cache_path, b"old-cache").unwrap();
2061
2062        let mut cache = DbCache::new();
2063        cache.pg_version_num = Some(180002);
2064        write_cache(&cache_path, cache, false).unwrap();
2065
2066        assert_eq!(
2067            decode_written_cache(&cache_path).pg_version_num,
2068            Some(180002)
2069        );
2070        assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
2071    }
2072
2073    #[test]
2074    fn concurrent_cache_writers_leave_one_complete_decodable_payload() {
2075        let temp_dir = tempfile::tempdir().unwrap();
2076        let cache_path = temp_dir.path().join("baseline.cache");
2077        let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
2078        let mut writers = Vec::new();
2079        for version in [170_007, 180_002] {
2080            let cache_path = cache_path.clone();
2081            let barrier = barrier.clone();
2082            writers.push(std::thread::spawn(move || {
2083                let mut cache = DbCache::new();
2084                cache.pg_version_num = Some(version);
2085                barrier.wait();
2086                write_cache(&cache_path, cache, false)
2087            }));
2088        }
2089        barrier.wait();
2090        let results = writers
2091            .into_iter()
2092            .map(|writer| writer.join().unwrap())
2093            .collect::<Vec<_>>();
2094
2095        assert!(results.iter().any(Result::is_ok));
2096        assert!(matches!(
2097            decode_written_cache(&cache_path).pg_version_num,
2098            Some(170_007 | 180_002)
2099        ));
2100        assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
2101    }
2102
2103    #[test]
2104    fn production_cache_writer_preserves_old_bytes_after_pre_install_failure() {
2105        let temp_dir = tempfile::tempdir().unwrap();
2106        let cache_path = temp_dir.path().join("baseline.cache");
2107        fs::write(&cache_path, b"known-good-cache").unwrap();
2108
2109        let error = write_cache_with_protection(&cache_path, DbCache::new(), |_| {
2110            Err(anyhow::anyhow!("injected payload-protection failure"))
2111        })
2112        .unwrap_err();
2113
2114        assert!(
2115            error
2116                .to_string()
2117                .contains("injected payload-protection failure")
2118        );
2119        assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
2120        assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
2121    }
2122
2123    #[test]
2124    fn cache_writer_rejects_oversized_decoded_payload_before_replacement() {
2125        let temp_dir = tempfile::tempdir().unwrap();
2126        let cache_path = temp_dir.path().join("baseline.cache");
2127        fs::write(&cache_path, b"known-good-cache").unwrap();
2128
2129        let error = write_cache_with_protection_and_limits(
2130            &cache_path,
2131            DbCache::new(),
2132            Ok,
2133            MAX_CACHE_FILE_BYTES,
2134            CACHE_V6_MAGIC.len(),
2135        )
2136        .unwrap_err();
2137
2138        assert!(format!("{error:#}").contains("decoded-size limit"));
2139        assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
2140        assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
2141    }
2142
2143    #[test]
2144    fn cache_writer_rejects_oversized_encoded_payload_before_replacement() {
2145        let temp_dir = tempfile::tempdir().unwrap();
2146        let cache_path = temp_dir.path().join("baseline.cache");
2147        fs::write(&cache_path, b"known-good-cache").unwrap();
2148        let max_file_bytes = 16_u64;
2149
2150        let error = write_cache_with_protection_and_limits(
2151            &cache_path,
2152            DbCache::new(),
2153            |_| Ok(vec![0; max_file_bytes as usize + 1]),
2154            max_file_bytes,
2155            MAX_CACHE_DECODE_BYTES,
2156        )
2157        .unwrap_err();
2158
2159        assert!(format!("{error:#}").contains("encoded-size limit"));
2160        assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache");
2161        assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1);
2162    }
2163}