Skip to main content

safe_migrate/_internal/db/
cache.rs

1use crate::_internal::ast::identifiers::ObjectId;
2use crate::_internal::model::constraint::{ConstraintKind, ConstraintState};
3use crate::_internal::model::function::FunctionState;
4use crate::_internal::model::relation::{RelationKind, RelationState};
5use crate::_internal::model::replication::{PublicationState, SubscriptionState};
6use crate::_internal::model::role::{RoleMembershipGrantor, RoleState};
7use crate::_internal::model::schema::SchemaState;
8use crate::_internal::model::sequence::SequenceState;
9use crate::_internal::model::trigger::TriggerEnableMode;
10use crate::_internal::model::types::TypeState;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeSet, HashMap, HashSet};
13
14/// Catalog families whose completeness is independently meaningful to the
15/// analyzer. The V7 cache records this explicitly instead of treating one
16/// optional schema list as evidence for every object class.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum CatalogFamily {
20    Schemas,
21    Relations,
22    Sequences,
23    Indexes,
24    Constraints,
25    Triggers,
26    Routines,
27    Types,
28    Dependencies,
29    Inheritance,
30    Roles,
31    Publications,
32    Subscriptions,
33}
34
35impl CatalogFamily {
36    pub const fn as_str(self) -> &'static str {
37        match self {
38            Self::Schemas => "schemas",
39            Self::Relations => "relations",
40            Self::Sequences => "sequences",
41            Self::Indexes => "indexes",
42            Self::Constraints => "constraints",
43            Self::Triggers => "triggers",
44            Self::Routines => "routines",
45            Self::Types => "types",
46            Self::Dependencies => "dependencies",
47            Self::Inheritance => "inheritance",
48            Self::Roles => "roles",
49            Self::Publications => "publications",
50            Self::Subscriptions => "subscriptions",
51        }
52    }
53}
54
55/// Schema boundary for the schema-scoped catalog families in [`CatalogCoverage`].
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum SchemaCoverage {
59    AllNonSystem,
60    Explicit(BTreeSet<String>),
61}
62
63impl SchemaCoverage {
64    pub fn from_sync_scope(schemas: Option<&[String]>) -> Self {
65        match schemas {
66            Some(schemas) => Self::Explicit(schemas.iter().cloned().collect()),
67            None => Self::AllNonSystem,
68        }
69    }
70
71    pub fn covers(&self, schema: &str) -> bool {
72        match self {
73            Self::AllNonSystem => true,
74            Self::Explicit(schemas) => schemas.contains(schema),
75        }
76    }
77
78    pub fn explicit_schemas(&self) -> Option<Vec<String>> {
79        match self {
80            Self::AllNonSystem => None,
81            Self::Explicit(schemas) => Some(schemas.iter().cloned().collect()),
82        }
83    }
84}
85
86/// Completeness contract emitted by synchronization and consumed by cache
87/// validation. The schema boundary applies to schema-scoped families; role,
88/// publication, and subscription rows are recorded separately in `families`.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct CatalogCoverage {
91    pub schema_scope: SchemaCoverage,
92    pub families: BTreeSet<CatalogFamily>,
93}
94
95impl CatalogCoverage {
96    pub fn from_sync_scope(schemas: Option<&[String]>) -> Self {
97        Self {
98            schema_scope: SchemaCoverage::from_sync_scope(schemas),
99            families: [
100                CatalogFamily::Schemas,
101                CatalogFamily::Relations,
102                CatalogFamily::Sequences,
103                CatalogFamily::Indexes,
104                CatalogFamily::Constraints,
105                CatalogFamily::Triggers,
106                CatalogFamily::Routines,
107                CatalogFamily::Types,
108                CatalogFamily::Dependencies,
109                CatalogFamily::Inheritance,
110                CatalogFamily::Roles,
111                CatalogFamily::Publications,
112                CatalogFamily::Subscriptions,
113            ]
114            .into_iter()
115            .collect(),
116        }
117    }
118
119    pub fn has(&self, family: CatalogFamily) -> bool {
120        self.families.contains(&family)
121    }
122
123    pub fn family_names(&self) -> impl Iterator<Item = &'static str> + '_ {
124        self.families.iter().copied().map(CatalogFamily::as_str)
125    }
126}
127
128impl Default for CatalogCoverage {
129    fn default() -> Self {
130        // Programmatic test baselines retain the historical all-schema
131        // assumption. Production sync always overwrites this with its actual
132        // requested scope before a V7 cache can be written.
133        Self::from_sync_scope(None)
134    }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct ForeignKeyCache {
139    pub constraint_name: String,
140    pub from_table: ObjectId,
141    pub to_table: ObjectId,
142    /// Ordered `pg_constraint.conkey` identities resolved through
143    /// `pg_attribute`. Empty vectors are invalid for V7 FK records.
144    pub from_columns: Vec<String>,
145    /// Ordered `pg_constraint.confkey` identities resolved through
146    /// `pg_attribute`. Position pairs with `from_columns`.
147    pub to_columns: Vec<String>,
148    /// Ordered equality operators selected by PostgreSQL for PK = FK
149    /// comparisons. These are textual identities rather than OIDs so a cache
150    /// remains meaningful across cluster restarts and logical restores.
151    pub pk_fk_equality_operators: Vec<String>,
152    /// Ordered equality operators selected for PK = PK comparisons.
153    pub pk_pk_equality_operators: Vec<String>,
154    /// Ordered equality operators selected for FK = FK comparisons.
155    pub fk_fk_equality_operators: Vec<String>,
156}
157
158impl ForeignKeyCache {
159    pub fn has_complete_operator_evidence(&self) -> bool {
160        let count = self.from_columns.len();
161        count > 0
162            && self.to_columns.len() == count
163            && [
164                &self.pk_fk_equality_operators,
165                &self.pk_pk_equality_operators,
166                &self.fk_fk_equality_operators,
167            ]
168            .iter()
169            .all(|operators| {
170                operators.len() == count
171                    && operators.iter().all(|operator| !operator.trim().is_empty())
172            })
173    }
174}
175
176/// Ordered key columns for a primary or unique constraint. This is separate
177/// from `ConstraintState` so the runtime state model remains focused on the
178/// mutable constraint lifecycle while Cache V7 can preserve catalog proof.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct ConstraintKeyCache {
181    pub table_id: ObjectId,
182    pub constraint_name: String,
183    pub columns: Vec<String>,
184    pub is_primary: bool,
185}
186
187/// Complete direct column dependencies for expression-backed constraints
188/// (currently CHECK and EXCLUDE). An empty vector is authoritative: the
189/// expression has no relation-column dependency.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct ConstraintDependencyCache {
192    pub table_id: ObjectId,
193    pub constraint_name: String,
194    pub columns: Vec<String>,
195}
196
197/// A generated column's direct source-column dependencies from `pg_attrdef`.
198/// The generated column itself is identified separately so dropping it can
199/// remove only its own expression edge; source-column drops remain
200/// conservative until dependent-column CASCADE is modeled.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct GeneratedColumnDependencyCache {
203    pub table_id: ObjectId,
204    pub column_name: String,
205    pub depends_on_column: String,
206}
207
208/// A normal `pg_attrdef` dependency from a column default to a sequence.
209/// This is distinct from sequence OWNED BY metadata: a default can reference
210/// a standalone sequence without making that sequence owned by the table.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct DefaultSequenceDependencyCache {
213    pub table_id: ObjectId,
214    pub column_name: String,
215    pub sequence_id: ObjectId,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219pub struct IndexCache {
220    pub index_id: ObjectId,
221    pub table_id: ObjectId,
222    pub using_method: String,
223    /// Ordered simple key columns. An expression key is omitted, so this is
224    /// complete only when `has_expression_keys` is false.
225    pub key_columns: Vec<String>,
226    /// Columns stored with `INCLUDE`; they remain dependencies of the index.
227    pub included_columns: Vec<String>,
228    /// Every table column named by PostgreSQL's `pg_depend` rows for this
229    /// index, including expression keys and predicates. This is unordered
230    /// dependency evidence, not an index definition.
231    pub dependency_columns: Vec<String>,
232    /// Whether `dependency_columns` came from complete catalog evidence.
233    /// Locally parsed expression/predicate indexes cannot claim this yet.
234    pub dependency_columns_known: bool,
235    pub has_expression_keys: bool,
236    pub has_predicate: bool,
237    pub is_unique: bool,
238    pub is_valid: bool,
239    pub is_ready: bool,
240    pub is_live: bool,
241    pub has_default_sort_order: bool,
242    pub has_default_opclasses: bool,
243    pub has_default_collations: bool,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct TriggerCache {
248    pub trigger_id: ObjectId,
249    pub table_id: ObjectId,
250    pub function_id: ObjectId,
251    pub enabled_mode: TriggerEnableMode,
252}
253
254/// A normalized `pg_rewrite`/`pg_depend` view edge.
255///
256/// Raw catalog OIDs are deliberately not serialized: their only useful
257/// contribution to the modeled relation dependency is `refobjsubid`, which is
258/// resolved during sync to the stable column identity below. Keeping raw OIDs
259/// alongside names made the old record look more authoritative without adding
260/// a transition consumer.
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct ViewDependencyCache {
263    pub dependent: ObjectId,
264    pub referenced: ObjectId,
265    /// `None` means PostgreSQL reported a relation-level dependency. Such a
266    /// row must remain conservative for column-level transitions.
267    pub referenced_column: Option<String>,
268}
269
270/// One direct table or index inheritance relationship from `pg_inherits`.
271///
272/// The row is retained separately from generic dependencies because PostgreSQL
273/// exposes detach-in-progress state here and the transition engine needs the
274/// direct parent/child direction for partition-cycle and publication scope
275/// reasoning.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct InheritanceCache {
278    pub child: ObjectId,
279    pub parent: ObjectId,
280    pub sequence: i32,
281    /// `pg_class.relispartition` for `child`. Distinguishes declarative
282    /// partitioning from traditional `INHERITS`, which shares `pg_inherits`.
283    pub is_partition: bool,
284    pub detach_pending: bool,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, Default)]
288pub struct CacheMetadata {
289    /// Seconds since the Unix epoch when `safe-migrate sync` assembled this
290    /// baseline. `None` represents a cache written before provenance support.
291    pub created_at_unix_secs: Option<u64>,
292    /// Whether all scope-boundary dependency queries completed in the same
293    /// repeatable-read synchronization transaction. This is deliberately
294    /// explicit: timestamps identify age, not catalog-query completeness.
295    #[serde(default)]
296    pub boundary_queries_complete: bool,
297    /// PostgreSQL database name only; connection credentials and host details
298    /// are deliberately never stored in a cache.
299    pub source_database: Option<String>,
300    /// Session role used when the cache was synchronized. This is needed to
301    /// resolve PostgreSQL's special `$user` search-path entry.
302    pub source_role: Option<String>,
303    /// `SESSION_USER` at synchronization time. This remains distinct from
304    /// `source_role` when the connection has selected another effective role.
305    pub source_session_role: Option<String>,
306    /// Parsed `search_path` setting before PostgreSQL expands `$user`.
307    pub source_search_path: Option<Vec<String>>,
308    /// Effective `lock_timeout` observed on the fresh synchronization
309    /// connection, normalized to milliseconds. PostgreSQL uses zero to mean
310    /// that the timeout is disabled.
311    pub source_lock_timeout_ms: u64,
312    /// Effective `statement_timeout` observed on the fresh synchronization
313    /// connection, normalized to milliseconds. PostgreSQL uses zero to mean
314    /// that the timeout is disabled.
315    pub source_statement_timeout_ms: u64,
316    /// Explicit schema scope passed to sync. `None` means all non-system
317    /// schemas were requested.
318    pub schemas: Option<Vec<String>>,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct DbCache {
323    pub pg_version_num: Option<u32>,
324    pub metadata: CacheMetadata,
325    pub coverage: CatalogCoverage,
326    pub search_path: Vec<String>,
327    pub relations: HashMap<ObjectId, RelationState>,
328    pub foreign_keys: Vec<ForeignKeyCache>,
329    pub indexes: Vec<IndexCache>,
330    pub constraints: Vec<ConstraintState>,
331    pub constraint_keys: Vec<ConstraintKeyCache>,
332    pub constraint_dependencies: Vec<ConstraintDependencyCache>,
333    pub generated_column_dependencies: Vec<GeneratedColumnDependencyCache>,
334    pub default_sequence_dependencies: Vec<DefaultSequenceDependencyCache>,
335    pub triggers: Vec<TriggerCache>,
336    pub functions: HashMap<ObjectId, FunctionState>,
337    pub types: HashMap<ObjectId, TypeState>,
338    pub roles: HashMap<ObjectId, RoleState>,
339    /// Grantor provenance for role memberships, used by membership CASCADE.
340    #[serde(default)]
341    pub role_membership_grantors: Vec<RoleMembershipGrantor>,
342    /// True only when the synchronizer queried every membership grantor row.
343    #[serde(default)]
344    pub role_membership_grantors_complete: bool,
345    pub schemas: HashMap<String, SchemaState>,
346    pub sequences: HashMap<ObjectId, SequenceState>,
347    pub dependencies: Vec<ViewDependencyCache>,
348    /// Synchronized relations with a known dependent outside an explicit
349    /// schema scope. Scoped destructive transitions stay conservative for
350    /// these identities; an absent entry means no such dependent was observed
351    /// in the catalog families the synchronizer resolves.
352    pub scoped_external_relation_dependencies: Vec<ObjectId>,
353    pub scoped_external_type_dependencies: Vec<ObjectId>,
354    pub scoped_external_routine_dependencies: Vec<ObjectId>,
355    pub inheritances: Vec<InheritanceCache>,
356    pub publications: HashMap<String, PublicationState>,
357    pub subscriptions: HashMap<String, SubscriptionState>,
358}
359
360pub const CACHE_FORMAT_VERSION: u32 = 7;
361
362/// Current durable cache header. V7 adds PostgreSQL-selected FK equality
363/// operator evidence to the normalized catalog snapshot.
364pub const CACHE_V7_MAGIC: &[u8] = b"SMCACHE07";
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub enum DbCacheVersioned {
368    // Unit variants reserve the historic bincode discriminants. The reader
369    // rejects non-current headers before decoding, so legacy layouts are not part
370    // of the production model and cannot be converted accidentally.
371    V1,
372    V2,
373    V3,
374    V4,
375    V5(Box<DbCache>),
376    V6(Box<DbCache>),
377    V7(Box<DbCache>),
378}
379
380impl DbCacheVersioned {
381    pub fn format_version(&self) -> u32 {
382        match self {
383            DbCacheVersioned::V1 => 1,
384            DbCacheVersioned::V2 => 2,
385            DbCacheVersioned::V3 => 3,
386            DbCacheVersioned::V4 => 4,
387            DbCacheVersioned::V5(_) => 5,
388            DbCacheVersioned::V6(_) => 6,
389            DbCacheVersioned::V7(_) => 7,
390        }
391    }
392
393    pub fn into_cache(self) -> Result<DbCache, String> {
394        match self {
395            DbCacheVersioned::V1
396            | DbCacheVersioned::V2
397            | DbCacheVersioned::V3
398            | DbCacheVersioned::V4
399            | DbCacheVersioned::V5(_)
400            | DbCacheVersioned::V6(_) => Err(
401                "This cache format is unsupported. Run `safe-migrate sync` to rebuild it."
402                    .to_string(),
403            ),
404            DbCacheVersioned::V7(c) => {
405                c.validate_semantics()?;
406                Ok(*c)
407            }
408        }
409    }
410}
411
412impl Default for DbCache {
413    fn default() -> Self {
414        Self::new()
415    }
416}
417
418impl DbCache {
419    pub fn new() -> Self {
420        Self {
421            pg_version_num: None,
422            metadata: CacheMetadata::default(),
423            coverage: CatalogCoverage::default(),
424            search_path: vec!["public".to_string()],
425            relations: HashMap::new(),
426            foreign_keys: Vec::new(),
427            indexes: Vec::new(),
428            constraints: Vec::new(),
429            constraint_keys: Vec::new(),
430            constraint_dependencies: Vec::new(),
431            generated_column_dependencies: Vec::new(),
432            default_sequence_dependencies: Vec::new(),
433            triggers: Vec::new(),
434            functions: HashMap::new(),
435            types: HashMap::new(),
436            roles: HashMap::new(),
437            role_membership_grantors: Vec::new(),
438            role_membership_grantors_complete: false,
439            schemas: HashMap::new(),
440            sequences: HashMap::new(),
441            dependencies: Vec::new(),
442            scoped_external_relation_dependencies: Vec::new(),
443            scoped_external_type_dependencies: Vec::new(),
444            scoped_external_routine_dependencies: Vec::new(),
445            inheritances: Vec::new(),
446            publications: HashMap::new(),
447            subscriptions: HashMap::new(),
448        }
449    }
450
451    pub fn insert_baseline(&mut self, id: ObjectId, state: RelationState) {
452        self.relations.insert(id, state);
453    }
454
455    pub fn baseline_relations(&self) -> impl Iterator<Item = (&ObjectId, &RelationState)> {
456        self.relations.iter()
457    }
458
459    fn role_membership_cycle(&self) -> Option<ObjectId> {
460        fn visit(
461            role_id: &ObjectId,
462            roles: &HashMap<ObjectId, RoleState>,
463            visiting: &mut HashSet<ObjectId>,
464            visited: &mut HashSet<ObjectId>,
465        ) -> Option<ObjectId> {
466            if visiting.contains(role_id) {
467                return Some(role_id.clone());
468            }
469            if !visited.insert(role_id.clone()) {
470                return None;
471            }
472            visiting.insert(role_id.clone());
473            if let Some(role) = roles.get(role_id) {
474                for parent in &role.member_of {
475                    if let Some(cycle) = visit(parent, roles, visiting, visited) {
476                        return Some(cycle);
477                    }
478                }
479            }
480            visiting.remove(role_id);
481            None
482        }
483
484        let mut visiting = HashSet::new();
485        let mut visited = HashSet::new();
486        self.roles
487            .keys()
488            .find_map(|role_id| visit(role_id, &self.roles, &mut visiting, &mut visited))
489    }
490
491    /// Validate cross-record identity, relationship, and catalog-coverage
492    /// invariants before a cache is used as an authoritative baseline.
493    pub fn validate_semantics(&self) -> Result<(), String> {
494        // Cache identities are authoritative catalog names, never resolver
495        // guesses. Reject malformed or inferred IDs at the boundary so every
496        // downstream map lookup has one canonical representation.
497        let validate_id = |label: &str, id: &ObjectId, require_schema: bool| {
498            if id.name.is_empty() {
499                return Err(format!("{label} has an empty object name"));
500            }
501            if require_schema && id.schema.is_empty() {
502                return Err(format!(
503                    "{label} '{}' must include a schema identity",
504                    id.name
505                ));
506            }
507            if id.inferred_schema {
508                return Err(format!(
509                    "{label} '{}' is marked as an inferred schema identity",
510                    id
511                ));
512            }
513            Ok(())
514        };
515        let validate_role_ref = |label: &str, id: &ObjectId| {
516            validate_id(label, id, false)?;
517            if !self.roles.is_empty() && !self.roles.contains_key(id) {
518                return Err(format!(
519                    "{label} '{}' references a role absent from the synchronized role catalog",
520                    id.name
521                ));
522            }
523            Ok(())
524        };
525
526        if let Some(schemas) = &self.metadata.schemas {
527            let mut seen = HashSet::new();
528            for schema in schemas {
529                if schema.is_empty() {
530                    return Err("cache schema scope contains an empty schema identity".to_string());
531                }
532                if !seen.insert(schema) {
533                    return Err(format!(
534                        "cache schema scope contains duplicate schema '{}'",
535                        schema
536                    ));
537                }
538            }
539        }
540        for (label, value) in [
541            ("source database", self.metadata.source_database.as_deref()),
542            ("source role", self.metadata.source_role.as_deref()),
543            (
544                "source session role",
545                self.metadata.source_session_role.as_deref(),
546            ),
547        ] {
548            if value.is_some_and(str::is_empty) {
549                return Err(format!("cache metadata contains an empty {label} identity"));
550            }
551        }
552        if let Some(template) = &self.metadata.source_search_path
553            && template.iter().any(String::is_empty)
554        {
555            return Err(
556                "cache metadata source search path contains an empty schema identity".to_string(),
557            );
558        }
559        let mut search_path = HashSet::new();
560        for schema in &self.search_path {
561            if schema.is_empty() {
562                return Err("cache search path contains an empty schema identity".to_string());
563            }
564            if !search_path.insert(schema) {
565                return Err(format!(
566                    "cache search path contains duplicate schema '{}'",
567                    schema
568                ));
569            }
570        }
571        let required_families = [
572            CatalogFamily::Schemas,
573            CatalogFamily::Relations,
574            CatalogFamily::Sequences,
575            CatalogFamily::Indexes,
576            CatalogFamily::Constraints,
577            CatalogFamily::Triggers,
578            CatalogFamily::Routines,
579            CatalogFamily::Types,
580            CatalogFamily::Dependencies,
581            CatalogFamily::Inheritance,
582            CatalogFamily::Roles,
583            CatalogFamily::Publications,
584            CatalogFamily::Subscriptions,
585        ];
586        for family in required_families {
587            if !self.coverage.has(family) {
588                return Err(format!(
589                    "Cache V7 coverage is missing the required '{}' catalog family",
590                    family.as_str(),
591                ));
592            }
593        }
594        let mut scoped_external_relations = HashSet::new();
595        for id in &self.scoped_external_relation_dependencies {
596            validate_id("scoped external relation dependency identity", id, true)?;
597            if !scoped_external_relations.insert(id) {
598                return Err(format!(
599                    "scoped external relation dependency '{}' appears more than once",
600                    id
601                ));
602            }
603            if !matches!(self.coverage.schema_scope, SchemaCoverage::Explicit(_)) {
604                return Err(format!(
605                    "scoped external relation dependency '{}' requires an explicit schema scope",
606                    id
607                ));
608            }
609            if !self.coverage.schema_scope.covers(&id.schema) {
610                return Err(format!(
611                    "scoped external relation dependency '{}' is outside the cache schema scope",
612                    id
613                ));
614            }
615        }
616        for (label, ids) in [
617            (
618                "scoped external type dependency",
619                &self.scoped_external_type_dependencies,
620            ),
621            (
622                "scoped external routine dependency",
623                &self.scoped_external_routine_dependencies,
624            ),
625        ] {
626            let mut seen = HashSet::new();
627            for id in ids {
628                validate_id(label, id, true)?;
629                if !seen.insert(id) {
630                    return Err(format!("{label} '{}' appears more than once", id));
631                }
632                if !matches!(self.coverage.schema_scope, SchemaCoverage::Explicit(_)) {
633                    return Err(format!(
634                        "{label} '{}' requires an explicit schema scope",
635                        id
636                    ));
637                }
638                if !self.coverage.schema_scope.covers(&id.schema) {
639                    return Err(format!(
640                        "{label} '{}' is outside the cache schema scope",
641                        id
642                    ));
643                }
644            }
645        }
646        let coverage_scope = self
647            .coverage
648            .schema_scope
649            .explicit_schemas()
650            .map(|schemas| schemas.into_iter().collect::<BTreeSet<_>>());
651        let metadata_scope = self
652            .metadata
653            .schemas
654            .as_ref()
655            .map(|schemas| schemas.iter().cloned().collect::<BTreeSet<_>>());
656        if coverage_scope != metadata_scope {
657            return Err(
658                "Cache V7 schema coverage disagrees with legacy metadata schema scope".to_string(),
659            );
660        }
661        for (id, relation) in &self.relations {
662            validate_id("relation cache identity", id, true)?;
663            validate_id("relation embedded identity", &relation.id, true)?;
664            validate_role_ref("relation owner identity", &relation.owner)?;
665            if id != &relation.id {
666                return Err(format!(
667                    "relation cache key '{}' disagrees with embedded identity '{}'",
668                    id, relation.id
669                ));
670            }
671            if relation.is_populated.is_some()
672                && !matches!(relation.kind, RelationKind::MaterializedView)
673            {
674                return Err(format!(
675                    "relation '{}' carries materialized-view population state but is not a materialized view",
676                    id
677                ));
678            }
679            let mut column_names = HashSet::new();
680            for column in &relation.columns {
681                if column.name.is_empty() {
682                    return Err(format!(
683                        "relation '{}' contains a column with an empty identity",
684                        id
685                    ));
686                }
687                if !column_names.insert(column.name.as_str()) {
688                    return Err(format!(
689                        "relation '{}' contains duplicate column '{}', which makes lookup ambiguous",
690                        id, column.name
691                    ));
692                }
693            }
694            for (grantee, privileges) in &relation.privileges.grants {
695                validate_id("relation privilege grantee", grantee, false)?;
696                if !grantee.schema.is_empty() {
697                    return Err(format!(
698                        "relation '{}' privilege grantee '{}' must use the cluster role namespace",
699                        id, grantee
700                    ));
701                }
702                if privileges.is_empty() {
703                    return Err(format!(
704                        "relation '{}' has an empty privilege set for grantee '{}'",
705                        id, grantee
706                    ));
707                }
708                if grantee.name != "public" && !self.roles.contains_key(grantee) {
709                    return Err(format!(
710                        "relation '{}' privilege references missing grantee role '{}'",
711                        id, grantee
712                    ));
713                }
714            }
715            for ((grantee, privilege), grantors) in &relation.privileges.grantors {
716                validate_id("relation privilege provenance grantee", grantee, false)?;
717                if !grantee.schema.is_empty() {
718                    return Err(format!(
719                        "relation '{}' privilege provenance grantee '{}' must use the cluster role namespace",
720                        id, grantee
721                    ));
722                }
723                if !relation.privileges.has_privilege(grantee, *privilege) {
724                    return Err(format!(
725                        "relation '{}' privilege provenance exists without the effective '{}' privilege for '{}'",
726                        id,
727                        format_args!("{privilege:?}"),
728                        grantee
729                    ));
730                }
731                for grantor in grantors {
732                    validate_id("relation privilege provenance grantor", grantor, false)?;
733                    if !grantor.schema.is_empty() {
734                        return Err(format!(
735                            "relation '{}' privilege provenance grantor '{}' must use the cluster role namespace",
736                            id, grantor
737                        ));
738                    }
739                    if grantor.name != "public" && !self.roles.contains_key(grantor) {
740                        return Err(format!(
741                            "relation '{}' privilege provenance references missing grantor role '{}'",
742                            id, grantor
743                        ));
744                    }
745                }
746            }
747            for ((grantee, privilege), grantors) in &relation.privileges.grant_option_grantors {
748                validate_id("relation grant-option provenance grantee", grantee, false)?;
749                if !relation.privileges.has_grant_option(grantee, *privilege) {
750                    return Err(format!(
751                        "relation '{}' grant-option provenance exists without the grant option for '{}'",
752                        id, grantee
753                    ));
754                }
755                for grantor in grantors {
756                    validate_id("relation grant-option provenance grantor", grantor, false)?;
757                    if !grantor.schema.is_empty()
758                        || (grantor.name != "public" && !self.roles.contains_key(grantor))
759                    {
760                        return Err(format!(
761                            "relation '{}' grant-option provenance references invalid grantor role '{}'",
762                            id, grantor
763                        ));
764                    }
765                }
766            }
767        }
768        for (id, function) in &self.functions {
769            validate_id("routine cache identity", id, true)?;
770            validate_id("routine embedded identity", &function.id, true)?;
771            if id != &function.id {
772                return Err(format!(
773                    "routine cache key '{}' disagrees with embedded identity '{}'",
774                    id, function.id
775                ));
776            }
777        }
778        for (id, ty) in &self.types {
779            validate_id("type cache identity", id, true)?;
780            validate_id("type embedded identity", &ty.id, true)?;
781            if id != &ty.id {
782                return Err(format!(
783                    "type cache key '{}' disagrees with embedded identity '{}'",
784                    id, ty.id
785                ));
786            }
787        }
788        for (id, role) in &self.roles {
789            validate_id("role cache identity", id, false)?;
790            validate_id("role embedded identity", &role.id, false)?;
791            if id != &role.id {
792                return Err(format!(
793                    "role cache key '{}' disagrees with embedded identity '{}'",
794                    id, role.id
795                ));
796            }
797            if !id.schema.is_empty() {
798                return Err(format!(
799                    "role '{}' must use the cluster role namespace, not schema '{}'",
800                    id.name, id.schema
801                ));
802            }
803        }
804        if let Some(role_id) = self.role_membership_cycle() {
805            return Err(format!(
806                "role membership graph contains a circular path at '{}'",
807                role_id.name
808            ));
809        }
810        for (name, schema) in &self.schemas {
811            if name.is_empty() {
812                return Err("schema cache contains an empty schema identity".to_string());
813            }
814            if schema.name.is_empty() {
815                return Err("schema cache contains an empty embedded schema identity".to_string());
816            }
817            validate_role_ref("schema owner identity", &schema.owner)?;
818            if name != &schema.name {
819                return Err(format!(
820                    "schema cache key '{}' disagrees with embedded identity '{}'",
821                    name, schema.name
822                ));
823            }
824        }
825        for (id, sequence) in &self.sequences {
826            validate_id("sequence cache identity", id, true)?;
827            validate_id("sequence embedded identity", &sequence.id, true)?;
828            validate_role_ref("sequence owner identity", &sequence.owner)?;
829            if id != &sequence.id {
830                return Err(format!(
831                    "sequence cache key '{}' disagrees with embedded identity '{}'",
832                    id, sequence.id
833                ));
834            }
835            if self.relations.contains_key(id) {
836                return Err(format!(
837                    "sequence '{}' collides with another relation-namespace object",
838                    id
839                ));
840            }
841        }
842        for (name, publication) in &self.publications {
843            if name.is_empty() || publication.name.is_empty() {
844                return Err("publication cache contains an empty identity".to_string());
845            }
846            if name != &publication.name {
847                return Err(format!(
848                    "publication cache key '{}' disagrees with embedded identity '{}'",
849                    name, publication.name
850                ));
851            }
852            if let Some(owner) = &publication.owner {
853                if owner.is_empty() {
854                    return Err(format!(
855                        "publication '{}' has an empty owner identity",
856                        name
857                    ));
858                }
859                if !self.roles.is_empty() && !self.roles.contains_key(&ObjectId::new("", owner)) {
860                    return Err(format!(
861                        "publication '{}' owner '{}' is absent from the synchronized role catalog",
862                        name, owner
863                    ));
864                }
865            }
866            match &publication.scope {
867                crate::_internal::analysis::facts::PublicationScope::AllTables { except } => {
868                    let mut seen = HashSet::new();
869                    for table in except {
870                        let resolved =
871                            crate::_internal::ast::identifiers::Ident::new(table, false).resolve();
872                        if resolved.is_empty() || !seen.insert(resolved) {
873                            return Err(format!(
874                                "publication '{}' has an empty or duplicate EXCEPT table",
875                                name
876                            ));
877                        }
878                    }
879                }
880                crate::_internal::analysis::facts::PublicationScope::Explicit(objects) => {
881                    let mut seen = HashSet::new();
882                    for object in objects {
883                        let key = match object {
884                            crate::_internal::analysis::facts::PublicationObjectFact::Table {
885                                name: table,
886                                columns,
887                                ..
888                            } => {
889                                let table_name = table.name.resolve();
890                                let schema = table
891                                    .schema
892                                    .as_ref()
893                                    .map(|schema| schema.resolve());
894                                if table_name.is_empty()
895                                    || schema.as_ref().is_some_and(String::is_empty)
896                                {
897                                    return Err(format!(
898                                        "publication '{}' contains an empty table identity",
899                                        name
900                                    ));
901                                }
902                                if let Some(columns) = columns {
903                                    let mut column_names = HashSet::new();
904                                    for column in columns {
905                                        let resolved =
906                                            crate::_internal::ast::identifiers::Ident::new(column, false)
907                                                .resolve();
908                                        if resolved.is_empty() || !column_names.insert(resolved) {
909                                            return Err(format!(
910                                                "publication '{}' contains an empty or duplicate table column",
911                                                name
912                                            ));
913                                        }
914                                    }
915                                }
916                                format!(
917                                    "table:{}:{table_name}",
918                                    schema.unwrap_or_else(|| "<unqualified>".to_string())
919                                )
920                            }
921                            crate::_internal::analysis::facts::PublicationObjectFact::SchemaTables {
922                                schema,
923                                ..
924                            } => {
925                                if schema.is_empty() {
926                                    return Err(format!(
927                                        "publication '{}' contains an empty schema identity",
928                                        name
929                                    ));
930                                }
931                                format!("schema:{schema}")
932                            }
933                            crate::_internal::analysis::facts::PublicationObjectFact::CurrentSchemaShorthand =>
934                                "current_schema".to_string(),
935                            crate::_internal::analysis::facts::PublicationObjectFact::Unknown => {
936                                return Err(format!(
937                                    "publication '{}' contains unsupported unknown scope metadata",
938                                    name
939                                ));
940                            }
941                        };
942                        if !seen.insert(key) {
943                            return Err(format!(
944                                "publication '{}' contains duplicate scope metadata",
945                                name
946                            ));
947                        }
948                    }
949                }
950            }
951        }
952        for (name, subscription) in &self.subscriptions {
953            if name.is_empty() || subscription.name.is_empty() {
954                return Err("subscription cache contains an empty identity".to_string());
955            }
956            if name != &subscription.name {
957                return Err(format!(
958                    "subscription cache key '{}' disagrees with embedded identity '{}'",
959                    name, subscription.name
960                ));
961            }
962            if let Some(owner) = &subscription.owner {
963                if owner.is_empty() {
964                    return Err(format!(
965                        "subscription '{}' has an empty owner identity",
966                        name
967                    ));
968                }
969                if !self.roles.is_empty() && !self.roles.contains_key(&ObjectId::new("", owner)) {
970                    return Err(format!(
971                        "subscription '{}' owner '{}' is absent from the synchronized role catalog",
972                        name, owner
973                    ));
974                }
975            }
976            let mut publication_names = HashSet::new();
977            for publication in &subscription.publications {
978                if publication.is_empty() || !publication_names.insert(publication) {
979                    return Err(format!(
980                        "subscription '{}' contains an empty or duplicate publication name",
981                        name
982                    ));
983                }
984            }
985        }
986
987        for schema in &self.search_path {
988            if !self.schemas.is_empty() && !self.schemas.contains_key(schema) {
989                return Err(format!(
990                    "effective search path references missing schema '{}'",
991                    schema
992                ));
993            }
994        }
995
996        for (id, sequence) in &self.sequences {
997            if let Some((table_id, column_name)) = &sequence.owned_by {
998                validate_id("sequence ownership table identity", table_id, true)?;
999                if id.schema != table_id.schema {
1000                    return Err(format!(
1001                        "sequence '{}' must be in the same schema as owning table '{}'",
1002                        id, table_id
1003                    ));
1004                }
1005                if column_name.is_empty() {
1006                    return Err(format!(
1007                        "sequence '{}' ownership has an empty column identity",
1008                        id
1009                    ));
1010                }
1011                let Some(relation) = self.relations.get(table_id) else {
1012                    let omitted_owner_schema =
1013                        self.metadata.schemas.as_ref().is_some_and(|schemas| {
1014                            !schemas.iter().any(|schema| schema == &table_id.schema)
1015                        });
1016                    if omitted_owner_schema {
1017                        continue;
1018                    }
1019                    return Err(format!(
1020                        "sequence '{}' ownership references missing relation '{}'",
1021                        id, table_id
1022                    ));
1023                };
1024                if !matches!(relation.kind, RelationKind::Table) {
1025                    return Err(format!(
1026                        "sequence '{}' ownership target '{}' is not a table",
1027                        id, table_id
1028                    ));
1029                }
1030                if !relation.has_column(column_name) {
1031                    return Err(format!(
1032                        "sequence '{}' ownership references missing column '{}.{}'",
1033                        id, table_id, column_name
1034                    ));
1035                }
1036            }
1037        }
1038
1039        for (id, role) in &self.roles {
1040            let mut memberships = HashSet::new();
1041            for target in role.member_of.iter().chain(&role.can_set_role_to) {
1042                validate_id("role membership target", target, false)?;
1043                if !target.schema.is_empty() {
1044                    return Err(format!(
1045                        "role '{}' membership target '{}' must use the cluster role namespace",
1046                        id.name, target
1047                    ));
1048                }
1049                if !self.roles.contains_key(target) {
1050                    return Err(format!(
1051                        "role '{}' membership references missing role '{}'",
1052                        id, target
1053                    ));
1054                }
1055            }
1056            for target in &role.member_of {
1057                if target == id {
1058                    return Err(format!("role '{}' cannot be a member of itself", id.name));
1059                }
1060                if !memberships.insert(target) {
1061                    return Err(format!(
1062                        "role '{}' contains duplicate membership in '{}'",
1063                        id.name, target.name
1064                    ));
1065                }
1066            }
1067            let mut set_role_targets = HashSet::new();
1068            for target in &role.can_set_role_to {
1069                if !memberships.contains(target) {
1070                    return Err(format!(
1071                        "role '{}' has SET ROLE access to '{}' without membership",
1072                        id.name, target.name
1073                    ));
1074                }
1075                if !set_role_targets.insert(target) {
1076                    return Err(format!(
1077                        "role '{}' contains duplicate SET ROLE target '{}'",
1078                        id.name, target.name
1079                    ));
1080                }
1081            }
1082            let mut admin_targets = HashSet::new();
1083            for target in &role.can_administer_membership {
1084                if !memberships.contains(target) {
1085                    return Err(format!(
1086                        "role '{}' has ADMIN option for '{}' without membership",
1087                        id.name, target.name
1088                    ));
1089                }
1090                if !admin_targets.insert(target) {
1091                    return Err(format!(
1092                        "role '{}' contains duplicate ADMIN target '{}'",
1093                        id.name, target.name
1094                    ));
1095                }
1096            }
1097            let mut inherit_targets = HashSet::new();
1098            for target in &role.can_inherit_from {
1099                if !memberships.contains(target) {
1100                    return Err(format!(
1101                        "role '{}' has INHERIT option for '{}' without membership",
1102                        id.name, target.name
1103                    ));
1104                }
1105                if !inherit_targets.insert(target) {
1106                    return Err(format!(
1107                        "role '{}' contains duplicate INHERIT target '{}'",
1108                        id.name, target.name
1109                    ));
1110                }
1111            }
1112        }
1113
1114        let mut membership_grantors = HashSet::new();
1115        for provenance in &self.role_membership_grantors {
1116            validate_id("role membership grantee", &provenance.member, false)?;
1117            validate_id("role membership role", &provenance.role, false)?;
1118            validate_id("role membership grantor", &provenance.grantor, false)?;
1119            for id in [&provenance.member, &provenance.role, &provenance.grantor] {
1120                if !id.schema.is_empty() {
1121                    return Err(format!(
1122                        "role membership provenance '{}' must use the cluster role namespace",
1123                        id
1124                    ));
1125                }
1126                if !self.roles.contains_key(id) {
1127                    return Err(format!(
1128                        "role membership provenance references missing role '{}'",
1129                        id
1130                    ));
1131                }
1132            }
1133            let Some(member) = self.roles.get(&provenance.member) else {
1134                unreachable!("validated role provenance member disappeared")
1135            };
1136            if !member.member_of.contains(&provenance.role) {
1137                return Err(format!(
1138                    "role membership provenance '{}' -> '{}' has no membership edge",
1139                    provenance.member, provenance.role
1140                ));
1141            }
1142            if !membership_grantors.insert((provenance.member.clone(), provenance.role.clone())) {
1143                return Err(format!(
1144                    "duplicate role membership provenance for '{}' -> '{}'",
1145                    provenance.member, provenance.role
1146                ));
1147            }
1148        }
1149        if self.role_membership_grantors_complete {
1150            for (member_id, role) in &self.roles {
1151                for role_id in &role.member_of {
1152                    if !membership_grantors.contains(&(member_id.clone(), role_id.clone())) {
1153                        return Err(format!(
1154                            "complete role membership provenance is missing for '{}' -> '{}'",
1155                            member_id, role_id
1156                        ));
1157                    }
1158                }
1159            }
1160        }
1161
1162        let mut constraint_ids = HashSet::new();
1163        for constraint in &self.constraints {
1164            validate_id("constraint table identity", &constraint.table_id, true)?;
1165            if constraint.name.is_empty() {
1166                return Err(format!(
1167                    "constraint on '{}' has an empty constraint name",
1168                    constraint.table_id
1169                ));
1170            }
1171            let Some(relation) = self.relations.get(&constraint.table_id) else {
1172                return Err(format!(
1173                    "constraint '{}.{}' references a missing relation",
1174                    constraint.table_id, constraint.name
1175                ));
1176            };
1177            if !matches!(relation.kind, RelationKind::Table) {
1178                return Err(format!(
1179                    "constraint '{}.{}' targets a non-table relation",
1180                    constraint.table_id, constraint.name
1181                ));
1182            }
1183            if !constraint_ids.insert((constraint.table_id.clone(), constraint.name.clone())) {
1184                return Err(format!(
1185                    "constraint '{}.{}' appears more than once",
1186                    constraint.table_id, constraint.name
1187                ));
1188            }
1189            if let Some(backing_index) = &constraint.backing_index {
1190                if !matches!(
1191                    constraint.kind,
1192                    ConstraintKind::PrimaryKey | ConstraintKind::Unique | ConstraintKind::Exclusion
1193                ) {
1194                    return Err(format!(
1195                        "constraint '{}.{}' has a backing index but is not a key or exclusion constraint",
1196                        constraint.table_id, constraint.name
1197                    ));
1198                }
1199                validate_id("constraint backing index identity", backing_index, true)?;
1200                let Some(index) = self.indexes.iter().find(|index| {
1201                    index.index_id == *backing_index && index.table_id == constraint.table_id
1202                }) else {
1203                    return Err(format!(
1204                        "constraint '{}.{}' references missing or unrelated backing index '{}'",
1205                        constraint.table_id, constraint.name, backing_index
1206                    ));
1207                };
1208                if !index.is_valid || !index.is_ready || !index.is_live {
1209                    return Err(format!(
1210                        "constraint '{}.{}' references an unusable backing index '{}'",
1211                        constraint.table_id, constraint.name, backing_index
1212                    ));
1213                }
1214                if matches!(
1215                    constraint.kind,
1216                    ConstraintKind::PrimaryKey | ConstraintKind::Unique
1217                ) && !index.is_unique
1218                {
1219                    return Err(format!(
1220                        "constraint '{}.{}' references non-unique backing index '{}'",
1221                        constraint.table_id, constraint.name, backing_index
1222                    ));
1223                }
1224            }
1225        }
1226
1227        let mut constraint_key_ids = HashSet::new();
1228        for key in &self.constraint_keys {
1229            validate_id("constraint key table identity", &key.table_id, true)?;
1230            if key.constraint_name.is_empty() {
1231                return Err(format!(
1232                    "constraint key on '{}' has an empty constraint name",
1233                    key.table_id
1234                ));
1235            }
1236            let Some(relation) = self.relations.get(&key.table_id) else {
1237                return Err(format!(
1238                    "constraint key '{}.{}' references a missing relation",
1239                    key.table_id, key.constraint_name
1240                ));
1241            };
1242            if !matches!(relation.kind, RelationKind::Table) {
1243                return Err(format!(
1244                    "constraint key '{}.{}' targets a non-table relation",
1245                    key.table_id, key.constraint_name
1246                ));
1247            }
1248            let valid_target_kind = self.constraints.iter().any(|constraint| {
1249                constraint.table_id == key.table_id
1250                    && constraint.name == key.constraint_name
1251                    && (constraint.kind == ConstraintKind::PrimaryKey
1252                        || constraint.kind == ConstraintKind::Unique
1253                        || constraint.kind == ConstraintKind::NotNull)
1254            });
1255            if !valid_target_kind {
1256                return Err(format!(
1257                    "constraint key '{}.{}' has no matching primary, unique, or not-null constraint",
1258                    key.table_id, key.constraint_name
1259                ));
1260            }
1261            if key.columns.is_empty() {
1262                return Err(format!(
1263                    "constraint key '{}.{}' has no column identities",
1264                    key.table_id, key.constraint_name
1265                ));
1266            }
1267            let mut key_columns = HashSet::new();
1268            if let Some(column) = key
1269                .columns
1270                .iter()
1271                .find(|column| !key_columns.insert(column.as_str()))
1272            {
1273                return Err(format!(
1274                    "constraint key '{}.{}' repeats column '{}'",
1275                    key.table_id, key.constraint_name, column
1276                ));
1277            }
1278            for column in &key.columns {
1279                if !relation.has_column(column) {
1280                    return Err(format!(
1281                        "constraint key '{}.{}' references missing column '{}.{}'",
1282                        key.table_id, key.constraint_name, key.table_id, column
1283                    ));
1284                }
1285            }
1286            if !constraint_key_ids.insert((key.table_id.clone(), key.constraint_name.clone())) {
1287                return Err(format!(
1288                    "constraint key '{}.{}' appears more than once",
1289                    key.table_id, key.constraint_name
1290                ));
1291            }
1292        }
1293
1294        let mut index_ids = HashSet::new();
1295        for index in &self.indexes {
1296            validate_id("index identity", &index.index_id, true)?;
1297            validate_id("index table identity", &index.table_id, true)?;
1298            if index.index_id.schema != index.table_id.schema {
1299                return Err(format!(
1300                    "index '{}' must be in the same schema as indexed relation '{}'",
1301                    index.index_id, index.table_id
1302                ));
1303            }
1304            if self.relations.contains_key(&index.index_id)
1305                || self.sequences.contains_key(&index.index_id)
1306            {
1307                return Err(format!(
1308                    "index '{}' collides with another relation-namespace object",
1309                    index.index_id
1310                ));
1311            }
1312            if index.using_method.is_empty() {
1313                return Err(format!(
1314                    "index '{}' has an empty access method",
1315                    index.index_id
1316                ));
1317            }
1318            let Some(relation) = self.relations.get(&index.table_id) else {
1319                return Err(format!(
1320                    "index '{}' references missing relation '{}'",
1321                    index.index_id, index.table_id
1322                ));
1323            };
1324            if !matches!(
1325                relation.kind,
1326                RelationKind::Table | RelationKind::MaterializedView
1327            ) {
1328                return Err(format!(
1329                    "index '{}' targets a non-indexable relation '{}'",
1330                    index.index_id, index.table_id
1331                ));
1332            }
1333            if !index_ids.insert(index.index_id.clone()) {
1334                return Err(format!("index '{}' appears more than once", index.index_id));
1335            }
1336            if !index.has_expression_keys && index.key_columns.is_empty() {
1337                return Err(format!(
1338                    "index '{}' has no key column identities",
1339                    index.index_id
1340                ));
1341            }
1342            if !index.dependency_columns_known {
1343                return Err(format!(
1344                    "index '{}' is missing complete dependency-column evidence",
1345                    index.index_id
1346                ));
1347            }
1348            for column in index
1349                .key_columns
1350                .iter()
1351                .chain(&index.included_columns)
1352                .chain(&index.dependency_columns)
1353            {
1354                if !relation.has_column(column) {
1355                    return Err(format!(
1356                        "index '{}' references missing column '{}.{}'",
1357                        index.index_id, index.table_id, column
1358                    ));
1359                }
1360            }
1361        }
1362
1363        let mut trigger_ids = HashSet::new();
1364        for trigger in &self.triggers {
1365            validate_id("trigger identity", &trigger.trigger_id, true)?;
1366            validate_id("trigger table identity", &trigger.table_id, true)?;
1367            validate_id("trigger function identity", &trigger.function_id, true)?;
1368            if trigger.trigger_id.schema != trigger.table_id.schema {
1369                return Err(format!(
1370                    "trigger '{}' must be in the same schema as trigger table '{}'",
1371                    trigger.trigger_id, trigger.table_id
1372                ));
1373            }
1374            let Some(relation) = self.relations.get(&trigger.table_id) else {
1375                return Err(format!(
1376                    "trigger '{}' references missing relation '{}'",
1377                    trigger.trigger_id, trigger.table_id
1378                ));
1379            };
1380            if !matches!(relation.kind, RelationKind::Table | RelationKind::View) {
1381                return Err(format!(
1382                    "trigger '{}' targets a relation kind that cannot have triggers",
1383                    trigger.trigger_id
1384                ));
1385            }
1386            if !trigger_ids.insert(trigger.trigger_id.clone()) {
1387                return Err(format!(
1388                    "trigger '{}' appears more than once",
1389                    trigger.trigger_id
1390                ));
1391            }
1392        }
1393
1394        let mut foreign_key_ids = HashSet::new();
1395        for foreign_key in &self.foreign_keys {
1396            validate_id("foreign-key source identity", &foreign_key.from_table, true)?;
1397            validate_id("foreign-key target identity", &foreign_key.to_table, true)?;
1398            if foreign_key.constraint_name.is_empty() {
1399                return Err(format!(
1400                    "foreign key on '{}' has an empty constraint name",
1401                    foreign_key.from_table
1402                ));
1403            }
1404            let Some(from_relation) = self.relations.get(&foreign_key.from_table) else {
1405                return Err(format!(
1406                    "foreign key '{}.{}' references a missing relation",
1407                    foreign_key.from_table, foreign_key.constraint_name
1408                ));
1409            };
1410            let Some(to_relation) = self.relations.get(&foreign_key.to_table) else {
1411                return Err(format!(
1412                    "foreign key '{}.{}' references a missing relation",
1413                    foreign_key.from_table, foreign_key.constraint_name
1414                ));
1415            };
1416            if !matches!(from_relation.kind, RelationKind::Table)
1417                || !matches!(to_relation.kind, RelationKind::Table)
1418            {
1419                return Err(format!(
1420                    "foreign key '{}.{}' must reference tables",
1421                    foreign_key.from_table, foreign_key.constraint_name
1422                ));
1423            }
1424            if foreign_key.from_columns.is_empty() || foreign_key.to_columns.is_empty() {
1425                return Err(format!(
1426                    "foreign key '{}.{}' is missing ordered column identities",
1427                    foreign_key.from_table, foreign_key.constraint_name
1428                ));
1429            }
1430            if foreign_key.from_columns.len() != foreign_key.to_columns.len() {
1431                return Err(format!(
1432                    "foreign key '{}.{}' has {} source columns but {} referenced columns",
1433                    foreign_key.from_table,
1434                    foreign_key.constraint_name,
1435                    foreign_key.from_columns.len(),
1436                    foreign_key.to_columns.len()
1437                ));
1438            }
1439            let column_count = foreign_key.from_columns.len();
1440            for (label, operators) in [
1441                ("PK/FK", &foreign_key.pk_fk_equality_operators),
1442                ("PK/PK", &foreign_key.pk_pk_equality_operators),
1443                ("FK/FK", &foreign_key.fk_fk_equality_operators),
1444            ] {
1445                if operators.len() != column_count
1446                    || operators.iter().any(|operator| operator.trim().is_empty())
1447                {
1448                    return Err(format!(
1449                        "foreign key '{}.{}' has incomplete {} equality-operator evidence",
1450                        foreign_key.from_table, foreign_key.constraint_name, label
1451                    ));
1452                }
1453            }
1454            let mut source_columns = HashSet::new();
1455            if let Some(column) = foreign_key
1456                .from_columns
1457                .iter()
1458                .find(|column| !source_columns.insert(column.as_str()))
1459            {
1460                return Err(format!(
1461                    "foreign key '{}.{}' repeats source column '{}'",
1462                    foreign_key.from_table, foreign_key.constraint_name, column
1463                ));
1464            }
1465            let mut target_columns = HashSet::new();
1466            if let Some(column) = foreign_key
1467                .to_columns
1468                .iter()
1469                .find(|column| !target_columns.insert(column.as_str()))
1470            {
1471                return Err(format!(
1472                    "foreign key '{}.{}' repeats referenced column '{}'",
1473                    foreign_key.from_table, foreign_key.constraint_name, column
1474                ));
1475            }
1476            for column in &foreign_key.from_columns {
1477                if !from_relation.has_column(column) {
1478                    return Err(format!(
1479                        "foreign key '{}.{}' references missing source column '{}.{}'",
1480                        foreign_key.from_table,
1481                        foreign_key.constraint_name,
1482                        foreign_key.from_table,
1483                        column
1484                    ));
1485                }
1486            }
1487            for column in &foreign_key.to_columns {
1488                if !to_relation.has_column(column) {
1489                    return Err(format!(
1490                        "foreign key '{}.{}' references missing target column '{}.{}'",
1491                        foreign_key.from_table,
1492                        foreign_key.constraint_name,
1493                        foreign_key.to_table,
1494                        column
1495                    ));
1496                }
1497            }
1498            if !foreign_key_ids.insert((
1499                foreign_key.from_table.clone(),
1500                foreign_key.constraint_name.clone(),
1501            )) {
1502                return Err(format!(
1503                    "foreign key '{}.{}' appears more than once",
1504                    foreign_key.from_table, foreign_key.constraint_name
1505                ));
1506            }
1507            if !self.constraints.iter().any(|constraint| {
1508                constraint.table_id == foreign_key.from_table
1509                    && constraint.name == foreign_key.constraint_name
1510                    && matches!(
1511                        constraint.kind,
1512                        crate::_internal::model::constraint::ConstraintKind::ForeignKey
1513                    )
1514            }) {
1515                return Err(format!(
1516                    "foreign key '{}.{}' has no matching constraint",
1517                    foreign_key.from_table, foreign_key.constraint_name
1518                ));
1519            }
1520        }
1521
1522        let mut inheritance_pairs = HashSet::new();
1523        for inheritance in &self.inheritances {
1524            validate_id("inheritance child identity", &inheritance.child, true)?;
1525            validate_id("inheritance parent identity", &inheritance.parent, true)?;
1526            if inheritance.child == inheritance.parent || inheritance.sequence < 1 {
1527                return Err(format!(
1528                    "inheritance '{} -> {}' has invalid direct relationship metadata",
1529                    inheritance.child, inheritance.parent
1530                ));
1531            }
1532            if inheritance.detach_pending {
1533                return Err(format!(
1534                    "inheritance '{} -> {}' is being detached; synchronize after the detach completes",
1535                    inheritance.child, inheritance.parent
1536                ));
1537            }
1538            let omitted_schema = |schema: &str| {
1539                self.metadata
1540                    .schemas
1541                    .as_ref()
1542                    .is_some_and(|schemas| !schemas.iter().any(|known| known == schema))
1543            };
1544            for id in [&inheritance.child, &inheritance.parent] {
1545                if let Some(relation) = self.relations.get(id) {
1546                    if !matches!(relation.kind, RelationKind::Table) {
1547                        return Err(format!(
1548                            "inheritance '{} -> {}' must reference tables",
1549                            inheritance.child, inheritance.parent
1550                        ));
1551                    }
1552                } else if !omitted_schema(&id.schema) {
1553                    return Err(format!(
1554                        "inheritance '{} -> {}' references a missing relation '{}'",
1555                        inheritance.child, inheritance.parent, id
1556                    ));
1557                }
1558            }
1559            if !inheritance_pairs.insert((
1560                inheritance.child.clone(),
1561                inheritance.parent.clone(),
1562                inheritance.sequence,
1563            )) {
1564                return Err(format!(
1565                    "inheritance '{} -> {}' appears more than once at sequence {}",
1566                    inheritance.child, inheritance.parent, inheritance.sequence
1567                ));
1568            }
1569        }
1570
1571        let mut constraint_dependency_ids = HashSet::new();
1572        for dependency in &self.constraint_dependencies {
1573            validate_id(
1574                "constraint dependency table identity",
1575                &dependency.table_id,
1576                true,
1577            )?;
1578            if dependency.constraint_name.is_empty() {
1579                return Err(format!(
1580                    "constraint dependency on '{}' has an empty constraint name",
1581                    dependency.table_id
1582                ));
1583            }
1584            if !constraint_dependency_ids.insert((
1585                dependency.table_id.clone(),
1586                dependency.constraint_name.clone(),
1587            )) {
1588                return Err(format!(
1589                    "constraint dependency '{}.{}' appears more than once",
1590                    dependency.table_id, dependency.constraint_name
1591                ));
1592            }
1593            let Some(relation) = self.relations.get(&dependency.table_id) else {
1594                return Err(format!(
1595                    "constraint dependency '{}' references missing relation '{}'",
1596                    dependency.constraint_name, dependency.table_id
1597                ));
1598            };
1599            if !self.constraints.iter().any(|constraint| {
1600                constraint.table_id == dependency.table_id
1601                    && constraint.name == dependency.constraint_name
1602                    && matches!(
1603                        constraint.kind,
1604                        crate::_internal::model::constraint::ConstraintKind::Check
1605                            | crate::_internal::model::constraint::ConstraintKind::Exclusion
1606                    )
1607            }) {
1608                return Err(format!(
1609                    "constraint dependency '{}.{}' has no matching CHECK or EXCLUDE constraint",
1610                    dependency.table_id, dependency.constraint_name
1611                ));
1612            }
1613            for column in &dependency.columns {
1614                if !relation.has_column(column) {
1615                    return Err(format!(
1616                        "constraint dependency '{}.{}' references missing column '{}.{}'",
1617                        dependency.table_id,
1618                        dependency.constraint_name,
1619                        dependency.table_id,
1620                        column
1621                    ));
1622                }
1623            }
1624        }
1625
1626        let mut generated_dependency_ids = HashSet::new();
1627        for dependency in &self.generated_column_dependencies {
1628            validate_id(
1629                "generated dependency table identity",
1630                &dependency.table_id,
1631                true,
1632            )?;
1633            if dependency.column_name.is_empty() || dependency.depends_on_column.is_empty() {
1634                return Err(format!(
1635                    "generated dependency on '{}' has an empty column identity",
1636                    dependency.table_id
1637                ));
1638            }
1639            if !generated_dependency_ids.insert((
1640                dependency.table_id.clone(),
1641                dependency.column_name.clone(),
1642                dependency.depends_on_column.clone(),
1643            )) {
1644                return Err(format!(
1645                    "generated column dependency '{}.{} -> {}' appears more than once",
1646                    dependency.table_id, dependency.column_name, dependency.depends_on_column
1647                ));
1648            }
1649            if dependency.column_name == dependency.depends_on_column {
1650                return Err(format!(
1651                    "generated column dependency '{}.{}' cannot depend on itself",
1652                    dependency.table_id, dependency.column_name
1653                ));
1654            }
1655            let Some(relation) = self.relations.get(&dependency.table_id) else {
1656                return Err(format!(
1657                    "generated column dependency references missing relation '{}'",
1658                    dependency.table_id
1659                ));
1660            };
1661            if !relation.has_column(&dependency.column_name) {
1662                return Err(format!(
1663                    "generated column dependency references missing generated column '{}.{}'",
1664                    dependency.table_id, dependency.column_name
1665                ));
1666            }
1667            if !relation.has_column(&dependency.depends_on_column) {
1668                return Err(format!(
1669                    "generated column dependency '{}.{}' references missing source column '{}.{}'",
1670                    dependency.table_id,
1671                    dependency.column_name,
1672                    dependency.table_id,
1673                    dependency.depends_on_column
1674                ));
1675            }
1676        }
1677
1678        let mut default_sequence_dependency_ids = HashSet::new();
1679        for dependency in &self.default_sequence_dependencies {
1680            validate_id(
1681                "default dependency table identity",
1682                &dependency.table_id,
1683                true,
1684            )?;
1685            validate_id(
1686                "default dependency sequence identity",
1687                &dependency.sequence_id,
1688                true,
1689            )?;
1690            if dependency.column_name.is_empty() {
1691                return Err(format!(
1692                    "default sequence dependency on '{}' has an empty column identity",
1693                    dependency.table_id
1694                ));
1695            }
1696            if !default_sequence_dependency_ids.insert((
1697                dependency.table_id.clone(),
1698                dependency.column_name.clone(),
1699                dependency.sequence_id.clone(),
1700            )) {
1701                return Err(format!(
1702                    "default sequence dependency '{}.{} -> {}' appears more than once",
1703                    dependency.table_id, dependency.column_name, dependency.sequence_id
1704                ));
1705            }
1706            let Some(relation) = self.relations.get(&dependency.table_id) else {
1707                return Err(format!(
1708                    "default sequence dependency references missing relation '{}'",
1709                    dependency.table_id
1710                ));
1711            };
1712            if !relation.has_column(&dependency.column_name) {
1713                return Err(format!(
1714                    "default sequence dependency '{}.{}' references missing column",
1715                    dependency.table_id, dependency.column_name
1716                ));
1717            }
1718            if !self.sequences.contains_key(&dependency.sequence_id)
1719                && !self.metadata.schemas.as_ref().is_some_and(|schemas| {
1720                    !schemas.iter().any(|s| s == &dependency.sequence_id.schema)
1721                })
1722            {
1723                return Err(format!(
1724                    "default sequence dependency references missing sequence '{}'",
1725                    dependency.sequence_id
1726                ));
1727            }
1728        }
1729
1730        let mut view_dependency_ids = HashSet::new();
1731        for dependency in &self.dependencies {
1732            validate_id("view dependent identity", &dependency.dependent, true)?;
1733            validate_id("view referenced identity", &dependency.referenced, true)?;
1734            if !view_dependency_ids.insert((
1735                dependency.dependent.clone(),
1736                dependency.referenced.clone(),
1737                dependency.referenced_column.clone(),
1738            )) {
1739                return Err(format!(
1740                    "view dependency '{} -> {}' appears more than once",
1741                    dependency.dependent, dependency.referenced
1742                ));
1743            }
1744            let omitted_schema = |schema: Option<&str>| {
1745                self.metadata
1746                    .schemas
1747                    .as_ref()
1748                    .zip(schema)
1749                    .is_some_and(|(schemas, schema)| !schemas.iter().any(|known| known == schema))
1750            };
1751            let object_missing = !self.relations.contains_key(&dependency.dependent);
1752            let referenced_missing = !self.relations.contains_key(&dependency.referenced);
1753            if (object_missing && !omitted_schema(Some(&dependency.dependent.schema)))
1754                || (referenced_missing && !omitted_schema(Some(&dependency.referenced.schema)))
1755            {
1756                return Err(format!(
1757                    "view dependency '{} -> {}' references a missing relation",
1758                    dependency.dependent, dependency.referenced
1759                ));
1760            }
1761            if let Some(relation) = self.relations.get(&dependency.dependent)
1762                && !matches!(
1763                    relation.kind,
1764                    RelationKind::View | RelationKind::MaterializedView
1765                )
1766            {
1767                return Err(format!(
1768                    "view dependency '{} -> {}' has a non-view dependent",
1769                    dependency.dependent, dependency.referenced
1770                ));
1771            }
1772            if let Some(column) = &dependency.referenced_column
1773                && column.is_empty()
1774            {
1775                return Err(format!(
1776                    "view dependency '{} -> {}' has an empty referenced column identity",
1777                    dependency.dependent, dependency.referenced
1778                ));
1779            }
1780            if let Some(column) = &dependency.referenced_column
1781                && let Some(relation) = self.relations.get(&dependency.referenced)
1782                && !relation.has_column(column)
1783            {
1784                return Err(format!(
1785                    "view dependency '{} -> {}' references missing column '{}.{}'",
1786                    dependency.dependent, dependency.referenced, dependency.referenced, column
1787                ));
1788            }
1789        }
1790
1791        Ok(())
1792    }
1793
1794    /// Return this cache only when all semantic invariants pass validation.
1795    /// This is the supported constructor for library callers that build a
1796    /// cache without going through the on-disk decoder.
1797    pub fn validated(self) -> Result<Self, String> {
1798        self.validate_semantics()?;
1799        Ok(self)
1800    }
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805    use super::*;
1806
1807    fn table(id: ObjectId, columns: &[&str]) -> RelationState {
1808        let mut relation = RelationState::new(
1809            id,
1810            ObjectId::new("", "postgres"),
1811            0,
1812            None,
1813            RelationKind::Table,
1814            crate::_internal::model::relation::Persistence::Permanent,
1815            0,
1816        );
1817        relation.columns = columns
1818            .iter()
1819            .map(|name| crate::_internal::model::column::Column {
1820                name: (*name).to_string(),
1821                data_type: Some("integer".to_string()),
1822                type_id: None,
1823                is_nullable: false,
1824                default: None,
1825                avg_width: Some(4),
1826                default_expr_text: None,
1827                type_modifier: None,
1828            })
1829            .collect();
1830        relation
1831    }
1832
1833    #[test]
1834    fn every_legacy_cache_variant_is_rejected_generically() {
1835        for (versioned, expected_version) in [
1836            (DbCacheVersioned::V1, 1),
1837            (DbCacheVersioned::V2, 2),
1838            (DbCacheVersioned::V3, 3),
1839            (DbCacheVersioned::V4, 4),
1840        ] {
1841            assert_eq!(versioned.format_version(), expected_version);
1842            assert_eq!(
1843                versioned.into_cache().unwrap_err(),
1844                "This cache format is unsupported. Run `safe-migrate sync` to rebuild it."
1845            );
1846        }
1847        let v5 = DbCacheVersioned::V5(Box::default());
1848        assert_eq!(v5.format_version(), 5);
1849        assert_eq!(
1850            v5.into_cache().unwrap_err(),
1851            "This cache format is unsupported. Run `safe-migrate sync` to rebuild it."
1852        );
1853        let v6 = DbCacheVersioned::V6(Box::default());
1854        assert_eq!(v6.format_version(), 6);
1855        assert_eq!(
1856            v6.into_cache().unwrap_err(),
1857            "This cache format is unsupported. Run `safe-migrate sync` to rebuild it."
1858        );
1859    }
1860
1861    #[test]
1862    fn current_cache_format_is_v7() {
1863        assert_eq!(CACHE_FORMAT_VERSION, 7);
1864        assert_eq!(DbCacheVersioned::V7(Box::default()).format_version(), 7);
1865        assert_eq!(CACHE_V7_MAGIC, b"SMCACHE07");
1866    }
1867
1868    #[test]
1869    fn current_cache_rejects_external_boundary_identity_outside_scope() {
1870        let mut cache = DbCache::new();
1871        let schemas = vec!["public".to_string()];
1872        cache.coverage = CatalogCoverage::from_sync_scope(Some(&schemas));
1873        cache.metadata.schemas = Some(schemas);
1874        cache
1875            .scoped_external_relation_dependencies
1876            .push(ObjectId::new("omitted", "table"));
1877
1878        let error = cache.validate_semantics().unwrap_err();
1879        assert!(error.contains("outside the cache schema scope"), "{error}");
1880    }
1881
1882    #[test]
1883    fn current_cache_rejects_mismatched_embedded_identity() {
1884        let mut cache = DbCache::new();
1885        cache.schemas.insert(
1886            "app".to_string(),
1887            SchemaState {
1888                name: "other".to_string(),
1889                owner: ObjectId::new("", "postgres"),
1890                generation: 0,
1891            },
1892        );
1893
1894        let error = DbCacheVersioned::V7(Box::new(cache))
1895            .into_cache()
1896            .unwrap_err();
1897        assert!(error.contains("schema cache key 'app'"));
1898    }
1899
1900    #[test]
1901    fn current_cache_rejects_mismatched_schema_coverage() {
1902        let mut cache = DbCache::new();
1903        cache.metadata.schemas = Some(vec!["app".to_string()]);
1904
1905        let error = DbCacheVersioned::V7(Box::new(cache))
1906            .into_cache()
1907            .unwrap_err();
1908        assert!(error.contains("schema coverage disagrees"));
1909    }
1910
1911    #[test]
1912    fn current_cache_rejects_invalid_foreign_key_column_identity() {
1913        let child = ObjectId::new("public", "child");
1914        let parent = ObjectId::new("public", "parent");
1915        let mut cache = DbCache::new();
1916        cache.insert_baseline(child.clone(), table(child.clone(), &["parent_id"]));
1917        cache.insert_baseline(parent.clone(), table(parent.clone(), &["id"]));
1918        cache.constraints.push(ConstraintState {
1919            table_id: child.clone(),
1920            name: "child_parent_id_fkey".to_string(),
1921            kind: crate::_internal::model::constraint::ConstraintKind::ForeignKey,
1922            validated: true,
1923            backing_index: None,
1924        });
1925        cache.foreign_keys.push(ForeignKeyCache {
1926            constraint_name: "child_parent_id_fkey".to_string(),
1927            from_table: child,
1928            to_table: parent,
1929            from_columns: vec!["missing".to_string()],
1930            to_columns: vec!["id".to_string()],
1931            pk_fk_equality_operators: vec!["=".to_string()],
1932            pk_pk_equality_operators: vec!["=".to_string()],
1933            fk_fk_equality_operators: vec!["=".to_string()],
1934        });
1935
1936        let error = cache.validate_semantics().unwrap_err();
1937        assert!(error.contains("missing source column 'public.child.missing'"));
1938    }
1939
1940    #[test]
1941    fn current_cache_rejects_repeated_foreign_key_columns() {
1942        let child = ObjectId::new("public", "child");
1943        let parent = ObjectId::new("public", "parent");
1944        let mut cache = DbCache::new();
1945        cache.insert_baseline(child.clone(), table(child.clone(), &["a", "b"]));
1946        cache.insert_baseline(parent.clone(), table(parent.clone(), &["id", "other"]));
1947        cache.constraints.push(ConstraintState {
1948            table_id: child.clone(),
1949            name: "child_parent_fkey".to_string(),
1950            kind: ConstraintKind::ForeignKey,
1951            validated: true,
1952            backing_index: None,
1953        });
1954        cache.foreign_keys.push(ForeignKeyCache {
1955            constraint_name: "child_parent_fkey".to_string(),
1956            from_table: child,
1957            to_table: parent,
1958            from_columns: vec!["a".to_string(), "a".to_string()],
1959            to_columns: vec!["id".to_string(), "other".to_string()],
1960            pk_fk_equality_operators: vec!["=".to_string(), "=".to_string()],
1961            pk_pk_equality_operators: vec!["=".to_string(), "=".to_string()],
1962            fk_fk_equality_operators: vec!["=".to_string(), "=".to_string()],
1963        });
1964
1965        let error = cache.validate_semantics().unwrap_err();
1966        assert!(error.contains("repeats source column 'a'"));
1967    }
1968
1969    #[test]
1970    fn current_cache_rejects_incomplete_foreign_key_operator_evidence() {
1971        let child = ObjectId::new("public", "child");
1972        let parent = ObjectId::new("public", "parent");
1973        let mut cache = DbCache::new();
1974        cache.insert_baseline(child.clone(), table(child.clone(), &["parent_id"]));
1975        cache.insert_baseline(parent.clone(), table(parent.clone(), &["id"]));
1976        cache.constraints.push(ConstraintState {
1977            table_id: child.clone(),
1978            name: "child_parent_fkey".into(),
1979            kind: ConstraintKind::ForeignKey,
1980            validated: true,
1981            backing_index: None,
1982        });
1983        cache.foreign_keys.push(ForeignKeyCache {
1984            constraint_name: "child_parent_fkey".into(),
1985            from_table: child,
1986            to_table: parent,
1987            from_columns: vec!["parent_id".into()],
1988            to_columns: vec!["id".into()],
1989            pk_fk_equality_operators: vec!["".into()],
1990            pk_pk_equality_operators: vec!["=".into()],
1991            fk_fk_equality_operators: vec!["=".into()],
1992        });
1993
1994        let error = cache.validate_semantics().unwrap_err();
1995        assert!(error.contains("incomplete PK/FK equality-operator evidence"));
1996    }
1997
1998    #[test]
1999    fn current_cache_accepts_a_valid_primary_key_record() {
2000        let parent = ObjectId::new("public", "parent");
2001        let mut cache = DbCache::new();
2002        cache.insert_baseline(parent.clone(), table(parent.clone(), &["id"]));
2003        cache.constraints.push(ConstraintState {
2004            table_id: parent.clone(),
2005            name: "parent_pkey".to_string(),
2006            kind: ConstraintKind::PrimaryKey,
2007            validated: true,
2008            backing_index: None,
2009        });
2010        cache.constraint_keys.push(ConstraintKeyCache {
2011            table_id: parent,
2012            constraint_name: "parent_pkey".to_string(),
2013            columns: vec!["id".to_string()],
2014            is_primary: true,
2015        });
2016
2017        assert!(cache.validate_semantics().is_ok());
2018    }
2019
2020    #[test]
2021    fn current_cache_accepts_non_unique_exclusion_backing_index() {
2022        let table_id = ObjectId::new("public", "ranges");
2023        let index_id = ObjectId::new("public", "ranges_excl");
2024        let mut cache = DbCache::new();
2025        cache.insert_baseline(table_id.clone(), table(table_id.clone(), &["id"]));
2026        cache.indexes.push(IndexCache {
2027            index_id: index_id.clone(),
2028            table_id: table_id.clone(),
2029            using_method: "gist".to_string(),
2030            key_columns: vec!["id".to_string()],
2031            included_columns: Vec::new(),
2032            dependency_columns: vec!["id".to_string()],
2033            dependency_columns_known: true,
2034            has_expression_keys: false,
2035            has_predicate: false,
2036            is_unique: false,
2037            is_valid: true,
2038            is_ready: true,
2039            is_live: true,
2040            has_default_sort_order: true,
2041            has_default_opclasses: true,
2042            has_default_collations: true,
2043        });
2044        cache.constraints.push(ConstraintState {
2045            table_id,
2046            name: "ranges_excl_constraint".to_string(),
2047            kind: ConstraintKind::Exclusion,
2048            validated: true,
2049            backing_index: Some(index_id),
2050        });
2051
2052        assert!(cache.validate_semantics().is_ok());
2053    }
2054
2055    #[test]
2056    fn current_cache_rejects_population_state_on_non_materialized_relation() {
2057        let table_id = ObjectId::new("public", "events");
2058        let mut relation = table(table_id.clone(), &[]);
2059        relation.is_populated = Some(true);
2060        let mut cache = DbCache::new();
2061        cache.insert_baseline(table_id, relation);
2062
2063        let error = cache.validate_semantics().unwrap_err();
2064        assert!(error.contains("materialized-view population state"));
2065    }
2066
2067    #[test]
2068    fn current_cache_rejects_inconsistent_role_membership_edges() {
2069        let member = ObjectId::new("", "member");
2070        let parent = ObjectId::new("", "parent");
2071        let mut cache = DbCache::new();
2072        cache.roles.insert(
2073            member.clone(),
2074            RoleState {
2075                id: member.clone(),
2076                can_login: true,
2077                is_superuser: false,
2078                inherits: true,
2079                member_of: Vec::new(),
2080                can_administer_membership: Vec::new(),
2081                can_inherit_from: Vec::new(),
2082                can_set_role_to: vec![parent.clone()],
2083            },
2084        );
2085        cache.roles.insert(
2086            parent.clone(),
2087            RoleState {
2088                id: parent,
2089                can_login: false,
2090                is_superuser: false,
2091                inherits: true,
2092                member_of: Vec::new(),
2093                can_administer_membership: Vec::new(),
2094                can_inherit_from: Vec::new(),
2095                can_set_role_to: Vec::new(),
2096            },
2097        );
2098
2099        let error = cache.validate_semantics().unwrap_err();
2100        assert!(error.contains("SET ROLE access") && error.contains("without membership"));
2101    }
2102
2103    #[test]
2104    fn current_cache_rejects_membership_options_without_membership() {
2105        let member = ObjectId::new("", "member");
2106        let parent = ObjectId::new("", "parent");
2107        let mut cache = DbCache::new();
2108        cache.roles.insert(
2109            member.clone(),
2110            RoleState {
2111                id: member,
2112                can_login: true,
2113                is_superuser: false,
2114                inherits: true,
2115                member_of: Vec::new(),
2116                can_administer_membership: vec![parent.clone()],
2117                can_inherit_from: Vec::new(),
2118                can_set_role_to: Vec::new(),
2119            },
2120        );
2121        cache.roles.insert(
2122            parent.clone(),
2123            RoleState {
2124                id: parent,
2125                can_login: false,
2126                is_superuser: false,
2127                inherits: true,
2128                member_of: Vec::new(),
2129                can_administer_membership: Vec::new(),
2130                can_inherit_from: Vec::new(),
2131                can_set_role_to: Vec::new(),
2132            },
2133        );
2134
2135        let error = cache.validate_semantics().unwrap_err();
2136        assert!(error.contains("ADMIN option") && error.contains("without membership"));
2137    }
2138
2139    #[test]
2140    fn current_cache_rejects_circular_role_membership() {
2141        let first = ObjectId::new("", "first");
2142        let second = ObjectId::new("", "second");
2143        let mut cache = DbCache::new();
2144        cache.roles.insert(
2145            first.clone(),
2146            RoleState {
2147                id: first.clone(),
2148                can_login: false,
2149                is_superuser: false,
2150                inherits: true,
2151                member_of: vec![second.clone()],
2152                can_administer_membership: Vec::new(),
2153                can_inherit_from: Vec::new(),
2154                can_set_role_to: vec![second.clone()],
2155            },
2156        );
2157        cache.roles.insert(
2158            second.clone(),
2159            RoleState {
2160                id: second,
2161                can_login: false,
2162                is_superuser: false,
2163                inherits: true,
2164                member_of: vec![first.clone()],
2165                can_administer_membership: Vec::new(),
2166                can_inherit_from: Vec::new(),
2167                can_set_role_to: vec![first],
2168            },
2169        );
2170
2171        let error = cache.validate_semantics().unwrap_err();
2172        assert!(error.contains("circular path"));
2173    }
2174
2175    #[test]
2176    fn current_cache_rejects_schema_qualified_role_identity() {
2177        let role = ObjectId::new("public", "app_role");
2178        let mut cache = DbCache::new();
2179        cache.roles.insert(
2180            role.clone(),
2181            RoleState {
2182                id: role,
2183                can_login: false,
2184                is_superuser: false,
2185                inherits: true,
2186                member_of: Vec::new(),
2187                can_administer_membership: Vec::new(),
2188                can_inherit_from: Vec::new(),
2189                can_set_role_to: Vec::new(),
2190            },
2191        );
2192
2193        let error = cache.validate_semantics().unwrap_err();
2194        assert!(error.contains("cluster role namespace"));
2195    }
2196
2197    #[test]
2198    fn current_cache_rejects_schema_qualified_role_membership_target() {
2199        let member = ObjectId::new("", "member");
2200        let parent = ObjectId::new("public", "parent");
2201        let mut cache = DbCache::new();
2202        cache.roles.insert(
2203            member.clone(),
2204            RoleState {
2205                id: member,
2206                can_login: false,
2207                is_superuser: false,
2208                inherits: true,
2209                member_of: vec![parent.clone()],
2210                can_administer_membership: Vec::new(),
2211                can_inherit_from: Vec::new(),
2212                can_set_role_to: vec![parent.clone()],
2213            },
2214        );
2215        cache.roles.insert(
2216            parent.clone(),
2217            RoleState {
2218                id: parent,
2219                can_login: false,
2220                is_superuser: false,
2221                inherits: true,
2222                member_of: Vec::new(),
2223                can_administer_membership: Vec::new(),
2224                can_inherit_from: Vec::new(),
2225                can_set_role_to: Vec::new(),
2226            },
2227        );
2228
2229        let error = cache.validate_semantics().unwrap_err();
2230        assert!(error.contains("cluster role namespace"));
2231    }
2232
2233    #[test]
2234    fn current_cache_rejects_ambiguous_relation_columns() {
2235        let table_id = ObjectId::new("public", "entries");
2236        let mut cache = DbCache::new();
2237        cache.insert_baseline(table_id.clone(), table(table_id, &["id", "id"]));
2238
2239        let error = cache.validate_semantics().unwrap_err();
2240        assert!(error.contains("duplicate column 'id'"));
2241    }
2242
2243    #[test]
2244    fn current_cache_rejects_empty_relation_column_identity() {
2245        let table_id = ObjectId::new("public", "entries");
2246        let mut cache = DbCache::new();
2247        cache.insert_baseline(table_id.clone(), table(table_id, &[""]));
2248
2249        let error = cache.validate_semantics().unwrap_err();
2250        assert!(error.contains("empty identity"));
2251    }
2252
2253    #[test]
2254    fn current_cache_rejects_noncanonical_object_identities() {
2255        let mut quoted_whitespace = DbCache::new();
2256        let id = ObjectId::new("public", " ");
2257        quoted_whitespace
2258            .relations
2259            .insert(id.clone(), table(id, &[" "]));
2260        assert!(quoted_whitespace.validate_semantics().is_ok());
2261
2262        let mut empty_name = DbCache::new();
2263        let id = ObjectId::new("public", "");
2264        empty_name.relations.insert(id.clone(), table(id, &[]));
2265        let error = empty_name.validate_semantics().unwrap_err();
2266        assert!(error.contains("empty object name"));
2267
2268        let mut inferred = DbCache::new();
2269        let mut id = ObjectId::new("public", "items");
2270        id.inferred_schema = true;
2271        inferred.relations.insert(id.clone(), table(id, &[]));
2272        let error = inferred.validate_semantics().unwrap_err();
2273        assert!(error.contains("inferred schema identity"));
2274    }
2275
2276    #[test]
2277    fn current_cache_rejects_ambiguous_schema_scope_and_search_path() {
2278        let mut duplicate_scope = DbCache::new();
2279        duplicate_scope.metadata.schemas = Some(vec!["app".into(), "app".into()]);
2280        let error = duplicate_scope.validate_semantics().unwrap_err();
2281        assert!(error.contains("duplicate schema 'app'"));
2282
2283        let mut duplicate_path = DbCache::new();
2284        duplicate_path.search_path = vec!["public".into(), "public".into()];
2285        let error = duplicate_path.validate_semantics().unwrap_err();
2286        assert!(error.contains("search path contains duplicate schema 'public'"));
2287    }
2288
2289    #[test]
2290    fn current_cache_accepts_explicit_scope_in_caller_order() {
2291        let scope = vec!["public".to_string(), "app".to_string()];
2292        let mut cache = DbCache::new();
2293        cache.metadata.schemas = Some(scope.clone());
2294        cache.coverage = CatalogCoverage::from_sync_scope(Some(&scope));
2295        assert!(cache.validate_semantics().is_ok());
2296    }
2297
2298    #[test]
2299    fn current_cache_rejects_owner_absent_from_role_catalog() {
2300        let table_id = ObjectId::new("public", "items");
2301        let mut cache = DbCache::new();
2302        cache.insert_baseline(table_id.clone(), table(table_id, &[]));
2303        let role_id = ObjectId::new("", "known_owner");
2304        cache.roles.insert(
2305            role_id.clone(),
2306            RoleState {
2307                id: role_id,
2308                can_login: false,
2309                is_superuser: false,
2310                inherits: true,
2311                member_of: Vec::new(),
2312                can_administer_membership: Vec::new(),
2313                can_inherit_from: Vec::new(),
2314                can_set_role_to: Vec::new(),
2315            },
2316        );
2317        let error = cache.validate_semantics().unwrap_err();
2318        assert!(error.contains("relation owner identity 'postgres'"));
2319    }
2320
2321    #[test]
2322    fn current_cache_rejects_empty_provenance_identities() {
2323        let mut cache = DbCache::new();
2324        cache.metadata.source_role = Some(String::new());
2325        let error = cache.validate_semantics().unwrap_err();
2326        assert!(error.contains("empty source role identity"));
2327
2328        let mut cache = DbCache::new();
2329        cache.metadata.source_search_path = Some(vec![String::new()]);
2330        let error = cache.validate_semantics().unwrap_err();
2331        assert!(error.contains("source search path contains an empty"));
2332    }
2333
2334    #[test]
2335    fn current_cache_rejects_malformed_publication_scope_metadata() {
2336        let table = crate::_internal::analysis::facts::PublicationObjectFact::Table {
2337            name: crate::_internal::ast::identifiers::QualifiedName::new(
2338                Some(crate::_internal::ast::identifiers::Ident::new(
2339                    "public", false,
2340                )),
2341                crate::_internal::ast::identifiers::Ident::new("items", false),
2342            ),
2343            only: false,
2344            include_partitions: false,
2345            columns: Some(vec!["id".into(), "id".into()]),
2346            row_filter: None,
2347        };
2348        let mut cache = DbCache::new();
2349        cache.publications.insert(
2350            "pub_items".into(),
2351            PublicationState {
2352                name: "pub_items".into(),
2353                owner: None,
2354                scope: crate::_internal::analysis::facts::PublicationScope::Explicit(vec![
2355                    table.clone(),
2356                    table,
2357                ]),
2358                params: Vec::new(),
2359                generation: 0,
2360            },
2361        );
2362
2363        let error = DbCacheVersioned::V7(Box::new(cache))
2364            .into_cache()
2365            .unwrap_err();
2366        assert!(error.contains("empty or duplicate table column"));
2367    }
2368
2369    #[test]
2370    fn current_cache_rejects_subscription_owner_outside_role_catalog() {
2371        let mut cache = DbCache::new();
2372        cache.roles.insert(
2373            ObjectId::new("", "present_owner"),
2374            RoleState {
2375                id: ObjectId::new("", "present_owner"),
2376                can_login: false,
2377                is_superuser: false,
2378                inherits: true,
2379                member_of: Vec::new(),
2380                can_administer_membership: Vec::new(),
2381                can_inherit_from: Vec::new(),
2382                can_set_role_to: Vec::new(),
2383            },
2384        );
2385        cache.subscriptions.insert(
2386            "sub".into(),
2387            SubscriptionState {
2388                name: "sub".into(),
2389                owner: Some("missing_owner".into()),
2390                connection: crate::_internal::analysis::facts::ConnectionTarget::Redacted,
2391                publications: vec!["pub".into()],
2392                params: None,
2393                enabled: true,
2394                slot_name: None,
2395                generation: 0,
2396            },
2397        );
2398
2399        let error = DbCacheVersioned::V7(Box::new(cache))
2400            .into_cache()
2401            .unwrap_err();
2402        assert!(error.contains("owner 'missing_owner' is absent"));
2403    }
2404
2405    #[test]
2406    fn current_cache_rejects_duplicate_view_dependencies() {
2407        let view = ObjectId::new("public", "active_entries");
2408        let table_id = ObjectId::new("public", "entries");
2409        let mut cache = DbCache::new();
2410        let mut view_state = table(view.clone(), &["id"]);
2411        view_state.kind = RelationKind::View;
2412        cache.insert_baseline(view.clone(), view_state);
2413        cache.insert_baseline(table_id.clone(), table(table_id.clone(), &["id"]));
2414        let dependency = ViewDependencyCache {
2415            dependent: view,
2416            referenced: table_id,
2417            referenced_column: Some("id".into()),
2418        };
2419        cache.dependencies = vec![dependency.clone(), dependency];
2420
2421        let error = cache.validate_semantics().unwrap_err();
2422        assert!(error.contains("appears more than once"));
2423    }
2424
2425    #[test]
2426    fn current_cache_rejects_empty_view_dependency_column() {
2427        let view = ObjectId::new("public", "active_entries");
2428        let table_id = ObjectId::new("public", "entries");
2429        let mut cache = DbCache::new();
2430        let mut view_state = table(view.clone(), &["id"]);
2431        view_state.kind = RelationKind::View;
2432        cache.insert_baseline(view.clone(), view_state);
2433        cache.insert_baseline(table_id.clone(), table(table_id.clone(), &["id"]));
2434        cache.dependencies.push(ViewDependencyCache {
2435            dependent: view,
2436            referenced: table_id,
2437            referenced_column: Some(String::new()),
2438        });
2439
2440        let error = cache.validate_semantics().unwrap_err();
2441        assert!(error.contains("empty referenced column identity"));
2442    }
2443
2444    #[test]
2445    fn current_cache_rejects_privilege_for_missing_role() {
2446        let table_id = ObjectId::new("public", "entries");
2447        let mut cache = DbCache::new();
2448        let mut relation = table(table_id.clone(), &["id"]);
2449        relation.privileges.grants.insert(
2450            ObjectId::new("", "missing_role"),
2451            [crate::_internal::model::relation::Privilege::Select]
2452                .into_iter()
2453                .collect(),
2454        );
2455        cache.insert_baseline(table_id, relation);
2456
2457        let error = cache.validate_semantics().unwrap_err();
2458        assert!(error.contains("missing grantee role"));
2459    }
2460
2461    #[test]
2462    fn current_cache_allows_public_privilege_grantee() {
2463        let table_id = ObjectId::new("public", "entries");
2464        let mut cache = DbCache::new();
2465        let mut relation = table(table_id.clone(), &["id"]);
2466        relation.privileges.grants.insert(
2467            ObjectId::new("", "public"),
2468            [crate::_internal::model::relation::Privilege::Select]
2469                .into_iter()
2470                .collect(),
2471        );
2472        cache.insert_baseline(table_id, relation);
2473
2474        assert!(cache.validate_semantics().is_ok());
2475    }
2476
2477    #[test]
2478    fn current_cache_rejects_repeated_constraint_key_columns() {
2479        let table_id = ObjectId::new("public", "entries");
2480        let mut cache = DbCache::new();
2481        cache.insert_baseline(
2482            table_id.clone(),
2483            table(table_id.clone(), &["id", "tenant_id"]),
2484        );
2485        cache.constraints.push(ConstraintState {
2486            table_id: table_id.clone(),
2487            name: "entries_key".to_string(),
2488            kind: ConstraintKind::Unique,
2489            validated: true,
2490            backing_index: None,
2491        });
2492        cache.constraint_keys.push(ConstraintKeyCache {
2493            table_id,
2494            constraint_name: "entries_key".to_string(),
2495            columns: vec!["id".to_string(), "id".to_string()],
2496            is_primary: false,
2497        });
2498
2499        let error = cache.validate_semantics().unwrap_err();
2500        assert!(error.contains("constraint key 'public.entries.entries_key' repeats column 'id'"));
2501    }
2502
2503    #[test]
2504    fn scoped_cache_accepts_a_dependency_to_an_omitted_schema() {
2505        let view_id = ObjectId::new("app", "v");
2506        let mut cache = DbCache::new();
2507        cache.metadata.schemas = Some(vec!["app".to_string()]);
2508        cache.coverage = CatalogCoverage::from_sync_scope(cache.metadata.schemas.as_deref());
2509        cache.insert_baseline(
2510            view_id.clone(),
2511            RelationState::new(
2512                view_id.clone(),
2513                ObjectId::new("", "postgres"),
2514                0,
2515                None,
2516                crate::_internal::model::relation::RelationKind::View,
2517                crate::_internal::model::relation::Persistence::Permanent,
2518                0,
2519            ),
2520        );
2521        cache.dependencies.push(ViewDependencyCache {
2522            dependent: view_id,
2523            referenced: ObjectId::new("tenant", "base"),
2524            referenced_column: None,
2525        });
2526
2527        assert!(cache.validate_semantics().is_ok());
2528    }
2529
2530    #[test]
2531    fn current_cache_rejects_view_dependency_on_a_missing_column() {
2532        let table_id = ObjectId::new("public", "entries");
2533        let view_id = ObjectId::new("public", "entry_view");
2534        let mut cache = DbCache::new();
2535        cache.insert_baseline(table_id.clone(), table(table_id.clone(), &["id"]));
2536        cache.insert_baseline(
2537            view_id.clone(),
2538            RelationState::new(
2539                view_id.clone(),
2540                ObjectId::new("", "postgres"),
2541                0,
2542                None,
2543                RelationKind::View,
2544                crate::_internal::model::relation::Persistence::Permanent,
2545                0,
2546            ),
2547        );
2548        cache.dependencies.push(ViewDependencyCache {
2549            dependent: view_id,
2550            referenced: table_id,
2551            referenced_column: Some("missing".to_string()),
2552        });
2553
2554        assert!(
2555            cache
2556                .validate_semantics()
2557                .unwrap_err()
2558                .contains("references missing column")
2559        );
2560    }
2561
2562    #[test]
2563    fn current_cache_rejects_view_dependency_with_a_non_view_dependent() {
2564        let table_id = ObjectId::new("public", "entries");
2565        let referenced_id = ObjectId::new("public", "source");
2566        let mut cache = DbCache::new();
2567        cache.insert_baseline(table_id.clone(), table(table_id.clone(), &["id"]));
2568        cache.insert_baseline(referenced_id.clone(), table(referenced_id.clone(), &["id"]));
2569        cache.dependencies.push(ViewDependencyCache {
2570            dependent: table_id,
2571            referenced: referenced_id,
2572            referenced_column: Some("id".to_string()),
2573        });
2574
2575        assert!(
2576            cache
2577                .validate_semantics()
2578                .unwrap_err()
2579                .contains("has a non-view dependent")
2580        );
2581    }
2582
2583    #[test]
2584    fn current_cache_rejects_constraint_dependency_on_a_missing_column() {
2585        let table_id = ObjectId::new("public", "entries");
2586        let mut cache = DbCache::new();
2587        cache.insert_baseline(table_id.clone(), table(table_id.clone(), &["id"]));
2588        cache
2589            .constraints
2590            .push(crate::_internal::model::constraint::ConstraintState {
2591                table_id: table_id.clone(),
2592                name: "entries_check".to_string(),
2593                kind: crate::_internal::model::constraint::ConstraintKind::Check,
2594                validated: true,
2595                backing_index: None,
2596            });
2597        cache
2598            .constraint_dependencies
2599            .push(ConstraintDependencyCache {
2600                table_id,
2601                constraint_name: "entries_check".to_string(),
2602                columns: vec!["missing".to_string()],
2603            });
2604
2605        assert!(
2606            cache
2607                .validate_semantics()
2608                .unwrap_err()
2609                .contains("constraint dependency")
2610        );
2611    }
2612
2613    #[test]
2614    fn current_cache_rejects_generated_dependency_on_a_missing_source_column() {
2615        let table_id = ObjectId::new("public", "entries");
2616        let mut cache = DbCache::new();
2617        cache.insert_baseline(table_id.clone(), table(table_id.clone(), &["id", "total"]));
2618        cache
2619            .generated_column_dependencies
2620            .push(GeneratedColumnDependencyCache {
2621                table_id,
2622                column_name: "total".to_string(),
2623                depends_on_column: "missing".to_string(),
2624            });
2625
2626        assert!(
2627            cache
2628                .validate_semantics()
2629                .unwrap_err()
2630                .contains("generated column dependency")
2631        );
2632    }
2633
2634    #[test]
2635    fn current_cache_rejects_self_referencing_generated_dependency() {
2636        let table_id = ObjectId::new("public", "entries");
2637        let mut cache = DbCache::new();
2638        cache.insert_baseline(table_id.clone(), table(table_id.clone(), &["total"]));
2639        cache
2640            .generated_column_dependencies
2641            .push(GeneratedColumnDependencyCache {
2642                table_id,
2643                column_name: "total".to_string(),
2644                depends_on_column: "total".to_string(),
2645            });
2646
2647        let error = cache.validate_semantics().unwrap_err();
2648        assert!(error.contains("cannot depend on itself"));
2649    }
2650
2651    #[test]
2652    fn current_cache_rejects_duplicate_generated_dependency_rows() {
2653        let table_id = ObjectId::new("public", "entries");
2654        let mut cache = DbCache::new();
2655        cache.insert_baseline(table_id.clone(), table(table_id.clone(), &["id", "total"]));
2656        let dependency = GeneratedColumnDependencyCache {
2657            table_id,
2658            column_name: "total".to_string(),
2659            depends_on_column: "id".to_string(),
2660        };
2661        cache.generated_column_dependencies.push(dependency.clone());
2662        cache.generated_column_dependencies.push(dependency);
2663
2664        assert!(cache.validate_semantics().unwrap_err().contains(
2665            "generated column dependency 'public.entries.total -> id' appears more than once"
2666        ));
2667    }
2668    #[test]
2669    fn current_cache_rejects_dangling_index_relationship() {
2670        let mut cache = DbCache::new();
2671        cache.indexes.push(IndexCache {
2672            index_id: ObjectId::new("public", "items_idx"),
2673            table_id: ObjectId::new("public", "items"),
2674            using_method: "btree".into(),
2675            key_columns: vec!["id".into()],
2676            included_columns: Vec::new(),
2677            dependency_columns: vec!["id".into()],
2678            dependency_columns_known: true,
2679            has_expression_keys: false,
2680            has_predicate: false,
2681            is_unique: false,
2682            is_valid: true,
2683            is_ready: true,
2684            is_live: true,
2685            has_default_sort_order: true,
2686            has_default_opclasses: true,
2687            has_default_collations: true,
2688        });
2689
2690        let error = DbCacheVersioned::V7(Box::new(cache))
2691            .into_cache()
2692            .unwrap_err();
2693        assert!(error.contains("references missing relation 'public.items'"));
2694    }
2695
2696    #[test]
2697    fn current_cache_rejects_index_in_a_different_schema_than_relation() {
2698        let table_id = ObjectId::new("public", "items");
2699        let mut cache = DbCache::new();
2700        cache.insert_baseline(table_id.clone(), table(table_id.clone(), &["id"]));
2701        cache.indexes.push(IndexCache {
2702            index_id: ObjectId::new("other", "items_idx"),
2703            table_id,
2704            using_method: "btree".into(),
2705            key_columns: vec!["id".into()],
2706            included_columns: Vec::new(),
2707            dependency_columns: vec!["id".into()],
2708            dependency_columns_known: true,
2709            has_expression_keys: false,
2710            has_predicate: false,
2711            is_unique: false,
2712            is_valid: true,
2713            is_ready: true,
2714            is_live: true,
2715            has_default_sort_order: true,
2716            has_default_opclasses: true,
2717            has_default_collations: true,
2718        });
2719
2720        let error = DbCacheVersioned::V7(Box::new(cache))
2721            .into_cache()
2722            .unwrap_err();
2723        assert!(error.contains("must be in the same schema as indexed relation"));
2724    }
2725
2726    #[test]
2727    fn current_cache_rejects_index_colliding_with_relation_namespace_object() {
2728        let table_id = ObjectId::new("public", "items");
2729        let index_id = ObjectId::new("public", "shared_name");
2730        let mut cache = DbCache::new();
2731        cache.insert_baseline(table_id.clone(), table(table_id, &["id"]));
2732        cache.insert_baseline(index_id.clone(), table(index_id.clone(), &["id"]));
2733        cache.indexes.push(IndexCache {
2734            index_id,
2735            table_id: ObjectId::new("public", "items"),
2736            using_method: "btree".into(),
2737            key_columns: vec!["id".into()],
2738            included_columns: Vec::new(),
2739            dependency_columns: vec!["id".into()],
2740            dependency_columns_known: true,
2741            has_expression_keys: false,
2742            has_predicate: false,
2743            is_unique: false,
2744            is_valid: true,
2745            is_ready: true,
2746            is_live: true,
2747            has_default_sort_order: true,
2748            has_default_opclasses: true,
2749            has_default_collations: true,
2750        });
2751
2752        let error = DbCacheVersioned::V7(Box::new(cache))
2753            .into_cache()
2754            .unwrap_err();
2755        assert!(error.contains("collides with another relation-namespace object"));
2756    }
2757
2758    #[test]
2759    fn current_cache_rejects_sequence_colliding_with_relation_namespace_object() {
2760        let table_id = ObjectId::new("public", "items");
2761        let shared_id = ObjectId::new("public", "shared_name");
2762        let mut cache = DbCache::new();
2763        cache.insert_baseline(table_id.clone(), table(table_id, &["id"]));
2764        cache.insert_baseline(shared_id.clone(), table(shared_id.clone(), &["id"]));
2765        cache.sequences.insert(
2766            shared_id.clone(),
2767            SequenceState {
2768                id: shared_id,
2769                owner: ObjectId::new("", "postgres"),
2770                owned_by: None,
2771                kind: crate::_internal::model::sequence::SequenceKind::Owned,
2772                generation: 0,
2773            },
2774        );
2775
2776        let error = DbCacheVersioned::V7(Box::new(cache))
2777            .into_cache()
2778            .unwrap_err();
2779        assert!(error.contains("collides with another relation-namespace object"));
2780    }
2781
2782    #[test]
2783    fn current_cache_rejects_trigger_in_a_different_schema_than_table() {
2784        let table_id = ObjectId::new("public", "items");
2785        let mut cache = DbCache::new();
2786        cache.insert_baseline(table_id.clone(), table(table_id.clone(), &["id"]));
2787        cache.triggers.push(TriggerCache {
2788            trigger_id: ObjectId::new("other", "items_trigger"),
2789            table_id,
2790            function_id: ObjectId::new("public", "items_trigger_fn()"),
2791            enabled_mode: TriggerEnableMode::Origin,
2792        });
2793
2794        let error = DbCacheVersioned::V7(Box::new(cache))
2795            .into_cache()
2796            .unwrap_err();
2797        assert!(error.contains("must be in the same schema as trigger table"));
2798    }
2799
2800    #[test]
2801    fn current_cache_rejects_index_without_complete_dependency_evidence() {
2802        let table_id = ObjectId::new("public", "items");
2803        let mut cache = DbCache::new();
2804        cache.insert_baseline(table_id.clone(), table(table_id.clone(), &["id"]));
2805        cache.indexes.push(IndexCache {
2806            index_id: ObjectId::new("public", "items_idx"),
2807            table_id,
2808            using_method: "btree".into(),
2809            key_columns: vec!["id".into()],
2810            included_columns: Vec::new(),
2811            dependency_columns: vec!["id".into()],
2812            dependency_columns_known: false,
2813            has_expression_keys: false,
2814            has_predicate: false,
2815            is_unique: false,
2816            is_valid: true,
2817            is_ready: true,
2818            is_live: true,
2819            has_default_sort_order: true,
2820            has_default_opclasses: true,
2821            has_default_collations: true,
2822        });
2823
2824        assert!(
2825            DbCacheVersioned::V7(Box::new(cache))
2826                .into_cache()
2827                .unwrap_err()
2828                .contains("missing complete dependency-column evidence")
2829        );
2830    }
2831
2832    #[test]
2833    fn current_cache_validates_stable_direct_inheritance() {
2834        let parent = ObjectId::new("public", "parent");
2835        let child = ObjectId::new("public", "child");
2836        let mut cache = DbCache::new();
2837        cache.search_path.clear();
2838        cache.insert_baseline(parent.clone(), table(parent.clone(), &["id"]));
2839        cache.insert_baseline(child.clone(), table(child.clone(), &["id"]));
2840        cache.inheritances.push(InheritanceCache {
2841            child: child.clone(),
2842            parent: parent.clone(),
2843            sequence: 1,
2844            is_partition: false,
2845            detach_pending: false,
2846        });
2847        assert!(
2848            DbCacheVersioned::V7(Box::new(cache.clone()))
2849                .into_cache()
2850                .is_ok()
2851        );
2852
2853        cache.inheritances[0].detach_pending = true;
2854        let error = DbCacheVersioned::V7(Box::new(cache))
2855            .into_cache()
2856            .unwrap_err();
2857        assert!(error.contains("being detached"));
2858    }
2859
2860    #[test]
2861    fn current_cache_rejects_cross_catalog_contradictions() {
2862        let mut missing_search_schema = DbCache::new();
2863        missing_search_schema.schemas.insert(
2864            "app".to_string(),
2865            SchemaState {
2866                name: "app".to_string(),
2867                owner: ObjectId::new("", "postgres"),
2868                generation: 0,
2869            },
2870        );
2871        assert!(
2872            missing_search_schema
2873                .validate_semantics()
2874                .unwrap_err()
2875                .contains("search path references missing schema 'public'")
2876        );
2877
2878        let mut missing_sequence_owner = DbCache::new();
2879        let sequence_id = ObjectId::new("public", "items_id_seq");
2880        missing_sequence_owner.sequences.insert(
2881            sequence_id.clone(),
2882            SequenceState {
2883                id: sequence_id,
2884                owner: ObjectId::new("", "postgres"),
2885                owned_by: Some((ObjectId::new("public", "items"), "id".to_string())),
2886                kind: crate::_internal::model::sequence::SequenceKind::Owned,
2887                generation: 0,
2888            },
2889        );
2890        assert!(
2891            missing_sequence_owner
2892                .validate_semantics()
2893                .unwrap_err()
2894                .contains("ownership references missing relation 'public.items'")
2895        );
2896
2897        let mut cross_schema_sequence_owner = DbCache::new();
2898        let table_id = ObjectId::new("public", "items");
2899        cross_schema_sequence_owner.insert_baseline(table_id.clone(), table(table_id, &["id"]));
2900        cross_schema_sequence_owner.sequences.insert(
2901            ObjectId::new("archive", "items_id_seq"),
2902            SequenceState {
2903                id: ObjectId::new("archive", "items_id_seq"),
2904                owner: ObjectId::new("", "postgres"),
2905                owned_by: Some((ObjectId::new("public", "items"), "id".to_string())),
2906                kind: crate::_internal::model::sequence::SequenceKind::Owned,
2907                generation: 0,
2908            },
2909        );
2910        assert!(
2911            cross_schema_sequence_owner
2912                .validate_semantics()
2913                .unwrap_err()
2914                .contains("must be in the same schema as owning table")
2915        );
2916
2917        let mut missing_membership_role = DbCache::new();
2918        let role_id = ObjectId::new("", "member");
2919        missing_membership_role.roles.insert(
2920            role_id.clone(),
2921            RoleState {
2922                id: role_id,
2923                can_login: true,
2924                is_superuser: false,
2925                inherits: true,
2926                member_of: vec![ObjectId::new("", "missing")],
2927                can_administer_membership: Vec::new(),
2928                can_inherit_from: Vec::new(),
2929                can_set_role_to: Vec::new(),
2930            },
2931        );
2932        assert!(
2933            missing_membership_role
2934                .validate_semantics()
2935                .unwrap_err()
2936                .contains("membership references missing role")
2937        );
2938
2939        let mut invalid_view_dependency = DbCache::new();
2940        invalid_view_dependency
2941            .dependencies
2942            .push(ViewDependencyCache {
2943                dependent: ObjectId::new("public", "view"),
2944                referenced: ObjectId::new("public", "missing"),
2945                referenced_column: None,
2946            });
2947        assert!(
2948            invalid_view_dependency
2949                .validate_semantics()
2950                .unwrap_err()
2951                .contains("references a missing relation")
2952        );
2953    }
2954}